Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ backend/src/data/*.db-shm

# Test-data generators must never reach a production image.
backend/src/scripts/tools/generate_spoof_*.py

# Built inside the image from the pinned Mojang jar instead.
backend/src/coreprotect/data
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ backend/src/data/skins/**
.claude/
backend/src/defines/*/derived_sources.json
CLAUDE.md

# Vanilla Minecraft names, built from Mojang's client jar (backend/scripts/build_minecraft_names.py).
backend/src/coreprotect/data/
4 changes: 4 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ RUN npm ci --ignore-scripts \
&& npx playwright install-deps chromium

WORKDIR /app
# Vanilla names for the staff activity list, from Mojang's jar (not ours to
# commit). Only re-runs when the script, and so the pinned version, changes.
COPY backend/scripts/build_minecraft_names.py /tmp/
RUN python3 /tmp/build_minecraft_names.py /app/src/coreprotect/data/minecraft_names.json
COPY backend/ .
# constants.py resolves ProvinceSystem/shared via parents[4] → /shared/...
COPY shared /shared
Expand Down
60 changes: 60 additions & 0 deletions backend/scripts/build_minecraft_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Build the vanilla block, item and entity names the staff activity list shows.

Mojang's language file is not ours to publish, so the repository keeps only
this script; the backend image runs it at build time. It downloads the pinned
client jar, checks its SHA-1, and keeps the plain `block.minecraft.<id>`,
`item.minecraft.<id>` and `entity.minecraft.<id>` names from `en_us.json`
(about 94 KB). Without the file, names fall back to tidied ids.

python3 scripts/build_minecraft_names.py src/coreprotect/data/minecraft_names.json

Bump VERSION, JAR_URL and JAR_SHA1 together when the server moves to a new
Minecraft version (the version manifest lists the client jar's URL and SHA-1).
"""
from __future__ import annotations

import hashlib
import io
import json
import re
import sys
import urllib.request
import zipfile
from pathlib import Path

VERSION = "1.21.10"
JAR_URL = "https://piston-data.mojang.com/v1/objects/d3bdf582a7fa723ce199f3665588dcfe6bf9aca8/client.jar"
JAR_SHA1 = "d3bdf582a7fa723ce199f3665588dcfe6bf9aca8"
LANG = "assets/minecraft/lang/en_us.json"

_KEY = re.compile(r"(block|item|entity)\.minecraft\.([a-z0-9_]+)")


def trim(lang: dict[str, str]) -> dict:
out: dict = {"version": VERSION, "block": {}, "item": {}, "entity": {}}
for key, name in lang.items():
match = _KEY.fullmatch(key)
if match:
out[match[1]][match[2]] = name
for kind in ("block", "item", "entity"):
out[kind] = dict(sorted(out[kind].items()))
return out


def main(target: str) -> None:
with urllib.request.urlopen(JAR_URL, timeout=120) as response:
jar = response.read()
digest = hashlib.sha1(jar).hexdigest()
if digest != JAR_SHA1:
raise SystemExit(f"client jar SHA-1 {digest} is not the pinned {JAR_SHA1}")
with zipfile.ZipFile(io.BytesIO(jar)) as archive:
lang = json.loads(archive.read(LANG))
path = Path(target)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(trim(lang), ensure_ascii=False, separators=(",", ":")), encoding="utf-8")


if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit(f"usage: {sys.argv[0]} OUTPUT.json")
main(sys.argv[1])
11 changes: 8 additions & 3 deletions backend/src/auth/players.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,15 +472,20 @@ def player_activity(config: CoreProtectConfig, text: str, before: str | None, li
raw = activity.fetch(reader, maps, ids, chosen, cursor, limit, full_text=full) if ids else None
except Unavailable as exc:
return {"entries": [], "next": None, "searched_to": None, "kinds": list(allowed),
"shows_messages": full, "coreprotect": _status(exc)}
"shows_messages": full, "coreprotect": _world_status(config, exc)}
if raw is None:
return {"entries": [], "next": None, "searched_to": None, "kinds": list(allowed),
"shows_messages": full, "coreprotect": _status(None)}
"shows_messages": full, "coreprotect": _world_status(config, None)}
built = activity.build(raw, maps, f"{config.server}:{key}")
# CoreProtect is closed by now; the view is on record before the response leaves.
if full:
_audit_messages(viewer, config, key, chosen, before, built["entries"])
return {**built, "kinds": list(allowed), "shows_messages": full, "coreprotect": _status(None)}
return {**built, "kinds": list(allowed), "shows_messages": full, "coreprotect": _world_status(config, None)}


def _world_status(config: CoreProtectConfig, error: Unavailable | None) -> dict:
"""The status, plus what to call the map's world (`TFMC_Map` is Vardera to players)."""
return {**_status(error), "server_label": config.label or None, "map_world": config.map_world}



Expand Down
3 changes: 2 additions & 1 deletion backend/src/auth/test_players.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,8 @@ def test_unknown_player_sections_are_empty(app, env, world, monkeypatch):
assert body == {"sessions": [], "next": None, "coreprotect": {"status": "available"}}
monkeypatch.setenv("COREPROTECT_DB", str(env.DATA_DIR / "nowhere.db"))
body = c.get(f"/admin/players/{HAZEL}/activity").json()
assert body["coreprotect"] == {"status": "unavailable", "reason": "missing"}
assert body["coreprotect"] == {"status": "unavailable", "reason": "missing",
"server_label": "Vardera", "map_world": "TFMC_Map"}


def message_audits(db):
Expand Down
14 changes: 14 additions & 0 deletions backend/src/coreprotect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@ kills from the custom-identity CoreProtect build additionally show the MythicMob
ID from the `coreprotect:mythic` block-metadata marker. No history rewrite is
needed. Custom-ID search and ItemsAdder block identity are outside this change.

Each activity entry also carries `target_info`: the name the panel shows
(`Short Grass`, not `short_grass`), where it came from (vanilla, MMOItems,
MythicMobs, a player) and the ids behind it. Vanilla names come from
Minecraft's own `en_us.json`, which is Mojang's to publish, not ours: the
backend image runs `scripts/build_minecraft_names.py` to fetch the pinned
client jar, check its SHA-1 and keep the block, item and entity names
(about 94 KB) in `data/minecraft_names.json`, which git ignores. Bump the
pinned version there when the server updates. Without the file, ids are
tidied instead. Block changes use block names (`wheat` is "Wheat Crops"),
items use item names ("Wheat"); only `minecraft:` ids are looked up.
Activity responses also return `server_label` and `map_world`, so the panel
calls the map's world by its name (Vardera). The panel merges consecutive
changes to the same block within a minute into one row; the API does not.

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
Expand Down
60 changes: 49 additions & 11 deletions backend/src/coreprotect/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from dataclasses import dataclass

from . import cursors, metadata
from . import cursors, metadata, names
from .maps import Maps
from .reader import Reader

Expand Down Expand Up @@ -291,7 +291,35 @@ def _rollback(state: int | None) -> str | None:
return _ROLLBACK.get(state, f"rollback state {state}")


def _material(maps: Maps, type_id: int | None, order: tuple[str, ...]) -> dict:
return names.describe(maps.materials.get(type_id or 0), order, f"Unknown material #{type_id}")


def _mob(maps: Maps, type_id: int | None) -> dict:
return names.describe(maps.entities.get(type_id or 0), names.ENTITY, f"Unknown entity #{type_id}")


def _item_target(row: dict, maps: Maps) -> dict:
base = _material(maps, row["type"], names.ITEM)
identity, name = metadata.item_identity(row.get("identity_blob"))
if identity:
title = identity.partition(":")[2].replace("_", " ").title()
return {**base, "name": name or title, "source": "mmoitems", "source_id": identity,
"vanilla_name": base["name"]}
return {**base, "custom_name": name or None}


def _mob_target(row: dict, maps: Maps) -> dict:
base = _mob(maps, row["type"])
identity, name = metadata.mob_identity(row.get("entity_blob"), row.get("identity_blob"))
if identity:
return {**base, "name": name or identity.replace("_", " ").title(), "source": "mythicmobs",
"source_id": identity, "vanilla_name": base["name"]}
return {**base, "custom_name": name or None}


def _entry(source: Source, row: dict, maps: Maps, victims: dict) -> dict:
"""One row. `target` is the plain label; `target_info` the structured name the panel shows."""
action = row.get("action")
entry = {
"id": f"{source.name}:{row['rid']}",
Expand All @@ -303,32 +331,42 @@ def _entry(source: Source, row: dict, maps: Maps, victims: dict) -> dict:
"message": None,
"truncated": False,
"rolled_back": _rollback(row.get("rolled_back")),
"target_info": None,
}
if source.name == "block":
if action == 3:
if row["type"] == 0:
victim = victims.get(row["data"])
entry.update(kind="kill", verb="killed", victim=victim,
target=victim["minecraft_name"] if victim else "a player")
name = victim["minecraft_name"] if victim else "a player"
entry.update(kind="kill", verb="killed", victim=victim, target=name,
target_info=names.target(name, source="player"))
else:
entry.update(kind="kill", verb="killed", target=metadata.mob_label(
row.get("entity_blob"), row.get("identity_blob"), maps.entity(row["type"])))
row.get("entity_blob"), row.get("identity_blob"), maps.entity(row["type"])),
target_info=_mob_target(row, maps))
elif action == 13:
entry.update(kind="spawn", verb="spawned", target=maps.entity(row["type"]))
entry.update(kind="spawn", verb="spawned", target=maps.entity(row["type"]),
target_info=_mob(maps, row["type"]))
elif action == 2:
entry.update(kind="click", verb="clicked", target=maps.material(row["type"]))
entry.update(kind="click", verb="clicked", target=maps.material(row["type"]),
target_info=_material(maps, row["type"], names.BLOCK))
else:
entry.update(kind="block", verb="placed" if action == 1 else "broke", target=maps.material(row["type"]))
entry.update(kind="block", verb="placed" if action == 1 else "broke", target=maps.material(row["type"]),
target_info=_material(maps, row["type"], names.BLOCK))
elif source.name in {"container", "entity_container"}:
entry.update(kind="container", verb="added" if action == 1 else "removed",
target=metadata.item_label(row.get("identity_blob"), maps.material(row["type"])), amount=row["amount"])
target=metadata.item_label(row.get("identity_blob"), maps.material(row["type"])), amount=row["amount"],
target_info=_item_target(row, maps))
elif source.name == "item":
entry.update(kind="item", verb=_ITEM_VERBS.get(action, "moved"),
target=metadata.item_label(row.get("identity_blob"), maps.material(row["type"])), amount=row["amount"])
target=metadata.item_label(row.get("identity_blob"), maps.material(row["type"])), amount=row["amount"],
target_info=_item_target(row, maps))
elif source.name == "entity":
entry.update(kind="entity", verb=_ENTITY_VERBS.get(action, "clicked"), target=maps.entity(row["type"]))
entry.update(kind="entity", verb=_ENTITY_VERBS.get(action, "clicked"), target=maps.entity(row["type"]),
target_info=_mob(maps, row["type"]))
elif source.name == "sign":
entry.update(kind="sign", verb=_SIGN_VERBS.get(action, "changed"), target="sign")
entry.update(kind="sign", verb=_SIGN_VERBS.get(action, "changed"), target="sign",
target_info=names.target("Sign"))
elif source.name == "command":
if row["is_skill"]:
entry.update(kind="skill", verb="teleported with" if row["teleport"] else "cast",
Expand Down
26 changes: 18 additions & 8 deletions backend/src/coreprotect/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@ def _id(value):
return value if isinstance(value, str) and _ID.fullmatch(value) else None


def item_label(data, vanilla):
def item_identity(data):
"""(MMOItems `TYPE:ID` or None, display name or "") from an item's metadata blob."""
try:
# Only the first item-meta map, never nested lore, books or container items.
groups = _list(_java(data))
Expand All @@ -257,12 +258,16 @@ def item_label(data, vanilla):
tags = _custom(meta.get("custom"))
item_type, item_id = _id(tags.get("MMOITEMS_ITEM_TYPE")), _id(tags.get("MMOITEMS_ITEM_ID"))
name = clean_name(meta.get("display-name") or meta.get("item-name"), component=True)
if item_type and item_id:
identity = f"{item_type}:{item_id}"
return f"{name or item_id.replace('_', ' ').title()} [MMOItems {identity}]"
return f"{vanilla} (renamed: {name})" if name else vanilla
return (f"{item_type}:{item_id}" if item_type and item_id else None), name
except (ValueError, TypeError, KeyError, IndexError, UnicodeError, RecursionError, zlib.error):
return vanilla
return None, ""


def item_label(data, vanilla):
identity, name = item_identity(data)
if identity:
return f"{name or identity.partition(':')[2].replace('_', ' ').title()} [MMOItems {identity}]"
return f"{vanilla} (renamed: {name})" if name else vanilla


def _safe_list(data):
Expand All @@ -272,12 +277,17 @@ def _safe_list(data):
return []


def mob_label(data, metadata, vanilla):
def mob_identity(data, metadata):
"""(MythicMobs id or None, custom name or "") from a killed mob's two blobs."""
# Each optional blob can fail independently; retain any valid evidence.
fields = _safe_list(metadata)
identity = _id(fields[1]) if len(fields) == 2 and fields[0] == "coreprotect:mythic" else None
state = _safe_list(data)
name = clean_name(state[4]) if len(state) > 4 else ""
return identity, clean_name(state[4]) if len(state) > 4 else ""


def mob_label(data, metadata, vanilla):
identity, name = mob_identity(data, metadata)
if identity:
return f"{name or identity.replace('_', ' ').title()} [MythicMobs {identity}]"
return f"{vanilla} (named: {name})" if name else vanilla
68 changes: 68 additions & 0 deletions backend/src/coreprotect/names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Vanilla block, item and entity names: `short_grass` → "Short Grass".

The names come from Mojang's `en_us.json`, trimmed into
`data/minecraft_names.json` when the backend image is built
(`scripts/build_minecraft_names.py`). Without that file, or for an id it does
not know, the id is tidied instead. Only `minecraft:` ids are looked up: a
plugin's `some_mod:wheat` is not vanilla wheat.
"""
from __future__ import annotations

import json
from functools import lru_cache
from pathlib import Path

PATH = Path(__file__).parent / "data" / "minecraft_names.json"

# Which language section to try first for each kind of thing a row names.
# The same id can differ: the `wheat` block is "Wheat Crops", the item "Wheat".
BLOCK = ("block", "item")
ITEM = ("item", "block")
ENTITY = ("entity",)


@lru_cache(maxsize=1)
def _table() -> dict:
try:
return json.loads(PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}


def tidy(text: str) -> str:
"""`short_grass` → "Short Grass", the way the game capitalises names."""
return " ".join(word[:1].upper() + word[1:] for word in text.replace("_", " ").split()) or text


def vanilla(raw: str | None, order: tuple[str, ...]) -> tuple[str | None, str | None]:
"""(official name, id without `minecraft:`) for a CoreProtect id, or (None, id) when not vanilla."""
if not raw:
return None, None
namespace, _, path = raw.rpartition(":")
if namespace not in ("", "minecraft"):
return None, raw
table = _table()
for section in order:
name = table.get(section, {}).get(path)
if isinstance(name, str):
return name, path
return None, path


def describe(raw: str | None, order: tuple[str, ...], missing: str) -> dict:
"""A row's target: its name and where that name came from."""
if not raw:
return target(missing, source="unknown")
namespace, _, path = raw.rpartition(":")
if namespace not in ("", "minecraft"):
# Another plugin's namespace: keep its id, never borrow a vanilla name.
return target(tidy(path), source="unknown", id=raw)
name, short = vanilla(raw, order)
return target(name or tidy(path), id=short, vanilla_name=name)


def target(name: str, *, source: str = "vanilla", id: str | None = None, vanilla_name: str | None = None,
source_id: str | None = None, custom_name: str | None = None) -> dict:
"""`id` is the recorded Minecraft id; `source_id` a plugin's own (MMOItems `TYPE:ID`, a MythicMobs mob)."""
return {"name": name, "source": source, "id": id, "vanilla_name": vanilla_name,
"source_id": source_id, "custom_name": custom_name}
9 changes: 8 additions & 1 deletion backend/src/coreprotect/test_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from src.coreprotect import activity, cursors, maps
from src.coreprotect.reader import CoreProtectConfig, Reader
from src.coreprotect.test_names import vanilla # noqa: F401 (fixture)

SCOPE = "main:0615a817-8cb4-4aef-95f7-f6c9bf7611b8"

Expand All @@ -17,7 +18,7 @@ def read(db, ids, kinds=activity.KINDS, before=None, limit=20):
return activity.build(raw, names, SCOPE)


def test_decodes_each_source(coreprotect):
def test_decodes_each_source(coreprotect, vanilla):
me = coreprotect.user("Hazel", "0615a817-8cb4-4aef-95f7-f6c9bf7611b8")
victim = coreprotect.user("Bob", "00000000-0000-0000-0000-000000000002")
coreprotect.block(me, 1, 0)
Expand Down Expand Up @@ -60,6 +61,12 @@ def test_decodes_each_source(coreprotect):
assert by_kind["container"]["rolled_back"] == "rolled back (player inventory)"
assert entries[9]["victim"] == {"minecraft_name": "Bob", "uuid": "00000000-0000-0000-0000-000000000002"}
assert by_kind["block"]["world"] == "TFMC_Map"
shown = [(e["target_info"] or {}).get("name") for e in entries]
assert shown == [None, None, None, None, "Sign", "Sheep", "Iron Ingot", "Iron Ingot", "Sheep", "Bob", "Cow",
"Oak Door", "Stone", "Stone"]
assert by_kind["block"]["target_info"] == {"name": "Stone", "source": "vanilla", "id": "stone",
"vanilla_name": "Stone", "source_id": None, "custom_name": None}
assert entries[9]["target_info"]["source"] == "player"
text = json.dumps(entries)
for secret in ("meet me", "secret base", "secret sign", "chat is never", "4 5 6"):
assert secret not in text
Expand Down
Loading