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 ? (
+ <>
+ 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)}.
+
+ {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.
+
- {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}
+
+ )}
+