Admin Progression page: quest limits, XP curve and rates, per-player XP adjustments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
6 files changed
+481
-8
No files matched your search
@@ -17,6 +17,7 @@
|
|||||||
import QuestEditor from './pages/QuestEditor.svelte';
|
import QuestEditor from './pages/QuestEditor.svelte';
|
||||||
import AchievementEditor from './pages/AchievementEditor.svelte';
|
import AchievementEditor from './pages/AchievementEditor.svelte';
|
||||||
import LevelingAdmin from './pages/LevelingAdmin.svelte';
|
import LevelingAdmin from './pages/LevelingAdmin.svelte';
|
||||||
|
import Progression from './pages/Progression.svelte';
|
||||||
import GuildsAdmin from './pages/GuildsAdmin.svelte';
|
import GuildsAdmin from './pages/GuildsAdmin.svelte';
|
||||||
import { get } from './lib/api';
|
import { get } from './lib/api';
|
||||||
import { route } from './lib/router.svelte';
|
import { route } from './lib/router.svelte';
|
||||||
@@ -86,6 +87,8 @@
|
|||||||
<AchievementEditor />
|
<AchievementEditor />
|
||||||
{:else if route.name === 'leveling'}
|
{:else if route.name === 'leveling'}
|
||||||
<LevelingAdmin />
|
<LevelingAdmin />
|
||||||
|
{:else if route.name === 'progression'}
|
||||||
|
<Progression />
|
||||||
{:else if route.name === 'guilds'}
|
{:else if route.name === 'guilds'}
|
||||||
<GuildsAdmin />
|
<GuildsAdmin />
|
||||||
{:else if route.name === 'capes'}
|
{:else if route.name === 'capes'}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag, Globe, Target, Trophy, Sparkles, Shield } from '@lucide/svelte';
|
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag, Globe, Target, Trophy, Sparkles, Shield, SlidersHorizontal } from '@lucide/svelte';
|
||||||
import { route } from '../lib/router.svelte';
|
import { route } from '../lib/router.svelte';
|
||||||
import { logout, session } from '../lib/session.svelte';
|
import { logout, session } from '../lib/session.svelte';
|
||||||
import Avatar from './Avatar.svelte';
|
import Avatar from './Avatar.svelte';
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
{ id: 'quests', label: 'Quests', icon: Target },
|
{ id: 'quests', label: 'Quests', icon: Target },
|
||||||
{ id: 'achievements', label: 'Achievements', icon: Trophy },
|
{ id: 'achievements', label: 'Achievements', icon: Trophy },
|
||||||
{ id: 'leveling', label: 'Leveling & XP', icon: Sparkles },
|
{ id: 'leveling', label: 'Leveling & XP', icon: Sparkles },
|
||||||
|
{ id: 'progression', label: 'Progression', icon: SlidersHorizontal },
|
||||||
{ id: 'guilds', label: 'Guilds & Claims', icon: Shield },
|
{ id: 'guilds', label: 'Guilds & Claims', icon: Shield },
|
||||||
{ id: 'activity', label: 'Activity', icon: LayoutDashboard },
|
{ id: 'activity', label: 'Activity', icon: LayoutDashboard },
|
||||||
{ id: 'capes', label: 'Capes', icon: Flag },
|
{ id: 'capes', label: 'Capes', icon: Flag },
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export interface Quest {
|
|||||||
target_count: number;
|
target_count: number;
|
||||||
icon: string;
|
icon: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
pinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserQuest {
|
export interface UserQuest {
|
||||||
@@ -383,3 +384,32 @@ export interface UserProfileView {
|
|||||||
stats: PlayerStats | null;
|
stats: PlayerStats | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProgressionSettings {
|
||||||
|
daily_quest_limit: number;
|
||||||
|
weekly_quest_limit: number;
|
||||||
|
quest_rotation: 'per_player' | 'shared';
|
||||||
|
level_base: number;
|
||||||
|
level_exponent: number;
|
||||||
|
max_level: number;
|
||||||
|
global_xp_multiplier: number;
|
||||||
|
server_xp_multiplier: number;
|
||||||
|
xp_rates: { playtime_per_hour: number; player_kill: number; mob_kill: number; block_broken: number; block_placed: number; message: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressionView {
|
||||||
|
settings: ProgressionSettings;
|
||||||
|
quest_pool: { daily: { enabled: number; pinned: number }; weekly: { enabled: number; pinned: number } };
|
||||||
|
curve: { level: number; xp: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerProgress {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
title: string | null;
|
||||||
|
global_xp: number;
|
||||||
|
global_level: number;
|
||||||
|
level_xp: number;
|
||||||
|
level_span: number;
|
||||||
|
progress_pct: number;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
let rewards = $state<LevelReward[]>([]);
|
let rewards = $state<LevelReward[]>([]);
|
||||||
let leaderboard = $state<UserLevelInfo[]>([]);
|
let leaderboard = $state<UserLevelInfo[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let curve = $state({ base: 100, exponent: 1.5, max: 0 });
|
||||||
|
|
||||||
let draft = $state<Partial<LevelReward> | null>(null);
|
let draft = $state<Partial<LevelReward> | null>(null);
|
||||||
let draftOpen = $state(false);
|
let draftOpen = $state(false);
|
||||||
@@ -39,6 +40,8 @@
|
|||||||
icon: x.icon ?? '🎁',
|
icon: x.icon ?? '🎁',
|
||||||
})).sort((a, b) => a.level - b.level);
|
})).sort((a, b) => a.level - b.level);
|
||||||
leaderboard = lb || [];
|
leaderboard = lb || [];
|
||||||
|
const p = await get<{ settings: { level_base: number; level_exponent: number; max_level: number } }>('/api/admin/progression');
|
||||||
|
curve = { base: p.settings.level_base, exponent: p.settings.level_exponent, max: p.settings.max_level };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -103,7 +106,7 @@
|
|||||||
|
|
||||||
function calcXp(lvl: number): number {
|
function calcXp(lvl: number): number {
|
||||||
if (lvl <= 1) return 0;
|
if (lvl <= 1) return 0;
|
||||||
return Math.round(Math.pow(lvl, 1.5) * 100);
|
return Math.round(Math.pow(lvl, curve.exponent) * curve.base);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -166,7 +169,8 @@
|
|||||||
<p class="formula-desc">
|
<p class="formula-desc">
|
||||||
Total XP required for level <em>L</em> is given by:
|
Total XP required for level <em>L</em> is given by:
|
||||||
</p>
|
</p>
|
||||||
<code class="formula-box">XP = round(L ^ 1.5 × 100)</code>
|
<code class="formula-box">XP = round(L ^ {curve.exponent} × {curve.base})</code>
|
||||||
|
<p class="formula-desc">{curve.max ? `Level cap: ${curve.max}.` : 'No level cap.'} <a href="#/progression">Tune the curve →</a></p>
|
||||||
<ul class="curve-points">
|
<ul class="curve-points">
|
||||||
<li><span>Level 5:</span> <strong>{calcXp(5).toLocaleString()} XP</strong></li>
|
<li><span>Level 5:</span> <strong>{calcXp(5).toLocaleString()} XP</strong></li>
|
||||||
<li><span>Level 10:</span> <strong>{calcXp(10).toLocaleString()} XP</strong></li>
|
<li><span>Level 10:</span> <strong>{calcXp(10).toLocaleString()} XP</strong></li>
|
||||||
@@ -186,10 +190,10 @@
|
|||||||
<div class="lb-item">
|
<div class="lb-item">
|
||||||
<span class="lb-rank">#{i + 1}</span>
|
<span class="lb-rank">#{i + 1}</span>
|
||||||
<div class="lb-info">
|
<div class="lb-info">
|
||||||
<strong>UUID: {p.uuid.slice(0, 8)}...</strong>
|
<strong>{(p as any).username ?? `UUID: ${p.uuid.slice(0, 8)}…`}</strong>
|
||||||
<span>{p.current_xp.toLocaleString()} XP</span>
|
<span>{Number((p as any).global_xp ?? p.current_xp ?? 0).toLocaleString()} XP</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="lb-lvl">Lvl {p.level}</span>
|
<span class="lb-lvl">Lvl {(p as any).global_level ?? p.level}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { Target, Sparkles, Users, Save, RotateCcw, Search, Shuffle, Shield, Pin, TrendingUp, Plus, Minus, Equal, ArrowUpToLine, Eraser, Info } from '@lucide/svelte';
|
||||||
|
import Avatar from '../components/Avatar.svelte';
|
||||||
|
import Modal from '../components/Modal.svelte';
|
||||||
|
import { get, post, put } from '../lib/api';
|
||||||
|
import { toast, toastError } from '../lib/toast.svelte';
|
||||||
|
import type { PlayerProgress, ProgressionSettings, ProgressionView } from '../lib/types';
|
||||||
|
|
||||||
|
const DEFAULTS: ProgressionSettings = {
|
||||||
|
daily_quest_limit: 5,
|
||||||
|
weekly_quest_limit: 3,
|
||||||
|
quest_rotation: 'per_player',
|
||||||
|
level_base: 100,
|
||||||
|
level_exponent: 1.5,
|
||||||
|
max_level: 0,
|
||||||
|
global_xp_multiplier: 1,
|
||||||
|
server_xp_multiplier: 1,
|
||||||
|
xp_rates: { playtime_per_hour: 600, player_kill: 50, mob_kill: 15, block_broken: 0.2, block_placed: 0.2, message: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
let tab = $state<'quests' | 'xp' | 'players'>('quests');
|
||||||
|
let saved = $state<ProgressionSettings | null>(null);
|
||||||
|
let s = $state<ProgressionSettings>(structuredClone(DEFAULTS));
|
||||||
|
let pool = $state({ daily: { enabled: 0, pinned: 0 }, weekly: { enabled: 0, pinned: 0 } });
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
const dirty = $derived(saved != null && JSON.stringify(saved) !== JSON.stringify(s));
|
||||||
|
const curveChanged = $derived(saved != null && (saved.level_base !== s.level_base || saved.level_exponent !== s.level_exponent || saved.max_level !== s.max_level));
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const v = await get<ProgressionView>('/api/admin/progression');
|
||||||
|
saved = structuredClone(v.settings);
|
||||||
|
s = structuredClone(v.settings);
|
||||||
|
pool = v.quest_pool;
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const v = await put<ProgressionView & { recalculated: number }>('/api/admin/progression', s);
|
||||||
|
saved = structuredClone(v.settings);
|
||||||
|
s = structuredClone(v.settings);
|
||||||
|
pool = v.quest_pool;
|
||||||
|
toast(v.recalculated ? `Saved — ${v.recalculated} player levels recalculated` : 'Progression settings saved');
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revert = () => saved && (s = structuredClone(saved));
|
||||||
|
|
||||||
|
// ---- curve helpers (mirror the server's maths for live previews) ----
|
||||||
|
const xpFor = (lvl: number, base = s.level_base, exp = s.level_exponent) => (lvl <= 1 ? 0 : Math.round(base * Math.pow(lvl, exp)));
|
||||||
|
function levelFrom(xp: number): number {
|
||||||
|
const cap = s.max_level > 0 ? s.max_level : 10000;
|
||||||
|
let l = 1;
|
||||||
|
while (l < cap && xp >= xpFor(l + 1)) l++;
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
const sampleLevels = $derived([5, 10, 25, 50, 100].filter((l) => !s.max_level || l <= s.max_level));
|
||||||
|
const chart = $derived.by(() => {
|
||||||
|
const top = Math.min(s.max_level > 0 ? s.max_level : 60, 60);
|
||||||
|
const pts = Array.from({ length: top - 1 }, (_, i) => ({ l: i + 2, xp: xpFor(i + 2) }));
|
||||||
|
const maxXp = Math.max(1, ...pts.map((p) => p.xp));
|
||||||
|
const path = pts.map((p, i) => `${i ? 'L' : 'M'}${(((p.l - 2) / Math.max(1, top - 2)) * 280 + 10).toFixed(1)},${(100 - (p.xp / maxXp) * 88).toFixed(1)}`).join(' ');
|
||||||
|
return { path, top, maxXp };
|
||||||
|
});
|
||||||
|
|
||||||
|
const n = (v: number) => Math.round(v).toLocaleString();
|
||||||
|
const per = (r: number) => (r > 0 && r < 1 ? `1 XP per ${Math.round(1 / r)}` : `${r} XP each`);
|
||||||
|
|
||||||
|
const quests = $derived([
|
||||||
|
{ key: 'daily_quest_limit' as const, label: 'Daily quests', hint: 'Reset every day at 00:00 UTC', pool: pool.daily, icon: Target },
|
||||||
|
{ key: 'weekly_quest_limit' as const, label: 'Weekly quests', hint: 'Reset every Monday at 00:00 UTC', pool: pool.weekly, icon: TrendingUp },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---- players ----
|
||||||
|
let q = $state('');
|
||||||
|
let players = $state<PlayerProgress[]>([]);
|
||||||
|
let total = $state(0);
|
||||||
|
let loadingPlayers = $state(false);
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
async function loadPlayers() {
|
||||||
|
loadingPlayers = true;
|
||||||
|
try {
|
||||||
|
const r = await get<{ players: PlayerProgress[]; total: number }>(`/api/admin/progression/players?q=${encodeURIComponent(q)}&limit=40`);
|
||||||
|
players = r.players;
|
||||||
|
total = r.total;
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
loadingPlayers = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
void q;
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => tab === 'players' && loadPlayers(), 250);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
$effect(() => { if (tab === 'players' && !players.length) loadPlayers(); });
|
||||||
|
|
||||||
|
type Detail = {
|
||||||
|
uuid: string; name: string; title: string | null; max_level: number;
|
||||||
|
global: { xp: number; level: number; level_xp: number; level_span: number; progress_pct: number };
|
||||||
|
servers: { server_id: number; server_name: string; xp: number; level: number; progress_pct: number; rank_name: string | null }[];
|
||||||
|
};
|
||||||
|
let detail = $state<Detail | null>(null);
|
||||||
|
let open = $state(false);
|
||||||
|
let scope = $state('global');
|
||||||
|
let mode = $state<'add' | 'remove' | 'set_xp' | 'set_level' | 'reset'>('add');
|
||||||
|
let amount = $state(500);
|
||||||
|
let reason = $state('');
|
||||||
|
let applying = $state(false);
|
||||||
|
|
||||||
|
async function inspect(p: PlayerProgress) {
|
||||||
|
try {
|
||||||
|
detail = await get<Detail>(`/api/admin/progression/players/${p.uuid}`);
|
||||||
|
scope = 'global';
|
||||||
|
mode = 'add';
|
||||||
|
amount = 500;
|
||||||
|
reason = '';
|
||||||
|
open = true;
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentXp = $derived(!detail ? 0 : scope === 'global' ? detail.global.xp : detail.servers.find((x) => String(x.server_id) === scope)?.xp ?? 0);
|
||||||
|
const nextXp = $derived(
|
||||||
|
mode === 'add' ? currentXp + amount
|
||||||
|
: mode === 'remove' ? Math.max(0, currentXp - amount)
|
||||||
|
: mode === 'set_xp' ? amount
|
||||||
|
: mode === 'set_level' ? xpFor(amount)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
if (!detail) return;
|
||||||
|
applying = true;
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = { scope: scope === 'global' ? 'global' : 'server', mode, amount: mode === 'reset' ? 0 : Number(amount), reason };
|
||||||
|
if (scope !== 'global') body.server_id = Number(scope);
|
||||||
|
detail = await post<Detail>(`/api/admin/progression/players/${detail.uuid}/adjust`, body);
|
||||||
|
toast('XP updated');
|
||||||
|
loadPlayers();
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
applying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modes = [
|
||||||
|
{ id: 'add', label: 'Give XP', icon: Plus },
|
||||||
|
{ id: 'remove', label: 'Take XP', icon: Minus },
|
||||||
|
{ id: 'set_xp', label: 'Set XP', icon: Equal },
|
||||||
|
{ id: 'set_level', label: 'Set level', icon: ArrowUpToLine },
|
||||||
|
{ id: 'reset', label: 'Reset', icon: Eraser },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Progression</h1>
|
||||||
|
<p>Decide how many quests players get, how fast they earn XP, how steep levels are — and fix any player's XP by hand.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs" role="tablist">
|
||||||
|
<button role="tab" aria-selected={tab === 'quests'} class:on={tab === 'quests'} onclick={() => (tab = 'quests')}><Target size={15} /> Quest limits</button>
|
||||||
|
<button role="tab" aria-selected={tab === 'xp'} class:on={tab === 'xp'} onclick={() => (tab = 'xp')}><Sparkles size={15} /> XP & levels</button>
|
||||||
|
<button role="tab" aria-selected={tab === 'players'} class:on={tab === 'players'} onclick={() => (tab = 'players')}><Users size={15} /> Players</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tab === 'quests'}
|
||||||
|
<div class="cols">
|
||||||
|
{#each quests as c (c.key)}
|
||||||
|
<section class="card quota">
|
||||||
|
<div class="q-head">
|
||||||
|
<span class="ic"><c.icon size={18} /></span>
|
||||||
|
<div><h2>{c.label}</h2><p class="muted small">{c.hint}</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="big">
|
||||||
|
<button class="step" aria-label="Fewer" onclick={() => (s[c.key] = Math.max(0, s[c.key] - 1))}><Minus size={16} /></button>
|
||||||
|
<div class="num">
|
||||||
|
<input type="number" min="0" max="100" bind:value={s[c.key]} aria-label={c.label + ' per player'} />
|
||||||
|
<span class="muted small">{s[c.key] === 0 ? 'every quest' : s[c.key] === 1 ? 'quest per player' : 'quests per player'}</span>
|
||||||
|
</div>
|
||||||
|
<button class="step" aria-label="More" onclick={() => (s[c.key] = Math.min(100, s[c.key] + 1))}><Plus size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<input class="range" type="range" min="0" max={Math.max(20, Math.min(100, c.pool.enabled))} bind:value={s[c.key]} aria-label="Limit slider" />
|
||||||
|
<div class="pool">
|
||||||
|
<span><strong>{c.pool.enabled}</strong> enabled in the pool</span>
|
||||||
|
<span class="pin"><Pin size={12} /> {c.pool.pinned} pinned (always included)</span>
|
||||||
|
</div>
|
||||||
|
{#if s[c.key] > 0 && s[c.key] < c.pool.pinned}
|
||||||
|
<p class="warn small"><Info size={13} /> Only {s[c.key]} of the {c.pool.pinned} pinned quests fit — unpin some or raise the limit.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-title"><h2><Shuffle size={16} /> Rotation</h2></div>
|
||||||
|
<div class="choice">
|
||||||
|
<button class="opt" class:on={s.quest_rotation === 'per_player'} onclick={() => (s.quest_rotation = 'per_player')}>
|
||||||
|
<Users size={17} /><span><strong>Per player</strong><small>Everyone draws their own random set each day/week.</small></span>
|
||||||
|
</button>
|
||||||
|
<button class="opt" class:on={s.quest_rotation === 'shared'} onclick={() => (s.quest_rotation = 'shared')}>
|
||||||
|
<Shield size={17} /><span><strong>Shared</strong><small>Everyone gets the same set — good for community goals.</small></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted small note">
|
||||||
|
A player's set is fixed the first time they open their quests in a period, so edits here never swap out quests they've started.
|
||||||
|
Raising a limit adds quests, lowering it trims the list. Pin quests on the <a href="#/quests">Quests</a> page to always include them.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{:else if tab === 'xp'}
|
||||||
|
<div class="cols">
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-title"><h2><TrendingUp size={16} /> Level curve</h2></div>
|
||||||
|
<p class="muted small">XP needed for level <em>L</em> is <code>round(base × L<sup>exponent</sup>)</code>.</p>
|
||||||
|
<div class="fields3">
|
||||||
|
<label class="field">Base<input type="number" min="10" max="100000" step="10" bind:value={s.level_base} /></label>
|
||||||
|
<label class="field">Exponent<input type="number" min="1" max="3" step="0.05" bind:value={s.level_exponent} /></label>
|
||||||
|
<label class="field">Max level<input type="number" min="0" max="10000" bind:value={s.max_level} /><span class="tiny muted">0 = no cap</span></label>
|
||||||
|
</div>
|
||||||
|
<svg class="chart" viewBox="0 0 300 110" role="img" aria-label="XP required per level">
|
||||||
|
<path d="M10,100 H290" class="axis" />
|
||||||
|
<path d={chart.path} class="line" />
|
||||||
|
</svg>
|
||||||
|
<div class="chart-legend tiny muted"><span>Level 2</span><span>Level {chart.top} · {n(chart.maxXp)} XP</span></div>
|
||||||
|
<ul class="samples">
|
||||||
|
{#each sampleLevels as l}<li><span class="muted">Level {l}</span><strong>{n(xpFor(l))} XP</strong></li>{/each}
|
||||||
|
</ul>
|
||||||
|
{#if curveChanged}<p class="warn small"><Info size={13} /> Saving recalculates every player's stored level from their XP. Nobody loses XP.</p>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-title"><h2><Sparkles size={16} /> XP earned from play</h2></div>
|
||||||
|
<div class="rates">
|
||||||
|
<label class="field"><span class="lab">Online time <span class="unit">XP / hour</span></span><input type="number" min="0" step="10" bind:value={s.xp_rates.playtime_per_hour} /></label>
|
||||||
|
<label class="field"><span class="lab">Player kill <span class="unit">XP</span></span><input type="number" min="0" step="1" bind:value={s.xp_rates.player_kill} /></label>
|
||||||
|
<label class="field"><span class="lab">Mob kill <span class="unit">XP</span></span><input type="number" min="0" step="1" bind:value={s.xp_rates.mob_kill} /></label>
|
||||||
|
<label class="field"><span class="lab">Block broken <span class="unit">{per(s.xp_rates.block_broken)}</span></span><input type="number" min="0" step="0.05" bind:value={s.xp_rates.block_broken} /></label>
|
||||||
|
<label class="field"><span class="lab">Block placed <span class="unit">{per(s.xp_rates.block_placed)}</span></span><input type="number" min="0" step="0.05" bind:value={s.xp_rates.block_placed} /></label>
|
||||||
|
<label class="field"><span class="lab">Chat message <span class="unit">XP</span></span><input type="number" min="0" step="1" bind:value={s.xp_rates.message} /></label>
|
||||||
|
</div>
|
||||||
|
<div class="section-title mult"><h2>Multipliers</h2></div>
|
||||||
|
<div class="fields2">
|
||||||
|
<label class="field">Global XP ×<input type="number" min="0" max="100" step="0.1" bind:value={s.global_xp_multiplier} /></label>
|
||||||
|
<label class="field">Server XP ×<input type="number" min="0" max="100" step="0.1" bind:value={s.server_xp_multiplier} /></label>
|
||||||
|
</div>
|
||||||
|
<p class="muted small">These stack with the multipliers in each server's plugin/mod config (server levels default to 1.5× there). Quest and achievement XP is set per quest/achievement.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<section class="card">
|
||||||
|
<div class="search"><Search size={16} /><input type="search" placeholder="Search players by name…" bind:value={q} /><span class="muted small">{total.toLocaleString()} players</span></div>
|
||||||
|
{#if loadingPlayers && !players.length}
|
||||||
|
<div class="skeleton"></div>
|
||||||
|
{:else if !players.length}
|
||||||
|
<p class="muted">No players match.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="plist">
|
||||||
|
{#each players as p (p.uuid)}
|
||||||
|
<button class="prow" onclick={() => inspect(p)}>
|
||||||
|
<Avatar name={p.name} uuid={p.uuid} size={34} />
|
||||||
|
<span class="pname"><strong>{p.name}</strong>{#if p.title}<small class="muted">{p.title}</small>{/if}</span>
|
||||||
|
<span class="pbar"><i style:width="{p.progress_pct}%"></i></span>
|
||||||
|
<span class="plvl">Lv {p.global_level}</span>
|
||||||
|
<span class="pxp muted">{n(p.global_xp)} XP</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if dirty}
|
||||||
|
<div class="savebar" role="status">
|
||||||
|
<span>You have unsaved progression changes.</span>
|
||||||
|
<button class="ghost" onclick={revert}><RotateCcw size={15} /> Discard</button>
|
||||||
|
<button class="primary" disabled={saving} onclick={save}><Save size={15} /> {saving ? 'Saving…' : 'Save changes'}</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Modal bind:open title={detail ? `Adjust ${detail.name}` : 'Adjust XP'} width={560}>
|
||||||
|
{#if detail}
|
||||||
|
<div class="who">
|
||||||
|
<Avatar name={detail.name} uuid={detail.uuid} size={44} />
|
||||||
|
<div>
|
||||||
|
<strong>{detail.name}</strong>
|
||||||
|
<span class="muted small">Global level {detail.global.level} · {n(detail.global.xp)} XP</span>
|
||||||
|
<div class="pbar wide"><i style:width="{detail.global.progress_pct}%"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">Level track
|
||||||
|
<select bind:value={scope}>
|
||||||
|
<option value="global">Global level (all servers)</option>
|
||||||
|
{#each detail.servers as sv}<option value={String(sv.server_id)}>{sv.server_name} — level {sv.level} · {n(sv.xp)} XP</option>{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="modes" role="tablist">
|
||||||
|
{#each modes as m}
|
||||||
|
<button role="tab" aria-selected={mode === m.id} class="mode" class:on={mode === m.id} onclick={() => (mode = m.id)}><m.icon size={14} /> {m.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if mode !== 'reset'}
|
||||||
|
<label class="field">{mode === 'set_level' ? 'Level' : 'XP amount'}
|
||||||
|
<input type="number" min={mode === 'set_level' ? 1 : 0} bind:value={amount} />
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<label class="field">Reason <span class="tiny muted">(kept in the activity log)</span><input maxlength="120" bind:value={reason} placeholder="e.g. event prize, rollback after bug" /></label>
|
||||||
|
|
||||||
|
<div class="preview">
|
||||||
|
<span>{n(currentXp)} XP <small class="muted">(level {levelFrom(currentXp)})</small></span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<strong>{n(nextXp)} XP <small>(level {levelFrom(nextXp)})</small></strong>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#snippet footer()}
|
||||||
|
<button class="ghost" onclick={() => (open = false)}>Close</button>
|
||||||
|
<button class={mode === 'reset' || mode === 'remove' ? 'danger' : 'primary'} disabled={applying || (mode !== 'reset' && !(amount >= (mode === 'set_level' ? 1 : 0)))} onclick={apply}>
|
||||||
|
{applying ? 'Applying…' : 'Apply'}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page { padding-bottom: 90px; }
|
||||||
|
.tabs { display: inline-flex; gap: 4px; padding: 4px; border-radius: 12px; background: var(--bg-2); margin-bottom: 18px; }
|
||||||
|
.tabs button { border: 0; background: transparent; padding: 8px 16px; border-radius: 9px; color: var(--muted); gap: 8px; }
|
||||||
|
.tabs button.on { background: var(--accent-soft); color: var(--text); }
|
||||||
|
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 16px; margin-bottom: 16px; align-items: start; }
|
||||||
|
.quota { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.q-head { display: flex; gap: 12px; align-items: center; }
|
||||||
|
.q-head h2 { margin: 0; font-size: 1.05rem; }
|
||||||
|
.q-head p { margin: 2px 0 0; }
|
||||||
|
.ic { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-2); }
|
||||||
|
.big { display: flex; align-items: center; justify-content: center; gap: 18px; padding: 6px 0; }
|
||||||
|
.step { width: 40px; height: 40px; padding: 0; border-radius: 50%; }
|
||||||
|
.num { display: flex; flex-direction: column; align-items: center; min-width: 120px; }
|
||||||
|
.num input { width: 100px; text-align: center; font-size: 2.6rem; font-weight: 700; letter-spacing: -0.03em; background: transparent; border: 0; padding: 0; font-variant-numeric: tabular-nums; -moz-appearance: textfield; appearance: textfield; }
|
||||||
|
.num input::-webkit-outer-spin-button, .num input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||||
|
.range { width: 100%; accent-color: var(--accent); }
|
||||||
|
.pool { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; font-size: 0.85rem; color: var(--text-2); padding-top: 10px; border-top: 1px solid var(--line); }
|
||||||
|
.pin { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); }
|
||||||
|
.warn { display: flex; gap: 7px; align-items: center; color: #fcd34d; margin: 0; }
|
||||||
|
.choice { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; margin: 12px 0; }
|
||||||
|
.opt { justify-content: flex-start; gap: 12px; text-align: left; white-space: normal; padding: 12px 14px; background: var(--bg-2); }
|
||||||
|
.opt span { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.opt small { color: var(--muted); font-weight: 400; }
|
||||||
|
.opt.on { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: var(--accent-soft); }
|
||||||
|
.note { line-height: 1.55; margin: 0; }
|
||||||
|
.fields3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 12px 0; }
|
||||||
|
.fields2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin: 10px 0; }
|
||||||
|
.rates { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 12px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
|
||||||
|
.lab { display: flex; justify-content: space-between; gap: 8px; }
|
||||||
|
.unit { font-weight: 400; color: var(--muted); font-size: 0.75rem; }
|
||||||
|
.mult { margin-top: 18px; }
|
||||||
|
.chart { width: 100%; height: 110px; margin-top: 6px; }
|
||||||
|
.axis { stroke: var(--line); stroke-width: 1; fill: none; }
|
||||||
|
.line { stroke: var(--accent-2); stroke-width: 2.2; fill: none; stroke-linejoin: round; stroke-linecap: round; }
|
||||||
|
.chart-legend { display: flex; justify-content: space-between; }
|
||||||
|
.samples { list-style: none; margin: 12px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; }
|
||||||
|
.samples li { display: flex; flex-direction: column; padding: 9px 12px; border-radius: 10px; background: var(--bg-2); font-size: 0.85rem; }
|
||||||
|
.search { display: flex; align-items: center; gap: 10px; padding: 0 12px; border-radius: 12px; background: var(--bg-2); margin-bottom: 14px; }
|
||||||
|
.search input { border: 0; background: transparent; }
|
||||||
|
.search span { white-space: nowrap; }
|
||||||
|
.plist { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.prow { display: grid; grid-template-columns: 34px minmax(120px, 1.3fr) minmax(80px, 2fr) 56px 100px; align-items: center; gap: 14px; padding: 9px 12px; border-radius: 12px; background: transparent; border-color: transparent; text-align: left; }
|
||||||
|
.prow:hover { background: var(--bg-2); }
|
||||||
|
.pname { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.pname small { font-size: 0.75rem; }
|
||||||
|
.pbar { height: 6px; border-radius: 99px; background: var(--bg-2); overflow: hidden; }
|
||||||
|
.pbar.wide { margin-top: 6px; width: 260px; max-width: 100%; }
|
||||||
|
.pbar i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--accent-2)); }
|
||||||
|
.plvl { font-weight: 700; color: #fcd34d; }
|
||||||
|
.pxp { text-align: right; font-variant-numeric: tabular-nums; font-size: 0.85rem; }
|
||||||
|
.skeleton { height: 220px; border-radius: 12px; background: var(--bg-2); }
|
||||||
|
.who { display: flex; gap: 14px; align-items: center; }
|
||||||
|
.who div { display: flex; flex-direction: column; }
|
||||||
|
.modes { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.mode { padding: 7px 12px; border-radius: 99px; font-size: 0.83rem; background: var(--bg-2); gap: 6px; }
|
||||||
|
.mode.on { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: var(--accent-soft); }
|
||||||
|
.preview { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 14px; border-radius: 12px; background: var(--bg-2); font-variant-numeric: tabular-nums; }
|
||||||
|
.preview strong { color: var(--accent-2); }
|
||||||
|
.arrow { color: var(--muted); }
|
||||||
|
.savebar { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); z-index: 40; display: flex; align-items: center; gap: 12px; padding: 10px 12px 10px 18px; border-radius: 14px; background: var(--surface); border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); box-shadow: 0 14px 40px -12px rgba(0, 0, 0, 0.6); animation: rise 0.2s ease; }
|
||||||
|
@keyframes rise { from { opacity: 0; transform: translate(-50%, 10px); } }
|
||||||
|
@media (max-width: 720px) { .fields3, .rates { grid-template-columns: 1fr; } .prow { grid-template-columns: 34px 1fr 56px; } .pbar, .pxp { display: none; } .savebar { left: 12px; right: 12px; transform: none; flex-wrap: wrap; } }
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Sparkles, Pickaxe, Swords, Blocks, Wheat, Users, Compass, Target } from '@lucide/svelte';
|
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Sparkles, Pin, SlidersHorizontal, Pickaxe, Swords, Blocks, Wheat, Users, Compass, Target } from '@lucide/svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
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';
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
target_count: q.target_count || 1,
|
target_count: q.target_count || 1,
|
||||||
icon: q.icon || 'pickaxe',
|
icon: q.icon || 'pickaxe',
|
||||||
active: q.active !== undefined ? q.active : (q.enabled !== undefined ? q.enabled : true),
|
active: q.active !== undefined ? q.active : (q.enabled !== undefined ? q.enabled : true),
|
||||||
|
pinned: !!q.pinned,
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
@@ -67,8 +68,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let limits = $state<{ daily_quest_limit: number; weekly_quest_limit: number; quest_rotation: string } | null>(null);
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadQuests();
|
loadQuests();
|
||||||
|
get<{ settings: NonNullable<typeof limits> }>('/api/admin/progression').then((v) => (limits = v.settings)).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
let filtered = $derived(
|
let filtered = $derived(
|
||||||
@@ -99,6 +102,7 @@
|
|||||||
target_count: 50,
|
target_count: 50,
|
||||||
icon: 'pickaxe',
|
icon: 'pickaxe',
|
||||||
active: true,
|
active: true,
|
||||||
|
pinned: false,
|
||||||
};
|
};
|
||||||
draftOpen = true;
|
draftOpen = true;
|
||||||
}
|
}
|
||||||
@@ -156,6 +160,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{#if limits}
|
||||||
|
<a class="limit-banner" href="#/progression">
|
||||||
|
<SlidersHorizontal size={16} />
|
||||||
|
<span>
|
||||||
|
Each player gets <strong>{limits.daily_quest_limit || 'every'}</strong> of {dailyCount} daily and
|
||||||
|
<strong>{limits.weekly_quest_limit || 'every'}</strong> of {weeklyCount} weekly quests
|
||||||
|
({limits.quest_rotation === 'shared' ? 'same set for everyone' : 'random per player'}).
|
||||||
|
</span>
|
||||||
|
<span class="edit">Change limits →</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span class="label">Total Quests</span>
|
<span class="label">Total Quests</span>
|
||||||
@@ -224,7 +240,7 @@
|
|||||||
<span class="quest-icon" title={q.icon}><Icon size={21} /></span>
|
<span class="quest-icon" title={q.icon}><Icon size={21} /></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="info-cell">
|
<td class="info-cell">
|
||||||
<strong>{q.title}</strong>
|
<strong>{q.title}{#if q.pinned}<span class="pin-tag" title="Always handed out first"><Pin size={11} /> Pinned</span>{/if}</strong>
|
||||||
<p>{q.description}</p>
|
<p>{q.description}</p>
|
||||||
<code class="id-tag">{q.id}</code>
|
<code class="id-tag">{q.id}</code>
|
||||||
</td>
|
</td>
|
||||||
@@ -340,6 +356,12 @@
|
|||||||
Active (players can receive and complete this quest)
|
Active (players can receive and complete this quest)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" bind:checked={draft.pinned} />
|
||||||
|
Pinned — always included when a player's quests are limited
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
|
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
|
||||||
@@ -367,6 +389,10 @@
|
|||||||
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 6px; }
|
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 6px; }
|
||||||
header p { color: var(--muted); margin: 0; font-size: 0.95rem; }
|
header p { color: var(--muted); margin: 0; font-size: 0.95rem; }
|
||||||
|
|
||||||
|
.limit-banner { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 9%, var(--bg-2)); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); color: var(--text); font-size: 0.9rem; }
|
||||||
|
.limit-banner:hover { border-color: color-mix(in srgb, var(--accent) 60%, transparent); }
|
||||||
|
.limit-banner .edit { margin-left: auto; color: var(--accent-2); white-space: nowrap; }
|
||||||
|
.pin-tag { display: inline-flex; align-items: center; gap: 3px; margin-left: 8px; padding: 1px 7px; border-radius: 99px; font-size: 0.68rem; font-weight: 600; background: color-mix(in srgb, #fcd34d 16%, transparent); color: #fcd34d; vertical-align: middle; }
|
||||||
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
|
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
|
||||||
.stat-card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; display: flex; flex-direction: column; gap: 4px; }
|
.stat-card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
.stat-card .label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
|
.stat-card .label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
|
||||||
|
|||||||
Reference in new issue
Block a user