diff --git a/.env.example b/.env.example index cb6ad2e..12e9002 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,8 @@ SERVER_TYPE=FABRIC MC_MEMORY=6G MC_CONTAINER_MEM_LIMIT=7g MC_PORT=25565 +# BlueMap web map port (used when the bluemap mod is installed) +MAP_PORT=8100 MC_MOTD=A cozy server for friends MC_DIFFICULTY=normal diff --git a/dashboard/src/app/(panel)/mods/page.tsx b/dashboard/src/app/(panel)/mods/page.tsx index 1a3b60d..4b7982b 100644 --- a/dashboard/src/app/(panel)/mods/page.tsx +++ b/dashboard/src/app/(panel)/mods/page.tsx @@ -35,6 +35,32 @@ const PROTECTED = new Set(["fabric-api", "easyauth"]); // by the base slug. const baseSlug = (entry: string) => entry.split(":")[0].toLowerCase(); +// Server-side-only picks that make an offline friends server nicer — no +// client installs needed. All verified compatible with Fabric 26.2. +const FEATURED = [ + { + slug: "bluemap", + emoji: "🗺", + title: "BlueMap", + description: + "Explorable 3D web map of your world, like Google Maps for the server. After installing, the map appears on the Overview page.", + }, + { + slug: "skinrestorer", + emoji: "🎭", + title: "SkinRestorer", + description: + "Offline-mode servers show everyone as Steve — this restores real skins by username, TLauncher players included.", + }, + { + slug: "chunky", + emoji: "⚡", + title: "Chunky", + description: + "Pre-generates the world around spawn so nobody lags exploring new chunks. Run `chunky radius 2000` then `chunky start` in Console.", + }, +]; + export default function ModsPage() { const { data, refresh } = useApi("/api/mods", 0); const [query, setQuery] = useState(""); @@ -102,6 +128,33 @@ export default function ModsPage() { + {/* featured picks */} + +
+ {FEATURED.map((f) => { + const already = managedSet.has(f.slug); + return ( +
+
+ {f.emoji} + {f.title} +
+

{f.description}

+ +
+ ); + })} +
+
+
{/* Modrinth search */} diff --git a/dashboard/src/app/(panel)/page.tsx b/dashboard/src/app/(panel)/page.tsx index 86e26dd..68959f4 100644 --- a/dashboard/src/app/(panel)/page.tsx +++ b/dashboard/src/app/(panel)/page.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Link from "next/link"; -import { Play, Square, RotateCw, HardDrive, Globe2, Cuboid } from "lucide-react"; +import { Play, Square, RotateCw, HardDrive, Globe2, Cuboid, Map } from "lucide-react"; import { api, useApi, useSSE, toast } from "@/lib/api"; import { Card, StatTile, Button, Badge, EmptyState } from "@/components/ui"; import { Sparkline, UsageBar, type Point } from "@/components/charts"; @@ -17,6 +17,7 @@ type ServerData = { serverType: string | null; metrics: Record | null; pendingApprovals: number; + map: { available: boolean; port: number }; world: { seed: string | null; sizeBytes: number | null; dimensions: string[]; levelName: string | null }; uptimeMs: number | null; }; @@ -254,6 +255,18 @@ export default function OverviewPage() { {server.world.seed}
)} + {server?.map.available && ( + + + Open the live world map + BlueMap ↗ + + )}
{m?.disk_used != null && m?.disk_free != null ? ( (null); const [kicking, setKicking] = useState(null); const [adminBusy, setAdminBusy] = useState(null); + const [tpTarget, setTpTarget] = useState(""); + const [giveItemId, setGiveItemId] = useState(""); + const [giveCount, setGiveCount] = useState("1"); const adminAction = async (label: string, body: Record, message: string) => { setAdminBusy(label); @@ -81,6 +84,7 @@ export default function PlayersPage() { const players = data?.players ?? []; const online = players.filter((p) => p.isOnline); + const onlineNames = data?.online ?? []; const joinEvents = (joins ?? []).filter((j) => ["join", "leave", "connect"].includes(j.type)); const pingBadge = (ping: number | null) => { @@ -257,6 +261,74 @@ export default function PlayersPage() { Ban
+ + {/* creative admin favors (online players only) */} + {detail.isOnline && ( +
+
+ + {onlineNames.filter((n) => n !== detail.name).length > 0 && ( + <> + + setGiveItemId(e.target.value)} + placeholder="item id, e.g. diamond" + className="w-44 font-mono text-xs" + /> + setGiveCount(e.target.value)} + className="w-20 font-mono text-xs" + /> + +
+
+ )} )} diff --git a/dashboard/src/app/api/admin/route.ts b/dashboard/src/app/api/admin/route.ts index b19d687..82123a7 100644 --- a/dashboard/src/app/api/admin/route.ts +++ b/dashboard/src/app/api/admin/route.ts @@ -8,6 +8,10 @@ import { getGamerules, setGamerule, say, + broadcastTitle, + giveItem, + teleportToPlayer, + healPlayer, } from "@/server/mc"; import { logActivity } from "@/server/db"; import { ok, fail, readJson, handle } from "@/server/api"; @@ -28,7 +32,10 @@ type Body = | { op: "weather"; value: "clear" | "rain" | "thunder" } | { op: "difficulty"; value: string } | { op: "gamerule"; rule: string; value: boolean } - | { op: "broadcast"; message: string }; + | { op: "broadcast"; message: string; style?: "chat" | "title" } + | { op: "give"; name: string; item: string; count?: number } + | { op: "teleport"; name: string; target: string } + | { op: "heal"; name: string }; export async function POST(req: NextRequest) { return handle(async () => { @@ -56,13 +63,33 @@ export async function POST(req: NextRequest) { return ok({ output: out }); } case "gamerule": { - const out = await setGamerule(body.rule, body.value); + const applied = await setGamerule(body.rule, body.value); logActivity("admin", null, `gamerule ${body.rule} ${body.value}`); - return ok({ output: out }); + return ok({ value: applied }); } case "broadcast": { if (!body.message?.trim()) return fail("Empty message"); - await say(body.message.trim().slice(0, 256)); + const message = body.message.trim().slice(0, 256); + if (body.style === "title") { + await broadcastTitle(message); + } else { + await say(message); + } + return ok({}); + } + case "give": { + const out = await giveItem(body.name, body.item, body.count ?? 1); + logActivity("admin", body.name, `give ${body.item} ×${body.count ?? 1}`); + return ok({ output: out }); + } + case "teleport": { + const out = await teleportToPlayer(body.name, body.target); + logActivity("admin", body.name, `tp → ${body.target}`); + return ok({ output: out }); + } + case "heal": { + await healPlayer(body.name); + logActivity("admin", body.name, "heal"); return ok({}); } default: diff --git a/dashboard/src/app/api/server/route.ts b/dashboard/src/app/api/server/route.ts index 9f432e1..ba36ea3 100644 --- a/dashboard/src/app/api/server/route.ts +++ b/dashboard/src/app/api/server/route.ts @@ -4,10 +4,26 @@ import { db } from "@/server/db"; import { env } from "@/server/env"; import { getWorldSizeBytes } from "@/server/sampler"; import { readWorldInfo } from "@/server/playerstats"; +import { readEnvFile } from "@/server/envfile"; import { ok, handle } from "@/server/api"; export const dynamic = "force-dynamic"; +// BlueMap detection (in-network probe of the mc container), cached 60 s. +let mapCheck: { at: number; available: boolean } = { at: 0, available: false }; + +async function bluemapAvailable(running: boolean): Promise { + if (!running) return false; + if (Date.now() - mapCheck.at < 60_000) return mapCheck.available; + let available = false; + try { + const res = await fetch(`http://${env.mcContainer}:8100/`, { signal: AbortSignal.timeout(1500) }); + available = res.ok; + } catch {} + mapCheck = { at: Date.now(), available }; + return available; +} + export async function GET() { return handle(async () => { let container; @@ -49,6 +65,10 @@ export async function GET() { serverType, metrics: latest ?? null, pendingApprovals: pendingCount, + map: { + available: await bluemapAvailable(container.running), + port: Number(readEnvFile()["MAP_PORT"]) || 8100, + }, world: readWorldInfo(getWorldSizeBytes()), uptimeMs: container.running && "startedAt" in container && container.startedAt diff --git a/dashboard/src/components/quickcontrols.tsx b/dashboard/src/components/quickcontrols.tsx index bf28f8a..0b78926 100644 --- a/dashboard/src/components/quickcontrols.tsx +++ b/dashboard/src/components/quickcontrols.tsx @@ -1,15 +1,17 @@ "use client"; import { useState } from "react"; -import { Sun, Moon, CloudSun, CloudRain, Megaphone } from "lucide-react"; +import { Sun, Moon, CloudSun, CloudRain, Megaphone, Monitor } from "lucide-react"; import { api, useApi, toast } from "@/lib/api"; import { Card, Button, Input, Select, Toggle } from "@/components/ui"; +// Canonical 26.x snake_case keys; the backend resolves old camelCase names +// automatically on older servers. const GAMERULE_LABELS: Record = { - keepInventory: "Keep inventory on death", - mobGriefing: "Mob griefing (creeper damage…)", - doDaylightCycle: "Day/night cycle", - doWeatherCycle: "Weather cycle", + keep_inventory: "Keep inventory on death", + mob_griefing: "Mob griefing (creeper damage…)", + advance_time: "Day/night cycle", + advance_weather: "Weather cycle", pvp: "PvP", }; @@ -18,6 +20,7 @@ export function QuickControls({ running }: { running: boolean }) { const { data, refresh } = useApi<{ gamerules: Record }>("/api/admin", 0); const [busy, setBusy] = useState(null); const [broadcast, setBroadcast] = useState(""); + const [broadcastStyle, setBroadcastStyle] = useState<"chat" | "title">("chat"); const [difficulty, setDifficulty] = useState(""); const run = async (label: string, body: Record, message?: string) => { @@ -28,13 +31,27 @@ export function QuickControls({ running }: { running: boolean }) { void refresh(); } catch (err) { toast((err as Error).message, "error"); + void refresh(); } finally { setBusy(null); } }; + const sendBroadcast = () => { + if (!broadcast.trim()) return; + void run( + "broadcast", + { op: "broadcast", message: broadcast, style: broadcastStyle }, + broadcastStyle === "title" ? "Title shown to everyone" : "Broadcast sent" + ); + setBroadcast(""); + }; + if (!running) return null; + const rules = data?.gamerules ?? {}; + const rulesLoaded = Object.keys(rules).length > 0; + return (
@@ -69,43 +86,51 @@ export function QuickControls({ running }: { running: boolean }) {
{/* gamerules */} -
- {Object.entries(GAMERULE_LABELS).map(([rule, label]) => ( - void run(`rule-${rule}`, { op: "gamerule", rule, value: v }, `${label}: ${v ? "on" : "off"}`)} - label={label} - /> - ))} +
+ {!rulesLoaded ? ( +

Reading gamerules from the server…

+ ) : ( +
+ {Object.entries(GAMERULE_LABELS).map(([rule, label]) => + rule in rules ? ( + + void run(`rule-${rule}`, { op: "gamerule", rule, value: v }, `${label}: ${v ? "on" : "off"}`) + } + label={label} + /> + ) : null + )} +
+ )}
{/* broadcast */}
+ setBroadcast(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && broadcast.trim()) { - void run("broadcast", { op: "broadcast", message: broadcast }, "Broadcast sent"); - setBroadcast(""); - } - }} - placeholder="Broadcast a message to everyone…" + onKeyDown={(e) => e.key === "Enter" && sendBroadcast()} + placeholder={ + broadcastStyle === "title" + ? "Show a message across everyone's screen…" + : "Broadcast a chat message to everyone…" + } className="flex-1" maxLength={256} /> -
diff --git a/dashboard/src/server/mc.ts b/dashboard/src/server/mc.ts index 5471dd9..d24a58b 100644 --- a/dashboard/src/server/mc.ts +++ b/dashboard/src/server/mc.ts @@ -205,8 +205,43 @@ export async function pardonIp(ip: string): Promise { const GAMEMODES = new Set(["survival", "creative", "adventure", "spectator"]); const DIFFICULTIES = new Set(["peaceful", "easy", "normal", "hard"]); -/** Gamerules the panel exposes as toggles. */ -export const TOGGLE_GAMERULES = ["keepInventory", "mobGriefing", "doDaylightCycle", "doWeatherCycle", "pvp"] as const; + +/** + * Gamerules the panel exposes as toggles. Minecraft 26.x renamed every rule + * to snake_case (keepInventory → keep_inventory, doDaylightCycle → + * advance_time, ...) with old names deprecated — so each toggle carries + * candidate names and the working one is resolved against the live server + * and cached. + */ +export const TOGGLE_GAMERULES: Record = { + keep_inventory: ["keep_inventory", "keepInventory"], + mob_griefing: ["mob_griefing", "mobGriefing"], + advance_time: ["advance_time", "doDaylightCycle"], + advance_weather: ["advance_weather", "weather_cycle", "doWeatherCycle"], + pvp: ["pvp"], +}; + +const resolvedRuleNames = new Map(); + +/** Query one gamerule, returning its value and the name that worked. */ +async function queryGamerule(key: string): Promise<{ name: string; value: boolean } | null> { + const cached = resolvedRuleNames.get(key); + const candidates = cached ? [cached] : TOGGLE_GAMERULES[key]; + for (const name of candidates) { + try { + const out = stripColors(await rcon().exec(`gamerule ${name}`)); + const m = out.match(/\b(true|false)\b/i); + if (m) { + resolvedRuleNames.set(key, name); + return { name, value: m[1].toLowerCase() === "true" }; + } + } catch { + return null; // RCON down — no point trying other names + } + } + if (cached) resolvedRuleNames.delete(key); // cached name stopped working (version change) + return null; +} export async function setGamemode(name: string, mode: string): Promise { assertValidUsername(name); @@ -236,19 +271,64 @@ export async function setDifficulty(value: string): Promise { export async function getGamerules(): Promise> { const out: Record = {}; - for (const rule of TOGGLE_GAMERULES) { - try { - const res = stripColors(await rcon().exec(`gamerule ${rule}`)); - const m = res.match(/\b(true|false)\b/i); - if (m) out[rule] = m[1].toLowerCase() === "true"; - } catch {} + for (const key of Object.keys(TOGGLE_GAMERULES)) { + const res = await queryGamerule(key); + if (res) out[key] = res.value; } return out; } -export async function setGamerule(rule: string, value: boolean): Promise { - if (!(TOGGLE_GAMERULES as readonly string[]).includes(rule)) throw new Error("Gamerule not allowed"); - return stripColors(await rcon().exec(`gamerule ${rule} ${value}`)); +/** Set a gamerule and verify the server actually applied it. */ +export async function setGamerule(key: string, value: boolean): Promise { + if (!(key in TOGGLE_GAMERULES)) throw new Error("Gamerule not allowed"); + const before = await queryGamerule(key); + if (!before) { + throw new Error(`The server doesn't recognize this gamerule (tried: ${TOGGLE_GAMERULES[key].join(", ")})`); + } + const setOut = stripColors(await rcon().exec(`gamerule ${before.name} ${value}`)); + const after = await queryGamerule(key); + if (!after || after.value !== value) { + throw new Error(`Server refused the change: ${setOut.trim() || "no response"}`); + } + return after.value; +} + +/** Run a command and throw if the server's reply reads like a command error. */ +async function execChecked(command: string): Promise { + const out = stripColors(await rcon().exec(command)); + if (/unknown|incorrect|expected|invalid|no (?:player|entity) was found/i.test(out)) { + throw new Error(out.trim().slice(0, 200)); + } + return out; +} + +/** Full-screen title (with chat fallback text left to the caller). */ +export async function broadcastTitle(message: string): Promise { + const json = JSON.stringify({ text: message.slice(0, 100), color: "green" }); + await execChecked(`title @a title ${json}`); +} + +const ITEM_ID_RE = /^[a-z0-9_]+(:[a-z0-9_/]+)?$/; + +export async function giveItem(name: string, item: string, count: number): Promise { + assertValidUsername(name); + const id = item.trim().toLowerCase(); + if (!ITEM_ID_RE.test(id)) throw new Error("Invalid item id (e.g. diamond or minecraft:cooked_beef)"); + const n = Math.max(1, Math.min(Math.floor(count) || 1, 6400)); + return execChecked(`give ${name} ${id} ${n}`); +} + +export async function teleportToPlayer(name: string, target: string): Promise { + assertValidUsername(name); + assertValidUsername(target); + return execChecked(`tp ${name} ${target}`); +} + +/** Top up health and food — the classic admin favor. */ +export async function healPlayer(name: string): Promise { + assertValidUsername(name); + await execChecked(`effect give ${name} minecraft:instant_health 1 10`); + await execChecked(`effect give ${name} minecraft:saturation 1 10`); } export async function kickAll(reason: string): Promise { diff --git a/docker-compose.yml b/docker-compose.yml index 87046ff..edaa69f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,8 @@ services: TZ: ${TZ:-UTC} ports: - "${MC_PORT:-25565}:25565" + # BlueMap web map (only active if the bluemap mod is installed) + - "${MAP_PORT:-8100}:8100" volumes: - ./data/mc:/data mem_limit: ${MC_CONTAINER_MEM_LIMIT:-7g}