From 1db38e488366fa344319808d936ac94fa4da208f Mon Sep 17 00:00:00 2001 From: Flash303 Date: Wed, 14 Oct 2026 19:18:48 +0200 Subject: [PATCH 1/2] fix: protocol version parser --- frontend/app/core/lib/minecraft-protocols.ts | 98 +++++++++++++++++++ .../app/core/lib/minecraft-version.test.ts | 71 ++++++++++++++ frontend/app/core/lib/utils.ts | 32 ++++-- .../admin/components/ServersTab/index.tsx | 4 +- .../app/pages/home/components/ServerCard.tsx | 8 +- .../components/ServerDetailHeader.tsx | 9 +- frontend/app/pages/server-detail/index.tsx | 10 +- frontend/package.json | 1 + .../scripts/update-minecraft-protocols.mjs | 86 ++++++++++++++++ 9 files changed, 299 insertions(+), 20 deletions(-) create mode 100644 frontend/app/core/lib/minecraft-protocols.ts create mode 100644 frontend/app/core/lib/minecraft-version.test.ts create mode 100644 frontend/scripts/update-minecraft-protocols.mjs diff --git a/frontend/app/core/lib/minecraft-protocols.ts b/frontend/app/core/lib/minecraft-protocols.ts new file mode 100644 index 0000000..33a42d4 --- /dev/null +++ b/frontend/app/core/lib/minecraft-protocols.ts @@ -0,0 +1,98 @@ +/** + * Mapping protocole réseau Java <-> version Minecraft affichable. + * + * Source de vérité (upstream, maintenu par bot à chaque sortie) : + * https://github.com/PrismarineJS/minecraft-data/blob/master/data/pc/common/protocolVersions.json + * (URL stable sur la branche `master`, auto-mise à jour — voir l'historique + * "Add 1.21.x to pc protocolVersions.json"). + * + * Pourquoi une copie vendored plutôt qu'un fetch runtime ? + * - Déterminisme SSR + client : la même table est utilisée des deux côtés, donc + * aucun risque de mismatch d'hydratation (#418) ni de dépendance réseau externe + * au rendu (cf. AGENTS.md : le data-fetching reste côté client, le rendu doit + * rester fluide et synchrone). + * - Pas de latence / panne d'un CDN tiers sur le chemin critique d'affichage. + * + * Mise à jour (à relancer à chaque version mineure de Minecraft, ou via CI) : + * bun run update:protocols + * Le script `scripts/update-minecraft-protocols.mjs` régénère le bloc + * `JAVA_RELEASE_PROTOCOL_VERSIONS` ci-dessous depuis l'URL upstream. + * + * Périmètre : releases Java uniquement (`usesNetty: true`, `releaseType: "release"`). + * Les snapshots / pre-releases / RCs partagent le même espace de numéros avec des + * valeurs sentinelles (>= 2^30) et ne sont pas affichés. Bedrock utilise une + * numérotation différente : aucun fallback protocole pour `type: "bedrock"`. + */ + +// <-- BEGIN GENERATED BLOCK (ne pas éditer à la main, voir `bun run update:protocols`) --> +export const JAVA_RELEASE_PROTOCOL_VERSIONS: Record = { + 4: "1.7.2 - 1.7.5", + 5: "1.7.6 - 1.7.10", + 47: "1.8 - 1.8.9", + 107: "1.9", + 108: "1.9.1", + 109: "1.9.2", + 110: "1.9.3 - 1.9.4", + 210: "1.10 - 1.10.2", + 315: "1.11", + 316: "1.11.1 - 1.11.2", + 335: "1.12", + 338: "1.12.1", + 340: "1.12.2", + 393: "1.13", + 401: "1.13.1", + 404: "1.13.2", + 477: "1.14", + 480: "1.14.1", + 485: "1.14.2", + 490: "1.14.3", + 498: "1.14.4", + 573: "1.15", + 575: "1.15.1", + 578: "1.15.2", + 735: "1.16", + 736: "1.16.1", + 751: "1.16.2", + 753: "1.16.3", + 754: "1.16.4 - 1.16.5", + 755: "1.17", + 756: "1.17.1", + 757: "1.18 - 1.18.1", + 758: "1.18.2", + 759: "1.19", + 760: "1.19.1 - 1.19.2", + 761: "1.19.3", + 762: "1.19.4", + 763: "1.20 - 1.20.1", + 764: "1.20.2", + 765: "1.20.3 - 1.20.4", + 766: "1.20.5 - 1.20.6", + 767: "1.21 - 1.21.1", + 768: "1.21.2 - 1.21.3", + 769: "1.21.4", + 770: "1.21.5", + 771: "1.21.6", + 772: "1.21.7 - 1.21.8", + 773: "1.21.9 - 1.21.10", + 774: "1.21.11", + 775: "26.1 - 26.1.2", + 776: "26.2", + 777: "26.3", +} +// <-- END GENERATED BLOCK --> + +/** + * Résout un numéro de protocole réseau en version Minecraft affichable. + * + * @returns la version (ex. "1.21.4", "1.21.7 - 1.21.8"), ou `null` si : + * - le protocole est absent, inconnu ou sentinelle (<= 0 : maintenance, MOTD custom), + * - le serveur est Bedrock (numérotation différente, non couverte). + */ +export function resolveProtocolVersion( + protocolVersion: number | null | undefined, + serverType?: string | null, +): string | null { + if (protocolVersion == null || protocolVersion <= 0) return null + if (serverType === "bedrock") return null + return JAVA_RELEASE_PROTOCOL_VERSIONS[protocolVersion] ?? null +} diff --git a/frontend/app/core/lib/minecraft-version.test.ts b/frontend/app/core/lib/minecraft-version.test.ts new file mode 100644 index 0000000..7c224cd --- /dev/null +++ b/frontend/app/core/lib/minecraft-version.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest" +import { formatMinecraftVersion, resolveServerVersion } from "./utils" +import { resolveProtocolVersion } from "./minecraft-protocols" + +describe("formatMinecraftVersion", () => { + it("returns null for missing versions", () => { + expect(formatMinecraftVersion(null)).toBeNull() + expect(formatMinecraftVersion(undefined)).toBeNull() + expect(formatMinecraftVersion("")).toBeNull() + }) + + it("never returns raw custom names (reported bug)", () => { + // Serveur avec un nom de version custom : aucun motif X.Y(.Z) -> null, + // jamais la chaîne brute ("Version ᴏɴʟɪɴᴇ: 12136 ..."). + expect(formatMinecraftVersion("ᴏɴʟɪɴᴇ: 12136 ɢʀᴀᴄᴢʏ")).toBeNull() + expect(formatMinecraftVersion("Online: 12136")).toBeNull() + expect(formatMinecraftVersion("Maintenance")).toBeNull() + }) + + it("extracts single versions", () => { + expect(formatMinecraftVersion("1.20.1")).toBe("1.20.1") + expect(formatMinecraftVersion("Paper 1.21.4")).toBe("1.21.4") + expect(formatMinecraftVersion("1.21.44")).toBe("1.21.44") + }) + + it("extracts ranges as first - last", () => { + expect(formatMinecraftVersion("BungeeCord 1.8 - 1.21")).toBe("1.8 - 1.21") + expect(formatMinecraftVersion("1.8.9 - 1.21.4")).toBe("1.8.9 - 1.21.4") + }) +}) + +describe("resolveProtocolVersion", () => { + it("returns null for missing or sentinel protocols", () => { + expect(resolveProtocolVersion(null, "java")).toBeNull() + expect(resolveProtocolVersion(undefined, "java")).toBeNull() + // Protocole sentinelle (maintenance / MOTD custom) -> badge masqué + expect(resolveProtocolVersion(-1, "java")).toBeNull() + expect(resolveProtocolVersion(0, "java")).toBeNull() + }) + + it("maps known Java protocols to release versions", () => { + expect(resolveProtocolVersion(769, "java")).toBe("1.21.4") + expect(resolveProtocolVersion(772, "java")).toBe("1.21.7 - 1.21.8") + expect(resolveProtocolVersion(47, "java")).toBe("1.8 - 1.8.9") + }) + + it("returns null for unknown protocols", () => { + expect(resolveProtocolVersion(999999, "java")).toBeNull() + }) + + it("never applies the Java table to Bedrock (different numbering)", () => { + expect(resolveProtocolVersion(769, "bedrock")).toBeNull() + }) +}) + +describe("resolveServerVersion", () => { + it("prefers the declared version when it is a real one", () => { + expect(resolveServerVersion("Paper 1.20.4", 769, "java")).toBe("1.20.4") + }) + + it("falls back to the protocol for custom names", () => { + expect(resolveServerVersion("ᴏɴʟɪɴᴇ: 12136 ɢʀᴀᴄᴢʏ", 769, "java")).toBe("1.21.4") + expect(resolveServerVersion(null, 772, "java")).toBe("1.21.7 - 1.21.8") + }) + + it("returns null when nothing displayable (badge hidden)", () => { + expect(resolveServerVersion("ᴏɴʟɪɴᴇ: 12136", -1, "java")).toBeNull() + expect(resolveServerVersion("Maintenance", 999999, "java")).toBeNull() + expect(resolveServerVersion(null, null, "java")).toBeNull() + }) +}) diff --git a/frontend/app/core/lib/utils.ts b/frontend/app/core/lib/utils.ts index df231a2..29f57df 100644 --- a/frontend/app/core/lib/utils.ts +++ b/frontend/app/core/lib/utils.ts @@ -1,5 +1,6 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" +import { resolveProtocolVersion } from "./minecraft-protocols" import { translate, type Language } from "./i18n" /** Normalise une langue libre en une `Language` du registre i18n (défaut: fr). */ @@ -97,19 +98,36 @@ export function parseMinecraftVersionRange(versionName: string): [string, string return [first, last]; } -export function formatMinecraftVersion(versionName: string | null | undefined, stripColors = true): string | null { +export function formatMinecraftVersion(versionName: string | null | undefined): string | null { if (!versionName) return null; const range = parseMinecraftVersionRange(versionName); + // Pas de motif `X.Y(.Z)` : nom de version custom (ex. "Online: 12136") — + // on ne renvoie JAMAIS la chaîne brute pour éviter d'afficher du texte + // arbitraire contrôlé par l'opérateur du serveur dans les badges. if (!range) { - // Fallback to original if parsing fails - if (stripColors) { - return versionName.replace(/(§x(?:§[0-9a-fA-F]){6}|§[0-9a-fk-orA-FK-OR]|&#[0-9a-fA-F]{6}|&f{[^}]+};|&s{[^}]+};|&h{[^}]*};)/g, ''); - } - return versionName; + return null; } if (range[0] === range[1]) { return range[0]; } - + return `${range[0]} - ${range[1]}`; } + +/** + * Version affichable d'un serveur : d'abord la version déclarée (`last_version` + * nettoyée par `formatMinecraftVersion`), puis — si c'est un nom custom sans + * motif de version — le numéro de protocole réseau (`last_protocol_version`, + * fiable car envoyé par le handshake, pas customisable en texte libre). + * + * @returns `null` quand rien d'affichable (protocole sentinelle <= 0 comme en + * maintenance, protocole inconnu, ou serveur Bedrock sans version lisible) : + * les appelants doivent alors masquer le badge / afficher "Version inconnue". + */ +export function resolveServerVersion( + versionName: string | null | undefined, + protocolVersion: number | null | undefined, + serverType?: string | null, +): string | null { + return formatMinecraftVersion(versionName) ?? resolveProtocolVersion(protocolVersion, serverType); +} diff --git a/frontend/app/pages/admin/components/ServersTab/index.tsx b/frontend/app/pages/admin/components/ServersTab/index.tsx index 9e62c6a..13c266e 100644 --- a/frontend/app/pages/admin/components/ServersTab/index.tsx +++ b/frontend/app/pages/admin/components/ServersTab/index.tsx @@ -18,7 +18,7 @@ import { Button } from "@/ui/components/button" import { Badge } from "@/ui/components/badge" import { Link } from "react-router" import { Pagination } from "@/ui/components/pagination" -import { formatMinecraftVersion } from "@/core/lib/utils" +import { resolveServerVersion } from "@/core/lib/utils" import type { Server, User } from "@/core/lib/api" interface ServersTabProps { @@ -294,7 +294,7 @@ export function ServersTab({ )} - v{formatMinecraftVersion(server.last_version) || "unknown"} + v{resolveServerVersion(server.last_version, server.last_protocol_version, server.type) || "unknown"} diff --git a/frontend/app/pages/home/components/ServerCard.tsx b/frontend/app/pages/home/components/ServerCard.tsx index e446915..9d8a64e 100644 --- a/frontend/app/pages/home/components/ServerCard.tsx +++ b/frontend/app/pages/home/components/ServerCard.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, lazy, Suspense } from "react" import type { Server } from "@/core/lib/api" const MiniChart = lazy(() => import("./MiniChart").then(m => ({ default: m.MiniChart }))) import { MiniChartSkeleton } from "./MiniChartSkeleton" -import { cn, getServerIp, copyServerIp, formatMinecraftVersion, formatNumber } from "@/core/lib/utils" +import { cn, getServerIp, copyServerIp, resolveServerVersion, formatNumber } from "@/core/lib/utils" import { ServerIcon } from "@/ui/components/ServerIcon" import { lunarLogoClass, labyLogoClass } from "@/core/lib/theme-colors" import { Check, Copy, Wifi, WifiOff } from "lucide-react" @@ -36,6 +36,8 @@ export function ServerCard({ server, to }: ServerCardProps) { const isOffline = server.last_status === "offline" const { displayIp } = getServerIp(server.ip, server.port, server.type) + + const displayVersion = resolveServerVersion(server.last_version, server.last_protocol_version, server.type) const labyInfo = server.client_infos?.laby; const lunarInfo = server.client_infos?.lunar; @@ -160,9 +162,9 @@ export function ServerCard({ server, to }: ServerCardProps) {
{server.type && } - {server.last_version ? ( + {displayVersion ? ( - {t("common.version", { version: formatMinecraftVersion(server.last_version) })} + {t("common.version", { version: displayVersion })} ) : ( diff --git a/frontend/app/pages/server-detail/components/ServerDetailHeader.tsx b/frontend/app/pages/server-detail/components/ServerDetailHeader.tsx index c6905b1..822debc 100644 --- a/frontend/app/pages/server-detail/components/ServerDetailHeader.tsx +++ b/frontend/app/pages/server-detail/components/ServerDetailHeader.tsx @@ -4,7 +4,7 @@ import type { Server } from "@/core/lib/api" import { Button } from "@/ui/components/button" import { Badge } from "@/ui/components/badge" import { ArrowLeft, Copy, Check, User as UserIcon, Calendar } from "lucide-react" -import { cn, getServerIp, copyServerIp, formatMinecraftVersion, formatDate } from "@/core/lib/utils" +import { cn, getServerIp, copyServerIp, resolveServerVersion, formatDate } from "@/core/lib/utils" import { ServerIcon } from "@/ui/components/ServerIcon" import { lunarLogoClass } from "@/core/lib/theme-colors" import { LunarLogo } from "@/ui/components/LunarLogo" @@ -30,6 +30,9 @@ export function ServerDetailHeader({ server, t, language }: ServerDetailHeaderPr const isOnline = server.last_status === "online" const { displayIp } = getServerIp(server.ip, server.port, server.type) + // Nom custom sans motif de version -> fallback protocole ; `null` (maintenance, + // protocole sentinelle <= 0, Bedrock illisible...) -> badge masqué. + const displayVersion = resolveServerVersion(server.last_version, server.last_protocol_version, server.type) const handleCopy = () => { copyServerIp(server.ip, server.port, server.type) @@ -102,9 +105,9 @@ export function ServerDetailHeader({ server, t, language }: ServerDetailHeaderPr )} - {server.last_version && ( + {displayVersion && ( - v{formatMinecraftVersion(server.last_version)} + v{displayVersion} )} {server.user && ( diff --git a/frontend/app/pages/server-detail/index.tsx b/frontend/app/pages/server-detail/index.tsx index 16a9d55..752b5b4 100644 --- a/frontend/app/pages/server-detail/index.tsx +++ b/frontend/app/pages/server-detail/index.tsx @@ -21,7 +21,7 @@ import { BarChart, WifiOff } from "lucide-react" import { useLanguage } from "@/core/contexts/LanguageContext" import { getTimeRanges, getIntervals } from "@/core/lib/chartUtils" -import { formatMinecraftVersion, formatNumber } from "@/core/lib/utils" +import { formatNumber } from "@/core/lib/utils" import { MinecraftMotd } from "@/ui/motd" import { ServerSidebar } from "@/pages/server-detail/components/ServerSidebar" @@ -296,10 +296,10 @@ export default function ServerDetail() { currentPlayers={ server.last_protocol_version != null && server.last_protocol_version <= 0 - ? formatMinecraftVersion( - server.last_version, - false - ) || "Version" + // Protocole sentinelle (maintenance...) : le client Minecraft + // affiche le texte brut du serveur dans la colonne joueurs. + // On reproduit fidèlement ce comportement ici (pas de badge). + ? (server.last_version?.trim() || "Version") : (server.last_connected ?? 0) } maxPlayers={ diff --git a/frontend/package.json b/frontend/package.json index 6277cdb..c45c3c7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "build": "react-router build", "lint": "eslint .", "test": "vitest run", + "update:protocols": "node scripts/update-minecraft-protocols.mjs", "start": "bun run server.ts", "typecheck": "react-router typegen && tsc", "preview": "bun run build && bun run start" diff --git a/frontend/scripts/update-minecraft-protocols.mjs b/frontend/scripts/update-minecraft-protocols.mjs new file mode 100644 index 0000000..d2f6988 --- /dev/null +++ b/frontend/scripts/update-minecraft-protocols.mjs @@ -0,0 +1,86 @@ +/** + * Régénère le bloc `JAVA_RELEASE_PROTOCOL_VERSIONS` de + * `app/core/lib/minecraft-protocols.ts` depuis l'upstream PrismarineJS. + * + * Usage : bun run update:protocols (ou `node scripts/update-minecraft-protocols.mjs`) + * + * Source (URL stable, branche `master`, auto-mise à jour par bot à chaque sortie) : + * https://raw.githubusercontent.com/PrismarineJS/minecraft-data/master/data/pc/common/protocolVersions.json + * + * Règles de filtrage (doivent rester synchronisées avec l'en-tête du fichier cible) : + * - `usesNetty === true` uniquement (exclut les protocoles pré-Netty qui réutilisent + * les mêmes numéros, ex. protocole 47 = 1.4.2 pré-Netty ET 1.8+ post-Netty), + * - releases uniquement : `releaseType === "release"`, ou (anciennes entrées sans + * `releaseType`) `minecraftVersion` de la forme stricte `X.Y` / `X.Y.Z`, + * - regroupe par numéro de protocole, tri sémantique ascendant : + * une seule version -> "1.21.4", plusieurs -> "1.21.7 - 1.21.8". + */ +import { readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const UPSTREAM_URL = + "https://raw.githubusercontent.com/PrismarineJS/minecraft-data/master/data/pc/common/protocolVersions.json" + +const TARGET_FILE = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "app", + "core", + "lib", + "minecraft-protocols.ts", +) +const BEGIN_MARKER = "// <-- BEGIN GENERATED BLOCK" +const END_MARKER = "// <-- END GENERATED BLOCK -->" + +const RELEASE_RE = /^\d+\.\d+(\.\d+)?$/ + +function versionKey(v) { + return v.split(".").map(Number) +} + +function compareVersions(a, b) { + const ka = versionKey(a) + const kb = versionKey(b) + for (let i = 0; i < Math.max(ka.length, kb.length); i++) { + const diff = (ka[i] ?? 0) - (kb[i] ?? 0) + if (diff !== 0) return diff + } + return 0 +} + +const res = await fetch(UPSTREAM_URL) +if (!res.ok) throw new Error(`Upstream fetch failed: ${res.status} ${res.statusText}`) +const data = await res.json() + +const byProtocol = new Map() +for (const entry of data) { + if (entry.usesNetty !== true) continue + const name = entry.minecraftVersion + const isRelease = entry.releaseType === "release" || (entry.releaseType == null && RELEASE_RE.test(name)) + if (!isRelease) continue + if (!byProtocol.has(entry.version)) byProtocol.set(entry.version, new Set()) + byProtocol.get(entry.version).add(name) +} + +const lines = ["export const JAVA_RELEASE_PROTOCOL_VERSIONS: Record = {"] +for (const protocol of [...byProtocol.keys()].sort((a, b) => a - b)) { + const versions = [...byProtocol.get(protocol)].sort(compareVersions) + const display = versions.length === 1 ? versions[0] : `${versions[0]} - ${versions[versions.length - 1]}` + lines.push(` ${protocol}: "${display}",`) +} +lines.push("}") +const block = `${BEGIN_MARKER} (ne pas éditer à la main, voir \`bun run update:protocols\`) -->\n${lines.join("\n")}\n${END_MARKER}` + +const current = readFileSync(TARGET_FILE, "utf8") +const start = current.indexOf(BEGIN_MARKER) +const end = current.indexOf(END_MARKER) +if (start === -1 || end === -1 || end < start) throw new Error("Generated block markers not found in target file") +const endOfLine = current.indexOf("\n", end) +const next = current.slice(start, endOfLine + 1) +if (next === block + "\n") { + console.log(`Up to date: ${byProtocol.size} protocols, no change.`) +} else { + writeFileSync(TARGET_FILE, current.slice(0, start) + block + current.slice(endOfLine + 1)) + console.log(`Updated ${TARGET_FILE}: ${byProtocol.size} protocols.`) +} From 77a270d05680551a2e4c6f3b9d918e7fd63695b2 Mon Sep 17 00:00:00 2001 From: Flash303 Date: Wed, 14 Oct 2026 19:21:23 +0200 Subject: [PATCH 2/2] feat: auto update protocols --- .github/workflows/update-protocols.yml | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/update-protocols.yml diff --git a/.github/workflows/update-protocols.yml b/.github/workflows/update-protocols.yml new file mode 100644 index 0000000..afdd009 --- /dev/null +++ b/.github/workflows/update-protocols.yml @@ -0,0 +1,42 @@ +name: Update Minecraft protocols + +# Régénère le mapping protocole -> version depuis l'upstream PrismarineJS +# (branche `master`, auto-mise à jour par bot) et ouvre une PR si ça change. +# La PR passe par la CI normale (typecheck/lint/test/build) avant merge : +# l'automatisation propose, l'humain dispose. +on: + schedule: + # Chaque lundi à 06:00 UTC (les releases Minecraft sortent le mardi, + # le mapping est donc à jour pour la semaine). + - cron: "0 6 * * 1" + workflow_dispatch: # déclenchement manuel depuis l'onglet Actions + +permissions: + contents: write + pull-requests: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Regenerate protocol mapping + working-directory: frontend + run: node scripts/update-minecraft-protocols.mjs + + - name: Open PR if mapping changed + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "chore: update Minecraft protocol mapping" + title: "chore: update Minecraft protocol mapping" + body: | + Mapping `JAVA_RELEASE_PROTOCOL_VERSIONS` régénéré depuis l'upstream + [PrismarineJS/minecraft-data](https://github.com/PrismarineJS/minecraft-data/blob/master/data/pc/common/protocolVersions.json). + + Généré par `bun run update:protocols` (voir `frontend/scripts/update-minecraft-protocols.mjs`). + La CI (typecheck/lint/test/build) doit être verte avant merge. + branch: chore/update-minecraft-protocols + base: dev + delete-branch: true + add-paths: frontend/app/core/lib/minecraft-protocols.ts