- Signed in as {me.discord_username ? `@${me.discord_username}` : "staff"} · {roleLabel(me.role)}. Every
- change here is recorded with your reason.
+ Signed in as {me.discord_username ? `@${me.discord_username}` : "staff"} · {roleLabel(me.role)}. Changes and reasons are recorded.
{/* The roster beside the lookup on wide screens; stacked below that. */}
diff --git a/frontend/app/components/admin/EveryoneMovement.test.tsx b/frontend/app/components/admin/EveryoneMovement.test.tsx
index 31c8990a..b1b36c33 100644
--- a/frontend/app/components/admin/EveryoneMovement.test.tsx
+++ b/frontend/app/components/admin/EveryoneMovement.test.tsx
@@ -76,7 +76,7 @@ afterEach(() => {
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/);
+ await screen.findByText(/2 of 2 players seen/);
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.
@@ -91,5 +91,5 @@ it("compares everyone at the inspected moment and carries the view to a player",
// "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/);
+ await screen.findByText(/1 of 1 players seen/);
});
diff --git a/frontend/app/components/admin/EveryoneMovement.tsx b/frontend/app/components/admin/EveryoneMovement.tsx
index 91b48ad5..6c2c76a4 100644
--- a/frontend/app/components/admin/EveryoneMovement.tsx
+++ b/frontend/app/components/admin/EveryoneMovement.tsx
@@ -70,11 +70,11 @@ function whereText(found: Inspection, moment: number, mapWorld: string): string
case "estimated":
return `≈ ${place(found.x, found.z, found.world)}`;
case "unobserved":
- return "unobserved transition";
+ return "unknown";
case "stale":
return `${place(found.before.x, found.before.z, found.world)} · ${formatDuration(moment - found.before.time).toLowerCase()} before`;
default:
- return "not observed";
+ return "not seen";
}
}
@@ -255,11 +255,7 @@ function EveryoneRange({ viewSwitch }: { viewSwitch: ReactNode }) {
panel={
<>
{viewSwitch}
-
- {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.
-
+
Lines between positions are estimates. Views are logged.
{data ? : null}
{notice ?
{notice}
: null}
{load.kind === "failed" ? (
@@ -316,7 +312,7 @@ function EveryoneRange({ viewSwitch }: { viewSwitch: ReactNode }) {
since={since}
until={until}
unknownUntil={unknownUntil}
- unknownLabel={completeFrom > since ? "earlier observations omitted" : "before available position observations"}
+ unknownLabel={completeFrom > since ? "too many to show" : "login and logout locations only"}
bands={bands}
cursor={moment}
onCursor={setMoment}
@@ -327,17 +323,16 @@ function EveryoneRange({ viewSwitch }: { viewSwitch: ReactNode }) {
<>
{data ? (
- Inspecting {formatClock(moment, true)} · {seenNow} of {drawn.length} players observed or estimated at this
- moment. Positions in the list are as of this moment.
+ {formatClock(moment, true)} · {seenNow} of {drawn.length} players seen or estimated
) : null}
{data && completeFrom > since ? (
- Earlier observations omitted: too many to show, so the range starts at {formatMoment(completeFrom)}.
+ Too many positions to show; the range starts at {formatMoment(completeFrom)}.
Before {formatMoment(data.pings_since)} there are no available position observations.
+
Before {formatMoment(data.pings_since)}, only login and logout locations.
) : null}
))}
- {data && !all.length ?
Nobody was observed in this range.
: null}
+ {data && !all.length ?
Nobody was seen in this range.
: null}
>
diff --git a/frontend/app/components/admin/EveryoneNow.test.tsx b/frontend/app/components/admin/EveryoneNow.test.tsx
index 806f6467..1da5c4be 100644
--- a/frontend/app/components/admin/EveryoneNow.test.tsx
+++ b/frontend/app/components/admin/EveryoneNow.test.tsx
@@ -88,7 +88,7 @@ afterEach(() => {
it("opens on where everyone online is now, and refreshes", async () => {
render();
- await screen.findByText(/2 players online · updated 14:00:00/);
+ await screen.findByText(/2 players seen recently · updated 14:00:00/);
expect(getLatestMovement).toHaveBeenCalledWith(T - 150);
// Carol logged out: not listed. Bob is in the Nether: listed, not on the map's world.
expect(screen.getByText("11, 21 · 40 s ago")).toBeTruthy();
@@ -109,7 +109,7 @@ it("opens on where everyone online is now, and refreshes", async () => {
it("keeps the last positions when a refresh fails", async () => {
render();
- await screen.findByText(/2 players online/);
+ await screen.findByText(/2 players seen recently/);
vi.mocked(getLatestMovement).mockRejectedValueOnce(new Error("boom"));
await act(async () => {
vi.advanceTimersByTime(NOW_REFRESH_MS);
@@ -120,7 +120,7 @@ it("keeps the last positions when a refresh fails", async () => {
it("switches between now and a time range", async () => {
render();
- await screen.findByText(/2 players online/);
+ await screen.findByText(/2 players seen recently/);
fireEvent.click(screen.getByRole("button", { name: "Time range" }));
expect(nav.state.params.get("view")).toBe("range");
expect(screen.getByRole("button", { name: "Time range" }).getAttribute("aria-pressed")).toBe("true");
@@ -130,7 +130,7 @@ it("switches between now and a time range", async () => {
it("ignores an answer that arrives after a newer one", async () => {
render();
- await screen.findByText(/2 players online/);
+ await screen.findByText(/2 players seen recently/);
let answerSlow: (value: Awaited>) => void = () => {};
const first = await vi.mocked(getLatestMovement).mock.results[0].value;
vi.mocked(getLatestMovement)
diff --git a/frontend/app/components/admin/EveryoneNow.tsx b/frontend/app/components/admin/EveryoneNow.tsx
index 52d77377..de55049b 100644
--- a/frontend/app/components/admin/EveryoneNow.tsx
+++ b/frontend/app/components/admin/EveryoneNow.tsx
@@ -159,12 +159,7 @@ export default function EveryoneNow({ viewSwitch = null }: { viewSwitch?: ReactN
panel={
<>
{viewSwitch}
-
- {data?.coreprotect.server_label ? `${data.coreprotect.server_label} · ` : ""}
- Where everyone online is now: each player’s latest recorded position. Positions are recorded once a
- minute, so one can be up to a minute old. Refreshes every {NOW_REFRESH_MS / 1000} seconds; each refresh is
- logged.
-
+
Positions update about once a minute. Views are logged.
: null}
>
}
diff --git a/frontend/app/components/admin/MovementControls.tsx b/frontend/app/components/admin/MovementControls.tsx
index 62350335..1969e564 100644
--- a/frontend/app/components/admin/MovementControls.tsx
+++ b/frontend/app/components/admin/MovementControls.tsx
@@ -7,7 +7,6 @@ import { useAccessibleMaps } from "../../hooks/useAccessibleMaps";
import { liveMapIdFrom } from "../../lib/map/chronicleDayRoute";
import { formatDuration } from "../../../lib/admin/time";
import {
- describeSpan,
formatClock,
formatDay,
formatMoment,
@@ -254,7 +253,7 @@ export function RangeForm({
}
}}
/>
- Keep up to date (moves the range with the clock)
+ Move range with the clock
{error ? (
@@ -262,8 +261,7 @@ export function RangeForm({
) : null}
- Showing {describeSpan(value.from, value.to)} ·{" "}
- {formatDuration(value.to - value.from).toLowerCase()} · times in {zoneLabel(value.from, value.to)}
+ {formatDuration(value.to - value.from)} · times in {zoneLabel(value.from, value.to)}
{value.follow && asOf ? ` · updated ${formatClock(asOf)}` : ""}
@@ -366,11 +364,11 @@ export function Timeline({
- observed
+ seen
not
- observed (offline, or not recorded)
+ seen (not proof of offline)
{hatchEnd !== null ? (
@@ -420,7 +418,7 @@ export function InspectBar({
{/* The box on its own row, the steps together below it: nothing beside a date box to run into. */}
{error ? (
@@ -495,7 +493,7 @@ function PinForm({ pin, onChange }: { pin: MapPin | null; onChange: (pin: MapPin
if (valid) onChange({ x: Number(x.trim()), z: Number(z.trim()) });
}}
>
- Pin a place on the overworld map (for a report)
+ Pin a place in the overworld
setX(e.target.value)} />
setZ(e.target.value)} />
diff --git a/frontend/app/components/admin/MovementMap.tsx b/frontend/app/components/admin/MovementMap.tsx
index b60c1c46..2d0051cb 100644
--- a/frontend/app/components/admin/MovementMap.tsx
+++ b/frontend/app/components/admin/MovementMap.tsx
@@ -152,9 +152,9 @@ function endpointLabels(trail: MovementTrail, mapWorld: string): { sample: Sampl
const lastStretch = shown[shown.length - 1].samples;
const last = lastStretch[lastStretch.length - 1];
const start = first.estimated
- ? `Shown from ${formatClock(first.time)} (estimated position)`
- : `${first.action === ACTION_LOGIN ? "Logged in" : "First observation shown"} ${formatClock(first.time)}`;
- const end = `${last.action === ACTION_LOGOUT ? "Logged out" : "Last observation shown"} ${formatClock(last.time)}`;
+ ? `≈ First shown ${formatClock(first.time)}`
+ : `${first.action === ACTION_LOGIN ? "Logged in" : "First shown"} ${formatClock(first.time)}`;
+ const end = `${last.action === ACTION_LOGOUT ? "Logged out" : "Last shown"} ${formatClock(last.time)}`;
if (first === last) return [{ sample: first, text: start }];
return [
{ sample: first, text: start },
@@ -677,8 +677,8 @@ export default function MovementMap({
) : null}
{latest ? (
- last recorded
- position (once a minute)
+ last
+ position
) : (
<>
@@ -694,10 +694,10 @@ export default function MovementMap({
) : null}
recorded
- estimate or last seen
+ estimated or last seen
- unobserved transition
+ route unknown
logged in
logged out
diff --git a/frontend/app/components/admin/PlayerActivity.tsx b/frontend/app/components/admin/PlayerActivity.tsx
index 1fc02469..54684919 100644
--- a/frontend/app/components/admin/PlayerActivity.tsx
+++ b/frontend/app/components/admin/PlayerActivity.tsx
@@ -37,7 +37,7 @@ export const KIND_LABELS: Record = {
chat: "Chat",
};
-const SOURCE_LABELS: Record = { mmoitems: "MMOItems", mythicmobs: "MythicMobs" };
+const SOURCE_LABELS: Record = { mmoitems: "Custom item", mythicmobs: "Custom mob" };
// Each channel in the colour its in-game tag uses (RPCharacters' chat.yml).
const CHANNEL_COLOURS: Record = {
@@ -49,7 +49,7 @@ function ChannelTag({ channel, inferred = false }: { channel: string; inferred?:
const colour = CHANNEL_COLOURS[channel] ?? "var(--tfmc-mist)";
return (
@@ -322,9 +322,7 @@ export default function PlayerActivity({ uuid }: { uuid: string }) {
Recent activity
- {showsMessages
- ? "From CoreProtect, including chat and whole commands. Your views of these are logged. Sign text is not shown."
- : "From CoreProtect. Chat, command arguments and sign text are not shown."}
+ {showsMessages ? "Includes chat and full commands; sign text is hidden. Views are logged." : "Chat, command details and sign text are hidden."}
{/* One row that scrolls sideways on a phone; wraps where there is room. */}
{
it("opens the newest session and inspects where it ended", async () => {
render();
- await screen.findByText(/Inspecting 14:10:00 · recorded at 30, 64, 0/);
+ await screen.findByText(/14:10:00 · 30, 64, 0/);
expect(nav.state.params.get("session")).toBe("s1");
expect(getSessionMovement).toHaveBeenCalledWith(UUID, "s1");
expect(screen.getAllByText("Logged out 14:10").length).toBeGreaterThan(0);
@@ -109,14 +109,14 @@ it("opens the newest session and inspects where it ended", async () => {
it("steps between observations and explains estimates", async () => {
render();
- await screen.findByText(/recorded at 30, 64, 0/);
- fireEvent.click(screen.getByRole("button", { name: "‹ Previous observation" }));
- await screen.findByText(/Inspecting 14:02:00 · recorded at 20, 64, 0/);
+ await screen.findByText(/14:10:00 · 30, 64, 0/);
+ fireEvent.click(screen.getByRole("button", { name: "‹ Previous" }));
+ await screen.findByText(/14:02:00 · 20, 64, 0/);
act(() => nav.go(`/x?session=s1&at=${T + 90}`));
- await screen.findByText(/estimated between observations at 14:01:00 and 14:02:00: near 15, 0/);
+ await screen.findByText(/about 15, 0 \(estimated between 14:01:00 and 14:02:00\)/);
// Between 14:02 and logout at 14:10 nothing was recorded for longer than a ping gap.
act(() => nav.go(`/x?session=s1&at=${T + 400}`));
- await screen.findByText(/not observed \(offline, or not recorded\); last observation 14:02:00/);
+ await screen.findByText(/not seen; last seen 14:02:00/);
});
it("applies a typed range and refuses times the clocks skip", async () => {
@@ -125,7 +125,7 @@ it("applies a typed range and refuses times the clocks skip", async () => {
coreprotect: status,
});
render();
- await screen.findByText(/recorded at 30/);
+ await screen.findByText(/· 30, 64, 0/);
fireEvent.click(screen.getByRole("tab", { name: "Time range" }));
fireEvent.change(screen.getByLabelText("From"), { target: { value: "2026-03-29T01:30" } });
fireEvent.click(screen.getByRole("button", { name: "Apply range" }));
@@ -136,7 +136,7 @@ it("applies a typed range and refuses times the clocks skip", async () => {
fireEvent.click(screen.getByRole("button", { name: "Apply range" }));
await waitFor(() => expect(getPlayerMovement).toHaveBeenCalledWith(UUID, T - 3600, T));
expect(nav.state.params.get("session")).toBeNull();
- await screen.findByText(/No observations between Tue 6 Oct 2026, 13:00 → 14:00/);
+ await screen.findByText(/Nothing recorded between Tue 6 Oct 2026, 13:00 → 14:00/);
});
it("tells non-admins the page is not for them", async () => {
@@ -147,7 +147,7 @@ it("tells non-admins the page is not for them", async () => {
it("frames a new session only once its own answer is in", async () => {
render();
- await screen.findByText(/recorded at 30, 64, 0/);
+ await screen.findByText(/14:10:00 · 30, 64, 0/);
expect(screen.getByTestId("map").dataset.fit).toBe("session:s1");
let answer: (value: unknown) => void = () => undefined;
vi.mocked(getSessionMovement).mockReturnValueOnce(new Promise((resolve) => (answer = resolve)) as never);
diff --git a/frontend/app/components/admin/PlayerMovementPage.tsx b/frontend/app/components/admin/PlayerMovementPage.tsx
index 2281f5e4..bb85106f 100644
--- a/frontend/app/components/admin/PlayerMovementPage.tsx
+++ b/frontend/app/components/admin/PlayerMovementPage.tsx
@@ -1,6 +1,5 @@
"use client";
-import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -299,12 +298,9 @@ export default function PlayerMovementPage({ uuid }: { uuid: string }) {
panel={
<>
- {name ?? "…"} · movement
+ {name ?? "…"}
-
- {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.
-
+
Lines between positions are estimates. Views are logged.
{view ? : null}
{notice ?
{notice}
: null}
{load.kind === "failed" ? (
@@ -380,7 +376,7 @@ export default function PlayerMovementPage({ uuid }: { uuid: string }) {
since={since}
until={until}
unknownUntil={unknownUntil}
- unknownLabel={view.completeFrom > since ? "earlier observations omitted" : "before available position observations"}
+ unknownLabel={view.completeFrom > since ? "too many to show" : "login and logout locations only"}
bands={observedBands(inWindow).map(([a, b]) => ({ from: a, to: b }))}
cursor={cursor}
onCursor={setCursor}
@@ -398,7 +394,7 @@ export default function PlayerMovementPage({ uuid }: { uuid: string }) {
{!view.points.length ? (
- No observations between {describeSpan(since, until)}.{" "}
+ Nothing recorded between {describeSpan(since, until)}.{" "}
{mode === "range" ? (
) : (
- Estimated observed time in the span shown: {formatDuration(observed).toLowerCase()}
+ Seen for about {formatDuration(observed).toLowerCase()} in this range
{byWorld.length > 1
? ` (${byWorld.map(([world, s]) => `${formatDuration(s).toLowerCase()} in ${worldLabel(world)}`).join(", ")})`
: byWorld.length === 1 && byWorld[0][0] !== mapWorld
@@ -416,25 +412,18 @@ export default function PlayerMovementPage({ uuid }: { uuid: string }) {
.
)}
- {onlyEdges ?
Login and logout locations only; the route is unavailable for this span.
: null}
+ {onlyEdges ?
Route unavailable; only where they logged in and out.
: null}
{view.completeFrom > since ? (
-
Earlier observations omitted: too many to show, so the route starts at {formatMoment(view.completeFrom)}.
+
Too many positions to show; the route starts at {formatMoment(view.completeFrom)}.
- Before {formatMoment(view.pingsSince)} there are no available position observations, only login and
- logout places.
+ Before {formatMoment(view.pingsSince)}, only login and logout locations.
) : null}
>
) : null}
-
- ← Back to the profile
-
>
}
/>
@@ -488,7 +477,7 @@ function Readout({
cursor: number;
mapWorld: string;
}) {
- const when = `Inspecting ${formatClock(cursor, true)}`;
+ const when = formatClock(cursor, true);
const where = (world: string | null) => (world === mapWorld ? "" : ` in ${worldLabel(world)}`);
switch (found.kind) {
case "observed": {
@@ -496,7 +485,7 @@ function Readout({
return (
- {when} · recorded at {s.x}, {s.y}, {s.z}
+ {when} · {s.x}, {s.y}, {s.z}
{where(found.world)}
{found.world === mapWorld ? : null}
@@ -506,15 +495,15 @@ function Readout({
case "estimated":
return (
- {when} · estimated between observations at {formatClock(found.before.time, true)} and{" "}
- {formatClock(found.after.time, true)}: near {Math.round(found.x)}, {Math.round(found.z)}
+ {when} · about {Math.round(found.x)}, {Math.round(found.z)} (estimated between{" "}
+ {formatClock(found.before.time, true)} and {formatClock(found.after.time, true)})
{where(found.world)}
);
case "unobserved":
return (
- {when} · position unknown: an unobserved transition from {found.before.x}, {found.before.z} (
+ {when} · unknown; moved from {found.before.x}, {found.before.z} (
{formatClock(found.before.time, true)}) to {found.after.x}, {found.after.z} ({formatClock(found.after.time, true)})
{where(found.world)}
@@ -522,7 +511,7 @@ function Readout({
case "stale":
return (
- {when} · position unknown; last observed {formatDuration(cursor - found.before.time).toLowerCase()} earlier at{" "}
+ {when} · unknown; last seen {formatDuration(cursor - found.before.time).toLowerCase()} earlier at{" "}
{found.before.x}, {found.before.z}
{where(found.world)}
@@ -530,8 +519,8 @@ function Readout({
default:
return (
- {when} · not observed (offline, or not recorded)
- {found.before ? `; last observation ${formatClock(found.before.time, true)}` : ""}.
+ {when} · not seen
+ {found.before ? `; last seen ${formatClock(found.before.time, true)}` : ""}.
);
}
diff --git a/frontend/app/components/admin/PlayerProfile.test.tsx b/frontend/app/components/admin/PlayerProfile.test.tsx
index 62160f71..2c590aae 100644
--- a/frontend/app/components/admin/PlayerProfile.test.tsx
+++ b/frontend/app/components/admin/PlayerProfile.test.tsx
@@ -135,7 +135,7 @@ it("groups sessions by day and hedges ones without a logout", async () => {
expect(within(sessions).getByText(/^No logout recorded · last seen \d\d:\d\d$/)).toBeTruthy();
expect(within(sessions).queryByText(/Logged out/)).toBeNull();
expect(within(sessions).queryByRole("link")).toBeNull();
- expect(within(sessions).getByText(/^Retained session records start/)).toBeTruthy();
+ expect(within(sessions).getByText(/^Server session history starts/)).toBeTruthy();
});
it("links each session to its route for those who may see movement", async () => {
@@ -185,7 +185,7 @@ it("reports CoreProtect problems per section", async () => {
nav.query = "tab=sessions";
render();
const sessions = await screen.findByRole("region", { name: "Sessions" });
- expect(await within(sessions).findByText(/CoreProtect took too long/)).toBeTruthy();
+ expect(await within(sessions).findByText(/Activity history took too long/)).toBeTruthy();
});
it.each([
@@ -228,7 +228,7 @@ it("keeps rows and the cursor when a later page is unavailable", async () => {
entries: [], next: null, searched_to: null, kinds: KINDS, shows_messages: false, coreprotect: { status: "unavailable", reason: "busy" },
});
fireEvent.click(within(feed).getByRole("button", { name: "Load more" }));
- expect(await within(feed).findByText(/CoreProtect is busy/)).toBeTruthy();
+ expect(await within(feed).findByText(/Activity history is busy/)).toBeTruthy();
expect(within(feed).getByText("stone")).toBeTruthy();
vi.mocked(getPlayerActivity).mockResolvedValueOnce({
@@ -239,7 +239,7 @@ it("keeps rows and the cursor when a later page is unavailable", async () => {
expect(await within(feed).findByText("oak_door")).toBeTruthy();
expect(getPlayerActivity).toHaveBeenLastCalledWith(UUID, { before: "cursor-1", kinds: undefined });
expect(within(feed).getByText("stone")).toBeTruthy();
- expect(within(feed).queryByText(/CoreProtect is busy/)).toBeNull();
+ expect(within(feed).queryByText(/Activity history is busy/)).toBeNull();
});
it("retries a failed sessions page from the same cursor", async () => {
@@ -258,7 +258,7 @@ it("retries a failed sessions page from the same cursor", async () => {
fireEvent.click(await within(sessions).findByRole("button", { name: "Load more sessions" }));
expect(await within(sessions).findByText("That page link has expired. Reload to start again.")).toBeTruthy();
expect(within(sessions).getByText("End unknown")).toBeTruthy();
- expect(within(sessions).queryByText(/Retained session records/)).toBeNull();
+ expect(within(sessions).queryByText(/Server session history starts/)).toBeNull();
fireEvent.click(within(sessions).getByRole("button", { name: "Try again" }));
await waitFor(() => expect(getPlayerSessions).toHaveBeenLastCalledWith(UUID, "s-1"));
});
@@ -277,7 +277,7 @@ it("shows chat and whole commands to admins, and says views are logged", async (
expect(within(feed).getByText("/msg Bob hello")).toBeTruthy();
expect(within(feed).getByText("(cut short)")).toBeTruthy();
expect(within(feed).getByRole("button", { name: "Chat" })).toBeTruthy();
- expect(within(feed).getByText(/Your views of these are logged/)).toBeTruthy();
+ expect(within(feed).getByText(/Views are logged/)).toBeTruthy();
});
it("tags chat with its channel, and tells moderators only the channel", async () => {
@@ -301,7 +301,7 @@ it("tags chat with its channel, and tells moderators only the channel", async ()
it("keeps moderators' note when messages are not included", async () => {
render();
const feed = await screen.findByRole("region", { name: "Recent activity" });
- expect(await within(feed).findByText(/Chat, command arguments and sign text are not shown/)).toBeTruthy();
+ expect(await within(feed).findByText(/Chat, command details and sign text are hidden/)).toBeTruthy();
expect(within(feed).queryByRole("button", { name: "Chat" })).toBeNull();
});
@@ -345,11 +345,11 @@ it("names things properly, merges block runs and calls the world by its name", a
expect(within(feed).getByText(/4052, 64, 2$/)).toBeTruthy();
expect(within(feed).getByText("Mouse Plush")).toBeTruthy();
- expect(within(feed).getByText("MMOItems")).toBeTruthy();
+ expect(within(feed).getByText("Custom item")).toBeTruthy();
expect(within(feed).queryByText(/MISC:MOUSE_PLUSH/)).toBeNull();
const plush = within(feed).getAllByRole("button", { name: /Details/ })[0];
fireEvent.click(plush);
- expect(within(feed).getByText("MMOItems id MISC:MOUSE_PLUSH")).toBeTruthy();
+ expect(within(feed).getByText("Custom item id MISC:MOUSE_PLUSH")).toBeTruthy();
expect(within(feed).getByText("Based on Paper")).toBeTruthy();
expect(within(feed).getByText("“Fang”")).toBeTruthy();
diff --git a/frontend/app/components/admin/PlayerProfile.tsx b/frontend/app/components/admin/PlayerProfile.tsx
index e3bc41da..4a3ac549 100644
--- a/frontend/app/components/admin/PlayerProfile.tsx
+++ b/frontend/app/components/admin/PlayerProfile.tsx
@@ -139,7 +139,7 @@ export default function PlayerProfile({ uuid }: { uuid: string }) {
`@${profile.discord.discord_username}`
) : (
- Handle not known yet (filled in when they sign in to the website) · ID {profile.discord.discord_user_id}
+ Not known yet · ID {profile.discord.discord_user_id}
)}
@@ -310,7 +310,7 @@ function Sessions({ uuid, movement }: { uuid: string; movement: boolean }) {
) : rows.length && historyStart && state === "ready" ? (
// The oldest session row CoreProtect still holds for anyone, not this player's first session.
-
Retained session records start {formatDate(historyStart)}.
+
Server session history starts {formatDate(historyStart)}.
) : null}
);
diff --git a/frontend/app/components/admin/PlayersDirectory.test.tsx b/frontend/app/components/admin/PlayersDirectory.test.tsx
index 27010158..55c3ae1e 100644
--- a/frontend/app/components/admin/PlayersDirectory.test.tsx
+++ b/frontend/app/components/admin/PlayersDirectory.test.tsx
@@ -94,7 +94,7 @@ it("lists everyone under Minecraft by default", async () => {
expect(within(screen.getByRole("link", { name: "Justin" }).closest("tr")!).queryByText("Not linked")).toBeNull();
expect(within(screen.getByRole("link", { name: "Quiet" }).closest("tr")!).getByText("Seen just now")).toBeTruthy();
expect(within(screen.getByRole("link", { name: "22222222-2222-2222-2222-222222222222" }).closest("tr")!).getByText("Never")).toBeTruthy();
- expect(screen.getByText("243 players · activity from Vardera")).toBeTruthy();
+ expect(screen.getByText("243 players")).toBeTruthy();
expect(screen.queryByRole("tab", { name: "Activity" })).toBeNull();
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["Minecraft", "Discord", "Character"]);
expect(screen.getByRole("tab", { name: "Minecraft" }).getAttribute("aria-selected")).toBe("true");
@@ -146,7 +146,7 @@ it("leads the Discord table with the handle and counts unlinked players", async
expect(lead(row)).toContain("@hazelstone");
expect(lead(row)).toContain("Enzo");
expect(within(row).getByText("2 h ago")).toBeTruthy();
- expect(screen.getByText("1 linked player · activity from Vardera")).toBeTruthy();
+ expect(screen.getByText("1 linked player")).toBeTruthy();
expect(screen.getByText(/34 players without a Discord link aren’t listed here/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "See everyone under Minecraft" }));
@@ -257,7 +257,7 @@ it("says when CoreProtect cannot be read, and that last seen is unknown", async
}),
});
renderDirectory();
- expect(await screen.findByText(/CoreProtect is busy.*Only linked players and characters are listed/)).toBeTruthy();
+ expect(await screen.findByText(/Activity history is busy.*Only linked players and characters are listed/)).toBeTruthy();
expect(within(screen.getByRole("link", { name: "Linked" }).closest("tr")!).getByText("Unknown")).toBeTruthy();
});
diff --git a/frontend/app/components/admin/PlayersDirectory.tsx b/frontend/app/components/admin/PlayersDirectory.tsx
index d752b156..09fb894b 100644
--- a/frontend/app/components/admin/PlayersDirectory.tsx
+++ b/frontend/app/components/admin/PlayersDirectory.tsx
@@ -287,7 +287,7 @@ function countLine(data: PlayerDirectory): string {
: n === 1 ? "1 player" : `${n} players`;
const source = data.coreprotect.server_label;
if (!source) return counted;
- return data.view === "character" ? `${counted} on ${source}` : `${counted} · activity from ${source}`;
+ return data.view === "character" ? `${counted} on ${source}` : counted;
}
export default function PlayersDirectory({ initialQuery, initialListing, initialPage }: Props) {
@@ -398,7 +398,7 @@ export default function PlayersDirectory({ initialQuery, initialListing, initial
setQuery(e.target.value);
setPage(1);
}}
- placeholder="Search by Minecraft, Discord (handle or nickname) or character name, or UUID"
+ placeholder="Minecraft, Discord or character name, or UUID"
autoComplete="off"
spellCheck={false}
className={inputClass}
diff --git a/frontend/app/components/admin/RailMap.tsx b/frontend/app/components/admin/RailMap.tsx
index a9f6e6d3..e8aa78ce 100644
--- a/frontend/app/components/admin/RailMap.tsx
+++ b/frontend/app/components/admin/RailMap.tsx
@@ -257,7 +257,7 @@ export default function RailMap({ mapId, network, highlight = null, focus = null
stroke={colour}
strokeWidth={2.5 * unit}
>
- {`${label}: ${blocks(s.to - s.from)}, ${Math.round(s.from).toLocaleString("en-GB")} blocks along`}
+ {`${label}: ${blocks(s.to - s.from)}`}
))
@@ -310,9 +310,7 @@ export default function RailMap({ mapId, network, highlight = null, focus = null
const capital = stop.kind === "faction_capital";
return (
- {`${stop.name}${capital ? " (faction capital)" : ""}\n${Math.round(stop.along).toLocaleString(
- "en-GB"
- )} blocks along the track · settlement ${blocks(stop.distance)} away`}
+ {`${stop.name}${capital ? " (faction capital)" : ""}\nSettlement ${blocks(stop.distance)} away`} {
render();
expect(await screen.findByText("Thalenthyr – Drammen")).toBeTruthy();
expect(getRailNetwork).toHaveBeenCalledWith("main");
- expect(screen.getByText(/last saved them, at Wed 7 Oct 2026, 20:30/)).toBeTruthy();
+ expect(screen.getByText(/Updated Wed 7 Oct 2026, 20:30/)).toBeTruthy();
expect(screen.getByText("Broken: 22 blocks")).toBeTruthy();
expect(screen.getByText("capital · 0, 0")).toBeTruthy();
expect(screen.getByText("Unnamed line")).toBeTruthy();
@@ -88,7 +88,7 @@ it("says when nothing needs repair", async () => {
it("explains a site that cannot read the tracks", async () => {
vi.mocked(getRailNetwork).mockResolvedValue({ ...NETWORK, status: "not_configured", lines: [], tracks: [], stops: [] });
render();
- expect(await screen.findByText("This site isn’t set up to read the rail network yet.")).toBeTruthy();
+ expect(await screen.findByText("Rail data isn’t available on this site.")).toBeTruthy();
expect(screen.queryByTestId("map")).toBeNull();
});
diff --git a/frontend/app/components/admin/RailOverview.tsx b/frontend/app/components/admin/RailOverview.tsx
index 6d7507b9..50403699 100644
--- a/frontend/app/components/admin/RailOverview.tsx
+++ b/frontend/app/components/admin/RailOverview.tsx
@@ -125,15 +125,11 @@ export default function RailOverview() {
panel={
<>
- The tracks as VehicleFramework last saved them
- {data.updated_at ? `, at ${formatMoment(data.updated_at)}` : ""}. A stop is a settlement whose provinces a
- track crosses; it sits where the track comes closest to the settlement.
+ {data.updated_at ? `Updated ${formatMoment(data.updated_at)}. ` : ""}Stops are where a track passes closest to a
+ settlement.
{data.unreadable_files ? (
-
- {data.unreadable_files} track {data.unreadable_files === 1 ? "file" : "files"} couldn’t be read, probably
- mid-save. Reload in a moment.
-
+
Some track couldn’t be loaded. Reload in a moment.
) : null}
{problems.length ? (
diff --git a/frontend/app/components/admin/ranks/RankGroup.tsx b/frontend/app/components/admin/ranks/RankGroup.tsx
index 0d73f906..ff613e4f 100644
--- a/frontend/app/components/admin/ranks/RankGroup.tsx
+++ b/frontend/app/components/admin/ranks/RankGroup.tsx
@@ -1,6 +1,5 @@
"use client";
-import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { AccountApiError } from "../../../../lib/account/api";
@@ -158,9 +157,7 @@ export default function RankGroup({ name }: { name: string }) {
Players in {group.name}{" "}
· {(members?.total ?? group.members).toLocaleString()}
-
- Players with a direct {group.name} entry, in any context. Chips show their other directly held groups.
-
+
Direct members only. Chips show their other groups.
{members ? (
{group.name}.
>
),
- warning: `${group.members} players hold it directly and keep a dangling entry; ${group.children.length} groups inherit it. Remove it from tracks first.`,
+ warning: `${group.members} players hold it directly and ${group.children.length} groups inherit it; deleting leaves its entries on those players. Remove it from tracks first.`,
ops: [{ op: "delete_group" }],
label: "Delete group",
})
@@ -212,11 +209,6 @@ export default function RankGroup({ name }: { name: string }) {
)}
) : null}
-
-
- ← All groups
-
-
>
);
}
@@ -330,7 +322,7 @@ function NodesPanel({
Permissions and settings
- {nodes.length} nodes
+ {nodes.length} entries
{nodes.length > 8 ? (
{
const groups = await screen.findByRole("region", { name: "Groups" });
fireEvent.change(within(groups).getByLabelText("Add to group"), { target: { value: "noble" } });
fireEvent.click(within(groups).getByRole("button", { name: "Add" }));
- expect(within(groups).getByText(/Patreon-managed/)).toBeTruthy();
+ expect(within(groups).getByText(/Patreon may reset/)).toBeTruthy();
fireEvent.change(within(groups).getByLabelText("Reason (recorded)"), { target: { value: "Gift" } });
fireEvent.click(within(groups).getAllByRole("button", { name: "Add" }).at(-1)!);
expect(await within(groups).findByText(/No server is applying rank changes/)).toBeTruthy();
diff --git a/frontend/app/components/admin/ranks/RankPlayer.tsx b/frontend/app/components/admin/ranks/RankPlayer.tsx
index a2d30f18..54f310c7 100644
--- a/frontend/app/components/admin/ranks/RankPlayer.tsx
+++ b/frontend/app/components/admin/ranks/RankPlayer.tsx
@@ -25,7 +25,7 @@ import { buttonClass, chipClass, headingClass, inputClass, mutedClass, panelClas
/** Servers that share this LuckPerms storage; a node may apply to just one. */
export const SERVERS = ["main", "dev", "tutorial"] as const;
const PATREON_NOTE =
- "Patreon-managed: for a linked patron, the Patreon sync resets permanent all-server grants of this group within 30 minutes.";
+ "For linked patrons, Patreon may reset permanent all-server changes to this group within 30 minutes.";
type Load = { kind: "loading" } | { kind: GateKind } | { kind: "failed"; message: string } | { kind: "ready"; data: LpPlayer };
@@ -203,7 +203,7 @@ export default function RankPlayer({ uuid }: { uuid: string }) {
Groups
{player.inherits.length ? (
-
With inheritance, everywhere: {player.inherits.join(", ")}.
+
Across all servers, including inherited: {player.inherits.join(", ")}.
) : null}
{groupNodes.length ? (
@@ -435,7 +435,7 @@ function PermissionsPanel({
Permissions
- {nodes.length} of their own; groups add more
+ {nodes.length} set directly
{nodes.length > 8 ? (
-
+
{/* Side by side on wide screens: neither needs the full width. */}
@@ -58,7 +58,7 @@ export default function RanksOverview({ initialQuery = "" }: { initialQuery?: st
);
}
-function PlayerSearch({ initialQuery, total }: { initialQuery: string; total: number }) {
+function PlayerSearch({ initialQuery }: { initialQuery: string }) {
const [query, setQuery] = useState(initialQuery);
const [page, setPage] = useState(null);
const [busy, setBusy] = useState(false);
@@ -106,7 +106,7 @@ function PlayerSearch({ initialQuery, total }: { initialQuery: string; total: nu
id="ranks-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
- placeholder="Find a player: Minecraft name or UUID"
+ placeholder="Minecraft name or UUID"
autoComplete="off"
spellCheck={false}
className={`${inputClass} min-w-0 flex-1 basis-64`}
@@ -115,7 +115,7 @@ function PlayerSearch({ initialQuery, total }: { initialQuery: string; total: nu
{busy ? "Searching…" : "Find"}
- {total.toLocaleString()} players have something set in LuckPerms. Part of a name is enough.
+ Part of a name is enough.
- Heaviest first: a player shows the rank of the heaviest group they inherit. Player counts are direct assignments only.
+ Highest weight first. A player shows the rank of their highest-weight group. Player counts are direct members only.
{creating ? (
diff --git a/frontend/app/components/admin/ranks/parts.tsx b/frontend/app/components/admin/ranks/parts.tsx
index f60ed9f0..e24897e8 100644
--- a/frontend/app/components/admin/ranks/parts.tsx
+++ b/frontend/app/components/admin/ranks/parts.tsx
@@ -16,25 +16,29 @@ function seconds(stamp: string | null): number | null {
return stamp ? Date.parse(stamp) / 1000 : null;
}
-/** Where the data comes from, how fresh it is, and whether changes can be made. */
+/** Says only when something is off: no data yet, stale data, or changes that can't be made. */
export function StatusLine({ status, rights }: { status: LpStatus; rights: LpRights }) {
if (!status.has_snapshot) {
return (
- No server has sent LuckPerms data to this site yet. It appears once TFMCWeb’s LuckPerms bridge is on.
+ Ranks aren’t available yet.
);
}
const checked = seconds(status.checked_at);
const stale = checked !== null && Date.now() / 1000 - checked > 180;
+ const notes = [
+ stale ? `Last checked ${formatAgo(checked).toLowerCase()}.` : null,
+ rights.read_only
+ ? "View only. Change ranks on the main site."
+ : status.applying
+ ? null
+ : "Rank changes are unavailable right now.",
+ ].filter(Boolean);
+ if (!notes.length) return null;
return (
- LuckPerms from {status.server ?? "the server"}, checked {formatAgo(checked).toLowerCase()}.{" "}
- {rights.read_only
- ? "This site only shows ranks; change them on the main site."
- : status.applying
- ? "Changes apply within seconds."
- : "No server is applying changes right now, so nothing can be changed."}
+ {notes.join(" ")}
);
}
diff --git a/frontend/lib/admin/api.ts b/frontend/lib/admin/api.ts
index 89e0f93d..02db3dd3 100644
--- a/frontend/lib/admin/api.ts
+++ b/frontend/lib/admin/api.ts
@@ -60,7 +60,7 @@ const ERROR_MESSAGES: Record = {
player_not_found: "No player with that UUID has been seen.",
bad_uuid: "That isn’t a Minecraft UUID.",
bad_session: "That session link isn’t valid.",
- session_gone: "That session is no longer in CoreProtect’s records.",
+ session_gone: "That session is no longer available.",
bad_window: "Choose a time range of up to 7 days for one player, or 24 hours for everyone.",
bad_cursor: "That page link has expired. Reload to start again.",
bad_kinds: "That filter isn’t available.",
@@ -296,12 +296,12 @@ export type ActivityPage = {
};
const COREPROTECT_REASONS: Record = {
- not_configured: "CoreProtect isn’t connected to this site.",
- missing: "The CoreProtect database couldn’t be found.",
- cannot_open: "The CoreProtect database couldn’t be opened.",
- busy: "CoreProtect is busy. Try again in a moment.",
- timeout: "CoreProtect took too long to answer. Try again in a moment.",
- error: "CoreProtect couldn’t be read.",
+ not_configured: "Activity history isn’t available on this site.",
+ missing: "Activity history isn’t available right now.",
+ cannot_open: "Activity history isn’t available right now.",
+ busy: "Activity history is busy. Try again in a moment.",
+ timeout: "Activity history took too long. Try again in a moment.",
+ error: "Activity history isn’t available right now.",
};
export function coreProtectMessage(status: CoreProtectStatus): string | null {
diff --git a/frontend/lib/admin/luckperms.ts b/frontend/lib/admin/luckperms.ts
index 10e4e8f7..37efcfeb 100644
--- a/frontend/lib/admin/luckperms.ts
+++ b/frontend/lib/admin/luckperms.ts
@@ -221,8 +221,8 @@ export const LP_ERRORS: Record = {
bad_group_name: "Use lowercase letters, numbers, _, + or - for group names.",
bad_track_name: "Use lowercase letters, numbers, _, + or - for track names.",
bad_track_groups: "List each group once.",
- player_not_found: "LuckPerms has no record of that player.",
- save_failed: "LuckPerms couldn’t save the change.",
+ player_not_found: "No rank record found for that player.",
+ save_failed: "The rank change couldn’t be saved.",
no_result: "The server never reported back.",
no_longer_allowed: "Cancelled: it was no longer allowed when the server picked it up (rights or groups changed).",
expired: "The server didn’t pick it up in time.",
diff --git a/frontend/lib/admin/movement.test.ts b/frontend/lib/admin/movement.test.ts
index 5c2f1987..1adf554f 100644
--- a/frontend/lib/admin/movement.test.ts
+++ b/frontend/lib/admin/movement.test.ts
@@ -225,9 +225,9 @@ describe("session labels", () => {
it("says only what the record shows", () => {
expect(sessionEndLabel(session("logout", at(14, 15), at(14, 15)))).toBe("Logged out 14:15");
- expect(sessionEndLabel(session("open", null, at(14, 14)))).toBe("Probably online · last observed 14:14");
+ expect(sessionEndLabel(session("open", null, at(14, 14)))).toBe("Probably online · last seen 14:14");
expect(sessionEndLabel(session("last_observed", at(14, 15), at(14, 15)))).toBe(
- "Last observed 14:15 · logout not recorded"
+ "Last seen 14:15 · no logout recorded"
);
expect(sessionEndLabel(session("unknown", null, at(13, 2)))).toBe("End unknown");
expect(sessionSpanLabel(session("logout", at(14, 15), at(14, 15)))).toBe("13:02 → 14:15");
diff --git a/frontend/lib/admin/movement.ts b/frontend/lib/admin/movement.ts
index cc210831..6216d89c 100644
--- a/frontend/lib/admin/movement.ts
+++ b/frontend/lib/admin/movement.ts
@@ -508,9 +508,9 @@ export function sessionEndLabel(session: PlayerSession): string {
case "logout":
return `Logged out ${formatClock(session.end!.time)}`;
case "open":
- return `Probably online · last observed ${formatClock(session.last_observed.time)}`;
+ return `Probably online · last seen ${formatClock(session.last_observed.time)}`;
case "last_observed":
- return `Last observed ${formatClock(session.last_observed.time)} · logout not recorded`;
+ return `Last seen ${formatClock(session.last_observed.time)} · no logout recorded`;
default:
return "End unknown";
}
diff --git a/frontend/lib/admin/rail.ts b/frontend/lib/admin/rail.ts
index d16dbb99..35fb69e8 100644
--- a/frontend/lib/admin/rail.ts
+++ b/frontend/lib/admin/rail.ts
@@ -67,8 +67,8 @@ export function lineColour(line: RailLine | undefined): string {
}
export function statusMessage(status: RailStatus): string | null {
- if (status === "not_configured") return "This site isn’t set up to read the rail network yet.";
- if (status === "missing") return "The rail network’s files can’t be found on the server.";
+ if (status === "not_configured") return "Rail data isn’t available on this site.";
+ if (status === "missing") return "Rail data isn’t available right now.";
return null;
}