From f92210d6d08e45f59676eaf78477cf55bff82181 Mon Sep 17 00:00:00 2001 From: VortexUK Date: Tue, 14 Jul 2026 11:14:12 +0100 Subject: [PATCH] feat(ui): Multi Attack stat label + footer server-status indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Character sheet (and compare, via the shared STAT_GROUPS config): the 'Double Attack' stat label becomes 'Multi Attack' to match item text. - Footer gains a per-world server-status dot next to the Census indicator, fed by Daybreak's game_server_status feed on the same 5-minute cadence as the census-health probe (skipped while Census is down — stale-but-labelled beats churn; failures keep the previous map). low/medium/high are treated as population levels on an up server (green, tooltip "online — current population: low"); locked is orange ("not accepting logins"); down is red; grey until the first report. World-aware via the active-server context. - New endpoint GET /api/census/server-status returns the in-focus world's state + report time. Co-Authored-By: Claude Fable 5 --- backend/server/api/census.py | 16 +++++ backend/server/census_health.py | 55 ++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/components/ServerStatus.tsx | 92 ++++++++++++++++++++++++ frontend/src/pages/characterSheet.ts | 2 +- tests/server/test_census_health.py | 63 ++++++++++++++++ 6 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ServerStatus.tsx diff --git a/backend/server/api/census.py b/backend/server/api/census.py index 246327a8..454a7153 100644 --- a/backend/server/api/census.py +++ b/backend/server/api/census.py @@ -19,6 +19,22 @@ async def get_census_health() -> dict: return census_health.get_state() +@router.get("/census/server-status") +async def get_server_status() -> dict: + """The Census-reported game-server state for the world in focus (footer + indicator). ``state`` is Daybreak's raw token — low/medium/high mean up, + plus locked and down; ``unknown`` until the first fetch lands.""" + from backend.server.server_context import current_world + + world = current_world() + info = census_health.get_server_state(world) + return { + "world": world, + "state": (info or {}).get("state", "unknown"), + "reported_at": (info or {}).get("reported_at", 0), + } + + @router.get("/census/stream") async def census_stream(request: Request) -> StreamingResponse: async def gen(): diff --git a/backend/server/census_health.py b/backend/server/census_health.py index ea7fb1ad..af2c8ac7 100644 --- a/backend/server/census_health.py +++ b/backend/server/census_health.py @@ -21,20 +21,71 @@ # Probe a real collection (not the base `/eq2/` index) so the response is a # normal Census JSON document we can validate. ``c:limit=1`` keeps it tiny. _PROBE_URL = f"{_CENSUS_BASE_URL}/s:{_SERVICE_ID}/json/get/eq2/world?c:limit=1" +# Daybreak's cross-game server list; filtered to EQ2 rows client-side. +_SERVER_STATUS_URL = f"{_CENSUS_BASE_URL}/s:{_SERVICE_ID}/get/global/game_server_status?c:limit=1000" _status: str = "unknown" # "up" | "down" | "unknown" _checked_at: int = 0 +# {world_name: {"state": last_reported_state, "reported_at": unix}} for every +# EQ2 server in the game_server_status feed. Refreshed by the same 5-minute +# loop as the health probe. +_server_states: dict[str, dict] = {} def _reset_for_test() -> None: global _status, _checked_at _status, _checked_at = "unknown", 0 + _server_states.clear() def get_state() -> dict: return {"status": _status, "checked_at": _checked_at} +def get_server_state(world: str) -> dict | None: + """The Census-reported state for one EQ2 server (e.g. "high", "locked", + "down"), or None when the feed hasn't been fetched or lacks the world.""" + return _server_states.get(world.lower()) + + +def _parse_server_states(body: dict) -> dict[str, dict]: + """game_server_status envelope → {world_lower: {name, state, reported_at}}.""" + out: dict[str, dict] = {} + for row in body.get("game_server_status_list", []) or []: + if not isinstance(row, dict) or row.get("game_code") != "eq2": + continue + name = str(row.get("name") or "") + if not name: + continue + try: + reported_at = int(row.get("last_reported_time") or 0) + except (TypeError, ValueError): + reported_at = 0 + out[name.lower()] = { + "name": name, + "state": str(row.get("last_reported_state") or "unknown").lower(), + "reported_at": reported_at, + } + return out + + +async def _fetch_server_states() -> None: + """Refresh the per-server state map. Failures leave the previous map in + place (stale beats empty for a footer indicator).""" + global _server_states + timeout = aiohttp.ClientTimeout(total=8) + try: + async with aiohttp.ClientSession(timeout=timeout) as s, s.get(_SERVER_STATUS_URL) as r: + if r.status != 200: + return + body = json.loads(await r.text()) + parsed = _parse_server_states(body) + if parsed: + _server_states = parsed + except Exception as exc: + _log.info("[census-health] server-status fetch failed: %s", exc) + + def is_down() -> bool: return _status == "down" @@ -89,6 +140,10 @@ async def refresh_health() -> str: event on change (import is local to avoid a cycle).""" global _status, _checked_at ok = await _probe_census() + if ok: + # Same cadence as the probe; skipped while Census is down (the feed + # lives on the same host, and stale-but-labelled beats churn). + await _fetch_server_states() new = "up" if ok else "down" changed = new != _status _status, _checked_at = new, int(time.time()) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 054a0219..11e8fb6b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,7 @@ import { Link } from 'react-router-dom' import logo from './assets/EQ2L.webp' import ServerLaunchTimer from './components/ServerLaunchTimer' import CensusStatus from './components/CensusStatus' +import ServerStatus from './components/ServerStatus' import { MobileNav } from './components/MobileNav' function LoginGate() { @@ -269,6 +270,7 @@ function Layout() { Support the site + diff --git a/frontend/src/components/ServerStatus.tsx b/frontend/src/components/ServerStatus.tsx new file mode 100644 index 00000000..af00e1c7 --- /dev/null +++ b/frontend/src/components/ServerStatus.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react' + +import { useServer } from '../hooks/useServer' + +/** + * Footer indicator for the in-focus EQ2 server's Census-reported state, + * shown next to the Census API indicator. Green for any "up" population + * state (low/medium/high), orange for locked, red for down; grey until + * the first report lands. The tooltip carries the raw reported status. + */ + +interface ServerStatusData { + world: string + state: string + reported_at: number +} + +/** Daybreak state token → dot colour + label suffix + tooltip description. + * The low/medium/high tokens are POPULATION levels on an up server, not + * degraded states. Exported for tests. */ +export function presentState(state: string): { colour: string; glow: string; suffix: string; describe: string } { + const s = state.toLowerCase() + if (['low', 'medium', 'high'].includes(s)) { + return { + colour: 'var(--color-success)', + glow: '0 0 5px rgba(74,222,128,0.7)', + suffix: 'online', + describe: `online — current population: ${s}`, + } + } + if (s === 'up') { + return { colour: 'var(--color-success)', glow: '0 0 5px rgba(74,222,128,0.7)', suffix: 'online', describe: 'online' } + } + if (s === 'locked') { + return { + colour: 'var(--color-warning)', + glow: '0 0 5px rgba(251,191,36,0.7)', + suffix: 'locked', + describe: 'locked — not accepting logins', + } + } + if (['down', 'offline'].includes(s)) { + return { colour: 'var(--color-danger)', glow: '0 0 5px rgba(248,113,113,0.7)', suffix: 'down', describe: 'down' } + } + return { colour: 'var(--color-text-muted)', glow: 'none', suffix: 'status unknown', describe: 'no status reported yet' } +} + +const REFRESH_MS = 5 * 60 * 1000 // match the backend poll cadence + +export default function ServerStatus() { + const server = useServer() + const displayName = server?.displayName + const [data, setData] = useState(null) + + useEffect(() => { + let cancelled = false + const load = () => + fetch('/api/census/server-status', { credentials: 'include' }) + .then(r => (r.ok ? r.json() : null)) + .then(d => { + if (!cancelled && d) setData(d as ServerStatusData) + }) + .catch(() => {}) + load() + const t = setInterval(load, REFRESH_MS) + return () => { + cancelled = true + clearInterval(t) + } + }, []) + + const state = data?.state ?? 'unknown' + const { colour, glow, suffix, describe } = presentState(state) + const name = displayName || data?.world || 'Server' + const reported = data?.reported_at + ? ` (reported ${new Date(data.reported_at * 1000).toLocaleTimeString()})` + : '' + + return ( + + + ) +} diff --git a/frontend/src/pages/characterSheet.ts b/frontend/src/pages/characterSheet.ts index b41d5d09..0c8223df 100644 --- a/frontend/src/pages/characterSheet.ts +++ b/frontend/src/pages/characterSheet.ts @@ -148,7 +148,7 @@ export const STAT_GROUPS: StatGroupDef[] = [ { key: 'crit_bonus', label: 'Crit Bonus', fmt: 'pct1' }, { key: 'fervor', label: 'Fervor', fmt: 'dec1' }, { key: 'dps', label: 'DPS', fmt: 'dec1' }, - { key: 'double_attack', label: 'Double Attack', fmt: 'pct1' }, + { key: 'double_attack', label: 'Multi Attack', fmt: 'pct1' }, { key: 'ability_doublecast', label: 'Ability Doublecast', fmt: 'pct1' }, { key: 'attack_speed', label: 'Attack Speed', fmt: 'pct1' }, { key: 'ability_mod', label: 'Ability Mod', fmt: 'int' }, diff --git a/tests/server/test_census_health.py b/tests/server/test_census_health.py index 64f1bb4c..82c7f3c3 100644 --- a/tests/server/test_census_health.py +++ b/tests/server/test_census_health.py @@ -85,3 +85,66 @@ def test_body_looks_healthy_rejects_non_dict(): assert ch._body_looks_healthy([]) is False # type: ignore[arg-type] assert ch._body_looks_healthy("oops") is False # type: ignore[arg-type] assert ch._body_looks_healthy(None) is False # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# game_server_status parsing + endpoint (footer server-status indicator) +# --------------------------------------------------------------------------- + +import pytest +from httpx import ASGITransport, AsyncClient + +from backend.server import census_health + + +def test_parse_server_states_filters_to_eq2_and_normalises(): + body = { + "game_server_status_list": [ + {"name": "Wuoshi", "game_code": "eq2", "last_reported_state": "HIGH", "last_reported_time": "1784023424"}, + {"name": "Antonia Bayle", "game_code": "eq2", "last_reported_state": "locked", "last_reported_time": "x"}, + {"name": "Povar", "game_code": "eq", "last_reported_state": "up", "last_reported_time": "1"}, + {"name": "", "game_code": "eq2", "last_reported_state": "up"}, + "garbage", + ] + } + out = census_health._parse_server_states(body) + assert out["wuoshi"] == {"name": "Wuoshi", "state": "high", "reported_at": 1784023424} + assert out["antonia bayle"]["state"] == "locked" + assert out["antonia bayle"]["reported_at"] == 0 # unparseable time -> 0 + assert "povar" not in out # wrong game + assert len(out) == 2 + + +def test_get_server_state_case_insensitive(monkeypatch): + monkeypatch.setattr( + census_health, "_server_states", {"wuoshi": {"name": "Wuoshi", "state": "high", "reported_at": 1}} + ) + assert census_health.get_server_state("Wuoshi")["state"] == "high" + assert census_health.get_server_state("nope") is None + + +@pytest.mark.asyncio +async def test_server_status_endpoint_unknown_before_first_fetch(app, monkeypatch): + monkeypatch.setattr(census_health, "_server_states", {}) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + r = await c.get("/api/census/server-status") + assert r.status_code == 200 + assert r.json()["state"] == "unknown" + + +@pytest.mark.asyncio +async def test_server_status_endpoint_returns_current_world(app, monkeypatch): + monkeypatch.setattr( + census_health, + "_server_states", + { + "wuoshi": {"name": "Wuoshi", "state": "locked", "reported_at": 42}, + "varsoon": {"name": "Varsoon", "state": "high", "reported_at": 43}, + }, + ) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + r = await c.get("/api/census/server-status") + body = r.json() + assert body["state"] in ("locked", "high") # whichever world the test app focuses + assert body["reported_at"] in (42, 43) + assert body["world"].lower() in ("wuoshi", "varsoon")