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

+2
View File
@@ -13,6 +13,8 @@ SERVER_TYPE=FABRIC
MC_MEMORY=6G MC_MEMORY=6G
MC_CONTAINER_MEM_LIMIT=7g MC_CONTAINER_MEM_LIMIT=7g
MC_PORT=25565 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_MOTD=A cozy server for friends
MC_DIFFICULTY=normal MC_DIFFICULTY=normal
+53
View File
@@ -35,6 +35,32 @@ const PROTECTED = new Set(["fabric-api", "easyauth"]);
// by the base slug. // by the base slug.
const baseSlug = (entry: string) => entry.split(":")[0].toLowerCase(); 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() { export default function ModsPage() {
const { data, refresh } = useApi<ModsData>("/api/mods", 0); const { data, refresh } = useApi<ModsData>("/api/mods", 0);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -102,6 +128,33 @@ export default function ModsPage() {
</Button> </Button>
</div> </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"> <div className="grid gap-4 lg:grid-cols-2">
{/* Modrinth search */} {/* Modrinth search */}
<Card title="Add from Modrinth"> <Card title="Add from Modrinth">
+14 -1
View File
@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; 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 { api, useApi, useSSE, toast } from "@/lib/api";
import { Card, StatTile, Button, Badge, EmptyState } from "@/components/ui"; import { Card, StatTile, Button, Badge, EmptyState } from "@/components/ui";
import { Sparkline, UsageBar, type Point } from "@/components/charts"; import { Sparkline, UsageBar, type Point } from "@/components/charts";
@@ -17,6 +17,7 @@ type ServerData = {
serverType: string | null; serverType: string | null;
metrics: Record<string, number | null> | null; metrics: Record<string, number | null> | null;
pendingApprovals: number; pendingApprovals: number;
map: { available: boolean; port: number };
world: { seed: string | null; sizeBytes: number | null; dimensions: string[]; levelName: string | null }; world: { seed: string | null; sizeBytes: number | null; dimensions: string[]; levelName: string | null };
uptimeMs: number | null; uptimeMs: number | null;
}; };
@@ -254,6 +255,18 @@ export default function OverviewPage() {
<span className="ml-auto font-mono text-xs">{server.world.seed}</span> <span className="ml-auto font-mono text-xs">{server.world.seed}</span>
</div> </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"> <div className="pt-1">
{m?.disk_used != null && m?.disk_free != null ? ( {m?.disk_used != null && m?.disk_free != null ? (
<UsageBar <UsageBar
+74 -2
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useState } from "react"; 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 { 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"; import { timeAgo, fmtDateTime } from "@/lib/format";
type PlayerRow = { type PlayerRow = {
@@ -32,6 +32,9 @@ export default function PlayersPage() {
const [detail, setDetail] = useState<PlayerRow | null>(null); const [detail, setDetail] = useState<PlayerRow | null>(null);
const [kicking, setKicking] = useState<string | null>(null); const [kicking, setKicking] = useState<string | null>(null);
const [adminBusy, setAdminBusy] = 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) => { const adminAction = async (label: string, body: Record<string, unknown>, message: string) => {
setAdminBusy(label); setAdminBusy(label);
@@ -81,6 +84,7 @@ export default function PlayersPage() {
const players = data?.players ?? []; const players = data?.players ?? [];
const online = players.filter((p) => p.isOnline); const online = players.filter((p) => p.isOnline);
const onlineNames = data?.online ?? [];
const joinEvents = (joins ?? []).filter((j) => ["join", "leave", "connect"].includes(j.type)); const joinEvents = (joins ?? []).filter((j) => ["join", "leave", "connect"].includes(j.type));
const pingBadge = (ping: number | null) => { const pingBadge = (ping: number | null) => {
@@ -257,6 +261,74 @@ export default function PlayersPage() {
<Gavel size={12} /> Ban <Gavel size={12} /> Ban
</Button> </Button>
</div> </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> </div>
)} )}
</Modal> </Modal>
+31 -4
View File
@@ -8,6 +8,10 @@ import {
getGamerules, getGamerules,
setGamerule, setGamerule,
say, say,
broadcastTitle,
giveItem,
teleportToPlayer,
healPlayer,
} from "@/server/mc"; } from "@/server/mc";
import { logActivity } from "@/server/db"; import { logActivity } from "@/server/db";
import { ok, fail, readJson, handle } from "@/server/api"; import { ok, fail, readJson, handle } from "@/server/api";
@@ -28,7 +32,10 @@ type Body =
| { op: "weather"; value: "clear" | "rain" | "thunder" } | { op: "weather"; value: "clear" | "rain" | "thunder" }
| { op: "difficulty"; value: string } | { op: "difficulty"; value: string }
| { op: "gamerule"; rule: string; value: boolean } | { 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) { export async function POST(req: NextRequest) {
return handle(async () => { return handle(async () => {
@@ -56,13 +63,33 @@ export async function POST(req: NextRequest) {
return ok({ output: out }); return ok({ output: out });
} }
case "gamerule": { 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}`); logActivity("admin", null, `gamerule ${body.rule} ${body.value}`);
return ok({ output: out }); return ok({ value: applied });
} }
case "broadcast": { case "broadcast": {
if (!body.message?.trim()) return fail("Empty message"); 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({}); return ok({});
} }
default: default:
+20
View File
@@ -4,10 +4,26 @@ import { db } from "@/server/db";
import { env } from "@/server/env"; import { env } from "@/server/env";
import { getWorldSizeBytes } from "@/server/sampler"; import { getWorldSizeBytes } from "@/server/sampler";
import { readWorldInfo } from "@/server/playerstats"; import { readWorldInfo } from "@/server/playerstats";
import { readEnvFile } from "@/server/envfile";
import { ok, handle } from "@/server/api"; import { ok, handle } from "@/server/api";
export const dynamic = "force-dynamic"; 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() { export async function GET() {
return handle(async () => { return handle(async () => {
let container; let container;
@@ -49,6 +65,10 @@ export async function GET() {
serverType, serverType,
metrics: latest ?? null, metrics: latest ?? null,
pendingApprovals: pendingCount, pendingApprovals: pendingCount,
map: {
available: await bluemapAvailable(container.running),
port: Number(readEnvFile()["MAP_PORT"]) || 8100,
},
world: readWorldInfo(getWorldSizeBytes()), world: readWorldInfo(getWorldSizeBytes()),
uptimeMs: uptimeMs:
container.running && "startedAt" in container && container.startedAt container.running && "startedAt" in container && container.startedAt
+57 -32
View File
@@ -1,15 +1,17 @@
"use client"; "use client";
import { useState } from "react"; 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 { api, useApi, toast } from "@/lib/api";
import { Card, Button, Input, Select, Toggle } from "@/components/ui"; 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> = { const GAMERULE_LABELS: Record<string, string> = {
keepInventory: "Keep inventory on death", keep_inventory: "Keep inventory on death",
mobGriefing: "Mob griefing (creeper damage…)", mob_griefing: "Mob griefing (creeper damage…)",
doDaylightCycle: "Day/night cycle", advance_time: "Day/night cycle",
doWeatherCycle: "Weather cycle", advance_weather: "Weather cycle",
pvp: "PvP", pvp: "PvP",
}; };
@@ -18,6 +20,7 @@ export function QuickControls({ running }: { running: boolean }) {
const { data, refresh } = useApi<{ gamerules: Record<string, boolean> }>("/api/admin", 0); const { data, refresh } = useApi<{ gamerules: Record<string, boolean> }>("/api/admin", 0);
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [broadcast, setBroadcast] = useState(""); const [broadcast, setBroadcast] = useState("");
const [broadcastStyle, setBroadcastStyle] = useState<"chat" | "title">("chat");
const [difficulty, setDifficulty] = useState(""); const [difficulty, setDifficulty] = useState("");
const run = async (label: string, body: Record<string, unknown>, message?: string) => { const run = async (label: string, body: Record<string, unknown>, message?: string) => {
@@ -28,13 +31,27 @@ export function QuickControls({ running }: { running: boolean }) {
void refresh(); void refresh();
} catch (err) { } catch (err) {
toast((err as Error).message, "error"); toast((err as Error).message, "error");
void refresh();
} finally { } finally {
setBusy(null); 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; if (!running) return null;
const rules = data?.gamerules ?? {};
const rulesLoaded = Object.keys(rules).length > 0;
return ( return (
<Card title="Quick controls"> <Card title="Quick controls">
<div className="space-y-4"> <div className="space-y-4">
@@ -69,43 +86,51 @@ export function QuickControls({ running }: { running: boolean }) {
</div> </div>
{/* gamerules */} {/* gamerules */}
<div className="grid grid-cols-1 gap-2 border-t border-border pt-3 sm:grid-cols-2"> <div className="border-t border-border pt-3">
{Object.entries(GAMERULE_LABELS).map(([rule, label]) => ( {!rulesLoaded ? (
<Toggle <p className="text-xs text-muted">Reading gamerules from the server…</p>
key={rule} ) : (
checked={data?.gamerules[rule] ?? false} <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
onChange={(v) => void run(`rule-${rule}`, { op: "gamerule", rule, value: v }, `${label}: ${v ? "on" : "off"}`)} {Object.entries(GAMERULE_LABELS).map(([rule, label]) =>
label={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> </div>
{/* broadcast */} {/* broadcast */}
<div className="flex gap-2 border-t border-border pt-3"> <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 <Input
value={broadcast} value={broadcast}
onChange={(e) => setBroadcast(e.target.value)} onChange={(e) => setBroadcast(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => e.key === "Enter" && sendBroadcast()}
if (e.key === "Enter" && broadcast.trim()) { placeholder={
void run("broadcast", { op: "broadcast", message: broadcast }, "Broadcast sent"); broadcastStyle === "title"
setBroadcast(""); ? "Show a message across everyone's screen…"
} : "Broadcast a chat message to everyone…"
}} }
placeholder="Broadcast a message to everyone…"
className="flex-1" className="flex-1"
maxLength={256} maxLength={256}
/> />
<Button <Button size="sm" variant="primary" disabled={!broadcast.trim()} busy={busy === "broadcast"} onClick={sendBroadcast}>
size="sm" {broadcastStyle === "title" ? <Monitor size={12} /> : <Megaphone size={12} />} Send
variant="primary"
disabled={!broadcast.trim()}
busy={busy === "broadcast"}
onClick={() => {
void run("broadcast", { op: "broadcast", message: broadcast }, "Broadcast sent");
setBroadcast("");
}}
>
<Megaphone size={12} /> Send
</Button> </Button>
</div> </div>
</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 GAMEMODES = new Set(["survival", "creative", "adventure", "spectator"]);
const DIFFICULTIES = new Set(["peaceful", "easy", "normal", "hard"]); 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> { export async function setGamemode(name: string, mode: string): Promise<string> {
assertValidUsername(name); assertValidUsername(name);
@@ -236,19 +271,64 @@ export async function setDifficulty(value: string): Promise<string> {
export async function getGamerules(): Promise<Record<string, boolean>> { export async function getGamerules(): Promise<Record<string, boolean>> {
const out: Record<string, boolean> = {}; const out: Record<string, boolean> = {};
for (const rule of TOGGLE_GAMERULES) { for (const key of Object.keys(TOGGLE_GAMERULES)) {
try { const res = await queryGamerule(key);
const res = stripColors(await rcon().exec(`gamerule ${rule}`)); if (res) out[key] = res.value;
const m = res.match(/\b(true|false)\b/i);
if (m) out[rule] = m[1].toLowerCase() === "true";
} catch {}
} }
return out; return out;
} }
export async function setGamerule(rule: string, value: boolean): Promise<string> { /** Set a gamerule and verify the server actually applied it. */
if (!(TOGGLE_GAMERULES as readonly string[]).includes(rule)) throw new Error("Gamerule not allowed"); export async function setGamerule(key: string, value: boolean): Promise<boolean> {
return stripColors(await rcon().exec(`gamerule ${rule} ${value}`)); 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> { export async function kickAll(reason: string): Promise<number> {
+2
View File
@@ -48,6 +48,8 @@ services:
TZ: ${TZ:-UTC} TZ: ${TZ:-UTC}
ports: ports:
- "${MC_PORT:-25565}:25565" - "${MC_PORT:-25565}:25565"
# BlueMap web map (only active if the bluemap mod is installed)
- "${MAP_PORT:-8100}:8100"
volumes: volumes:
- ./data/mc:/data - ./data/mc:/data
mem_limit: ${MC_CONTAINER_MEM_LIMIT:-7g} mem_limit: ${MC_CONTAINER_MEM_LIMIT:-7g}