Fix achievement icons in launcher, allow opening OAuth URLs, live map docs

- Shared achievement icon catalogue; launcher resolves icon/background/border ids
- Tauri opener capability now allows http(s) URLs (fixes Discord sign-in)
- Rewrite live map docs, admin guide progression section

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 17:31:24 +00:00
1 parent 3ceed4382f
commit 1a321336b4
17 files changed
+500 -140

No files matched your search

+4
View File
@@ -10,3 +10,7 @@ RUST_LOG=info
PUBLIC_URL= PUBLIC_URL=
# Comma-separated IPs of your reverse proxies; blank means forwarded IP headers are ignored. # Comma-separated IPs of your reverse proxies; blank means forwarded IP headers are ignored.
SCOPENET_TRUSTED_PROXIES= SCOPENET_TRUSTED_PROXIES=
# Live Map: optional static Vantage Linux release to bundle, and memory for the panel.
VANTAGE_URL=
PANEL_MEMORY=256M
+14
View File
@@ -83,3 +83,17 @@ Everything on this page is pushed to launchers when they start or refresh — no
See the README's *Build your branded launcher*. Players get updates automatically: the launcher checks GitHub Releases on start and offers the new installer. See the README's *Build your branded launcher*. Players get updates automatically: the launcher checks GitHub Releases on start and offers the new installer.
**Code signing:** unsigned installers trigger a Windows SmartScreen warning ("More info → Run anyway"). To avoid it, sign the installer with a code-signing certificate (e.g. Azure Trusted Signing) — Tauri supports this via `bundle.windows.signCommand`. **Code signing:** unsigned installers trigger a Windows SmartScreen warning ("More info → Run anyway"). To avoid it, sign the installer with a code-signing certificate (e.g. Azure Trusted Signing) — Tauri supports this via `bundle.windows.signCommand`.
## Progression (quests, XP, levels)
**Progression** in the sidebar controls how players advance:
* **Quest limits** – how many daily and weekly quests each player is given (0 = every
enabled quest), and whether everyone draws their own set or shares one. Pin quests
on the Quests page to always include them. A player's set is stored when they first
open their quests for the period, so edits never swap out quests they've started.
* **XP & levels** – the level curve (`round(base × L^exponent)`, optional max level),
XP per playtime hour / kill / block / message, and panel-wide multipliers that stack
with each server's own. Saving a new curve recalculates stored levels; nobody loses XP.
* **Players** – search a player, then give, take, set or reset XP (global or per
server) or set their level. Changes are recorded in the Activity log with your reason.
+63 -36
View File
@@ -1,44 +1,71 @@
# Vantage live map # Live Map (central Vantage)
SCOPENET can display a [Vantage](https://github.com/thoughts-on-things/vantage-mc) The panel hosts a [Vantage](https://github.com/thoughts-on-things/vantage-mc) 3D map
world in the launcher's **Guilds & Claims → Territory** view. The map shows **for every game server**, so nobody has to run Vantage next to Minecraft.
live terrain, optional player positions, SCOPENET guild names, and the claim
outline for each nearby chunk. Clicking terrain selects the chunk; the Claim
and Unclaim buttons use the existing authenticated SCOPENET API. Claim
protection on the game server is unchanged.
## Set up the world map ```
Minecraft server ──(SCOPENET plugin/mod)──► Admin panel ──► Launcher "Live Map" button
chunks + players HTTPS / WebSocket mirror + Vantage Vantage viewer in-app
```
1. Install a Vantage release on the machine that can read the Minecraft world 1. **Collect** – the SCOPENET Paper plugin / Fabric / Forge mod reads the world's
files. Give it read access to the save, and a separate writable cache. region files (only chunks newer than what the panel already has) and sends them to
2. Start a live render for that world. For example: the panel over HTTPS. Player positions stream over a WebSocket (HTTPS fallback).
2. **Store & render** – the panel keeps a region-file mirror per server under
`<data>/livemap/<server id>/world` and runs a `vantage server` sidecar over it on
demand (stopped again after 15 idle minutes). Sidecars listen on loopback only.
3. **Serve** – `GET /api/livemap/<server>/<dimension>/v1/worlds/default/…` proxies the
sidecar to signed-in launcher/admin sessions. Player markers come straight from
memory. One map per server and dimension (`overworld`, `the_nether`, `the_end`).
4. **View** – the launcher checks `GET /api/v1/servers/<id>/livemap`; when Live Map is
on for a server linked to the instance, the instance page shows a **Live Map**
button that opens the map inside the launcher (and the Guilds territory view uses
it too). Admins see the same map on *Servers → your server*.
```sh Chunk data only reaches the panel when the game server has written it, so the map
vantage live /srv/minecraft/world --out /var/cache/vantage/world --host 127.0.0.1 --port 8268 follows autosaves (and `/save-all`) rather than every block change.
```
3. Put Vantage behind an HTTPS reverse proxy, or have your portal proxy its ## Turn it on
map files. The launcher's **Vantage live map manifest URL** is the complete
`manifest.json` URL, such as `https://maps.example.com/manifest.json`.
A pre-rendered multi-dimension world can use `world.json` instead. In the
panel, open **Servers → your server → Edit** and save that URL.
4. Link the game server to the correct launcher instance. Open that instance's
Guilds & Claims page in the launcher. The selected game server controls both
the Vantage world and the SCOPENET claims shown on it.
The native launcher streams map assets through its HTTP client so the map host 1. **Servers → your server → Settings → Live Map** (on by default for new servers).
does not need browser CORS headers. Browser-only development previews require 2. Update the plugin/mod. `livemap.enabled: true` (Paper `config.yml`) or
CORS on the map host. The URL is included in the public game-server summary; `livemap.enabled=true` (`config/scopenet.properties`) is the default; set it to
use a map you intend players to see. Keep Vantage `server` bearer credentials `false` to keep a server off the map locally.
out of this URL. For private worlds, put Vantage behind a session-gated proxy 3. The server page shows a checklist: generator, assets, world data, player positions.
that serves the manifest and tile files with the player's existing session.
Vantage's [server guide](https://github.com/thoughts-on-things/vantage-mc/blob/main/docs/server.md)
describes the sidecar and its authorization boundary.
Live terrain comes from Vantage's read-only view of saved world files. Claims ## Panel requirements
and guild names come from SCOPENET's claim database. If the map cannot be
loaded, the launcher falls back to the chunk grid. The map does not run inside
Minecraft; `/guild map` is still the Paper plugin's nearby chat radar.
Vantage's web package is MIT licensed. See its Rendering needs two things on the machine running the panel:
[license](https://github.com/thoughts-on-things/vantage-mc/blob/main/LICENSE).
| Setting | Meaning |
| --- | --- |
| `SCOPENET_VANTAGE_BIN` | The `vantage` executable (default: `vantage` on `PATH`, then `/opt/vantage/vantage`). |
| `SCOPENET_VANTAGE_ASSETS` | Minecraft client assets directory passed as `--assets` (default `<data>/livemap/assets`). |
| `SCOPENET_VANTAGE_ARGS` | Optional extra `vantage server` flags, e.g. `--radius 2048 --memory 1024`. |
With Docker, either mount your binary at `/opt/vantage/vantage` and the assets at
`/data/livemap/assets`, or bake a static Linux release in with
`VANTAGE_URL=<release archive> docker compose build`. Until both exist the panel still
stores what servers send and launchers show "the map is being prepared".
Give the container more than the default 256 MB if Live Map is on (`PANEL_MEMORY`).
## Security
* Game servers authenticate with their existing `sn_…` token; the endpoints refuse
servers whose Live Map switch is off.
* Viewers must be signed in; the launcher attaches its session token natively, so it
never reaches page scripts. Anyone with an account can view a server's map — use
the server's access rules and leave Live Map off for private worlds.
* The sidecar gets a random per-process bearer token and never leaves loopback.
## Ingest API (for reference)
All under `/api/server/v1/livemap/` with `Authorization: Bearer <server token>`:
`GET config`, `GET manifest?dim=`, `POST chunks?dim=` (binary records of
`i32 x, i32 z, u32 timestamp, u8 compression, u32 length, bytes`), `POST level`
(`level.dat`), `POST players`, `GET ws` (WebSocket: JSON `players` frames).
## External Vantage (advanced)
Under *Use an external Vantage map instead* you can still point a server at a
`manifest.json` / `world.json` you host yourself. It is only used when Live Map is off.
+9 -1
View File
@@ -15,6 +15,14 @@
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"dialog:allow-open", "dialog:allow-open",
"opener:allow-open-url" {
"identifier": "opener:allow-open-url",
"allow": [
{ "url": "https://*" },
{ "url": "https://*/**" },
{ "url": "http://*" },
{ "url": "http://*/**" }
]
}
] ]
} }
@@ -0,0 +1,34 @@
<script lang="ts">
import { resolveIcon } from '../lib/achievementIcons';
let { ach, size = 2.8, locked = false }: {
ach: { icon_item?: string | null; icon_bg?: string | null; icon_border?: string | null; frame_type?: string | null };
size?: number;
locked?: boolean;
} = $props();
// Achievements store ids ("diamond_sword", "obsidian", "gold"); resolve them into something drawable.
const icon = $derived(resolveIcon(ach));
const frame = $derived(ach.frame_type === 'goal' || ach.frame_type === 'challenge' ? ach.frame_type : 'task');
</script>
<span
class="frame {frame}"
class:locked
style:--size="{size}rem"
style:--rim={icon.borderColor}
style:--glow={icon.glow}
aria-hidden="true"
>
<span class="inner" style:background={icon.background}><span class="glyph">{icon.emoji}</span></span>
</span>
<style>
.frame { width: var(--size); height: var(--size); flex-shrink: 0; display: grid; place-items: center; border: 2px solid var(--rim); background: #18181b; box-shadow: var(--glow); transition: filter 0.2s, box-shadow 0.2s; }
.frame.task { border-radius: 0.4rem; }
.frame.goal { border-radius: 50%; }
.frame.challenge { border-radius: 0.4rem; clip-path: polygon(15% 0%, 85% 0%, 100% 15%, 100% 85%, 85% 100%, 15% 100%, 0% 85%, 0% 15%); box-shadow: none; }
.inner { width: 78%; height: 78%; display: grid; place-items: center; border-radius: inherit; box-shadow: inset 0 0 0.4rem rgba(0, 0, 0, 0.5); }
.glyph { font-size: calc(var(--size) * 0.5); line-height: 1; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.6)); }
.frame.locked { filter: grayscale(0.85) brightness(0.75); box-shadow: none; }
</style>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import AchievementIcon from './AchievementIcon.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
X, Trophy, Shield, Heart, Image as ImageIcon, Send, Edit3, Check, X, Trophy, Shield, Heart, Image as ImageIcon, Send, Edit3, Check,
@@ -338,9 +339,7 @@
<div class="ach-grid"> <div class="ach-grid">
{#each profile.achievements as ach (ach.id)} {#each profile.achievements as ach (ach.id)}
<div class="ach-item glass" class:frame-challenge={ach.frame_type === 'challenge'}> <div class="ach-item glass" class:frame-challenge={ach.frame_type === 'challenge'}>
<div class="ach-icon-box" style:background={ach.icon_bg || 'rgba(0,0,0,0.5)'}> <AchievementIcon {ach} size={2.2} />
<span>{ach.icon_item || '🏆'}</span>
</div>
<div class="ach-meta"> <div class="ach-meta">
<span class="ach-name">{ach.title}</span> <span class="ach-name">{ach.title}</span>
<p class="ach-sub tiny muted">{ach.description}</p> <p class="ach-sub tiny muted">{ach.description}</p>
@@ -760,15 +759,6 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--line); border: 1px solid var(--line);
} }
.ach-icon-box {
width: 2.2rem;
height: 2.2rem;
border-radius: 4px;
display: grid;
place-items: center;
font-size: 1.1rem;
flex-shrink: 0;
}
.ach-meta { .ach-meta {
flex: 1; flex: 1;
display: flex; display: flex;
+116
View File
@@ -0,0 +1,116 @@
// Achievement icon catalogue shared by the admin panel and the launcher.
//
// Achievements store *ids* (`icon_item: "diamond_sword"`, `icon_bg: "obsidian"`,
// `icon_border: "gold"`), not pictures, so every app has to turn them into
// something drawable. This file is that one translation. The launcher keeps an
// identical copy at launcher/src/lib/achievementIcons.ts — change both together.
export interface ItemIcon { id: string; label: string; emoji: string }
export const ITEM_ICONS: ItemIcon[] = [
{ id: 'diamond_pickaxe', label: 'Diamond Pickaxe', emoji: '⛏️' },
{ id: 'wooden_pickaxe', label: 'Wooden Pickaxe', emoji: '⛏️' },
{ id: 'diamond', label: 'Diamond', emoji: '💎' },
{ id: 'emerald', label: 'Emerald', emoji: '🟢' },
{ id: 'gold_ingot', label: 'Gold Ingot', emoji: '🪙' },
{ id: 'iron_sword', label: 'Iron Sword', emoji: '🗡️' },
{ id: 'diamond_sword', label: 'Diamond Sword', emoji: '⚔️' },
{ id: 'netherite_sword', label: 'Netherite Sword', emoji: '⚔️' },
{ id: 'netherite_axe', label: 'Netherite Axe', emoji: '🪓' },
{ id: 'bow', label: 'Bow', emoji: '🏹' },
{ id: 'trident', label: 'Trident', emoji: '🔱' },
{ id: 'shield', label: 'Shield', emoji: '🛡️' },
{ id: 'bricks', label: 'Bricks', emoji: '🧱' },
{ id: 'stone_bricks', label: 'Stone Bricks', emoji: '🧱' },
{ id: 'grass_block', label: 'Grass Block', emoji: '🟩' },
{ id: 'crafting_table', label: 'Crafting Table', emoji: '🪚' },
{ id: 'chest', label: 'Treasure Chest', emoji: '📦' },
{ id: 'beacon', label: 'Beacon', emoji: '✨' },
{ id: 'clock', label: 'Clock', emoji: '🕰️' },
{ id: 'compass', label: 'Compass', emoji: '🧭' },
{ id: 'map', label: 'Map', emoji: '🗺️' },
{ id: 'book', label: 'Book', emoji: '📖' },
{ id: 'enchanted_book', label: 'Enchanted Book', emoji: '📘' },
{ id: 'experience_bottle', label: 'Bottle o’ Enchanting', emoji: '🧪' },
{ id: 'golden_apple', label: 'Golden Apple', emoji: '🍏' },
{ id: 'cake', label: 'Cake', emoji: '🎂' },
{ id: 'elytra', label: 'Elytra', emoji: '🪽' },
{ id: 'ender_eye', label: 'Eye of Ender', emoji: '👁️' },
{ id: 'ender_pearl', label: 'Ender Pearl', emoji: '🔮' },
{ id: 'flint_and_steel', label: 'Flint and Steel', emoji: '🔥' },
{ id: 'blaze_rod', label: 'Blaze Rod', emoji: '🔥' },
{ id: 'heart_of_the_sea', label: 'Heart of the Sea', emoji: '💙' },
{ id: 'nether_star', label: 'Nether Star', emoji: '⭐' },
{ id: 'totem_of_undying', label: 'Totem of Undying', emoji: '🗿' },
{ id: 'dragon_egg', label: 'Dragon Egg', emoji: '🥚' },
{ id: 'dragon_head', label: 'Dragon Head', emoji: '🐉' },
{ id: 'skull', label: 'Skull', emoji: '💀' },
{ id: 'fishing_rod', label: 'Fishing Rod', emoji: '🎣' },
{ id: 'wheat', label: 'Wheat', emoji: '🌾' },
{ id: 'castle', label: 'Castle Flag', emoji: '🚩' },
{ id: 'trophy', label: 'Trophy', emoji: '🏆' },
];
export interface Backdrop { id: string; label: string; css: string }
export const BACKGROUNDS: Backdrop[] = [
{ id: 'stone', label: 'Stone', css: 'radial-gradient(circle, #6b7280 0%, #27272a 100%)' },
{ id: 'deepslate', label: 'Deepslate', css: 'radial-gradient(circle, #27272a 0%, #09090b 100%)' },
{ id: 'obsidian', label: 'Obsidian', css: 'radial-gradient(circle, #2e1065 0%, #0f172a 100%)' },
{ id: 'dirt', label: 'Dirt', css: 'radial-gradient(circle, #92400e 0%, #451a03 100%)' },
{ id: 'bedrock', label: 'Bedrock', css: 'radial-gradient(circle, #3f3f46 0%, #09090b 100%)' },
{ id: 'oak_planks', label: 'Oak Planks', css: 'radial-gradient(circle, #c08a4b 0%, #6b4423 100%)' },
{ id: 'netherrack', label: 'Netherrack', css: 'radial-gradient(circle, #9f1239 0%, #3b0a16 100%)' },
{ id: 'end_stone', label: 'End Stone', css: 'radial-gradient(circle, #e8e2a8 0%, #8a8456 100%)' },
{ id: 'gilded_blackstone', label: 'Gilded Blackstone', css: 'radial-gradient(circle, #a16207 0%, #1c1917 100%)' },
];
export interface Rim { id: string; label: string; color: string }
export const BORDERS: Rim[] = [
{ id: 'bronze', label: 'Bronze', color: '#b45309' },
{ id: 'silver', label: 'Silver', color: '#94a3b8' },
{ id: 'gold', label: 'Gold', color: '#eab308' },
{ id: 'diamond', label: 'Diamond', color: '#06b6d4' },
{ id: 'purple', label: 'Amethyst / Purple', color: '#c084fc' },
{ id: 'netherite', label: 'Netherite', color: '#475569' },
];
export const FRAMES = [
{ id: 'task', label: 'Task (Normal)', bannerTitle: 'Advancement Made!', bannerColor: '#55ff55' },
{ id: 'goal', label: 'Goal (Rounded)', bannerTitle: 'Goal Reached!', bannerColor: '#55ffff' },
{ id: 'challenge', label: 'Challenge (Spiked & Glowing)', bannerTitle: 'Challenge Complete!', bannerColor: '#ff55ff' },
] as const;
// Ids that aren't in the catalogue (added through the API, or from an older
// data set) still get a sensible picture from their name.
const KEYWORDS: [RegExp, string][] = [
[/pickaxe|shovel|hoe/, '⛏️'], [/axe/, '🪓'], [/sword|dagger/, '⚔️'], [/bow|arrow/, '🏹'], [/trident/, '🔱'],
[/shield/, '🛡️'], [/helmet|chestplate|leggings|boots|armor/, '🛡️'], [/apple|carrot|bread|food|steak/, '🍏'],
[/potion|bottle/, '🧪'], [/book/, '📖'], [/ore|ingot|nugget/, '🪙'], [/diamond|emerald|amethyst|gem/, '💎'],
[/star|beacon/, '✨'], [/egg/, '🥚'], [/head|skull/, '💀'], [/block|brick|stone|planks|log/, '🧱'],
[/fire|flame|blaze|lava/, '🔥'], [/fish|rod/, '🎣'], [/clock|watch/, '🕰️'], [/map|compass/, '🧭'],
];
const isCss = (v: string) => /^(#|rgb|hsl|linear-gradient|radial-gradient|var\()/i.test(v.trim());
const isId = (v: string) => /^[a-z0-9_]+$/.test(v);
export interface ResolvedIcon { emoji: string; background: string; borderColor: string; glow: string }
export function resolveIcon(a: { icon_item?: string | null; icon_bg?: string | null; icon_border?: string | null }): ResolvedIcon {
const item = (a.icon_item ?? '').trim();
let emoji = '🏆';
if (item) {
const known = ITEM_ICONS.find((i) => i.id === item);
if (known) emoji = known.emoji;
else if (!isId(item)) emoji = item; // already an emoji or symbol
else emoji = KEYWORDS.find(([re]) => re.test(item))?.[1] ?? '🏆';
}
const bgRaw = (a.icon_bg ?? '').trim();
const background = BACKGROUNDS.find((b) => b.id === bgRaw)?.css ?? (bgRaw && isCss(bgRaw) ? bgRaw : BACKGROUNDS[1].css);
const rimRaw = (a.icon_border ?? '').trim();
const borderColor = BORDERS.find((b) => b.id === rimRaw)?.color ?? (rimRaw && isCss(rimRaw) ? rimRaw : '#eab308');
return { emoji, background, borderColor, glow: `0 0 12px ${borderColor}99` };
}
+1 -1
View File
@@ -231,7 +231,7 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
{ id: 'ach1', title: 'Stone Age', description: 'Mine your first piece of cobblestone.', category: 'mining', xp_reward: 50, frame_type: 'task', icon_item: '🪨', icon_bg: '#334155', icon_border: '#64748b', unlocked: true, unlocked_at: '2026-09-20T12:00:00Z' }, { id: 'ach1', title: 'Stone Age', description: 'Mine your first piece of cobblestone.', category: 'mining', xp_reward: 50, frame_type: 'task', icon_item: '🪨', icon_bg: '#334155', icon_border: '#64748b', unlocked: true, unlocked_at: '2026-09-20T12:00:00Z' },
{ id: 'ach2', title: 'Acquire Hardware', description: 'Smelt an iron ingot in a furnace.', category: 'mining', xp_reward: 100, frame_type: 'task', icon_item: '⛓️', icon_bg: '#475569', icon_border: '#94a3b8', unlocked: true, unlocked_at: '2026-09-21T14:30:00Z' }, { id: 'ach2', title: 'Acquire Hardware', description: 'Smelt an iron ingot in a furnace.', category: 'mining', xp_reward: 100, frame_type: 'task', icon_item: '⛓️', icon_bg: '#475569', icon_border: '#94a3b8', unlocked: true, unlocked_at: '2026-09-21T14:30:00Z' },
{ id: 'ach3', title: 'Diamonds!', description: 'Acquire diamonds from deep underground.', category: 'mining', xp_reward: 350, frame_type: 'goal', icon_item: '💎', icon_bg: '#0284c7', icon_border: '#38bdf8', unlocked: true, unlocked_at: '2026-09-24T18:15:00Z' }, { id: 'ach3', title: 'Diamonds!', description: 'Acquire diamonds from deep underground.', category: 'mining', xp_reward: 350, frame_type: 'goal', icon_item: '💎', icon_bg: '#0284c7', icon_border: '#38bdf8', unlocked: true, unlocked_at: '2026-09-24T18:15:00Z' },
{ id: 'ach4', title: 'Free the End', description: 'Defeat the Ender Dragon in the End dimension.', category: 'combat', xp_reward: 1000, frame_type: 'challenge', icon_item: '🐉', icon_bg: '#581c87', icon_border: '#f59e0b', unlocked: false, unlocked_at: null }, { id: 'ach4', title: 'Free the End', description: 'Defeat the Ender Dragon in the End dimension.', category: 'combat', xp_reward: 1000, frame_type: 'challenge', icon_item: 'dragon_head', icon_bg: 'end_stone', icon_border: 'purple', unlocked: false, unlocked_at: null },
]; ];
case 'get_guilds': case 'get_guilds':
return [ return [
+2 -3
View File
@@ -4,6 +4,7 @@
Target, Trophy, Clock, Sparkles, Check, Gift, Pickaxe, Swords, Hammer, Target, Trophy, Clock, Sparkles, Check, Gift, Pickaxe, Swords, Hammer,
Wheat, Users, Compass, RefreshCw, LoaderCircle, Flame, Star, Award, Shield Wheat, Users, Compass, RefreshCw, LoaderCircle, Flame, Star, Award, Shield
} from '@lucide/svelte'; } from '@lucide/svelte';
import AchievementIcon from '../components/AchievementIcon.svelte';
import { invoke } from '../lib/tauri'; import { invoke } from '../lib/tauri';
import { toast } from '../lib/store.svelte'; import { toast } from '../lib/store.svelte';
import type { UserLevelInfo, ServerLevelInfo, UserQuest, Achievement } from '../lib/types'; import type { UserLevelInfo, ServerLevelInfo, UserQuest, Achievement } from '../lib/types';
@@ -217,9 +218,7 @@
<div class="achievements-grid"> <div class="achievements-grid">
{#each achievements as ach (ach.id)} {#each achievements as ach (ach.id)}
<div class="achievement-card glass" class:unlocked={ach.unlocked} class:frame-task={ach.frame_type === 'task'} class:frame-goal={ach.frame_type === 'goal'} class:frame-challenge={ach.frame_type === 'challenge'}> <div class="achievement-card glass" class:unlocked={ach.unlocked} class:frame-task={ach.frame_type === 'task'} class:frame-goal={ach.frame_type === 'goal'} class:frame-challenge={ach.frame_type === 'challenge'}>
<div class="achievement-icon-frame" style:background={ach.icon_bg || 'rgba(0,0,0,0.5)'} style:border-color={ach.icon_border || 'var(--line)'}> <AchievementIcon {ach} locked={!ach.unlocked} />
<span class="sprite-icon">{ach.icon_item || '💎'}</span>
</div>
<div class="achievement-body"> <div class="achievement-body">
<div class="achievement-header"> <div class="achievement-header">
<span class="ach-title">{ach.title}</span> <span class="ach-title">{ach.title}</span>
+25 -4
View File
@@ -279,7 +279,8 @@ fn find_bin(configured: Option<&Path>) -> Option<PathBuf> {
let candidates: Vec<PathBuf> = match configured { let candidates: Vec<PathBuf> = match configured {
Some(p) => vec![p.to_path_buf()], Some(p) => vec![p.to_path_buf()],
None => { None => {
let mut v: Vec<PathBuf> = std::env::var_os("PATH").map(|p| std::env::split_paths(&p).map(|d| d.join("vantage")).collect()).unwrap_or_default(); let mut v: Vec<PathBuf> =
std::env::var_os("PATH").map(|p| std::env::split_paths(&p).map(|d| d.join("vantage")).collect()).unwrap_or_default();
v.push("/opt/vantage/vantage".into()); v.push("/opt/vantage/vantage".into());
v v
} }
@@ -315,7 +316,11 @@ impl LiveMap {
} }
pub fn region_dir(&self, id: i64, dim: Dim) -> PathBuf { pub fn region_dir(&self, id: i64, dim: Dim) -> PathBuf {
let w = self.world_dir(id); let w = self.world_dir(id);
if dim.folder().is_empty() { w.join("region") } else { w.join(dim.folder()).join("region") } if dim.folder().is_empty() {
w.join("region")
} else {
w.join(dim.folder()).join("region")
}
} }
fn cache_dir(&self, id: i64, dim: Dim) -> PathBuf { fn cache_dir(&self, id: i64, dim: Dim) -> PathBuf {
self.server_dir(id).join("cache").join(dim.slug()) self.server_dir(id).join("cache").join(dim.slug())
@@ -449,7 +454,11 @@ impl LiveMap {
/// return its loopback port and bearer token. /// return its loopback port and bearer token.
pub async fn ensure_sidecar(&self, id: i64, dim: Dim) -> Result<(u16, String), String> { pub async fn ensure_sidecar(&self, id: i64, dim: Dim) -> Result<(u16, String), String> {
let bin = self.bin.clone().ok_or("The Vantage generator isn't installed on this panel. See docs/live-map.md.")?; let bin = self.bin.clone().ok_or("The Vantage generator isn't installed on this panel. See docs/live-map.md.")?;
let assets = self.assets.clone().filter(|a| a.is_dir()).ok_or("Minecraft client assets for Vantage aren't configured. See docs/live-map.md.")?; let assets = self
.assets
.clone()
.filter(|a| a.is_dir())
.ok_or("Minecraft client assets for Vantage aren't configured. See docs/live-map.md.")?;
let world = self.world_dir(id); let world = self.world_dir(id);
if !world.join("level.dat").is_file() || !self.region_dir(id, dim).is_dir() { if !world.join("level.dat").is_file() || !self.region_dir(id, dim).is_dir() {
return Err("Waiting for the game server to upload this world.".into()); return Err("Waiting for the game server to upload this world.".into());
@@ -514,7 +523,19 @@ impl LiveMap {
let deadline = Instant::now() + Duration::from_secs(30); let deadline = Instant::now() + Duration::from_secs(30);
loop { loop {
if let Ok(Some(status)) = sidecar.child.try_wait() { if let Ok(Some(status)) = sidecar.child.try_wait() {
let tail = sidecar.log.lock().unwrap().iter().rev().take(4).cloned().collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join(" | "); let tail = sidecar
.log
.lock()
.unwrap()
.iter()
.rev()
.take(4)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join(" | ");
let msg = format!("Vantage exited ({status}) {tail}"); let msg = format!("Vantage exited ({status}) {tail}");
self.last_error.lock().unwrap().insert((id, dim), msg.clone()); self.last_error.lock().unwrap().insert((id, dim), msg.clone());
return Err(msg); return Err(msg);
+40 -9
View File
@@ -202,7 +202,8 @@ pub async fn save(pool: &sqlx::SqlitePool, p: &Progression) -> AppResult<()> {
/// Recompute every stored level from its XP after the curve changed. /// Recompute every stored level from its XP after the curve changed.
pub async fn recalculate_levels(pool: &sqlx::SqlitePool) -> AppResult<usize> { pub async fn recalculate_levels(pool: &sqlx::SqlitePool) -> AppResult<usize> {
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
let globals: Vec<(String, i64, i64)> = sqlx::query_as("SELECT uuid, global_xp, global_level FROM user_levels").fetch_all(&mut *tx).await?; let globals: Vec<(String, i64, i64)> =
sqlx::query_as("SELECT uuid, global_xp, global_level FROM user_levels").fetch_all(&mut *tx).await?;
let mut changed = 0; let mut changed = 0;
for (uuid, xp, level) in globals { for (uuid, xp, level) in globals {
let new = level_from_xp(xp).0; let new = level_from_xp(xp).0;
@@ -211,11 +212,17 @@ pub async fn recalculate_levels(pool: &sqlx::SqlitePool) -> AppResult<usize> {
changed += 1; changed += 1;
} }
} }
let servers: Vec<(i64, String, i64, i64)> = sqlx::query_as("SELECT server_id, uuid, server_xp, server_level FROM server_levels").fetch_all(&mut *tx).await?; let servers: Vec<(i64, String, i64, i64)> =
sqlx::query_as("SELECT server_id, uuid, server_xp, server_level FROM server_levels").fetch_all(&mut *tx).await?;
for (server_id, uuid, xp, level) in servers { for (server_id, uuid, xp, level) in servers {
let new = level_from_xp(xp).0; let new = level_from_xp(xp).0;
if new != level { if new != level {
sqlx::query("UPDATE server_levels SET server_level = ? WHERE server_id = ? AND uuid = ?").bind(new).bind(server_id).bind(&uuid).execute(&mut *tx).await?; sqlx::query("UPDATE server_levels SET server_level = ? WHERE server_id = ? AND uuid = ?")
.bind(new)
.bind(server_id)
.bind(&uuid)
.execute(&mut *tx)
.await?;
changed += 1; changed += 1;
} }
} }
@@ -234,7 +241,13 @@ pub async fn recalculate_levels(pool: &sqlx::SqlitePool) -> AppResult<usize> {
/// stays put for the whole period, even if admins add or edit quests; it is /// stays put for the whole period, even if admins add or edit quests; it is
/// only topped up (raised limit, a disabled quest) or truncated (lowered /// only topped up (raised limit, a disabled quest) or truncated (lowered
/// limit). Pinned quests are always chosen first. /// limit). Pinned quests are always chosen first.
pub async fn assigned_quest_ids(conn: &mut SqliteConnection, cfg: &Progression, uuid: &str, period: &str, key: &str) -> AppResult<Vec<String>> { pub async fn assigned_quest_ids(
conn: &mut SqliteConnection,
cfg: &Progression,
uuid: &str,
period: &str,
key: &str,
) -> AppResult<Vec<String>> {
let limit = if period == "weekly" { cfg.weekly_quest_limit } else { cfg.daily_quest_limit } as usize; let limit = if period == "weekly" { cfg.weekly_quest_limit } else { cfg.daily_quest_limit } as usize;
let eligible: Vec<(String, bool)> = sqlx::query_as("SELECT id, pinned FROM quests WHERE enabled = 1 AND period = ? ORDER BY id") let eligible: Vec<(String, bool)> = sqlx::query_as("SELECT id, pinned FROM quests WHERE enabled = 1 AND period = ? ORDER BY id")
.bind(period) .bind(period)
@@ -262,13 +275,16 @@ pub async fn assigned_quest_ids(conn: &mut SqliteConnection, cfg: &Progression,
.map(|(id, pinned)| (!pinned, hex::encode(Sha256::digest(format!("{seed}|{id}").as_bytes())), id)) .map(|(id, pinned)| (!pinned, hex::encode(Sha256::digest(format!("{seed}|{id}").as_bytes())), id))
.collect(); .collect();
ranked.sort(); ranked.sort();
let mut position: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM quest_assignments WHERE user_uuid = ? AND period_key = ?") let mut position: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM quest_assignments WHERE user_uuid = ? AND period_key = ?")
.bind(uuid) .bind(uuid)
.bind(key) .bind(key)
.fetch_one(&mut *conn) .fetch_one(&mut *conn)
.await?; .await?;
for (_, _, id) in ranked.into_iter().take(limit - current.len()) { for (_, _, id) in ranked.into_iter().take(limit - current.len()) {
sqlx::query("INSERT OR REPLACE INTO quest_assignments (user_uuid, period, period_key, quest_id, position) VALUES (?, ?, ?, ?, ?)") sqlx::query(
"INSERT OR REPLACE INTO quest_assignments (user_uuid, period, period_key, quest_id, position) VALUES (?, ?, ?, ?, ?)",
)
.bind(uuid) .bind(uuid)
.bind(period) .bind(period)
.bind(key) .bind(key)
@@ -284,7 +300,14 @@ pub async fn assigned_quest_ids(conn: &mut SqliteConnection, cfg: &Progression,
Ok(current) Ok(current)
} }
pub async fn is_assigned(conn: &mut SqliteConnection, cfg: &Progression, uuid: &str, period: &str, key: &str, quest_id: &str) -> AppResult<bool> { pub async fn is_assigned(
conn: &mut SqliteConnection,
cfg: &Progression,
uuid: &str,
period: &str,
key: &str,
quest_id: &str,
) -> AppResult<bool> {
Ok(assigned_quest_ids(conn, cfg, uuid, period, key).await?.iter().any(|id| id == quest_id)) Ok(assigned_quest_ids(conn, cfg, uuid, period, key).await?.iter().any(|id| id == quest_id))
} }
@@ -329,7 +352,8 @@ pub fn target_xp(mode: Mode, current: i64, amount: i64) -> AppResult<i64> {
} }
pub async fn adjust_global(conn: &mut SqliteConnection, uuid: &str, mode: Mode, amount: i64, now: &str) -> AppResult<XpChange> { pub async fn adjust_global(conn: &mut SqliteConnection, uuid: &str, mode: Mode, amount: i64, now: &str) -> AppResult<XpChange> {
let old_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&mut *conn).await?.unwrap_or(0); let old_xp: i64 =
sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&mut *conn).await?.unwrap_or(0);
let new_xp = target_xp(mode, old_xp, amount)?; let new_xp = target_xp(mode, old_xp, amount)?;
let (old_level, new_level) = (level_from_xp(old_xp).0, level_from_xp(new_xp).0); let (old_level, new_level) = (level_from_xp(old_xp).0, level_from_xp(new_xp).0);
sqlx::query( sqlx::query(
@@ -345,7 +369,14 @@ pub async fn adjust_global(conn: &mut SqliteConnection, uuid: &str, mode: Mode,
Ok(XpChange { old_xp, new_xp, old_level, new_level }) Ok(XpChange { old_xp, new_xp, old_level, new_level })
} }
pub async fn adjust_server(conn: &mut SqliteConnection, server_id: i64, uuid: &str, mode: Mode, amount: i64, now: &str) -> AppResult<XpChange> { pub async fn adjust_server(
conn: &mut SqliteConnection,
server_id: i64,
uuid: &str,
mode: Mode,
amount: i64,
now: &str,
) -> AppResult<XpChange> {
let old_xp: i64 = sqlx::query_scalar("SELECT server_xp FROM server_levels WHERE server_id = ? AND uuid = ?") let old_xp: i64 = sqlx::query_scalar("SELECT server_xp FROM server_levels WHERE server_id = ? AND uuid = ?")
.bind(server_id) .bind(server_id)
.bind(uuid) .bind(uuid)
+34 -6
View File
@@ -60,15 +60,29 @@ pub async fn game_config_route(GameServer(server): GameServer) -> Json<Value> {
} }
/// Timestamp tables of the regions the panel already has, base64 per region. /// Timestamp tables of the regions the panel already has, base64 per region.
pub async fn game_manifest(GameServer(server): GameServer, State(state): State<AppState>, Query(q): Query<DimQuery>) -> AppResult<Json<Value>> { pub async fn game_manifest(
GameServer(server): GameServer,
State(state): State<AppState>,
Query(q): Query<DimQuery>,
) -> AppResult<Json<Value>> {
require_enabled(server.live_map_enabled)?; require_enabled(server.live_map_enabled)?;
let dim = dim_of(&q.dim)?; let dim = dim_of(&q.dim)?;
let b64 = base64::engine::general_purpose::STANDARD; let b64 = base64::engine::general_purpose::STANDARD;
let regions: Vec<Value> = state.livemap.manifest(server.id, dim).into_iter().map(|(x, z, ts)| json!({ "x": x, "z": z, "timestamps": b64.encode(ts) })).collect(); let regions: Vec<Value> = state
.livemap
.manifest(server.id, dim)
.into_iter()
.map(|(x, z, ts)| json!({ "x": x, "z": z, "timestamps": b64.encode(ts) }))
.collect();
Ok(Json(json!({ "dimension": dim.slug(), "regions": regions }))) Ok(Json(json!({ "dimension": dim.slug(), "regions": regions })))
} }
pub async fn game_chunks(GameServer(server): GameServer, State(state): State<AppState>, Query(q): Query<DimQuery>, body: Bytes) -> AppResult<Json<Value>> { pub async fn game_chunks(
GameServer(server): GameServer,
State(state): State<AppState>,
Query(q): Query<DimQuery>,
body: Bytes,
) -> AppResult<Json<Value>> {
require_enabled(server.live_map_enabled)?; require_enabled(server.live_map_enabled)?;
let dim = dim_of(&q.dim)?; let dim = dim_of(&q.dim)?;
let chunks = parse_chunks(&body).map_err(AppError::bad_request)?; let chunks = parse_chunks(&body).map_err(AppError::bad_request)?;
@@ -102,7 +116,11 @@ fn sanitize(mut players: Vec<LivePlayer>) -> Vec<LivePlayer> {
players players
} }
pub async fn game_players(GameServer(server): GameServer, State(state): State<AppState>, Json(b): Json<PlayersBody>) -> AppResult<Json<Value>> { pub async fn game_players(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(b): Json<PlayersBody>,
) -> AppResult<Json<Value>> {
require_enabled(server.live_map_enabled)?; require_enabled(server.live_map_enabled)?;
state.livemap.set_players(server.id, sanitize(b.players)); state.livemap.set_players(server.id, sanitize(b.players));
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
@@ -140,7 +158,12 @@ async fn ws_loop(mut socket: WebSocket, state: AppState, id: i64) {
} }
Message::Binary(b) => { Message::Binary(b) => {
let reply = match b.split_first() { let reply = match b.split_first() {
Some((d, rest)) => match Dim::ALL.get(*d as usize).copied().ok_or("unknown dimension").and_then(|dim| parse_chunks(rest).map(|c| (dim, c))) { Some((d, rest)) => match Dim::ALL
.get(*d as usize)
.copied()
.ok_or("unknown dimension")
.and_then(|dim| parse_chunks(rest).map(|c| (dim, c)))
{
Ok((dim, chunks)) => match state.livemap.ingest_chunks(id, dim, chunks).await { Ok((dim, chunks)) => match state.livemap.ingest_chunks(id, dim, chunks).await {
Ok(n) => json!({ "type": "stored", "count": n }), Ok(n) => json!({ "type": "stored", "count": n }),
Err(e) => json!({ "type": "error", "error": e.to_string() }), Err(e) => json!({ "type": "error", "error": e.to_string() }),
@@ -236,7 +259,12 @@ pub async fn admin_reset(_: AdminUser, State(state): State<AppState>, Path(id):
/// Authenticated reverse proxy to the per-server Vantage sidecar. Clients /// Authenticated reverse proxy to the per-server Vantage sidecar. Clients
/// use `…/api/livemap/{server}/{dimension}/v1/worlds/default/manifest.json` /// use `…/api/livemap/{server}/{dimension}/v1/worlds/default/manifest.json`
/// and everything the manifest references. /// and everything the manifest references.
pub async fn proxy(_: AuthUser, State(state): State<AppState>, Path((id, dim, path)): Path<(i64, String, String)>, headers: HeaderMap) -> AppResult<Response> { pub async fn proxy(
_: AuthUser,
State(state): State<AppState>,
Path((id, dim, path)): Path<(i64, String, String)>,
headers: HeaderMap,
) -> AppResult<Response> {
let server = get_server(&state, id).await?; let server = get_server(&state, id).await?;
require_enabled(server.live_map_enabled)?; require_enabled(server.live_map_enabled)?;
let dim = dim_of(&dim)?; let dim = dim_of(&dim)?;
+22 -8
View File
@@ -11,9 +11,8 @@ use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
async fn pool_counts(state: &AppState) -> AppResult<Value> { async fn pool_counts(state: &AppState) -> AppResult<Value> {
let rows: Vec<(String, i64, i64)> = sqlx::query_as( let rows: Vec<(String, i64, i64)> =
"SELECT period, COUNT(*), COALESCE(SUM(pinned), 0) FROM quests WHERE enabled = 1 GROUP BY period", sqlx::query_as("SELECT period, COUNT(*), COALESCE(SUM(pinned), 0) FROM quests WHERE enabled = 1 GROUP BY period")
)
.fetch_all(&state.db) .fetch_all(&state.db)
.await?; .await?;
let mut out = json!({ "daily": { "enabled": 0, "pinned": 0 }, "weekly": { "enabled": 0, "pinned": 0 } }); let mut out = json!({ "daily": { "enabled": 0, "pinned": 0 }, "weekly": { "enabled": 0, "pinned": 0 } });
@@ -45,9 +44,11 @@ pub async fn put_settings(admin: AdminUser, State(state): State<AppState>, Json(
let before = progression::load_pool(&state.db).await?; let before = progression::load_pool(&state.db).await?;
progression::save(&state.db, &next).await?; progression::save(&state.db, &next).await?;
progression::set_curve(&next); progression::set_curve(&next);
let curve_changed = before.level_base != next.level_base || before.level_exponent != next.level_exponent || before.max_level != next.max_level; let curve_changed =
before.level_base != next.level_base || before.level_exponent != next.level_exponent || before.max_level != next.max_level;
let recalculated = if curve_changed { progression::recalculate_levels(&state.db).await? } else { 0 }; let recalculated = if curve_changed { progression::recalculate_levels(&state.db).await? } else { 0 };
crate::routes::activity::record(&state, &admin, "panel", "progression_settings", Some(&format!("recalculated {recalculated} levels"))).await?; crate::routes::activity::record(&state, &admin, "panel", "progression_settings", Some(&format!("recalculated {recalculated} levels")))
.await?;
let mut v = view(&state, &next).await?; let mut v = view(&state, &next).await?;
v["recalculated"] = json!(recalculated); v["recalculated"] = json!(recalculated);
Ok(Json(v)) Ok(Json(v))
@@ -139,7 +140,12 @@ pub struct Adjust {
reason: String, reason: String,
} }
pub async fn adjust_player(admin: AdminUser, State(state): State<AppState>, Path(uuid): Path<String>, Json(a): Json<Adjust>) -> AppResult<Json<Value>> { pub async fn adjust_player(
admin: AdminUser,
State(state): State<AppState>,
Path(uuid): Path<String>,
Json(a): Json<Adjust>,
) -> AppResult<Json<Value>> {
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ?)").bind(&uuid).fetch_one(&state.db).await?; let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ?)").bind(&uuid).fetch_one(&state.db).await?;
if !exists { if !exists {
return Err(AppError::not_found("player not found")); return Err(AppError::not_found("player not found"));
@@ -150,7 +156,8 @@ pub async fn adjust_player(admin: AdminUser, State(state): State<AppState>, Path
"global" => ("global".to_string(), progression::adjust_global(&mut tx, &uuid, a.mode, a.amount, &now).await?), "global" => ("global".to_string(), progression::adjust_global(&mut tx, &uuid, a.mode, a.amount, &now).await?),
"server" => { "server" => {
let id = a.server_id.ok_or_else(|| AppError::bad_request("pick a server"))?; let id = a.server_id.ok_or_else(|| AppError::bad_request("pick a server"))?;
let name: Option<String> = sqlx::query_scalar("SELECT name FROM game_servers WHERE id = ?").bind(id).fetch_optional(&mut *tx).await?; let name: Option<String> =
sqlx::query_scalar("SELECT name FROM game_servers WHERE id = ?").bind(id).fetch_optional(&mut *tx).await?;
let name = name.ok_or_else(|| AppError::not_found("server not found"))?; let name = name.ok_or_else(|| AppError::not_found("server not found"))?;
(format!("server {name}"), progression::adjust_server(&mut tx, id, &uuid, a.mode, a.amount, &now).await?) (format!("server {name}"), progression::adjust_server(&mut tx, id, &uuid, a.mode, a.amount, &now).await?)
} }
@@ -163,7 +170,14 @@ pub async fn adjust_player(admin: AdminUser, State(state): State<AppState>, Path
tx.commit().await?; tx.commit().await?;
let reason: String = a.reason.chars().filter(|c| !c.is_control()).take(120).collect(); let reason: String = a.reason.chars().filter(|c| !c.is_control()).take(120).collect();
let detail = format!("{label} XP {} → {} (level {} → {}) for {uuid}{}", change.old_xp, change.new_xp, change.old_level, change.new_level, if reason.is_empty() { String::new() } else { format!(": {reason}") }); let detail = format!(
"{label} XP {} → {} (level {} → {}) for {uuid}{}",
change.old_xp,
change.new_xp,
change.old_level,
change.new_level,
if reason.is_empty() { String::new() } else { format!(": {reason}") }
);
crate::routes::activity::record(&state, &admin, "panel", "xp_adjust", Some(&detail)).await?; crate::routes::activity::record(&state, &admin, "panel", "xp_adjust", Some(&detail)).await?;
Ok(Json(player_detail(&state, &uuid).await?)) Ok(Json(player_detail(&state, &uuid).await?))
} }
+2 -1
View File
@@ -33,7 +33,8 @@ async fn live_map_ingest_status_and_proxy() {
let (_, hello) = t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({}))).await; let (_, hello) = t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({}))).await;
assert_eq!(hello["live_map"]["enabled"], false); assert_eq!(hello["live_map"]["enabled"], false);
let (s, v) = t.call("PUT", &format!("/api/admin/servers/{id}"), Some(&admin), Some(json!({"name": "Survival", "live_map_enabled": true}))).await; let (s, v) =
t.call("PUT", &format!("/api/admin/servers/{id}"), Some(&admin), Some(json!({"name": "Survival", "live_map_enabled": true}))).await;
assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["live_map_enabled"], true); assert_eq!(v["live_map_enabled"], true);
let (_, hello) = t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({}))).await; let (_, hello) = t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({}))).await;
+7 -1
View File
@@ -11,7 +11,13 @@ async fn player(t: &TestApp, admin: &str, name: &str) -> (String, String) {
} }
fn ids(v: &Value, period: &str) -> Vec<String> { fn ids(v: &Value, period: &str) -> Vec<String> {
let mut out: Vec<String> = v.as_array().unwrap().iter().filter(|q| q["quest"]["period"] == period).map(|q| q["quest"]["id"].as_str().unwrap().to_string()).collect(); let mut out: Vec<String> = v
.as_array()
.unwrap()
.iter()
.filter(|q| q["quest"]["period"] == period)
.map(|q| q["quest"]["id"].as_str().unwrap().to_string())
.collect();
out.sort(); out.sort();
out out
} }
+116
View File
@@ -0,0 +1,116 @@
// Achievement icon catalogue shared by the admin panel and the launcher.
//
// Achievements store *ids* (`icon_item: "diamond_sword"`, `icon_bg: "obsidian"`,
// `icon_border: "gold"`), not pictures, so every app has to turn them into
// something drawable. This file is that one translation. The launcher keeps an
// identical copy at launcher/src/lib/achievementIcons.ts — change both together.
export interface ItemIcon { id: string; label: string; emoji: string }
export const ITEM_ICONS: ItemIcon[] = [
{ id: 'diamond_pickaxe', label: 'Diamond Pickaxe', emoji: '⛏️' },
{ id: 'wooden_pickaxe', label: 'Wooden Pickaxe', emoji: '⛏️' },
{ id: 'diamond', label: 'Diamond', emoji: '💎' },
{ id: 'emerald', label: 'Emerald', emoji: '🟢' },
{ id: 'gold_ingot', label: 'Gold Ingot', emoji: '🪙' },
{ id: 'iron_sword', label: 'Iron Sword', emoji: '🗡️' },
{ id: 'diamond_sword', label: 'Diamond Sword', emoji: '⚔️' },
{ id: 'netherite_sword', label: 'Netherite Sword', emoji: '⚔️' },
{ id: 'netherite_axe', label: 'Netherite Axe', emoji: '🪓' },
{ id: 'bow', label: 'Bow', emoji: '🏹' },
{ id: 'trident', label: 'Trident', emoji: '🔱' },
{ id: 'shield', label: 'Shield', emoji: '🛡️' },
{ id: 'bricks', label: 'Bricks', emoji: '🧱' },
{ id: 'stone_bricks', label: 'Stone Bricks', emoji: '🧱' },
{ id: 'grass_block', label: 'Grass Block', emoji: '🟩' },
{ id: 'crafting_table', label: 'Crafting Table', emoji: '🪚' },
{ id: 'chest', label: 'Treasure Chest', emoji: '📦' },
{ id: 'beacon', label: 'Beacon', emoji: '✨' },
{ id: 'clock', label: 'Clock', emoji: '🕰️' },
{ id: 'compass', label: 'Compass', emoji: '🧭' },
{ id: 'map', label: 'Map', emoji: '🗺️' },
{ id: 'book', label: 'Book', emoji: '📖' },
{ id: 'enchanted_book', label: 'Enchanted Book', emoji: '📘' },
{ id: 'experience_bottle', label: 'Bottle o’ Enchanting', emoji: '🧪' },
{ id: 'golden_apple', label: 'Golden Apple', emoji: '🍏' },
{ id: 'cake', label: 'Cake', emoji: '🎂' },
{ id: 'elytra', label: 'Elytra', emoji: '🪽' },
{ id: 'ender_eye', label: 'Eye of Ender', emoji: '👁️' },
{ id: 'ender_pearl', label: 'Ender Pearl', emoji: '🔮' },
{ id: 'flint_and_steel', label: 'Flint and Steel', emoji: '🔥' },
{ id: 'blaze_rod', label: 'Blaze Rod', emoji: '🔥' },
{ id: 'heart_of_the_sea', label: 'Heart of the Sea', emoji: '💙' },
{ id: 'nether_star', label: 'Nether Star', emoji: '⭐' },
{ id: 'totem_of_undying', label: 'Totem of Undying', emoji: '🗿' },
{ id: 'dragon_egg', label: 'Dragon Egg', emoji: '🥚' },
{ id: 'dragon_head', label: 'Dragon Head', emoji: '🐉' },
{ id: 'skull', label: 'Skull', emoji: '💀' },
{ id: 'fishing_rod', label: 'Fishing Rod', emoji: '🎣' },
{ id: 'wheat', label: 'Wheat', emoji: '🌾' },
{ id: 'castle', label: 'Castle Flag', emoji: '🚩' },
{ id: 'trophy', label: 'Trophy', emoji: '🏆' },
];
export interface Backdrop { id: string; label: string; css: string }
export const BACKGROUNDS: Backdrop[] = [
{ id: 'stone', label: 'Stone', css: 'radial-gradient(circle, #6b7280 0%, #27272a 100%)' },
{ id: 'deepslate', label: 'Deepslate', css: 'radial-gradient(circle, #27272a 0%, #09090b 100%)' },
{ id: 'obsidian', label: 'Obsidian', css: 'radial-gradient(circle, #2e1065 0%, #0f172a 100%)' },
{ id: 'dirt', label: 'Dirt', css: 'radial-gradient(circle, #92400e 0%, #451a03 100%)' },
{ id: 'bedrock', label: 'Bedrock', css: 'radial-gradient(circle, #3f3f46 0%, #09090b 100%)' },
{ id: 'oak_planks', label: 'Oak Planks', css: 'radial-gradient(circle, #c08a4b 0%, #6b4423 100%)' },
{ id: 'netherrack', label: 'Netherrack', css: 'radial-gradient(circle, #9f1239 0%, #3b0a16 100%)' },
{ id: 'end_stone', label: 'End Stone', css: 'radial-gradient(circle, #e8e2a8 0%, #8a8456 100%)' },
{ id: 'gilded_blackstone', label: 'Gilded Blackstone', css: 'radial-gradient(circle, #a16207 0%, #1c1917 100%)' },
];
export interface Rim { id: string; label: string; color: string }
export const BORDERS: Rim[] = [
{ id: 'bronze', label: 'Bronze', color: '#b45309' },
{ id: 'silver', label: 'Silver', color: '#94a3b8' },
{ id: 'gold', label: 'Gold', color: '#eab308' },
{ id: 'diamond', label: 'Diamond', color: '#06b6d4' },
{ id: 'purple', label: 'Amethyst / Purple', color: '#c084fc' },
{ id: 'netherite', label: 'Netherite', color: '#475569' },
];
export const FRAMES = [
{ id: 'task', label: 'Task (Normal)', bannerTitle: 'Advancement Made!', bannerColor: '#55ff55' },
{ id: 'goal', label: 'Goal (Rounded)', bannerTitle: 'Goal Reached!', bannerColor: '#55ffff' },
{ id: 'challenge', label: 'Challenge (Spiked & Glowing)', bannerTitle: 'Challenge Complete!', bannerColor: '#ff55ff' },
] as const;
// Ids that aren't in the catalogue (added through the API, or from an older
// data set) still get a sensible picture from their name.
const KEYWORDS: [RegExp, string][] = [
[/pickaxe|shovel|hoe/, '⛏️'], [/axe/, '🪓'], [/sword|dagger/, '⚔️'], [/bow|arrow/, '🏹'], [/trident/, '🔱'],
[/shield/, '🛡️'], [/helmet|chestplate|leggings|boots|armor/, '🛡️'], [/apple|carrot|bread|food|steak/, '🍏'],
[/potion|bottle/, '🧪'], [/book/, '📖'], [/ore|ingot|nugget/, '🪙'], [/diamond|emerald|amethyst|gem/, '💎'],
[/star|beacon/, '✨'], [/egg/, '🥚'], [/head|skull/, '💀'], [/block|brick|stone|planks|log/, '🧱'],
[/fire|flame|blaze|lava/, '🔥'], [/fish|rod/, '🎣'], [/clock|watch/, '🕰️'], [/map|compass/, '🧭'],
];
const isCss = (v: string) => /^(#|rgb|hsl|linear-gradient|radial-gradient|var\()/i.test(v.trim());
const isId = (v: string) => /^[a-z0-9_]+$/.test(v);
export interface ResolvedIcon { emoji: string; background: string; borderColor: string; glow: string }
export function resolveIcon(a: { icon_item?: string | null; icon_bg?: string | null; icon_border?: string | null }): ResolvedIcon {
const item = (a.icon_item ?? '').trim();
let emoji = '🏆';
if (item) {
const known = ITEM_ICONS.find((i) => i.id === item);
if (known) emoji = known.emoji;
else if (!isId(item)) emoji = item; // already an emoji or symbol
else emoji = KEYWORDS.find(([re]) => re.test(item))?.[1] ?? '🏆';
}
const bgRaw = (a.icon_bg ?? '').trim();
const background = BACKGROUNDS.find((b) => b.id === bgRaw)?.css ?? (bgRaw && isCss(bgRaw) ? bgRaw : BACKGROUNDS[1].css);
const rimRaw = (a.icon_border ?? '').trim();
const borderColor = BORDERS.find((b) => b.id === rimRaw)?.color ?? (rimRaw && isCss(rimRaw) ? rimRaw : '#eab308');
return { emoji, background, borderColor, glow: `0 0 12px ${borderColor}99` };
}
+9 -58
View File
@@ -5,6 +5,7 @@
import { get, post, put, del } from '../lib/api'; import { get, post, put, del } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte'; import { toast, toastError } from '../lib/toast.svelte';
import type { Achievement } from '../lib/types'; import type { Achievement } from '../lib/types';
import { BACKGROUNDS, BORDERS, FRAMES, ITEM_ICONS, resolveIcon } from '../lib/achievementIcons';
let achievements = $state<Achievement[]>([]); let achievements = $state<Achievement[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -19,45 +20,10 @@
const categories = ['progression', 'mining', 'combat', 'building', 'social', 'guilds', 'exploration']; const categories = ['progression', 'mining', 'combat', 'building', 'social', 'guilds', 'exploration'];
const frameTypes = [ const frameTypes = FRAMES;
{ id: 'task', label: 'Task (Normal)', bannerTitle: 'Advancement Made!', bannerColor: '#55ff55' }, const bgTextures = BACKGROUNDS;
{ id: 'goal', label: 'Goal (Rounded)', bannerTitle: 'Goal Reached!', bannerColor: '#55ffff' }, const borderGlows = BORDERS;
{ id: 'challenge', label: 'Challenge (Spiked & Glowing)', bannerTitle: 'Challenge Complete!', bannerColor: '#ff55ff' }, const popularItems = ITEM_ICONS;
] as const;
const bgTextures = [
{ id: 'stone', label: 'Stone', color: '#52525b', pattern: 'radial-gradient(circle, #52525b 0%, #27272a 100%)' },
{ id: 'deepslate', label: 'Deepslate', color: '#18181b', pattern: 'radial-gradient(circle, #27272a 0%, #09090b 100%)' },
{ id: 'obsidian', label: 'Obsidian', color: '#1e1b4b', pattern: 'radial-gradient(circle, #2e1065 0%, #0f172a 100%)' },
{ id: 'dirt', label: 'Dirt', color: '#78350f', pattern: 'radial-gradient(circle, #92400e 0%, #451a03 100%)' },
{ id: 'bedrock', label: 'Bedrock', color: '#09090b', pattern: 'radial-gradient(circle, #3f3f46 0%, #09090b 100%)' },
] as const;
const borderGlows = [
{ id: 'bronze', label: 'Bronze', color: '#b45309', glow: '0 0 10px rgba(180, 83, 9, 0.6)' },
{ id: 'silver', label: 'Silver', color: '#94a3b8', glow: '0 0 10px rgba(148, 163, 184, 0.6)' },
{ id: 'gold', label: 'Gold', color: '#eab308', glow: '0 0 12px rgba(234, 179, 8, 0.7)' },
{ id: 'diamond', label: 'Diamond', color: '#06b6d4', glow: '0 0 14px rgba(6, 182, 212, 0.8)' },
{ id: 'purple', label: 'Amethyst / Purple', color: '#c084fc', glow: '0 0 14px rgba(192, 132, 252, 0.8)' },
{ id: 'netherite', label: 'Netherite', color: '#475569', glow: '0 0 10px rgba(71, 85, 105, 0.7)' },
] as const;
const popularItems = [
{ id: 'diamond_pickaxe', label: 'Diamond Pickaxe', emoji: '⛏️' },
{ id: 'netherite_sword', label: 'Netherite Sword', emoji: '⚔️' },
{ id: 'beacon', label: 'Beacon', emoji: '✨' },
{ id: 'golden_apple', label: 'Golden Apple', emoji: '🍏' },
{ id: 'elytra', label: 'Elytra', emoji: '🪽' },
{ id: 'ender_eye', label: 'Eye of Ender', emoji: '👁️' },
{ id: 'crafting_table', label: 'Crafting Table', emoji: '🪚' },
{ id: 'shield', label: 'Shield', emoji: '🛡️' },
{ id: 'heart_of_the_sea', label: 'Heart of the Sea', emoji: '💙' },
{ id: 'dragon_egg', label: 'Dragon Egg', emoji: '🥚' },
{ id: 'totem_of_undying', label: 'Totem of Undying', emoji: '🗿' },
{ id: 'nether_star', label: 'Nether Star', emoji: '⭐' },
{ id: 'chest', label: 'Treasure Chest', emoji: '📦' },
{ id: 'castle', label: 'Castle Flag', emoji: '🚩' },
];
async function loadAchievements() { async function loadAchievements() {
loading = true; loading = true;
@@ -155,25 +121,10 @@
} }
} }
function getItemEmoji(id?: string | null): string { const getItemEmoji = (id?: string | null) => resolveIcon({ icon_item: id }).emoji;
const item = popularItems.find((i) => i.id === id); const getBorderColor = (border?: string | null) => resolveIcon({ icon_border: border }).borderColor;
return item ? item.emoji : '🏆'; const getBorderGlow = (border?: string | null) => resolveIcon({ icon_border: border }).glow;
} const getBgPattern = (bg?: string | null) => resolveIcon({ icon_bg: bg }).background;
function getBorderColor(border?: string | null): string {
const b = borderGlows.find((bg) => bg.id === border);
return b ? b.color : '#eab308';
}
function getBorderGlow(border?: string | null): string {
const b = borderGlows.find((bg) => bg.id === border);
return b ? b.glow : '0 0 10px rgba(234, 179, 8, 0.6)';
}
function getBgPattern(bg?: string | null): string {
const b = bgTextures.find((t) => t.id === bg);
return b ? b.pattern : 'radial-gradient(circle, #27272a 0%, #09090b 100%)';
}
</script> </script>
<div class="page"> <div class="page">