Active player list
This commit is contained in:
parent
4a4563ba85
commit
b63db82a99
3
.gitignore
vendored
3
.gitignore
vendored
@ -16,4 +16,5 @@ vanilla/
|
|||||||
server/
|
server/
|
||||||
|
|
||||||
# Config file for MCGRIZZ
|
# Config file for MCGRIZZ
|
||||||
mcgrizz.json
|
mcgrizz.json
|
||||||
|
players.cache.json
|
@ -18,8 +18,13 @@ const colors = {
|
|||||||
text: "text-white",
|
text: "text-white",
|
||||||
},
|
},
|
||||||
fire: {
|
fire: {
|
||||||
bg: "bg-grape",
|
bg: "bg-fire",
|
||||||
border: "border-grape-800",
|
border: "border-fire-800",
|
||||||
|
text: "text-white",
|
||||||
|
},
|
||||||
|
wasabi: {
|
||||||
|
bg: "bg-wasabi-600",
|
||||||
|
border: "border-wasabi-800",
|
||||||
text: "text-white",
|
text: "text-white",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -38,6 +43,7 @@ export function Button(
|
|||||||
${colors[color].text}
|
${colors[color].text}
|
||||||
hover:bg-smoke-500
|
hover:bg-smoke-500
|
||||||
transition-colors
|
transition-colors
|
||||||
|
disabled:opacity-30
|
||||||
${props.class || ""}`;
|
${props.class || ""}`;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
@ -2,7 +2,7 @@ import { VNode } from "preact";
|
|||||||
|
|
||||||
export function Content(props: { children?: VNode | VNode[] }) {
|
export function Content(props: { children?: VNode | VNode[] }) {
|
||||||
return (
|
return (
|
||||||
<div class="mt-16 bg-smoke-200 dark:bg-smoke-900 border-4 border-licorice-800 p-8 rounded-3xl">
|
<div class="bg-smoke-200 dark:bg-smoke-900 border-4 border-licorice-800 p-8 rounded-3xl">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
28
components/properties.tsx
Normal file
28
components/properties.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Button } from "./Button.tsx";
|
||||||
|
import { Content } from "./Content.tsx";
|
||||||
|
|
||||||
|
export function MCProperties(
|
||||||
|
{ properties }: { properties: Map<string, string> },
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="container p-8">
|
||||||
|
<Content>
|
||||||
|
<form method="POST" action="/properties">
|
||||||
|
<div class="grid grid-cols-3 gap-y-4 gap-x-16">
|
||||||
|
{Array.from(properties.entries()).sort(([a],[b]) => a > b ? 1 : -1).map(([k, v]) => (
|
||||||
|
<div>
|
||||||
|
<label class="block font-pixel text-lg" htmlFor={k}>{k}</label>
|
||||||
|
<input class="w-full" type="text" name={k} value={v} autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div class="mt-8">
|
||||||
|
<Button type="submit">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Content>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
38
fresh.gen.ts
38
fresh.gen.ts
@ -5,30 +5,40 @@
|
|||||||
import * as $0 from "./routes/_404.tsx";
|
import * as $0 from "./routes/_404.tsx";
|
||||||
import * as $1 from "./routes/_app.tsx";
|
import * as $1 from "./routes/_app.tsx";
|
||||||
import * as $2 from "./routes/api/fabric/index.ts";
|
import * as $2 from "./routes/api/fabric/index.ts";
|
||||||
import * as $3 from "./routes/api/terminal.ts";
|
import * as $3 from "./routes/api/manage.ts";
|
||||||
import * as $4 from "./routes/index.tsx";
|
import * as $4 from "./routes/api/players.ts";
|
||||||
import * as $5 from "./routes/setup/eula.tsx";
|
import * as $5 from "./routes/index.tsx";
|
||||||
import * as $6 from "./routes/setup/fabric.tsx";
|
import * as $6 from "./routes/players.tsx";
|
||||||
import * as $7 from "./routes/setup/index.tsx";
|
import * as $7 from "./routes/properties.tsx";
|
||||||
import * as $8 from "./routes/setup/start.tsx";
|
import * as $8 from "./routes/setup/eula.tsx";
|
||||||
|
import * as $9 from "./routes/setup/fabric.tsx";
|
||||||
|
import * as $10 from "./routes/setup/index.tsx";
|
||||||
|
import * as $11 from "./routes/terminal.tsx";
|
||||||
import * as $$0 from "./islands/fabricVersions.tsx";
|
import * as $$0 from "./islands/fabricVersions.tsx";
|
||||||
import * as $$1 from "./islands/terminal.tsx";
|
import * as $$1 from "./islands/players.tsx";
|
||||||
|
import * as $$2 from "./islands/statusManager.tsx";
|
||||||
|
import * as $$3 from "./islands/terminal.tsx";
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
routes: {
|
routes: {
|
||||||
"./routes/_404.tsx": $0,
|
"./routes/_404.tsx": $0,
|
||||||
"./routes/_app.tsx": $1,
|
"./routes/_app.tsx": $1,
|
||||||
"./routes/api/fabric/index.ts": $2,
|
"./routes/api/fabric/index.ts": $2,
|
||||||
"./routes/api/terminal.ts": $3,
|
"./routes/api/manage.ts": $3,
|
||||||
"./routes/index.tsx": $4,
|
"./routes/api/players.ts": $4,
|
||||||
"./routes/setup/eula.tsx": $5,
|
"./routes/index.tsx": $5,
|
||||||
"./routes/setup/fabric.tsx": $6,
|
"./routes/players.tsx": $6,
|
||||||
"./routes/setup/index.tsx": $7,
|
"./routes/properties.tsx": $7,
|
||||||
"./routes/setup/start.tsx": $8,
|
"./routes/setup/eula.tsx": $8,
|
||||||
|
"./routes/setup/fabric.tsx": $9,
|
||||||
|
"./routes/setup/index.tsx": $10,
|
||||||
|
"./routes/terminal.tsx": $11,
|
||||||
},
|
},
|
||||||
islands: {
|
islands: {
|
||||||
"./islands/fabricVersions.tsx": $$0,
|
"./islands/fabricVersions.tsx": $$0,
|
||||||
"./islands/terminal.tsx": $$1,
|
"./islands/players.tsx": $$1,
|
||||||
|
"./islands/statusManager.tsx": $$2,
|
||||||
|
"./islands/terminal.tsx": $$3,
|
||||||
},
|
},
|
||||||
baseUrl: import.meta.url,
|
baseUrl: import.meta.url,
|
||||||
};
|
};
|
||||||
|
55
islands/players.tsx
Normal file
55
islands/players.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { StatusManager } from "./statusManager.tsx";
|
||||||
|
import { IS_BROWSER } from "$fresh/runtime.ts";
|
||||||
|
import { PlayerData } from "../util/players.ts";
|
||||||
|
import { Button } from "../components/Button.tsx";
|
||||||
|
|
||||||
|
export function ActivePlayerList() {
|
||||||
|
const [players, setPlayers] = useState<PlayerData[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!IS_BROWSER) return;
|
||||||
|
configureEventSource();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const eventSource = useRef<EventSource>();
|
||||||
|
|
||||||
|
const configureEventSource = () => {
|
||||||
|
if (eventSource.current?.OPEN) eventSource.current.close();
|
||||||
|
eventSource.current = new EventSource("/api/players");
|
||||||
|
eventSource.current.addEventListener("players", (e) => {
|
||||||
|
setPlayers(JSON.parse(e.data));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const followStatusManager = (action: string) => {
|
||||||
|
if (action === "started" || action === "restarted") {
|
||||||
|
configureEventSource();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<StatusManager onAction={followStatusManager} />
|
||||||
|
<div class="grid grid-cols-2 p-8">
|
||||||
|
<h2 class="col-span-2 font-pixel text-xl">Active Players</h2>
|
||||||
|
{players.map((p) => <PlayerCard player={p} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerCard({ player }: { player: PlayerData }) {
|
||||||
|
return (
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<img class="w-16" src={player.avatar} alt={`${player.username}'s avatar`} />
|
||||||
|
<div>
|
||||||
|
<h3>{player.username}</h3>
|
||||||
|
<small class="opacity-50">UUID: {player.id}</small>
|
||||||
|
<div>
|
||||||
|
Did you know that bats are the only mammal that can fly?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
51
islands/statusManager.tsx
Normal file
51
islands/statusManager.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { JSX } from "preact";
|
||||||
|
import { Button } from "../components/Button.tsx";
|
||||||
|
import { ManageAction } from "../routes/api/manage.ts";
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import { IS_BROWSER } from "$fresh/runtime.ts";
|
||||||
|
|
||||||
|
export function StatusManager(
|
||||||
|
props: JSX.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
onAction?: (res: string) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const sendCommand = async (action: ManageAction) => {
|
||||||
|
console.log(action);
|
||||||
|
const res = await fetch("/api/manage", {
|
||||||
|
method: "POST",
|
||||||
|
body: action,
|
||||||
|
});
|
||||||
|
const body = await res.text();
|
||||||
|
props.onAction && props.onAction(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
|
||||||
|
const getStatus = async () => {
|
||||||
|
const res = await fetch('/api/manage');
|
||||||
|
const body = await res.text();
|
||||||
|
|
||||||
|
setStatus(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (IS_BROWSER) getStatus();
|
||||||
|
},[])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...props}>
|
||||||
|
{!!status && <small>Server is {status}</small>}
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<Button color="wasabi" disabled={!status || status === 'running'} onClick={() => sendCommand(ManageAction.start)}>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
<Button color="fire" disabled={!status || status === 'stopped'} onClick={() => sendCommand(ManageAction.stop)}>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
<Button color="sky" disabled={!status} onClick={() => sendCommand(ManageAction.restart)}>
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,19 +1,40 @@
|
|||||||
import { IS_BROWSER } from "$fresh/runtime.ts";
|
import { IS_BROWSER } from "$fresh/runtime.ts";
|
||||||
import { useEffect, useRef, useState } from "preact/hooks";
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { JSX } from "preact/jsx-runtime";
|
||||||
import { Sockpuppet } from "puppet/client";
|
import { Sockpuppet } from "puppet/client";
|
||||||
|
|
||||||
|
|
||||||
export function Terminal(props: { channelId: string }) {
|
export function Terminal(props: { channelId: string }) {
|
||||||
const puppet = useRef(new Sockpuppet("ws://sockpuppet.cyborggrizzly.com"));
|
const puppet = useRef(new Sockpuppet("ws://sockpuppet.cyborggrizzly.com"));
|
||||||
const [lines, setLines] = useState<string[]>([]);
|
const [lines, setLines] = useState<string[]>([]);
|
||||||
const divRef = useRef<HTMLDivElement>(null);
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
const storeKey = 'commandHistory';
|
||||||
|
const [commandHistory, setCommandHistory] = useState<string[]>(JSON.parse(localStorage.getItem(storeKey) || '[]'));
|
||||||
|
const [historyIndex, setHistoryIndex] = useState(commandHistory.length);
|
||||||
|
|
||||||
const [command, setCommand] = useState("");
|
const [command, setCommand] = useState("");
|
||||||
|
|
||||||
|
const changeHistoryIndex = (by: number) => setHistoryIndex(i => i + by);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_BROWSER) return;
|
if (!IS_BROWSER) return;
|
||||||
puppet.current.joinChannel(props.channelId, (line) => {
|
puppet.current.joinChannel(props.channelId, (line) => {
|
||||||
setLines((l) => [...l, line]);
|
setLines((l) => [...l, line]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keyup', (e) => {
|
||||||
|
console.log(e.key)
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Up':
|
||||||
|
case 'ArrowUp':
|
||||||
|
changeHistoryIndex(-1);
|
||||||
|
break;
|
||||||
|
case 'Down':
|
||||||
|
case 'ArrowDown':
|
||||||
|
changeHistoryIndex(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
})
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -24,22 +45,38 @@ export function Terminal(props: { channelId: string }) {
|
|||||||
|
|
||||||
const sendCommand = (e: Event) => {
|
const sendCommand = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
puppet.current.getChannel(props.channelId)?.send(command);
|
puppet.current.getChannel(props.channelId)?.send(historyIndex === commandHistory.length ? command : commandHistory[historyIndex]);
|
||||||
|
setCommandHistory(c => [...c, command]);
|
||||||
|
setHistoryIndex(commandHistory.length + 1)
|
||||||
setCommand("");
|
setCommand("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCommandUpdate = (e: JSX.TargetedEvent<HTMLInputElement, Event>) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
|
||||||
|
if (historyIndex !== commandHistory.length) {
|
||||||
|
setHistoryIndex(commandHistory.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommand(value || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(storeKey, JSON.stringify(commandHistory.slice(0,100)));
|
||||||
|
}, [commandHistory])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div ref={divRef} class="flex flex-col h-[600px] w-full overflow-auto">
|
<div ref={divRef} class="flex flex-col h-[600px] w-full overflow-auto">
|
||||||
{lines.map((l) => <div class="font-mono w-full">{l}</div>)}
|
{lines.map((l) => <pre class="font-mono w-full">{l}</pre>)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mt-4">
|
||||||
<form onSubmit={sendCommand}>
|
<form onSubmit={sendCommand}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full bg-smoke-600 text-white"
|
class="w-full bg-smoke-600 text-white"
|
||||||
value={command}
|
value={commandHistory[historyIndex] || command}
|
||||||
onInput={(e) => setCommand((e.target as HTMLInputElement).value)}
|
onInput={handleCommandUpdate}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
31
routes/api/manage.ts
Normal file
31
routes/api/manage.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Handlers } from "$fresh/server.ts";
|
||||||
|
import { SERVER_STATE } from "../../state/serverState.ts";
|
||||||
|
|
||||||
|
export enum ManageAction {
|
||||||
|
start = "start",
|
||||||
|
stop = "stop",
|
||||||
|
restart = "restart",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers = {
|
||||||
|
async POST(req) {
|
||||||
|
const body: ManageAction & string = await req.text() as ManageAction;
|
||||||
|
switch (body) {
|
||||||
|
case ManageAction.start:
|
||||||
|
SERVER_STATE.startMCServer();
|
||||||
|
return new Response("started");
|
||||||
|
case ManageAction.stop:
|
||||||
|
SERVER_STATE.gracefullyStopMCServer();
|
||||||
|
return new Response("stopped");
|
||||||
|
case ManageAction.restart:
|
||||||
|
SERVER_STATE.restartMCServer();
|
||||||
|
return new Response("restarted");
|
||||||
|
default:
|
||||||
|
SERVER_STATE.sendStdIn(body);
|
||||||
|
return new Response("action done");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
GET() {
|
||||||
|
return new Response(SERVER_STATE.status);
|
||||||
|
}
|
||||||
|
};
|
44
routes/api/players.ts
Normal file
44
routes/api/players.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Handlers } from "$fresh/server.ts";
|
||||||
|
import { SERVER_STATE } from "../../state/serverState.ts";
|
||||||
|
import { getActivePlayers } from "../../util/players.ts";
|
||||||
|
|
||||||
|
export const handler: Handlers = {
|
||||||
|
GET(_req, _ctx) {
|
||||||
|
let listener: (e: CustomEvent) => void;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
async start(controller){
|
||||||
|
console.log('did the thing')
|
||||||
|
if (SERVER_STATE.status !== 'running') return;
|
||||||
|
const players = await getActivePlayers();
|
||||||
|
|
||||||
|
const event = `event:players\ndata:${JSON.stringify(players)}\n\n`
|
||||||
|
|
||||||
|
controller.enqueue(event);
|
||||||
|
console.log('sent the thing')
|
||||||
|
|
||||||
|
listener = async (e: CustomEvent<string>) => {
|
||||||
|
console.log('message received')
|
||||||
|
if (e.detail.includes('joined the game') || e.detail.includes('lost connection')) {
|
||||||
|
console.log('connection change')
|
||||||
|
const players = await getActivePlayers();
|
||||||
|
const event = `event: players\ndata: ${JSON.stringify(players)}\n\n`
|
||||||
|
|
||||||
|
controller.enqueue(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.addEventListener('stdoutmsg' as any, listener);
|
||||||
|
console.log('listened the thing')
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
console.log('cancelled')
|
||||||
|
globalThis.removeEventListener('stdoutmsg' as any, listener);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(body.pipeThrough(new TextEncoderStream()), { headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"cache-control": "no-cache"
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
}
|
@ -1 +0,0 @@
|
|||||||
// const
|
|
12
routes/players.tsx
Normal file
12
routes/players.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Content } from "../components/Content.tsx";
|
||||||
|
import { ActivePlayerList } from "../islands/players.tsx";
|
||||||
|
|
||||||
|
export default function PlayerManger() {
|
||||||
|
return (
|
||||||
|
<div className="container p-8">
|
||||||
|
<Content>
|
||||||
|
<ActivePlayerList />
|
||||||
|
</Content>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
32
routes/properties.tsx
Normal file
32
routes/properties.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Handlers } from "$fresh/server.ts";
|
||||||
|
import { MCProperties } from "../components/properties.tsx";
|
||||||
|
import {
|
||||||
|
deserializeMCProperties,
|
||||||
|
serializeMCProperties,
|
||||||
|
} from "../util/mcProperties.ts";
|
||||||
|
|
||||||
|
export const handler: Handlers = {
|
||||||
|
async POST(req, ctx) {
|
||||||
|
const filePath = `./server/server.properties`;
|
||||||
|
const formData = await req.formData();
|
||||||
|
const text = await Deno.readTextFile(filePath);
|
||||||
|
const properties = deserializeMCProperties(text);
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
properties.set(key, value as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = serializeMCProperties(properties);
|
||||||
|
|
||||||
|
await Deno.writeTextFile(filePath, serialized);
|
||||||
|
|
||||||
|
return ctx.render();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Properties() {
|
||||||
|
const text = await Deno.readTextFile(`./server/server.properties`);
|
||||||
|
const properties = await deserializeMCProperties(text);
|
||||||
|
|
||||||
|
return <MCProperties properties={properties} />;
|
||||||
|
}
|
@ -4,7 +4,7 @@ import { Content } from "../../components/Content.tsx";
|
|||||||
import { SERVER_STATE } from "../../state/serverState.ts";
|
import { SERVER_STATE } from "../../state/serverState.ts";
|
||||||
|
|
||||||
export const handler: Handlers = {
|
export const handler: Handlers = {
|
||||||
async POST(req, _ctx) {
|
POST(req, _ctx) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
SERVER_STATE.acceptEULA();
|
SERVER_STATE.acceptEULA();
|
||||||
return Response.redirect(url.origin + '/setup/start');
|
return Response.redirect(url.origin + '/setup/start');
|
||||||
|
@ -1,27 +0,0 @@
|
|||||||
import { Handlers } from "$fresh/server.ts";
|
|
||||||
import { Content } from "../../components/Content.tsx";
|
|
||||||
import { Terminal } from "../../islands/terminal.tsx";
|
|
||||||
import { SERVER_STATE } from "../../state/serverState.ts";
|
|
||||||
|
|
||||||
export const handler: Handlers = {
|
|
||||||
async GET(req,ctx) {
|
|
||||||
if (!SERVER_STATE.eulaAccepted) {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
return Response.redirect(`${url.origin}/setup/eula`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await ctx.render();
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StartFabric() {
|
|
||||||
SERVER_STATE.startMCServer();
|
|
||||||
return (
|
|
||||||
<div class="container p-8">
|
|
||||||
<Content>
|
|
||||||
<Terminal channelId={SERVER_STATE.channelId} />
|
|
||||||
</Content>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
18
routes/terminal.tsx
Normal file
18
routes/terminal.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Content } from "../components/Content.tsx";
|
||||||
|
import { StatusManager } from "../islands/statusManager.tsx";
|
||||||
|
import { Terminal } from "../islands/terminal.tsx";
|
||||||
|
import { SERVER_STATE } from "../state/serverState.ts";
|
||||||
|
|
||||||
|
export default function TerminalPage() {
|
||||||
|
const chId = SERVER_STATE.channelId;
|
||||||
|
return (
|
||||||
|
<div class="container p-8">
|
||||||
|
<Content>
|
||||||
|
<div>
|
||||||
|
<StatusManager class="ml-auto" />
|
||||||
|
</div>
|
||||||
|
<Terminal channelId={chId} />
|
||||||
|
</Content>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,49 +1,67 @@
|
|||||||
import { Tail } from "npm:tail";
|
|
||||||
import { Sockpuppet } from "puppet/client";
|
import { Sockpuppet } from "puppet/client";
|
||||||
import { acceptEULA, checkEULA } from "../util/EULA.ts";
|
import { acceptEULA, checkEULA } from "../util/EULA.ts";
|
||||||
|
import { Loader } from "../types/mcgrizzconf.ts";
|
||||||
|
import { updateConfFile } from "../util/confFile.ts";
|
||||||
|
import { IS_BROWSER } from "$fresh/runtime.ts";
|
||||||
|
|
||||||
|
type MCServerEvent = 'message';
|
||||||
|
type MCServerEventCallback = (msg: string) => void;
|
||||||
|
|
||||||
class ServerState {
|
class ServerState {
|
||||||
private status: "running" | "stopped" = "stopped";
|
private _status: "running" | "stopped" = "stopped";
|
||||||
|
public get status() {
|
||||||
|
return this._status;
|
||||||
|
}
|
||||||
|
|
||||||
private command!: Deno.Command;
|
private command!: Deno.Command;
|
||||||
private process!: Deno.ChildProcess;
|
private process!: Deno.ChildProcess;
|
||||||
|
|
||||||
private tail: Tail;
|
|
||||||
|
|
||||||
private _eulaAccepted: boolean;
|
private _eulaAccepted: boolean;
|
||||||
|
|
||||||
private sockpuppet!: Sockpuppet;
|
private sockpuppet!: Sockpuppet;
|
||||||
private _channelId = "blanaba";
|
private _channelId = "blanaba";
|
||||||
public get channelId () {
|
public get channelId() {
|
||||||
return this._channelId;
|
return this._channelId;
|
||||||
}
|
}
|
||||||
public get eulaAccepted() {
|
public get eulaAccepted() {
|
||||||
return this._eulaAccepted;
|
return this._eulaAccepted;
|
||||||
}
|
}
|
||||||
|
private stdin!: WritableStreamDefaultWriter;
|
||||||
|
|
||||||
|
private _serverType: Loader = 'unset';
|
||||||
|
public get serverType(): Loader {
|
||||||
|
return this.serverType;
|
||||||
|
}
|
||||||
|
public set serverType(loader: Loader) {
|
||||||
|
updateConfFile({loader});
|
||||||
|
this._serverType = loader;
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._eulaAccepted = checkEULA();
|
this._eulaAccepted = checkEULA();
|
||||||
this.sockpuppet = new Sockpuppet(
|
this.sockpuppet = new Sockpuppet(
|
||||||
"ws://sockpuppet.cyborggrizzly.com",
|
"ws://sockpuppet.cyborggrizzly.com",
|
||||||
() => {
|
() => {
|
||||||
this.sockpuppet.joinChannel(this.channelId, (msg) => {this.sendStdIn(msg)});
|
this.sockpuppet.joinChannel(this.channelId, (msg) => {
|
||||||
|
this.sendStdIn(msg);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get stdin() {
|
|
||||||
return this.process.stdin;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get channel() {
|
public get channel() {
|
||||||
return this.sockpuppet.getChannel(this.channelId)
|
return this.sockpuppet.getChannel(this.channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendStdIn(message: string) {
|
public async sendStdIn(message: string) {
|
||||||
const msg = new TextEncoder().encode(message);
|
if (IS_BROWSER || !this.stdin) return;
|
||||||
this.process.stdin.getWriter().write(msg);
|
|
||||||
|
const msg = new TextEncoder().encode(message + "\n");
|
||||||
|
|
||||||
|
await this.stdin.write(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// "instance" should be moved to a private member once multi-instance support is implemented
|
// "instance" should be moved to a private member once multi-instance support is implemented
|
||||||
public startMCServer(instance = "server") {
|
public startMCServer(instance = "server") {
|
||||||
this.command = new Deno.Command("java", {
|
this.command = new Deno.Command("java", {
|
||||||
@ -60,6 +78,11 @@ class ServerState {
|
|||||||
|
|
||||||
this.process = this.command.spawn();
|
this.process = this.command.spawn();
|
||||||
|
|
||||||
|
const {readable, writable} = new TransformStream();
|
||||||
|
readable.pipeTo(this.process.stdin);
|
||||||
|
this.stdin = writable.getWriter();
|
||||||
|
|
||||||
|
this._status = "running";
|
||||||
this.startStream();
|
this.startStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,14 +91,39 @@ class ServerState {
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const {done, value} = await stream.read();
|
const { done, value } = await stream.read();
|
||||||
if (value) {
|
if (value) {
|
||||||
this.channel?.send(decoder.decode(value));
|
const line = decoder.decode(value);
|
||||||
|
this.channel?.send(line);
|
||||||
|
const stdoutMsg = new CustomEvent('stdoutmsg', {
|
||||||
|
detail: line
|
||||||
|
})
|
||||||
|
globalThis.dispatchEvent(stdoutMsg);
|
||||||
}
|
}
|
||||||
if (done) break;
|
if (done) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public gracefullyStopMCServer() {
|
||||||
|
this._status = "stopped";
|
||||||
|
this.sendStdIn("stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
public forceStopMCServer() {
|
||||||
|
this._status = "stopped";
|
||||||
|
this.process.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async restartMCServer() {
|
||||||
|
if (this.status === "running") {
|
||||||
|
await this.sendStdIn("stop");
|
||||||
|
// while (true) {
|
||||||
|
await this.process.status;
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
this.startMCServer();
|
||||||
|
}
|
||||||
|
|
||||||
public acceptEULA() {
|
public acceptEULA() {
|
||||||
this._eulaAccepted = true;
|
this._eulaAccepted = true;
|
||||||
acceptEULA();
|
acceptEULA();
|
||||||
|
File diff suppressed because one or more lines are too long
18
util/EULA.ts
18
util/EULA.ts
@ -1,11 +1,13 @@
|
|||||||
|
import { IS_BROWSER } from "$fresh/runtime.ts";
|
||||||
|
|
||||||
const eulaRegex = /(eula=false)/;
|
const eulaRegex = /(eula=false)/;
|
||||||
export const checkEULA = (instance = 'server') =>
|
export const checkEULA = (instance = "server") =>
|
||||||
!eulaRegex.test(Deno.readTextFileSync(`./${instance}/eula.txt`));
|
!IS_BROWSER && !eulaRegex.test(Deno.readTextFileSync(`./${instance}/eula.txt`));
|
||||||
|
// true;
|
||||||
|
|
||||||
|
export const acceptEULA = (instance = "server") => {
|
||||||
export const acceptEULA = (instance = 'server') => {
|
|
||||||
const eula = Deno.readTextFileSync(`./${instance}/eula.txt`);
|
const eula = Deno.readTextFileSync(`./${instance}/eula.txt`);
|
||||||
const mod = eula.replace(eulaRegex, 'eula=true');
|
const mod = eula.replace(eulaRegex, "eula=true");
|
||||||
console.log(mod)
|
console.log(mod);
|
||||||
Deno.writeTextFileSync(`./${instance}/eula.txt`, mod);
|
!IS_BROWSER && Deno.writeTextFileSync(`./${instance}/eula.txt`, mod);
|
||||||
}
|
};
|
||||||
|
30
util/confFile.ts
Normal file
30
util/confFile.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { MCGrizzConf } from "../types/mcgrizzconf.ts";
|
||||||
|
|
||||||
|
const defaultConf: MCGrizzConf = {
|
||||||
|
loader: 'unset',
|
||||||
|
}
|
||||||
|
|
||||||
|
const confPath = 'mcgrizz.json'
|
||||||
|
|
||||||
|
export function makeConfFile(): MCGrizzConf {
|
||||||
|
|
||||||
|
Deno.writeTextFileSync(confPath, JSON.stringify(defaultConf, null, 2), {create: true});
|
||||||
|
|
||||||
|
return defaultConf;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfFile(): MCGrizzConf {
|
||||||
|
const conf = JSON.parse(Deno.readTextFileSync(confPath));
|
||||||
|
|
||||||
|
if (!conf) {
|
||||||
|
return makeConfFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
return conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConfFile(newConf: Partial<MCGrizzConf>) {
|
||||||
|
const conf = {...getConfFile(), newConf};
|
||||||
|
|
||||||
|
await Deno.writeTextFile(confPath, JSON.stringify(conf, null, 2));
|
||||||
|
}
|
1
util/filters.ts
Normal file
1
util/filters.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const filterTruthy = (a: unknown) => !!a
|
@ -1,6 +1,6 @@
|
|||||||
import { MCGrizzConf } from "../types/mcgrizzconf.ts";
|
import { MCGrizzConf } from "../types/mcgrizzconf.ts";
|
||||||
import { NavItem } from "../types/nav.ts";
|
import { NavItem } from "../types/nav.ts";
|
||||||
import { makeConfFile } from "./makeConfFile.ts";
|
import { makeConfFile } from "./confFile.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Determines the state of setup and returns the nav items for that state
|
* @description Determines the state of setup and returns the nav items for that state
|
||||||
@ -8,7 +8,7 @@ import { makeConfFile } from "./makeConfFile.ts";
|
|||||||
export function getNavItems(): NavItem[] {
|
export function getNavItems(): NavItem[] {
|
||||||
let conf: MCGrizzConf;
|
let conf: MCGrizzConf;
|
||||||
try {
|
try {
|
||||||
conf = JSON.parse(Deno.readTextFileSync('mcgrizz.json'));
|
conf = JSON.parse(Deno.readTextFileSync("mcgrizz.json"));
|
||||||
} catch {
|
} catch {
|
||||||
conf = makeConfFile();
|
conf = makeConfFile();
|
||||||
}
|
}
|
||||||
@ -16,12 +16,25 @@ export function getNavItems(): NavItem[] {
|
|||||||
switch (conf.loader) {
|
switch (conf.loader) {
|
||||||
case "unset":
|
case "unset":
|
||||||
return [{
|
return [{
|
||||||
title: 'Setup',
|
title: "Setup",
|
||||||
href: '/',
|
href: "/",
|
||||||
}]
|
}];
|
||||||
case "forge":
|
case "forge":
|
||||||
case "fabric":
|
case "fabric":
|
||||||
case "vanilla":
|
case "vanilla":
|
||||||
return [];
|
return [
|
||||||
|
{
|
||||||
|
title: "Server Terminal",
|
||||||
|
href: "/terminal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Players",
|
||||||
|
href: "/players",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Server Properties",
|
||||||
|
href: "/properties",
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
import { MCGrizzConf } from "../types/mcgrizzconf.ts";
|
|
||||||
|
|
||||||
export function makeConfFile(): MCGrizzConf {
|
|
||||||
const conf: MCGrizzConf = {
|
|
||||||
loader: 'unset',
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.writeTextFileSync('mcgrizz.json', JSON.stringify(conf), {create: true});
|
|
||||||
|
|
||||||
return conf;
|
|
||||||
}
|
|
24
util/mcProperties.ts
Normal file
24
util/mcProperties.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { filterTruthy } from "./filters.ts";
|
||||||
|
|
||||||
|
export const deserializeMCProperties = (serialized: string) => {
|
||||||
|
const propertiesMap = new Map<string,string>();
|
||||||
|
|
||||||
|
const commentRegex = /#.+\r?\n/g;
|
||||||
|
serialized.replace(commentRegex, '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter(filterTruthy)
|
||||||
|
.forEach(prop => {
|
||||||
|
const [key, value] = prop.split('=');
|
||||||
|
propertiesMap.set(key, value ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return propertiesMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serializeMCProperties = (deserialized: Map<string,string>) => {
|
||||||
|
let text = '';
|
||||||
|
for (const [key,value] of deserialized.entries()) {
|
||||||
|
text += `${key}=${value}\n`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
46
util/players.ts
Normal file
46
util/players.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { SERVER_STATE } from "../state/serverState.ts";
|
||||||
|
import { filterTruthy } from "./filters.ts";
|
||||||
|
|
||||||
|
const playerListRegex= /There are [0-9] of a max of [0-9]+ players online:/
|
||||||
|
|
||||||
|
export const getActivePlayers = (): Promise<PlayerData[]> => new Promise(res => {
|
||||||
|
const listener = async (e: CustomEvent<string>) => {
|
||||||
|
if (playerListRegex.test(e.detail)) {
|
||||||
|
const players: PlayerData[] = []
|
||||||
|
const [_, playerString] = e.detail.split(playerListRegex);
|
||||||
|
for (const playerName of playerString.split(', ')) {
|
||||||
|
players.push(await getPlayerData(playerName));
|
||||||
|
}
|
||||||
|
res(players.filter(filterTruthy));
|
||||||
|
globalThis.removeEventListener('stdoutmsg' as any, listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.addEventListener('stdoutmsg' as any, listener);
|
||||||
|
SERVER_STATE.sendStdIn('list');
|
||||||
|
})
|
||||||
|
|
||||||
|
export type PlayerData = {
|
||||||
|
username: string;
|
||||||
|
id: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlayerData = async (username: string) => {
|
||||||
|
username = username.trim();
|
||||||
|
if (!username) return;
|
||||||
|
const cacheFile = 'players.cache.json'
|
||||||
|
await Deno.create(cacheFile);
|
||||||
|
const cache = JSON.parse(await Deno.readTextFile(cacheFile) || '{}');
|
||||||
|
if (!cache[username]) {
|
||||||
|
const req = await fetch('https://playerdb.co/api/player/minecraft/' + username, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "MCGRIZZ/0.1 emma@cyborggrizzly.com"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const json = await req.json();
|
||||||
|
cache[username] = json.data.player;
|
||||||
|
await Deno.writeTextFile(cacheFile, JSON.stringify(cache, null, 2), {create: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache[username]
|
||||||
|
}
|
@ -1,17 +0,0 @@
|
|||||||
import { startTail } from "./startTail.ts";
|
|
||||||
|
|
||||||
export async function startMCServer(instance = "server") {
|
|
||||||
const start = new Deno.Command("java", {
|
|
||||||
args: [
|
|
||||||
"-Xmx2G",
|
|
||||||
"-jar",
|
|
||||||
"./server.jar",
|
|
||||||
"nogui",
|
|
||||||
],
|
|
||||||
cwd: "./" + instance,
|
|
||||||
});
|
|
||||||
|
|
||||||
start.output();
|
|
||||||
|
|
||||||
return await startTail(instance);
|
|
||||||
}
|
|
@ -1,25 +0,0 @@
|
|||||||
import { Sockpuppet } from "puppet/client";
|
|
||||||
import { Tail } from "npm:tail";
|
|
||||||
|
|
||||||
export function startTail(instance = "server") {
|
|
||||||
return new Promise<[Tail, string]>((resolve) => {
|
|
||||||
const puppet = new Sockpuppet("ws://sockpuppet.cyborggrizzly.com", () => {
|
|
||||||
const channelId = crypto.randomUUID();
|
|
||||||
puppet.createChannel(channelId);
|
|
||||||
puppet.joinChannel(channelId, (line) => {
|
|
||||||
console.log(line);
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const path = `./${instance}/logs/latest.log`;
|
|
||||||
const tail = new Tail(path, { follow: true });
|
|
||||||
|
|
||||||
tail.on("line", (line: string) => {
|
|
||||||
puppet.getChannel(channelId)?.send(line);
|
|
||||||
console.log(line);
|
|
||||||
});
|
|
||||||
resolve([tail, channelId]);
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user