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:
17 files changed
+500
-140
No files matched your search
@@ -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
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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` };
|
||||||
|
}
|
||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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?))
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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` };
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in new issue
Block a user