From 946d86271e201fcd7708a18ffd7857778255c762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Wed, 18 Feb 2026 18:07:53 +0100 Subject: [PATCH 1/4] 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 --- achievements.plan.md | 240 +++++++++++++++++++++++++ src/players/views/html/player.page.tsx | 120 ++++++++++++- src/players/views/html/style.css | 186 +++++++++++++++++++ 3 files changed, 543 insertions(+), 3 deletions(-) create mode 100644 achievements.plan.md diff --git a/achievements.plan.md b/achievements.plan.md new file mode 100644 index 000000000..5a9926db0 --- /dev/null +++ b/achievements.plan.md @@ -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/`. 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 `` 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 diff --git a/src/players/views/html/player.page.tsx b/src/players/views/html/player.page.tsx index d4a1d2f9f..746f7561a 100644 --- a/src/players/views/html/player.page.tsx +++ b/src/players/views/html/player.page.tsx @@ -8,6 +8,7 @@ import { queue } from '../../../queue' import { GameClassIcon } from '../../../html/components/game-class-icon' import { IconAlignBoxBottomRight, + IconAwardFilled, IconBrandSteam, IconBrandTwitch, IconClover, @@ -66,8 +67,14 @@ export async function PlayerPage(props: { steamId: SteamId64; page: number }) { {user?.player.roles.includes(PlayerRole.admin) && } -
- +
+
+ +
+ +
+ +
@@ -108,7 +115,7 @@ export async function PlayerGameList(props: { steamId: SteamId64; page: number }
Game history
-
+
{games.map(game => ( ) } + +// ── 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 = { + 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 ( +
+
+ +
+
{a.name}
+
+ {a.tier} +
+ {!a.unlocked && a.progress && ( +
+
+
+ )} +
+
{a.description}
+
+ {a.unlocked ? `Unlocked ${a.unlockedAt}` : `${String(a.progress?.current ?? 0)} / ${String(a.progress?.target ?? '?')}`} +
+
+
+ ) +} + +function PlayerAchievements() { + const unlocked = mockAchievements.filter(a => a.unlocked) + const locked = mockAchievements.filter(a => !a.unlocked) + + return ( + <> +
+ Achievements + + {unlocked.length}/{mockAchievements.length} + +
+
+
+ {unlocked.map(a => ( + + ))} +
+
+ {locked.length > 0 && ( +
+ + {locked.length} locked achievement{locked.length !== 1 ? 's' : ''} + +
+ {locked.map(a => ( + + ))} +
+
+ )} + + ) +} diff --git a/src/players/views/html/style.css b/src/players/views/html/style.css index 6551774bd..9a027983f 100644 --- a/src/players/views/html/style.css +++ b/src/players/views/html/style.css @@ -286,3 +286,189 @@ } } } + +/* ── Two-column layout ── */ + +.player-content-columns { + display: flex; + flex-direction: column; + gap: 30px; + + @media (width >= theme(--breakpoint-lg)) { + display: grid; + grid-template-columns: 320px 1fr; + gap: 24px; + align-items: start; + } + + @media (width >= theme(--breakpoint-xl)) { + grid-template-columns: 380px 1fr; + } +} + +.player-content-left { + display: flex; + flex-direction: column; + gap: 16px; +} + +.player-content-right { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +/* ── Achievements ── */ + +.achievements-scroll { + @apply fade-scroll; + max-height: 520px; + overflow-y: auto; + --scrollbar-width: 8px; +} + +.achievements-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; + + @media (width >= theme(--breakpoint-lg)) { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } +} + +.achievement-badge { + @apply transition-colors; + @apply duration-75; + + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px 12px; + border-radius: 8px; + background: var(--color-abru-dark-29); + border: 1px solid transparent; + cursor: default; + + &:hover { + background: var(--color-abru-light-5); + } + + &.tier-bronze { border-color: color-mix(in srgb, #cd7f32 30%, transparent); } + &.tier-silver { border-color: color-mix(in srgb, #bbbbbb 25%, transparent); } + &.tier-gold { border-color: color-mix(in srgb, #e3c392 35%, transparent); } + &.tier-australium { border-color: color-mix(in srgb, #e3b63a 40%, transparent); } + + &.locked { + opacity: 0.35; + border-color: transparent; + + &:hover { opacity: 0.5; } + + .achievement-name { color: var(--color-abru-light-50); } + .achievement-tier { color: var(--color-abru-light-35); } + } +} + +.achievement-icon { + width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + .tier-bronze & { background: rgba(205, 127, 50, 0.10); color: #cd7f32; } + .tier-silver & { background: rgba(187, 187, 187, 0.10); color: #bbbbbb; } + .tier-gold & { background: rgba(227, 195, 146, 0.10); color: #e3c392; } + .tier-australium & { background: rgba(227, 182, 58, 0.12); color: #e3b63a; } + .locked & { background: var(--color-abru-light-5); color: var(--color-abru-light-35); } +} + +.achievement-name { + font-size: 13px; + font-weight: 600; + text-align: center; + line-height: 1.3; + color: var(--color-abru-light-85); +} + +.achievement-tier { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.achievement-progress { + width: 100%; + height: 3px; + background: var(--color-abru-light-10); + border-radius: 2px; + overflow: hidden; + margin-top: -2px; +} + +.achievement-progress-bar { + height: 100%; + border-radius: 2px; +} + +.achievements-locked-group { + margin-top: 4px; +} + +.achievements-locked-toggle { + @apply transition-colors; + @apply duration-75; + + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + background: var(--color-abru-dark-29); + color: var(--color-abru-light-50); + font-size: 13px; + font-weight: 600; + cursor: pointer; + list-style: none; + user-select: none; + + &:hover { + background: var(--color-abru-light-5); + color: var(--color-abru-light-70); + } + + &::before { + content: '▸'; + font-size: 11px; + transition: transform 0.15s; + } + + [open] > &::before { + transform: rotate(90deg); + } + + &::-webkit-details-marker { + display: none; + } +} + +.tooltip-desc { + font-size: 13px; + font-weight: 400; + color: var(--color-abru-light-70); + margin-bottom: 4px; +} + +.tooltip-date { + font-size: 11px; + color: var(--color-abru-light-50); +} From de352dd04d937043e0b40eaa22fc80aaee1e014f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Sun, 3 May 2026 19:42:14 +0200 Subject: [PATCH 2/4] ai-generated icons --- scripts/generate-achievement-icons.ts | 354 ++ .../views/html/icons/australium-legend.tsx | 425 ++ .../views/html/icons/australium-rl.tsx | 309 ++ .../views/html/icons/f2p-no-more.tsx | 459 ++ .../views/html/icons/first-blood.tsx | 4370 +++++++++++++++++ .../views/html/icons/grasshopper.tsx | 333 ++ .../views/html/icons/grizzled-veteran.tsx | 385 ++ .../views/html/icons/iron-mann.tsx | 734 +++ src/achievements/views/html/icons/kabooom.tsx | 369 ++ src/achievements/views/html/icons/maggots.tsx | 929 ++++ .../views/html/icons/mann-co-reserve.tsx | 769 +++ .../views/html/icons/mann-of-steel.tsx | 437 ++ .../views/html/icons/mannpower-medic.tsx | 790 +++ .../views/html/icons/mercenary.tsx | 510 ++ .../views/html/icons/miracle-worker.tsx | 133 + .../html/icons/need-a-dispenser-here.tsx | 357 ++ .../views/html/icons/pain-train.tsx | 353 ++ .../views/html/icons/quick-fix.tsx | 306 ++ .../views/html/icons/reinforcements.tsx | 369 ++ .../views/html/icons/top-damage-dealer.tsx | 330 ++ .../views/html/icons/ubermensch.tsx | 265 + .../views/html/icons/ze-healing.tsx | 206 + src/players/views/html/player.page.tsx | 192 +- src/players/views/html/style.css | 53 +- 24 files changed, 13700 insertions(+), 37 deletions(-) create mode 100644 scripts/generate-achievement-icons.ts create mode 100644 src/achievements/views/html/icons/australium-legend.tsx create mode 100644 src/achievements/views/html/icons/australium-rl.tsx create mode 100644 src/achievements/views/html/icons/f2p-no-more.tsx create mode 100644 src/achievements/views/html/icons/first-blood.tsx create mode 100644 src/achievements/views/html/icons/grasshopper.tsx create mode 100644 src/achievements/views/html/icons/grizzled-veteran.tsx create mode 100644 src/achievements/views/html/icons/iron-mann.tsx create mode 100644 src/achievements/views/html/icons/kabooom.tsx create mode 100644 src/achievements/views/html/icons/maggots.tsx create mode 100644 src/achievements/views/html/icons/mann-co-reserve.tsx create mode 100644 src/achievements/views/html/icons/mann-of-steel.tsx create mode 100644 src/achievements/views/html/icons/mannpower-medic.tsx create mode 100644 src/achievements/views/html/icons/mercenary.tsx create mode 100644 src/achievements/views/html/icons/miracle-worker.tsx create mode 100644 src/achievements/views/html/icons/need-a-dispenser-here.tsx create mode 100644 src/achievements/views/html/icons/pain-train.tsx create mode 100644 src/achievements/views/html/icons/quick-fix.tsx create mode 100644 src/achievements/views/html/icons/reinforcements.tsx create mode 100644 src/achievements/views/html/icons/top-damage-dealer.tsx create mode 100644 src/achievements/views/html/icons/ubermensch.tsx create mode 100644 src/achievements/views/html/icons/ze-healing.tsx diff --git a/scripts/generate-achievement-icons.ts b/scripts/generate-achievement-icons.ts new file mode 100644 index 000000000..8606a27c4 --- /dev/null +++ b/scripts/generate-achievement-icons.ts @@ -0,0 +1,354 @@ +#!/usr/bin/env tsx +/** + * One-off script that generates icon components for every achievement via the Recraft API. + * + * Usage: + * RECRAFT_API_TOKEN= pnpm tsx scripts/generate-achievement-icons.ts + * + * Output: src/achievements/views/html/icons/.tsx (one file per achievement) + * + * Each file exports a single JSX function component named in PascalCase, e.g. + * first-blood.tsx → export function FirstBloodIcon(...) + * + * Style note: recraftv3_vector + "Bold stroke" was chosen for clean single-colour + * scalable icons that read well at small sizes (28 px default). Re-run with a + * different style value if you want a different look. + */ + +import { NodeType, parse as parseHtml } from 'node-html-parser' +import type { HTMLElement as ParsedElement, Node as ParsedNode } from 'node-html-parser' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +// ── Config ──────────────────────────────────────────────────────────────────── + +const TOKEN = process.env['RECRAFT_API_TOKEN'] +if (!TOKEN) { + console.error('Error: RECRAFT_API_TOKEN environment variable is required') + process.exit(1) +} + +const RECRAFT_URL = 'https://external.api.recraft.ai/v1/images/generations' +const MODEL = 'recraftv3_vector' +const STYLE = 'Bold stroke' +const DEFAULT_ICON_SIZE = 28 +const DELAY_MS = 600 // stay well within 100 req/min + +const OUTPUT_DIR = join(import.meta.dirname, '..', 'src', 'achievements', 'views', 'html', 'icons') + +// ── Achievement definitions ─────────────────────────────────────────────────── + +interface IconDef { + id: string // kebab-case, becomes filename and CSS class + name: string // PascalCase component name (without "Icon" suffix) + prompt: string +} + +const icons: IconDef[] = [ + // Games played + { + id: 'first-blood', + name: 'FirstBlood', + prompt: + 'minimalist bold-stroke icon of a single blood drop, clean geometric shape, no text, no background, centered composition', + }, + { + id: 'mercenary', + name: 'Mercenary', + prompt: + 'minimalist bold-stroke icon of a stack of three coins, clean lines, no text, no background', + }, + { + id: 'grizzled-veteran', + name: 'GrizzledVeteran', + prompt: + 'minimalist bold-stroke icon of a military service medal with a ribbon, clean lines, no text, no background', + }, + { + id: 'f2p-no-more', + name: 'F2pNoMore', + prompt: + 'minimalist bold-stroke icon of a graduation mortarboard cap, clean lines, no text, no background', + }, + { + id: 'australium-legend', + name: 'AustraliumLegend', + prompt: + 'minimalist bold-stroke icon of a trophy cup with a star on top, clean lines, no text, no background', + }, + // Class-specific + { + id: 'ze-healing', + name: 'ZeHealing', + prompt: + 'minimalist bold-stroke icon of a medical cross with a small heart inside, clean lines, no text, no background', + }, + { + id: 'ubermensch', + name: 'Ubermensch', + prompt: + 'minimalist bold-stroke icon of a medical syringe with a cross symbol, clean lines, no text, no background', + }, + { + id: 'grasshopper', + name: 'Grasshopper', + prompt: + 'minimalist bold-stroke icon of a running human figure with speed lines, clean lines, no text, no background', + }, + { + id: 'maggots', + name: 'Maggots', + prompt: + 'minimalist bold-stroke icon of a military combat helmet, clean lines, no text, no background', + }, + { + id: 'kabooom', + name: 'Kabooom', + prompt: + 'minimalist bold-stroke icon of a starburst explosion with jagged rays, clean lines, no text, no background', + }, + // Substitute + { + id: 'reinforcements', + name: 'Reinforcements', + prompt: 'minimalist bold-stroke icon of an open parachute, clean lines, no text, no background', + }, + { + id: 'mann-co-reserve', + name: 'MannCoReserve', + prompt: + 'minimalist bold-stroke icon of a circular reserve badge with a star in the center, clean lines, no text, no background', + }, + // Server join speed + { + id: 'need-a-dispenser-here', + name: 'NeedADispenserHere', + prompt: + 'minimalist bold-stroke icon of a boxy machine dispenser with a lightning bolt symbol, clean lines, no text, no background', + }, + // No disconnects + { + id: 'iron-mann', + name: 'IronMann', + prompt: + 'minimalist bold-stroke icon of a riveted iron shield, clean lines, no text, no background', + }, + { + id: 'mann-of-steel', + name: 'MannOfSteel', + prompt: + 'minimalist bold-stroke icon of a steel breastplate armor, clean lines, no text, no background', + }, + // Top DPM + { + id: 'top-damage-dealer', + name: 'TopDamageDealer', + prompt: 'minimalist bold-stroke icon of a bold flame, clean lines, no text, no background', + }, + { + id: 'pain-train', + name: 'PainTrain', + prompt: + 'minimalist bold-stroke icon of a locomotive train seen from the front, clean lines, no text, no background', + }, + { + id: 'australium-rl', + name: 'AustraliumRl', + prompt: + 'minimalist bold-stroke icon of a rocket launcher tube, clean lines, no text, no background', + }, + // High HPM + { + id: 'quick-fix', + name: 'QuickFix', + prompt: + 'minimalist bold-stroke icon of a first-aid kit box with a cross symbol, clean lines, no text, no background', + }, + { + id: 'miracle-worker', + name: 'MiracleWorker', + prompt: + 'minimalist bold-stroke icon of a glowing halo ring floating above a medical cross, clean lines, no text, no background', + }, + { + id: 'mannpower-medic', + name: 'MannpowerMedic', + prompt: + 'minimalist bold-stroke icon of a flexing muscular arm with a small medical cross symbol, clean lines, no text, no background', + }, +] + +// ── Recraft API ─────────────────────────────────────────────────────────────── + +async function generateSvg(prompt: string): Promise { + const res = await fetch(RECRAFT_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ prompt, model: MODEL, style: STYLE, n: 1, size: '1:1' }), + }) + + if (!res.ok) { + throw new Error(`Recraft API ${res.status}: ${await res.text()}`) + } + + const json = (await res.json()) as { data: { url: string }[] } + const url = json.data[0]?.url + if (!url) throw new Error('No URL in Recraft response') + + const svgRes = await fetch(url) + if (!svgRes.ok) throw new Error(`Failed to download SVG: ${svgRes.status}`) + return svgRes.text() +} + +// ── SVG → JSX serialization ─────────────────────────────────────────────────── + +// Matches any explicit color value (hex, rgb(), named colours). +// Preserves "none" and "currentColor" — those are not stripped. +const COLOR_VALUE_RE = + /^(#[0-9a-fA-F]{3,8}|rgb\(|rgba\(|hsl\(|hsla\(|black|white|red|green|blue|gray|grey|yellow|orange|purple|pink|brown|transparent)/i + +function isColorValue(v: string): boolean { + return COLOR_VALUE_RE.test(v) +} + +function serializeNode(node: ParsedNode, indent: string): string { + if (node.nodeType === NodeType.TEXT_NODE) { + // Escape JSX expression delimiters in raw text (e.g. JSON content in ) + return node.rawText.trim().replace(/\{/g, "{'{'}").replace(/\}/g, "{'}'}") + } + if (node.nodeType !== NodeType.ELEMENT_NODE) return '' + + const el = node as ParsedElement + const tag = el.rawTagName + + // Drop non-visual elements — (gradients), (Recraft signature) + if (tag === 'defs' || tag === 'metadata') return '' + + const attrs: Record = {} + + // The API places a full-canvas background path as the first element (M 0 0 … 2048 … z). + // After color stripping it would inherit currentColor and paint a solid square, so force it transparent. + const isBackgroundPath = + tag === 'path' && + (el.attrs['d'] ?? '').startsWith('M 0 0') && + (el.attrs['d'] ?? '').includes('2048') + + for (const [k, v] of Object.entries(el.attrs)) { + if (k === 'fill') { + if (isBackgroundPath) { + attrs['fill'] = 'none' + continue + } + // Remove colored fills — children inherit "currentColor" from the SVG parent. + // Preserve fill="none" (transparent intent) and fill="currentColor". + // Also strip url() gradient references (gradient defs are dropped above). + if (v !== 'none' && v !== 'currentColor' && (isColorValue(v) || v.startsWith('url('))) + continue + } + if (k === 'stroke') { + // Replace colored strokes with "currentColor" so they stay visible. + // Preserve stroke="none" and stroke="currentColor" as-is. + if (v !== 'none' && v !== 'currentColor' && (isColorValue(v) || v.startsWith('url('))) { + attrs['stroke'] = 'currentColor' + continue + } + } + // Drop stop-color / stop-opacity (only meaningful inside gradient defs) + if (k === 'stop-color' || k === 'stop-opacity') continue + attrs[k] = v + } + + if (isBackgroundPath && !('fill' in attrs)) attrs['fill'] = 'none' + + const attrStr = Object.entries(attrs) + .map(([k, v]) => `${k}="${v.replace(/"/g, '"')}"`) + .join(' ') + + const childIndent = `${indent} ` + const children = el.childNodes.map(child => serializeNode(child, childIndent)).filter(Boolean) + + const openTag = `<${tag}${attrStr ? ` ${attrStr}` : ''}>` + const closeTag = `` + + if (children.length === 0) { + return `${indent}<${tag}${attrStr ? ` ${attrStr}` : ''} />` + } + if (children.every(c => !c.includes('\n')) && children.join('').length < 80) { + return `${indent}${openTag}${children.join('')}${closeTag}` + } + return `${indent}${openTag}\n${children.map(c => (c.startsWith(childIndent) ? c : `${childIndent}${c}`)).join('\n')}\n${indent}${closeTag}` +} + +function svgToTsx(icon: IconDef, svgText: string): string { + const root = parseHtml(svgText) + const svgEl = root.querySelector('svg') + if (!svgEl) throw new Error('No element in response') + + const viewBox = svgEl.getAttribute('viewBox') ?? '0 0 100 100' + + const innerLines = svgEl.childNodes + .map(child => serializeNode(child, ' ')) + .filter(Boolean) + .join('\n') + + return `export function ${icon.name}Icon(props: { size?: number }) { + return ( + +${innerLines} + + ) +} +` +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +async function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +async function main() { + // Optional: pass icon IDs as CLI args to regenerate only specific icons. + // e.g. pnpm tsx scripts/generate-achievement-icons.ts first-blood mercenary + const filter = new Set(process.argv.slice(2)) + const queue = filter.size > 0 ? icons.filter(i => filter.has(i.id)) : icons + + await mkdir(OUTPUT_DIR, { recursive: true }) + console.log(`Output directory: ${OUTPUT_DIR}`) + console.log(`Model: ${MODEL} Style: ${STYLE}`) + console.log(`Generating ${queue.length} icons...\n`) + + let ok = 0 + let failed = 0 + + for (const icon of queue) { + process.stdout.write(` ${icon.id} ... `) + try { + const svgText = await generateSvg(icon.prompt) + const tsx = svgToTsx(icon, svgText) + const outPath = join(OUTPUT_DIR, `${icon.id}.tsx`) + await writeFile(outPath, tsx, 'utf8') + console.log('✓') + ok++ + } catch (err) { + console.log(`✗ ${(err as Error).message}`) + failed++ + } + + if (ok + failed < queue.length) await sleep(DELAY_MS) + } + + console.log(`\nDone: ${ok} generated, ${failed} failed`) + if (failed > 0) process.exit(1) +} + +void main() diff --git a/src/achievements/views/html/icons/australium-legend.tsx b/src/achievements/views/html/icons/australium-legend.tsx new file mode 100644 index 000000000..c91cfefe9 --- /dev/null +++ b/src/achievements/views/html/icons/australium-legend.tsx @@ -0,0 +1,425 @@ +export function AustraliumLegendIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/australium-rl.tsx b/src/achievements/views/html/icons/australium-rl.tsx new file mode 100644 index 000000000..e3bba5a60 --- /dev/null +++ b/src/achievements/views/html/icons/australium-rl.tsx @@ -0,0 +1,309 @@ +export function AustraliumRlIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/f2p-no-more.tsx b/src/achievements/views/html/icons/f2p-no-more.tsx new file mode 100644 index 000000000..8c80de139 --- /dev/null +++ b/src/achievements/views/html/icons/f2p-no-more.tsx @@ -0,0 +1,459 @@ +export function F2pNoMoreIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/first-blood.tsx b/src/achievements/views/html/icons/first-blood.tsx new file mode 100644 index 000000000..460154f99 --- /dev/null +++ b/src/achievements/views/html/icons/first-blood.tsx @@ -0,0 +1,4370 @@ +export function FirstBloodIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/grasshopper.tsx b/src/achievements/views/html/icons/grasshopper.tsx new file mode 100644 index 000000000..04babef21 --- /dev/null +++ b/src/achievements/views/html/icons/grasshopper.tsx @@ -0,0 +1,333 @@ +export function GrasshopperIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/grizzled-veteran.tsx b/src/achievements/views/html/icons/grizzled-veteran.tsx new file mode 100644 index 000000000..3c589e5e9 --- /dev/null +++ b/src/achievements/views/html/icons/grizzled-veteran.tsx @@ -0,0 +1,385 @@ +export function GrizzledVeteranIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/iron-mann.tsx b/src/achievements/views/html/icons/iron-mann.tsx new file mode 100644 index 000000000..03936ce95 --- /dev/null +++ b/src/achievements/views/html/icons/iron-mann.tsx @@ -0,0 +1,734 @@ +export function IronMannIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/kabooom.tsx b/src/achievements/views/html/icons/kabooom.tsx new file mode 100644 index 000000000..130a72b7b --- /dev/null +++ b/src/achievements/views/html/icons/kabooom.tsx @@ -0,0 +1,369 @@ +export function KabooomIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/maggots.tsx b/src/achievements/views/html/icons/maggots.tsx new file mode 100644 index 000000000..fe81af9f5 --- /dev/null +++ b/src/achievements/views/html/icons/maggots.tsx @@ -0,0 +1,929 @@ +export function MaggotsIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/mann-co-reserve.tsx b/src/achievements/views/html/icons/mann-co-reserve.tsx new file mode 100644 index 000000000..c0d2899b8 --- /dev/null +++ b/src/achievements/views/html/icons/mann-co-reserve.tsx @@ -0,0 +1,769 @@ +export function MannCoReserveIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/mann-of-steel.tsx b/src/achievements/views/html/icons/mann-of-steel.tsx new file mode 100644 index 000000000..d2f422ea1 --- /dev/null +++ b/src/achievements/views/html/icons/mann-of-steel.tsx @@ -0,0 +1,437 @@ +export function MannOfSteelIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/mannpower-medic.tsx b/src/achievements/views/html/icons/mannpower-medic.tsx new file mode 100644 index 000000000..fad1a2335 --- /dev/null +++ b/src/achievements/views/html/icons/mannpower-medic.tsx @@ -0,0 +1,790 @@ +export function MannpowerMedicIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/mercenary.tsx b/src/achievements/views/html/icons/mercenary.tsx new file mode 100644 index 000000000..e987df77d --- /dev/null +++ b/src/achievements/views/html/icons/mercenary.tsx @@ -0,0 +1,510 @@ +export function MercenaryIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/miracle-worker.tsx b/src/achievements/views/html/icons/miracle-worker.tsx new file mode 100644 index 000000000..478ba0281 --- /dev/null +++ b/src/achievements/views/html/icons/miracle-worker.tsx @@ -0,0 +1,133 @@ +export function MiracleWorkerIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/need-a-dispenser-here.tsx b/src/achievements/views/html/icons/need-a-dispenser-here.tsx new file mode 100644 index 000000000..075b0cff3 --- /dev/null +++ b/src/achievements/views/html/icons/need-a-dispenser-here.tsx @@ -0,0 +1,357 @@ +export function NeedADispenserHereIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/pain-train.tsx b/src/achievements/views/html/icons/pain-train.tsx new file mode 100644 index 000000000..eb3f96be9 --- /dev/null +++ b/src/achievements/views/html/icons/pain-train.tsx @@ -0,0 +1,353 @@ +export function PainTrainIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/quick-fix.tsx b/src/achievements/views/html/icons/quick-fix.tsx new file mode 100644 index 000000000..97ed78b0c --- /dev/null +++ b/src/achievements/views/html/icons/quick-fix.tsx @@ -0,0 +1,306 @@ +export function QuickFixIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/reinforcements.tsx b/src/achievements/views/html/icons/reinforcements.tsx new file mode 100644 index 000000000..e9a75689d --- /dev/null +++ b/src/achievements/views/html/icons/reinforcements.tsx @@ -0,0 +1,369 @@ +export function ReinforcementsIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/top-damage-dealer.tsx b/src/achievements/views/html/icons/top-damage-dealer.tsx new file mode 100644 index 000000000..3bf9c6fcd --- /dev/null +++ b/src/achievements/views/html/icons/top-damage-dealer.tsx @@ -0,0 +1,330 @@ +export function TopDamageDealerIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/ubermensch.tsx b/src/achievements/views/html/icons/ubermensch.tsx new file mode 100644 index 000000000..2dca2ae00 --- /dev/null +++ b/src/achievements/views/html/icons/ubermensch.tsx @@ -0,0 +1,265 @@ +export function UbermenschIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/achievements/views/html/icons/ze-healing.tsx b/src/achievements/views/html/icons/ze-healing.tsx new file mode 100644 index 000000000..99dd0729f --- /dev/null +++ b/src/achievements/views/html/icons/ze-healing.tsx @@ -0,0 +1,206 @@ +export function ZeHealingIcon(props: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/players/views/html/player.page.tsx b/src/players/views/html/player.page.tsx index 746f7561a..1ac5b170f 100644 --- a/src/players/views/html/player.page.tsx +++ b/src/players/views/html/player.page.tsx @@ -8,13 +8,29 @@ import { queue } from '../../../queue' import { GameClassIcon } from '../../../html/components/game-class-icon' import { IconAlignBoxBottomRight, - IconAwardFilled, IconBrandSteam, IconBrandTwitch, IconClover, IconStars, IconSum, } from '../../../html/components/icons' +import { AustraliumLegendIcon } from '../../../achievements/views/html/icons/australium-legend' +import { AustraliumRlIcon } from '../../../achievements/views/html/icons/australium-rl' +import { F2pNoMoreIcon } from '../../../achievements/views/html/icons/f2p-no-more' +import { FirstBloodIcon } from '../../../achievements/views/html/icons/first-blood' +import { GrizzledVeteranIcon } from '../../../achievements/views/html/icons/grizzled-veteran' +import { IronMannIcon } from '../../../achievements/views/html/icons/iron-mann' +import { MannOfSteelIcon } from '../../../achievements/views/html/icons/mann-of-steel' +import { MannpowerMedicIcon } from '../../../achievements/views/html/icons/mannpower-medic' +import { MercenaryIcon } from '../../../achievements/views/html/icons/mercenary' +import { MiracleWorkerIcon } from '../../../achievements/views/html/icons/miracle-worker' +import { NeedADispenserHereIcon } from '../../../achievements/views/html/icons/need-a-dispenser-here' +import { PainTrainIcon } from '../../../achievements/views/html/icons/pain-train' +import { QuickFixIcon } from '../../../achievements/views/html/icons/quick-fix' +import { ReinforcementsIcon } from '../../../achievements/views/html/icons/reinforcements' +import { TopDamageDealerIcon } from '../../../achievements/views/html/icons/top-damage-dealer' +import { UbermenschIcon } from '../../../achievements/views/html/icons/ubermensch' +import { ZeHealingIcon } from '../../../achievements/views/html/icons/ze-healing' import { resolve } from 'node:path' import { Page } from '../../../html/components/page' import { Footer } from '../../../html/components/footer' @@ -274,6 +290,7 @@ interface MockAchievement { unlocked: boolean unlockedAt?: string progress?: { current: number; target: number } + icon: (props: { size?: number }) => JSX.Element } const tierColors: Record = { @@ -284,23 +301,142 @@ const tierColors: Record = { } 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 } }, + { + name: 'First Blood', + description: 'Play your first game', + tier: 'bronze', + unlocked: true, + unlockedAt: 'Jan 15, 2025', + icon: FirstBloodIcon, + }, + { + name: 'Mercenary', + description: 'Play 100 games', + tier: 'bronze', + unlocked: true, + unlockedAt: 'Mar 22, 2025', + icon: MercenaryIcon, + }, + { + name: 'Ze Healing Is Not As Rewarding As Ze Hurting', + description: 'Play 100 games as medic', + tier: 'bronze', + unlocked: true, + unlockedAt: 'May 10, 2025', + icon: ZeHealingIcon, + }, + { + name: 'Reinforcements Have Arrived', + description: 'Join a game as a substitute', + tier: 'bronze', + unlocked: true, + unlockedAt: 'Feb 3, 2025', + icon: ReinforcementsIcon, + }, + { + name: 'Top Damage Dealer', + description: 'Have the highest DPM in a game 10 times', + tier: 'bronze', + unlocked: true, + unlockedAt: 'Apr 8, 2025', + icon: TopDamageDealerIcon, + }, + { + name: 'Quick-Fix', + description: 'Heal more than 1200 HPM in a game', + tier: 'bronze', + unlocked: true, + unlockedAt: 'Jun 1, 2025', + icon: QuickFixIcon, + }, + { + name: 'Grizzled Veteran', + description: 'Play 250 games', + tier: 'silver', + unlocked: true, + unlockedAt: 'Jul 14, 2025', + icon: GrizzledVeteranIcon, + }, + { + name: 'Übermensch', + description: 'Play 500 games as medic', + tier: 'silver', + unlocked: true, + unlockedAt: 'Nov 2, 2025', + icon: UbermenschIcon, + }, + { + name: 'Iron Mann', + description: 'Complete 10 games without disconnecting', + tier: 'silver', + unlocked: true, + unlockedAt: 'Feb 28, 2025', + icon: IronMannIcon, + }, + { + name: 'Need A Dispenser Here', + description: 'Join the server within 1 min 50 times', + tier: 'silver', + unlocked: true, + unlockedAt: 'Sep 5, 2025', + icon: NeedADispenserHereIcon, + }, + { + name: 'F2P No More', + description: 'Play 1000 games', + tier: 'gold', + unlocked: true, + unlockedAt: 'Dec 20, 2025', + icon: F2pNoMoreIcon, + }, + { + name: 'Mann of Steel', + description: 'Complete 50 games without disconnecting', + tier: 'gold', + unlocked: false, + progress: { current: 30, target: 50 }, + icon: MannOfSteelIcon, + }, + { + name: 'Pain Train', + description: 'Have the highest DPM in a game 100 times', + tier: 'silver', + unlocked: false, + progress: { current: 42, target: 100 }, + icon: PainTrainIcon, + }, + { + name: 'Miracle Worker', + description: 'Heal more than 1200 HPM in 10 games', + tier: 'silver', + unlocked: false, + progress: { current: 7, target: 10 }, + icon: MiracleWorkerIcon, + }, + { + name: 'Australium Legend', + description: 'Play 5000 games', + tier: 'australium', + unlocked: false, + progress: { current: 1100, target: 5000 }, + icon: AustraliumLegendIcon, + }, + { + name: 'Australium Rocket Launcher', + description: 'Have the highest DPM 1000 times', + tier: 'australium', + unlocked: false, + progress: { current: 42, target: 1000 }, + icon: AustraliumRlIcon, + }, + { + name: 'Mannpower Medic', + description: 'Heal more than 1200 HPM in 100 games', + tier: 'australium', + unlocked: false, + progress: { current: 7, target: 100 }, + icon: MannpowerMedicIcon, + }, ] function AchievementBadge(props: { achievement: MockAchievement }) { @@ -310,10 +446,10 @@ function AchievementBadge(props: { achievement: MockAchievement }) { return (
-
- +
{a.icon({ size: 28 })}
+
+ {a.name}
-
{a.name}
{a.tier}
@@ -326,9 +462,13 @@ function AchievementBadge(props: { achievement: MockAchievement }) {
)}
-
{a.description}
+
+ {a.description} +
- {a.unlocked ? `Unlocked ${a.unlockedAt}` : `${String(a.progress?.current ?? 0)} / ${String(a.progress?.target ?? '?')}`} + {a.unlocked + ? `Unlocked ${a.unlockedAt}` + : `${String(a.progress?.current ?? 0)} / ${String(a.progress?.target ?? '?')}`}
@@ -343,7 +483,7 @@ function PlayerAchievements() { <>
Achievements - + {unlocked.length}/{mockAchievements.length}
@@ -357,7 +497,9 @@ function PlayerAchievements() { {locked.length > 0 && (
- {locked.length} locked achievement{locked.length !== 1 ? 's' : ''} + + {locked.length} locked achievement{locked.length !== 1 ? 's' : ''} +
{locked.map(a => ( diff --git a/src/players/views/html/style.css b/src/players/views/html/style.css index 9a027983f..b39cacad3 100644 --- a/src/players/views/html/style.css +++ b/src/players/views/html/style.css @@ -358,19 +358,33 @@ background: var(--color-abru-light-5); } - &.tier-bronze { border-color: color-mix(in srgb, #cd7f32 30%, transparent); } - &.tier-silver { border-color: color-mix(in srgb, #bbbbbb 25%, transparent); } - &.tier-gold { border-color: color-mix(in srgb, #e3c392 35%, transparent); } - &.tier-australium { border-color: color-mix(in srgb, #e3b63a 40%, transparent); } + &.tier-bronze { + border-color: color-mix(in srgb, #cd7f32 30%, transparent); + } + &.tier-silver { + border-color: color-mix(in srgb, #bbbbbb 25%, transparent); + } + &.tier-gold { + border-color: color-mix(in srgb, #e3c392 35%, transparent); + } + &.tier-australium { + border-color: color-mix(in srgb, #e3b63a 40%, transparent); + } &.locked { opacity: 0.35; border-color: transparent; - &:hover { opacity: 0.5; } + &:hover { + opacity: 0.5; + } - .achievement-name { color: var(--color-abru-light-50); } - .achievement-tier { color: var(--color-abru-light-35); } + .achievement-name { + color: var(--color-abru-light-50); + } + .achievement-tier { + color: var(--color-abru-light-35); + } } } @@ -383,11 +397,26 @@ justify-content: center; flex-shrink: 0; - .tier-bronze & { background: rgba(205, 127, 50, 0.10); color: #cd7f32; } - .tier-silver & { background: rgba(187, 187, 187, 0.10); color: #bbbbbb; } - .tier-gold & { background: rgba(227, 195, 146, 0.10); color: #e3c392; } - .tier-australium & { background: rgba(227, 182, 58, 0.12); color: #e3b63a; } - .locked & { background: var(--color-abru-light-5); color: var(--color-abru-light-35); } + .tier-bronze & { + background: rgba(205, 127, 50, 0.1); + color: #cd7f32; + } + .tier-silver & { + background: rgba(187, 187, 187, 0.1); + color: #bbbbbb; + } + .tier-gold & { + background: rgba(227, 195, 146, 0.1); + color: #e3c392; + } + .tier-australium & { + background: rgba(227, 182, 58, 0.12); + color: #e3b63a; + } + .locked & { + background: var(--color-abru-light-5); + color: var(--color-abru-light-35); + } } .achievement-name { From 8ba8e4d89190ad25cf9938bc5b762a7f04ef9389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Sun, 3 May 2026 19:42:28 +0200 Subject: [PATCH 3/4] plan update --- achievements.plan.md | 70 ++++++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/achievements.plan.md b/achievements.plan.md index 5a9926db0..0e8666250 100644 --- a/achievements.plan.md +++ b/achievements.plan.md @@ -10,59 +10,59 @@ Static array in code. Each has: `id`, `name`, `description`, `tier` (bronze/silv ### Games played -| ID | Name | Description | Tier | -| ------------------- | ----------------- | ------------------ | ---------- | +| 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 | +| `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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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 @@ -73,7 +73,7 @@ New collection: `playerachievements` (single document per player) interface PlayerAchievementModel { player: SteamId64 achievements: PlayerAchievement[] // unlocked achievements - progress: AchievementProgress // counters for multi-game tracking + progress: AchievementProgress // counters for multi-game tracking } interface PlayerAchievement { From bc4e1e01a35eed81afa6bd557df663dc51ae4c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Fri, 22 May 2026 16:39:26 +0200 Subject: [PATCH 4/4] new icons --- scripts/generate-achievement-icons.ts | 23 +- scripts/review-achievement-icons.ts | 500 ++ .../views/html/icons/australium-rl.tsx | 361 +- .../views/html/icons/first-blood.tsx | 4394 +---------------- .../views/html/icons/grasshopper.tsx | 405 +- .../views/html/icons/iron-mann.tsx | 885 +--- .../views/html/icons/mannpower-medic.tsx | 810 +-- .../views/html/icons/mercenary.tsx | 556 +-- .../views/html/icons/miracle-worker.tsx | 187 +- .../views/html/icons/pain-train.tsx | 397 +- .../views/html/icons/reinforcements.tsx | 488 +- .../views/html/icons/top-damage-dealer.tsx | 356 +- .../views/html/icons/ubermensch.tsx | 396 +- 13 files changed, 1389 insertions(+), 8369 deletions(-) create mode 100644 scripts/review-achievement-icons.ts diff --git a/scripts/generate-achievement-icons.ts b/scripts/generate-achievement-icons.ts index 8606a27c4..f8a64cd48 100644 --- a/scripts/generate-achievement-icons.ts +++ b/scripts/generate-achievement-icons.ts @@ -29,7 +29,7 @@ if (!TOKEN) { } const RECRAFT_URL = 'https://external.api.recraft.ai/v1/images/generations' -const MODEL = 'recraftv3_vector' +const MODEL = 'recraftv4_1_vector' const STYLE = 'Bold stroke' const DEFAULT_ICON_SIZE = 28 const DELAY_MS = 600 // stay well within 100 req/min @@ -214,6 +214,15 @@ function isColorValue(v: string): boolean { return COLOR_VALUE_RE.test(v) } +// Detects white/near-white fills that should map to transparent rather than currentColor. +// Recraft v4+ splits the canvas into foreground (dark fill) and background (white fill) segments; +// stripping both equally leaves everything as currentColor and paints the whole canvas. +const LIGHT_COLOR_RE = /^(white|#fff\b|#fff{3}\b|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\))/i + +function isLightColor(v: string): boolean { + return LIGHT_COLOR_RE.test(v.trim()) +} + function serializeNode(node: ParsedNode, indent: string): string { if (node.nodeType === NodeType.TEXT_NODE) { // Escape JSX expression delimiters in raw text (e.g. JSON content in ) @@ -242,11 +251,15 @@ function serializeNode(node: ParsedNode, indent: string): string { attrs['fill'] = 'none' continue } - // Remove colored fills — children inherit "currentColor" from the SVG parent. - // Preserve fill="none" (transparent intent) and fill="currentColor". - // Also strip url() gradient references (gradient defs are dropped above). - if (v !== 'none' && v !== 'currentColor' && (isColorValue(v) || v.startsWith('url('))) + // Map colored fills: light (white) → none (transparent background segments), + // dark/other → drop so children inherit "currentColor" from the SVG parent. + // Recraft v4+ tiles the canvas with foreground+background path segments; treating + // both the same way paints the whole canvas. url() gradient refs are also dropped + // (their defs are stripped above). + if (v !== 'none' && v !== 'currentColor' && (isColorValue(v) || v.startsWith('url('))) { + if (isLightColor(v)) attrs['fill'] = 'none' continue + } } if (k === 'stroke') { // Replace colored strokes with "currentColor" so they stay visible. diff --git a/scripts/review-achievement-icons.ts b/scripts/review-achievement-icons.ts new file mode 100644 index 000000000..5be3dbe34 --- /dev/null +++ b/scripts/review-achievement-icons.ts @@ -0,0 +1,500 @@ +#!/usr/bin/env tsx +/** + * Interactive achievement icon review CLI. + * + * Opens a browser preview for each icon and lets you approve or regenerate + * it until you're happy. + * + * Usage: + * RECRAFT_API_TOKEN= pnpm tsx scripts/review-achievement-icons.ts [icon-id ...] + * + * If no icon IDs are given, goes through all icons in definition order. + * Keys: [y/Enter] approve and next [r] regenerate [s] skip [q] quit + */ + +import { NodeType, parse as parseHtml } from 'node-html-parser' +import type { HTMLElement as ParsedElement, Node as ParsedNode } from 'node-html-parser' +import { readFile, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { createInterface } from 'node:readline' +import { exec } from 'node:child_process' +import { promisify } from 'node:util' +import { tmpdir } from 'node:os' + +const execAsync = promisify(exec) + +// ── Config ──────────────────────────────────────────────────────────────────── + +const TOKEN = process.env['RECRAFT_API_TOKEN'] +const RECRAFT_URL = 'https://external.api.recraft.ai/v1/images/generations' +const MODEL = 'recraftv3_vector' +const STYLE = 'Bold stroke' +const DEFAULT_ICON_SIZE = 28 + +const OUTPUT_DIR = join(import.meta.dirname, '..', 'src', 'achievements', 'views', 'html', 'icons') +const PREVIEW_PATH = join(tmpdir(), 'achievement-icon-preview.html') + +// ── Achievement definitions ─────────────────────────────────────────────────── + +interface IconDef { + id: string + name: string + prompt: string +} + +const icons: IconDef[] = [ + // Games played + { + id: 'first-blood', + name: 'FirstBlood', + prompt: + 'minimalist bold-stroke icon of a single blood drop, clean geometric shape, no text, no background, centered composition', + }, + { + id: 'mercenary', + name: 'Mercenary', + prompt: + 'minimalist bold-stroke icon of a stack of three coins, clean lines, no text, no background', + }, + { + id: 'grizzled-veteran', + name: 'GrizzledVeteran', + prompt: + 'minimalist bold-stroke icon of a military service medal with a ribbon, clean lines, no text, no background', + }, + { + id: 'f2p-no-more', + name: 'F2pNoMore', + prompt: + 'minimalist bold-stroke icon of a graduation mortarboard cap, clean lines, no text, no background', + }, + { + id: 'australium-legend', + name: 'AustraliumLegend', + prompt: + 'minimalist bold-stroke icon of a trophy cup with a star on top, clean lines, no text, no background', + }, + // Class-specific + { + id: 'ze-healing', + name: 'ZeHealing', + prompt: + 'minimalist bold-stroke icon of a medical cross with a small heart inside, clean lines, no text, no background', + }, + { + id: 'ubermensch', + name: 'Ubermensch', + prompt: + 'minimalist bold-stroke icon of a medical syringe with a cross symbol, clean lines, no text, no background', + }, + { + id: 'grasshopper', + name: 'Grasshopper', + prompt: + 'minimalist bold-stroke icon of a running human figure with speed lines, clean lines, no text, no background', + }, + { + id: 'maggots', + name: 'Maggots', + prompt: + 'minimalist bold-stroke icon of a military combat helmet, clean lines, no text, no background', + }, + { + id: 'kabooom', + name: 'Kabooom', + prompt: + 'minimalist bold-stroke icon of a starburst explosion with jagged rays, clean lines, no text, no background', + }, + // Substitute + { + id: 'reinforcements', + name: 'Reinforcements', + prompt: 'minimalist bold-stroke icon of an open parachute, clean lines, no text, no background', + }, + { + id: 'mann-co-reserve', + name: 'MannCoReserve', + prompt: + 'minimalist bold-stroke icon of a circular reserve badge with a star in the center, clean lines, no text, no background', + }, + // Server join speed + { + id: 'need-a-dispenser-here', + name: 'NeedADispenserHere', + prompt: + 'minimalist bold-stroke icon of a boxy machine dispenser with a lightning bolt symbol, clean lines, no text, no background', + }, + // No disconnects + { + id: 'iron-mann', + name: 'IronMann', + prompt: + 'minimalist bold-stroke icon of a riveted iron shield, clean lines, no text, no background', + }, + { + id: 'mann-of-steel', + name: 'MannOfSteel', + prompt: + 'minimalist bold-stroke icon of a steel breastplate armor, clean lines, no text, no background', + }, + // Top DPM + { + id: 'top-damage-dealer', + name: 'TopDamageDealer', + prompt: 'minimalist bold-stroke icon of a bold flame, clean lines, no text, no background', + }, + { + id: 'pain-train', + name: 'PainTrain', + prompt: + 'minimalist bold-stroke icon of a locomotive train seen from the front, clean lines, no text, no background', + }, + { + id: 'australium-rl', + name: 'AustraliumRl', + prompt: + 'minimalist bold-stroke icon of a rocket launcher tube, clean lines, no text, no background', + }, + // High HPM + { + id: 'quick-fix', + name: 'QuickFix', + prompt: + 'minimalist bold-stroke icon of a first-aid kit box with a cross symbol, clean lines, no text, no background', + }, + { + id: 'miracle-worker', + name: 'MiracleWorker', + prompt: + 'minimalist bold-stroke icon of a glowing halo ring floating above a medical cross, clean lines, no text, no background', + }, + { + id: 'mannpower-medic', + name: 'MannpowerMedic', + prompt: + 'minimalist bold-stroke icon of a flexing muscular arm with a small medical cross symbol, clean lines, no text, no background', + }, +] + +// ── Recraft API ─────────────────────────────────────────────────────────────── + +async function generateSvg(prompt: string): Promise { + const res = await fetch(RECRAFT_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ prompt, model: MODEL, style: STYLE, n: 1, size: '1:1' }), + }) + + if (!res.ok) throw new Error(`Recraft API ${res.status}: ${await res.text()}`) + + const json = (await res.json()) as { data: { url: string }[] } + const url = json.data[0]?.url + if (!url) throw new Error('No URL in Recraft response') + + const svgRes = await fetch(url) + if (!svgRes.ok) throw new Error(`Failed to download SVG: ${svgRes.status}`) + return svgRes.text() +} + +// ── SVG → JSX serialization (identical to generate-achievement-icons.ts) ───── + +const COLOR_VALUE_RE = + /^(#[0-9a-fA-F]{3,8}|rgb\(|rgba\(|hsl\(|hsla\(|black|white|red|green|blue|gray|grey|yellow|orange|purple|pink|brown|transparent)/i + +function isColorValue(v: string): boolean { + return COLOR_VALUE_RE.test(v) +} + +const LIGHT_COLOR_RE = /^(white|#fff\b|#fff{3}\b|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\))/i + +function isLightColor(v: string): boolean { + return LIGHT_COLOR_RE.test(v.trim()) +} + +function serializeNode(node: ParsedNode, indent: string): string { + if (node.nodeType === NodeType.TEXT_NODE) { + return node.rawText.trim().replace(/\{/g, "{'{'}").replace(/\}/g, "{'}'}") + } + if (node.nodeType !== NodeType.ELEMENT_NODE) return '' + + const el = node as ParsedElement + const tag = el.rawTagName + + if (tag === 'defs' || tag === 'metadata') return '' + + const attrs: Record = {} + + const isBackgroundPath = + tag === 'path' && + (el.attrs['d'] ?? '').startsWith('M 0 0') && + (el.attrs['d'] ?? '').includes('2048') + + for (const [k, v] of Object.entries(el.attrs)) { + if (k === 'fill') { + if (isBackgroundPath) { + attrs['fill'] = 'none' + continue + } + if (v !== 'none' && v !== 'currentColor' && (isColorValue(v) || v.startsWith('url('))) { + if (isLightColor(v)) attrs['fill'] = 'none' + continue + } + } + if (k === 'stroke') { + if (v !== 'none' && v !== 'currentColor' && (isColorValue(v) || v.startsWith('url('))) { + attrs['stroke'] = 'currentColor' + continue + } + } + if (k === 'stop-color' || k === 'stop-opacity') continue + attrs[k] = v + } + + if (isBackgroundPath && !('fill' in attrs)) attrs['fill'] = 'none' + + const attrStr = Object.entries(attrs) + .map(([k, v]) => `${k}="${v.replace(/"/g, '"')}"`) + .join(' ') + + const childIndent = `${indent} ` + const children = el.childNodes.map(child => serializeNode(child, childIndent)).filter(Boolean) + + const openTag = `<${tag}${attrStr ? ` ${attrStr}` : ''}>` + const closeTag = `` + + if (children.length === 0) { + return `${indent}<${tag}${attrStr ? ` ${attrStr}` : ''} />` + } + if (children.every(c => !c.includes('\n')) && children.join('').length < 80) { + return `${indent}${openTag}${children.join('')}${closeTag}` + } + return `${indent}${openTag}\n${children.map(c => (c.startsWith(childIndent) ? c : `${childIndent}${c}`)).join('\n')}\n${indent}${closeTag}` +} + +function svgToTsx(icon: IconDef, svgText: string): string { + const root = parseHtml(svgText) + const svgEl = root.querySelector('svg') + if (!svgEl) throw new Error('No element in response') + + const viewBox = svgEl.getAttribute('viewBox') ?? '0 0 100 100' + + const innerLines = svgEl.childNodes + .map(child => serializeNode(child, ' ')) + .filter(Boolean) + .join('\n') + + return `export function ${icon.name}Icon(props: { size?: number }) { + return ( + +${innerLines} + + ) +} +` +} + +// ── Preview helpers ─────────────────────────────────────────────────────────── + +function tsxToSvgHtml(tsxContent: string): string { + const svgMatch = tsxContent.match(//) + if (!svgMatch) throw new Error('Could not extract SVG from TSX') + // Replace JSX dynamic expressions with concrete values for browser rendering + return svgMatch[0] + .replace(/width=\{[^}]+\}/g, 'width="256"') + .replace(/height=\{[^}]+\}/g, 'height="256"') +} + +function resizeSvg(svgHtml: string, size: number): string { + return svgHtml + .replace(/width="\d+"/, `width="${size}"`) + .replace(/height="\d+"/, `height="${size}"`) +} + +async function writePreview(icon: IconDef, tsxContent: string): Promise { + const svgHtml = tsxToSvgHtml(tsxContent) + const html = ` + + + + ${icon.id} + + + + +

${icon.id}

+

${icon.prompt}

+
+
+
+ ${resizeSvg(svgHtml, 256)} +
+ 256 px +
+
+
+ ${resizeSvg(svgHtml, 64)} + ${resizeSvg(svgHtml, 64)} +
+ 64 px +
+
+
+ ${resizeSvg(svgHtml, 28)} + ${resizeSvg(svgHtml, 28)} +
+ 28 px (default) +
+
+ +` + await writeFile(PREVIEW_PATH, html, 'utf8') +} + +async function openBrowser(): Promise { + await execAsync(`xdg-open "${PREVIEW_PATH}"`).catch(() => { + console.log(` Open manually: file://${PREVIEW_PATH}`) + }) +} + +// ── CLI helpers ─────────────────────────────────────────────────────────────── + +function ask(rl: ReturnType, question: string): Promise { + return new Promise(resolve => rl.question(question, answer => resolve(answer.trim().toLowerCase()))) +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +async function main() { + if (!TOKEN) { + console.error('Error: RECRAFT_API_TOKEN environment variable is required') + process.exit(1) + } + + const filter = new Set(process.argv.slice(2)) + const queue = filter.size > 0 ? icons.filter(i => filter.has(i.id)) : icons + + if (queue.length === 0) { + console.error('No matching icons found.') + process.exit(1) + } + + console.log(`Reviewing ${queue.length} icon(s). Keys: [y/Enter] approve [r] regenerate [s] skip [q] quit\n`) + + const rl = createInterface({ input: process.stdin, output: process.stdout }) + let browserOpened = false + + for (let i = 0; i < queue.length; i++) { + const icon = queue[i]! + const filePath = join(OUTPUT_DIR, `${icon.id}.tsx`) + + console.log(`[${i + 1}/${queue.length}] ${icon.id}`) + + let tsxContent: string | null = existsSync(filePath) ? await readFile(filePath, 'utf8') : null + + if (!tsxContent) { + process.stdout.write(' No file found — generating ... ') + try { + const svgText = await generateSvg(icon.prompt) + tsxContent = svgToTsx(icon, svgText) + await writeFile(filePath, tsxContent, 'utf8') + console.log('done') + } catch (e) { + console.log(`failed: ${(e as Error).message}`) + console.log(' Skipping.') + continue + } + } + + while (true) { + try { + await writePreview(icon, tsxContent) + if (!browserOpened) { + await openBrowser() + browserOpened = true + await new Promise(r => setTimeout(r, 800)) + } + } catch (e) { + console.log(` Warning: preview failed: ${(e as Error).message}`) + console.log(` Open manually: file://${PREVIEW_PATH}`) + } + + const answer = await ask(rl, ' > ') + + if (answer === 'q') { + console.log('Quit.') + rl.close() + return + } + + if (answer === 's') { + console.log(' Skipped.') + break + } + + if (answer === 'y' || answer === '') { + console.log(' Approved ✓') + break + } + + if (answer === 'r') { + process.stdout.write(' Regenerating ... ') + try { + const svgText = await generateSvg(icon.prompt) + tsxContent = svgToTsx(icon, svgText) + await writeFile(filePath, tsxContent, 'utf8') + console.log('done') + } catch (e) { + console.log(`failed: ${(e as Error).message}`) + } + continue + } + + console.log(' Unknown key. Use y/Enter, r, s, or q.') + } + } + + rl.close() + console.log('\nAll done!') +} + +void main() diff --git a/src/achievements/views/html/icons/australium-rl.tsx b/src/achievements/views/html/icons/australium-rl.tsx index e3bba5a60..26e9e70b3 100644 --- a/src/achievements/views/html/icons/australium-rl.tsx +++ b/src/achievements/views/html/icons/australium-rl.tsx @@ -7,303 +7,70 @@ export function AustraliumRlIcon(props: { size?: number }) { height={props.size ?? 28} fill="currentColor" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/achievements/views/html/icons/first-blood.tsx b/src/achievements/views/html/icons/first-blood.tsx index 460154f99..9d7ce2387 100644 --- a/src/achievements/views/html/icons/first-blood.tsx +++ b/src/achievements/views/html/icons/first-blood.tsx @@ -7,4364 +7,42 @@ export function FirstBloodIcon(props: { size?: number }) { height={props.size ?? 28} fill="currentColor" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/achievements/views/html/icons/grasshopper.tsx b/src/achievements/views/html/icons/grasshopper.tsx index 04babef21..5b4e22724 100644 --- a/src/achievements/views/html/icons/grasshopper.tsx +++ b/src/achievements/views/html/icons/grasshopper.tsx @@ -7,327 +7,90 @@ export function GrasshopperIcon(props: { size?: number }) { height={props.size ?? 28} fill="currentColor" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/achievements/views/html/icons/iron-mann.tsx b/src/achievements/views/html/icons/iron-mann.tsx index 03936ce95..aa809f6be 100644 --- a/src/achievements/views/html/icons/iron-mann.tsx +++ b/src/achievements/views/html/icons/iron-mann.tsx @@ -7,728 +7,169 @@ export function IronMannIcon(props: { size?: number }) { height={props.size ?? 28} fill="currentColor" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/achievements/views/html/icons/mannpower-medic.tsx b/src/achievements/views/html/icons/mannpower-medic.tsx index fad1a2335..0ca5d15e9 100644 --- a/src/achievements/views/html/icons/mannpower-medic.tsx +++ b/src/achievements/views/html/icons/mannpower-medic.tsx @@ -7,784 +7,38 @@ export function MannpowerMedicIcon(props: { size?: number }) { height={props.size ?? 28} fill="currentColor" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/achievements/views/html/icons/mercenary.tsx b/src/achievements/views/html/icons/mercenary.tsx index e987df77d..8fb355a11 100644 --- a/src/achievements/views/html/icons/mercenary.tsx +++ b/src/achievements/views/html/icons/mercenary.tsx @@ -7,504 +7,64 @@ export function MercenaryIcon(props: { size?: number }) { height={props.size ?? 28} fill="currentColor" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/achievements/views/html/icons/miracle-worker.tsx b/src/achievements/views/html/icons/miracle-worker.tsx index 478ba0281..1813e0109 100644 --- a/src/achievements/views/html/icons/miracle-worker.tsx +++ b/src/achievements/views/html/icons/miracle-worker.tsx @@ -7,127 +7,72 @@ export function MiracleWorkerIcon(props: { size?: number }) { height={props.size ?? 28} fill="currentColor" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/achievements/views/html/icons/pain-train.tsx b/src/achievements/views/html/icons/pain-train.tsx index eb3f96be9..23e884d1c 100644 --- a/src/achievements/views/html/icons/pain-train.tsx +++ b/src/achievements/views/html/icons/pain-train.tsx @@ -7,347 +7,62 @@ export function PainTrainIcon(props: { size?: number }) { height={props.size ?? 28} fill="currentColor" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/achievements/views/html/icons/reinforcements.tsx b/src/achievements/views/html/icons/reinforcements.tsx index e9a75689d..c9de4e16e 100644 --- a/src/achievements/views/html/icons/reinforcements.tsx +++ b/src/achievements/views/html/icons/reinforcements.tsx @@ -7,363 +7,137 @@ export function ReinforcementsIcon(props: { size?: number }) { height={props.size ?? 28} fill="currentColor" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/achievements/views/html/icons/top-damage-dealer.tsx b/src/achievements/views/html/icons/top-damage-dealer.tsx index 3bf9c6fcd..326c7ad4e 100644 --- a/src/achievements/views/html/icons/top-damage-dealer.tsx +++ b/src/achievements/views/html/icons/top-damage-dealer.tsx @@ -7,324 +7,44 @@ export function TopDamageDealerIcon(props: { size?: number }) { height={props.size ?? 28} fill="currentColor" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/achievements/views/html/icons/ubermensch.tsx b/src/achievements/views/html/icons/ubermensch.tsx index 2dca2ae00..cff6680f1 100644 --- a/src/achievements/views/html/icons/ubermensch.tsx +++ b/src/achievements/views/html/icons/ubermensch.tsx @@ -7,259 +7,149 @@ export function UbermenschIcon(props: { size?: number }) { height={props.size ?? 28} fill="currentColor" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) }