Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ dashmap = "7.0.0-rc2"

futures = "0.3.*"

tokio = { version = "1.53.*", features = ["full"] }
tokio = { version = "1.54.*", features = ["full"] }

serde = { version = "1.0.*", features = ["derive"] }
serde_json = "1.0.*"
Expand Down
160 changes: 160 additions & 0 deletions frontend/app/core/lib/servers-dat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { describe, it, expect } from "vitest"
import {
buildServersDat,
orderServersForExport,
toServersDatAddress,
type SortableServer,
} from "./servers-dat"

// Minimal NBT reader, just enough to validate the generated servers.dat.
function readU16BE(bytes: Uint8Array, offset: number): number {
return (bytes[offset]! * 256 + bytes[offset + 1]!) >>> 0
}

function readI32BE(bytes: Uint8Array, offset: number): number {
return (
((bytes[offset]! << 24) |
(bytes[offset + 1]! << 16) |
(bytes[offset + 2]! << 8) |
bytes[offset + 3]!) >>>
0
)
}

const decoder = new TextDecoder()

function readString(bytes: Uint8Array, offset: number): { value: string; next: number } {
const len = readU16BE(bytes, offset)
const value = decoder.decode(bytes.slice(offset + 2, offset + 2 + len))
return { value, next: offset + 2 + len }
}

function parseServersDat(bytes: Uint8Array): { name: string; ip: string }[] {
let o = 0
// Root TAG_Compound with empty name.
expect(bytes[o++]).toBe(0x0a)
expect(readU16BE(bytes, o)).toBe(0)
o += 2
// TAG_List "servers" of compounds.
expect(bytes[o++]).toBe(0x09)
const listName = readString(bytes, o)
expect(listName.value).toBe("servers")
o = listName.next
expect(bytes[o++]).toBe(0x0a)
const count = readI32BE(bytes, o)
o += 4
const servers: { name: string; ip: string }[] = []
for (let i = 0; i < count; i++) {
const fields: Record<string, string> = {}
for (;;) {
const tag = bytes[o++]!
if (tag === 0x00) break
expect(tag).toBe(0x08)
const field = readString(bytes, o)
o = field.next
const val = readString(bytes, o)
o = val.next
fields[field.value] = val.value
}
servers.push({ name: fields["name"]!, ip: fields["ip"]! })
}
// Root TAG_End + nothing trailing.
expect(bytes[o++]).toBe(0x00)
expect(o).toBe(bytes.length)
return servers
}

describe("orderServersForExport", () => {
const servers: SortableServer[] = [
{ id: 1, name: "Bravo", last_status: "online", last_connected: 50 },
{ id: 2, name: "Alpha", last_status: "online", last_connected: 200 },
{ id: 3, name: "Charlie", last_status: "offline", last_connected: 999 },
{ id: 4, name: "Delta", last_status: "offline", last_connected: 10 },
]
const names = (list: SortableServer[]) => list.map((s) => s.name)

it("puts online servers first by default, sorted by players desc", () => {
expect(names(orderServersForExport(servers))).toEqual([
"Alpha",
"Bravo",
"Delta",
"Charlie",
])
})

it("supports ascending popularity", () => {
expect(names(orderServersForExport(servers, { sort: "popularityAsc" }))).toEqual([
"Bravo",
"Alpha",
"Delta",
"Charlie",
])
})

it("places offline servers first when requested", () => {
expect(
names(orderServersForExport(servers, { offlinePlacement: "first" }))
).toEqual(["Delta", "Charlie", "Alpha", "Bravo"])
})

it("drops offline servers with ignoreOffline", () => {
expect(names(orderServersForExport(servers, { ignoreOffline: true }))).toEqual([
"Alpha",
"Bravo",
])
})

it("supports name and recent sorts", () => {
expect(names(orderServersForExport(servers, { sort: "name" }))).toEqual([
"Alpha",
"Bravo",
"Charlie",
"Delta",
])
expect(names(orderServersForExport(servers, { sort: "recent" }))).toEqual([
"Alpha",
"Bravo",
"Delta",
"Charlie",
])
})
})
describe("toServersDatAddress", () => {
it("omits the default Java port", () => {
expect(toServersDatAddress("mc.example.com", 25565)).toBe("mc.example.com")
})

it("keeps custom ports", () => {
expect(toServersDatAddress("mc.example.com", 25566)).toBe("mc.example.com:25566")
})
})

describe("buildServersDat", () => {
it("writes a valid servers.dat round-trip", () => {
const parsed = parseServersDat(
buildServersDat([
{ name: "Hypixel", ip: "mc.hypixel.net", port: 25565 },
{ name: "Custom", ip: "play.example.com", port: 25566 },
])
)
expect(parsed).toEqual([
{ name: "Hypixel", ip: "mc.hypixel.net" },
{ name: "Custom", ip: "play.example.com:25566" },
])
})

it("falls back to the ip when the name is blank", () => {
const parsed = parseServersDat(
buildServersDat([{ name: " ", ip: "mc.example.com", port: 25565 }])
)
expect(parsed).toEqual([{ name: "mc.example.com", ip: "mc.example.com" }])
})

it("handles unicode names and an empty list", () => {
const parsed = parseServersDat(
buildServersDat([{ name: "Érisium ⚔", ip: "mc.erisium.fr", port: 25565 }])
)
expect(parsed).toEqual([{ name: "Érisium ⚔", ip: "mc.erisium.fr" }])
expect(parseServersDat(buildServersDat([]))).toEqual([])
})
})
180 changes: 180 additions & 0 deletions frontend/app/core/lib/servers-dat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Client-side builder for Minecraft Java Edition `servers.dat` files.
*
* `servers.dat` is an uncompressed NBT file: a root TAG_Compound (empty
* name) containing a TAG_List named "servers" of TAG_Compound entries, each
* with a TAG_String "name" and a TAG_String "ip" ("host:port", the port being
* omitted for the 25565 default).
*
* Everything runs in the browser: no new dependency, no server round-trip.
* Bedrock servers are excluded by the caller (servers.dat is Java-only).
*/

export interface ServersDatEntry {
name: string
ip: string
port: number
}

export const SERVERS_DAT_DEFAULT_PORT = 25565

/** Minimal shape needed to order servers before export. */
export interface SortableServer {
id: number
name: string
last_status?: string | null
last_connected?: number | null
}

export type ServersDatSort = "popularity" | "popularityAsc" | "name" | "recent"
export type ServersDatOfflinePlacement = "last" | "first"

export interface ServersDatExportOptions {
sort?: ServersDatSort
offlinePlacement?: ServersDatOfflinePlacement
ignoreOffline?: boolean
}

/** Online servers rank by live player count, offline ones always last. */
function playerCountForSort(server: SortableServer): number {
return server.last_status === "online" ? (server.last_connected ?? 0) : -1
}

function sortServerGroup<T extends SortableServer>(list: T[], sort: ServersDatSort): T[] {
const arr = [...list]
switch (sort) {
case "popularityAsc":
arr.sort(
(a, b) =>
playerCountForSort(a) - playerCountForSort(b) || b.id - a.id
)
break
case "name":
arr.sort((a, b) => a.name.localeCompare(b.name))
break
case "recent":
arr.sort((a, b) => b.id - a.id)
break
case "popularity":
default:
arr.sort(
(a, b) =>
playerCountForSort(b) - playerCountForSort(a) || b.id - a.id
)
break
}
return arr
}

/**
* Orders servers for export: each status group (online/offline) is sorted
* with the chosen criterion, then concatenated according to
* `offlinePlacement` — or offline servers are dropped with `ignoreOffline`.
*/
export function orderServersForExport<T extends SortableServer>(
servers: T[],
options: ServersDatExportOptions = {}
): T[] {
const { sort = "popularity", offlinePlacement = "last", ignoreOffline = false } = options
const online = sortServerGroup(
servers.filter((s) => s.last_status === "online"),
sort
)
if (ignoreOffline) return online
const offline = sortServerGroup(
servers.filter((s) => s.last_status !== "online"),
sort
)
return offlinePlacement === "first" ? [...offline, ...online] : [...online, ...offline]
}

/** "host:port" address as expected by the Minecraft client. */
export function toServersDatAddress(ip: string, port: number): string {
return port === SERVERS_DAT_DEFAULT_PORT ? ip : `${ip}:${port}`
}

const encoder = new TextEncoder()

function pushU16BE(out: number[], value: number): void {
out.push((value >>> 8) & 0xff, value & 0xff)
}

function pushI32BE(out: number[], value: number): void {
out.push(
(value >>> 24) & 0xff,
(value >>> 16) & 0xff,
(value >>> 8) & 0xff,
value & 0xff
)
}

function pushStringPayload(out: number[], value: string): void {
const bytes = encoder.encode(value)
pushU16BE(out, bytes.length)
for (const b of bytes) out.push(b)
}

/** TAG_String with its field name, e.g. (0x08, "name", value). */
function pushNamedString(out: number[], fieldName: string, value: string): void {
out.push(0x08)
const nameBytes = encoder.encode(fieldName)
pushU16BE(out, nameBytes.length)
for (const b of nameBytes) out.push(b)
pushStringPayload(out, value)
}

/**
* Builds an uncompressed NBT `servers.dat` payload for the given servers.
* Returns the raw file bytes, ready to be saved as "servers.dat".
*/
export function buildServersDat(servers: ServersDatEntry[]): Uint8Array {
const out: number[] = []

// Root TAG_Compound with an empty name.
out.push(0x0a, 0x00, 0x00)

// TAG_List "servers" of TAG_Compound (element type 0x0a).
out.push(0x09)
const listName = encoder.encode("servers")
pushU16BE(out, listName.length)
for (const b of listName) out.push(b)
out.push(0x0a)
pushI32BE(out, servers.length)

for (const server of servers) {
const name = server.name.trim() || server.ip
pushNamedString(out, "name", name)
pushNamedString(out, "ip", toServersDatAddress(server.ip, server.port))
// TAG_End: closes the server compound.
out.push(0x00)
}

// TAG_End: closes the root compound.
out.push(0x00)

return new Uint8Array(out)
}

/**
* Triggers the download of a `servers.dat` file in the browser.
* Returns the number of servers written.
*/export function downloadServersDat(
servers: ServersDatEntry[],
filename = "servers.dat"
): number {
const bytes = buildServersDat(servers)
// Copy into a fresh ArrayBuffer: `bytes.buffer` may be a shared pool
// whose byteOffset/length upset some Blob implementations.
const buffer = new Uint8Array(bytes.length)
buffer.set(bytes)
const blob = new Blob([buffer], { type: "application/octet-stream" })
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
return servers.length
}
19 changes: 19 additions & 0 deletions frontend/app/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,25 @@
"noSearchResults": "Keine Server entsprechen Ihrer Suche \"{{query}}\".",
"noFilterResults": "Keine Server entsprechen diesen Filtern.",
"error": "Fehler beim Laden der Server",
"exportServersDat": "Alle Server exportieren",
"exportServersDatTitle": "Liste als servers.dat exportieren (Minecraft Java)",
"exported": "{{count}} Server in servers.dat exportiert",
"exportEmpty": "Keine Java-Server zum Exportieren",
"exportError": "Export von servers.dat fehlgeschlagen",
"exportTitle": "Als servers.dat exportieren",
"exportDescription": "Wähle die Reihenfolge der Server in deiner Minecraft-Multiplayer-Liste.",
"exportSortLabel": "Serverreihenfolge",
"exportSortPopularity": "Meiste Spieler zuerst",
"exportSortPopularityAsc": "Wenigste Spieler zuerst",
"exportSortName": "Name (A–Z)",
"exportSortRecent": "Zuletzt hinzugefügt zuerst",
"exportOfflineLabel": "Offline-Server",
"exportOfflineLast": "Zuletzt",
"exportOfflineFirst": "Zuerst",
"exportIgnoreOffline": "Offline-Server überspringen",
"exportPreview": "{{count}} Server werden exportiert",
"exportBedrockNote": "{{skipped}} Bedrock-Server ausgeschlossen (nur Java)",
"exportConfirm": "Exportieren",
"filters": {
"all": "Alle",
"online": "Online",
Expand Down
Loading
Loading