diff --git a/src/lib/map-aliases.ts b/src/lib/map-aliases.ts new file mode 100644 index 0000000..d794a77 --- /dev/null +++ b/src/lib/map-aliases.ts @@ -0,0 +1,91 @@ +// T5 (Black Ops 1) zombies map catalog. Server names encode `slug` so we can +// derive game, mode, and map from the folder name (t5_zombie_cosmodrome_1 → +// game 't5', mode 'zombie', slug 'cosmodrome', canonical 'zombie_cosmodrome'). +// +// Fields: +// friendly — human name shown in the deploy UI dropdown +// slug — segment used in server name (t5_zombie__) +// canonical — actual mapname Pluto/RCON emits (matches T5 script scoping) +// +// Add rows when new maps land; keep slugs short so paths stay readable. + +export interface T5MapEntry { + friendly: string; + slug: string; + canonical: string; +} + +export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([ + { friendly: 'Kino Der Toten', slug: 'theater', canonical: 'zombie_theater' }, + { friendly: 'Ascension', slug: 'cosmodrome', canonical: 'zombie_cosmodrome' }, + { friendly: 'Call of the Dead', slug: 'coast', canonical: 'zombie_coast' }, + { friendly: 'Shangri-La', slug: 'temple', canonical: 'zombie_temple' }, + { friendly: 'Moon', slug: 'moon', canonical: 'zombie_moon' }, + { friendly: 'Five', slug: 'pentagon', canonical: 'zombie_pentagon' }, + { friendly: 'Dead Ops Arcade', slug: 'zombietron', canonical: 'zombietron' }, + { friendly: 'Nacht Der Untoten', slug: 'prototype', canonical: 'zombie_cod5_prototype' }, + { friendly: 'Verrückt', slug: 'asylum', canonical: 'zombie_cod5_asylum' }, + { friendly: 'Shi No Numa', slug: 'sumpf', canonical: 'zombie_cod5_sumpf' }, + { friendly: 'Der Riese', slug: 'factory', canonical: 'zombie_cod5_factory' } +]); + +const BY_SLUG: ReadonlyMap = new Map( + T5_ZOMBIE_MAPS.map((m) => [m.slug, m]) +); +// Older folder names used friendly names (shi_no_numa) instead of engine +// slugs (sumpf). Keep deriving their canonical map from the same catalog. +const BY_FRIENDLY_SLUG: ReadonlyMap = new Map( + T5_ZOMBIE_MAPS.map((m) => [m.friendly.toLowerCase().replace(/[^a-z0-9]/g, ''), m]) +); + +export function t5ZombieMapBySlug(slug: string): T5MapEntry | undefined { + return BY_SLUG.get(slug.toLowerCase()); +} + +export type GameMode = 'zombie' | 'mp' | 'sp'; + +export interface ParsedServerName { + game: string; // 't5' + gameMode: GameMode | null; // 'zombie' — null when the second segment isn't a known mode + mapSlug: string; // 'cosmodrome' — joined without separators for lookup + mapCanonical: string | null;// 'zombie_cosmodrome' — null when slug not in catalog + lobby: number | null; +} + +const KNOWN_MODES: ReadonlySet = new Set(['zombie', 'mp', 'sp']); + +/** + * `t5_zombie_cosmodrome_1` → { game:'t5', gameMode:'zombie', mapSlug:'cosmodrome', mapCanonical:'zombie_cosmodrome', lobby:1 } + * + * Rules: + * - First segment = game + * - Second segment = mode when it matches a known mode (zombie|mp|sp); otherwise the whole + * tail is treated as legacy `__` (mode null, slug from segments 1..-1) + * - Trailing all-digits segment = lobby number + * - Everything in between = map slug (joined without separators for the catalog lookup) + * + * Multi-word slugs still work because we strip underscores when looking up + * (e.g. `t5_zombie_shi_no_numa_1` → slug 'shinonuma'). + */ +export function parseServerName(name: string): ParsedServerName | null { + const parts = name.split('_').filter(Boolean); + if (parts.length < 2) return null; + const game = parts[0]; + const lastIsLobby = /^\d+$/.test(parts[parts.length - 1]); + const lobby = lastIsLobby ? Number.parseInt(parts[parts.length - 1], 10) : null; + const tail = lastIsLobby ? parts.slice(1, -1) : parts.slice(1); + if (tail.length === 0) return null; + + let gameMode: GameMode | null = null; + let slugParts = tail; + const maybeMode = tail[0].toLowerCase(); + if (KNOWN_MODES.has(maybeMode as GameMode)) { + gameMode = maybeMode as GameMode; + slugParts = tail.slice(1); + } + if (slugParts.length === 0) return null; + + const mapSlug = slugParts.join('').toLowerCase(); + const mapCanonical = (BY_SLUG.get(mapSlug) ?? BY_FRIENDLY_SLUG.get(mapSlug))?.canonical ?? null; + return { game, gameMode, mapSlug, mapCanonical, lobby }; +} diff --git a/src/lib/server/fleet/map-aliases.ts b/src/lib/server/fleet/map-aliases.ts index d794a77..bd3c697 100644 --- a/src/lib/server/fleet/map-aliases.ts +++ b/src/lib/server/fleet/map-aliases.ts @@ -1,91 +1,2 @@ -// T5 (Black Ops 1) zombies map catalog. Server names encode `slug` so we can -// derive game, mode, and map from the folder name (t5_zombie_cosmodrome_1 → -// game 't5', mode 'zombie', slug 'cosmodrome', canonical 'zombie_cosmodrome'). -// -// Fields: -// friendly — human name shown in the deploy UI dropdown -// slug — segment used in server name (t5_zombie__) -// canonical — actual mapname Pluto/RCON emits (matches T5 script scoping) -// -// Add rows when new maps land; keep slugs short so paths stay readable. - -export interface T5MapEntry { - friendly: string; - slug: string; - canonical: string; -} - -export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([ - { friendly: 'Kino Der Toten', slug: 'theater', canonical: 'zombie_theater' }, - { friendly: 'Ascension', slug: 'cosmodrome', canonical: 'zombie_cosmodrome' }, - { friendly: 'Call of the Dead', slug: 'coast', canonical: 'zombie_coast' }, - { friendly: 'Shangri-La', slug: 'temple', canonical: 'zombie_temple' }, - { friendly: 'Moon', slug: 'moon', canonical: 'zombie_moon' }, - { friendly: 'Five', slug: 'pentagon', canonical: 'zombie_pentagon' }, - { friendly: 'Dead Ops Arcade', slug: 'zombietron', canonical: 'zombietron' }, - { friendly: 'Nacht Der Untoten', slug: 'prototype', canonical: 'zombie_cod5_prototype' }, - { friendly: 'Verrückt', slug: 'asylum', canonical: 'zombie_cod5_asylum' }, - { friendly: 'Shi No Numa', slug: 'sumpf', canonical: 'zombie_cod5_sumpf' }, - { friendly: 'Der Riese', slug: 'factory', canonical: 'zombie_cod5_factory' } -]); - -const BY_SLUG: ReadonlyMap = new Map( - T5_ZOMBIE_MAPS.map((m) => [m.slug, m]) -); -// Older folder names used friendly names (shi_no_numa) instead of engine -// slugs (sumpf). Keep deriving their canonical map from the same catalog. -const BY_FRIENDLY_SLUG: ReadonlyMap = new Map( - T5_ZOMBIE_MAPS.map((m) => [m.friendly.toLowerCase().replace(/[^a-z0-9]/g, ''), m]) -); - -export function t5ZombieMapBySlug(slug: string): T5MapEntry | undefined { - return BY_SLUG.get(slug.toLowerCase()); -} - -export type GameMode = 'zombie' | 'mp' | 'sp'; - -export interface ParsedServerName { - game: string; // 't5' - gameMode: GameMode | null; // 'zombie' — null when the second segment isn't a known mode - mapSlug: string; // 'cosmodrome' — joined without separators for lookup - mapCanonical: string | null;// 'zombie_cosmodrome' — null when slug not in catalog - lobby: number | null; -} - -const KNOWN_MODES: ReadonlySet = new Set(['zombie', 'mp', 'sp']); - -/** - * `t5_zombie_cosmodrome_1` → { game:'t5', gameMode:'zombie', mapSlug:'cosmodrome', mapCanonical:'zombie_cosmodrome', lobby:1 } - * - * Rules: - * - First segment = game - * - Second segment = mode when it matches a known mode (zombie|mp|sp); otherwise the whole - * tail is treated as legacy `__` (mode null, slug from segments 1..-1) - * - Trailing all-digits segment = lobby number - * - Everything in between = map slug (joined without separators for the catalog lookup) - * - * Multi-word slugs still work because we strip underscores when looking up - * (e.g. `t5_zombie_shi_no_numa_1` → slug 'shinonuma'). - */ -export function parseServerName(name: string): ParsedServerName | null { - const parts = name.split('_').filter(Boolean); - if (parts.length < 2) return null; - const game = parts[0]; - const lastIsLobby = /^\d+$/.test(parts[parts.length - 1]); - const lobby = lastIsLobby ? Number.parseInt(parts[parts.length - 1], 10) : null; - const tail = lastIsLobby ? parts.slice(1, -1) : parts.slice(1); - if (tail.length === 0) return null; - - let gameMode: GameMode | null = null; - let slugParts = tail; - const maybeMode = tail[0].toLowerCase(); - if (KNOWN_MODES.has(maybeMode as GameMode)) { - gameMode = maybeMode as GameMode; - slugParts = tail.slice(1); - } - if (slugParts.length === 0) return null; - - const mapSlug = slugParts.join('').toLowerCase(); - const mapCanonical = (BY_SLUG.get(mapSlug) ?? BY_FRIENDLY_SLUG.get(mapSlug))?.canonical ?? null; - return { game, gameMode, mapSlug, mapCanonical, lobby }; -} +// Shared catalog lives outside the server-only directory so the UI can use it too. +export * from '../../map-aliases.js'; diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index 29f59fa..6b78431 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -4,6 +4,7 @@ import { uiNow } from '$ui/stores/servers.js'; import { gametypeLabel } from '$ui/gametype.js'; import { elapsedSince } from '$ui/duration.js'; + import { mapTitle, serverTitle } from '$ui/server-labels.js'; import Card from './primitives/Card.svelte'; import Badge from './primitives/Badge.svelte'; import Stat from './primitives/Stat.svelte'; @@ -64,7 +65,8 @@ .slice(0, 3) ); let showRoster = $derived(health === 'online' || health === 'stale'); - let mapLabel = $derived(map ?? '—'); + let title = $derived(serverTitle(name)); + let mapLabel = $derived(mapTitle(map)); let gtLabel = $derived(gametypeLabel(gametype)); let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null); @@ -85,7 +87,7 @@ elevation="interactive" padding="md" class={restarting ? 'restarting' : health === 'offline' ? 'offline' : ''} - ariaLabel="Server {name}, {health}" + ariaLabel="Server {title}, {health}" style={cardStyle} > {#if restarting} @@ -95,7 +97,7 @@
- {name} + {title}
{#if health === 'starting'} Starting diff --git a/src/lib/ui/server-labels.ts b/src/lib/ui/server-labels.ts new file mode 100644 index 0000000..3864b5f --- /dev/null +++ b/src/lib/ui/server-labels.ts @@ -0,0 +1,15 @@ +import { parseServerName, T5_ZOMBIE_MAPS } from '../map-aliases.js'; + +export function mapTitle(map: string | null | undefined): string { + return T5_ZOMBIE_MAPS.find((entry) => entry.canonical === map?.toLowerCase())?.friendly ?? map ?? '—'; +} + +export function serverTitle(name: string): string { + const parsed = parseServerName(name); + if (!parsed || parsed.game.toLowerCase() !== 't5' || + (parsed.gameMode !== null && parsed.gameMode !== 'zombie') || !parsed.mapCanonical) { + return name; + } + const title = mapTitle(parsed.mapCanonical); + return parsed.lobby === null ? title : `${title} · Lobby ${parsed.lobby}`; +}