From b2ff88b25573de01c08709e62b59bb6997983b57 Mon Sep 17 00:00:00 2001 From: Aryan Singh Date: Sat, 11 Jul 2026 12:05:23 -0500 Subject: [PATCH] Fix gamerule toggles for 26.x renames; add map/skins/pregen + admin favors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minecraft 26.x renamed every gamerule to snake_case (keepInventory → keep_inventory, doDaylightCycle → advance_time, ...), so the panel's queries hit unknown rules: reads returned nothing, toggles always started from 'off', and sets silently failed. Rules are now defined by candidate names resolved against the live server (works on old and new versions), sets are verified by re-query, and failures surface the server's actual reply. Toggles render only for rules the server reports. Creative additions: - Featured mods on the Mods page: BlueMap (3D web map), SkinRestorer (real skins in offline mode), Chunky (pregen) — verified for 26.2 - BlueMap plumbing: MAP_PORT mapping in compose, in-network detection, 'Open the live world map' link on the Overview - Player modal favors: heal, teleport-to-player, give item - Broadcast box gained a full-screen title mode Co-Authored-By: Claude Fable 5 --- .env.example | 2 + dashboard/src/app/(panel)/mods/page.tsx | 53 +++++++++++ dashboard/src/app/(panel)/page.tsx | 15 ++- dashboard/src/app/(panel)/players/page.tsx | 76 ++++++++++++++- dashboard/src/app/api/admin/route.ts | 35 ++++++- dashboard/src/app/api/server/route.ts | 20 ++++ dashboard/src/components/quickcontrols.tsx | 89 +++++++++++------- dashboard/src/server/mc.ts | 102 ++++++++++++++++++--- docker-compose.yml | 2 + 9 files changed, 344 insertions(+), 50 deletions(-) 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}