Skip to content

Commit ab2f677

Browse files
ryanbarlow97claude
andauthored
Add staff player movement, session by session, on the map (#94)
* Add staff movement history from CoreProtect pings Admins and the owner can read where a player went, or where everyone went, in a window of up to 7 days (one player) or 24 hours (everyone), from co_session's logins, logouts and once-a-minute pings. Reads are batched newest first so no statement holds CoreProtect's read lock for long; a window too big for one answer says from when it is complete. Every view is audited as player.movement.view, without positions. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Show player movement on the map in the staff panel A Movement panel on each player's profile draws their path on the live map's tiles over a chosen window, with a slider (and play) for where they were at a moment, and /admin/movement shows everyone at once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Document movement in the CoreProtect README Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Hold online players' last sighting and draw paths in map-contrasting colours At the newest moment the latest ping can be up to a minute old, so the map showed everyone as not seen. A player now stays at their last sighting for two ping intervals unless they logged out. Paths fade violet to orange, hues the terrain does not use, over a dark edge. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Count only the window, keep the slider in it, and pick everyone's lead-in per player The row before the window now only places the player as it opens: paths, framing and totals are clipped to the window. A refresh that moves the window past the slider's moment clamps it. Everyone's row before the window is chosen per player across all their CoreProtect ids, so a logout under one id is not undone by an older ping under another. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Match CoreProtect UUIDs in any case and keep half-edited end dates out of live mode Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Let the staff movement maps zoom in to single blocks The site map stops at 16 times its fitted scale, about one screen pixel per block in the staff panel. useMapViewport takes an optional maxDisplayScale that raises the cap for one viewport; the movement maps allow 12 screen pixels per block. The site map is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Give sessions ids and serve one session's movement /sessions rows carry an opaque id (their login row) and the last observed point. GET /admin/players/{uuid}/sessions/{id}/movement returns that session and its rows from login through logout, or up to the next login, or now while it is open; an id that is not the player's, or no longer found, is 404 session_gone. Movement answers carry as_of. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Rebuild staff movement around sessions, clear time ranges and an inspected moment Agreed with Astra over two design rounds. Each player gets a full movement page, linked from a card on their profile: - Sessions (default) or a time range. Session rows say only what the record shows: Logged out, Probably online · last observed, Last observed · logout not recorded, End unknown. - From and To are always filled in and applied together, with quick ranges, an explicit Keep up to date, and the span and time zone spelled out. Local times the clocks skip or repeat are refused. - The map labels where the trail starts and ends (Logged in / First observation shown, Logged out / Last observation shown), marks direction, dims what comes after the inspected moment, shows recorded positions solid and estimates hollow, and has a legend, a scale bar, an incident pin and buttons to fit the trail or go to the inspected position. It frames a new selection once, not on refresh. - A timeline shows observed bands, unobserved gaps and the span before any observations; the inspected moment can be typed, dragged, clicked on a dot, or stepped between observations, with wording for recorded, estimated, unobserved, last-seen and not-observed moments, and a teleport command to copy for recorded ones. - The whole view lives in the URL, and Copy link freezes a live range. The everyone page gets the same controls, a searchable player list with selection, positions as of the inspected moment, and links that carry the range, moment and pin to a player's page. The distance figure is gone: the pings cannot support it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep the movement views' query in the URL without navigating; scroll long session lists Ticking a player or dragging the timeline went through the Next.js router, a navigation per change that lagged by up to half a second. The query now changes through the native history API, which Next.js keeps useSearchParams in step with. Long session lists scroll on their own, and the everyone timeline gives each of up to eight drawn players their own row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Fix framing, following, stepping and accessibility in the movement views From Astra's review of the rebuilt pages: - A new selection is framed only once its own answer is in, not against the previous trail. - Following an open session stops on a new selection or a recorded end; following a range no longer overwrites From and To while they are edited. - Previous/next observation steps only between recorded rows; the point made up where a clipped path meets the range's edge is never a dot or an observation label. - Only selected is kept in the URL, so copied links show the same players. - The timeline shows keyboard focus and reads out its moment; controls and checkboxes keep 44 px targets. - Local times that repeat when the clocks change are caught for any size of change, and day ticks stay on local midnight across one. - On phones the scale bar sits clear of the legend and labels near the right edge turn to the left. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep a row on a range's edge as recorded, and hourly ticks on the hour after a half-hour clock change Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Stop Safari's date boxes running into the buttons beside them Safari draws a date and time box at its own width, past a narrow box, so Inspect time ran under Previous observation. Date boxes drop the native appearance so they keep to the width given, and the inspect box has its own row with the step buttons together below it. Checked in WebKit at phone, tablet and desktop widths. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Sort the players list by character name, and give its search box a row of its own The search box shared a row with the sort buttons and was squeezed to a few letters on narrower screens. It now spans the page with a search icon and a clear button, and the sort buttons sit below it under "Sort by". Players can be sorted by character name (their first alphabetically; those without one last), and on narrow screens their characters show under their name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Show both a player's Discord handle and their server nickname Links made before handles were checked stored the server nickname in discord_username ("Justin"), which the staff panel showed as a handle. The nickname now has its own column, discord_nickname: - On upgrade, a stored name that is not shaped like a handle moves there and the handle is cleared, so the bot's fill or the player's next sign-in stores the real one. - Website sign-in stores the nickname from the member record Discord already returns, and the bot may send discord_nickname with usernames. - The profile shows Discord (the handle) and Server nickname; the players list shows both, searches both, and sorts by handle, else nickname. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Say what could not be logged without naming chat, now that movement is audited too Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Skip a nickname that is not valid instead of clearing the stored one Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 87f1f01 commit ab2f677

40 files changed

Lines changed: 4181 additions & 64 deletions

‎backend/src/api/admin_routes.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020

2121
admin_router = APIRouter(prefix="/admin", tags=["admin"])
2222

23+
# Unix seconds well past any real window; keeps query values inside SQLite's integers.
24+
MAX_TIME = 2 ** 40
25+
2326

2427
class RoleBody(BaseModel):
2528
role: str = Field(..., max_length=16)
@@ -44,8 +47,8 @@ def _capable(request: Request, capability: str) -> dict:
4447
return user
4548

4649

47-
def _players(request: Request, response: Response, run):
48-
viewer = _capable(request, "view_players")
50+
def _players(request: Request, response: Response, run, capability: str = "view_players"):
51+
viewer = _capable(request, capability)
4952
_no_store(response)
5053
try:
5154
return run(CoreProtectConfig.from_env(), viewer)
@@ -137,6 +140,31 @@ def get_player_activity(player_uuid: str, request: Request, response: Response,
137140
viewer))
138141

139142

143+
@admin_router.get("/players/{player_uuid}/movement")
144+
def get_player_movement(player_uuid: str, request: Request, response: Response,
145+
since: int | None = Query(None, ge=0, le=MAX_TIME),
146+
until: int | None = Query(None, ge=0, le=MAX_TIME)):
147+
return _players(request, response,
148+
lambda config, viewer: players.player_movement(config, player_uuid, since, until, viewer),
149+
"view_player_movement")
150+
151+
152+
@admin_router.get("/players/{player_uuid}/sessions/{session_id}/movement")
153+
def get_session_movement(player_uuid: str, session_id: str, request: Request, response: Response):
154+
return _players(request, response,
155+
lambda config, viewer: players.session_movement(config, player_uuid, session_id, viewer),
156+
"view_player_movement")
157+
158+
159+
@admin_router.get("/movement")
160+
def get_movement(request: Request, response: Response,
161+
since: int | None = Query(None, ge=0, le=MAX_TIME),
162+
until: int | None = Query(None, ge=0, le=MAX_TIME)):
163+
return _players(request, response,
164+
lambda config, viewer: players.everyone_movement(config, since, until, viewer),
165+
"view_player_movement")
166+
167+
140168
@admin_router.post("/accounts/{user_id}/role")
141169
def post_role(user_id: int, body: RoleBody, request: Request, response: Response):
142170
_no_store(response)

‎backend/src/api/auth_routes.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,10 @@ def _finish_sign_in(config: AuthConfig, code: str | None) -> str | None:
191191
access_token = client.exchange_code(code)
192192
try:
193193
identity = client.identity(access_token)
194+
member = None
194195
try:
195-
guild_member = client.is_guild_member(access_token)
196+
member = client.guild_member(access_token)
197+
guild_member = member is not None
196198
except DiscordError as exc:
197199
# Sign-in still works; linking asks for a fresh check.
198200
logger.warning("Discord guild check failed code=%s", exc)
@@ -204,7 +206,7 @@ def _finish_sign_in(config: AuthConfig, code: str | None) -> str | None:
204206
return None
205207
finally:
206208
client.close()
207-
return users.sign_in(identity, guild_member=guild_member)
209+
return users.sign_in(identity, guild_member=guild_member, member=member)
208210

209211

210212
@auth_router.post("/auth/logout")

‎backend/src/api/skins_routes.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
LinkError,
3636
complete_link,
3737
get_identity_status,
38+
remember_discord_nicknames,
3839
remember_discord_usernames,
3940
record_guild_joined,
4041
record_guild_left,
@@ -158,6 +159,8 @@ class LinkCompleteBody(BaseModel):
158159
class DiscordUsernameUpdate(BaseModel):
159160
discord_user_id: str = Field(..., min_length=1, max_length=32)
160161
discord_username: str | None = Field(default=None, max_length=80)
162+
# Their server nickname. Sent (even as null, for none) replaces the stored one; left out, it is kept.
163+
discord_nickname: str | None = Field(default=None, max_length=80)
161164

162165

163166
class DiscordUsernamesBody(BaseModel):
@@ -367,10 +370,10 @@ def post_discord_usernames(
367370
body: DiscordUsernamesBody,
368371
x_staff_key: str | None = Header(default=None, alias=HEADER_STAFF_KEY),
369372
):
370-
"""Store Discord account usernames on existing links for staff lookup."""
373+
"""Store Discord account usernames (and server nicknames, when sent) on existing links for staff lookup."""
371374
_require_staff(x_staff_key)
372375
try:
373-
return remember_discord_usernames(
376+
result = remember_discord_usernames(
374377
[
375378
{
376379
"discord_user_id": item.discord_user_id,
@@ -380,6 +383,14 @@ def post_discord_usernames(
380383
],
381384
overwrite=body.overwrite,
382385
)
386+
nicknames = [
387+
{"discord_user_id": item.discord_user_id, "discord_nickname": item.discord_nickname}
388+
for item in body.updates
389+
if "discord_nickname" in item.model_fields_set
390+
]
391+
if nicknames:
392+
result["nicknames_updated"] = remember_discord_nicknames(nicknames)["updated"]
393+
return result
383394
except LinkError as e:
384395
raise HTTPException(status_code=400, detail=str(e)) from e
385396

‎backend/src/auth/discord.py‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,28 @@ def identity(self, token: str) -> dict:
7676
"discord_avatar": _text(data.get("avatar")),
7777
}
7878

79-
def is_guild_member(self, token: str) -> bool:
80-
"""True for a full member; members still on rules screening do not count."""
79+
def guild_member(self, token: str) -> dict | None:
80+
"""The user's membership of the server, or None; members still on rules screening do not count.
81+
82+
`nick` is their server nickname, None when they have not set one.
83+
"""
8184
response = self._get(f"/users/@me/guilds/{self.config.guild_id}/member", token)
8285
if response.status_code == 404:
83-
return False
86+
return None
8487
if response.status_code != 200:
8588
raise DiscordError("discord_guild_check_failed")
8689
try:
87-
return response.json().get("pending") is not True
90+
data = response.json()
91+
pending = data.get("pending")
8892
except (ValueError, AttributeError):
8993
raise DiscordError("discord_guild_check_failed") from None
94+
if pending is True:
95+
return None
96+
return {"nick": _text(data.get("nick"))}
97+
98+
def is_guild_member(self, token: str) -> bool:
99+
"""True for a full member; members still on rules screening do not count."""
100+
return self.guild_member(token) is not None
90101

91102
def revoke(self, token: str) -> None:
92103
"""Best effort: the site keeps no Discord tokens after sign-in."""

‎backend/src/auth/players.py‎

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Staff view of Minecraft players: a directory, profiles, sessions and activity.
1+
"""Staff view of Minecraft players: a directory, profiles, sessions, activity and movement.
22
33
A player is a Minecraft account, keyed by its UUID; names are never merged
44
on. The directory is the union of everyone CoreProtect has seen join, every
@@ -15,7 +15,7 @@
1515
import time
1616
import uuid as uuidlib
1717

18-
from src.coreprotect import activity, maps as co_maps, sessions
18+
from src.coreprotect import activity, maps as co_maps, movement, sessions
1919
from src.coreprotect.cursors import BadCursor, decode as decode_cursor, encode as encode_cursor
2020
from src.coreprotect.reader import REQUEST_BUDGET_SECONDS, Budget, CoreProtectConfig, Reader, Unavailable
2121
from src.skins.db import connect
@@ -25,7 +25,7 @@
2525
DIRECTORY_TTL_SECONDS = 60
2626
PAGE_SIZE = 50
2727
QUERY_MAX = 64
28-
SORTS = ("last_seen", "minecraft", "discord")
28+
SORTS = ("last_seen", "minecraft", "discord", "character")
2929

3030
_DIRECTORY_LOCK = threading.Lock()
3131
_DIRECTORY: dict[tuple[str, str], tuple[float, list[dict], dict]] = {}
@@ -54,7 +54,7 @@ def _status(error: Unavailable | None) -> dict:
5454
# --------------------
5555

5656
_LINKS = """
57-
SELECT dl.player_uuid, dl.discord_user_id, dl.discord_username, dl.minecraft_name,
57+
SELECT dl.player_uuid, dl.discord_user_id, dl.discord_username, dl.discord_nickname, dl.minecraft_name,
5858
dl.linked_at, dl.left_guild_at, dl.grace_until,
5959
u.id AS user_id, u.role, u.discord_username AS account_username,
6060
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:
8484
return None
8585
return {
8686
"discord_user_id": link["discord_user_id"],
87+
# The account handle (from their sign-in, else the link) and their server nickname.
8788
"discord_username": link["account_username"] or link["discord_username"],
89+
"discord_nickname": link["discord_nickname"],
8890
"linked_at": link["linked_at"],
8991
"left_guild_at": link["left_guild_at"],
9092
"grace_until": link["grace_until"],
@@ -145,7 +147,7 @@ def _build_directory(config: CoreProtectConfig) -> tuple[list[dict], dict]:
145147
def person(key: str) -> dict:
146148
return people.setdefault(key, {
147149
"uuid": key, "minecraft_name": None, "names": set(), "discord_user_id": None,
148-
"discord_username": None, "discord_global_name": None, "site_role": None,
150+
"discord_username": None, "discord_nickname": None, "discord_global_name": None, "site_role": None,
149151
"characters": [], "last_seen": None, "online": False,
150152
})
151153

@@ -164,6 +166,7 @@ def person(key: str) -> dict:
164166
entry.update(
165167
discord_user_id=link["discord_user_id"],
166168
discord_username=link["account_username"] or link["discord_username"],
169+
discord_nickname=link["discord_nickname"],
167170
discord_global_name=link["discord_global_name"],
168171
site_role=link["role"],
169172
)
@@ -198,15 +201,24 @@ def _directory(config: CoreProtectConfig) -> tuple[list[dict], dict]:
198201
def _matches(entry: dict, needle: str) -> bool:
199202
if needle in {entry["uuid"], entry["uuid"].replace("-", ""), (entry["discord_user_id"] or "")}:
200203
return True
201-
haystack = [*entry["names"], entry["discord_username"], entry["discord_global_name"], *entry["characters"]]
204+
haystack = [*entry["names"], entry["discord_username"], entry["discord_nickname"], entry["discord_global_name"],
205+
*entry["characters"]]
202206
return any(needle in value.casefold() for value in haystack if value)
203207

204208

205209
def _sort_key(sort: str):
206210
if sort == "minecraft":
207211
return lambda e: (e["minecraft_name"] is None, (e["minecraft_name"] or "").casefold(), e["uuid"])
208212
if sort == "discord":
209-
return lambda e: (e["discord_username"] is None, (e["discord_username"] or "").casefold(), e["uuid"])
213+
# By handle, else server nickname (links older than handles may have only the nickname).
214+
return lambda e: (
215+
not (e["discord_username"] or e["discord_nickname"]),
216+
(e["discord_username"] or e["discord_nickname"] or "").casefold(),
217+
e["uuid"],
218+
)
219+
if sort == "character":
220+
# By the first of their characters alphabetically; players without one last.
221+
return lambda e: (not e["characters"], min((c.casefold() for c in e["characters"]), default=""), e["uuid"])
210222
return lambda e: (e["last_seen"] is None, -(e["last_seen"] or 0), (e["minecraft_name"] or "").casefold())
211223

212224

@@ -216,6 +228,7 @@ def _directory_json(entry: dict) -> dict:
216228
"minecraft_name": entry["minecraft_name"],
217229
"discord_user_id": entry["discord_user_id"],
218230
"discord_username": entry["discord_username"],
231+
"discord_nickname": entry["discord_nickname"],
219232
"site_role": entry["site_role"],
220233
"characters": entry["characters"],
221234
"last_seen": entry["last_seen"],
@@ -253,8 +266,13 @@ def clear_cache() -> None:
253266
# --------------------
254267

255268
def _ids(reader: Reader, key: str) -> list[dict]:
256-
"""A player's co_user rows, oldest first. CoreProtect keeps one per UUID, but nothing enforces it."""
257-
return [dict(r) for r in reader.rows("SELECT id, user, time FROM co_user WHERE uuid = ? ORDER BY time, id", (key,))]
269+
"""A player's co_user rows, oldest first. CoreProtect keeps one per UUID, but nothing enforces it.
270+
271+
Matched without regard to letter case, as `key` is canonical (lower case) and the stored UUID
272+
need not be. That scans co_user, one of the small name tables.
273+
"""
274+
return [dict(r) for r in reader.rows(
275+
"SELECT id, user, time FROM co_user WHERE lower(uuid) = ? ORDER BY time, id", (key,))]
258276

259277

260278
def _require(text: str) -> str:
@@ -323,6 +341,7 @@ def player_sessions(config: CoreProtectConfig, text: str, before: str | None, li
323341
if raw is None:
324342
return {"sessions": [], "next": None, "coreprotect": _status(None)}
325343
built = sessions.build(raw, maps, int(time.time()), config.ping_seconds)
344+
built["sessions"] = [_session_json(config, key, s) for s in built["sessions"]]
326345
nxt = raw["next"]
327346
return {
328347
**built,
@@ -331,6 +350,12 @@ def player_sessions(config: CoreProtectConfig, text: str, before: str | None, li
331350
}
332351

333352

353+
def _session_json(config: CoreProtectConfig, key: str, session: dict) -> dict:
354+
"""A built session with an opaque id (its login row) in place of the raw key."""
355+
login = session.pop("key")
356+
return {"id": encode_cursor(config.server, f"session:{key}", login.time, login.rowid), **session}
357+
358+
334359
def _audit_messages(viewer: dict, config: CoreProtectConfig, key: str, kinds: tuple[str, ...],
335360
before: str | None, entries: list[dict]) -> None:
336361
"""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
386411
if full:
387412
_audit_messages(viewer, config, key, chosen, before, built["entries"])
388413
return {**built, "kinds": list(allowed), "shows_messages": full, "coreprotect": _status(None)}
414+
415+
416+
417+
def _window(since: int | None, until: int | None, longest: int) -> tuple[int, int]:
418+
try:
419+
return movement.window(since, until, int(time.time()), longest)
420+
except movement.BadWindow:
421+
raise PlayerError(400, "bad_window") from None
422+
423+
424+
def _movement_status(config: CoreProtectConfig, error: Unavailable | None) -> dict:
425+
return {**_status(error), "server_label": config.label or None, "ping_seconds": config.ping_seconds or None,
426+
"map_world": config.map_world}
427+
428+
429+
def _audit_movement(viewer: dict, config: CoreProtectConfig, subject: str, since: int, until: int,
430+
body: dict, rows: int, session: str | None = None) -> None:
431+
"""Record that a viewer was shown where players went, before they see it. Never stores positions."""
432+
detail = {
433+
"server": config.server,
434+
"player_uuid": subject,
435+
"since": since,
436+
"until": until,
437+
"complete_from": body["complete_from"],
438+
"rows": rows,
439+
}
440+
if session is not None:
441+
detail["session"] = session
442+
try:
443+
with connect() as conn:
444+
audit.record(conn, actor=viewer, action="player.movement.view", outcome="ok", detail=detail)
445+
conn.commit()
446+
except Exception:
447+
raise PlayerError(503, "audit_unavailable") from None
448+
449+
450+
def player_movement(config: CoreProtectConfig, text: str, since: int | None, until: int | None,
451+
viewer: dict) -> dict:
452+
"""One player's logins, logouts and pings between since and until (epoch seconds). Audited."""
453+
key = _require(text)
454+
since, until = _window(since, until, movement.PLAYER_WINDOW_SECONDS)
455+
empty = {"since": since, "until": until, "worlds": [], "points": [], "complete_from": since,
456+
"pings_since": None}
457+
try:
458+
with Reader(config, Budget()) as reader:
459+
ids = [a["id"] for a in _ids(reader, key)]
460+
maps = co_maps.get(reader)
461+
raw = (movement.fetch_player(reader, ids, since, until, movement.lead_seconds(config.ping_seconds))
462+
if ids else None)
463+
except Unavailable as exc:
464+
return {**empty, "coreprotect": _movement_status(config, exc)}
465+
body = {**empty, **movement.build_player(raw, maps)} if raw else empty
466+
_audit_movement(viewer, config, key, since, until, body, len(body["points"]))
467+
return {**body, "as_of": int(time.time()), "coreprotect": _movement_status(config, None)}
468+
469+
470+
def session_movement(config: CoreProtectConfig, text: str, session_id: str, viewer: dict) -> dict:
471+
"""One session's movement: the session as /sessions describes it, and its rows from login to end. Audited."""
472+
key = _require(text)
473+
try:
474+
values = decode_cursor(config.server, f"session:{key}", session_id, 2)
475+
except BadCursor:
476+
raise PlayerError(400, "bad_session") from None
477+
if values is None:
478+
raise PlayerError(400, "bad_session")
479+
now = int(time.time())
480+
try:
481+
with Reader(config, Budget()) as reader:
482+
ids = [a["id"] for a in _ids(reader, key)]
483+
maps = co_maps.get(reader)
484+
win = sessions.window(reader, ids, sessions.Key(*values)) if ids else None
485+
raw = movement.fetch_session(reader, ids, win) if win else None
486+
except Unavailable as exc:
487+
return {"session": None, "worlds": [], "points": [], "complete_from": None, "pings_since": None,
488+
"as_of": now, "coreprotect": _movement_status(config, exc)}
489+
if win is None:
490+
# Never theirs, or no longer there (a purge, or a rebuilt database renumbering rows).
491+
raise PlayerError(404, "session_gone")
492+
built = sessions.build({"windows": [win], "first_seen": None, "history_start": None}, maps, now,
493+
config.ping_seconds)
494+
session = _session_json(config, key, built["sessions"][0])
495+
# The span shown: from the login (or, if the session held too many rows, from where the kept rows
496+
# are complete) to its logout, its last sighting, or now while it is open. An open session has no end.
497+
since = session["start"]["time"]
498+
if session["end_kind"] == "open":
499+
until = now
500+
else:
501+
until = (session["end"] or session["last_observed"])["time"]
502+
body = {"session": session, "since": since, "until": max(since, until), **movement.build_player(raw, maps)}
503+
_audit_movement(viewer, config, key, since, body["until"], body, len(body["points"]), session=session_id)
504+
return {**body, "as_of": now, "coreprotect": _movement_status(config, None)}
505+
506+
507+
def everyone_movement(config: CoreProtectConfig, since: int | None, until: int | None, viewer: dict) -> dict:
508+
"""Every player's logins, logouts and pings between since and until (epoch seconds). Audited."""
509+
since, until = _window(since, until, movement.EVERYONE_WINDOW_SECONDS)
510+
try:
511+
with Reader(config, Budget()) as reader:
512+
maps = co_maps.get(reader)
513+
raw = movement.fetch_everyone(reader, since, until, movement.lead_seconds(config.ping_seconds))
514+
except Unavailable as exc:
515+
return {"since": since, "until": until, "worlds": [], "players": [], "complete_from": since,
516+
"pings_since": None, "coreprotect": _movement_status(config, exc)}
517+
body = {"since": since, "until": until, "as_of": int(time.time()), **movement.build_everyone(raw, maps)}
518+
_audit_movement(viewer, config, "everyone", since, until, body, sum(len(p["points"]) for p in body["players"]))
519+
return {**body, "coreprotect": _movement_status(config, None)}

0 commit comments

Comments
 (0)