This commit is contained in:
@@ -66,7 +66,9 @@ export function freshServer(server: FleetServer, now: number): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fleetIdentity(server: FleetServer) {
|
export function fleetIdentity(
|
||||||
|
server: Pick<FleetServer, 'name' | 'game' | 'map' | 'map_slug' | 'game_mode' | 'last_map'>
|
||||||
|
) {
|
||||||
const parsed = parseServerName(server.name);
|
const parsed = parseServerName(server.name);
|
||||||
const game = server.game ?? parsed?.game ?? '';
|
const game = server.game ?? parsed?.game ?? '';
|
||||||
const mode = server.game_mode ?? parsed?.gameMode;
|
const mode = server.game_mode ?? parsed?.gameMode;
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||||
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
||||||
import { elapsedSince } from '$ui/duration.js';
|
import { elapsedSince } from '$ui/duration.js';
|
||||||
import { mapTitle, serverIdentity, serverTitle } from '$ui/server-labels.js';
|
import { serverIdentity, serverTitle } from '$ui/server-labels.js';
|
||||||
|
import { fleetIdentity } from '$lib/fleet-overview.js';
|
||||||
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
|
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
|
||||||
import Card from './primitives/Card.svelte';
|
import Card from './primitives/Card.svelte';
|
||||||
import Badge from './primitives/Badge.svelte';
|
import Badge from './primitives/Badge.svelte';
|
||||||
@@ -20,6 +21,9 @@
|
|||||||
name: string;
|
name: string;
|
||||||
players?: number | null;
|
players?: number | null;
|
||||||
map?: string | null;
|
map?: string | null;
|
||||||
|
game?: string | null;
|
||||||
|
mapSlug?: string | null;
|
||||||
|
gameMode?: string | null;
|
||||||
gametype?: string | null;
|
gametype?: string | null;
|
||||||
roster?: RosterPlayer[] | null;
|
roster?: RosterPlayer[] | null;
|
||||||
health?: Health;
|
health?: Health;
|
||||||
@@ -45,6 +49,9 @@
|
|||||||
name,
|
name,
|
||||||
players = null,
|
players = null,
|
||||||
map = null,
|
map = null,
|
||||||
|
game: serverGame = null,
|
||||||
|
mapSlug = null,
|
||||||
|
gameMode = null,
|
||||||
roster = null,
|
roster = null,
|
||||||
health = 'offline',
|
health = 'offline',
|
||||||
lastSeenAt = null,
|
lastSeenAt = null,
|
||||||
@@ -76,8 +83,17 @@
|
|||||||
const compact = $derived(compactEmpty && health === 'online' && players === 0 && !restarting);
|
const compact = $derived(compactEmpty && health === 'online' && players === 0 && !restarting);
|
||||||
const state = $derived(restarting ? 'restarting' : compact ? 'ready' : health);
|
const state = $derived(restarting ? 'restarting' : compact ? 'ready' : health);
|
||||||
const error = $derived(decodeRconError(lastError));
|
const error = $derived(decodeRconError(lastError));
|
||||||
const mapLabel = $derived(mapTitle(map));
|
const mapLabel = $derived(
|
||||||
const showMap = $derived(mapLabel !== '—' && mapLabel !== title);
|
fleetIdentity({
|
||||||
|
name,
|
||||||
|
game: serverGame,
|
||||||
|
map,
|
||||||
|
last_map: null,
|
||||||
|
map_slug: mapSlug,
|
||||||
|
game_mode: gameMode
|
||||||
|
}).map
|
||||||
|
);
|
||||||
|
const showMap = $derived(map !== null && mapLabel !== '—' && mapLabel !== title);
|
||||||
const topRoster = $derived(
|
const topRoster = $derived(
|
||||||
(roster ?? [])
|
(roster ?? [])
|
||||||
.slice()
|
.slice()
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ export function gameTitle(game: string | null | undefined, compact = false): str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapTitle(map: string | null | undefined): string {
|
export function mapTitle(map: string | null | undefined): string {
|
||||||
return (
|
const matches = [...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].filter(
|
||||||
[...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].find(
|
(entry) => entry.canonical === map?.toLowerCase()
|
||||||
(entry) => entry.canonical === map?.toLowerCase()
|
|
||||||
)?.friendly ??
|
|
||||||
map ??
|
|
||||||
'—'
|
|
||||||
);
|
);
|
||||||
|
// Engine identifiers alone cannot distinguish selections such as Town and TranZit.
|
||||||
|
if (matches.length > 1) return `${map} (variant unknown)`;
|
||||||
|
return matches[0]?.friendly ?? map ?? '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serverTitle(name: string): string {
|
export function serverTitle(name: string): string {
|
||||||
|
|||||||
@@ -552,6 +552,9 @@
|
|||||||
telemetry={server.telemetry}
|
telemetry={server.telemetry}
|
||||||
players={server.last_players}
|
players={server.last_players}
|
||||||
map={server.last_map ?? server.map}
|
map={server.last_map ?? server.map}
|
||||||
|
game={server.game}
|
||||||
|
mapSlug={server.map_slug}
|
||||||
|
gameMode={server.game_mode}
|
||||||
capacity={currentCapacity(server, observationNow())}
|
capacity={currentCapacity(server, observationNow())}
|
||||||
gametype={server.last_gametype}
|
gametype={server.last_gametype}
|
||||||
roster={server.roster}
|
roster={server.roster}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync, mount, unmount, type ComponentProps } from 'svelte';
|
||||||
|
import { fromStore, writable } from 'svelte/store';
|
||||||
|
import ServerCard from '../../src/lib/ui/components/ServerCard.svelte';
|
||||||
|
import ServerIdentity from '../../src/lib/ui/components/ServerIdentity.svelte';
|
||||||
|
import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte';
|
||||||
|
import { fleetIdentity } from '../../src/lib/fleet-overview.js';
|
||||||
|
import { gameEvent } from '../helpers/telemetry.js';
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(components.splice(0).map((component) => unmount(component)));
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
vi.mock('$app/stores', () => ({
|
||||||
|
page: writable({ url: new URL('http://localhost/') })
|
||||||
|
}));
|
||||||
|
const components: ReturnType<typeof mount>[] = [];
|
||||||
|
type MapServer = Parameters<typeof fleetIdentity>[0];
|
||||||
|
const town: MapServer = {
|
||||||
|
name: 't6_zombie_town_1',
|
||||||
|
game: 't6',
|
||||||
|
map: 'zm_transit',
|
||||||
|
last_map: 'zm_transit'
|
||||||
|
};
|
||||||
|
|
||||||
|
function show(
|
||||||
|
initial: MapServer,
|
||||||
|
options: Partial<ComponentProps<typeof ServerCard>> = {},
|
||||||
|
fleetHeading = true
|
||||||
|
) {
|
||||||
|
const store = writable(initial);
|
||||||
|
const state = fromStore(store);
|
||||||
|
const identity = () => fleetIdentity(state.current);
|
||||||
|
components.push(
|
||||||
|
mount(ServerCard, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
get name() {
|
||||||
|
return state.current.name;
|
||||||
|
},
|
||||||
|
get game() {
|
||||||
|
return state.current.game;
|
||||||
|
},
|
||||||
|
get map() {
|
||||||
|
return state.current.last_map ?? state.current.map;
|
||||||
|
},
|
||||||
|
get mapSlug() {
|
||||||
|
return state.current.map_slug;
|
||||||
|
},
|
||||||
|
get gameMode() {
|
||||||
|
return state.current.game_mode;
|
||||||
|
},
|
||||||
|
get mapLabelOverride() {
|
||||||
|
return fleetHeading ? identity().map : undefined;
|
||||||
|
},
|
||||||
|
health: 'online',
|
||||||
|
players: 4,
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
mount(ServerIdentity, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
get name() {
|
||||||
|
return state.current.name;
|
||||||
|
},
|
||||||
|
get mapLabel() {
|
||||||
|
return identity().map;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
mount(PlayerScoreboard, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
get report() {
|
||||||
|
return {
|
||||||
|
...gameEvent(
|
||||||
|
'heartbeat',
|
||||||
|
12,
|
||||||
|
4,
|
||||||
|
state.current.name,
|
||||||
|
state.current.last_map ?? state.current.map ?? ''
|
||||||
|
),
|
||||||
|
receivedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
},
|
||||||
|
get mapLabel() {
|
||||||
|
return identity().map;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
flushSync();
|
||||||
|
return (next: Partial<MapServer>) => {
|
||||||
|
store.update((server) => ({ ...server, ...next }));
|
||||||
|
flushSync();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function expectLabels(label: string) {
|
||||||
|
expect(document.querySelector('h3')?.textContent).toBe(label);
|
||||||
|
expect(document.querySelector('.identity .map')?.textContent).toBe(label);
|
||||||
|
expect(document.querySelector('.match-title h2')?.textContent).toBe(label);
|
||||||
|
expect(document.querySelector('.map-note')).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = [
|
||||||
|
['town', 'Town Survival'],
|
||||||
|
['farm', 'Farm Survival'],
|
||||||
|
['busdepot', 'Bus Depot Survival'],
|
||||||
|
['transit', 'TranZit']
|
||||||
|
];
|
||||||
|
describe('variant-aware rendered map labels', () => {
|
||||||
|
it.each(variants)('keeps %s consistent across card, list, and detail', (slug, label) => {
|
||||||
|
const update = show({ ...town, name: `t6_zombie_${slug}_1` });
|
||||||
|
expectLabels(label);
|
||||||
|
update({ name: 'Friday lobby', map_slug: slug, game_mode: 'zombie' });
|
||||||
|
expectLabels(label);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([undefined, null, 'unknown'])('does not infer TranZit from variant %s', (map_slug) => {
|
||||||
|
const server = { ...town, name: 'Custom lobby', map_slug };
|
||||||
|
show(server);
|
||||||
|
expectLabels('zm_transit (variant unknown)');
|
||||||
|
expect(fleetIdentity(server).launch).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets unknown stored metadata override a recognized name', () => {
|
||||||
|
show({ ...town, map_slug: 'unknown' });
|
||||||
|
expectLabels('zm_transit (variant unknown)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates map and variant metadata reactively without retaining an obsolete launch shortcut', () => {
|
||||||
|
const update = show(town);
|
||||||
|
expectLabels('Town Survival');
|
||||||
|
update({ last_map: 'zm_tomb' });
|
||||||
|
expectLabels('Origins');
|
||||||
|
expect(fleetIdentity({ ...town, last_map: 'zm_tomb' }).launch).toBeNull();
|
||||||
|
update({ last_map: 'zm_transit', map_slug: 'farm' });
|
||||||
|
expectLabels('Farm Survival');
|
||||||
|
expect(fleetIdentity({ ...town, map_slug: 'farm' }).launch).toEqual({
|
||||||
|
game: 't6',
|
||||||
|
slug: 'farm'
|
||||||
|
});
|
||||||
|
update({ map_slug: 'unknown' });
|
||||||
|
expectLabels('zm_transit (variant unknown)');
|
||||||
|
update({ last_map: 'zm_buried' });
|
||||||
|
expectLabels('Buried');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['stale', 'offline'] as const)(
|
||||||
|
'keeps the selected variant for %s observations',
|
||||||
|
(health) => {
|
||||||
|
show(town, { health, lastSeenAt: '2026-01-01T00:00:00Z' });
|
||||||
|
expectLabels('Town Survival');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([true, false])('preserves empty-card compact=%s behavior', (compactEmpty) => {
|
||||||
|
show(town, { players: 0, compactEmpty });
|
||||||
|
expectLabels('Town Survival');
|
||||||
|
expect(document.querySelector('article')?.classList.contains('compact')).toBe(compactEmpty);
|
||||||
|
expect(document.querySelector('article .metrics') === null).toBe(compactEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['online', 'Current map'],
|
||||||
|
['stale', 'Last map'],
|
||||||
|
['offline', 'Last map']
|
||||||
|
] as const)('retains useful %s map context with a name-based heading', (health, prefix) => {
|
||||||
|
const update = show(town, { health }, false);
|
||||||
|
expect(document.querySelector('.map-note')).toBeNull();
|
||||||
|
update({ last_map: 'zm_tomb' });
|
||||||
|
expect(document.querySelector('h3')?.textContent).toBe('Town Survival');
|
||||||
|
expect(document.querySelector('.map-note')?.textContent?.trim()).toBe(`${prefix}: Origins`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides additional map context in compact cards', () => {
|
||||||
|
show({ ...town, last_map: 'zm_tomb' }, { players: 0 }, false);
|
||||||
|
expect(document.querySelector('.map-note')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses stored variant metadata for a custom heading and does not invent a missing observation', () => {
|
||||||
|
const update = show({ ...town, name: 'Friday lobby', map_slug: 'town' }, {}, false);
|
||||||
|
expect(document.querySelector('.map-note')?.textContent?.trim()).toBe(
|
||||||
|
'Current map: Town Survival'
|
||||||
|
);
|
||||||
|
update({ map: null, last_map: null });
|
||||||
|
expect(document.querySelector('.map-note')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user