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 (
+
+
+ {name} server {suffix}
+
+ )
+}
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")