Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
37fa145
Add staff movement history from CoreProtect pings
ryanbarlow97 Oct 6, 2026
014a938
Show player movement on the map in the staff panel
ryanbarlow97 Oct 6, 2026
ac338cd
Document movement in the CoreProtect README
ryanbarlow97 Oct 6, 2026
acaa2b0
Hold online players' last sighting and draw paths in map-contrasting …
ryanbarlow97 Oct 6, 2026
729d6d1
Count only the window, keep the slider in it, and pick everyone's lea…
ryanbarlow97 Oct 6, 2026
ee52ee4
Match CoreProtect UUIDs in any case and keep half-edited end dates ou…
ryanbarlow97 Oct 6, 2026
dfeb302
Let the staff movement maps zoom in to single blocks
ryanbarlow97 Oct 6, 2026
9178d1e
Give sessions ids and serve one session's movement
ryanbarlow97 Oct 6, 2026
1877dee
Rebuild staff movement around sessions, clear time ranges and an insp…
ryanbarlow97 Oct 6, 2026
0d82048
Keep the movement views' query in the URL without navigating; scroll …
ryanbarlow97 Oct 6, 2026
d8fb237
Fix framing, following, stepping and accessibility in the movement views
ryanbarlow97 Oct 6, 2026
d6cbf62
Keep a row on a range's edge as recorded, and hourly ticks on the hou…
ryanbarlow97 Oct 6, 2026
58485ee
Stop Safari's date boxes running into the buttons beside them
ryanbarlow97 Oct 6, 2026
3de4bca
Sort the players list by character name, and give its search box a ro…
ryanbarlow97 Oct 6, 2026
533566c
Show both a player's Discord handle and their server nickname
ryanbarlow97 Oct 6, 2026
1c837e9
Say what could not be logged without naming chat, now that movement i…
ryanbarlow97 Oct 6, 2026
3b8563e
Skip a nickname that is not valid instead of clearing the stored one
ryanbarlow97 Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions backend/src/api/admin_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -137,6 +140,24 @@ 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("/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)
Expand Down
71 changes: 69 additions & 2 deletions backend/src/auth/players.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -386,3 +386,70 @@ 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) -> 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,
}
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)]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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, "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, **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)}
2 changes: 2 additions & 0 deletions backend/src/auth/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
78 changes: 78 additions & 0 deletions backend/src/auth/test_players.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,81 @@ 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_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"
22 changes: 20 additions & 2 deletions backend/src/coreprotect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
Loading