Fix gamerule toggles for 26.x renames; add map/skins/pregen + admin favors

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 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Fable 5 committed 2026-07-11 12:05:23 -05:00
1 parent deeeaf0042
commit b2ff88b255
9 files changed
+344 -50

No files matched your search

+53
View File
@@ -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<ModsData>("/api/mods", 0);
const [query, setQuery] = useState("");
@@ -102,6 +128,33 @@ export default function ModsPage() {
</Button>
</div>
{/* featured picks */}
<Card title="Recommended for this server">
<div className="grid gap-2 sm:grid-cols-3">
{FEATURED.map((f) => {
const already = managedSet.has(f.slug);
return (
<div key={f.slug} className="flex flex-col gap-1.5 rounded-lg border border-border bg-surface2 p-3">
<div className="flex items-center gap-2">
<span className="text-base">{f.emoji}</span>
<span className="text-sm font-medium">{f.title}</span>
</div>
<p className="flex-1 text-[11px] leading-relaxed text-muted">{f.description}</p>
<Button
size="sm"
variant={already ? "ghost" : "primary"}
disabled={already}
busy={busy === `add-${f.slug}`}
onClick={() => void manage(`add-${f.slug}`, { op: "add", slug: f.slug }, `${f.title} added — hit Apply to install`)}
>
<Download size={12} /> {already ? "Installed" : "Add"}
</Button>
</div>
);
})}
</div>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
{/* Modrinth search */}
<Card title="Add from Modrinth">
+14 -1
View File
@@ -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<string, number | null> | 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() {
<span className="ml-auto font-mono text-xs">{server.world.seed}</span>
</div>
)}
{server?.map.available && (
<a
href={`http://${typeof window !== "undefined" ? window.location.hostname : "localhost"}:${server.map.port}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 rounded-lg border border-accent-deep/40 bg-accent/10 px-3 py-2 text-sm text-accent hover:bg-accent/20"
>
<Map size={15} />
<span className="font-medium">Open the live world map</span>
<span className="ml-auto text-xs">BlueMap ↗</span>
</a>
)}
<div className="pt-1">
{m?.disk_used != null && m?.disk_free != null ? (
<UsageBar
+74 -2
View File
@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import { Users, UserX, Wifi, Crown, Gavel } from "lucide-react";
import { Users, UserX, Wifi, Crown, Gavel, HeartPulse, MapPin, Gift } from "lucide-react";
import { api, useApi, useSSE, toast } from "@/lib/api";
import { Card, Badge, Button, EmptyState, Modal } from "@/components/ui";
import { Card, Badge, Button, EmptyState, Modal, Input, Select } from "@/components/ui";
import { timeAgo, fmtDateTime } from "@/lib/format";
type PlayerRow = {
@@ -32,6 +32,9 @@ export default function PlayersPage() {
const [detail, setDetail] = useState<PlayerRow | null>(null);
const [kicking, setKicking] = useState<string | null>(null);
const [adminBusy, setAdminBusy] = useState<string | null>(null);
const [tpTarget, setTpTarget] = useState("");
const [giveItemId, setGiveItemId] = useState("");
const [giveCount, setGiveCount] = useState("1");
const adminAction = async (label: string, body: Record<string, unknown>, 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() {
<Gavel size={12} /> Ban
</Button>
</div>
{/* creative admin favors (online players only) */}
{detail.isOnline && (
<div className="mt-3 space-y-2 border-t border-border pt-3">
<div className="flex flex-wrap items-center gap-1.5">
<Button
size="sm"
busy={adminBusy === "heal"}
onClick={() => void adminAction("heal", { op: "heal", name: detail.name }, `${detail.name} healed & fed`)}
>
<HeartPulse size={12} /> Heal
</Button>
{onlineNames.filter((n) => n !== detail.name).length > 0 && (
<>
<span className="mx-1 h-4 w-px bg-border" />
<Select
value={tpTarget}
onChange={setTpTarget}
options={[
{ value: "", label: "teleport to…" },
...onlineNames.filter((n) => n !== detail.name).map((n) => ({ value: n, label: n })),
]}
/>
<Button
size="sm"
disabled={!tpTarget}
busy={adminBusy === "tp"}
onClick={() =>
void adminAction("tp", { op: "teleport", name: detail.name, target: tpTarget }, `${detail.name} → ${tpTarget}`)
}
>
<MapPin size={12} /> Teleport
</Button>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Input
value={giveItemId}
onChange={(e) => setGiveItemId(e.target.value)}
placeholder="item id, e.g. diamond"
className="w-44 font-mono text-xs"
/>
<Input
type="number"
min={1}
max={6400}
value={giveCount}
onChange={(e) => setGiveCount(e.target.value)}
className="w-20 font-mono text-xs"
/>
<Button
size="sm"
disabled={!giveItemId.trim()}
busy={adminBusy === "give"}
onClick={() =>
void adminAction(
"give",
{ op: "give", name: detail.name, item: giveItemId.trim(), count: Number(giveCount) || 1 },
`Gave ${detail.name} ${giveCount}× ${giveItemId.trim()}`
)
}
>
<Gift size={12} /> Give
</Button>
</div>
</div>
)}
</div>
)}
</Modal>
+31 -4
View File
@@ -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:
+20
View File
@@ -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<boolean> {
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
+57 -32
View File
@@ -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<string, string> = {
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<string, boolean> }>("/api/admin", 0);
const [busy, setBusy] = useState<string | null>(null);
const [broadcast, setBroadcast] = useState("");
const [broadcastStyle, setBroadcastStyle] = useState<"chat" | "title">("chat");
const [difficulty, setDifficulty] = useState("");
const run = async (label: string, body: Record<string, unknown>, 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 (
<Card title="Quick controls">
<div className="space-y-4">
@@ -69,43 +86,51 @@ export function QuickControls({ running }: { running: boolean }) {
</div>
{/* gamerules */}
<div className="grid grid-cols-1 gap-2 border-t border-border pt-3 sm:grid-cols-2">
{Object.entries(GAMERULE_LABELS).map(([rule, label]) => (
<Toggle
key={rule}
checked={data?.gamerules[rule] ?? false}
onChange={(v) => void run(`rule-${rule}`, { op: "gamerule", rule, value: v }, `${label}: ${v ? "on" : "off"}`)}
label={label}
/>
))}
<div className="border-t border-border pt-3">
{!rulesLoaded ? (
<p className="text-xs text-muted">Reading gamerules from the server…</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{Object.entries(GAMERULE_LABELS).map(([rule, label]) =>
rule in rules ? (
<Toggle
key={rule}
checked={rules[rule]}
onChange={(v) =>
void run(`rule-${rule}`, { op: "gamerule", rule, value: v }, `${label}: ${v ? "on" : "off"}`)
}
label={label}
/>
) : null
)}
</div>
)}
</div>
{/* broadcast */}
<div className="flex gap-2 border-t border-border pt-3">
<Select
value={broadcastStyle}
onChange={(v) => setBroadcastStyle(v as "chat" | "title")}
options={[
{ value: "chat", label: "💬 chat" },
{ value: "title", label: "🖥 big title" },
]}
/>
<Input
value={broadcast}
onChange={(e) => 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}
/>
<Button
size="sm"
variant="primary"
disabled={!broadcast.trim()}
busy={busy === "broadcast"}
onClick={() => {
void run("broadcast", { op: "broadcast", message: broadcast }, "Broadcast sent");
setBroadcast("");
}}
>
<Megaphone size={12} /> Send
<Button size="sm" variant="primary" disabled={!broadcast.trim()} busy={busy === "broadcast"} onClick={sendBroadcast}>
{broadcastStyle === "title" ? <Monitor size={12} /> : <Megaphone size={12} />} Send
</Button>
</div>
</div>
+91 -11
View File
@@ -205,8 +205,43 @@ export async function pardonIp(ip: string): Promise<string> {
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<string, string[]> = {
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<string, string>();
/** 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<string> {
assertValidUsername(name);
@@ -236,19 +271,64 @@ export async function setDifficulty(value: string): Promise<string> {
export async function getGamerules(): Promise<Record<string, boolean>> {
const out: Record<string, boolean> = {};
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<string> {
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<boolean> {
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<string> {
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<void> {
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<string> {
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<string> {
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<void> {
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<number> {