diff --git a/backend/src/api/admin_routes.py b/backend/src/api/admin_routes.py index faec68c7..aaa4235a 100644 --- a/backend/src/api/admin_routes.py +++ b/backend/src/api/admin_routes.py @@ -20,6 +20,9 @@ admin_router = APIRouter(prefix="/admin", tags=["admin"]) +# Unix seconds well past any real window; keeps query values inside SQLite's integers. +MAX_TIME = 2 ** 40 + class RoleBody(BaseModel): role: str = Field(..., max_length=16) @@ -44,8 +47,8 @@ def _capable(request: Request, capability: str) -> dict: return user -def _players(request: Request, response: Response, run): - viewer = _capable(request, "view_players") +def _players(request: Request, response: Response, run, capability: str = "view_players"): + viewer = _capable(request, capability) _no_store(response) try: return run(CoreProtectConfig.from_env(), viewer) @@ -137,6 +140,31 @@ def get_player_activity(player_uuid: str, request: Request, response: Response, viewer)) +@admin_router.get("/players/{player_uuid}/movement") +def get_player_movement(player_uuid: str, request: Request, response: Response, + since: int | None = Query(None, ge=0, le=MAX_TIME), + until: int | None = Query(None, ge=0, le=MAX_TIME)): + return _players(request, response, + lambda config, viewer: players.player_movement(config, player_uuid, since, until, viewer), + "view_player_movement") + + +@admin_router.get("/players/{player_uuid}/sessions/{session_id}/movement") +def get_session_movement(player_uuid: str, session_id: str, request: Request, response: Response): + return _players(request, response, + lambda config, viewer: players.session_movement(config, player_uuid, session_id, viewer), + "view_player_movement") + + +@admin_router.get("/movement") +def get_movement(request: Request, response: Response, + since: int | None = Query(None, ge=0, le=MAX_TIME), + until: int | None = Query(None, ge=0, le=MAX_TIME)): + return _players(request, response, + lambda config, viewer: players.everyone_movement(config, since, until, viewer), + "view_player_movement") + + @admin_router.post("/accounts/{user_id}/role") def post_role(user_id: int, body: RoleBody, request: Request, response: Response): _no_store(response) diff --git a/backend/src/api/auth_routes.py b/backend/src/api/auth_routes.py index c6aefe75..5b5803a7 100644 --- a/backend/src/api/auth_routes.py +++ b/backend/src/api/auth_routes.py @@ -191,8 +191,10 @@ def _finish_sign_in(config: AuthConfig, code: str | None) -> str | None: access_token = client.exchange_code(code) try: identity = client.identity(access_token) + member = None try: - guild_member = client.is_guild_member(access_token) + member = client.guild_member(access_token) + guild_member = member is not None except DiscordError as exc: # Sign-in still works; linking asks for a fresh check. logger.warning("Discord guild check failed code=%s", exc) @@ -204,7 +206,7 @@ def _finish_sign_in(config: AuthConfig, code: str | None) -> str | None: return None finally: client.close() - return users.sign_in(identity, guild_member=guild_member) + return users.sign_in(identity, guild_member=guild_member, member=member) @auth_router.post("/auth/logout") diff --git a/backend/src/api/skins_routes.py b/backend/src/api/skins_routes.py index e97a81b0..1316743c 100644 --- a/backend/src/api/skins_routes.py +++ b/backend/src/api/skins_routes.py @@ -35,6 +35,7 @@ LinkError, complete_link, get_identity_status, + remember_discord_nicknames, remember_discord_usernames, record_guild_joined, record_guild_left, @@ -158,6 +159,8 @@ class LinkCompleteBody(BaseModel): class DiscordUsernameUpdate(BaseModel): discord_user_id: str = Field(..., min_length=1, max_length=32) discord_username: str | None = Field(default=None, max_length=80) + # Their server nickname. Sent (even as null, for none) replaces the stored one; left out, it is kept. + discord_nickname: str | None = Field(default=None, max_length=80) class DiscordUsernamesBody(BaseModel): @@ -367,10 +370,10 @@ def post_discord_usernames( body: DiscordUsernamesBody, x_staff_key: str | None = Header(default=None, alias=HEADER_STAFF_KEY), ): - """Store Discord account usernames on existing links for staff lookup.""" + """Store Discord account usernames (and server nicknames, when sent) on existing links for staff lookup.""" _require_staff(x_staff_key) try: - return remember_discord_usernames( + result = remember_discord_usernames( [ { "discord_user_id": item.discord_user_id, @@ -380,6 +383,14 @@ def post_discord_usernames( ], overwrite=body.overwrite, ) + nicknames = [ + {"discord_user_id": item.discord_user_id, "discord_nickname": item.discord_nickname} + for item in body.updates + if "discord_nickname" in item.model_fields_set + ] + if nicknames: + result["nicknames_updated"] = remember_discord_nicknames(nicknames)["updated"] + return result except LinkError as e: raise HTTPException(status_code=400, detail=str(e)) from e diff --git a/backend/src/auth/discord.py b/backend/src/auth/discord.py index c374ebea..c0aa1cfb 100644 --- a/backend/src/auth/discord.py +++ b/backend/src/auth/discord.py @@ -76,17 +76,28 @@ def identity(self, token: str) -> dict: "discord_avatar": _text(data.get("avatar")), } - def is_guild_member(self, token: str) -> bool: - """True for a full member; members still on rules screening do not count.""" + def guild_member(self, token: str) -> dict | None: + """The user's membership of the server, or None; members still on rules screening do not count. + + `nick` is their server nickname, None when they have not set one. + """ response = self._get(f"/users/@me/guilds/{self.config.guild_id}/member", token) if response.status_code == 404: - return False + return None if response.status_code != 200: raise DiscordError("discord_guild_check_failed") try: - return response.json().get("pending") is not True + data = response.json() + pending = data.get("pending") except (ValueError, AttributeError): raise DiscordError("discord_guild_check_failed") from None + if pending is True: + return None + return {"nick": _text(data.get("nick"))} + + def is_guild_member(self, token: str) -> bool: + """True for a full member; members still on rules screening do not count.""" + return self.guild_member(token) is not None def revoke(self, token: str) -> None: """Best effort: the site keeps no Discord tokens after sign-in.""" diff --git a/backend/src/auth/players.py b/backend/src/auth/players.py index 6264fe9d..ad3dc37f 100644 --- a/backend/src/auth/players.py +++ b/backend/src/auth/players.py @@ -1,4 +1,4 @@ -"""Staff view of Minecraft players: a directory, profiles, sessions and activity. +"""Staff view of Minecraft players: a directory, profiles, sessions, activity and movement. A player is a Minecraft account, keyed by its UUID; names are never merged on. The directory is the union of everyone CoreProtect has seen join, every @@ -15,7 +15,7 @@ import time import uuid as uuidlib -from src.coreprotect import activity, maps as co_maps, sessions +from src.coreprotect import activity, maps as co_maps, movement, sessions from src.coreprotect.cursors import BadCursor, decode as decode_cursor, encode as encode_cursor from src.coreprotect.reader import REQUEST_BUDGET_SECONDS, Budget, CoreProtectConfig, Reader, Unavailable from src.skins.db import connect @@ -25,7 +25,7 @@ DIRECTORY_TTL_SECONDS = 60 PAGE_SIZE = 50 QUERY_MAX = 64 -SORTS = ("last_seen", "minecraft", "discord") +SORTS = ("last_seen", "minecraft", "discord", "character") _DIRECTORY_LOCK = threading.Lock() _DIRECTORY: dict[tuple[str, str], tuple[float, list[dict], dict]] = {} @@ -54,7 +54,7 @@ def _status(error: Unavailable | None) -> dict: # -------------------- _LINKS = """ - SELECT dl.player_uuid, dl.discord_user_id, dl.discord_username, dl.minecraft_name, + SELECT dl.player_uuid, dl.discord_user_id, dl.discord_username, dl.discord_nickname, dl.minecraft_name, dl.linked_at, dl.left_guild_at, dl.grace_until, u.id AS user_id, u.role, u.discord_username AS account_username, u.discord_global_name, u.discord_avatar, u.created_at AS account_created_at, u.last_login_at @@ -84,7 +84,9 @@ def _discord(link: dict | None) -> dict | None: return None return { "discord_user_id": link["discord_user_id"], + # The account handle (from their sign-in, else the link) and their server nickname. "discord_username": link["account_username"] or link["discord_username"], + "discord_nickname": link["discord_nickname"], "linked_at": link["linked_at"], "left_guild_at": link["left_guild_at"], "grace_until": link["grace_until"], @@ -145,7 +147,7 @@ def _build_directory(config: CoreProtectConfig) -> tuple[list[dict], dict]: def person(key: str) -> dict: return people.setdefault(key, { "uuid": key, "minecraft_name": None, "names": set(), "discord_user_id": None, - "discord_username": None, "discord_global_name": None, "site_role": None, + "discord_username": None, "discord_nickname": None, "discord_global_name": None, "site_role": None, "characters": [], "last_seen": None, "online": False, }) @@ -164,6 +166,7 @@ def person(key: str) -> dict: entry.update( discord_user_id=link["discord_user_id"], discord_username=link["account_username"] or link["discord_username"], + discord_nickname=link["discord_nickname"], discord_global_name=link["discord_global_name"], site_role=link["role"], ) @@ -198,7 +201,8 @@ def _directory(config: CoreProtectConfig) -> tuple[list[dict], dict]: def _matches(entry: dict, needle: str) -> bool: if needle in {entry["uuid"], entry["uuid"].replace("-", ""), (entry["discord_user_id"] or "")}: return True - haystack = [*entry["names"], entry["discord_username"], entry["discord_global_name"], *entry["characters"]] + haystack = [*entry["names"], entry["discord_username"], entry["discord_nickname"], entry["discord_global_name"], + *entry["characters"]] return any(needle in value.casefold() for value in haystack if value) @@ -206,7 +210,15 @@ def _sort_key(sort: str): if sort == "minecraft": return lambda e: (e["minecraft_name"] is None, (e["minecraft_name"] or "").casefold(), e["uuid"]) if sort == "discord": - return lambda e: (e["discord_username"] is None, (e["discord_username"] or "").casefold(), e["uuid"]) + # By handle, else server nickname (links older than handles may have only the nickname). + return lambda e: ( + not (e["discord_username"] or e["discord_nickname"]), + (e["discord_username"] or e["discord_nickname"] or "").casefold(), + e["uuid"], + ) + if sort == "character": + # By the first of their characters alphabetically; players without one last. + return lambda e: (not e["characters"], min((c.casefold() for c in e["characters"]), default=""), e["uuid"]) return lambda e: (e["last_seen"] is None, -(e["last_seen"] or 0), (e["minecraft_name"] or "").casefold()) @@ -216,6 +228,7 @@ def _directory_json(entry: dict) -> dict: "minecraft_name": entry["minecraft_name"], "discord_user_id": entry["discord_user_id"], "discord_username": entry["discord_username"], + "discord_nickname": entry["discord_nickname"], "site_role": entry["site_role"], "characters": entry["characters"], "last_seen": entry["last_seen"], @@ -253,8 +266,13 @@ def clear_cache() -> None: # -------------------- def _ids(reader: Reader, key: str) -> list[dict]: - """A player's co_user rows, oldest first. CoreProtect keeps one per UUID, but nothing enforces it.""" - return [dict(r) for r in reader.rows("SELECT id, user, time FROM co_user WHERE uuid = ? ORDER BY time, id", (key,))] + """A player's co_user rows, oldest first. CoreProtect keeps one per UUID, but nothing enforces it. + + Matched without regard to letter case, as `key` is canonical (lower case) and the stored UUID + need not be. That scans co_user, one of the small name tables. + """ + return [dict(r) for r in reader.rows( + "SELECT id, user, time FROM co_user WHERE lower(uuid) = ? ORDER BY time, id", (key,))] def _require(text: str) -> str: @@ -323,6 +341,7 @@ def player_sessions(config: CoreProtectConfig, text: str, before: str | None, li if raw is None: return {"sessions": [], "next": None, "coreprotect": _status(None)} built = sessions.build(raw, maps, int(time.time()), config.ping_seconds) + built["sessions"] = [_session_json(config, key, s) for s in built["sessions"]] nxt = raw["next"] return { **built, @@ -331,6 +350,12 @@ def player_sessions(config: CoreProtectConfig, text: str, before: str | None, li } +def _session_json(config: CoreProtectConfig, key: str, session: dict) -> dict: + """A built session with an opaque id (its login row) in place of the raw key.""" + login = session.pop("key") + return {"id": encode_cursor(config.server, f"session:{key}", login.time, login.rowid), **session} + + def _audit_messages(viewer: dict, config: CoreProtectConfig, key: str, kinds: tuple[str, ...], before: str | None, entries: list[dict]) -> None: """Record that a viewer was shown a player's chat or whole commands, before they see it. @@ -386,3 +411,109 @@ def player_activity(config: CoreProtectConfig, text: str, before: str | None, li if full: _audit_messages(viewer, config, key, chosen, before, built["entries"]) return {**built, "kinds": list(allowed), "shows_messages": full, "coreprotect": _status(None)} + + + +def _window(since: int | None, until: int | None, longest: int) -> tuple[int, int]: + try: + return movement.window(since, until, int(time.time()), longest) + except movement.BadWindow: + raise PlayerError(400, "bad_window") from None + + +def _movement_status(config: CoreProtectConfig, error: Unavailable | None) -> dict: + return {**_status(error), "server_label": config.label or None, "ping_seconds": config.ping_seconds or None, + "map_world": config.map_world} + + +def _audit_movement(viewer: dict, config: CoreProtectConfig, subject: str, since: int, until: int, + body: dict, rows: int, session: str | None = None) -> None: + """Record that a viewer was shown where players went, before they see it. Never stores positions.""" + detail = { + "server": config.server, + "player_uuid": subject, + "since": since, + "until": until, + "complete_from": body["complete_from"], + "rows": rows, + } + if session is not None: + detail["session"] = session + try: + with connect() as conn: + audit.record(conn, actor=viewer, action="player.movement.view", outcome="ok", detail=detail) + conn.commit() + except Exception: + raise PlayerError(503, "audit_unavailable") from None + + +def player_movement(config: CoreProtectConfig, text: str, since: int | None, until: int | None, + viewer: dict) -> dict: + """One player's logins, logouts and pings between since and until (epoch seconds). Audited.""" + key = _require(text) + since, until = _window(since, until, movement.PLAYER_WINDOW_SECONDS) + empty = {"since": since, "until": until, "worlds": [], "points": [], "complete_from": since, + "pings_since": None} + try: + with Reader(config, Budget()) as reader: + ids = [a["id"] for a in _ids(reader, key)] + maps = co_maps.get(reader) + raw = (movement.fetch_player(reader, ids, since, until, movement.lead_seconds(config.ping_seconds)) + if ids else None) + except Unavailable as exc: + return {**empty, "coreprotect": _movement_status(config, exc)} + body = {**empty, **movement.build_player(raw, maps)} if raw else empty + _audit_movement(viewer, config, key, since, until, body, len(body["points"])) + return {**body, "as_of": int(time.time()), "coreprotect": _movement_status(config, None)} + + +def session_movement(config: CoreProtectConfig, text: str, session_id: str, viewer: dict) -> dict: + """One session's movement: the session as /sessions describes it, and its rows from login to end. Audited.""" + key = _require(text) + try: + values = decode_cursor(config.server, f"session:{key}", session_id, 2) + except BadCursor: + raise PlayerError(400, "bad_session") from None + if values is None: + raise PlayerError(400, "bad_session") + now = int(time.time()) + try: + with Reader(config, Budget()) as reader: + ids = [a["id"] for a in _ids(reader, key)] + maps = co_maps.get(reader) + win = sessions.window(reader, ids, sessions.Key(*values)) if ids else None + raw = movement.fetch_session(reader, ids, win) if win else None + except Unavailable as exc: + return {"session": None, "worlds": [], "points": [], "complete_from": None, "pings_since": None, + "as_of": now, "coreprotect": _movement_status(config, exc)} + if win is None: + # Never theirs, or no longer there (a purge, or a rebuilt database renumbering rows). + raise PlayerError(404, "session_gone") + built = sessions.build({"windows": [win], "first_seen": None, "history_start": None}, maps, now, + config.ping_seconds) + session = _session_json(config, key, built["sessions"][0]) + # The span shown: from the login (or, if the session held too many rows, from where the kept rows + # are complete) to its logout, its last sighting, or now while it is open. An open session has no end. + since = session["start"]["time"] + if session["end_kind"] == "open": + until = now + else: + until = (session["end"] or session["last_observed"])["time"] + body = {"session": session, "since": since, "until": max(since, until), **movement.build_player(raw, maps)} + _audit_movement(viewer, config, key, since, body["until"], body, len(body["points"]), session=session_id) + return {**body, "as_of": now, "coreprotect": _movement_status(config, None)} + + +def everyone_movement(config: CoreProtectConfig, since: int | None, until: int | None, viewer: dict) -> dict: + """Every player's logins, logouts and pings between since and until (epoch seconds). Audited.""" + since, until = _window(since, until, movement.EVERYONE_WINDOW_SECONDS) + try: + with Reader(config, Budget()) as reader: + maps = co_maps.get(reader) + raw = movement.fetch_everyone(reader, since, until, movement.lead_seconds(config.ping_seconds)) + except Unavailable as exc: + return {"since": since, "until": until, "worlds": [], "players": [], "complete_from": since, + "pings_since": None, "coreprotect": _movement_status(config, exc)} + body = {"since": since, "until": until, "as_of": int(time.time()), **movement.build_everyone(raw, maps)} + _audit_movement(viewer, config, "everyone", since, until, body, sum(len(p["points"]) for p in body["players"])) + return {**body, "coreprotect": _movement_status(config, None)} diff --git a/backend/src/auth/roles.py b/backend/src/auth/roles.py index 3ca874e8..33aee780 100644 --- a/backend/src/auth/roles.py +++ b/backend/src/auth/roles.py @@ -10,6 +10,8 @@ "view_players": "mod", # Chat and whole commands in a player's activity; every view is audited. "view_player_messages": "admin", + # Where players went, minute by minute; every view is audited. + "view_player_movement": "admin", "revoke_sessions": "mod", "change_role": "admin", } diff --git a/backend/src/auth/test_auth_routes.py b/backend/src/auth/test_auth_routes.py index fb136b8b..5272f032 100644 --- a/backend/src/auth/test_auth_routes.py +++ b/backend/src/auth/test_auth_routes.py @@ -42,10 +42,10 @@ def identity(self, token): return {"discord_user_id": self.discord_id, "discord_username": "steve_tfmc", "discord_global_name": "Steve", "discord_avatar": "a_1234abcd"} - def is_guild_member(self, token): + def guild_member(self, token): if self.fail == "guild": raise DiscordError("discord_guild_check_failed") - return self.member + return {"nick": None} if self.member else None def revoke(self, token): self.revoked.append(token) diff --git a/backend/src/auth/test_discord_nicknames.py b/backend/src/auth/test_discord_nicknames.py new file mode 100644 index 00000000..78eb1d24 --- /dev/null +++ b/backend/src/auth/test_discord_nicknames.py @@ -0,0 +1,100 @@ +import sqlite3 + +import httpx +import pytest + +from src.api import skins_routes +from src.auth import users +from src.skins import db +from src.skins.discord_link import remember_discord_nicknames + + +def link(conn, uuid, discord_id, username, nickname=None): + conn.execute( + "INSERT INTO discord_links (player_uuid, discord_user_id, minecraft_name, discord_username, linked_at, " + "discord_nickname) VALUES (?, ?, 'mc', ?, '2026-09-20T10:00:00Z', ?)", + (uuid, discord_id, username, nickname)) + + +def names(discord_id): + with db.connect() as conn: + row = conn.execute("SELECT discord_username, discord_nickname FROM discord_links WHERE discord_user_id = ?", + (discord_id,)).fetchone() + return row["discord_username"], row["discord_nickname"] + + +def test_upgrade_moves_old_nicknames_out_of_usernames(tmp_path, monkeypatch): + monkeypatch.setattr(db, "DB_PATH", tmp_path / "province.db") + monkeypatch.setattr(db, "DATA_DIR", tmp_path) + for name in ("SKINS_DIR", "WARDROBE_DIR", "DRINKS_DIR"): + monkeypatch.setattr(db, name, tmp_path / name.lower()) + # A database from before nicknames had a column of their own. + conn = sqlite3.connect(tmp_path / "province.db") + conn.execute("CREATE TABLE discord_links (player_uuid TEXT PRIMARY KEY, discord_user_id TEXT NOT NULL UNIQUE, " + "minecraft_name TEXT, discord_username TEXT, linked_at TEXT NOT NULL, left_guild_at TEXT, " + "grace_until TEXT)") + for uuid, discord_id, name in [("u1", "1", "Justin"), ("u2", "2", "justin_x"), ("u3", "3", "Dread Captain - Wren"), + ("u4", "4", None)]: + conn.execute("INSERT INTO discord_links (player_uuid, discord_user_id, discord_username, linked_at) " + "VALUES (?, ?, ?, 'now')", (uuid, discord_id, name)) + conn.commit() + conn.close() + + db.migrate() + assert names("1") == (None, "Justin") + assert names("2") == ("justin_x", None) + assert names("3") == (None, "Dread Captain - Wren") + assert names("4") == (None, None) + # Only once: a second start leaves later data alone. + remember_discord_nicknames([{"discord_user_id": "2", "discord_nickname": "Jus"}]) + db.migrate() + assert names("2") == ("justin_x", "Jus") + + +def test_sign_in_stores_handle_and_server_nickname(database): + with database.connect() as conn: + link(conn, "u1", "422", "Justin") + conn.commit() + identity = {"discord_user_id": "422", "discord_username": "justin_x", "discord_global_name": "Justin", + "discord_avatar": None} + users.sign_in(identity, guild_member=True, member={"nick": "Justin the Bold"}) + assert names("422") == ("justin_x", "Justin the Bold") + # A member with no nickname set clears it; a failed membership check leaves it alone. + users.sign_in(identity, guild_member=True, member={"nick": None}) + assert names("422") == ("justin_x", None) + remember_discord_nicknames([{"discord_user_id": "422", "discord_nickname": "Justin"}]) + users.sign_in(identity, guild_member=False, member=None) + assert names("422") == ("justin_x", "Justin") + + +def test_bot_can_send_nicknames(database, monkeypatch): + monkeypatch.setattr(skins_routes, "_require_staff", lambda key: None) + with database.connect() as conn: + link(conn, "u1", "1", None, "Old") + link(conn, "u2", "2", None, "Kept") + conn.commit() + body = skins_routes.DiscordUsernamesBody.model_validate({"updates": [ + {"discord_user_id": "1", "discord_username": "one_handle", "discord_nickname": " Lady One "}, + {"discord_user_id": "2", "discord_username": "two_handle"}, + ]}) + result = skins_routes.post_discord_usernames(body, x_staff_key="k") + assert result == {"updated": 2, "nicknames_updated": 1} + assert names("1") == ("one_handle", "Lady One") + # Not sent: kept. Not a valid nickname: skipped, so the stored one is kept too. + assert names("2") == ("two_handle", "Kept") + remember_discord_nicknames([{"discord_user_id": "2", "discord_nickname": "bad\x07name"}, + {"discord_user_id": "2", "discord_nickname": "x" * 33}]) + assert names("2") == ("two_handle", "Kept") + # Blank or null: they have no nickname now. + remember_discord_nicknames([{"discord_user_id": "2", "discord_nickname": " "}]) + assert names("2") == ("two_handle", None) + + +@pytest.mark.parametrize("payload,expected", [ + ({"roles": [], "nick": "Justin"}, {"nick": "Justin"}), + ({"roles": [], "nick": None}, {"nick": None}), + ({"pending": True, "nick": "x"}, None), +]) +def test_guild_member_reads_the_nickname(payload, expected): + from src.auth.test_discord_client import client + assert client(lambda request: httpx.Response(200, json=payload)).guild_member("tok") == expected diff --git a/backend/src/auth/test_players.py b/backend/src/auth/test_players.py index e56e3a33..617503dd 100644 --- a/backend/src/auth/test_players.py +++ b/backend/src/auth/test_players.py @@ -110,6 +110,8 @@ def test_directory_sorts(app, env, world): assert by_mc == ["LinkedOnly", "MrEnzo99", "Quiet", None] by_discord = [p["discord_username"] for p in c.get("/admin/players", params={"sort": "discord"}).json()["players"]] assert by_discord[:2] == ["hazelstone", "linky"] + by_character = [p["characters"] for p in c.get("/admin/players", params={"sort": "character"}).json()["players"]] + assert by_character[:2] == [["Aldric"], ["Hazel Stonebrook"]] and by_character[2:] == [[], []] assert c.get("/admin/players", params={"sort": "bogus"}).status_code == 400 assert c.get("/admin/players", params={"q": "x" * 65}).status_code == 400 @@ -263,3 +265,118 @@ def test_oversized_cursor_values_are_refused(app, env, world): c = client(app, staff(env)) huge = cursors.encode("main", f"sessions:{HAZEL}", 10 ** 30, 1) assert c.get(f"/admin/players/{HAZEL}/sessions", params={"before": huge}).status_code == 400 + + + +def movement_audits(db): + with db.connect() as conn: + return [dict(r) for r in conn.execute( + "SELECT * FROM admin_audit WHERE action = 'player.movement.view' ORDER BY id")] + + +@pytest.mark.parametrize("role", ["admin", "root"]) +def test_movement_routes_are_audited(app, env, world, coreprotect, role): + coreprotect.session(world["hazel"], 160, 2, x=42) + _, token = account(env, f"boss-{role}", role) + c = client(app, token) + body = c.get(f"/admin/players/{HAZEL}/movement", params={"since": 50, "until": 300}).json() + assert [p[0] for p in body["points"]] == [100, 160, 200] + assert body["points"][1][2] == 42 + assert body["worlds"] == ["TFMC_Map"] + assert body["complete_from"] == 50 + assert body["coreprotect"]["status"] == "available" + assert body["coreprotect"]["map_world"] == "TFMC_Map" + assert (body["since"], body["until"]) == (50, 300) + + body = c.get("/admin/movement", params={"since": 0, "until": 300}).json() + assert {p["minecraft_name"] for p in body["players"]} == {"MrEnzo99", "Quiet"} + + rows = movement_audits(env) + assert [json.loads(r["detail_json"])["player_uuid"] for r in rows] == [HAZEL, "everyone"] + detail = json.loads(rows[0]["detail_json"]) + assert (detail["since"], detail["until"], detail["rows"]) == (50, 300, 3) + # Which window and how much, never where. + assert set(detail) == {"server", "player_uuid", "since", "until", "complete_from", "rows"} + assert rows[0]["actor_role"] == role + + +def test_movement_matches_uuids_in_any_case(app, env, world, coreprotect): + upper = "44444444-AAAA-4BBB-8CCC-DDDDDDDDDDDD" + shouty = coreprotect.user("Shouty", upper) + coreprotect.session(shouty, 120, 2, x=9) + _, token = account(env, "boss", "admin") + body = client(app, token).get(f"/admin/players/{upper.lower()}/movement", params={"since": 50, "until": 300}).json() + assert [p[0] for p in body["points"]] == [120] + + +def test_session_movement(app, env, world, coreprotect): + coreprotect.session(world["hazel"], 160, 2, x=42) + _, token = account(env, "boss", "admin") + c = client(app, token) + listed = c.get(f"/admin/players/{HAZEL}/sessions").json()["sessions"] + assert len(listed) == 1 and listed[0]["id"] and "key" not in listed[0] + sid = listed[0]["id"] + body = c.get(f"/admin/players/{HAZEL}/sessions/{sid}/movement").json() + assert body["session"]["id"] == sid + assert body["session"]["end_kind"] == "logout" + assert body["session"]["last_observed"]["time"] == 200 + assert (body["since"], body["until"]) == (100, 200) + assert [p[0] for p in body["points"]] == [100, 160, 200] + assert body["as_of"] > 0 + detail = json.loads(movement_audits(env)[-1]["detail_json"]) + assert detail["session"] == sid and detail["rows"] == 3 + + # Another player's session id, a forged one, and a mod are all refused. + other = "33333333-3333-3333-3333-333333333333" + assert c.get(f"/admin/players/{other}/sessions/{sid}/movement").status_code == 400 + assert c.get(f"/admin/players/{HAZEL}/sessions/nonsense/movement").status_code == 400 + from src.coreprotect import cursors + forged = cursors.encode("main", f"session:{HAZEL}", 160, 1) + gone = c.get(f"/admin/players/{HAZEL}/sessions/{forged}/movement") + assert gone.status_code == 404 and gone.json()["detail"] == "session_gone" + assert client(app, staff(env, "mod")).get(f"/admin/players/{HAZEL}/sessions/{sid}/movement").status_code == 403 + + +def test_movement_is_for_admins(app, env, world): + c = client(app, staff(env, "mod")) + assert c.get(f"/admin/players/{HAZEL}/movement").status_code == 403 + assert c.get("/admin/movement").status_code == 403 + assert movement_audits(env) == [] + + +def test_movement_bad_windows(app, env, world): + _, token = account(env, "boss", "admin") + c = client(app, token) + for path, params in [ + (f"/admin/players/{HAZEL}/movement", {"since": 300, "until": 50}), + (f"/admin/players/{HAZEL}/movement", {"since": 0, "until": 8 * 86_400}), + ("/admin/movement", {"since": 0, "until": 2 * 86_400}), + # In the future, and beyond SQLite's integers. + ("/admin/movement", {"until": 2 ** 39}), + ("/admin/movement", {"since": 10 ** 30, "until": 10 ** 30 + 3600}), + ]: + assert c.get(path, params=params).status_code in (400, 422), (path, params) + assert c.get("/admin/players/not-a-uuid/movement").status_code == 400 + assert movement_audits(env) == [] + + +def test_no_audit_no_movement(app, env, world, monkeypatch): + _, token = account(env, "boss", "admin") + + def broken(*args, **kwargs): + raise RuntimeError("disk full") + + monkeypatch.setattr(players.audit, "record", broken) + response = client(app, token).get(f"/admin/players/{HAZEL}/movement", params={"since": 50, "until": 300}) + assert response.status_code == 503 + assert response.json()["detail"] == "audit_unavailable" + + +def test_movement_without_coreprotect(app, env, world, monkeypatch): + monkeypatch.setenv("COREPROTECT_DB", str(env.DATA_DIR / "nowhere.db")) + _, token = account(env, "boss", "admin") + c = client(app, token) + body = c.get(f"/admin/players/{HAZEL}/movement").json() + assert body["points"] == [] and body["coreprotect"]["reason"] == "missing" + body = c.get("/admin/movement").json() + assert body["players"] == [] and body["coreprotect"]["reason"] == "missing" diff --git a/backend/src/auth/users.py b/backend/src/auth/users.py index 513964a6..d84f5233 100644 --- a/backend/src/auth/users.py +++ b/backend/src/auth/users.py @@ -7,7 +7,7 @@ from src.skins.codes import hash_secret from src.skins.db import connect -from src.skins.discord_link import remember_discord_usernames +from src.skins.discord_link import remember_discord_nicknames, remember_discord_usernames from .config import AuthConfig from .discord import SCOPES @@ -87,7 +87,7 @@ def consume_state(state: str | None) -> str | None: return row["return_to"] -def sign_in(identity: dict, *, guild_member: bool) -> str: +def sign_in(identity: dict, *, guild_member: bool, member: dict | None = None) -> str: """Upsert the user, open a session and return its plaintext token.""" token = secrets.token_urlsafe(32) now = _utcnow() @@ -130,6 +130,11 @@ def sign_in(identity: dict, *, guild_member: bool) -> str: [{"discord_user_id": identity["discord_user_id"], "discord_username": identity["discord_username"]}], overwrite=True, ) + if member is not None: + # And their server nickname, now that Discord has just said what it is. + remember_discord_nicknames( + [{"discord_user_id": identity["discord_user_id"], "discord_nickname": member.get("nick")}] + ) return token diff --git a/backend/src/coreprotect/README.md b/backend/src/coreprotect/README.md index 9aee0f72..b958c79c 100644 --- a/backend/src/coreprotect/README.md +++ b/backend/src/coreprotect/README.md @@ -5,7 +5,8 @@ SQLite database: who has played, their sessions, and their recent actions. `src/auth/players.py` joins that with `discord_links`, `users` and `character_roster`; the routes are `GET /admin/players`, `/admin/players/{uuid}`, `/sessions` and `/activity` (`view_players`, mod and -above). +above). Movement, `GET /admin/players/{uuid}/movement` and `/admin/movement` +for everyone, is for admins and the owner (`view_player_movement`). ## Settings @@ -14,7 +15,8 @@ above). | `COREPROTECT_DB` | Path to `database.db` inside the backend container. Unset: the directory still lists linked players and characters, and CoreProtect sections say they are unavailable. | | `COREPROTECT_SERVER` | Short id that scopes caches and page cursors (default `main`). | | `COREPROTECT_SERVER_LABEL` | World name shown with the data, for example `Vardera`. | -| `COREPROTECT_PING_SECONDS` | The server's `player-pings` interval (default 60; `0` if pings are off). Only used to guess whether someone is online and to end crashed sessions. | +| `COREPROTECT_PING_SECONDS` | The server's `player-pings` interval (default 60; `0` if pings are off). Used to guess whether someone is online, to end crashed sessions and to tell a gap in a movement path. | +| `COREPROTECT_MAP_WORLD` | The CoreProtect world the site's map shows: the server's `level-name` (default `TFMC_Map`). Movement in other worlds is summarised, not drawn. | Mount the CoreProtect **directory** read-only, so SQLite can see a hot `-journal`. Set `disable-wal: true` in CoreProtect's `config.yml` (then @@ -67,6 +69,15 @@ text) before it is returned, and the page is refused if that record cannot be written. Sign text, item metadata and NBT are never selected. Sessions are rebuilt from login, logout and ping rows; see `sessions.py` for how crashed sessions end. +Movement is a player's session rows in a window (up to 7 days for one +player, 24 hours for everyone): logins, logouts and a position ping once a +minute, so the path between pings is a guess. Every request is recorded as +`player.movement.view` (who, which player or everyone, the window and how +many rows, never positions) before it is returned, and refused if that +record cannot be written. Rows are read newest first in batches of 5,000, +one statement each; a window holding more than the limit keeps the newest +rows and reports `complete_from`. + ## Deployment gate `backend/benchmarks/coreprotect_reader.py` measures both sides: @@ -84,6 +95,13 @@ failures and writer transaction p99/max up by no more than 100/500 ms. Results on 2026-10-06: +- Movement, inside Main's backend container (60 requests): everyone over + 24 h (26,900 rows, 10 statements) 33 ms per request, longest statement + 58 ms, usually under 8 ms; one player over 7 days (3,155 rows) 4 ms. Do + not time live reads through `~/work/amp-readonly`: it is a FUSE mount that + does not pass file locks through, so SQLite sees CoreProtect's journal as + hot and fails with "attempt to write a readonly database". + - `hold` on live Main (7.4 GB, 242 players, 1,455 requests): p99 8.6 ms, max 18.5 ms. - `contend` on a 6.5 GB synthetic Main, one reader (as in production): diff --git a/backend/src/coreprotect/movement.py b/backend/src/coreprotect/movement.py new file mode 100644 index 00000000..b0ff25bc --- /dev/null +++ b/backend/src/coreprotect/movement.py @@ -0,0 +1,207 @@ +"""Where players were: their co_session rows in a time window. + +Our fork logs each online player's position once a minute as a ping (action +2), besides the login (1) and logout (0) rows, so a player's rows in a window +trace where they went at one-minute resolution. Nothing in between is +recorded: a straight line between two pings is a guess, and a long jump may +be a teleport or just fast travel. + +Every read is an indexed seek: by player on session_user_index (user, time), +or for everyone on session_time_index (time). Rows are read newest first in +batches of BATCH, each its own statement, so no single statement holds the +read lock for long however wide the window. When a window holds more rows +than its limit, the newest are kept and `complete_from` says from when the +answer is complete; rows in that boundary second are dropped, as some of +them may have been. + +The row just before the window (if it is recent and not a logout) comes +along too, so a path starts where the player already was. +""" +from __future__ import annotations + +import uuid as uuidlib + +from .maps import Maps +from .reader import Reader +from .sessions import ACTION_LOGOUT, ACTION_PING, Key, _before + +# A week of one player's pings is about 10,000 rows. +PLAYER_WINDOW_SECONDS = 7 * 86_400 +PLAYER_POINT_LIMIT = 12_000 +# Main's busiest day so far (6 October 2026) held about 27,000 rows. +EVERYONE_WINDOW_SECONDS = 86_400 +EVERYONE_POINT_LIMIT = 50_000 +BATCH = 5_000 +# How far ahead of the server's clock a window may end (a viewer's clock may run fast). +FUTURE_SLACK_SECONDS = 300 +# Without a known ping interval, how far back the row before the window may be. +DEFAULT_LEAD_SECONDS = 150 + +_COLUMNS = "rowid AS rid, time, user, action, wid, x, y, z" + + +class BadWindow(ValueError): + pass + + +def window(since: int | None, until: int | None, now: int, longest: int) -> tuple[int, int]: + """The [since, until] window asked for: the last hour by default, at most `longest` seconds, not in the future.""" + until = now if until is None else until + since = until - 3_600 if since is None else since + if since < 0 or since >= until or until - since > longest or until > now + FUTURE_SLACK_SECONDS: + raise BadWindow("bad_window") + return since, until + + +def lead_seconds(ping_seconds: int) -> int: + """How old the row before the window may be and still say where the player was at its start.""" + return 2 * ping_seconds + 30 if ping_seconds > 0 else DEFAULT_LEAD_SECONDS + + +def _newest(reader: Reader, where: str, params: tuple, limit: int) -> list: + """Up to limit + 1 rows matching `where`, newest first, read in batches.""" + rows: list = [] + while len(rows) <= limit: + want = min(BATCH, limit + 1 - len(rows)) + cond, extra = where, params + if rows: + older, args = _before(Key(rows[-1]["time"], rows[-1]["rid"])) + cond, extra = f"{where} AND {older}", params + args + batch = reader.rows( + f"SELECT {_COLUMNS} FROM co_session WHERE {cond} ORDER BY time DESC, rowid DESC LIMIT ?", + extra + (want,), + ) + rows += batch + if len(batch) < want: + break + return rows + + +def _cap(rows: list, since: int, limit: int) -> tuple[list, int]: + """Newest first, at most limit rows, and the time from which they are complete.""" + rows = sorted(rows, key=lambda r: (r["time"], r["rid"]), reverse=True) + if len(rows) <= limit: + return rows, since + boundary = rows[limit]["time"] + return [r for r in rows[:limit] if r["time"] > boundary], boundary + 1 + + +def fetch_player(reader: Reader, ids: list[int], since: int, until: int, lead: int) -> dict: + rows = [] + for uid in ids: + rows += _newest(reader, "user = ? AND time >= ? AND time <= ?", (uid, since, until), PLAYER_POINT_LIMIT) + rows, complete_from = _cap(rows, since, PLAYER_POINT_LIMIT) + before = [] + if complete_from == since: + for uid in ids: + before += reader.rows( + f"SELECT {_COLUMNS} FROM co_session WHERE user = ? AND time < ? AND time >= ? " + "ORDER BY time DESC, rowid DESC LIMIT 1", + (uid, since, since - lead), + ) + newest_before = max(before, key=lambda r: (r["time"], r["rid"])) if before else None + if newest_before is not None and newest_before["action"] != ACTION_LOGOUT: + rows.append(newest_before) + return {"rows": rows, "complete_from": complete_from, "pings_since": _pings_since(reader)} + + +def fetch_session(reader: Reader, ids: list[int], win: dict) -> dict: + """One session's rows: from its login, through its logout or up to the next login (or now, if newest).""" + login = win["login"] + where = ["time >= ? AND NOT (time = ? AND rowid < ?)"] + params: tuple = (login["time"], login["time"], login["rid"]) + if win["logout"] is not None: + end = win["logout"] + where.append("time <= ? AND NOT (time = ? AND rowid > ?)") + params += (end["time"], end["time"], end["rid"]) + elif win["next_login"] is not None: + cond, args = _before(Key(win["next_login"]["time"], win["next_login"]["rid"])) + where.append(cond) + params += args + rows = [] + for uid in ids: + rows += _newest(reader, " AND ".join(["user = ?", *where]), (uid, *params), PLAYER_POINT_LIMIT) + rows, complete_from = _cap(rows, login["time"], PLAYER_POINT_LIMIT) + return {"rows": rows, "complete_from": complete_from, "pings_since": _pings_since(reader)} + + +def fetch_everyone(reader: Reader, since: int, until: int, lead: int) -> dict: + rows = _newest(reader, "time >= ? AND time <= ?", (since, until), EVERYONE_POINT_LIMIT) + rows, complete_from = _cap(rows, since, EVERYONE_POINT_LIMIT) + # A couple of ping intervals of everyone's rows before the window: a few dozen on a busy evening. + before = (_newest(reader, "time < ? AND time >= ?", (since, since - lead), EVERYONE_POINT_LIMIT) + if complete_from == since else []) + users = sorted({r["user"] for r in rows + before}) + names = {} + # Chunked to stay under SQLite's bound-parameter limit. + for start in range(0, len(users), 500): + chunk = users[start:start + 500] + names.update({r["id"]: dict(r) for r in reader.rows( + f"SELECT id, user, uuid FROM co_user WHERE id IN ({','.join('?' * len(chunk))})", tuple(chunk))}) + # The newest row per player, across all of their co_user ids, unless it is a logout. + latest: dict[str, object] = {} + for row in before: + user = names.get(row["user"]) + key = _canonical(user["uuid"]) if user else None + if key is not None: + latest.setdefault(key, row) + rows += [r for r in latest.values() if r["action"] != ACTION_LOGOUT] + return {"rows": rows, "complete_from": complete_from, "users": names, "pings_since": _pings_since(reader)} + + +def _pings_since(reader: Reader) -> int | None: + found = reader.rows("SELECT time FROM co_session WHERE action = ? ORDER BY time LIMIT 1", (ACTION_PING,)) + return found[0]["time"] if found else None + + +def _world_name(maps: Maps, wid: int | None) -> str: + return maps.world(wid) or f"world #{wid}" + + +def _points(rows, maps: Maps, worlds: list[str]) -> list[list[int]]: + """Oldest first, each [time, world index, x, y, z, action].""" + index = {name: i for i, name in enumerate(worlds)} + points = [] + for row in sorted(rows, key=lambda r: (r["time"], r["rid"])): + name = _world_name(maps, row["wid"]) + if name not in index: + index[name] = len(worlds) + worlds.append(name) + points.append([row["time"], index[name], row["x"], row["y"], row["z"], row["action"]]) + return points + + +def build_player(raw: dict, maps: Maps) -> dict: + worlds: list[str] = [] + points = _points(raw["rows"], maps, worlds) + return {"worlds": worlds, "points": points, "complete_from": raw["complete_from"], + "pings_since": raw["pings_since"]} + + +def _canonical(text: str | None) -> str | None: + try: + return str(uuidlib.UUID(str(text or "").strip())) + except ValueError: + return None + + +def build_everyone(raw: dict, maps: Maps) -> dict: + """Players with any row in the window, by UUID; rows under several co_user ids for one UUID merge.""" + worlds: list[str] = [] + groups: dict[str, list] = {} + names: dict[str, tuple[tuple[int, int], str]] = {} + for row in raw["rows"]: + user = raw["users"].get(row["user"]) + key = _canonical(user["uuid"]) if user else None + if key is None: + continue + groups.setdefault(key, []).append(row) + order = (row["time"], row["rid"]) + if key not in names or order > names[key][0]: + names[key] = (order, user["user"]) + players = [ + {"uuid": key, "minecraft_name": names[key][1], "points": _points(groups[key], maps, worlds)} + for key in sorted(groups, key=lambda k: (names[k][1].lower(), k)) + ] + return {"worlds": worlds, "players": players, "complete_from": raw["complete_from"], + "pings_since": raw["pings_since"]} diff --git a/backend/src/coreprotect/reader.py b/backend/src/coreprotect/reader.py index d9ddd4e6..3ecfc70f 100644 --- a/backend/src/coreprotect/reader.py +++ b/backend/src/coreprotect/reader.py @@ -48,6 +48,8 @@ class CoreProtectConfig: label: str # CoreProtect's player-pings interval; 0 when pings are off or unknown. ping_seconds: int + # The CoreProtect world the site's map shows (the server's level-name). + map_world: str = "TFMC_Map" @classmethod def from_env(cls) -> CoreProtectConfig: @@ -61,6 +63,7 @@ def from_env(cls) -> CoreProtectConfig: server=os.getenv("COREPROTECT_SERVER", "main").strip() or "main", label=os.getenv("COREPROTECT_SERVER_LABEL", "").strip(), ping_seconds=ping, + map_world=os.getenv("COREPROTECT_MAP_WORLD", "").strip() or "TFMC_Map", ) diff --git a/backend/src/coreprotect/sessions.py b/backend/src/coreprotect/sessions.py index cba3f489..14c29318 100644 --- a/backend/src/coreprotect/sessions.py +++ b/backend/src/coreprotect/sessions.py @@ -103,6 +103,24 @@ def fetch(reader: Reader, ids: list[int], before: Key | None, limit: int) -> dic } +def window(reader: Reader, ids: list[int], login: Key) -> dict | None: + """One session's window, found by its login row, in the shape fetch() gives; None if that login is not theirs.""" + found = [] + for uid in ids: + found += reader.rows( + f"SELECT {_COLUMNS} FROM co_session WHERE user = ? AND action = ? AND time = ? AND rowid = ?", + (uid, ACTION_LOGIN, login.time, login.rowid), + ) + if not found: + return None + following = _seek(reader, ids, ACTION_LOGIN, [_after(login)], newest=False) + bounds = [_after(login)] + ([_before(_key(following))] if following else []) + logout = _seek(reader, ids, ACTION_LOGOUT, bounds, newest=False) + ping = None if logout else _seek(reader, ids, ACTION_PING, bounds, newest=True) + return {"login": found[0], "logout": logout, "ping": ping, "newest": following is None, + "next_login": following} + + def last_event(reader: Reader, ids: list[int]): """The newest session row of any kind across a player's ids, or None.""" rows = [] @@ -145,6 +163,8 @@ def build(raw: dict, maps: Maps, now: int, ping_seconds: int) -> dict: duration = max(0, now - login["time"]) else: duration = None + # The newest row seen in the session, whatever its end: for an open one, its last sighting. + seen = logout or ping or login sessions.append({"start": _point(login, maps), "end": end, "end_kind": end_kind, - "duration_seconds": duration}) + "duration_seconds": duration, "last_observed": _point(seen, maps), "key": _key(login)}) return {"sessions": sessions, "first_seen": raw["first_seen"], "history_start": raw["history_start"]} diff --git a/backend/src/coreprotect/test_movement.py b/backend/src/coreprotect/test_movement.py new file mode 100644 index 00000000..8ef1ce0a --- /dev/null +++ b/backend/src/coreprotect/test_movement.py @@ -0,0 +1,182 @@ +import pytest + +from src.coreprotect import maps, movement +from src.coreprotect.reader import CoreProtectConfig, Reader + +LOGOUT, LOGIN, PING = 0, 1, 2 +LEAD = 150 + + +def at(db, user, time, action, x, z, wid=1): + db.execute("INSERT INTO co_session (time, user, wid, x, y, z, action) VALUES (?, ?, ?, ?, 64, ?, ?)", + (time, user, wid, x, z, action)) + + +def config(db): + return CoreProtectConfig(path=str(db.path), server="main", label="", ping_seconds=60) + + +def player(db, ids, since, until): + with Reader(config(db)) as r: + return movement.build_player(movement.fetch_player(r, ids, since, until, LEAD), maps.get(r)) + + +def everyone(db, since, until): + with Reader(config(db)) as r: + return movement.build_everyone(movement.fetch_everyone(r, since, until, LEAD), maps.get(r)) + + +def test_window_defaults_and_limits(): + assert movement.window(None, None, 10_000, 86_400) == (6_400, 10_000) + assert movement.window(100, 200, 10_000, 86_400) == (100, 200) + assert movement.window(None, 5_000, 10_000, 86_400) == (1_400, 5_000) + assert movement.window(None, 10_300, 10_000, 86_400) == (6_700, 10_300) + for since, until in [(200, 200), (300, 200), (0, 86_401), (-5, 100), (None, 10_301)]: + with pytest.raises(movement.BadWindow): + movement.window(since, until, 10_000, 86_400) + + +def test_lead_seconds(): + assert movement.lead_seconds(60) == 150 + assert movement.lead_seconds(0) == movement.DEFAULT_LEAD_SECONDS + + +def test_player_points_in_window_oldest_first(coreprotect): + me = coreprotect.user("Hazel", "0615a817-8cb4-4aef-95f7-f6c9bf7611b8") + other = coreprotect.user("Bob", "00000000-0000-0000-0000-000000000002") + at(coreprotect, me, 10, PING, 1, 1) + at(coreprotect, me, 100, LOGIN, 10, 20) + at(coreprotect, me, 160, PING, 30, 40) + at(coreprotect, me, 220, PING, 50, 60, wid=2) + at(coreprotect, me, 250, LOGOUT, 70, 80, wid=2) + at(coreprotect, me, 400, LOGIN, 0, 0, wid=9) + at(coreprotect, other, 160, PING, 999, 999) + + built = player(coreprotect, [me], 100, 250) + assert built["worlds"] == ["TFMC_Map", "TFMC_Map_the_end"] + assert built["points"] == [ + # The row just before the window, within a couple of ping intervals. + [10, 0, 1, 64, 1, PING], + [100, 0, 10, 64, 20, LOGIN], + [160, 0, 30, 64, 40, PING], + [220, 1, 50, 64, 60, PING], + [250, 1, 70, 64, 80, LOGOUT], + ] + assert built["complete_from"] == 100 + assert built["pings_since"] == 10 + # An unknown world keeps its own name rather than merging with others. + assert player(coreprotect, [me], 300, 500)["worlds"] == ["world #9"] + + +def test_player_path_starts_from_the_row_before_the_window(coreprotect): + me = coreprotect.user("Hazel", "0615a817-8cb4-4aef-95f7-f6c9bf7611b8") + at(coreprotect, me, 1_000, PING, 5, 5) + at(coreprotect, me, 1_060, PING, 6, 6) + assert [p[0] for p in player(coreprotect, [me], 1_050, 1_100)["points"]] == [1_000, 1_060] + # Too old to say where they were when the window opened. + assert [p[0] for p in player(coreprotect, [me], 1_061 + LEAD, 2_000)["points"]] == [] + # A logout just before the window means they were offline at its start. + at(coreprotect, me, 1_070, LOGOUT, 6, 6) + assert [p[0] for p in player(coreprotect, [me], 1_080, 1_100)["points"]] == [] + + +def test_player_rows_merge_across_ids_and_keep_the_newest(coreprotect, monkeypatch): + first = coreprotect.user("Old", "0615a817-8cb4-4aef-95f7-f6c9bf7611b8") + second = coreprotect.user("New", "0615a817-8cb4-4aef-95f7-f6c9bf7611b8") + for t in (100, 300, 500, 500): + at(coreprotect, first, t, PING, t, 0) + for t in (200, 400, 600): + at(coreprotect, second, t, PING, t, 0) + # Batches smaller than the answer still read every row once. + monkeypatch.setattr(movement, "BATCH", 2) + assert [p[0] for p in player(coreprotect, [first, second], 0, 1_000)["points"]] == [100, 200, 300, 400, 500, + 500, 600] + + monkeypatch.setattr(movement, "PLAYER_POINT_LIMIT", 2) + built = player(coreprotect, [first, second], 0, 1_000) + # The cut fell inside second 500, so that whole second is left out. + assert [p[0] for p in built["points"]] == [600] + assert built["complete_from"] == 501 + + +def test_everyone_groups_by_canonical_uuid(coreprotect, monkeypatch): + a1 = coreprotect.user("Zed", "00000000-0000-0000-0000-00000000000a") + a2 = coreprotect.user("Zed2", "00000000-0000-0000-0000-00000000000A") + b = coreprotect.user("alice", "00000000-0000-0000-0000-00000000000b") + console = coreprotect.user("#console", None) + junk = coreprotect.user("junk", "not-a-uuid") + at(coreprotect, a1, 100, LOGIN, 1, 1) + at(coreprotect, a2, 200, PING, 2, 2) + at(coreprotect, b, 150, PING, 5, 5, wid=2) + at(coreprotect, console, 150, PING, 9, 9) + at(coreprotect, junk, 150, PING, 9, 9) + at(coreprotect, b, 900, PING, 6, 6) + + built = everyone(coreprotect, 0, 500) + assert [(p["uuid"][-1], p["minecraft_name"]) for p in built["players"]] == [("b", "alice"), ("a", "Zed2")] + assert built["players"][0]["points"] == [[150, 0, 5, 64, 5, PING]] + assert [p[0] for p in built["players"][1]["points"]] == [100, 200] + assert built["complete_from"] == 0 + + # Rows of the console and malformed UUIDs count towards the limit, though they are dropped later. + monkeypatch.setattr(movement, "EVERYONE_POINT_LIMIT", 2) + built = everyone(coreprotect, 0, 500) + assert built["complete_from"] == 151 + assert [p[0] for pl in built["players"] for p in pl["points"]] == [200] + + +def test_everyone_starts_from_the_rows_before_the_window(coreprotect): + a = coreprotect.user("a", "00000000-0000-0000-0000-00000000000a") + b = coreprotect.user("b", "00000000-0000-0000-0000-00000000000b") + c = coreprotect.user("c", "00000000-0000-0000-0000-00000000000c") + at(coreprotect, a, 900, PING, 1, 1) + at(coreprotect, a, 950, PING, 2, 2) + at(coreprotect, b, 960, LOGOUT, 3, 3) + at(coreprotect, c, 500, PING, 4, 4) + at(coreprotect, a, 1_010, PING, 3, 3) + built = everyone(coreprotect, 1_000, 1_100) + assert [(p["minecraft_name"], [x[0] for x in p["points"]]) for p in built["players"]] == [("a", [950, 1_010])] + + +def test_everyone_lead_in_is_per_player_not_per_id(coreprotect): + old = coreprotect.user("Old", "00000000-0000-0000-0000-00000000000a") + new = coreprotect.user("New", "00000000-0000-0000-0000-00000000000A") + at(coreprotect, old, 900, PING, 1, 1) + at(coreprotect, new, 990, LOGOUT, 2, 2) + # Logged out under the newer id: offline when the window opens, whatever the older id said. + assert everyone(coreprotect, 1_000, 1_100)["players"] == [] + + +def session_rows(db, ids, login): + from src.coreprotect import sessions + with Reader(config(db)) as r: + win = sessions.window(r, ids, sessions.Key(*login)) + return None if win is None else [p[0] for p in movement.build_player( + movement.fetch_session(r, ids, win), maps.get(r))["points"]] + + +def test_session_rows_run_from_login_to_logout_or_next_login(coreprotect): + me = coreprotect.user("Hazel", "0615a817-8cb4-4aef-95f7-f6c9bf7611b8") + other = coreprotect.user("Bob", "00000000-0000-0000-0000-000000000002") + at(coreprotect, me, 50, PING, 0, 0) + first = coreprotect.execute( + "INSERT INTO co_session (time, user, wid, x, y, z, action) VALUES (100, ?, 1, 0, 64, 0, 1)", (me,)) + at(coreprotect, me, 160, PING, 1, 1) + at(coreprotect, other, 170, PING, 9, 9) + at(coreprotect, me, 200, LOGOUT, 2, 2) + # Logged out, then a stray row before the next login is not part of the session. + at(coreprotect, me, 210, PING, 3, 3) + second = coreprotect.execute( + "INSERT INTO co_session (time, user, wid, x, y, z, action) VALUES (300, ?, 1, 0, 64, 0, 1)", (me,)) + at(coreprotect, me, 360, PING, 4, 4) + # Crashed: no logout before the next login. + third = coreprotect.execute( + "INSERT INTO co_session (time, user, wid, x, y, z, action) VALUES (500, ?, 1, 0, 64, 0, 1)", (me,)) + at(coreprotect, me, 560, PING, 5, 5) + + assert session_rows(coreprotect, [me], (100, first)) == [100, 160, 200] + assert session_rows(coreprotect, [me], (300, second)) == [300, 360] + assert session_rows(coreprotect, [me], (500, third)) == [500, 560] + # Someone else's login, or a row that is not a login, is not a session of theirs. + assert session_rows(coreprotect, [other], (100, first)) is None + assert session_rows(coreprotect, [me], (360, first + 5)) is None diff --git a/backend/src/skins/db.py b/backend/src/skins/db.py index 036cf0e8..035f4315 100644 --- a/backend/src/skins/db.py +++ b/backend/src/skins/db.py @@ -40,6 +40,23 @@ def migrate() -> None: ) +def _move_nicknames_out_of_usernames(conn: sqlite3.Connection) -> None: + """Links made before usernames were checked hold a server nickname there ("Justin"). + + Move each into discord_nickname and clear the username, so the bot's next + fill (which only fills empty ones) or the player's next sign-in stores the handle. + """ + from .discord_link import is_discord_username + + for row in conn.execute("SELECT player_uuid, discord_username FROM discord_links").fetchall(): + name = row["discord_username"] + if name and not is_discord_username(name): + conn.execute( + "UPDATE discord_links SET discord_nickname = ?, discord_username = NULL WHERE player_uuid = ?", + (name.strip(), row["player_uuid"]), + ) + + def _upgrade(conn: sqlite3.Connection) -> None: """Add columns that CREATE TABLE IF NOT EXISTS cannot add to old tables.""" conn.execute("BEGIN IMMEDIATE") @@ -48,6 +65,10 @@ def _upgrade(conn: sqlite3.Connection) -> None: if "role" not in columns: conn.execute(f"ALTER TABLE users ADD COLUMN {_USERS_ROLE_COLUMN}") conn.execute("CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)") + links = {row["name"] for row in conn.execute("PRAGMA table_info(discord_links)")} + if "discord_nickname" not in links: + conn.execute("ALTER TABLE discord_links ADD COLUMN discord_nickname TEXT") + _move_nicknames_out_of_usernames(conn) conn.commit() except BaseException: conn.rollback() diff --git a/backend/src/skins/discord_link.py b/backend/src/skins/discord_link.py index 3cec41f6..37b4622e 100644 --- a/backend/src/skins/discord_link.py +++ b/backend/src/skins/discord_link.py @@ -156,6 +156,54 @@ def start_link( return {"code": plaintext, "expires_at": expires_at} +def is_discord_username(value: str) -> bool: + """Whether text is shaped like a Discord account handle (not a display name or nickname).""" + return _DISCORD_USERNAME_RE.fullmatch(value.strip()) is not None + + +_NICKNAME_MAX = 32 + + +def _sanitize_discord_nickname(value: str | None) -> str | None: + """A server nickname: any text up to Discord's 32 characters, without control characters.""" + raw = " ".join(str(value or "").split()) + if not raw or len(raw) > _NICKNAME_MAX or any(ord(ch) < 32 or ord(ch) == 127 for ch in raw): + return None + return raw + + +def remember_discord_nicknames(updates: list[dict]) -> dict: + """Store each link's current server nickname. + + None or blank clears it (they have no nickname). A value that is not a + valid nickname (too long, control characters) is skipped, keeping the + stored one, rather than taken as a clear. + """ + if not isinstance(updates, list): + raise LinkError("updates must be a list") + if len(updates) > _USERNAME_UPDATES_MAX: + raise LinkError("too many nickname updates") + updated = 0 + with connect() as conn: + for item in updates: + if not isinstance(item, dict): + continue + discord_id = str(item.get("discord_user_id") or "").strip() + if not discord_id: + continue + raw = item.get("discord_nickname") + nickname = _sanitize_discord_nickname(raw) + if nickname is None and str(raw or "").strip(): + continue + cur = conn.execute( + "UPDATE discord_links SET discord_nickname = ? WHERE discord_user_id = ?", + (nickname, discord_id), + ) + updated += cur.rowcount + conn.commit() + return {"updated": updated} + + def _sanitize_discord_username(value: str | None) -> str | None: """Keep a Discord account username. Anything else is omitted, never fatal.""" raw = str(value or "").strip() diff --git a/backend/src/skins/schema.sql b/backend/src/skins/schema.sql index 21f9a86a..0e76e53d 100644 --- a/backend/src/skins/schema.sql +++ b/backend/src/skins/schema.sql @@ -60,7 +60,9 @@ CREATE TABLE IF NOT EXISTS discord_links ( discord_username TEXT, linked_at TEXT NOT NULL, left_guild_at TEXT, - grace_until TEXT + grace_until TEXT, + -- Their nickname in the TFMC Discord server, if any; discord_username is the account handle. + discord_nickname TEXT ); CREATE TABLE IF NOT EXISTS discord_link_codes ( diff --git a/frontend/app/admin/movement/page.tsx b/frontend/app/admin/movement/page.tsx new file mode 100644 index 00000000..358d5305 --- /dev/null +++ b/frontend/app/admin/movement/page.tsx @@ -0,0 +1,18 @@ +import { Suspense } from "react"; +import AdminNav from "../../components/admin/AdminNav"; +import EveryoneMovement from "../../components/admin/EveryoneMovement"; + +export default function AdminMovementPage() { + return ( +
+

+ Staff panel +

+ + {/* The view lives in the URL's query, which needs a Suspense boundary. */} + Loading…

}> + +
+
+ ); +} diff --git a/frontend/app/admin/players/[uuid]/movement/page.tsx b/frontend/app/admin/players/[uuid]/movement/page.tsx new file mode 100644 index 00000000..3084b7d0 --- /dev/null +++ b/frontend/app/admin/players/[uuid]/movement/page.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import { Suspense } from "react"; +import AdminNav from "../../../../components/admin/AdminNav"; +import PlayerMovementPage from "../../../../components/admin/PlayerMovementPage"; + +export default async function AdminPlayerMovementPage({ params }: { params: Promise<{ uuid: string }> }) { + const { uuid } = await params; + const id = decodeURIComponent(uuid); + return ( +
+

+ Staff panel +

+ + + ← Profile + + {/* The view lives in the URL's query, which needs a Suspense boundary. Keyed so a new player starts afresh. */} + Loading…

}> + +
+
+ ); +} diff --git a/frontend/app/components/admin/AdminNav.tsx b/frontend/app/components/admin/AdminNav.tsx index dafe311d..e3a3c084 100644 --- a/frontend/app/components/admin/AdminNav.tsx +++ b/frontend/app/components/admin/AdminNav.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; const LINKS = [ { href: "/admin", label: "Accounts", key: "accounts" }, { href: "/admin/players", label: "Players", key: "players" }, + { href: "/admin/movement", label: "Movement", key: "movement" }, ] as const; export default function AdminNav({ current }: { current: (typeof LINKS)[number]["key"] }) { diff --git a/frontend/app/components/admin/EveryoneMovement.test.tsx b/frontend/app/components/admin/EveryoneMovement.test.tsx new file mode 100644 index 00000000..31c8990a --- /dev/null +++ b/frontend/app/components/admin/EveryoneMovement.test.tsx @@ -0,0 +1,95 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import EveryoneMovement from "./EveryoneMovement"; +import { getEveryoneMovement } from "../../../lib/admin/movement"; + +process.env.TZ = "Europe/London"; + +const nav = vi.hoisted(() => { + const listeners = new Set<() => void>(); + const state = { params: new URLSearchParams() }; + const go = (url: string) => { + state.params = new URLSearchParams(url.split("?")[1] ?? ""); + listeners.forEach((listener) => listener()); + }; + return { listeners, state, go }; +}); + +vi.mock("next/navigation", async () => { + const { useEffect, useReducer } = await import("react"); + return { + useSearchParams: () => { + const [, force] = useReducer((n: number) => n + 1, 0); + useEffect(() => { + nav.listeners.add(force); + return () => { + nav.listeners.delete(force); + }; + }, []); + return nav.state.params; + }, + usePathname: () => "/admin/players/u/movement", + }; +}); +vi.mock("../../../lib/admin/urlState", () => ({ writeUrl: (url: string) => nav.go(url) })); +vi.mock("../../../lib/admin/movement", async (importOriginal) => ({ + ...(await importOriginal()), + getEveryoneMovement: vi.fn(), +})); +vi.mock("./MovementMap", () => ({ + default: ({ cursor, fitKey }: { cursor: number; fitKey: string | null }) => ( +
+ ), +})); +vi.mock("./MovementControls", async (importOriginal) => ({ + ...(await importOriginal()), + useLiveMapId: () => "main", +})); + +const T = Date.UTC(2026, 9, 6, 13, 0) / 1000; +const A = "00000000-0000-0000-0000-00000000000a"; +const B = "00000000-0000-0000-0000-00000000000b"; + +beforeEach(() => { + nav.state.params = new URLSearchParams(`from=${T - 3600}&to=${T}&at=${T - 60}`); + vi.mocked(getEveryoneMovement).mockResolvedValue({ + since: T - 3600, + until: T, + as_of: T, + worlds: ["TFMC_Map", "TFMC_Map_the_end"], + players: [ + { uuid: A, minecraft_name: "Alice", points: [[T - 120, 0, 10, 64, 20, 2], [T - 60, 0, 11, 64, 21, 2]] }, + { uuid: B, minecraft_name: "Bob", points: [[T - 150, 1, 5, 64, 5, 2]] }, + ], + complete_from: T - 3600, + pings_since: T - 86400, + coreprotect: { status: "available", server_label: "Vardera", ping_seconds: 60, map_world: "TFMC_Map" }, + }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +it("compares everyone at the inspected moment and carries the view to a player", async () => { + render(); + await screen.findByText(/2 of 2 players observed or estimated at this moment/); + expect(getEveryoneMovement).toHaveBeenCalledWith(T - 3600, T); + expect(screen.getByText("11, 21")).toBeTruthy(); + // Bob's last ping was 90 s before the moment, in the End. + expect(screen.getByText(/the End · 2 min before/)).toBeTruthy(); + const link = screen.getByRole("link", { name: "Alice" }).getAttribute("href")!; + expect(link).toContain(`/admin/players/${A}/movement?`); + expect(link).toContain(`from=${T - 3600}`); + expect(link).toContain(`at=${T - 60}`); + + fireEvent.click(screen.getByLabelText("Select Bob")); + expect(nav.state.params.get("players")).toBe(B); + // "Only selected" lives in the URL, so a copied link shows the same players. + fireEvent.click(await screen.findByLabelText(/Only selected/)); + expect(nav.state.params.get("only")).toBe("1"); + await screen.findByText(/1 of 1 players observed/); +}); diff --git a/frontend/app/components/admin/EveryoneMovement.tsx b/frontend/app/components/admin/EveryoneMovement.tsx new file mode 100644 index 00000000..bf531376 --- /dev/null +++ b/frontend/app/components/admin/EveryoneMovement.tsx @@ -0,0 +1,351 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { adminErrorMessage, coreProtectMessage } from "../../../lib/admin/api"; +import { + EVERYONE_WINDOW_SECONDS, + clipStretches, + formatClock, + formatMoment, + gapSeconds, + getEveryoneMovement, + inspect, + observationTimes, + observedBands, + playerColour, + stretches, + worldLabel, + type EveryoneMovement as Movement, + type Inspection, +} from "../../../lib/admin/movement"; +import { formatDuration } from "../../../lib/admin/time"; +import { writeUrl } from "../../../lib/admin/urlState"; +import { StaffGateMessage, gateKind, type GateKind } from "./StaffGate"; +import MovementMap, { type MapPin, type MovementTrail } from "./MovementMap"; +import { + CopyButton, + InspectBar, + PinForm, + RangeForm, + Timeline, + chipClass, + chipOff, + inputClass, + mutedClass, + useLiveMapId, + useMinuteClock, + type Range, +} from "./MovementControls"; + +type Load = + | { kind: "loading"; previous: Movement | null } + | { kind: GateKind } + | { kind: "failed"; message: string; previous: Movement | null } + | { kind: "ready"; data: Movement; key: string }; + +function intParam(value: string | null): number | null { + return value !== null && /^-?\d+$/.test(value) ? Number(value) : null; +} + +function pinParam(value: string | null): MapPin | null { + const match = value?.match(/^(-?\d+),(-?\d+)$/); + return match ? { x: Number(match[1]), z: Number(match[2]) } : null; +} + +/** Where a player was at the inspected moment, with how it is known. */ +function whereText(found: Inspection, moment: number, mapWorld: string): string { + const place = (x: number, z: number, world: string | null) => + world === mapWorld ? `${Math.round(x)}, ${Math.round(z)}` : worldLabel(world); + switch (found.kind) { + case "observed": + return place(found.sample.x, found.sample.z, found.world); + case "estimated": + return `≈ ${place(found.x, found.z, found.world)}`; + case "unobserved": + return "unobserved transition"; + case "stale": + return `${place(found.before.x, found.before.z, found.world)} · ${formatDuration(moment - found.before.time).toLowerCase()} before`; + default: + return "not observed"; + } +} + +/** Every player's movement over a range on one map, compared at one inspected moment. */ +export default function EveryoneMovement() { + const pathname = usePathname(); + const search = useSearchParams(); + const mapId = useLiveMapId(); + + const [opened] = useState(() => Math.floor(Date.now() / 1000)); + const from = intParam(search.get("from")) ?? opened - 3600; + const to = intParam(search.get("to")) ?? opened; + const follow = search.get("follow") === "1"; + const at = intParam(search.get("at")); + const pin = pinParam(search.get("pin")); + const chosen = useMemo(() => new Set((search.get("players") ?? "").split(",").filter(Boolean)), [search]); + const onlyChosen = search.get("only") === "1"; + const [query, setQuery] = useState(""); + const [highlight, setHighlight] = useState(null); + + const update = useCallback( + (changes: Record, replace = false) => { + const next = new URLSearchParams(search.toString()); + for (const [key, value] of Object.entries(changes)) { + if (value === null) next.delete(key); + else next.set(key, value); + } + writeUrl(`${pathname}?${next.toString()}`, replace); + }, + [pathname, search] + ); + + const now = useMinuteClock(follow); + const range: Range = follow ? { from: now - (to - from), to: now, follow: true } : { from, to, follow: false }; + const viewKey = follow ? `follow:${to - from}` : `range:${from}:${to}`; + + const [load, setLoad] = useState({ kind: "loading", previous: null }); + const [retry, setRetry] = useState(0); + useEffect(() => { + let live = true; + setLoad((prev) => ({ + kind: "loading", + previous: prev.kind === "ready" ? prev.data : prev.kind === "loading" || prev.kind === "failed" ? prev.previous : null, + })); + getEveryoneMovement(range.from, range.to) + .then((data) => live && setLoad({ kind: "ready", data, key: viewKey })) + .catch((err) => { + if (!live) return; + const gate = gateKind(err); + setLoad((prev) => + gate === "error" + ? { kind: "failed", message: adminErrorMessage(err), previous: prev.kind === "loading" ? prev.previous : null } + : { kind: gate } + ); + }); + return () => { + live = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [range.from, range.to, retry]); + + const data = load.kind === "ready" ? load.data : load.kind === "loading" || load.kind === "failed" ? load.previous : null; + const all = useMemo( + () => + data + ? data.players.map((player) => ({ + key: player.uuid, + label: player.minecraft_name, + colour: playerColour(player.uuid), + stretches: stretches(player.points, data.worlds, data.coreprotect.ping_seconds), + })) + : [], + [data] + ); + const drawn = useMemo( + () => (onlyChosen && chosen.size ? all.filter((t) => chosen.has(t.key)) : all), + [all, chosen, onlyChosen] + ); + const since = data?.since ?? range.from; + const until = data?.until ?? range.to; + const times = useMemo( + () => observationTimes(drawn.flatMap((t) => t.stretches), data?.complete_from ?? since, until), + [drawn, data, since, until] + ); + + if (load.kind === "forbidden") { + return

Movement is for admins and the owner only.

; + } + if (load.kind === "signed_out" || load.kind === "unavailable" || load.kind === "error") { + return ; + } + + const mapWorld = data?.coreprotect.map_world ?? "TFMC_Map"; + const hold = gapSeconds(data?.coreprotect.ping_seconds); + const completeFrom = data?.complete_from ?? since; + const moment = Math.min(until, Math.max(since, at ?? until)); + const notice = data ? coreProtectMessage(data.coreprotect) : null; + const unknownUntil = data + ? Math.max(completeFrom, data.pings_since !== null && data.pings_since > since ? data.pings_since : since) + : null; + // A row per player while few enough to tell apart; otherwise when anyone was observed. + const perPlayer = drawn.length <= 8; + const bands = drawn.flatMap((t, row) => + observedBands(clipStretches(t.stretches, completeFrom, until)).map(([a, b]) => + perPlayer ? { from: a, to: b, colour: t.colour, row } : { from: a, to: b } + ) + ); + const needle = query.trim().toLowerCase(); + const listed = all.filter((t) => !needle || t.label.toLowerCase().includes(needle)); + const seenNow = drawn.filter((t) => inspect(t.stretches, moment, hold).kind !== "none").length; + + const toggle = (key: string) => { + const next = new Set(chosen); + if (next.has(key)) next.delete(key); + else next.add(key); + update({ players: next.size ? [...next].join(",") : null }, true); + }; + const playerHref = (uuid: string) => { + const params = new URLSearchParams({ from: String(range.from), to: String(range.to), at: String(moment) }); + if (pin) params.set("pin", `${pin.x},${pin.z}`); + return `/admin/players/${encodeURIComponent(uuid)}/movement?${params.toString()}`; + }; + const shareUrl = () => { + const params = new URLSearchParams(search.toString()); + params.set("from", String(range.from)); + params.set("to", String(range.to)); + params.delete("follow"); + params.set("at", String(moment)); + return `${window.location.origin}${pathname}?${params.toString()}`; + }; + + return ( +
+
+

+ {data?.coreprotect.server_label ? `${data.coreprotect.server_label} · ` : ""} + Everyone online over a range of up to 24 hours. Positions are recorded once a minute; lines between them are + estimates. Each view is logged. +

+ {data ? : null} +
+
+ +
+ {notice ?

{notice}

: null} + {load.kind === "failed" ? ( +
+

+ {load.message} + {load.previous ? " Showing the previous results." : ""} +

+ +
+ ) : null} + {load.kind === "loading" ? ( +

{load.previous ? "Loading… (showing the previous results)" : "Loading…"}

+ ) : null} +
+ {mapId ? ( + update({ at: String(time) }, true)} + className="h-[60vh] min-h-[22rem] rounded-sm lg:h-[calc(100dvh-24rem)]" + /> + ) : ( +
+ )} +
+ {data ? ( + <> + since ? "earlier observations omitted" : "before available position observations"} + bands={bands} + cursor={moment} + onCursor={(time) => update({ at: String(time) }, true)} + /> + update({ at: String(time) }, true)}> + Inspecting {formatClock(moment, true)} · {seenNow} of {drawn.length} players observed or estimated at this + moment. Positions in the list are as of this moment. + + {completeFrom > since ? ( +

+ Earlier observations omitted: too many to show, so the range starts at {formatMoment(completeFrom)}. +

+ ) : null} + {data.pings_since !== null && data.pings_since > since ? ( +

Before {formatMoment(data.pings_since)} there are no available position observations.

+ ) : null} + + ) : null} +
+
+
+ ); +} diff --git a/frontend/app/components/admin/MovementCard.tsx b/frontend/app/components/admin/MovementCard.tsx new file mode 100644 index 00000000..4799bdfd --- /dev/null +++ b/frontend/app/components/admin/MovementCard.tsx @@ -0,0 +1,40 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import { getAdminMe } from "../../../lib/admin/api"; + +/** A way into the player's movement page, for those allowed to see it (admins and the owner). */ +export default function MovementCard({ uuid }: { uuid: string }) { + const [allowed, setAllowed] = useState(false); + useEffect(() => { + let live = true; + getAdminMe() + .then((me) => live && setAllowed(me.capabilities.includes("view_player_movement"))) + .catch(() => undefined); + return () => { + live = false; + }; + }, []); + if (!allowed) return null; + return ( +
+
+

Movement

+

+ Where they went, session by session or over any time range, on the map. +

+
+ + Open movement + +
+ ); +} diff --git a/frontend/app/components/admin/MovementControls.tsx b/frontend/app/components/admin/MovementControls.tsx new file mode 100644 index 00000000..54824cb8 --- /dev/null +++ b/frontend/app/components/admin/MovementControls.tsx @@ -0,0 +1,468 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; + +import type { MapPin } from "./MovementMap"; +import { useAccessibleMaps } from "../../hooks/useAccessibleMaps"; +import { liveMapIdFrom } from "../../lib/map/chronicleDayRoute"; +import { formatDuration } from "../../../lib/admin/time"; +import { + describeSpan, + formatClock, + formatDay, + formatMoment, + parseLocalInput, + stepObservation, + timelineTicks, + toLocalInput, + zoneLabel, +} from "../../../lib/admin/movement"; + +export const chipClass = "min-h-11 rounded-sm border px-3 py-1 text-sm transition-colors disabled:opacity-40"; +export const chipOn = "border-[var(--tfmc-accent)] text-[var(--tfmc-cream)]"; +export const chipOff = + "border-[color-mix(in_srgb,var(--tfmc-cream)_20%,transparent)] text-[var(--tfmc-stone)] hover:text-[var(--tfmc-cream)]"; +export const inputClass = + "min-h-11 w-full rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_20%,transparent)] bg-transparent px-2 py-1 text-sm text-[var(--tfmc-cream)] [color-scheme:dark]"; +export const mutedClass = "text-sm text-[var(--tfmc-mist)]"; +/** + * Date and time boxes. Safari draws them at their own width, past a narrow + * box, over whatever sits beside them; without its native appearance it + * keeps to the width given. + */ +export const dateInputClass = `${inputClass} block min-w-0 appearance-none`; +const errorClass = "text-sm text-[#e8a0a0]"; + +/** How often a range that follows the clock asks again: about one ping. */ +export const FOLLOW_REFRESH_MS = 60_000; + +/** The map the site shows at /map: Main's on the public site, Dev's on the dev site, as CoreProtect is. */ +export function useLiveMapId(): string | null { + const { maps, loading, error } = useAccessibleMaps(); + if (loading) return null; + return error ? "main" : liveMapIdFrom(maps); +} + +/** The server clock, ticking once a minute while `on`. */ +export function useMinuteClock(on: boolean): number { + const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)); + useEffect(() => { + if (!on) return; + setNow(Math.floor(Date.now() / 1000)); + const timer = window.setInterval(() => setNow(Math.floor(Date.now() / 1000)), FOLLOW_REFRESH_MS); + return () => window.clearInterval(timer); + }, [on]); + return now; +} + +export type Range = { from: number; to: number; follow: boolean }; + +export type Preset = { label: string; range: (now: number) => [number, number] }; + +function startOfLocalDay(at: number): number { + const d = new Date(at * 1000); + d.setHours(0, 0, 0, 0); + return Math.floor(d.getTime() / 1000); +} + +export function rangePresets(longest: number): Preset[] { + const last = (label: string, seconds: number): Preset => ({ label, range: (now) => [now - seconds, now] }); + const presets: Preset[] = [ + last("Last 15 minutes", 15 * 60), + last("Last hour", 3600), + last("Last 6 hours", 6 * 3600), + last("Last 24 hours", 86400), + last("Last 7 days", 7 * 86400), + { label: "Today", range: (now) => [startOfLocalDay(now), now] }, + { + label: "Yesterday", + range: (now) => { + const today = startOfLocalDay(now); + return [startOfLocalDay(today - 1), today]; + }, + }, + ]; + return presets.filter((preset) => { + const [from, to] = preset.range(Math.floor(Date.now() / 1000)); + return to - from <= longest; + }); +} + +/** + * From and To, always filled in, applied together. A quick range fills both + * and applies. "Keep up to date" moves the range forward with the clock. + */ +export function RangeForm({ + value, + longest, + asOf, + onApply, +}: { + value: Range; + longest: number; + asOf: number | null; + onApply: (next: Range) => void; +}) { + const [fromText, setFromText] = useState(toLocalInput(value.from)); + const [toText, setToText] = useState(toLocalInput(value.to)); + const [seen, setSeen] = useState(value); + const [error, setError] = useState(null); + // Typed but not applied: following the clock must not overwrite it. + const [dirty, setDirty] = useState(false); + if (seen.from !== value.from || seen.to !== value.to) { + setSeen(value); + if (!dirty) { + setFromText(toLocalInput(value.from)); + setToText(toLocalInput(value.to)); + } + } + + const apply = (follow: boolean) => { + const from = parseLocalInput(fromText); + const to = parseLocalInput(toText); + const problem = from.error ? `From: ${from.error}` : to.error ? `To: ${to.error}` : null; + if (problem) return setError(problem); + if (from.time! >= to.time!) return setError("From must be before To."); + if (to.time! - from.time! > longest) return setError(`Choose a range of up to ${formatDuration(longest).toLowerCase()}.`); + setError(null); + setDirty(false); + onApply({ from: from.time!, to: to.time!, follow }); + }; + + return ( +
{ + event.preventDefault(); + apply(false); + }} + > + +
+ + +
+
+ + +
+ + {error ? ( +

+ {error} +

+ ) : null} +

+ Showing {describeSpan(value.from, value.to)} ·{" "} + {formatDuration(value.to - value.from).toLowerCase()} · times in {zoneLabel(value.from, value.to)} + {value.follow && asOf ? ` · updated ${formatClock(asOf)}` : ""} +

+
+ ); +} + +/** A stretch of observation; `row` stacks one row per player when several are compared. */ +export type TimelineBand = { from: number; to: number; colour?: string; row?: number }; + +/** + * The whole span left to right, with what is known about it: bands where a + * player was observed, hatching where nothing could have been (before the + * available observations, or rows left out), and the inspected moment. + * A gap between bands is "not observed", which is not proof of offline. + */ +export function Timeline({ + since, + until, + unknownUntil, + unknownLabel, + bands, + cursor, + onCursor, +}: { + since: number; + until: number; + /** Before this, nothing is available (hatched). */ + unknownUntil: number | null; + unknownLabel: string; + bands: TimelineBand[]; + cursor: number; + onCursor: (time: number) => void; +}) { + const span = Math.max(1, until - since); + const pct = (t: number) => `${(Math.min(Math.max(t, since), until) - since) / span * 100}%`; + const ticks = timelineTicks(since, until); + const daily = ticks.length > 1 && ticks[1] - ticks[0] >= 86400; + const multiDay = formatDay(since) !== formatDay(until); + const hatchEnd = unknownUntil !== null && unknownUntil > since ? Math.min(unknownUntil, until) : null; + const rows = Math.max(1, ...bands.map((band) => (band.row ?? 0) + 1)); + const rowHeight = rows === 1 ? 20 : Math.max(4, Math.min(10, 40 / rows)); + const barHeight = rows === 1 ? 36 : rows * (rowHeight + 2) + 8; + + return ( +
+
+ {formatMoment(since)} + {formatMoment(until)} +
+
+ {hatchEnd !== null ? ( +
+ ) : null} + {bands.map((band, i) => ( +
+ ))} + {ticks.map((t) => ( +
+ ))} + onCursor(Number(event.target.value))} + className="peer absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" + /> + {/* The input is invisible, so its keyboard focus is drawn here. */} +
+
+
+
+ {ticks.map((t) => ( + + {daily ? formatDay(t) : multiDay && formatClock(t) === "00:00" ? formatDay(t) : formatClock(t)} + + ))} +
+
+ + observed + + + not + observed (offline, or not recorded) + + {hatchEnd !== null ? ( + + + {unknownLabel} + + ) : null} +
+
+ ); +} + +/** + * The inspected moment: typed, stepped between observations, or dragged on + * the timeline; `children` says what is known about it. + */ +export function InspectBar({ + cursor, + since, + until, + times, + onCursor, + children, +}: { + cursor: number; + since: number; + until: number; + /** Observation times, oldest first. */ + times: readonly number[]; + onCursor: (time: number) => void; + children: ReactNode; +}) { + const [text, setText] = useState(toLocalInput(cursor)); + const [seen, setSeen] = useState(cursor); + const [error, setError] = useState(null); + if (seen !== cursor) { + setSeen(cursor); + setText(toLocalInput(cursor)); + } + const previous = stepObservation(times, cursor, -1); + const next = stepObservation(times, cursor, 1); + + return ( +
+ {/* The box on its own row, the steps together below it: nothing beside a date box to run into. */} + +
+ + +
+ {error ? ( +

+ {error} +

+ ) : null} +
{children}
+
+ ); +} + +/** Copies text, saying so for a moment. */ +export function CopyButton({ text, label }: { text: string; label: string }) { + const [done, setDone] = useState(false); + return ( + + ); +} + +export /** An incident location to compare against, kept in the URL. */ +function PinForm({ pin, onChange }: { pin: MapPin | null; onChange: (pin: MapPin | null) => void }) { + const [x, setX] = useState(pin ? String(pin.x) : ""); + const [z, setZ] = useState(pin ? String(pin.z) : ""); + const valid = /^-?\d+$/.test(x.trim()) && /^-?\d+$/.test(z.trim()); + return ( +
{ + event.preventDefault(); + if (valid) onChange({ x: Number(x.trim()), z: Number(z.trim()) }); + }} + > + Pin a place on the overworld map (for a report) +
+ setX(e.target.value)} /> + setZ(e.target.value)} /> + + {pin ? ( + + ) : null} +
+
+ ); +} diff --git a/frontend/app/components/admin/MovementMap.tsx b/frontend/app/components/admin/MovementMap.tsx new file mode 100644 index 00000000..48645334 --- /dev/null +++ b/frontend/app/components/admin/MovementMap.tsx @@ -0,0 +1,535 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; + +import MapViewport from "../map/MapViewport"; +import TileLayer from "../map/TileLayer"; +import { mapFallbackSize, type MapId } from "../map/types"; +import { tileUrl, useTileManifest } from "../../hooks/useTileManifest"; +import { useMapViewport } from "../../hooks/useMapViewport"; +import { mapApiUrl } from "@/lib/map/api"; +import { + ACTION_LOGIN, + ACTION_LOGOUT, + ageColour, + boundsOf, + clipStretches, + formatClock, + isJump, + positionAt, + type Sample, + type Stretch, +} from "../../../lib/admin/movement"; + +/** How many colour steps a player's trail fades through, old to new. */ +const AGE_BANDS = 16; +/** Above this many rows the per-ping dots are left out; the line still shows every ping. */ +const MAX_DOTS = 3000; +/** How close staff can zoom: screen pixels per block, far past the site map's limit. */ +const MAX_PIXELS_PER_BLOCK = 12; +/** Smallest box the camera frames, in blocks, so one spot is not zoomed in to single pixels. */ +const MIN_FRAME = 160; +/** Screen pixels between direction arrows along a path, and the most drawn at once. */ +const ARROW_GAP_PX = 90; +const MAX_ARROWS = 400; +/** The part of a trail after the inspected moment is drawn this faint. */ +const AFTER_OPACITY = 0.3; +const FOCUS_INSET = { left: 24, right: 24, top: 24, bottom: 24 }; + +export type MovementTrail = { + key: string; + label: string; + stretches: Stretch[]; + /** A fixed colour (everyone view); otherwise the line fades from old to new. */ + colour?: string; +}; + +export type MapPin = { x: number; z: number }; + +type Props = { + mapId: MapId; + /** The CoreProtect world this map shows; rows in other worlds are not drawn. */ + mapWorld: string; + trails: MovementTrail[]; + since: number; + until: number; + /** The inspected moment: a marker shows where each trail was then, and later parts are faint. */ + cursor: number; + /** How long after their last row a player still counts as there (see `inspect`). */ + hold: number; + /** A trail to draw over the others, with the rest dimmed. */ + highlight?: string | null; + /** Label where each trail starts and ends (one player's view). */ + endpoints?: boolean; + /** An incident location, independent of any player. */ + pin?: MapPin | null; + /** + * The camera frames the trails when this changes (a new session or range), + * not when the same view refreshes. Pass null until its data has loaded. + */ + fitKey: string | null; + /** A recorded dot was clicked: inspect that moment. */ + onInspect?: (time: number) => void; + className?: string; +}; + +type Band = { d: string; colour: string; after: boolean }; + +function pt(x: number, z: number): string { + return `${x + 0.5} ${z + 0.5}`; +} + +/** One path per colour band (split at the inspected moment) for walked steps, and the jumps. */ +function trailPaths(trail: MovementTrail, mapWorld: string, since: number, until: number, cursor: number) { + const span = Math.max(1, until - since); + const bands = new Map(); + const jumps = { before: [] as string[], after: [] as string[] }; + for (const stretch of trail.stretches) { + if (stretch.world !== mapWorld) continue; + const s = stretch.samples; + for (let i = 1; i < s.length; i += 1) { + const a = s[i - 1]; + const b = s[i]; + const step = `M${pt(a.x, a.z)}L${pt(b.x, b.z)}`; + const after = b.time > cursor; + if (isJump(a, b)) { + (after ? jumps.after : jumps.before).push(step); + continue; + } + const colour = + trail.colour ?? ageColour(Math.floor((((a.time + b.time) / 2 - since) / span) * AGE_BANDS) / (AGE_BANDS - 1)); + const id = `${colour}|${after}`; + const band = bands.get(id) ?? { d: "", colour, after }; + band.d += step; + bands.set(id, band); + } + } + return { walked: [...bands.values()], jumpsBefore: jumps.before.join(""), jumpsAfter: jumps.after.join("") }; +} + +/** Chevrons along walked steps, `gap` blocks apart: x, z and heading in degrees. */ +function arrows(trails: MovementTrail[], mapWorld: string, gap: number) { + const out: { x: number; z: number; angle: number }[] = []; + for (const trail of trails) { + for (const stretch of trail.stretches) { + if (stretch.world !== mapWorld) continue; + let travelled = gap / 2; + const s = stretch.samples; + for (let i = 1; i < s.length && out.length < MAX_ARROWS; i += 1) { + const a = s[i - 1]; + const b = s[i]; + if (isJump(a, b)) continue; + const length = Math.hypot(b.x - a.x, b.z - a.z); + let at = gap - travelled; + while (at <= length && out.length < MAX_ARROWS) { + const f = at / length; + out.push({ + x: a.x + (b.x - a.x) * f, + z: a.z + (b.z - a.z) * f, + angle: (Math.atan2(b.z - a.z, b.x - a.x) * 180) / Math.PI, + }); + at += gap; + } + travelled = (travelled + length) % gap; + } + } + } + return out; +} + +/** The first and last samples shown in the map's world, labelled for what they are. */ +function endpointLabels(trail: MovementTrail, mapWorld: string): { sample: Sample; text: string }[] { + const shown = trail.stretches.filter((s) => s.world === mapWorld); + if (!shown.length) return []; + const first = shown[0].samples[0]; + const lastStretch = shown[shown.length - 1].samples; + const last = lastStretch[lastStretch.length - 1]; + const start = first.estimated + ? `Shown from ${formatClock(first.time)} (estimated position)` + : `${first.action === ACTION_LOGIN ? "Logged in" : "First observation shown"} ${formatClock(first.time)}`; + const end = `${last.action === ACTION_LOGOUT ? "Logged out" : "Last observation shown"} ${formatClock(last.time)}`; + if (first === last) return [{ sample: first, text: start }]; + return [ + { sample: first, text: start }, + { sample: last, text: end }, + ]; +} + +/** 1, 2 or 5 times a power of ten, at least `wanted`. */ +function niceLength(wanted: number): number { + const power = 10 ** Math.floor(Math.log10(Math.max(wanted, 1e-6))); + const step = [1, 2, 5, 10].find((m) => m * power >= wanted) ?? 10; + return step * power; +} + +export default function MovementMap({ + mapId, + mapWorld, + trails, + since, + until, + cursor, + hold, + highlight = null, + endpoints = false, + pin = null, + fitKey, + onInspect, + className, +}: Props) { + const tiles = useTileManifest(mapId, "base", true); + const [mapSize, setMapSize] = useState({ w: mapFallbackSize(mapId), h: mapFallbackSize(mapId) }); + const manifest = tiles.manifest; + useEffect(() => { + if (manifest) setMapSize({ w: manifest.width, h: manifest.height }); + }, [manifest]); + + const viewport = useMapViewport({ + mapSize, + fitMode: "contain", + dragPan: true, + keyboard: false, + restingZoom: true, + maxDisplayScale: MAX_PIXELS_PER_BLOCK, + }); + const { displayScale, focusMapRect, resetViewport, zoomBy, consumeDragClick } = viewport; + const ready = viewport.viewportSize.w > 0; + + const tileView = useMemo( + () => ({ + displayScale: viewport.displayScale, + translateX: viewport.translateX, + translateY: viewport.translateY, + viewportW: viewport.viewportSize.w, + viewportH: viewport.viewportSize.h, + }), + [viewport.displayScale, viewport.translateX, viewport.translateY, viewport.viewportSize.w, viewport.viewportSize.h] + ); + + // Drawn and framed: only the window. Markers read the full trails, which + // carry the row before the window for where the player was as it opened. + const shown = useMemo( + () => trails.map((trail) => ({ ...trail, stretches: clipStretches(trail.stretches, since, until) })), + [trails, since, until] + ); + const paths = useMemo( + () => shown.map((trail) => ({ trail, ...trailPaths(trail, mapWorld, since, until, cursor) })), + [shown, mapWorld, since, until, cursor] + ); + const unit = displayScale > 0 ? 1 / displayScale : 1; + const chevrons = useMemo( + () => (displayScale > 0 ? arrows(shown, mapWorld, ARROW_GAP_PX / displayScale) : []), + [shown, mapWorld, displayScale] + ); + const bounds = useMemo(() => { + const boxes = shown.map((t) => boundsOf(t.stretches, mapWorld)).filter((b) => b !== null); + if (!boxes.length) return null; + const x = Math.min(...boxes.map((b) => b.x)); + const y = Math.min(...boxes.map((b) => b.y)); + const w = Math.max(...boxes.map((b) => b.x + b.w)) - x; + const h = Math.max(...boxes.map((b) => b.y + b.h)) - y; + const padW = Math.max(MIN_FRAME, w) - w; + const padH = Math.max(MIN_FRAME, h) - h; + return { x: x - padW / 2, y: y - padH / 2, w: w + padW, h: h + padH }; + }, [shown, mapWorld]); + + const fitTrail = () => (bounds ? focusMapRect(bounds, FOCUS_INSET) : resetViewport({ animated: true })); + const focusPoint = (x: number, z: number) => + focusMapRect({ x: x - MIN_FRAME / 2, y: z - MIN_FRAME / 2, w: MIN_FRAME, h: MIN_FRAME }, FOCUS_INSET); + + // Frame a new selection once, when its data is in; refreshes keep the camera. + const fittedRef = useRef(null); + useEffect(() => { + if (!ready || fitKey === null || fittedRef.current === fitKey) return; + fittedRef.current = fitKey; + if (bounds) focusMapRect(bounds, FOCUS_INSET); + else if (pin) focusPoint(pin.x, pin.z); + else resetViewport({ animated: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fitKey, ready]); + + const dots = shown.reduce((n, t) => n + t.stretches.reduce((m, s) => m + s.samples.length, 0), 0) <= MAX_DOTS; + const markers = trails.map((trail) => ({ trail, at: positionAt(trail.stretches, cursor, hold) })); + const single = trails.length === 1 ? markers[0]?.at : null; + const scaleBlocks = displayScale > 0 ? niceLength(100 / displayScale) : 0; + const halo = { stroke: "#10160f", strokeWidth: 3 * unit, paintOrder: "stroke" as const }; + /** Text beside a map point, flipped to its left when it would run off the right of the view. */ + const beside = (x: number, gap: number) => { + const screenX = (x + 0.5) * displayScale + viewport.translateX; + const flip = screenX > viewport.viewportSize.w - 220; + return { x: x + 0.5 + (flip ? -gap : gap) * unit, textAnchor: flip ? ("end" as const) : ("start" as const) }; + }; + + const dotTitle = (trail: MovementTrail, s: Sample) => + `${trail.label} — ${formatClock(s.time, true)}${ + s.action === ACTION_LOGIN ? " (logged in)" : s.action === ACTION_LOGOUT ? " (logged out)" : "" + }\n${s.x}, ${s.y}, ${s.z}${onInspect ? "\nClick to inspect this moment" : ""}`; + + const controls = [ + { label: "+", title: "Zoom in", run: () => zoomBy(1.6) }, + { label: "−", title: "Zoom out", run: () => zoomBy(1 / 1.6) }, + { label: "⤢", title: "Fit the trail", run: fitTrail }, + ...(single && single.world === mapWorld + ? [{ label: "◎", title: "Go to the inspected position", run: () => focusPoint(single.x, single.z) }] + : []), + ...(pin ? [{ label: "⌖", title: "Go to the pin", run: () => focusPoint(pin.x, pin.z) }] : []), + ]; + + return ( +
+ + {manifest ? ( + tileUrl(mapId, "base", manifest, level, x, y)} + view={tileView} + className={displayScale >= 1 ? "[image-rendering:pixelated]" : ""} + /> + ) : ( + // eslint-disable-next-line @next/next/no-img-element + Map preview + )} + + {paths.map(({ trail, walked, jumpsBefore, jumpsAfter }) => { + const dim = highlight !== null && highlight !== trail.key; + const casing = walked.map((band) => band.d).join(""); + return ( + + {/* A dark edge so the line reads over snow, sand and forest alike. */} + + {walked.map((band) => ( + + ))} + {[ + { d: jumpsBefore, opacity: 0.8 }, + { d: jumpsAfter, opacity: AFTER_OPACITY }, + ].map((jump, i) => + jump.d ? ( + + ) : null + )} + {trail.stretches.map((stretch) => + stretch.world === mapWorld + ? stretch.samples.map((s, i) => { + const edge = s.action === ACTION_LOGIN || s.action === ACTION_LOGOUT; + // A point made up at the range's edge is drawn through, never as a recorded dot. + if (s.estimated || (!edge && !dots)) return null; + const fill = + s.action === ACTION_LOGIN + ? "#7fd18b" + : s.action === ACTION_LOGOUT + ? "#e8796f" + : (trail.colour ?? ageColour((s.time - since) / Math.max(1, until - since))); + return ( + cursor ? AFTER_OPACITY : 1} + stroke={edge ? "#1b241d" : "none"} + strokeWidth={1.5 * unit} + className={onInspect ? "pointer-events-auto cursor-pointer" : "pointer-events-auto"} + onClick={ + onInspect + ? () => { + if (!consumeDragClick()) onInspect(s.time); + } + : undefined + } + > + {dotTitle(trail, s)} + + ); + }) + : null + )} + + ); + })} + {chevrons.map((c, i) => ( + + ))} + {endpoints + ? shown.flatMap((trail) => { + const labels = endpointLabels(trail, mapWorld); + // Endpoints within a few screen pixels share one label. + if ( + labels.length === 2 && + Math.hypot(labels[0].sample.x - labels[1].sample.x, labels[0].sample.z - labels[1].sample.z) < 30 * unit + ) { + labels.splice(0, 2, { sample: labels[1].sample, text: `${labels[0].text} · ${labels[1].text}` }); + } + return labels.map((label, i) => ( + + {label.text} + + )); + }) + : null} + {pin ? ( + + + + + {`Pin ${pin.x}, ${pin.z}`} + + + ) : null} + {markers.map(({ trail, at }) => { + if (!at || at.world !== mapWorld) return null; + const dim = highlight !== null && highlight !== trail.key; + const colour = trail.colour ?? "#f4c96b"; + return ( + + {/* Solid: recorded at this second. Hollow: estimated or last seen. */} + + {trails.length > 1 ? ( + + {trail.label} + + ) : null} + + ); + })} + + +
+ {controls.map((button) => ( + + ))} +
+
+ {trails.length === 1 && !trails[0].colour ? ( + + older + + newer + + ) : null} + + recorded + estimate or last seen + + + unobserved transition + logged in + logged out + +
+ {scaleBlocks ? ( +
+
+ {scaleBlocks.toLocaleString("en-GB")} {scaleBlocks === 1 ? "block" : "blocks"} +
+ ) : null} +
+ ); +} diff --git a/frontend/app/components/admin/PlayerMovementPage.test.tsx b/frontend/app/components/admin/PlayerMovementPage.test.tsx new file mode 100644 index 00000000..0e21fbce --- /dev/null +++ b/frontend/app/components/admin/PlayerMovementPage.test.tsx @@ -0,0 +1,164 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import PlayerMovementPage from "./PlayerMovementPage"; +import { AccountApiError } from "../../../lib/account/api"; +import { getPlayer, getPlayerSessions, type PlayerSession } from "../../../lib/admin/api"; +import { getPlayerMovement, getSessionMovement } from "../../../lib/admin/movement"; + +process.env.TZ = "Europe/London"; + +const nav = vi.hoisted(() => { + const listeners = new Set<() => void>(); + const state = { params: new URLSearchParams() }; + const go = (url: string) => { + state.params = new URLSearchParams(url.split("?")[1] ?? ""); + listeners.forEach((listener) => listener()); + }; + return { listeners, state, go }; +}); + +vi.mock("next/navigation", async () => { + const { useEffect, useReducer } = await import("react"); + return { + useSearchParams: () => { + const [, force] = useReducer((n: number) => n + 1, 0); + useEffect(() => { + nav.listeners.add(force); + return () => { + nav.listeners.delete(force); + }; + }, []); + return nav.state.params; + }, + usePathname: () => "/admin/players/u/movement", + }; +}); +vi.mock("../../../lib/admin/urlState", () => ({ writeUrl: (url: string) => nav.go(url) })); +vi.mock("../../../lib/admin/api", async (importOriginal) => ({ + ...(await importOriginal()), + getPlayer: vi.fn(), + getPlayerSessions: vi.fn(), +})); +vi.mock("../../../lib/admin/movement", async (importOriginal) => ({ + ...(await importOriginal()), + getPlayerMovement: vi.fn(), + getSessionMovement: vi.fn(), +})); +vi.mock("./MovementMap", () => ({ + default: ({ cursor, fitKey }: { cursor: number; fitKey: string | null }) => ( +
+ ), +})); +vi.mock("./MovementControls", async (importOriginal) => ({ + ...(await importOriginal()), + useLiveMapId: () => "main", +})); + +const UUID = "0615a817-8cb4-4aef-95f7-f6c9bf7611b8"; +// 14:00 London on Tue 6 Oct 2026. +const T = Date.UTC(2026, 9, 6, 13, 0) / 1000; +const status = { status: "available" as const, server_label: "Vardera", ping_seconds: 60, map_world: "TFMC_Map" }; +const point = (time: number, x = 0) => ({ time, world: "TFMC_Map", x, y: 64, z: 0 }); +const SESSION: PlayerSession = { + id: "s1", + start: point(T), + end: point(T + 600, 30), + end_kind: "logout", + duration_seconds: 600, + last_observed: point(T + 600, 30), +}; + +beforeEach(() => { + nav.state.params = new URLSearchParams(); + vi.mocked(getPlayer).mockResolvedValue({ minecraft_name: "MrEnzo99" } as never); + vi.mocked(getPlayerSessions).mockResolvedValue({ sessions: [SESSION], next: null, coreprotect: { status: "available" } }); + vi.mocked(getSessionMovement).mockResolvedValue({ + session: SESSION, + since: T, + until: T + 600, + worlds: ["TFMC_Map"], + points: [ + [T, 0, 0, 64, 0, 1], + [T + 60, 0, 10, 64, 0, 2], + [T + 120, 0, 20, 64, 0, 2], + [T + 600, 0, 30, 64, 0, 0], + ], + complete_from: T, + pings_since: T - 86400, + as_of: T + 3600, + coreprotect: status, + }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +it("opens the newest session and inspects where it ended", async () => { + render(); + await screen.findByText(/Inspecting 14:10:00 · recorded at 30, 64, 0/); + expect(nav.state.params.get("session")).toBe("s1"); + expect(getSessionMovement).toHaveBeenCalledWith(UUID, "s1"); + expect(screen.getAllByText("Logged out 14:10").length).toBeGreaterThan(0); + expect(screen.getByTestId("map").dataset.fit).toBe("session:s1"); + expect(screen.getByRole("button", { name: "Copy teleport command" })).toBeTruthy(); +}); + +it("steps between observations and explains estimates", async () => { + render(); + await screen.findByText(/recorded at 30, 64, 0/); + fireEvent.click(screen.getByRole("button", { name: "‹ Previous observation" })); + await screen.findByText(/Inspecting 14:02:00 · recorded at 20, 64, 0/); + act(() => nav.go(`/x?session=s1&at=${T + 90}`)); + await screen.findByText(/estimated between observations at 14:01:00 and 14:02:00: near 15, 0/); + // Between 14:02 and logout at 14:10 nothing was recorded for longer than a ping gap. + act(() => nav.go(`/x?session=s1&at=${T + 400}`)); + await screen.findByText(/not observed \(offline, or not recorded\); last observation 14:02:00/); +}); + +it("applies a typed range and refuses times the clocks skip", async () => { + vi.mocked(getPlayerMovement).mockResolvedValue({ + since: T - 3600, until: T, worlds: [], points: [], complete_from: T - 3600, pings_since: null, as_of: T, + coreprotect: status, + }); + render(); + await screen.findByText(/recorded at 30/); + fireEvent.click(screen.getByRole("tab", { name: "Time range" })); + fireEvent.change(screen.getByLabelText("From"), { target: { value: "2026-03-29T01:30" } }); + fireEvent.click(screen.getByRole("button", { name: "Apply range" })); + expect(screen.getByRole("alert").textContent).toMatch(/clocks went forward/); + + fireEvent.change(screen.getByLabelText("From"), { target: { value: "2026-10-06T13:00" } }); + fireEvent.change(screen.getByLabelText("To"), { target: { value: "2026-10-06T14:00" } }); + fireEvent.click(screen.getByRole("button", { name: "Apply range" })); + await waitFor(() => expect(getPlayerMovement).toHaveBeenCalledWith(UUID, T - 3600, T)); + expect(nav.state.params.get("session")).toBeNull(); + await screen.findByText(/No observations between Tue 6 Oct 2026, 13:00 → 14:00/); +}); + +it("tells non-admins the page is not for them", async () => { + vi.mocked(getSessionMovement).mockRejectedValue(new AccountApiError("forbidden", 403)); + render(); + await screen.findByText("Movement is for admins and the owner only."); +}); + +it("frames a new session only once its own answer is in", async () => { + render(); + await screen.findByText(/recorded at 30, 64, 0/); + expect(screen.getByTestId("map").dataset.fit).toBe("session:s1"); + let answer: (value: unknown) => void = () => undefined; + vi.mocked(getSessionMovement).mockReturnValueOnce(new Promise((resolve) => (answer = resolve)) as never); + act(() => nav.go("/x?session=s2")); + // Still showing s1's answer: the map must not take s2's key against s1's trail. + expect(screen.getByTestId("map").dataset.fit).toBe(""); + await act(async () => + answer({ + session: { ...SESSION, id: "s2" }, since: T, until: T + 600, worlds: ["TFMC_Map"], + points: [[T, 0, 500, 64, 0, 1]], complete_from: T, pings_since: null, as_of: T, coreprotect: status, + }) + ); + await waitFor(() => expect(screen.getByTestId("map").dataset.fit).toBe("session:s2")); +}); diff --git a/frontend/app/components/admin/PlayerMovementPage.tsx b/frontend/app/components/admin/PlayerMovementPage.tsx new file mode 100644 index 00000000..8e7c9e6b --- /dev/null +++ b/frontend/app/components/admin/PlayerMovementPage.tsx @@ -0,0 +1,518 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { AccountApiError } from "../../../lib/account/api"; +import { adminErrorMessage, coreProtectMessage, getPlayer, type PlayerSession } from "../../../lib/admin/api"; +import { + ACTION_PING, + PLAYER_WINDOW_SECONDS, + clipStretches, + describeSpan, + formatClock, + formatMoment, + gapSeconds, + getPlayerMovement, + getSessionMovement, + inspect, + observationTimes, + observedBands, + sessionEndLabel, + stretches, + timeByWorld, + worldLabel, + type MovementPoint, + type MovementStatus, +} from "../../../lib/admin/movement"; +import { formatDuration } from "../../../lib/admin/time"; +import { writeUrl } from "../../../lib/admin/urlState"; +import MovementMap, { type MapPin } from "./MovementMap"; +import { + CopyButton, + InspectBar, + PinForm, + RangeForm, + Timeline, + chipClass, + chipOff, + chipOn, + mutedClass, + useLiveMapId, + useMinuteClock, + type Range, +} from "./MovementControls"; +import SessionList from "./SessionList"; +import { StaffGateMessage, gateKind, type GateKind } from "./StaffGate"; + +/** What either kind of answer comes down to for drawing. */ +type View = { + key: string; + since: number; + until: number; + completeFrom: number; + points: MovementPoint[]; + worlds: (string | null)[]; + pingsSince: number | null; + asOf: number | null; + coreprotect: MovementStatus; + session: PlayerSession | null; +}; + +type Load = + | { kind: "idle" } + | { kind: "loading"; previous: View | null } + | { kind: "ready"; view: View } + | { kind: "failed"; message: string; previous: View | null } + | { kind: "gate"; gate: GateKind }; + +function spanOrLastHour(since: number, until: number): Range { + if (since && until > since && until - since <= PLAYER_WINDOW_SECONDS) return { from: since, to: until, follow: false }; + const now = Math.floor(Date.now() / 1000); + return { from: now - 3600, to: now, follow: false }; +} + +function intParam(value: string | null): number | null { + if (value === null || !/^-?\d+$/.test(value)) return null; + return Number(value); +} + +function pinParam(value: string | null): MapPin | null { + const match = value?.match(/^(-?\d+),(-?\d+)$/); + return match ? { x: Number(match[1]), z: Number(match[2]) } : null; +} + +/** + * One player's movement, session by session or over a chosen range, with the + * route on the live map, a timeline, and an inspected moment. Everything that + * picks the view lives in the URL, so a view can be shared as a link. + */ +export default function PlayerMovementPage({ uuid }: { uuid: string }) { + const pathname = usePathname(); + const search = useSearchParams(); + const mapId = useLiveMapId(); + + const sessionId = search.get("session"); + const from = intParam(search.get("from")); + const to = intParam(search.get("to")); + const follow = search.get("follow") === "1"; + const at = intParam(search.get("at")); + const pin = pinParam(search.get("pin")); + // A session in the URL wins over a range. + const mode: "session" | "range" = !sessionId && from !== null && to !== null ? "range" : "session"; + const [tab, setTab] = useState<"session" | "range">(mode); + const [collapsed, setCollapsed] = useState(false); + const [followSession, setFollowSession] = useState(false); + // Following is for one open session: a new selection starts without it. + const [followedSession, setFollowedSession] = useState(sessionId); + if (followedSession !== sessionId) { + setFollowedSession(sessionId); + setFollowSession(false); + } + + const update = useCallback( + (changes: Record, replace = false) => { + const next = new URLSearchParams(search.toString()); + for (const [key, value] of Object.entries(changes)) { + if (value === null) next.delete(key); + else next.set(key, value); + } + writeUrl(`${pathname}?${next.toString()}`, replace); + }, + [pathname, search] + ); + + const [name, setName] = useState(null); + const [gate, setGate] = useState(null); + useEffect(() => { + let live = true; + getPlayer(uuid) + .then((profile) => live && setName(profile.minecraft_name ?? "Unknown name")) + .catch((err) => { + if (!live) return; + const status = err instanceof AccountApiError ? err.status : 0; + if (status === 400 || status === 404) setName("Unknown player"); + else setGate(gateKind(err)); + }); + return () => { + live = false; + }; + }, [uuid]); + + // The clock only ticks while something follows it. + const now = useMinuteClock((mode === "range" && follow) || (mode === "session" && followSession)); + const range: Range | null = + mode === "range" && from !== null && to !== null + ? follow + ? { from: now - (to - from), to: now, follow: true } + : { from, to, follow: false } + : null; + // What the camera frames: a new session or range, not a refresh of the same one. + const viewKey = mode === "session" ? `session:${sessionId}` : follow ? `follow:${(to ?? 0) - (from ?? 0)}` : `range:${from}:${to}`; + const fetchKey = + mode === "session" + ? sessionId + ? `${viewKey}:${followSession ? now : ""}` + : null + : range + ? `${viewKey}:${range.from}:${range.to}` + : null; + + const [load, setLoad] = useState({ kind: "idle" }); + const [retry, setRetry] = useState(0); + useEffect(() => { + if (fetchKey === null) return; + let live = true; + setLoad((prev) => ({ kind: "loading", previous: prev.kind === "ready" ? prev.view : prev.kind === "loading" || prev.kind === "failed" ? prev.previous : null })); + const request = + mode === "session" + ? getSessionMovement(uuid, sessionId!).then((data): View => ({ + key: viewKey, + since: data.since ?? data.session?.start.time ?? 0, + until: data.until ?? data.as_of, + completeFrom: data.complete_from ?? data.since ?? 0, + points: data.points, + worlds: data.worlds, + pingsSince: data.pings_since, + asOf: data.as_of, + coreprotect: data.coreprotect, + session: data.session, + })) + : getPlayerMovement(uuid, range!.from, range!.to).then((data): View => ({ + key: viewKey, + since: data.since, + until: data.until, + completeFrom: data.complete_from, + points: data.points, + worlds: data.worlds, + pingsSince: data.pings_since, + asOf: data.as_of ?? null, + coreprotect: data.coreprotect, + session: null, + })); + request + .then((view) => { + if (!live) return; + setLoad({ kind: "ready", view }); + // A logout (or a stale session) ends following; nothing more will come. + if (view.session && view.session.end_kind !== "open") setFollowSession(false); + }) + .catch((err) => { + if (!live) return; + const status = err instanceof AccountApiError ? err.status : 0; + if (status === 401 || status === 403) return setLoad({ kind: "gate", gate: gateKind(err) }); + setLoad((prev) => ({ + kind: "failed", + message: adminErrorMessage(err), + previous: prev.kind === "loading" ? prev.previous : null, + })); + }); + return () => { + live = false; + }; + // fetchKey captures everything the request depends on. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchKey, uuid, retry]); + + const view = load.kind === "ready" ? load.view : load.kind === "loading" || load.kind === "failed" ? load.previous : null; + const stale = view !== null && load.kind !== "ready"; + const parts = useMemo( + () => (view ? stretches(view.points, view.worlds, view.coreprotect.ping_seconds) : []), + [view] + ); + const trails = useMemo(() => [{ key: uuid, label: name ?? "Player", stretches: parts }], [uuid, name, parts]); + const times = useMemo( + () => observationTimes(parts, view?.completeFrom ?? view?.since ?? 0, view?.until ?? 0), + [parts, view] + ); + + if (gate) return ; + if (load.kind === "gate") { + return load.gate === "forbidden" ? ( +

Movement is for admins and the owner only.

+ ) : ( + + ); + } + + const mapWorld = view?.coreprotect.map_world ?? "TFMC_Map"; + const hold = gapSeconds(view?.coreprotect.ping_seconds); + const since = view?.since ?? range?.from ?? 0; + const until = view?.until ?? range?.to ?? 0; + // Default to the last observation, so a session opens on where it ended. + const cursor = Math.min(until, Math.max(since, at ?? times[times.length - 1] ?? until)); + const found = inspect(parts, cursor, hold); + const inWindow = clipStretches(parts, view?.completeFrom ?? since, until); + const byWorld = [...timeByWorld(inWindow)].filter(([, seconds]) => seconds > 0); + const observed = byWorld.reduce((sum, [, seconds]) => sum + seconds, 0); + const notice = view ? coreProtectMessage(view.coreprotect) : null; + const unknownUntil = view + ? Math.max(view.completeFrom, view.pingsSince !== null && view.pingsSince > since ? view.pingsSince : since) + : null; + const onlyEdges = view !== null && view.points.length > 0 && !view.points.some((p) => p[5] === ACTION_PING); + + const selectSession = (session: PlayerSession, replace: boolean) => + update({ session: session.id, from: null, to: null, follow: null, at: null }, replace); + const applyRange = (next: Range) => + update({ session: null, from: String(next.from), to: String(next.to), follow: next.follow ? "1" : null, at: null }); + const setCursor = (time: number) => update({ at: String(Math.round(time)) }, true); + const shareUrl = () => { + const params = new URLSearchParams(search.toString()); + if (range) { + params.set("from", String(range.from)); + params.set("to", String(range.to)); + params.delete("follow"); + } + params.set("at", String(cursor)); + return `${window.location.origin}${pathname}?${params.toString()}`; + }; + + return ( +
+
+
+

+ {name ?? "…"} · movement +

+

+ {view?.coreprotect.server_label ? `${view.coreprotect.server_label} · ` : ""} + Positions are recorded once a minute; lines between them are estimates. Admins only; each view is logged. +

+
+
+ + {view ? : null} +
+
+ +
+ {!collapsed ? ( + + ) : null} + +
+ + {notice ?

{notice}

: null} + {load.kind === "failed" ? ( +
+

+ {load.message} + {load.previous ? " Showing the previous results." : ""} +

+ +
+ ) : null} + {load.kind === "loading" ? ( +

{load.previous ? "Loading… (showing the previous results)" : "Loading…"}

+ ) : null} +
+ {mapId ? ( + + ) : ( +
+ )} +
+ {view ? ( + <> + since ? "earlier observations omitted" : "before available position observations" + } + bands={observedBands(inWindow).map(([a, b]) => ({ from: a, to: b }))} + cursor={cursor} + onCursor={setCursor} + /> + + + +
+ {!view.points.length ? ( +

+ No observations between {describeSpan(since, until)}.{" "} + {mode === "range" ? ( + + ) : null} +

+ ) : ( +

+ Estimated observed time in the span shown: {formatDuration(observed).toLowerCase()} + {byWorld.length > 1 + ? ` (${byWorld.map(([world, s]) => `${formatDuration(s).toLowerCase()} in ${worldLabel(world)}`).join(", ")})` + : byWorld.length === 1 && byWorld[0][0] !== mapWorld + ? `, all in ${worldLabel(byWorld[0][0])}` + : ""} + . +

+ )} + {onlyEdges ?

Login and logout locations only; the route is unavailable for this span.

: null} + {view.completeFrom > since ? ( +

Earlier observations omitted: too many to show, so the route starts at {formatMoment(view.completeFrom)}.

+ ) : null} + {view.pingsSince !== null && view.pingsSince > since ? ( +

+ Before {formatMoment(view.pingsSince)} there are no available position observations, only login and + logout places. +

+ ) : null} +
+ + ) : null} +
+
+ + ← Back to the profile + +
+ ); +} + +function Heading({ + view, + mode, + followSession, + onFollowSession, +}: { + view: View | null; + mode: "session" | "range"; + followSession: boolean; + onFollowSession: (on: boolean) => void; +}) { + if (!view) return null; + if (mode === "session" && view.session) { + const s = view.session; + return ( +
+

+ Session {describeSpan(s.start.time, view.until)} + {s.duration_seconds !== null ? ` · ${formatDuration(s.duration_seconds).toLowerCase()}` : ""} ·{" "} + {sessionEndLabel(s)} +

+ {s.end_kind === "open" ? ( + + ) : null} +
+ ); + } + return

{describeSpan(view.since, view.until)}

; +} + +function Readout({ + found, + cursor, + mapWorld, +}: { + found: ReturnType; + cursor: number; + mapWorld: string; +}) { + const when = `Inspecting ${formatClock(cursor, true)}`; + const where = (world: string | null) => (world === mapWorld ? "" : ` in ${worldLabel(world)}`); + switch (found.kind) { + case "observed": { + const s = found.sample; + return ( +
+ + {when} · recorded at {s.x}, {s.y}, {s.z} + {where(found.world)} + + {found.world === mapWorld ? : null} +
+ ); + } + case "estimated": + return ( + + {when} · estimated between observations at {formatClock(found.before.time, true)} and{" "} + {formatClock(found.after.time, true)}: near {Math.round(found.x)}, {Math.round(found.z)} + {where(found.world)} + + ); + case "unobserved": + return ( + + {when} · position unknown: an unobserved transition from {found.before.x}, {found.before.z} ( + {formatClock(found.before.time, true)}) to {found.after.x}, {found.after.z} ({formatClock(found.after.time, true)}) + {where(found.world)} + + ); + case "stale": + return ( + + {when} · position unknown; last observed {formatDuration(cursor - found.before.time).toLowerCase()} earlier at{" "} + {found.before.x}, {found.before.z} + {where(found.world)} + + ); + default: + return ( + + {when} · not observed (offline, or not recorded) + {found.before ? `; last observation ${formatClock(found.before.time, true)}` : ""}. + + ); + } +} diff --git a/frontend/app/components/admin/PlayerProfile.test.tsx b/frontend/app/components/admin/PlayerProfile.test.tsx index 6934ce2a..433d55cd 100644 --- a/frontend/app/components/admin/PlayerProfile.test.tsx +++ b/frontend/app/components/admin/PlayerProfile.test.tsx @@ -18,6 +18,9 @@ vi.mock("../../../lib/admin/api", async (importOriginal) => ({ getPlayerActivity: vi.fn(), })); +// The movement card asks who is signed in; it has its own tests. +vi.mock("./MovementCard", () => ({ default: () => null })); + const UUID = "0615a817-8cb4-4aef-95f7-f6c9bf7611b8"; const NOW = 2_000_000_000; const KINDS = ["block", "click", "kill", "spawn", "container", "item", "entity", "sign", "skill", "command", "session"]; @@ -30,7 +33,8 @@ const PROFILE: Profile = { last_seen: NOW - 3600, online: false, discord: { - discord_user_id: "422545450919526411", discord_username: "hazelstone", linked_at: "2026-09-20T10:00:00Z", + discord_user_id: "422545450919526411", discord_username: "hazelstone", discord_nickname: "Hazel | Enzo", + linked_at: "2026-09-20T10:00:00Z", left_guild_at: null, grace_until: null, }, account: null, @@ -52,9 +56,11 @@ beforeEach(() => { vi.mocked(getPlayerSessions).mockResolvedValue({ sessions: [ { start: { time: NOW - 7200, world: "TFMC_Map", x: 0, y: 64, z: 0 }, - end: { time: NOW - 3600, world: "TFMC_Map", x: 5, y: 64, z: 5 }, end_kind: "logout", duration_seconds: 3600 }, + end: { time: NOW - 3600, world: "TFMC_Map", x: 5, y: 64, z: 5 }, end_kind: "logout", duration_seconds: 3600, + id: "s1", last_observed: { time: NOW - 3600, world: "TFMC_Map", x: 5, y: 64, z: 5 } }, { start: { time: NOW - 90000, world: "TFMC_Map", x: 0, y: 64, z: 0 }, - end: { time: NOW - 88200, world: "TFMC_Map", x: 1, y: 64, z: 1 }, end_kind: "last_observed", duration_seconds: 1800 }, + end: { time: NOW - 88200, world: "TFMC_Map", x: 1, y: 64, z: 1 }, end_kind: "last_observed", duration_seconds: 1800, + id: "s2", last_observed: { time: NOW - 88200, world: "TFMC_Map", x: 1, y: 64, z: 1 } }, ], next: null, first_seen: NOW - 864000, history_start: NOW - 864000, coreprotect: { status: "available" }, }); @@ -190,7 +196,8 @@ it("retries a failed sessions page from the same cursor", async () => { vi.mocked(getPlayerSessions) .mockResolvedValueOnce({ sessions: [{ start: { time: NOW - 7200, world: "TFMC_Map", x: 0, y: 64, z: 0 }, end: null, end_kind: "unknown", - duration_seconds: null }], + duration_seconds: null, id: "s3", + last_observed: { time: NOW - 7200, world: "TFMC_Map", x: 0, y: 64, z: 0 } }], next: "s-1", history_start: NOW - 864000, coreprotect: { status: "available" }, }) .mockRejectedValueOnce(new AccountApiError("bad_cursor", 400)) @@ -235,3 +242,10 @@ it("never offers Chat before the server allows it", async () => { expect(within(feed).getByRole("button", { name: "Kills" })).toBeTruthy(); expect(within(feed).queryByRole("button", { name: "Chat" })).toBeNull(); }); + +it("shows the Discord handle and the server nickname", async () => { + render(); + await screen.findByText("@hazelstone"); + expect(screen.getByText("Server nickname")).toBeTruthy(); + expect(screen.getByText("Hazel | Enzo")).toBeTruthy(); +}); diff --git a/frontend/app/components/admin/PlayerProfile.tsx b/frontend/app/components/admin/PlayerProfile.tsx index 1d1279ee..b8236cbe 100644 --- a/frontend/app/components/admin/PlayerProfile.tsx +++ b/frontend/app/components/admin/PlayerProfile.tsx @@ -18,6 +18,7 @@ import { import { formatLocal } from "../../../lib/skins/formatTime"; import { formatAgo, formatDuration, formatEpoch } from "../../../lib/admin/time"; import { StaffGateMessage, gateKind, type GateKind } from "./StaffGate"; +import MovementCard from "./MovementCard"; const panelClass = "mt-6 rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_18%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest)_28%,transparent)] p-5"; @@ -99,13 +100,25 @@ export default function PlayerProfile({ uuid }: { uuid: string }) { ) : null} + +

Discord and website

{profile.discord ? (
Discord
- {profile.discord.discord_username ? `@${profile.discord.discord_username}` : profile.discord.discord_user_id} + {profile.discord.discord_username ? ( + `@${profile.discord.discord_username}` + ) : ( + + Handle not known yet (filled in when they sign in to the website) · ID {profile.discord.discord_user_id} + + )} +
+
Server nickname
+
+ {profile.discord.discord_nickname ?? None known}
Linked
{formatLocal(profile.discord.linked_at)}
diff --git a/frontend/app/components/admin/PlayersDirectory.test.tsx b/frontend/app/components/admin/PlayersDirectory.test.tsx index b890971e..a9c012bc 100644 --- a/frontend/app/components/admin/PlayersDirectory.test.tsx +++ b/frontend/app/components/admin/PlayersDirectory.test.tsx @@ -14,7 +14,7 @@ const NOW = 2_000_000_000; function player(uuid: string, name: string | null, extra: Partial = {}): PlayerSummary { return { - uuid, minecraft_name: name, discord_user_id: null, discord_username: null, site_role: null, + uuid, minecraft_name: name, discord_user_id: null, discord_username: null, discord_nickname: null, site_role: null, characters: [], last_seen: null, online: false, ...extra, }; } @@ -40,8 +40,11 @@ afterEach(() => { it("lists players with Discord, characters and last seen", async () => { vi.mocked(getPlayers).mockResolvedValue(directory([ player("0615a817-8cb4-4aef-95f7-f6c9bf7611b8", "MrEnzo99", { - discord_username: "hazelstone", characters: ["Hazel Stonebrook"], last_seen: NOW - 7200, site_role: "mod", + discord_user_id: "422545450919526411", discord_username: "hazelstone", discord_nickname: "Enzo", + characters: ["Hazel Stonebrook"], last_seen: NOW - 7200, site_role: "mod", }), + // Linked before handles were stored: only the server nickname is known. + player("44444444-4444-4444-4444-444444444444", "Justin", { discord_user_id: "5", discord_nickname: "Justin" }), player("33333333-3333-3333-3333-333333333333", "Quiet", { online: true, last_seen: NOW }), player("22222222-2222-2222-2222-222222222222", null), ])); @@ -50,13 +53,19 @@ it("lists players with Discord, characters and last seen", async () => { const link = await screen.findByRole("link", { name: "MrEnzo99" }); expect(link.getAttribute("href")).toBe("/admin/players/0615a817-8cb4-4aef-95f7-f6c9bf7611b8"); const row = link.closest("tr")!; - expect(within(row).getAllByText("@hazelstone")).toHaveLength(2); - expect(within(row).getByText("Hazel Stonebrook")).toBeTruthy(); + // The handle and the server nickname, in the Discord column and in one line for narrow screens. + expect(within(row).getByText("@hazelstone")).toBeTruthy(); + expect(within(row).getByText("Enzo")).toBeTruthy(); + expect(within(row).getByText("@hazelstone · Enzo")).toBeTruthy(); + const nicknameOnly = screen.getByRole("link", { name: "Justin" }).closest("tr")!; + expect(within(nicknameOnly).queryByText("Not linked")).toBeNull(); + // In its column, and again under the name for narrow screens. + expect(within(row).getAllByText("Hazel Stonebrook").length).toBeGreaterThan(0); expect(within(row).getByText("2 h ago")).toBeTruthy(); expect(within(row).getByText("Moderator")).toBeTruthy(); expect(screen.getByText("Seen just now")).toBeTruthy(); expect(screen.getByRole("link", { name: "22222222-2222-2222-2222-222222222222" })).toBeTruthy(); - expect(screen.getByText(/3 players · activity from Vardera/)).toBeTruthy(); + expect(screen.getByText(/4 players · activity from Vardera/)).toBeTruthy(); }); it("searches after typing stops and sorts by Discord name", async () => { @@ -69,6 +78,12 @@ it("searches after typing stops and sorts by Discord name", async () => { fireEvent.click(screen.getByRole("button", { name: "Discord name" })); await waitFor(() => expect(getPlayers).toHaveBeenLastCalledWith({ q: "hazel", sort: "discord", page: 1 })); expect(window.location.search).toBe("?q=hazel&sort=discord"); + + fireEvent.click(screen.getByRole("button", { name: "Character name" })); + await waitFor(() => expect(getPlayers).toHaveBeenLastCalledWith({ q: "hazel", sort: "character", page: 1 })); + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + await waitFor(() => expect(getPlayers).toHaveBeenLastCalledWith({ q: "", sort: "character", page: 1 })); + expect(screen.queryByRole("button", { name: "Clear search" })).toBeNull(); }); it("pages through results", async () => { diff --git a/frontend/app/components/admin/PlayersDirectory.tsx b/frontend/app/components/admin/PlayersDirectory.tsx index a86097bf..f6b9c136 100644 --- a/frontend/app/components/admin/PlayersDirectory.tsx +++ b/frontend/app/components/admin/PlayersDirectory.tsx @@ -19,16 +19,24 @@ const SORTS: { key: PlayerSort; label: string }[] = [ { key: "last_seen", label: "Last seen" }, { key: "minecraft", label: "Minecraft name" }, { key: "discord", label: "Discord name" }, + { key: "character", label: "Character name" }, ]; const SEARCH_DELAY_MS = 250; const inputClass = - "rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_25%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest)_40%,transparent)] px-3 py-2 text-[var(--tfmc-cream)] outline-none placeholder:text-[color-mix(in_srgb,var(--tfmc-mist)_60%,transparent)] focus:border-[var(--tfmc-accent)]"; + "w-full appearance-none rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_25%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest)_40%,transparent)] py-3 pl-10 pr-11 text-base text-[var(--tfmc-cream)] outline-none placeholder:text-[color-mix(in_srgb,var(--tfmc-mist)_70%,transparent)] focus:border-[var(--tfmc-accent)] [&::-webkit-search-cancel-button]:hidden"; const pagerClass = "rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_20%,transparent)] px-3 py-1.5 text-sm text-[var(--tfmc-cream)] hover:border-[var(--tfmc-accent)] disabled:opacity-40"; type Props = { initialQuery: string; initialSort: PlayerSort; initialPage: number }; +/** `@handle · Server nickname`, whichever of the two is known. */ +function discordLabel(player: { discord_username: string | null; discord_nickname: string | null }): string { + return [player.discord_username ? `@${player.discord_username}` : null, player.discord_nickname] + .filter(Boolean) + .join(" · ") || "Linked"; +} + type Load = | { kind: "loading" } | { kind: GateKind } @@ -79,10 +87,22 @@ export default function PlayersDirectory({ initialQuery, initialSort, initialPag return (
-
- + {/* Search gets a row of its own, so the sort buttons never squeeze it. */} + +
+ + + + -
+ {query ? ( + + ) : null} +
+
+ Sort by +
{SORTS.map((option) => ( + + ); + })} + + {!state.busy && !state.sessions.length && !state.error ? ( +

No sessions recorded.

+ ) : null} + {state.busy ?

Loading sessions…

: null} + {state.next && !state.busy ? ( + + ) : null} +
+ ); +} diff --git a/frontend/app/hooks/useMapViewport.ts b/frontend/app/hooks/useMapViewport.ts index 6a46b4a9..21787e64 100644 --- a/frontend/app/hooks/useMapViewport.ts +++ b/frontend/app/hooks/useMapViewport.ts @@ -13,6 +13,7 @@ import { computeCenteredTransform, computeDisplayScale, computeFitScale, + MAP_ZOOM_MAX, screenToMap, transformForMapRect, viewportTransformStyle, @@ -70,6 +71,12 @@ export type UseMapViewportOptions = { * Zoomed instead, the content is laid out at its size on screen. */ restingZoom?: boolean; + /** + * Allow zooming in until the map draws this many screen pixels per map + * pixel, past the site map's usual limit (`MAP_ZOOM_MAX` times the fit). + * For close-up maps such as staff movement, where single blocks matter. + */ + maxDisplayScale?: number; }; export type MapFocusInset = { @@ -178,6 +185,7 @@ export function useMapViewport({ keyboard = false, onLiveTransform, restingZoom = false, + maxDisplayScale, }: UseMapViewportOptions): UseMapViewportResult { const viewportRef = useRef(null); const contentRef = useRef(null); @@ -207,6 +215,15 @@ export function useMapViewport({ mapSizeRef.current = mapSize; const fitModeRef = useRef(fitMode); fitModeRef.current = fitMode; + const maxDisplayScaleRef = useRef(maxDisplayScale); + maxDisplayScaleRef.current = maxDisplayScale; + /** The deepest user scale allowed at the current fit (see `maxDisplayScale`). */ + const userScaleCap = useCallback((): number => { + const wanted = maxDisplayScaleRef.current; + if (!wanted) return MAP_ZOOM_MAX; + const fit = computeFitScale(viewportSizeRef.current, mapSizeRef.current, fitModeRef.current); + return fit > 0 ? Math.max(MAP_ZOOM_MAX, wanted / fit) : MAP_ZOOM_MAX; + }, []); const dragPanRef = useRef(dragPan); dragPanRef.current = dragPan; @@ -307,7 +324,7 @@ export function useMapViewport({ const viewport = viewportSizeRef.current; const map = mapSizeRef.current; const nextFitScale = computeFitScale(viewport, map, fitModeRef.current); - const clampedUserScale = clampUserScale(next.userScale); + const clampedUserScale = clampUserScale(next.userScale, userScaleCap()); const nextDisplayScale = computeDisplayScale(nextFitScale, clampedUserScale); const clamped = clampTranslate( viewport, @@ -360,7 +377,8 @@ export function useMapViewport({ current, { x: viewport.w / 2, y: viewport.h / 2 }, current.userScale * factor, - fitModeRef.current + fitModeRef.current, + userScaleCap() ), VIEWPORT_RESET_TRANSITION, VIEWPORT_RESET_TRANSITION_MS @@ -513,7 +531,8 @@ export function useMapViewport({ transformRef.current, cursor, event.deltaY, - fitModeRef.current + fitModeRef.current, + userScaleCap() ) ) ); @@ -567,7 +586,8 @@ export function useMapViewport({ gesture.startTransform, gesture.start.midpoint, pinchUserScale(gesture.startTransform.userScale, gesture.start, sample), - fitModeRef.current + fitModeRef.current, + userScaleCap() ); presentLive( applyClampedTransform({ @@ -661,7 +681,8 @@ export function useMapViewport({ current, toViewportPoint(event), current.userScale * MAP_ZOOM_STEP, - fitModeRef.current + fitModeRef.current, + userScaleCap() ), VIEWPORT_RESET_TRANSITION, VIEWPORT_RESET_TRANSITION_MS diff --git a/frontend/app/lib/mapViewportMath.test.ts b/frontend/app/lib/mapViewportMath.test.ts index 81d69ec4..b4d97cb1 100644 --- a/frontend/app/lib/mapViewportMath.test.ts +++ b/frontend/app/lib/mapViewportMath.test.ts @@ -216,6 +216,13 @@ describe("zoomToScaleAtPoint", () => { expect(zoomToScaleAtPoint(viewport, map, start, centre, 0.1).userScale).toBe(1); }); + it("zooms deeper when a viewport allows it", () => { + const start = { userScale: 1, translateX: 0, translateY: 0 }; + const centre = { x: 500, y: 500 }; + expect(zoomToScaleAtPoint(viewport, map, start, centre, 99, "cover", 120).userScale).toBe(99); + expect(zoomToScaleAtPoint(viewport, map, start, centre, 500, "cover", 120).userScale).toBe(120); + }); + it("ignores a non-finite scale", () => { const start = { userScale: 2, translateX: -100, translateY: -50 }; diff --git a/frontend/app/lib/mapViewportMath.ts b/frontend/app/lib/mapViewportMath.ts index 43f998ce..8b7ebb0e 100644 --- a/frontend/app/lib/mapViewportMath.ts +++ b/frontend/app/lib/mapViewportMath.ts @@ -54,8 +54,9 @@ export function computeDisplayScale(fitScale: number, userScale: number): number return fitScale * userScale; } -export function clampUserScale(scale: number): number { - return Math.min(MAP_ZOOM_MAX, Math.max(MAP_ZOOM_MIN, scale)); +/** `max` lets one viewport zoom deeper than the site map (see `useMapViewport`'s `maxDisplayScale`). */ +export function clampUserScale(scale: number, max: number = MAP_ZOOM_MAX): number { + return Math.min(max, Math.max(MAP_ZOOM_MIN, scale)); } /** @@ -161,7 +162,8 @@ export function zoomAtPoint( transform: ViewportTransform, cursor: ViewportPoint, wheelDelta: number, - mode: FitMode = "cover" + mode: FitMode = "cover", + maxUserScale: number = MAP_ZOOM_MAX ): ViewportTransform { // Horizontal-only wheel events (sideways trackpad swipe, Shift+wheel on // Windows) arrive with deltaY === 0 and carry no zoom direction. @@ -177,7 +179,8 @@ export function zoomAtPoint( transform, cursor, transform.userScale * zoomFactor, - mode + mode, + maxUserScale ); } @@ -192,7 +195,8 @@ export function zoomToScaleAtPoint( transform: ViewportTransform, anchor: ViewportPoint, userScale: number, - mode: FitMode = "cover" + mode: FitMode = "cover", + maxUserScale: number = MAP_ZOOM_MAX ): ViewportTransform { if (!Number.isFinite(userScale)) return transform; @@ -201,7 +205,7 @@ export function zoomToScaleAtPoint( const translate = { x: transform.translateX, y: transform.translateY }; const mapPoint = screenToMap(anchor.x, anchor.y, displayScale, translate); - const nextUserScale = clampUserScale(userScale); + const nextUserScale = clampUserScale(userScale, maxUserScale); const nextDisplayScale = computeDisplayScale(fitScale, nextUserScale); const clamped = clampTranslate( viewport, diff --git a/frontend/lib/admin/api.ts b/frontend/lib/admin/api.ts index 38d72467..2a94a2a6 100644 --- a/frontend/lib/admin/api.ts +++ b/frontend/lib/admin/api.ts @@ -57,12 +57,15 @@ const ERROR_MESSAGES: Record = { bad_origin: "That request was blocked. Reload the page and try again.", player_not_found: "No player with that UUID has been seen.", bad_uuid: "That isn’t a Minecraft UUID.", + bad_session: "That session link isn’t valid.", + session_gone: "That session is no longer in CoreProtect’s records.", + bad_window: "Choose a time range of up to 7 days for one player, or 24 hours for everyone.", bad_cursor: "That page link has expired. Reload to start again.", bad_kinds: "That filter isn’t available.", bad_sort: "That sort order isn’t available.", query_too_long: "Search for 64 characters or fewer.", directory_busy: "The player list is busy. Try again in a moment.", - audit_unavailable: "Chat and commands can’t be shown because the view couldn’t be logged. Try again.", + audit_unavailable: "This can’t be shown because the view couldn’t be logged. Try again.", }; export function adminErrorMessage(err: unknown): string { @@ -70,7 +73,7 @@ export function adminErrorMessage(err: unknown): string { return "Something went wrong. Please try again."; } -async function adminRequest(path: string, init?: RequestInit): Promise { +export async function adminRequest(path: string, init?: RequestInit): Promise { const res = await fetch(`${getApiBase()}${path}`, { ...init, credentials: "include", @@ -120,17 +123,20 @@ export function revokeSessions(userId: number, reason: string): Promise export type CoreProtectStatus = { status: "available"; reason?: undefined } | { status: "unavailable"; reason: string }; -export type PlayerSort = "last_seen" | "minecraft" | "discord"; +export type PlayerSort = "last_seen" | "minecraft" | "discord" | "character"; export function parsePlayerSort(value: string | undefined): PlayerSort { - return value === "minecraft" || value === "discord" ? value : "last_seen"; + return value === "minecraft" || value === "discord" || value === "character" ? value : "last_seen"; } export type PlayerSummary = { uuid: string; minecraft_name: string | null; discord_user_id: string | null; + /** Their Discord account handle, when known. */ discord_username: string | null; + /** Their nickname in the TFMC Discord server, if they have one. */ + discord_nickname: string | null; site_role: StaffRole | null; characters: string[]; last_seen: number | null; @@ -155,6 +161,7 @@ export type PlayerProfile = { discord: { discord_user_id: string; discord_username: string | null; + discord_nickname: string | null; linked_at: string | null; left_guild_at: string | null; grace_until: string | null; @@ -182,10 +189,14 @@ export type PlayerProfile = { export type WorldPoint = { time: number; world: string | null; x: number; y: number; z: number }; export type PlayerSession = { + /** Opaque; names this session in /sessions/{id}/movement and in links. */ + id: string; start: WorldPoint; end: WorldPoint | null; end_kind: "logout" | "last_observed" | "open" | "unknown"; duration_seconds: number | null; + /** The newest row seen in the session: its logout, last ping or login. */ + last_observed: WorldPoint; }; export type SessionPage = { diff --git a/frontend/lib/admin/movement.test.ts b/frontend/lib/admin/movement.test.ts new file mode 100644 index 00000000..d9151b1a --- /dev/null +++ b/frontend/lib/admin/movement.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; + +// Clock-change cases below are written for London. +process.env.TZ = "Europe/London"; + +import { + ACTION_LOGIN, + ACTION_LOGOUT, + ACTION_PING, + boundsOf, + clampMoment, + clipStretches, + describeSpan, + inspect, + observationTimes, + observedBands, + parseLocalInput, + sessionEndLabel, + sessionSpanLabel, + stepObservation, + timelineTicks, + zoneLabel, + fromLocalInput, + positionAt, + stretches, + timeByWorld, + toLocalInput, + worldLabel, + type MovementPoint, +} from "./movement"; + +const WORLDS = ["TFMC_Map", "TFMC_Map_the_end"]; + +function p(time: number, x: number, z: number, action = ACTION_PING, world = 0): MovementPoint { + return [time, world, x, 64, z, action]; +} + +describe("stretches", () => { + it("breaks at logins, after logouts, on a world change and across long gaps", () => { + const points = [ + p(0, 0, 0, ACTION_LOGIN), + p(60, 10, 0), + p(120, 20, 0, ACTION_LOGOUT), + p(500, 0, 0, ACTION_LOGIN), + p(560, 5, 5), + p(620, 5, 5, ACTION_PING, 1), + p(680, 6, 6, ACTION_PING, 1), + // Not seen for five minutes: a crash or missed pings. + p(980, 7, 7, ACTION_PING, 1), + ]; + const out = stretches(points, WORLDS, 60); + expect(out.map((s) => [s.world, s.samples.map((x) => x.time)])).toEqual([ + ["TFMC_Map", [0, 60, 120]], + ["TFMC_Map", [500, 560]], + ["TFMC_Map_the_end", [620, 680]], + ["TFMC_Map_the_end", [980]], + ]); + expect(timeByWorld(out).get("TFMC_Map")).toBe(180); + expect(timeByWorld(out).get("TFMC_Map_the_end")).toBe(60); + }); +}); + +describe("positionAt", () => { + const out = stretches([p(0, 0, 0, ACTION_LOGIN), p(60, 60, 0), p(120, 60, 60), p(180, 5000, 60)], WORLDS, 60); + + it("is exact on a row and interpolated between rows", () => { + expect(positionAt(out, 60)).toEqual({ world: "TFMC_Map", x: 60, z: 0, exact: true }); + expect(positionAt(out, 90)).toEqual({ world: "TFMC_Map", x: 60, z: 30, exact: false }); + }); + + it("does not slide along a teleport", () => { + expect(positionAt(out, 150)).toEqual({ world: "TFMC_Map", x: 60, z: 60, exact: false }); + }); + + it("is null outside every stretch", () => { + expect(positionAt(out, -1)).toBeNull(); + expect(positionAt(out, 181)).toBeNull(); + }); + + it("holds the last sighting while the next ping may still come", () => { + expect(positionAt(out, 250, 150)).toEqual({ world: "TFMC_Map", x: 5000, z: 60, exact: false, lastSeen: 180 }); + expect(positionAt(out, 331, 150)).toBeNull(); + const loggedOut = stretches([p(0, 1, 1, ACTION_LOGIN), p(60, 2, 2, ACTION_LOGOUT)], WORLDS, 60); + expect(positionAt(loggedOut, 90, 150)).toBeNull(); + }); + +}); + +describe("inspect", () => { + const out = stretches([p(0, 0, 0, ACTION_LOGIN), p(60, 60, 0), p(120, 60, 60), p(180, 5000, 60)], WORLDS, 60); + + it("says how it knows", () => { + expect(inspect(out, 60).kind).toBe("observed"); + expect(inspect(out, 90)).toMatchObject({ kind: "estimated", x: 60, z: 30 }); + expect(inspect(out, 150)).toMatchObject({ kind: "unobserved", before: { time: 120 }, after: { time: 180 } }); + expect(inspect(out, 200, 150)).toMatchObject({ kind: "stale", before: { time: 180 } }); + expect(inspect(out, 400, 150)).toMatchObject({ kind: "none", before: { time: 180 } }); + expect(inspect(out, -5)).toEqual({ kind: "none", before: null }); + }); + + it("steps between observations and finds the observed bands", () => { + const times = observationTimes(out); + expect(stepObservation(times, 90, -1)).toBe(60); + expect(stepObservation(times, 90, 1)).toBe(120); + expect(stepObservation(times, 0, -1)).toBeNull(); + expect(stepObservation(times, 180, 1)).toBeNull(); + expect(observedBands(out)).toEqual([[0, 180]]); + // A point made up at a range's edge is not an observation. + expect(observationTimes(clipStretches(out, 30, 200), 30, 200)).toEqual([60, 120, 180]); + }); +}); + +describe("clipStretches", () => { + it("cuts the row before the window to the window's edge", () => { + const all = stretches([p(900, 0, 0), p(1010, 110, 0), p(1070, 170, 0)], WORLDS, 60); + const clipped = clipStretches(all, 1000, 1100); + expect(clipped[0].samples.map((s) => [s.time, s.x])).toEqual([[1000, 100], [1010, 110], [1070, 170]]); + expect(timeByWorld(clipped).get("TFMC_Map")).toBe(70); + // The unclipped stretches still say where they were as the window opened. + expect(positionAt(all, 1000)?.x).toBe(100); + }); + + it("keeps a row exactly on the window's edge as it is", () => { + const all = stretches([p(900, 0, 0), p(1000, 100, 0), p(1060, 160, 0)], WORLDS, 60); + const clipped = clipStretches(all, 1000, 1100); + expect(clipped[0].samples.map((s) => [s.time, s.estimated ?? false])).toEqual([[1000, false], [1060, false]]); + }); + + it("drops stretches wholly outside the window", () => { + expect(clipStretches(stretches([p(0, 0, 0), p(60, 1, 1)], WORLDS, 60), 100, 200)).toEqual([]); + }); + + it("keeps the slider inside the window", () => { + expect(clampMoment(null, 10, 20)).toBeNull(); + expect(clampMoment(5, 10, 20)).toBe(10); + expect(clampMoment(25, 10, 20)).toBe(20); + }); +}); + +describe("times", () => { + it("refuses local times the clocks skip or repeat", () => { + expect(parseLocalInput("2026-10-06T12:00").time).toBe(Date.UTC(2026, 9, 6, 11, 0) / 1000); + expect(parseLocalInput("2026-03-29T01:30").error).toMatch(/clocks went forward/); + expect(parseLocalInput("2026-10-25T01:30").error).toMatch(/clocks went back/); + expect(parseLocalInput("").error).toMatch(/Enter/); + }); + + it("states the zone and the span", () => { + const noon = Date.UTC(2026, 9, 6, 11, 0) / 1000; + expect(zoneLabel(noon)).toBe("Europe/London (UTC+01:00)"); + expect(zoneLabel(noon, noon + 30 * 86400)).toBe("Europe/London (UTC+01:00 → UTC+00:00)"); + expect(describeSpan(noon, noon + 3600)).toBe("Tue 6 Oct 2026, 12:00 → 13:00"); + expect(describeSpan(noon, noon + 86400)).toBe("Tue 6 Oct 2026, 12:00 → Wed 7 Oct 2026, 12:00"); + }); + + it("refuses a repeated half hour too", () => { + process.env.TZ = "Australia/Lord_Howe"; + try { + expect(parseLocalInput("2026-04-05T01:45").error).toMatch(/clocks went back/); + expect(parseLocalInput("2026-04-05T03:00").time).toBeDefined(); + } finally { + process.env.TZ = "Europe/London"; + } + }); + + it("keeps hourly ticks on the hour across a half-hour clock change", () => { + process.env.TZ = "Australia/Lord_Howe"; + try { + const start = Date.UTC(2026, 9, 3, 13, 0) / 1000; + const ticks = timelineTicks(start, start + 8 * 3600); + expect(ticks.map((t) => new Date(t * 1000).getMinutes())).toEqual(ticks.map(() => 0)); + } finally { + process.env.TZ = "Europe/London"; + } + }); + + it("keeps daily ticks on local midnight across a clock change", () => { + const start = Date.UTC(2026, 9, 23, 12, 0) / 1000; + const ticks = timelineTicks(start, start + 5 * 86400); + expect(ticks.map((t) => new Date(t * 1000).getHours())).toEqual(ticks.map(() => 0)); + expect(ticks.map((t) => new Date(t * 1000).getDate())).toEqual([24, 25, 26, 27, 28]); + }); + + it("puts timeline ticks on round local times", () => { + const noon = Date.UTC(2026, 9, 6, 11, 0) / 1000; + expect(timelineTicks(noon + 60, noon + 3600 + 60).map((t) => new Date(t * 1000).getMinutes())).toEqual( + [10, 20, 30, 40, 50, 0] + ); + }); +}); + +describe("helpers", () => { + it("names the Nether and the End", () => { + expect(worldLabel("TFMC_Map_nether")).toBe("the Nether"); + expect(worldLabel("TFMC_Map_the_end")).toBe("the End"); + expect(worldLabel("world #9")).toBe("world #9"); + }); + + it("bounds only the map's world", () => { + const out = stretches([p(0, 10, 20), p(60, 30, 5), p(120, 900, 900, ACTION_PING, 1)], WORLDS, 60); + expect(boundsOf(out, "TFMC_Map")).toEqual({ x: 10, y: 5, w: 20, h: 15 }); + expect(boundsOf([], "TFMC_Map")).toBeNull(); + }); + + it("round-trips datetime-local values to the minute", () => { + const t = 1_791_293_460; + expect(fromLocalInput(toLocalInput(t))).toBe(t); + expect(fromLocalInput("")).toBeNull(); + }); +}); + +describe("session labels", () => { + const at = (h: number, m: number) => Date.UTC(2026, 9, 6, h - 1, m) / 1000; + const point = (time: number) => ({ time, world: "TFMC_Map", x: 0, y: 64, z: 0 }); + const session = (end_kind: "logout" | "open" | "last_observed" | "unknown", end: number | null, seen: number) => ({ + id: "s", + start: point(at(13, 2)), + end: end === null ? null : point(end), + end_kind, + duration_seconds: null, + last_observed: point(seen), + }); + + it("says only what the record shows", () => { + expect(sessionEndLabel(session("logout", at(14, 15), at(14, 15)))).toBe("Logged out 14:15"); + expect(sessionEndLabel(session("open", null, at(14, 14)))).toBe("Probably online · last observed 14:14"); + expect(sessionEndLabel(session("last_observed", at(14, 15), at(14, 15)))).toBe( + "Last observed 14:15 · logout not recorded" + ); + expect(sessionEndLabel(session("unknown", null, at(13, 2)))).toBe("End unknown"); + expect(sessionSpanLabel(session("logout", at(14, 15), at(14, 15)))).toBe("13:02 → 14:15"); + expect(sessionSpanLabel(session("open", null, at(14, 14)))).toBe("13:02 → now"); + }); +}); diff --git a/frontend/lib/admin/movement.ts b/frontend/lib/admin/movement.ts new file mode 100644 index 00000000..4f000e93 --- /dev/null +++ b/frontend/lib/admin/movement.ts @@ -0,0 +1,486 @@ +/** + * Where players went, from CoreProtect's session rows: logins, logouts and a + * position ping once a minute while online. Nothing between two pings is + * recorded, so the lines drawn between them are guesses. + */ + +import { adminRequest, type CoreProtectStatus, type PlayerSession } from "./api"; + +export const ACTION_LOGOUT = 0; +export const ACTION_LOGIN = 1; +export const ACTION_PING = 2; + +/** [time, world index, x, y, z, action], oldest first. */ +export type MovementPoint = [number, number, number, number, number, number]; + +export type MovementStatus = CoreProtectStatus & { + server_label: string | null; + ping_seconds: number | null; + /** The CoreProtect world the map shows. */ + map_world: string; +}; + +export type PlayerMovement = { + since: number; + until: number; + worlds: (string | null)[]; + /** Includes the row just before `since`, when it says where the player was as the window opened. */ + points: MovementPoint[]; + /** + * From when the answer is complete: `since`, unless the window held more + * rows than one answer carries and the oldest were left out. + */ + complete_from: number; + /** When the server began logging pings; before that only logins and logouts exist. */ + pings_since: number | null; + /** The server's clock when it answered. */ + as_of?: number; + coreprotect: MovementStatus; +}; + +export type SessionMovement = Omit & { + /** Null only when CoreProtect could not be read. */ + session: PlayerSession | null; + /** The span shown: login to logout, last sighting, or (open) now. */ + since?: number; + until?: number; + complete_from: number | null; + as_of: number; +}; + +export type EveryoneMovement = Omit & { + players: { uuid: string; minecraft_name: string; points: MovementPoint[] }[]; +}; + +/** The longest range the backend serves for one player, and for everyone. */ +export const PLAYER_WINDOW_SECONDS = 7 * 86400; +export const EVERYONE_WINDOW_SECONDS = 86400; + +export function getPlayerMovement(uuid: string, since: number, until: number): Promise { + return adminRequest(`/admin/players/${encodeURIComponent(uuid)}/movement?since=${since}&until=${until}`); +} + +export function getSessionMovement(uuid: string, session: string): Promise { + return adminRequest( + `/admin/players/${encodeURIComponent(uuid)}/sessions/${encodeURIComponent(session)}/movement` + ); +} + +export function getEveryoneMovement(since: number, until: number): Promise { + return adminRequest(`/admin/movement?since=${since}&until=${until}`); +} + +/** CoreProtect names the Nether and the End after the overworld (`TFMC_Map_nether`, `TFMC_Map_the_end`). */ +export function worldLabel(world: string | null | undefined): string { + if (!world) return "an unknown world"; + if (world.endsWith("_the_end")) return "the End"; + if (world.endsWith("_nether")) return "the Nether"; + return world; +} + +/** Pings further apart than this mean the player was not seen in between. */ +export function gapSeconds(pingSeconds: number | null | undefined): number { + const ping = pingSeconds && pingSeconds > 0 ? pingSeconds : 60; + return 2 * ping + 30; +} + +/** + * Faster than this between two rows is not plain walking or riding (horses + * top out near 15 blocks a second): a teleport, or travel nobody saw, so the + * straight line between them is not where the player went. + */ +export const JUMP_BLOCKS_PER_SECOND = 20; + +export type Sample = { + time: number; + x: number; + y: number; + z: number; + action: number; + /** A point made up for drawing (where a clipped path crosses the range's edge), not a row. */ + estimated?: true; +}; + +/** A run of rows with the player seen throughout, in one world. */ +export type Stretch = { world: string | null; samples: Sample[] }; + +export function isJump(a: Sample, b: Sample): boolean { + const distance = Math.hypot(b.x - a.x, b.z - a.z); + const seconds = Math.max(1, b.time - a.time); + return distance > 64 && distance / seconds > JUMP_BLOCKS_PER_SECOND; +} + +/** + * Splits rows into stretches: a new one at every login, after every logout, + * on a change of world, and wherever the player went unseen for longer than + * `gapSeconds` (a crash, or pings missed). + */ +export function stretches( + points: readonly MovementPoint[], + worlds: readonly (string | null)[], + pingSeconds: number | null | undefined +): Stretch[] { + const gap = gapSeconds(pingSeconds); + const out: Stretch[] = []; + let current: Stretch | null = null; + let previous: Sample | null = null; + for (const [time, wi, x, y, z, action] of points) { + const world = worlds[wi] ?? null; + const sample = { time, x, y, z, action }; + const fresh = + current === null || + previous === null || + action === ACTION_LOGIN || + previous.action === ACTION_LOGOUT || + current.world !== world || + time - previous.time > gap; + if (fresh) { + current = { world, samples: [] }; + out.push(current); + } + current!.samples.push(sample); + previous = sample; + } + return out; +} + +/** + * The stretches cut to [from, to], for drawing and totals. The row before the + * window (kept for `positionAt`) becomes a point on the window's edge, partway + * to the next row, unless that step was a jump. + */ +export function clipStretches(all: readonly Stretch[], from: number, to: number): Stretch[] { + const out: Stretch[] = []; + for (const stretch of all) { + const s = stretch.samples; + const kept: Sample[] = []; + for (let i = 0; i < s.length; i += 1) { + const b = s[i]; + if (b.time < from || b.time > to) continue; + const a = s[i - 1]; + if (!kept.length && a && a.time < from && b.time > from && !isJump(a, b)) { + const f = (from - a.time) / (b.time - a.time); + kept.push({ + time: from, + x: a.x + (b.x - a.x) * f, + y: a.y + (b.y - a.y) * f, + z: a.z + (b.z - a.z) * f, + action: ACTION_PING, + estimated: true, + }); + } + kept.push(b); + } + if (kept.length) out.push({ world: stretch.world, samples: kept }); + } + return out; +} + +/** A slider moment kept inside the window shown; null (the newest moment) stays null. */ +export function clampMoment(cursor: number | null, from: number, to: number): number | null { + return cursor === null ? null : Math.min(to, Math.max(from, cursor)); +} + +/** + * What the record says about one moment: + * - `observed`: a row at exactly that second; + * - `estimated`: between two rows of a stretch, on the straight line between them; + * - `unobserved`: between two rows too far apart to have walked (a teleport, or travel nobody saw); + * - `stale`: after a stretch's last row, within `hold` seconds, before any logout or later row + * (the next ping may simply not have come yet); + * - `none`: not seen then. `before` is the last observation earlier than the moment, if any. + */ +export type Inspection = + | { kind: "observed"; world: string | null; sample: Sample } + | { kind: "estimated"; world: string | null; x: number; z: number; before: Sample; after: Sample } + | { kind: "unobserved"; world: string | null; before: Sample; after: Sample } + | { kind: "stale"; world: string | null; before: Sample } + | { kind: "none"; before: Sample | null }; + +export function inspect(all: readonly Stretch[], time: number, hold = 0): Inspection { + let earlier: Sample | null = null; + for (const [index, stretch] of all.entries()) { + const s = stretch.samples; + const first = s[0]; + const last = s[s.length - 1]; + if (time < first.time) break; + if (time > last.time) { + earlier = last; + const next = all[index + 1]?.samples[0]; + if (time - last.time <= hold && last.action !== ACTION_LOGOUT && (!next || next.time > time)) { + return { kind: "stale", world: stretch.world, before: last }; + } + continue; + } + for (let i = 0; i < s.length; i += 1) { + const a = s[i]; + if (a.time === time) return { kind: "observed", world: stretch.world, sample: a }; + const b = s[i + 1]; + if (b && a.time < time && time < b.time) { + if (isJump(a, b)) return { kind: "unobserved", world: stretch.world, before: a, after: b }; + const f = (time - a.time) / (b.time - a.time); + return { kind: "estimated", world: stretch.world, x: a.x + (b.x - a.x) * f, z: a.z + (b.z - a.z) * f, before: a, after: b }; + } + } + } + return { kind: "none", before: earlier }; +} + +export type Position = { + world: string | null; + x: number; + z: number; + /** Recorded at that very second, rather than estimated or last seen. */ + exact: boolean; + /** Past a stretch's last row: where they were last seen, at this time. */ + lastSeen?: number; +}; + +/** Where to draw a player's marker at `time`, or null when they were not seen then (see `inspect`). */ +export function positionAt(all: readonly Stretch[], time: number, hold = 0): Position | null { + const found = inspect(all, time, hold); + switch (found.kind) { + case "observed": + return { world: found.world, x: found.sample.x, z: found.sample.z, exact: true }; + case "estimated": + return { world: found.world, x: found.x, z: found.z, exact: false }; + case "unobserved": + return { world: found.world, x: found.before.x, z: found.before.z, exact: false }; + case "stale": + return { world: found.world, x: found.before.x, z: found.before.z, exact: false, lastSeen: found.before.time }; + default: + return null; + } +} + +/** Every recorded observation time in [from, to], oldest first, for stepping to the previous or next one. */ +export function observationTimes(all: readonly Stretch[], from = -Infinity, to = Infinity): number[] { + const times = all.flatMap((stretch) => + stretch.samples.filter((s) => !s.estimated && s.time >= from && s.time <= to).map((s) => s.time) + ); + return [...new Set(times)].sort((a, b) => a - b); +} + +/** The nearest observation strictly before (or after) `time`, or null. */ +export function stepObservation(times: readonly number[], time: number, direction: -1 | 1): number | null { + if (direction < 0) { + for (let i = times.length - 1; i >= 0; i -= 1) if (times[i] < time) return times[i]; + return null; + } + for (const t of times) if (t > time) return t; + return null; +} + +/** [first, last] observation of each stretch: when the player was continuously seen. */ +export function observedBands(all: readonly Stretch[]): [number, number][] { + return all.map((stretch) => [stretch.samples[0].time, stretch.samples[stretch.samples.length - 1].time]); +} + +/** Estimated seconds observed in each world: the stretches' spans, leaving out the gaps between them. */ +export function timeByWorld(all: readonly Stretch[]): Map { + const totals = new Map(); + for (const stretch of all) { + const s = stretch.samples; + const span = s[s.length - 1].time - s[0].time; + totals.set(stretch.world, (totals.get(stretch.world) ?? 0) + span); + } + return totals; +} + +/** The map-space box around every sample in the map's world, or null when there is none. */ +export function boundsOf( + all: readonly Stretch[], + mapWorld: string +): { x: number; y: number; w: number; h: number } | null { + let minX = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxZ = -Infinity; + for (const stretch of all) { + if (stretch.world !== mapWorld) continue; + for (const s of stretch.samples) { + minX = Math.min(minX, s.x); + maxX = Math.max(maxX, s.x); + minZ = Math.min(minZ, s.z); + maxZ = Math.max(maxZ, s.z); + } + } + if (minX === Infinity) return null; + return { x: minX, y: minZ, w: maxX - minX, h: maxZ - minZ }; +} + +/** + * A colour from old (violet) through magenta and red to new (orange) for a + * moment in the window: hues the map's greens, browns, snow and sea do not use. + */ +export function ageColour(fraction: number): string { + const f = Math.min(1, Math.max(0, fraction)); + const hue = (275 + 125 * f) % 360; + const light = 58 + 6 * f; + return `hsl(${hue.toFixed(0)} 90% ${light.toFixed(0)}%)`; +} + +/** A steady colour per player for the everyone view. */ +export function playerColour(uuid: string): string { + let hash = 0; + for (let i = 0; i < uuid.length; i += 1) hash = (hash * 31 + uuid.charCodeAt(i)) | 0; + return `hsl(${Math.abs(hash) % 360} 80% 62%)`; +} + +/** `datetime-local` value for Unix seconds, in the browser's time zone. */ +export function toLocalInput(seconds: number): string { + const d = new Date(seconds * 1000); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** Unix seconds for a `datetime-local` value, or null when it is empty or invalid. */ +export function fromLocalInput(value: string): number | null { + if (!value) return null; + const ms = new Date(value).getTime(); + return Number.isFinite(ms) ? Math.floor(ms / 1000) : null; +} + +export type ParsedTime = { time: number; error?: undefined } | { time?: undefined; error: string }; + +/** + * A `datetime-local` value as Unix seconds, refusing local times the clocks + * skip (spring forward) or pass twice (fall back) rather than guessing. + */ +export function parseLocalInput(value: string): ParsedTime { + const minute = value.slice(0, 16); + if (!minute) return { error: "Enter a date and time." }; + const time = fromLocalInput(minute); + if (time === null) return { error: "That isn’t a date and time." }; + if (toLocalInput(time) !== minute) { + return { error: "That time doesn’t exist here: the clocks went forward. Choose another time." }; + } + // Any other instant with the same wall-clock minute, one offset change away (zones shift by 30 min to 2 h). + const offsets = new Set([-2, -1, 0, 1, 2].map((days) => offsetSeconds(time + days * 86400))); + for (const a of offsets) { + for (const b of offsets) { + if (a !== b && toLocalInput(time + (a - b)) === minute) { + return { error: "That time happens twice here: the clocks went back. Choose a time outside that change." }; + } + } + } + return { time }; +} + +function offsetSeconds(at: number): number { + return -new Date(at * 1000).getTimezoneOffset() * 60; +} + +/** `UTC+01:00` for the browser's offset at a moment. */ +export function utcOffset(at: number): string { + const minutes = -new Date(at * 1000).getTimezoneOffset(); + const sign = minutes < 0 ? "−" : "+"; + const abs = Math.abs(minutes); + return `UTC${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`; +} + +/** The browser's time zone and offset(s) over a span: `Europe/London (UTC+01:00)`. */ +export function zoneLabel(since: number, until: number = since): string { + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local time"; + const from = utcOffset(since); + const to = utcOffset(until); + return `${zone} (${from === to ? from : `${from} → ${to}`})`; +} + +/** Parts of a local date; formatters are made per call so they follow the zone in force. */ +function dateParts(at: number, options: Intl.DateTimeFormatOptions): Record { + const parts = new Intl.DateTimeFormat("en-GB", options).formatToParts(new Date(at * 1000)); + return Object.fromEntries(parts.map((part) => [part.type, part.value])); +} + +/** `14:03`, or with seconds `14:03:18`, in local time. */ +export function formatClock(at: number, seconds = false): string { + const p = dateParts(at, { hour: "2-digit", minute: "2-digit", second: seconds ? "2-digit" : undefined, hourCycle: "h23" }); + return seconds ? `${p.hour}:${p.minute}:${p.second}` : `${p.hour}:${p.minute}`; +} + +/** `Tue 6 Oct` in local time. */ +export function formatDay(at: number): string { + const p = dateParts(at, { weekday: "short", day: "numeric", month: "short" }); + return `${p.weekday} ${p.day} ${p.month}`; +} + +function formatDate(at: number): string { + const p = dateParts(at, { weekday: "short", day: "numeric", month: "short", year: "numeric" }); + return `${p.weekday} ${p.day} ${p.month} ${p.year}`; +} + +/** `Tue 6 Oct 2026, 14:03` in local time. */ +export function formatMoment(at: number, seconds = false): string { + return `${formatDate(at)}, ${formatClock(at, seconds)}`; +} + +function sameLocalDay(a: number, b: number): boolean { + return formatDate(a) === formatDate(b); +} + +/** `Tue 6 Oct 2026, 12:00 → 13:00`, with the end's date too when it is another day. */ +export function describeSpan(since: number, until: number): string { + return `${formatMoment(since)} → ${sameLocalDay(since, until) ? formatClock(until) : formatMoment(until)}`; +} + +const TICK_STEPS = [60, 300, 600, 900, 1800, 3600, 2 * 3600, 3 * 3600, 6 * 3600, 12 * 3600, 86400, 2 * 86400]; + +/** Round local-time tick marks for a timeline, at most about `most` of them. */ +export function timelineTicks(since: number, until: number, most = 8): number[] { + const span = Math.max(1, until - since); + const step = TICK_STEPS.find((s) => span / s <= most) ?? TICK_STEPS[TICK_STEPS.length - 1]; + const ticks: number[] = []; + if (step < 3600) { + // Minutes line up the same in every zone this side of an offset change. + const offset = offsetSeconds(since); + for (let t = Math.ceil((since + offset) / step) * step - offset; t <= until; t += step) ticks.push(t); + return ticks; + } + // Hours and days step by wall-clock time, so a clock change does not shift them off round times. + const d = new Date(since * 1000); + d.setMinutes(0, 0, 0); + if (step >= 86400) { + d.setHours(0); + const days = step / 86400; + for (; d.getTime() / 1000 <= until; d.setDate(d.getDate() + days)) { + if (d.getTime() / 1000 >= since) ticks.push(d.getTime() / 1000); + } + return ticks; + } + const hours = step / 3600; + d.setHours(Math.floor(d.getHours() / hours) * hours, 0, 0, 0); + // Minutes are reset each step: a half-hour clock change would otherwise leave every later tick at :30. + for (; d.getTime() / 1000 <= until; d.setHours(Math.floor(d.getHours() / hours) * hours + hours, 0, 0, 0)) { + // An hour the clocks skip lands off the hour (02:00 → 02:30): leave it out. + if (d.getTime() / 1000 >= since && d.getMinutes() === 0) ticks.push(d.getTime() / 1000); + } + return ticks; +} + +/** + * How a session ended, saying only what the record shows: a logout, a + * player probably still online, or a last sighting with no logout recorded. + */ +export function sessionEndLabel(session: PlayerSession): string { + switch (session.end_kind) { + case "logout": + return `Logged out ${formatClock(session.end!.time)}`; + case "open": + return `Probably online · last observed ${formatClock(session.last_observed.time)}`; + case "last_observed": + return `Last observed ${formatClock(session.last_observed.time)} · logout not recorded`; + default: + return "End unknown"; + } +} + +/** `13:02 → 14:15`, the end being the logout or last sighting, or `13:02 → now` while open. */ +export function sessionSpanLabel(session: PlayerSession): string { + const start = session.start.time; + if (session.end_kind === "open") return `${formatClock(start)} → now`; + const end = session.end?.time ?? session.last_observed.time; + return formatDay(start) === formatDay(end) + ? `${formatClock(start)} → ${formatClock(end)}` + : `${formatClock(start)} → ${formatDay(end)} ${formatClock(end)}`; +} diff --git a/frontend/lib/admin/urlState.ts b/frontend/lib/admin/urlState.ts new file mode 100644 index 00000000..d0b47788 --- /dev/null +++ b/frontend/lib/admin/urlState.ts @@ -0,0 +1,10 @@ +/** + * Change the page's query without a navigation. Next.js keeps + * `useSearchParams` in step with the native history API, and skipping the + * router means dragging the timeline or ticking a player does not ask the + * server for the page again. + */ +export function writeUrl(url: string, replace: boolean): void { + if (replace) window.history.replaceState(null, "", url); + else window.history.pushState(null, "", url); +}