diff --git a/backend/src/api/characters_routes.py b/backend/src/api/characters_routes.py index afac85dc..01f77893 100644 --- a/backend/src/api/characters_routes.py +++ b/backend/src/api/characters_routes.py @@ -47,6 +47,14 @@ wipe_realm_character_data, ) from src.characters.roster import RosterError, replace_roster +from src.characters.wills import ( + WillError, + ack_will_results, + check_heir_search_rate, + create_will_request, + list_pending_wills, + search_heirs, +) from src.characters.wardrobe import ( WardrobeError, ack_wardrobe_slots, @@ -99,6 +107,18 @@ class RosterBody(BaseModel): realm_id: str | None = None +class WillRequestBody(BaseModel): + """Profile will change: an heir, or null to revoke, against the revision the page showed.""" + + heir_character_id: str | None + base_revision: int + + +class WillAppliedBody(BaseModel): + realm_id: str | None = None + results: list[dict] = Field(default_factory=list) + + class WardrobeActiveBody(BaseModel): """PATCH body for wardrobe active slot. null clears auto-apply.""" @@ -587,6 +607,47 @@ def delete_lore_item_customise_route( raise _lore_http(e) from e +def _will_http(exc: WillError) -> HTTPException: + return HTTPException(status_code=exc.status_code, detail=str(exc)) + + +@characters_router.get("/heirs") +def get_heir_search( + q: str | None = None, + character_id: str | None = None, + authorization: str | None = Header(default=None), +): + """Living characters of the session's realm by name, for naming an heir.""" + session = _profile_session_from_auth(authorization) + try: + check_heir_search_rate(session["player_uuid"]) + results = search_heirs(session["player_uuid"], session.get("realm_id"), q, character_id) + except WillError as e: + raise _will_http(e) from e + return {"results": results} + + +@characters_router.post("/{character_id}/will") +def post_character_will( + character_id: str, + body: WillRequestBody, + authorization: str | None = Header(default=None), +): + """Queue a will change for RPCharacters to apply in game.""" + session = _profile_session_from_auth(authorization) + try: + request = create_will_request( + session["player_uuid"], + session.get("realm_id"), + character_id, + body.heir_character_id, + body.base_revision, + ) + except WillError as e: + raise _will_http(e) from e + return {"ok": True, "request": request} + + @characters_router.get("/{character_id}/wardrobe") def get_character_wardrobe( character_id: str, @@ -986,6 +1047,32 @@ def plugin_applied_creates( raise HTTPException(status_code=400, detail=str(e)) from e +@characters_router.get("/plugin/wills/pending") +def plugin_pending_wills( + realm_id: str | None = None, + x_plugin_key: str | None = Header(default=None, alias=HEADER_PLUGIN_KEY), +): + """Will changes made on Profile for this realm. The realm is required.""" + _require_plugin(x_plugin_key) + try: + return {"requests": list_pending_wills(realm_id)} + except WillError as e: + raise _will_http(e) from e + + +@characters_router.post("/plugin/wills/applied") +def plugin_applied_wills( + body: WillAppliedBody, + x_plugin_key: str | None = Header(default=None, alias=HEADER_PLUGIN_KEY), +): + """RPCharacters' answer for each pulled will request. The realm must match the request's.""" + _require_plugin(x_plugin_key) + try: + return ack_will_results(body.realm_id, body.results) + except WillError as e: + raise _will_http(e) from e + + @characters_router.put("/plugin/roster") def plugin_put_roster( body: RosterBody, diff --git a/backend/src/auth/test_players.py b/backend/src/auth/test_players.py index f68ffb50..4a87cabe 100644 --- a/backend/src/auth/test_players.py +++ b/backend/src/auth/test_players.py @@ -77,7 +77,9 @@ def staff(env, role="mod"): def test_requires_a_staff_role(app, env, world): assert client(app).get("/admin/players").status_code == 401 + assert client(app).get(f"/admin/players/{HAZEL}").status_code == 401 assert client(app, staff(env, "player")).get("/admin/players").status_code == 403 + assert client(app, staff(env, "player")).get(f"/admin/players/{HAZEL}").status_code == 403 assert client(app, staff(env, "player")).get(f"/admin/players/{HAZEL}/activity").status_code == 403 assert client(app, staff(env, "mod")).get("/admin/players").status_code == 200 @@ -245,7 +247,8 @@ def test_profile_includes_the_shared_character_sheet_in_each_realm(app, env, wor @pytest.mark.parametrize("kind,size", [("face", (8, 8)), ("figure", (16, 32))]) -def test_character_pictures_require_staff_and_read_the_requested_realm(app, env, world, monkeypatch, kind, size): +@pytest.mark.parametrize("status", ["ALIVE", "DEAD", "MISSING"]) +def test_character_pictures_require_staff_and_read_the_requested_realm(app, env, world, monkeypatch, kind, size, status): import io from PIL import Image from src.characters import wardrobe @@ -256,6 +259,8 @@ def test_character_pictures_require_staff_and_read_the_requested_realm(app, env, cid = "c-Hazel Stonebrook" character(env, HAZEL, "Tutorial Hazel", realm="tutorial", character_id=cid) with env.connect() as conn: + conn.execute("UPDATE character_roster SET status = ? WHERE player_uuid = ? AND character_id = ?", + (status, HAZEL, cid)) conn.execute("UPDATE character_roster SET wardrobe_active_slot = CASE realm_id " "WHEN 'tutorial' THEN 'extra_1' ELSE 'base' END WHERE player_uuid = ?", (HAZEL,)) for slot, colour in [("base", (10, 20, 30, 255)), ("extra_1", (40, 50, 60, 255))]: @@ -288,6 +293,10 @@ def test_character_pictures_require_staff_and_read_the_requested_realm(app, env, assert [tuple(r) for r in conn.execute("SELECT * FROM character_wardrobe_slots")] == before assert conn.execute("SELECT wardrobe_active_slot FROM character_roster " "WHERE realm_id = 'tutorial' AND character_id = ?", (cid,)).fetchone()[0] == "extra_1" + if status != "ALIVE": + with pytest.raises(wardrobe.WardrobeError, match="Character is not alive") as error: + wardrobe.get_wardrobe(HAZEL, cid) + assert error.value.status_code == 403 @pytest.mark.parametrize("kind", ["face", "figure"]) diff --git a/backend/src/characters/creates.py b/backend/src/characters/creates.py index a2ad7eed..cbecf59d 100644 --- a/backend/src/characters/creates.py +++ b/backend/src/characters/creates.py @@ -611,7 +611,9 @@ def list_for_player( get_player_meta, list_roster, ) + from src.characters.estates import estate_for from src.characters.rpc_player_meta import resolve_web_entitlements + from src.characters.wills import will_requests_for from src.skins.codes import normalize_realm_id from src.skins.db import connect @@ -642,6 +644,14 @@ def list_for_player( (uuid, realm), ).fetchall() + alive_ids = [c["id"] for c in roster if "will" in c] + requests = will_requests_for(conn, realm, uuid, alive_ids) + for c in roster: + if c["id"] in requests: + c["will_request"] = requests[c["id"]] + if str(c.get("status") or "").upper() in ("DEAD", "MISSING"): + c["estate"] = estate_for(conn, realm, c["id"], c.get("death_id")) + catalog = get_catalog() characters: list[dict[str, Any]] = list(roster) for row in pending_rows: diff --git a/backend/src/characters/estates.py b/backend/src/characters/estates.py new file mode 100644 index 00000000..58f57d40 --- /dev/null +++ b/backend/src/characters/estates.py @@ -0,0 +1,300 @@ +"""Death, will and estate fields from RPCharacters' roster push. + +Estates live in their own table keyed by (realm_id, death_id), so a roster push that +drops a character never deletes its estate. Older revisions never replace newer ones. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +from datetime import datetime, timezone +from typing import Any + +ESTATE_STATUSES = frozenset({"OPEN", "HELD", "CANCELLED", "DECIDED", "REVERTED"}) +ESTATE_PARTS = ("money", "property", "guild_locks", "letters") + +_KIND_RE = re.compile(r"^[A-Z_]{1,24}$") +_PARTICIPANT_RE = re.compile(r"^[a-z0-9._-]{1,64}$") +_MAX_PARTICIPANTS = 32 +_MAX_COUNTS = 32 +_MAX_NAME = 64 +_MAX_ID = 64 + + +def _iso_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def iso_from_epoch_ms(raw: Any) -> str | None: + """Epoch milliseconds (or an ISO string) to the site's ISO form; None if unusable.""" + if raw is None or isinstance(raw, bool): + return None + if isinstance(raw, (int, float)): + if raw <= 0: + return None + try: + moment = datetime.fromtimestamp(raw / 1000, tz=timezone.utc) + except (OverflowError, OSError, ValueError): + return None + return moment.strftime("%Y-%m-%dT%H:%M:%SZ") + text = str(raw).strip() + if not text: + return None + if text.isdigit(): + return iso_from_epoch_ms(int(text)) + try: + moment = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def iso_ms_from_epoch_ms(raw: Any) -> str | None: + """Like iso_from_epoch_ms but keeps milliseconds, for ordering pushes inside one second.""" + if raw is None or isinstance(raw, bool) or not isinstance(raw, (int, float)) or raw <= 0: + return iso_from_epoch_ms(raw) + try: + moment = datetime.fromtimestamp(int(raw) / 1000, tz=timezone.utc) + except (OverflowError, OSError, ValueError): + return None + return moment.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(raw) % 1000:03d}Z" + + +def _short_text(raw: Any, limit: int) -> str | None: + if raw is None: + return None + text = str(raw).strip() + return text[:limit] if text else None + + +def _revision(raw: Any) -> int | None: + if raw is None or isinstance(raw, bool): + return None + try: + value = int(raw) + except (TypeError, ValueError): + return None + return value if value >= 0 else None + + +def death_fields(raw: dict) -> tuple[str | None, str | None]: + """(died_at ISO, death_id) as pushed; None clears.""" + return iso_from_epoch_ms(raw.get("died_at")), _short_text(raw.get("death_id"), _MAX_ID) + + +def will_fields(raw: dict) -> tuple[str | None, str | None, int] | None: + """(heir id, heir name, revision), or None when the push carries no will revision.""" + revision = _revision(raw.get("will_revision")) + if revision is None: + return None + heir_id = _short_text(raw.get("will_heir_id"), _MAX_ID) + heir_name = _short_text(raw.get("will_heir_name"), _MAX_NAME) if heir_id else None + return heir_id, heir_name, revision + + +def store_will( + conn: sqlite3.Connection, + player_uuid: str, + realm: str, + character_id: str, + will: tuple[str | None, str | None, int], +) -> bool: + """Write a will unless the row already holds a newer revision. True when written.""" + heir_id, heir_name, revision = will + cur = conn.execute( + """ + UPDATE character_roster + SET will_heir_id = ?, will_heir_name = ?, will_revision = ? + WHERE player_uuid = ? AND realm_id = ? AND character_id = ? + AND (will_revision IS NULL OR will_revision <= ?) + """, + (heir_id, heir_name, revision, player_uuid, realm, character_id, revision), + ) + return cur.rowcount > 0 + + +def _recipient(raw: Any) -> dict[str, str | None] | None: + if not isinstance(raw, dict): + return None + kind = str(raw.get("kind") or "").strip().upper() + if not _KIND_RE.match(kind): + return None + return {"kind": kind, "name": _short_text(raw.get("name"), _MAX_NAME)} + + +def _counts(raw: Any) -> dict[str, int]: + out: dict[str, int] = {} + if not isinstance(raw, dict): + return out + for key, value in raw.items(): + if len(out) >= _MAX_COUNTS: + break + label = _short_text(key, _MAX_ID) + if not label or isinstance(value, bool): + continue + try: + out[label] = max(0, int(value)) + except (TypeError, ValueError): + continue + return out + + +def _participants(raw: Any) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + if not isinstance(raw, dict): + return out + for key, value in raw.items(): + if len(out) >= _MAX_PARTICIPANTS: + break + pid = str(key or "").strip().lower() + if not _PARTICIPANT_RE.match(pid) or not isinstance(value, dict): + continue + outcome = str(value.get("outcome") or "").strip().upper() + out[pid] = { + "outcome": outcome if _KIND_RE.match(outcome) else None, + "revision": _revision(value.get("revision")), + "counts": _counts(value.get("counts")), + } + return out + + +def parse_estate(raw: Any, death_id: str | None) -> dict[str, Any] | None: + """A pushed estate object, cleaned; None when it is unusable (missing death id, status or revision).""" + if not isinstance(raw, dict): + return None + did = _short_text(raw.get("death_id"), _MAX_ID) or death_id + status = str(raw.get("status") or "").strip().upper() + revision = _revision(raw.get("revision")) + if not did or status not in ESTATE_STATUSES or revision is None: + return None + recipients = {} + raw_recipients = raw.get("recipients") + if isinstance(raw_recipients, dict): + for part in ESTATE_PARTS: + parsed = _recipient(raw_recipients.get(part)) + if parsed is not None: + recipients[part] = parsed + money_moved = raw.get("money_moved_cents") + try: + money_cents = None if money_moved is None or isinstance(money_moved, bool) else max(0, int(money_moved)) + except (TypeError, ValueError): + money_cents = None + return { + "death_id": did, + "status": status, + "revision": revision, + "updated_at": iso_ms_from_epoch_ms(raw.get("updated_at")), + "payload": { + "due_at": iso_from_epoch_ms(raw.get("due_at")), + "decided_at": iso_from_epoch_ms(raw.get("decided_at")), + "heir_name": _short_text(raw.get("heir_name"), _MAX_NAME), + "recipients": recipients, + "money_moved_cents": money_cents, + "participants": _participants(raw.get("participants")), + }, + } + + +def store_estate( + conn: sqlite3.Connection, + player_uuid: str, + realm: str, + character_id: str, + estate: dict[str, Any], +) -> bool: + """Insert or replace one estate unless the stored one is newer. True when written. + + Newer means a higher revision, or the same revision with a later ``updated_at``. + An estate already stored for another character is left alone. + """ + existing = conn.execute( + """ + SELECT character_id, revision, source_updated_at + FROM character_estates WHERE realm_id = ? AND death_id = ? + """, + (realm, estate["death_id"]), + ).fetchone() + if existing is not None: + if existing["character_id"] != character_id: + return False + if estate["revision"] < existing["revision"]: + return False + stored_at = existing["source_updated_at"] + if ( + estate["revision"] == existing["revision"] + and stored_at + and (estate["updated_at"] is None or estate["updated_at"] < stored_at) + ): + return False + conn.execute( + """ + INSERT INTO character_estates ( + realm_id, death_id, player_uuid, character_id, status, revision, + source_updated_at, payload, received_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(realm_id, death_id) DO UPDATE SET + player_uuid = excluded.player_uuid, + status = excluded.status, + revision = excluded.revision, + source_updated_at = excluded.source_updated_at, + payload = excluded.payload, + received_at = excluded.received_at + """, + ( + realm, + estate["death_id"], + player_uuid, + character_id, + estate["status"], + estate["revision"], + estate["updated_at"], + json.dumps(estate["payload"], separators=(",", ":")), + _iso_now(), + ), + ) + return True + + +def _estate_out(row: sqlite3.Row) -> dict[str, Any]: + try: + payload = json.loads(row["payload"] or "{}") + except (TypeError, json.JSONDecodeError): + payload = {} + if not isinstance(payload, dict): + payload = {} + return { + "death_id": row["death_id"], + "status": row["status"], + "revision": row["revision"], + "due_at": payload.get("due_at"), + "decided_at": payload.get("decided_at"), + "heir_name": payload.get("heir_name"), + "recipients": payload.get("recipients") or {}, + "money_moved_cents": payload.get("money_moved_cents"), + "participants": payload.get("participants") or {}, + } + + +def estate_for( + conn: sqlite3.Connection, realm: str, character_id: str, death_id: str | None +) -> dict[str, Any] | None: + """The estate of the character's current death; its latest stored one when the death id is unknown.""" + if death_id: + row = conn.execute( + "SELECT * FROM character_estates WHERE realm_id = ? AND death_id = ? AND character_id = ?", + (realm, death_id, character_id), + ).fetchone() + else: + row = conn.execute( + """ + SELECT * FROM character_estates WHERE realm_id = ? AND character_id = ? + ORDER BY received_at DESC LIMIT 1 + """, + (realm, character_id), + ).fetchone() + return _estate_out(row) if row is not None else None diff --git a/backend/src/characters/lore_items.py b/backend/src/characters/lore_items.py index cc6412f5..2614fcf5 100644 --- a/backend/src/characters/lore_items.py +++ b/backend/src/characters/lore_items.py @@ -132,6 +132,7 @@ def _require_customise_allowed( """Allow customise for roster (claimable kit) or pending web create.""" from src.characters.pending_create import ( PendingCreateError, + require_living, resolve_player_character, ) @@ -140,6 +141,7 @@ def _require_customise_allowed( kit = (kit_id or "starter").strip().lower() or "starter" try: access = resolve_player_character(uuid, cid) + require_living(access) except PendingCreateError as e: raise LoreItemError(str(e), status_code=e.status_code) from e if access["kind"] == "pending": @@ -1063,6 +1065,7 @@ def list_character_kits(player_uuid: str, character_id: str | None) -> dict[str, from src.characters.creation_catalog import get_catalog from src.characters.pending_create import ( PendingCreateError, + require_living, resolve_player_character, ) from src.characters.roster import get_player_meta @@ -1073,6 +1076,7 @@ def list_character_kits(player_uuid: str, character_id: str | None) -> dict[str, cid = _require_character_id(character_id) try: access = resolve_player_character(uuid, cid) + require_living(access) except PendingCreateError as e: raise LoreItemError(str(e), status_code=e.status_code) from e is_pending = access["kind"] == "pending" diff --git a/backend/src/characters/pending_create.py b/backend/src/characters/pending_create.py index acc83651..bdc076a4 100644 --- a/backend/src/characters/pending_create.py +++ b/backend/src/characters/pending_create.py @@ -57,7 +57,7 @@ def resolve_player_character( with connect() as conn: row = conn.execute( """ - SELECT player_uuid, character_id, wardrobe_active_slot, realm_id + SELECT player_uuid, character_id, wardrobe_active_slot, realm_id, status FROM character_roster WHERE player_uuid = ? AND character_id = ? """, @@ -70,6 +70,7 @@ def resolve_player_character( "character_id": str(row["character_id"]), "realm_id": row["realm_id"], "wardrobe_active_slot": row["wardrobe_active_slot"], + "status": str(row["status"] or "").upper(), } pending = fetch_owned_pending_create(uuid, cid) @@ -85,3 +86,9 @@ def resolve_player_character( } raise PendingCreateError("Character not found", status_code=403) + + +def require_living(access: dict[str, Any]) -> None: + """Wardrobe and kit edits are for living (or pending) characters only.""" + if access["kind"] == "roster" and access.get("status") != "ALIVE": + raise PendingCreateError("Character is not alive", status_code=403) diff --git a/backend/src/characters/roster.py b/backend/src/characters/roster.py index 6bc09a58..5fe02f4e 100644 --- a/backend/src/characters/roster.py +++ b/backend/src/characters/roster.py @@ -6,6 +6,14 @@ from datetime import datetime, timezone from typing import Any +from src.characters.estates import ( + death_fields, + parse_estate, + store_estate, + store_will, + will_fields, +) + class RosterError(ValueError): """Invalid roster payload.""" @@ -317,7 +325,8 @@ def list_roster( rows = conn.execute( """ SELECT character_id, name, status, race, class, created_at, updated_at, - kit_status, kit_statuses_json, sheet_json, realm_id + kit_status, kit_statuses_json, sheet_json, realm_id, + died_at, death_id, will_heir_id, will_heir_name, will_revision FROM character_roster WHERE player_uuid = ? AND realm_id = ? ORDER BY created_at ASC, character_id ASC @@ -336,7 +345,15 @@ def list_roster( "updated_at": row["updated_at"], "realm_id": realm, "source": "roster", + "died_at": row["died_at"], + "death_id": row["death_id"], } + if str(row["status"] or "").upper() == "ALIVE": + item["will"] = { + "heir_id": row["will_heir_id"], + "heir_name": row["will_heir_name"] if row["will_heir_id"] else None, + "revision": int(row["will_revision"] or 0), + } kit_status = row["kit_status"] if kit_status is not None and str(kit_status).strip(): item["kit_status"] = str(kit_status).strip().lower() @@ -475,6 +492,9 @@ def replace_roster( now = _iso_now() normalized: list[tuple] = [] seen: set[str] = set() + deaths: list[tuple] = [] + wills: list[tuple] = [] + estates: list[tuple] = [] for i, raw in enumerate(characters): if not isinstance(raw, dict): raise RosterError(f"characters[{i}] must be an object") @@ -509,6 +529,18 @@ def replace_roster( kit_statuses_json = json.dumps(cleaned, separators=(",", ":")) if kit_status is None and "starter" in cleaned: kit_status = cleaned["starter"] + if "died_at" in raw or "death_id" in raw: + died_at, death_id = death_fields(raw) + deaths.append((died_at, death_id, uuid, realm, cid)) + else: + death_id = None + will = will_fields(raw) + if will is not None: + wills.append((cid, will)) + if "estate" in raw: + estate = parse_estate(raw.get("estate"), death_id) + if estate is not None: + estates.append((cid, estate)) sheet = _sheet_from_character(raw) sheet_json = ( json.dumps(sheet, separators=(",", ":")) if sheet is not None else None @@ -558,6 +590,17 @@ class = excluded.class, """, normalized, ) + conn.executemany( + """ + UPDATE character_roster SET died_at = ?, death_id = ? + WHERE player_uuid = ? AND realm_id = ? AND character_id = ? + """, + deaths, + ) + for cid, will in wills: + store_will(conn, uuid, realm, cid, will) + for cid, estate in estates: + store_estate(conn, uuid, realm, cid, estate) if ( max_alive is not None or eighteen is not None diff --git a/backend/src/characters/test_wills_estates.py b/backend/src/characters/test_wills_estates.py new file mode 100644 index 00000000..7716bd3f --- /dev/null +++ b/backend/src/characters/test_wills_estates.py @@ -0,0 +1,539 @@ +"""Stage 6b: deaths, wills and estates from the roster push, Profile will requests and the plugin queue.""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from unittest import mock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import src.api.characters_routes as routes +from src.characters import wills +from src.characters.roster import replace_roster +from src.skins import db as db_mod + +OWNER = "acc-owner" +OTHER = "acc-other" +PLUGIN = {"X-Plugin-Key": "pk"} +DIED_MS = 1_760_356_800_000 # 2025-10-13T12:00:00Z + + +@pytest.fixture +def db(tmp_path, monkeypatch): + for name, sub in ( + ("DATA_DIR", ""), + ("DB_PATH", "province.db"), + ("DRINKS_DIR", "drinks"), + ("SKINS_DIR", "skins"), + ("WARDROBE_DIR", "wardrobe"), + ): + monkeypatch.setattr(db_mod, name, tmp_path / sub if sub else tmp_path) + monkeypatch.setenv("PLUGIN_KEY", "pk") + db_mod.migrate() + wills._heir_rate.clear() + return db_mod + + +def sessions(token: str) -> dict | None: + return { + "owner": {"scope": "profile", "player_uuid": OWNER, "realm_id": "main"}, + "other": {"scope": "profile", "player_uuid": OTHER, "realm_id": "main"}, + "owner-dev": {"scope": "profile", "player_uuid": OWNER, "realm_id": "dev"}, + }.get(token) + + +@pytest.fixture +def api(db): + app = FastAPI() + app.include_router(routes.characters_router) + with mock.patch.object(routes, "get_session", side_effect=sessions): + yield TestClient(app) + + +def bearer(token: str = "owner") -> dict: + return {"Authorization": f"Bearer {token}"} + + +def char(cid: str, name: str, status: str = "ALIVE", **extra) -> dict: + return {"id": cid, "name": name, "status": status, "race": "human", **extra} + + +def roster_row(cid: str, realm: str = "main") -> dict: + with db_mod.connect() as conn: + row = conn.execute( + "SELECT * FROM character_roster WHERE character_id = ? AND realm_id = ?", (cid, realm) + ).fetchone() + return dict(row) if row else {} + + +def estate_row(death_id: str, realm: str = "main") -> dict: + with db_mod.connect() as conn: + row = conn.execute( + "SELECT * FROM character_estates WHERE death_id = ? AND realm_id = ?", (death_id, realm) + ).fetchone() + return dict(row) if row else {} + + +def estate(status: str = "OPEN", revision: int = 0, **extra) -> dict: + return {"status": status, "revision": revision, "due_at": DIED_MS + 86_400_000, **extra} + + +def seed_owner(realm: str = "main") -> None: + replace_roster( + OWNER, + [ + char("c-ana", "Ana", will_revision=2, will_heir_id=None), + char("c-dead", "Bram", "DEAD", died_at=DIED_MS, death_id="d-bram"), + char("c-alt", "Kira Alt"), + ], + realm_id=realm, + ) + replace_roster(OTHER, [char("c-kira", "Kira"), char("c-gone", "Kirsten", "DEAD")], realm_id=realm) + + +# --- roster push: deaths, wills, estates --- + + +def test_push_stores_death_fields_and_a_null_clears_them(db): + replace_roster(OWNER, [char("c1", "Ana", "DEAD", died_at=DIED_MS, death_id="d-1")]) + row = roster_row("c1") + assert (row["died_at"], row["death_id"]) == ("2025-10-13T12:00:00Z", "d-1") + + replace_roster(OWNER, [char("c1", "Ana", "ALIVE")]) + assert roster_row("c1")["death_id"] == "d-1" # an old plugin's push leaves them + + replace_roster(OWNER, [char("c1", "Ana", "ALIVE", died_at=None, death_id=None)]) + row = roster_row("c1") + assert (row["died_at"], row["death_id"]) == (None, None) + + +def test_will_revision_never_goes_back(db): + replace_roster(OWNER, [char("c1", "Ana", will_heir_id="h-1", will_heir_name="Kira", will_revision=3)]) + assert roster_row("c1")["will_heir_name"] == "Kira" + + replace_roster(OWNER, [char("c1", "Ana", will_heir_id="h-old", will_heir_name="Old", will_revision=2)]) + row = roster_row("c1") + assert (row["will_heir_id"], row["will_revision"]) == ("h-1", 3) + + replace_roster(OWNER, [char("c1", "Ana")]) # no will_revision: untouched + assert roster_row("c1")["will_heir_id"] == "h-1" + + replace_roster(OWNER, [char("c1", "Ana", will_heir_id="h-1", will_heir_name="Kira Vale", will_revision=3)]) + assert roster_row("c1")["will_heir_name"] == "Kira Vale" # equal revision replaces + + replace_roster(OWNER, [char("c1", "Ana", will_heir_id=None, will_heir_name="ignored", will_revision=4)]) + row = roster_row("c1") + assert (row["will_heir_id"], row["will_heir_name"], row["will_revision"]) == (None, None, 4) + + +def test_estate_is_stored_by_realm_and_death_and_survives_the_character_leaving_the_push(db): + replace_roster( + OWNER, + [char("c1", "Ana", "DEAD", died_at=DIED_MS, death_id="d-1", estate=estate())], + ) + replace_roster( + OWNER, + [char("c1", "Ana", "DEAD", died_at=DIED_MS, death_id="d-1", estate=estate(revision=0, status="HELD"))], + realm_id="dev", + ) + assert estate_row("d-1")["status"] == "OPEN" + assert estate_row("d-1", "dev")["status"] == "HELD" + + replace_roster(OWNER, [char("c2", "Cael")]) # c1 gone from the push + assert roster_row("c1") == {} + assert estate_row("d-1")["status"] == "OPEN" + + +def test_estate_revision_and_update_time_order(db): + def push(**kw): + replace_roster(OWNER, [char("c1", "Ana", "DEAD", death_id="d-1", estate=estate(**kw))]) + + push(status="DECIDED", revision=1, updated_at=DIED_MS + 2000) + push(status="OPEN", revision=0, updated_at=DIED_MS + 9000) # older revision + assert estate_row("d-1")["status"] == "DECIDED" + + push(status="OPEN", revision=1, updated_at=DIED_MS + 1000) # same revision, older write + assert estate_row("d-1")["status"] == "DECIDED" + + push(status="OPEN", revision=1) # same revision, no time: cannot prove it is newer + assert estate_row("d-1")["status"] == "DECIDED" + + push(status="REVERTED", revision=1, updated_at=DIED_MS + 3000) + assert estate_row("d-1")["status"] == "REVERTED" + + push(status="DECIDED", revision=2) + row = estate_row("d-1") + assert (row["status"], row["revision"]) == ("DECIDED", 2) + + +def test_same_second_pushes_are_ordered_by_milliseconds(db): + def push(status, updated_at): + replace_roster(OWNER, [char("c1", "Ana", "DEAD", death_id="d-1", estate=estate(status, updated_at=updated_at))]) + + push("DECIDED", DIED_MS + 800) + push("OPEN", DIED_MS + 200) # built earlier in the same second, delivered later + assert estate_row("d-1")["status"] == "DECIDED" + push("REVERTED", DIED_MS + 801) + assert estate_row("d-1")["status"] == "REVERTED" + + +def test_negative_counts_and_money_read_as_zero(db): + raw = estate("DECIDED", money_moved_cents=-5, participants={"thievery": {"outcome": "DONE", "counts": {"locks": -3}}}) + replace_roster(OWNER, [char("c1", "Ana", "DEAD", death_id="d-1", estate=raw)]) + payload = json.loads(estate_row("d-1")["payload"]) + assert payload["money_moved_cents"] == 0 + assert payload["participants"]["thievery"]["counts"] == {"locks": 0} + + +def test_bad_estate_is_dropped_but_the_rest_of_the_push_applies(db): + replace_roster( + OWNER, + [ + char("c1", "Ana", "DEAD", death_id="d-1", estate={"status": "WHATEVER", "revision": 0}), + char("c2", "Bram", "DEAD", estate=estate()), # no death id anywhere + char("c3", "Cael", "DEAD", death_id="d-3", estate=estate(revision=-1)), + ], + ) + with db_mod.connect() as conn: + assert conn.execute("SELECT COUNT(*) FROM character_estates").fetchone()[0] == 0 + assert roster_row("c1")["death_id"] == "d-1" + + +def test_estate_of_one_character_is_not_taken_over_by_another(db): + replace_roster(OWNER, [char("c1", "Ana", "DEAD", death_id="d-1", estate=estate())]) + replace_roster(OTHER, [char("c9", "Mallory", "DEAD", death_id="d-1", estate=estate(revision=5))]) + row = estate_row("d-1") + assert (row["character_id"], row["revision"]) == ("c1", 0) + + +def test_estate_payload_is_cleaned(db): + raw = estate( + status="DECIDED", + decided_at=DIED_MS + 86_400_000, + heir_name="Kira", + recipients={ + "money": {"kind": "character", "name": "Kira"}, + "property": {"kind": "WAITING"}, + "guild_locks": {"kind": "bad kind!", "name": "x"}, + "unknown": {"kind": "GUILD", "name": "y"}, + }, + money_moved_cents=34025, + participants={ + "denareconomy": {"outcome": "done", "revision": 0, "counts": {"accounts": 2, "bad": "x"}}, + "Bad Id!": {"outcome": "DONE"}, + }, + ) + replace_roster(OWNER, [char("c1", "Ana", "DEAD", death_id="d-1", estate=raw)]) + payload = json.loads(estate_row("d-1")["payload"]) + assert payload["recipients"] == { + "money": {"kind": "CHARACTER", "name": "Kira"}, + "property": {"kind": "WAITING", "name": None}, + } + assert payload["participants"] == {"denareconomy": {"outcome": "DONE", "revision": 0, "counts": {"accounts": 2}}} + assert payload["money_moved_cents"] == 34025 + assert payload["decided_at"] == "2025-10-14T12:00:00Z" + + +# --- Profile list --- + + +def test_list_shows_wills_requests_and_estates(api): + seed_owner() + replace_roster( + OWNER, + [ + char("c-ana", "Ana", will_heir_id="c-kira", will_heir_name="Kira", will_revision=3), + char("c-dead", "Bram", "DEAD", died_at=DIED_MS, death_id="d-bram", estate=estate("DECIDED", heir_name="Kira")), + char("c-alt", "Kira Alt", will_revision=0), + char("c-lost", "Dara", "MISSING"), + ], + ) + api.post("/characters/c-alt/will", json={"heir_character_id": "c-kira", "base_revision": 0}, headers=bearer()) + + chars = {c["id"]: c for c in api.get("/characters", headers=bearer()).json()["characters"]} + assert chars["c-ana"]["will"] == {"heir_id": "c-kira", "heir_name": "Kira", "revision": 3} + assert "will_request" not in chars["c-ana"] + assert chars["c-alt"]["will_request"]["status"] == "pending" + assert chars["c-alt"]["will_request"]["heir_name"] == "Kira" + dead = chars["c-dead"] + assert "will" not in dead + assert dead["died_at"] == "2025-10-13T12:00:00Z" + assert dead["estate"]["status"] == "DECIDED" + assert dead["estate"]["heir_name"] == "Kira" + assert chars["c-lost"]["estate"] is None + + +def test_list_shows_the_estate_of_the_current_death_only(api): + replace_roster(OWNER, [char("c1", "Ana", "DEAD", death_id="d-1", estate=estate("CANCELLED"))]) + replace_roster(OWNER, [char("c1", "Ana", "DEAD", death_id="d-2")]) # died again, no estate yet + chars = api.get("/characters", headers=bearer()).json()["characters"] + assert chars[0]["estate"] is None + + +def test_refused_request_stays_on_the_card_for_a_week(api): + seed_owner() + req = api.post("/characters/c-ana/will", json={"heir_character_id": "c-kira", "base_revision": 2}, headers=bearer()) + rid = req.json()["request"]["id"] + api.post("/characters/plugin/wills/applied", json={"realm_id": "main", "results": [{"id": rid, "result": "stale"}]}, headers=PLUGIN) + ana = next(c for c in api.get("/characters", headers=bearer()).json()["characters"] if c["id"] == "c-ana") + assert ana["will_request"]["result"] == "stale" + + old = (datetime.now(timezone.utc) - timedelta(days=8)).strftime("%Y-%m-%dT%H:%M:%SZ") + with db_mod.connect() as conn: + conn.execute("UPDATE will_requests SET acked_at = ?", (old,)) + conn.commit() + ana = next(c for c in api.get("/characters", headers=bearer()).json()["characters"] if c["id"] == "c-ana") + assert "will_request" not in ana + + +# --- Profile will requests --- + + +def test_owner_names_an_heir_and_revokes(api): + seed_owner() + res = api.post("/characters/c-ana/will", json={"heir_character_id": "c-kira", "base_revision": 2}, headers=bearer()) + assert res.status_code == 200 + req = res.json()["request"] + assert (req["heir_character_id"], req["heir_name"], req["status"], req["base_revision"]) == ("c-kira", "Kira", "pending", 2) + + res = api.post("/characters/c-alt/will", json={"heir_character_id": None, "base_revision": 0}, headers=bearer()) + assert res.status_code == 200 + assert res.json()["request"]["heir_character_id"] is None + + +def test_own_alt_may_be_the_heir(api): + seed_owner() + res = api.post("/characters/c-ana/will", json={"heir_character_id": "c-alt", "base_revision": 2}, headers=bearer()) + assert res.status_code == 200 + + +@pytest.mark.parametrize( + ("token", "cid", "body", "status", "detail"), + [ + ("other", "c-ana", {"heir_character_id": "c-kira", "base_revision": 2}, 403, None), + ("owner-dev", "c-ana", {"heir_character_id": "c-kira", "base_revision": 2}, 403, None), + ("owner", "c-dead", {"heir_character_id": "c-kira", "base_revision": 0}, 409, "character_not_alive"), + ("owner", "c-ana", {"heir_character_id": "c-kira", "base_revision": 1}, 409, "will_changed"), + ("owner", "c-ana", {"heir_character_id": "c-ana", "base_revision": 2}, 400, "invalid_heir"), + ("owner", "c-ana", {"heir_character_id": "c-gone", "base_revision": 2}, 400, "invalid_heir"), + ("owner", "c-ana", {"heir_character_id": "nobody", "base_revision": 2}, 400, "invalid_heir"), + ("owner", "c-ana", {"heir_character_id": "c-kira", "base_revision": -1}, 400, None), + ("owner", "c-ana", {"base_revision": 2}, 422, None), + ], +) +def test_will_request_refusals(api, token, cid, body, status, detail): + seed_owner() + res = api.post(f"/characters/{cid}/will", json=body, headers=bearer(token)) + assert res.status_code == status + if detail: + assert res.json()["detail"] == detail + with db_mod.connect() as conn: + assert conn.execute("SELECT COUNT(*) FROM will_requests").fetchone()[0] == 0 + + +def test_heir_in_another_realm_is_refused(api): + seed_owner() + replace_roster(OTHER, [char("c-devonly", "Devin")], realm_id="dev") + res = api.post("/characters/c-ana/will", json={"heir_character_id": "c-devonly", "base_revision": 2}, headers=bearer()) + assert res.json()["detail"] == "invalid_heir" + + +def test_will_request_needs_a_profile_session(api): + assert api.post("/characters/c-ana/will", json={"heir_character_id": None, "base_revision": 0}).status_code == 401 + + +def test_one_pending_request_at_a_time_until_it_is_old(api): + seed_owner() + body = {"heir_character_id": "c-kira", "base_revision": 2} + first = api.post("/characters/c-ana/will", json=body, headers=bearer()).json()["request"]["id"] + res = api.post("/characters/c-ana/will", json=body, headers=bearer()) + assert (res.status_code, res.json()["detail"]) == (409, "request_pending") + + old = (datetime.now(timezone.utc) - timedelta(minutes=11)).strftime("%Y-%m-%dT%H:%M:%SZ") + with db_mod.connect() as conn: + conn.execute("UPDATE will_requests SET created_at = ?", (old,)) + conn.commit() + second = api.post("/characters/c-ana/will", json=body, headers=bearer()).json()["request"]["id"] + pending = api.get("/characters/plugin/wills/pending?realm_id=main", headers=PLUGIN).json()["requests"] + assert [r["id"] for r in pending] == [second] + + res = api.post("/characters/plugin/wills/applied", json={"realm_id": "main", "results": [{"id": first, "result": "applied"}]}, headers=PLUGIN) + assert res.json()["already"] == [first] + + +# --- plugin pull and ack --- + + +def test_pull_requires_a_realm_and_the_plugin_key(api): + seed_owner() + api.post("/characters/c-ana/will", json={"heir_character_id": "c-kira", "base_revision": 2}, headers=bearer()) + assert api.get("/characters/plugin/wills/pending", headers=PLUGIN).status_code == 400 + assert api.get("/characters/plugin/wills/pending?realm_id=", headers=PLUGIN).status_code == 400 + assert api.get("/characters/plugin/wills/pending?realm_id=main").status_code == 401 + assert api.get("/characters/plugin/wills/pending?realm_id=dev", headers=PLUGIN).json() == {"requests": []} + + (req,) = api.get("/characters/plugin/wills/pending?realm_id=main", headers=PLUGIN).json()["requests"] + assert set(req) == {"id", "realm_id", "character_id", "account_uuid", "heir_character_id", "base_revision", "created_at"} + assert (req["realm_id"], req["character_id"], req["account_uuid"], req["heir_character_id"], req["base_revision"]) == ( + "main", + "c-ana", + OWNER, + "c-kira", + 2, + ) + + +def test_dev_requests_stay_in_dev(api): + seed_owner("dev") + res = api.post("/characters/c-ana/will", json={"heir_character_id": "c-kira", "base_revision": 2}, headers=bearer("owner-dev")) + rid = res.json()["request"]["id"] + assert api.get("/characters/plugin/wills/pending?realm_id=main", headers=PLUGIN).json() == {"requests": []} + + res = api.post("/characters/plugin/wills/applied", json={"realm_id": "main", "results": [{"id": rid, "result": "applied"}]}, headers=PLUGIN) + assert res.json()["refused"] == [{"id": rid, "reason": "realm"}] + assert [r["id"] for r in api.get("/characters/plugin/wills/pending?realm_id=dev", headers=PLUGIN).json()["requests"]] == [rid] + + +def test_ack_requires_a_realm(api): + assert api.post("/characters/plugin/wills/applied", json={"results": []}, headers=PLUGIN).status_code == 400 + assert api.post("/characters/plugin/wills/applied", json={"realm_id": "main", "results": []}).status_code == 401 + + +def test_ack_records_results_and_a_replay_changes_nothing(api): + seed_owner() + rid = api.post("/characters/c-ana/will", json={"heir_character_id": "c-kira", "base_revision": 2}, headers=bearer()).json()["request"]["id"] + body = { + "realm_id": "main", + "results": [ + {"id": rid, "result": "applied", "will_revision": 3, "heir_name": "Kira"}, + {"id": "nope", "result": "applied"}, + ], + } + res = api.post("/characters/plugin/wills/applied", json=body, headers=PLUGIN).json() + assert res == {"ok": True, "acked": [rid], "already": [], "refused": [{"id": "nope", "reason": "unknown"}]} + row = roster_row("c-ana") + assert (row["will_heir_id"], row["will_heir_name"], row["will_revision"]) == ("c-kira", "Kira", 3) + assert api.get("/characters/plugin/wills/pending?realm_id=main", headers=PLUGIN).json() == {"requests": []} + + body["results"] = [{"id": rid, "result": "stale"}] + res = api.post("/characters/plugin/wills/applied", json=body, headers=PLUGIN).json() + assert res["already"] == [rid] + with db_mod.connect() as conn: + assert conn.execute("SELECT result FROM will_requests WHERE id = ?", (rid,)).fetchone()[0] == "applied" + + +@pytest.mark.parametrize("result", ["stale", "invalid heir", "invalid", "character dead"]) +def test_refusals_are_recorded_and_leave_the_will(api, result): + seed_owner() + rid = api.post("/characters/c-ana/will", json={"heir_character_id": "c-kira", "base_revision": 2}, headers=bearer()).json()["request"]["id"] + res = api.post( + "/characters/plugin/wills/applied", + json={"realm_id": "main", "results": [{"id": rid, "result": result, "will_revision": 9}]}, + headers=PLUGIN, + ).json() + assert res["acked"] == [rid] + assert roster_row("c-ana")["will_revision"] == 2 + + +def test_unknown_result_is_refused(api): + seed_owner() + rid = api.post("/characters/c-ana/will", json={"heir_character_id": None, "base_revision": 2}, headers=bearer()).json()["request"]["id"] + res = api.post("/characters/plugin/wills/applied", json={"realm_id": "main", "results": [{"id": rid, "result": "maybe"}]}, headers=PLUGIN) + assert res.json()["refused"] == [{"id": rid, "reason": "result"}] + assert len(api.get("/characters/plugin/wills/pending?realm_id=main", headers=PLUGIN).json()["requests"]) == 1 + + +@pytest.mark.parametrize("revision", [999, 2, 4]) +def test_applied_ack_updates_the_will_only_one_past_its_base(api, revision): + seed_owner() + rid = api.post("/characters/c-ana/will", json={"heir_character_id": "c-kira", "base_revision": 2}, headers=bearer()).json()["request"]["id"] + res = api.post( + "/characters/plugin/wills/applied", + json={"realm_id": "main", "results": [{"id": rid, "result": "applied", "will_revision": revision}]}, + headers=PLUGIN, + ).json() + assert res["acked"] == [rid] + row = roster_row("c-ana") + assert (row["will_heir_id"], row["will_revision"]) == (None, 2) # left for the roster push + replace_roster(OWNER, [char("c-ana", "Ana", will_heir_id="c-kira", will_heir_name="Kira", will_revision=3)]) + assert roster_row("c-ana")["will_revision"] == 3 + + +def test_applied_ack_never_rolls_a_newer_will_back(api): + seed_owner() + rid = api.post("/characters/c-ana/will", json={"heir_character_id": "c-kira", "base_revision": 2}, headers=bearer()).json()["request"]["id"] + replace_roster(OWNER, [char("c-ana", "Ana", will_heir_id="c-alt", will_heir_name="Kira Alt", will_revision=5)]) + api.post( + "/characters/plugin/wills/applied", + json={"realm_id": "main", "results": [{"id": rid, "result": "applied", "will_revision": 3}]}, + headers=PLUGIN, + ) + row = roster_row("c-ana") + assert (row["will_heir_id"], row["will_revision"]) == ("c-alt", 5) + + +# --- heir search --- + + +def test_heir_search_finds_living_characters_in_the_realm(api): + seed_owner() + replace_roster("acc-third", [char("c-dev-kira", "Kira Dev")], realm_id="dev") + res = api.get("/characters/heirs?q=kir&character_id=c-ana", headers=bearer()) + results = res.json()["results"] + assert [(r["name"], r["own"]) for r in results] == [("Kira", False), ("Kira Alt", True)] + for r in results: + assert set(r) == {"id", "short_id", "name", "race", "own"} + assert r["short_id"] == r["id"][:8] + assert OWNER not in res.text and OTHER not in res.text + + +def test_heir_search_excludes_the_testator_and_uses_race_names(api): + replace_roster(OWNER, [char("c-ana", "Anabel", race_name="High Elf"), char("c-ann", "Annabeth")]) + results = api.get("/characters/heirs?q=Ana&character_id=c-ana", headers=bearer()).json()["results"] + assert results == [] + results = api.get("/characters/heirs?q=ana", headers=bearer()).json()["results"] + assert [(r["name"], r["race"]) for r in results] == [("Anabel", "High Elf")] + + +def test_heir_search_limits(api): + replace_roster(OTHER, [char(f"c-{i:02d}", f"Tomas {i:02d}") for i in range(14)] + [char("c-pct", "100% Tom")]) + assert api.get("/characters/heirs?q=to", headers=bearer()).status_code == 400 + assert len(api.get("/characters/heirs?q=tom", headers=bearer()).json()["results"]) == 10 + assert [r["name"] for r in api.get("/characters/heirs?q=0%25 t", headers=bearer()).json()["results"]] == ["100% Tom"] + assert api.get("/characters/heirs?q=tom").status_code == 401 + + +def test_heir_search_is_rate_limited(api): + seed_owner() + for _ in range(20): + assert api.get("/characters/heirs?q=kir", headers=bearer()).status_code == 200 + assert api.get("/characters/heirs?q=kir", headers=bearer()).status_code == 429 + assert api.get("/characters/heirs?q=kir", headers=bearer("other")).status_code == 200 + + +# --- wardrobe and kits refuse the dead --- + + +def test_wardrobe_and_kits_refuse_non_living_characters(db): + from src.characters.lore_items import LoreItemError, list_character_kits, list_lore_items + from src.characters.wardrobe import WardrobeError, clear_slot, get_wardrobe, set_active_slot + + replace_roster(OWNER, [char("c-ana", "Ana"), char("c-dead", "Bram", "DEAD"), char("c-lost", "Cael", "MISSING")]) + assert get_wardrobe(OWNER, "c-ana")["character_id"] == "c-ana" + for cid in ("c-dead", "c-lost"): + for call in ( + lambda: get_wardrobe(OWNER, cid), + lambda: set_active_slot(OWNER, cid, None), + lambda: clear_slot(OWNER, cid, "base"), + ): + with pytest.raises(WardrobeError) as err: + call() + assert err.value.status_code == 403 + assert str(err.value) == "Character is not alive" + for call in (lambda: list_character_kits(OWNER, cid), lambda: list_lore_items(OWNER, cid)): + with pytest.raises(LoreItemError) as err: + call() + assert str(err.value) == "Character is not alive" diff --git a/backend/src/characters/wardrobe.py b/backend/src/characters/wardrobe.py index 02990a6a..96014914 100644 --- a/backend/src/characters/wardrobe.py +++ b/backend/src/characters/wardrobe.py @@ -14,6 +14,7 @@ from src.characters.wardrobe_sign import sign_wardrobe_skin from src.characters.pending_create import ( PendingCreateError, + require_living, resolve_player_character, ) from src.skins.db import DATA_DIR, WARDROBE_DIR, connect @@ -942,17 +943,21 @@ def _build_pending_wardrobe_payload( def _resolve_player_wardrobe_access( - player_uuid: str, character_id: str + player_uuid: str, character_id: str, *, living: bool = False ) -> dict[str, Any]: + """Owned roster or pending character; ``living`` refuses dead and missing ones (edits).""" try: - return resolve_player_character(player_uuid, character_id) + access = resolve_player_character(player_uuid, character_id) + if living: + require_living(access) + return access except PendingCreateError as e: raise WardrobeError(str(e), status_code=e.status_code) from e def get_wardrobe(player_uuid: str, character_id: str) -> dict[str, Any]: """Session-safe wardrobe list (no Mojang texture value/signature).""" - access = _resolve_player_wardrobe_access(player_uuid, character_id) + access = _resolve_player_wardrobe_access(player_uuid, character_id, living=True) if access["kind"] == "pending": return _build_pending_wardrobe_payload( access["player_uuid"], @@ -1017,7 +1022,7 @@ def _upload_slot_impl( create_masked: bool = False, model_override: str | None = None, ) -> dict[str, Any]: - access = _resolve_player_wardrobe_access(player_uuid, character_id) + access = _resolve_player_wardrobe_access(player_uuid, character_id, living=True) if access["kind"] == "pending": uuid = access["player_uuid"] cid = access["character_id"] @@ -1146,7 +1151,7 @@ def set_slot_display_name( display_name: str | None, ) -> dict[str, Any]: """Rename a filled slot. Does not re-sign or set apply_pending.""" - access = _resolve_player_wardrobe_access(player_uuid, character_id) + access = _resolve_player_wardrobe_access(player_uuid, character_id, living=True) uuid = access["player_uuid"] cid = access["character_id"] slot_key = _normalize_slot(slot) @@ -1235,7 +1240,7 @@ def ack_wardrobe_slots( def clear_slot( player_uuid: str, character_id: str, slot: str ) -> dict[str, Any]: - access = _resolve_player_wardrobe_access(player_uuid, character_id) + access = _resolve_player_wardrobe_access(player_uuid, character_id, living=True) if access["kind"] == "pending": uuid = access["player_uuid"] cid = access["character_id"] @@ -1333,7 +1338,7 @@ def _set_active_slot( **_require_owned_character(player_uuid, character_id), } else: - access = _resolve_player_wardrobe_access(player_uuid, character_id) + access = _resolve_player_wardrobe_access(player_uuid, character_id, living=True) uuid = str(access["player_uuid"]) cid = str(access["character_id"]) diff --git a/backend/src/characters/wills.py b/backend/src/characters/wills.py new file mode 100644 index 00000000..d2d73d3b --- /dev/null +++ b/backend/src/characters/wills.py @@ -0,0 +1,336 @@ +"""Wills changed on Profile: requests RPCharacters pulls and acknowledges, and the heir search.""" + +from __future__ import annotations + +import json +import sqlite3 +import time +import uuid as uuid_mod +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from typing import Any + +from src.characters.estates import store_will +from src.skins.codes import CodeError, normalize_realm_id +from src.skins.db import connect + +WILL_RESULTS = frozenset({"applied", "stale", "invalid heir", "invalid", "character dead"}) +# A pending request this old no longer blocks a new one (an old plugin never pulls it). +REPLACE_AFTER = timedelta(minutes=10) +# Profile shows a refused request this long after RPCharacters answered. +RESULT_SHOWN_FOR = timedelta(days=7) + +HEIR_SEARCH_MIN = 3 +HEIR_SEARCH_MAX_RESULTS = 10 +_HEIR_RATE_LIMIT = 20 +_HEIR_RATE_WINDOW_SEC = 60.0 +_heir_rate: dict[str, list[float]] = defaultdict(list) + + +class WillError(ValueError): + def __init__(self, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.status_code = status_code + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(moment: datetime) -> str: + return moment.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _parse_iso(text: str | None) -> datetime | None: + if not text: + return None + try: + return datetime.fromisoformat(str(text).replace("Z", "+00:00")) + except ValueError: + return None + + +def require_realm(realm_id: str | None) -> str: + """Plugin will routes name their realm; there is no default.""" + if not (realm_id or "").strip(): + raise WillError("realm_id is required") + try: + return normalize_realm_id(realm_id) + except CodeError as e: + raise WillError(str(e)) from e + + +def _session_realm(realm_id: str | None) -> str: + try: + return normalize_realm_id(realm_id) + except CodeError as e: + raise WillError(str(e)) from e + + +def _roster_row( + conn: sqlite3.Connection, realm: str, character_id: str, account: str | None = None +) -> sqlite3.Row | None: + sql = """ + SELECT player_uuid, character_id, name, status, will_heir_id, will_revision + FROM character_roster WHERE realm_id = ? AND character_id = ? + """ + params: tuple = (realm, character_id) + if account is not None: + sql += " AND player_uuid = ?" + params += (account,) + return conn.execute(sql + " LIMIT 1", params).fetchone() + + +def _race_name(row: sqlite3.Row) -> str | None: + try: + sheet = json.loads(row["sheet_json"] or "{}") + except (TypeError, json.JSONDecodeError): + sheet = {} + name = sheet.get("race_name") if isinstance(sheet, dict) else None + return str(name) if name else row["race"] + + +def _request_out(row: sqlite3.Row, heir_name: str | None = None) -> dict[str, Any]: + return { + "id": row["id"], + "heir_character_id": row["heir_character_id"], + "heir_name": heir_name, + "base_revision": row["base_revision"], + "status": row["status"], + "result": row["result"], + "created_at": row["created_at"], + "acked_at": row["acked_at"], + } + + +def _heir_name(conn: sqlite3.Connection, realm: str, heir_id: str | None) -> str | None: + if not heir_id: + return None + row = conn.execute( + "SELECT name FROM character_roster WHERE realm_id = ? AND character_id = ? LIMIT 1", + (realm, heir_id), + ).fetchone() + return row["name"] if row else None + + +def create_will_request( + player_uuid: str, + realm_id: str | None, + character_id: str, + heir_character_id: str | None, + base_revision: int, +) -> dict[str, Any]: + """Queue a will change for one of the player's own living characters.""" + account = (player_uuid or "").strip() + cid = (character_id or "").strip() + heir = (heir_character_id or "").strip() or None + realm = _session_realm(realm_id) + if not account or not cid: + raise WillError("character_id is required") + if isinstance(base_revision, bool) or not isinstance(base_revision, int) or base_revision < 0: + raise WillError("base_revision must be a whole number") + now = _now() + with connect() as conn: + conn.execute("BEGIN IMMEDIATE") + row = _roster_row(conn, realm, cid, account) + if row is None: + raise WillError("Character not found", status_code=403) + if str(row["status"] or "").upper() != "ALIVE": + raise WillError("character_not_alive", status_code=409) + if base_revision != int(row["will_revision"] or 0): + raise WillError("will_changed", status_code=409) + if heir is not None: + heir_row = _roster_row(conn, realm, heir) + if heir == cid or heir_row is None or str(heir_row["status"] or "").upper() != "ALIVE": + raise WillError("invalid_heir") + pending = conn.execute( + """ + SELECT id, created_at FROM will_requests + WHERE realm_id = ? AND character_id = ? AND status = 'pending' + """, + (realm, cid), + ).fetchall() + for old in pending: + created = _parse_iso(old["created_at"]) + if created is not None and now - created < REPLACE_AFTER: + raise WillError("request_pending", status_code=409) + conn.execute( + "UPDATE will_requests SET status = 'replaced' WHERE realm_id = ? AND character_id = ? AND status = 'pending'", + (realm, cid), + ) + request_id = str(uuid_mod.uuid4()) + conn.execute( + """ + INSERT INTO will_requests ( + id, realm_id, character_id, account_uuid, heir_character_id, + base_revision, created_at, status + ) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending') + """, + (request_id, realm, cid, account, heir, base_revision, _iso(now)), + ) + conn.commit() + created_row = conn.execute("SELECT * FROM will_requests WHERE id = ?", (request_id,)).fetchone() + return _request_out(created_row, _heir_name(conn, realm, heir)) + + +def list_pending_wills(realm_id: str | None) -> list[dict[str, Any]]: + realm = require_realm(realm_id) + with connect() as conn: + rows = conn.execute( + """ + SELECT * FROM will_requests + WHERE realm_id = ? AND status = 'pending' + ORDER BY created_at ASC, id ASC + """, + (realm,), + ).fetchall() + return [ + { + "id": r["id"], + "realm_id": r["realm_id"], + "character_id": r["character_id"], + "account_uuid": r["account_uuid"], + "heir_character_id": r["heir_character_id"], + "base_revision": r["base_revision"], + "created_at": r["created_at"], + } + for r in rows + ] + + +def ack_will_results(realm_id: str | None, results: Any) -> dict[str, Any]: + """Record RPCharacters' answers. Only pending requests of this realm change.""" + realm = require_realm(realm_id) + if not isinstance(results, list): + raise WillError("results must be a list") + acked: list[str] = [] + already: list[str] = [] + refused: list[dict[str, str]] = [] + now = _iso(_now()) + with connect() as conn: + conn.execute("BEGIN IMMEDIATE") + for i, raw in enumerate(results): + if not isinstance(raw, dict): + raise WillError(f"results[{i}] must be an object") + rid = str(raw.get("id") or "").strip() + result = str(raw.get("result") or "").strip().lower() + row = conn.execute("SELECT * FROM will_requests WHERE id = ?", (rid,)).fetchone() if rid else None + if row is None: + refused.append({"id": rid, "reason": "unknown"}) + continue + if row["realm_id"] != realm: + refused.append({"id": rid, "reason": "realm"}) + continue + if result not in WILL_RESULTS: + refused.append({"id": rid, "reason": "result"}) + continue + if row["status"] != "pending": + already.append(rid) + continue + conn.execute( + "UPDATE will_requests SET status = 'acked', result = ?, acked_at = ? WHERE id = ?", + (result, now, rid), + ) + acked.append(rid) + # Applying bumps the will by one; any other revision waits for the roster push, so a bad + # ack cannot pin the stored will at a revision the game never reaches. + revision = raw.get("will_revision") + if ( + result == "applied" + and isinstance(revision, int) + and not isinstance(revision, bool) + and revision == row["base_revision"] + 1 + ): + heir = row["heir_character_id"] + name = str(raw.get("heir_name") or "").strip()[:64] or _heir_name(conn, realm, heir) + store_will( + conn, + row["account_uuid"], + realm, + row["character_id"], + (heir, name if heir else None, revision), + ) + conn.commit() + return {"ok": True, "acked": acked, "already": already, "refused": refused} + + +def will_requests_for( + conn: sqlite3.Connection, realm: str, account: str, character_ids: list[str] +) -> dict[str, dict[str, Any]]: + """Each character's pending request, or its last answered one if recent and not applied.""" + if not character_ids: + return {} + marks = ", ".join("?" for _ in character_ids) + rows = conn.execute( + f""" + SELECT * FROM will_requests + WHERE realm_id = ? AND account_uuid = ? AND character_id IN ({marks}) + AND status IN ('pending', 'acked') + ORDER BY created_at DESC, id DESC + """, + (realm, account, *character_ids), + ).fetchall() + cutoff = _now() - RESULT_SHOWN_FOR + out: dict[str, dict[str, Any]] = {} + for row in rows: + cid = row["character_id"] + if cid in out: + continue + if row["status"] == "acked": + acked = _parse_iso(row["acked_at"]) + if acked is None or acked < cutoff: + out[cid] = {} + continue + out[cid] = _request_out(row, _heir_name(conn, realm, row["heir_character_id"])) + return {cid: req for cid, req in out.items() if req} + + +def check_heir_search_rate(key: str) -> None: + now = time.monotonic() + bucket = _heir_rate[key or "unknown"] + bucket[:] = [t for t in bucket if t > now - _HEIR_RATE_WINDOW_SEC] + if len(bucket) >= _HEIR_RATE_LIMIT: + raise WillError("Too many searches; try again in a minute", status_code=429) + bucket.append(now) + + +def search_heirs( + player_uuid: str, + realm_id: str | None, + query: str | None, + exclude_character_id: str | None = None, +) -> list[dict[str, Any]]: + """Living characters of the realm whose name contains the query. Never says who plays them.""" + account = (player_uuid or "").strip() + text = " ".join((query or "").split()) + if len(text) < HEIR_SEARCH_MIN: + raise WillError(f"Type at least {HEIR_SEARCH_MIN} letters") + text = text[:64] + realm = _session_realm(realm_id) + like = "%" + text.lower().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" + exclude = (exclude_character_id or "").strip() + with connect() as conn: + rows = conn.execute( + """ + SELECT character_id, name, race, sheet_json, player_uuid FROM character_roster + WHERE realm_id = ? AND UPPER(status) = 'ALIVE' + AND LOWER(name) LIKE ? ESCAPE '\\' + AND character_id != ? + ORDER BY (LOWER(name) = ?) DESC, (LOWER(name) LIKE ? ESCAPE '\\') DESC, LOWER(name), character_id + LIMIT ? + """, + (realm, like, exclude, text.lower(), like[1:], HEIR_SEARCH_MAX_RESULTS), + ).fetchall() + out = [] + for row in rows: + out.append( + { + "id": row["character_id"], + "short_id": str(row["character_id"])[:8], + "name": row["name"], + "race": _race_name(row), + "own": row["player_uuid"] == account, + } + ) + return out diff --git a/backend/src/skins/db.py b/backend/src/skins/db.py index 8414a3a4..0594614b 100644 --- a/backend/src/skins/db.py +++ b/backend/src/skins/db.py @@ -86,6 +86,16 @@ def _set_legacy_armor_sets(conn: sqlite3.Connection) -> None: ) +# Death and will fields from RPCharacters' roster push (stage 6b). +_ROSTER_DEATH_COLUMNS = ( + ("died_at", "TEXT"), + ("death_id", "TEXT"), + ("will_heir_id", "TEXT"), + ("will_heir_name", "TEXT"), + ("will_revision", "INTEGER"), +) + + def _upgrade(conn: sqlite3.Connection) -> None: """Add and drop columns that CREATE TABLE IF NOT EXISTS cannot change on old tables.""" conn.execute("BEGIN IMMEDIATE") @@ -117,6 +127,10 @@ def _upgrade(conn: sqlite3.Connection) -> None: creates = {row["name"] for row in conn.execute("PRAGMA table_info(character_creates)")} if "held_until" not in creates: conn.execute("ALTER TABLE character_creates ADD COLUMN held_until TEXT") + roster = {row["name"] for row in conn.execute("PRAGMA table_info(character_roster)")} + for column, kind in _ROSTER_DEATH_COLUMNS: + if column not in roster: + conn.execute(f"ALTER TABLE character_roster ADD COLUMN {column} {kind}") rpc_meta = {row["name"] for row in conn.execute("PRAGMA table_info(rpc_player_meta)")} if "donator_tier" in rpc_meta: conn.execute("ALTER TABLE rpc_player_meta DROP COLUMN donator_tier") diff --git a/backend/src/skins/schema.sql b/backend/src/skins/schema.sql index e5ca2456..8858d4a5 100644 --- a/backend/src/skins/schema.sql +++ b/backend/src/skins/schema.sql @@ -588,9 +588,51 @@ CREATE TABLE IF NOT EXISTS character_roster ( kit_statuses_json TEXT, sheet_json TEXT, wardrobe_active_slot TEXT, + died_at TEXT, + death_id TEXT, + will_heir_id TEXT, + will_heir_name TEXT, + will_revision INTEGER, PRIMARY KEY (player_uuid, realm_id, character_id) ); +-- One row per death, from RPCharacters' roster pushes. A push never deletes these. +CREATE TABLE IF NOT EXISTS character_estates ( + realm_id TEXT NOT NULL, + death_id TEXT NOT NULL, + player_uuid TEXT NOT NULL, + character_id TEXT NOT NULL, + status TEXT NOT NULL, + revision INTEGER NOT NULL, + source_updated_at TEXT, + payload TEXT NOT NULL, + received_at TEXT NOT NULL, + PRIMARY KEY (realm_id, death_id) +); + +CREATE INDEX IF NOT EXISTS idx_character_estates_character + ON character_estates(realm_id, character_id); + +-- Will changes made on Profile, pulled and acknowledged by RPCharacters. +CREATE TABLE IF NOT EXISTS will_requests ( + id TEXT PRIMARY KEY, + realm_id TEXT NOT NULL, + character_id TEXT NOT NULL, + account_uuid TEXT NOT NULL, + heir_character_id TEXT, + base_revision INTEGER NOT NULL, + created_at TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'acked', 'replaced')), + result TEXT, + acked_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_will_requests_pending + ON will_requests(realm_id, status, created_at); + +CREATE INDEX IF NOT EXISTS idx_will_requests_character + ON will_requests(realm_id, character_id, created_at); + CREATE TABLE IF NOT EXISTS character_player_meta ( player_uuid TEXT PRIMARY KEY, -- Age-only upserts need to leave the character limit unset. diff --git a/frontend/app/character/[id]/CharacterIdLayoutClient.tsx b/frontend/app/character/[id]/CharacterIdLayoutClient.tsx index 54c6a186..b23d5279 100644 --- a/frontend/app/character/[id]/CharacterIdLayoutClient.tsx +++ b/frontend/app/character/[id]/CharacterIdLayoutClient.tsx @@ -1,11 +1,13 @@ "use client"; import { useEffect, useState } from "react"; +import Link from "next/link"; import { useParams } from "next/navigation"; import PendingServerBanner from "../../components/character/PendingServerBanner"; import { listCharacters } from "../../../lib/characters/api"; import { getSession, isSessionValid } from "../../../lib/characters/session"; import { isCharacterUiDev } from "../../../lib/characters/uiDev"; +import { characterPath } from "../../../lib/profile/paths"; type Props = { children: React.ReactNode; @@ -15,16 +17,16 @@ export default function CharacterIdLayoutClient({ children }: Props) { const params = useParams(); const characterId = String(params?.id || "").trim(); const uiDev = isCharacterUiDev(); - const [isPending, setIsPending] = useState(false); + const [status, setStatus] = useState(""); useEffect(() => { if (!characterId || uiDev) { - setIsPending(false); + setStatus(""); return; } const session = getSession(); if (!session || !isSessionValid(session) || session.scope !== "profile") { - setIsPending(false); + setStatus(""); return; } let cancelled = false; @@ -32,19 +34,34 @@ export default function CharacterIdLayoutClient({ children }: Props) { .then((list) => { if (cancelled) return; const row = list.characters.find((c) => c.id === characterId) || null; - setIsPending(String(row?.status || "").toLowerCase() === "pending"); + setStatus(String(row?.status || "").toUpperCase()); }) .catch(() => { - if (!cancelled) setIsPending(false); + if (!cancelled) setStatus(""); }); return () => { cancelled = true; }; }, [characterId, uiDev]); + // Wardrobe and kits are for the living; the site refuses changes to the dead too. + if (status === "DEAD" || status === "MISSING") { + return ( +

+ {status === "DEAD" ? "This character has died." : "This character is missing."}{" "} + + Back to the character + +

+ ); + } + return ( <> - {isPending ? : null} + {status === "PENDING" ? : null} {children} ); diff --git a/frontend/app/components/admin/PlayerCharacters.test.tsx b/frontend/app/components/admin/PlayerCharacters.test.tsx index 14fd5c92..b336b405 100644 --- a/frontend/app/components/admin/PlayerCharacters.test.tsx +++ b/frontend/app/components/admin/PlayerCharacters.test.tsx @@ -98,6 +98,28 @@ it("opens the shared full sheet from its address, focuses the name and keeps the expect(document.querySelector("img")?.getAttribute("src")).toContain("/account/minecraft/head?u=p-1"); }); +it("keeps staff cards and sheets read only when the roster carries a living will", () => { + const withWill = { + ...profile, + characters: [{ ...profile.characters[0], will: { heir_id: "heir", heir_name: "Kira", revision: 3 } }], + }; + const { rerender } = render(); + expect(screen.getByRole("link", { name: /Hazel Human/ })).toBeTruthy(); + expect(screen.queryByText("Kira")).toBeNull(); + expect(screen.queryByRole("button", { name: "Change" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Revoke" })).toBeNull(); + nav.query = "tab=characters&character=c1&realm=main"; + rerender(); + expect(screen.getByRole("heading", { name: "Hazel", level: 2 })).toBeTruthy(); + expect(screen.getByText("A traveller in green.")).toBeTruthy(); + expect(screen.queryByText("Kira")).toBeNull(); + expect(screen.queryByRole("button", { name: "Change" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Revoke" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Wardrobe" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Kits" })).toBeNull(); + expect(characterFigure).not.toHaveBeenCalled(); +}); + it("keeps characters with the same id in different realms separate when navigating", () => { nav.query = "tab=characters&character=c1&realm=main"; const { rerender } = render(); diff --git a/frontend/app/components/character/CharacterList.test.tsx b/frontend/app/components/character/CharacterList.test.tsx index 7a8fc1cf..21bebcfd 100644 --- a/frontend/app/components/character/CharacterList.test.tsx +++ b/frontend/app/components/character/CharacterList.test.tsx @@ -1,12 +1,13 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; -import { characterFace, type CharacterListItem } from "../../../lib/characters/api"; +import { characterFace, requestWill, type CharacterListItem } from "../../../lib/characters/api"; import CharacterList from "./CharacterList"; vi.mock("../../../lib/characters/api", async (original) => ({ ...(await original()), characterFace: vi.fn(), + requestWill: vi.fn(), })); vi.mock("../../../lib/account/api", () => ({ minecraftHeadUrl: () => "https://api.test/account/minecraft/head" })); @@ -102,11 +103,15 @@ it("reads the faces again when the list reloads, and never shows another session await waitFor(() => expect(portrait("Vaelthir")?.getAttribute("src")).toBe("data:image/png;base64,other")); }); -it("opens a character inside the tab, and only living or pending ones", () => { +it("opens a character inside the tab, the dead read only, but not a rejected one", () => { render( , @@ -119,5 +124,62 @@ it("opens a character inside the tab, and only living or pending ones", () => { fireEvent.click(card, { ctrlKey: true }); expect(open).toHaveBeenCalledTimes(1); expect(screen.getByText("Mira").closest("a")).toBeTruthy(); - expect(screen.getByText("Old Tamsin").closest("a")).toBeNull(); + expect(screen.getByText("Old Tamsin").closest("a")?.getAttribute("href")).toBe( + "/profile?tab=characters&character=c4", + ); + expect(screen.getByText("Nope").closest("a")).toBeNull(); +}); + +it("shows each living character's will under its card", () => { + vi.mocked(characterFace).mockResolvedValue(null); + const withWill = [ + { id: "c1", name: "Vaelthir", status: "ALIVE", will: { heir_id: "c9", heir_name: "Kira", revision: 1 } }, + { id: "c2", name: "Brin", status: "ALIVE", will: { heir_id: null, heir_name: null, revision: 0 } }, + { id: "c4", name: "Old Tamsin", status: "DEAD" }, + ] as CharacterListItem[]; + render(); + const vaelthir = screen.getByText("Vaelthir").closest("li")!; + expect(vaelthir.textContent).toContain("Heir: Kira"); + // The will sits beside the card's link, not inside it. + expect(screen.getByText("Kira").closest("a")).toBeNull(); + expect(screen.getByText("Brin").closest("li")!.textContent).toContain("No will"); + expect(screen.getByText("Old Tamsin").closest("li")!.textContent).not.toMatch(/will|Heir/); +}); + +it.each([true, false])("refreshes Profile after a will revoke with an explicit callback: %s", async (explicit) => { + vi.mocked(characterFace).mockResolvedValue(null); + vi.mocked(requestWill).mockResolvedValue({ + id: "request-1", + heir_character_id: null, + heir_name: null, + base_revision: 3, + status: "pending", + result: null, + created_at: "2026-10-13T17:00:00Z", + acked_at: null, + }); + const refresh = vi.fn(); + const changed = vi.fn(); + const character = { + id: "c1", + name: "Vaelthir", + status: "ALIVE", + will: { heir_id: "c9", heir_name: "Kira", revision: 3 }, + } as CharacterListItem; + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Revoke" })); + await waitFor(() => expect(requestWill).toHaveBeenCalledWith("profile-session", "c1", null, 3)); + await waitFor(() => expect(explicit ? changed : refresh).toHaveBeenCalledTimes(1)); + expect(explicit ? refresh : changed).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); }); diff --git a/frontend/app/components/character/CharacterList.tsx b/frontend/app/components/character/CharacterList.tsx index 38cff8af..ad534010 100644 --- a/frontend/app/components/character/CharacterList.tsx +++ b/frontend/app/components/character/CharacterList.tsx @@ -7,6 +7,7 @@ import { characterFace, type CharacterListItem, type CreationCatalog } from "../ import { displayClass, displayRace } from "../../../lib/characters/displayNames"; import { characterPath } from "../../../lib/profile/paths"; import { OPENABLE } from "../profile/ProfileCharacter"; +import CharacterWill from "./CharacterWill"; type Props = { characters: CharacterListItem[]; @@ -17,6 +18,8 @@ type Props = { onOpen: (id: string) => void; onRefresh?: () => void; refreshing?: boolean; + /** After a will change is sent. */ + onWillChanged?: () => void; /** Reads each character's wardrobe for its face. */ sessionToken?: string | null; /** The player's own head stands in for a character without a skin. */ @@ -27,7 +30,7 @@ type Props = { type Faces = Record; const cardClass = - "flex h-full min-h-[84px] items-center gap-4 rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_12%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest-deep)_50%,transparent)] p-3 pr-4"; + "flex min-w-0 flex-1 min-h-[84px] items-center gap-4 rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_12%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest-deep)_50%,transparent)] p-3 pr-4"; const linkCardClass = "group transition-colors hover:border-[color-mix(in_srgb,var(--tfmc-cream)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--tfmc-cream)_4%,transparent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--tfmc-cream)]"; const tileClass = @@ -90,6 +93,8 @@ export function CharacterCard({ face, playerUuid, onOpen, + sessionToken, + onWillChanged, href, realmLabel, }: { @@ -98,6 +103,8 @@ export function CharacterCard({ face: string | null | undefined; playerUuid?: string | null; onOpen?: (id: string) => void; + sessionToken?: string | null; + onWillChanged?: () => void; /** Staff cards stay in the player's frame and can open historical characters. */ href?: string; realmLabel?: string; @@ -109,6 +116,7 @@ export function CharacterCard({ const chip = CHIPS[status]; const linkable = Boolean(item.id) && (Boolean(href) || OPENABLE.has(status)); const faded = status === "DEAD" || status === "MISSING" || status === "REJECTED"; + const willShown = status === "ALIVE" && Boolean(item.will && sessionToken); const body = ( <> @@ -139,19 +147,22 @@ export function CharacterCard({ onOpen(item.id); } return ( -
  • +
  • {linkable ? ( {body} ) : (
    {body}
    )} + {willShown ? ( + {})} /> + ) : null}
  • ); } @@ -254,6 +265,7 @@ export default function CharacterList({ onOpen, onRefresh, refreshing = false, + onWillChanged, sessionToken = null, playerUuid = null, }: Props) { @@ -292,6 +304,8 @@ export default function CharacterList({ face={faces[c.id]} playerUuid={playerUuid} onOpen={onOpen} + sessionToken={sessionToken} + onWillChanged={onWillChanged ?? onRefresh} /> )} createSlot={ diff --git a/frontend/app/components/character/CharacterMemorial.tsx b/frontend/app/components/character/CharacterMemorial.tsx new file mode 100644 index 00000000..cfa31623 --- /dev/null +++ b/frontend/app/components/character/CharacterMemorial.tsx @@ -0,0 +1,82 @@ +"use client"; + +import type { Ref } from "react"; +import type { CharacterListItem, CreationCatalog } from "../../../lib/characters/api"; +import { displayClass, displayRace } from "../../../lib/characters/displayNames"; +import { + ESTATE_PARTS, + estateMoved, + estateStatusLine, + formatDay, + formatDenars, + recipientLabel, +} from "../../../lib/characters/wills"; +import CharacterFigure from "./CharacterFigure"; + +type Props = { + character: CharacterListItem; + catalog?: CreationCatalog | null; + sessionToken?: string | null; + playerUuid?: string | null; + headingRef?: Ref; +}; + +const labelClass = "text-xs font-bold uppercase tracking-[0.08em] text-[var(--tfmc-stone)]"; + +function Row({ label, value }: { label: string; value: string }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} + +/** A dead or missing character: when they died, their will and where their estate went. Read only. */ +export default function CharacterMemorial({ character, catalog, sessionToken = null, playerUuid, headingRef }: Props) { + const name = character.name || "Unnamed"; + const missing = String(character.status || "").toUpperCase() === "MISSING"; + const lineage = [displayRace(character, catalog), displayClass(character, catalog)].filter(Boolean).join(" · "); + const estate = character.estate ?? null; + const died = missing ? "Missing" : (formatDay(character.died_at) ?? "Before records began"); + + const rows: [string, string][] = [[missing ? "Status" : "Died", died]]; + if (estate) { + rows.push(["Heir", estate.heir_name || "No will"]); + rows.push(["Estate", estateStatusLine(estate)]); + for (const [part, label] of ESTATE_PARTS) { + const to = recipientLabel(estate.recipients?.[part]); + if (!to) continue; + const cents = part === "money" ? estate.money_moved_cents : null; + rows.push([label, cents ? `${to} (${formatDenars(cents)})` : to]); + } + const moved = estateMoved(estate); + if (moved) rows.push(["Moved", moved]); + } + + return ( +
    +
    + +
    +
    +

    + {name} +

    + {lineage ?

    {lineage}

    : null} +
    +
    + {rows.map(([label, value]) => ( + + ))} +
    +
    + ); +} diff --git a/frontend/app/components/character/CharacterWill.test.tsx b/frontend/app/components/character/CharacterWill.test.tsx new file mode 100644 index 00000000..78ffd2ff --- /dev/null +++ b/frontend/app/components/character/CharacterWill.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { CharactersApiError, requestWill, searchHeirs, type CharacterListItem } from "../../../lib/characters/api"; +import CharacterWill from "./CharacterWill"; + +vi.mock("../../../lib/characters/api", async (original) => ({ + ...(await original()), + requestWill: vi.fn(), + searchHeirs: vi.fn(), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function character(extra: Partial = {}): CharacterListItem { + return { + id: "c-ana", + name: "Ana", + status: "ALIVE", + will: { heir_id: "c-kira", heir_name: "Kira", revision: 3 }, + ...extra, + }; +} + +it("shows the heir with Change and Revoke, and revokes against the shown revision", async () => { + vi.mocked(requestWill).mockResolvedValue({} as never); + const changed = vi.fn(); + render(); + expect(screen.getByText("Kira")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Revoke" })); + await waitFor(() => expect(changed).toHaveBeenCalled()); + expect(requestWill).toHaveBeenCalledWith("t", "c-ana", null, 3); +}); + +it("says No will and offers no Revoke", () => { + render( + , + ); + expect(screen.getByText("No will")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Revoke" })).toBeNull(); +}); + +it("searches from three letters and names the picked heir", async () => { + vi.mocked(searchHeirs).mockResolvedValue([ + { id: "c-mara-1234", short_id: "c-mara-1", name: "Mara", race: "Elf", own: true }, + ]); + vi.mocked(requestWill).mockResolvedValue({} as never); + const changed = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Change" })); + const input = screen.getByPlaceholderText("Heir’s name"); + fireEvent.change(input, { target: { value: "ma" } }); + await new Promise((r) => setTimeout(r, 400)); + expect(searchHeirs).not.toHaveBeenCalled(); + fireEvent.change(input, { target: { value: "mar" } }); + const pick = await screen.findByRole("button", { name: /Mara/ }); + expect(pick.textContent).toContain("Elf · c-mara-1 · yours"); + expect(searchHeirs).toHaveBeenCalledWith("t", "mar", "c-ana"); + fireEvent.click(pick); + await waitFor(() => expect(changed).toHaveBeenCalled()); + expect(requestWill).toHaveBeenCalledWith("t", "c-ana", "c-mara-1234", 3); +}); + +it("shows a waiting change instead of the buttons", () => { + render( + , + ); + expect(screen.getByRole("status").textContent).toBe("Pending: Mara"); + expect(screen.queryByRole("button", { name: "Change" })).toBeNull(); +}); + +it("shows why the server refused the last change", () => { + render( + , + ); + expect(screen.getByText("Not saved: the will changed in game.")).toBeTruthy(); +}); + +it("words the site's refusals", async () => { + vi.mocked(requestWill).mockRejectedValue(new CharactersApiError("request_pending", 409)); + render(); + fireEvent.click(screen.getByRole("button", { name: "Revoke" })); + expect((await screen.findByRole("alert")).textContent).toBe("A change is already waiting for the server."); +}); diff --git a/frontend/app/components/character/CharacterWill.tsx b/frontend/app/components/character/CharacterWill.tsx new file mode 100644 index 00000000..cfb247bd --- /dev/null +++ b/frontend/app/components/character/CharacterWill.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useEffect, useId, useState } from "react"; +import { + CharactersApiError, + requestWill, + searchHeirs, + type CharacterListItem, + type HeirMatch, +} from "../../../lib/characters/api"; +import { willErrorMessage, willResultMessage } from "../../../lib/characters/wills"; + +type Props = { + character: CharacterListItem; + sessionToken: string; + /** After a change is sent, so the list shows it waiting. */ + onChanged: () => void; +}; + +const SEARCH_MIN = 3; +const SEARCH_DELAY_MS = 300; + +const buttonClass = + "min-h-9 rounded-sm px-2 text-sm text-[var(--tfmc-stone)] underline-offset-2 hover:text-[var(--tfmc-cream)] hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--tfmc-cream)] disabled:opacity-50"; +const inputClass = + "w-full rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_22%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest-deep)_70%,transparent)] px-3 py-2 text-sm text-[var(--tfmc-cream)] placeholder:text-[var(--tfmc-stone)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--tfmc-cream)]"; +const matchClass = + "flex w-full min-h-11 items-center gap-2 rounded-sm px-2 text-left text-sm text-[var(--tfmc-cream)] hover:bg-[color-mix(in_srgb,var(--tfmc-cream)_6%,transparent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--tfmc-cream)] disabled:opacity-50"; + +function message(e: unknown): string { + return e instanceof CharactersApiError ? willErrorMessage(e.message) : "Couldn’t reach the site. Try again."; +} + +/** Searches living characters by name once three letters are typed. */ +function useHeirSearch(sessionToken: string, characterId: string, query: string) { + const [state, setState] = useState<{ query: string; matches: HeirMatch[]; error: string | null } | null>(null); + const trimmed = query.trim(); + useEffect(() => { + if (trimmed.length < SEARCH_MIN) return; + let live = true; + const timer = window.setTimeout(() => { + searchHeirs(sessionToken, trimmed, characterId) + .then((matches) => live && setState({ query: trimmed, matches, error: null })) + .catch((e) => live && setState({ query: trimmed, matches: [], error: message(e) })); + }, SEARCH_DELAY_MS); + return () => { + live = false; + window.clearTimeout(timer); + }; + }, [sessionToken, characterId, trimmed]); + return trimmed.length >= SEARCH_MIN && state?.query === trimmed ? state : null; +} + +/** A living character's will on its Profile card: the heir, or none, with Change and Revoke. */ +export default function CharacterWill({ character, sessionToken, onChanged }: Props) { + const will = character.will; + const request = character.will_request; + const [picking, setPicking] = useState(false); + const [query, setQuery] = useState(""); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const search = useHeirSearch(sessionToken, character.id, picking ? query : ""); + const inputId = useId(); + if (!will) return null; + const pending = request?.status === "pending"; + const refused = request?.status === "acked" ? willResultMessage(request.result) : null; + + async function send(heirId: string | null) { + if (!will || sending) return; + setSending(true); + setError(null); + try { + await requestWill(sessionToken, character.id, heirId, will.revision); + setPicking(false); + setQuery(""); + onChanged(); + } catch (e) { + setError(message(e)); + } finally { + setSending(false); + } + } + + return ( +
    +
    +

    + {will.heir_id ? ( + <> + Heir: {will.heir_name || will.heir_id.slice(0, 8)} + + ) : ( + "No will" + )} +

    + {pending ? ( +

    + {request.heir_character_id ? `Pending: ${request.heir_name || "new heir"}` : "Pending: no will"} +

    + ) : ( + + + {will.heir_id ? ( + + ) : null} + + )} +
    + {refused && !picking ?

    {refused}

    : null} + {error ? ( +

    + {error} +

    + ) : null} + {picking && !pending ? ( +
    + + setQuery(e.target.value)} + className={inputClass} + /> + {search?.error ?

    {search.error}

    : null} + {search && !search.error && !search.matches.length ? ( +

    No living character by that name.

    + ) : null} + {search?.matches.length ? ( +
      + {search.matches.map((m) => ( +
    • + +
    • + ))} +
    + ) : null} +
    + ) : null} +
    + ); +} diff --git a/frontend/app/components/profile/ProfileCharacter.tsx b/frontend/app/components/profile/ProfileCharacter.tsx index 02243db4..215592e6 100644 --- a/frontend/app/components/profile/ProfileCharacter.tsx +++ b/frontend/app/components/profile/ProfileCharacter.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import CharacterMemorial from "../character/CharacterMemorial"; import CharacterSheet from "../character/CharacterSheet"; import { CharactersApiError, @@ -10,8 +11,9 @@ import { } from "../../../lib/characters/api"; import { ItemFrame } from "./ProfileItems"; -/** Characters that open in the tab; the rest only show on their card. */ -export const OPENABLE = new Set(["ALIVE", "PENDING"]); +/** Characters that open in the tab; the rest only show on their card. Dead and missing ones open read only. */ +export const OPENABLE = new Set(["ALIVE", "PENDING", "DEAD", "MISSING"]); +const MEMORIAL = new Set(["DEAD", "MISSING"]); type Props = { /** The dashboard's row for the address's id, or null when Profile has none. */ @@ -87,15 +89,25 @@ export default function ProfileCharacter({ {error}

    ) : null} - + {MEMORIAL.has(status) ? ( + + ) : ( + + )} diff --git a/frontend/app/components/profile/ProfilePanel.test.tsx b/frontend/app/components/profile/ProfilePanel.test.tsx index d450b6a8..6060cb3b 100644 --- a/frontend/app/components/profile/ProfilePanel.test.tsx +++ b/frontend/app/components/profile/ProfilePanel.test.tsx @@ -868,7 +868,23 @@ it("lays skins out as a wardrobe and starts a new one without a code", async () const CHARACTERS = [ { id: "c1", status: "ALIVE", name: "Vaelthir", description: "A quiet scout." }, { id: "c2", status: "pending", name: "Mira" }, - { id: "c3", status: "DEAD", name: "Old Tamsin" }, + { + id: "c3", + status: "DEAD", + name: "Old Tamsin", + died_at: null, + estate: { + death_id: "d-3", + status: "DECIDED", + revision: 0, + due_at: "2026-10-14T09:00:00Z", + decided_at: "2026-10-14T09:01:00Z", + heir_name: "Kira", + recipients: { money: { kind: "CHARACTER", name: "Kira" }, guild_locks: { kind: "WAITING", name: null } }, + money_moved_cents: 34025, + participants: { thievery: { outcome: "DONE", revision: 0, counts: { locks: 297 } } }, + }, + }, ] as unknown as ProfileDashboard["characters"]; it("opens a character in its tab, and Back returns to the list", async () => { @@ -897,12 +913,29 @@ it("opens a character in its tab, and Back returns to the list", async () => { it("sends a character's address back to the list when it can't be opened", async () => { vi.mocked(getAccount).mockResolvedValue(account({ minecraft: linked() })); vi.mocked(getProfileDashboard).mockResolvedValue(wardrobe({ characters: CHARACTERS })); - window.history.replaceState(null, "", "/profile?tab=characters&character=c3"); + window.history.replaceState(null, "", "/profile?tab=characters&character=gone"); render(); expect(await screen.findByRole("link", { name: /Vaelthir/ })).toBeTruthy(); expect(window.location.search).toBe(""); }); +it("opens a dead character's memorial: death, heir and where the estate went", async () => { + vi.mocked(getAccount).mockResolvedValue(account({ minecraft: linked() })); + vi.mocked(getProfileDashboard).mockResolvedValue(wardrobe({ characters: CHARACTERS })); + render(); + fireEvent.click(await screen.findByRole("link", { name: /Old Tamsin/ })); + expect(await screen.findByRole("heading", { level: 2, name: "Old Tamsin" })).toBeTruthy(); + expect(window.location.search).toBe("?tab=characters&character=c3"); + const value = (label: string) => screen.getByText(label, { selector: "dt" }).nextElementSibling?.textContent; + expect(value("Died")).toBe("Before records began"); + expect(value("Heir")).toBe("Kira"); + expect(value("Estate")).toBe("Passed on 14 Oct 2026"); + expect(value("Money")).toBe("Kira (340.25)"); + expect(value("Guild locks")).toBe("Waiting for a guild leader"); + expect(value("Moved")).toBe("297 locks"); + expect(screen.queryByRole("link", { name: /Wardrobe/ })).toBeNull(); +}); + it("cancels a pending character from its sheet and returns to the list", async () => { vi.mocked(getAccount).mockResolvedValue(account({ minecraft: linked() })); vi.mocked(getProfileDashboard).mockResolvedValue(wardrobe({ characters: CHARACTERS })); diff --git a/frontend/app/components/profile/ProfilePanel.tsx b/frontend/app/components/profile/ProfilePanel.tsx index 13de3064..e1452ae0 100644 --- a/frontend/app/components/profile/ProfilePanel.tsx +++ b/frontend/app/components/profile/ProfilePanel.tsx @@ -96,7 +96,7 @@ function hasPending(dashboard: ProfileDashboard | null): boolean { if (!dashboard) return false; const status = (value: unknown) => String(value || "").toLowerCase(); return ( - dashboard.characters.some((c) => status(c.status) === "pending") || + dashboard.characters.some((c) => status(c.status) === "pending" || c.will_request?.status === "pending") || dashboard.skins.some((s) => s.status === "pending") || dashboard.drinks.some((d) => d.status === "pending") || dashboard.custom_items.some((i) => i.state === "pending_skin" || status(i.submission_status) === "pending") @@ -743,6 +743,7 @@ export default function ProfilePanel({ maxSlots={dashboard.max_alive_characters ?? 3} catalog={catalog} onRefresh={() => token && void loadDashboard(token)} + onWillChanged={() => token && void loadDashboard(token, { quiet: true })} refreshing={loadingDashboard} sessionToken={token} playerUuid={minecraft?.player_uuid} diff --git a/frontend/lib/characters/api.ts b/frontend/lib/characters/api.ts index fa539755..31d64d46 100644 --- a/frontend/lib/characters/api.ts +++ b/frontend/lib/characters/api.ts @@ -278,6 +278,57 @@ export type CharacterListItem = { clues?: string[] | null; /** Profile only: the name of the skin the character wears, null with none. */ worn_skin?: string | null; + /** ISO time of the latest death; null for legacy deaths. */ + died_at?: string | null; + death_id?: string | null; + /** Living characters: the will as the server last reported it. */ + will?: CharacterWill; + /** Living characters: a Profile change waiting for the server, or its recent refusal. */ + will_request?: WillRequest; + /** Dead and missing characters: the estate of the latest death, null before the server sends one. */ + estate?: CharacterEstate | null; +}; + +export type CharacterWill = { + heir_id: string | null; + heir_name: string | null; + revision: number; +}; + +export type WillResult = "applied" | "stale" | "invalid heir" | "invalid" | "character dead"; + +export type WillRequest = { + id: string; + heir_character_id: string | null; + heir_name: string | null; + base_revision: number; + status: "pending" | "acked"; + result: WillResult | null; + created_at: string; + acked_at: string | null; +}; + +export type EstateRecipient = { kind: string; name: string | null }; + +export type CharacterEstate = { + death_id: string; + status: "OPEN" | "HELD" | "CANCELLED" | "DECIDED" | "REVERTED"; + revision: number; + due_at: string | null; + decided_at: string | null; + heir_name: string | null; + recipients: Partial>; + money_moved_cents: number | null; + participants: Record }>; +}; + +export type HeirMatch = { + id: string; + short_id: string; + name: string; + race: string | null; + /** One of the signed-in player's own characters. */ + own: boolean; }; export type CharacterListResponse = { @@ -938,3 +989,35 @@ export function characterFace(sessionToken: string, characterId: string): Promis export function characterFigure(sessionToken: string, characterId: string): Promise { return characterPicture(sessionToken, characterId, "figure"); } + +/** Ask the server to name an heir (null revokes) against the will revision the page showed. */ +export async function requestWill( + sessionToken: string, + characterId: string, + heirId: string | null, + baseRevision: number, +): Promise { + const id = encodeURIComponent(characterId.trim()); + const res = await apiFetch(`${getApiBase()}/characters/${id}/will`, { + method: "POST", + headers: { ...authHeaders(sessionToken), "Content-Type": "application/json" }, + body: JSON.stringify({ heir_character_id: heirId, base_revision: baseRevision }), + }); + const data = await parseJson(res); + if (!res.ok) { + throw new CharactersApiError(detailMessage(data, `Will change failed (${res.status})`), res.status); + } + return (data as { request: WillRequest }).request; +} + +/** Living characters on this server whose name contains the query (three letters or more). */ +export async function searchHeirs(sessionToken: string, query: string, characterId: string): Promise { + const params = new URLSearchParams({ q: query, character_id: characterId }); + const res = await apiFetch(`${getApiBase()}/characters/heirs?${params}`, { headers: authHeaders(sessionToken) }); + const data = await parseJson(res); + if (!res.ok) { + throw new CharactersApiError(detailMessage(data, `Search failed (${res.status})`), res.status); + } + const results = (data as { results?: unknown }).results; + return Array.isArray(results) ? (results as HeirMatch[]) : []; +} diff --git a/frontend/lib/characters/wills.test.ts b/frontend/lib/characters/wills.test.ts new file mode 100644 index 00000000..b586f2e9 --- /dev/null +++ b/frontend/lib/characters/wills.test.ts @@ -0,0 +1,69 @@ +import { expect, it } from "vitest"; +import type { CharacterEstate } from "./api"; +import { estateMoved, estateStatusLine, formatDay, recipientLabel, willErrorMessage, willResultMessage } from "./wills"; + +function estate(extra: Partial = {}): CharacterEstate { + return { + death_id: "d", + status: "OPEN", + revision: 0, + due_at: "2026-10-14T12:00:00Z", + decided_at: null, + heir_name: null, + recipients: {}, + money_moved_cents: null, + participants: {}, + ...extra, + }; +} + +it("words each estate state", () => { + expect(estateStatusLine(estate())).toBe("Passes on 14 Oct 2026"); + expect(estateStatusLine(estate({ due_at: null }))).toBe("Not passed on yet"); + expect(estateStatusLine(estate({ status: "HELD" }))).toBe("Held by staff"); + expect(estateStatusLine(estate({ status: "CANCELLED" }))).toBe("Cancelled: brought back to life"); + expect(estateStatusLine(estate({ status: "DECIDED", decided_at: "2026-10-15T00:30:00Z" }))).toBe( + "Passed on 15 Oct 2026", + ); + expect(estateStatusLine(estate({ status: "REVERTED" }))).toBe("Stopped by staff"); +}); + +it("names recipients, and staff or waiting parts by what happens to them", () => { + expect(recipientLabel({ kind: "CHARACTER", name: "Kira" })).toBe("Kira"); + expect(recipientLabel({ kind: "GUILD", name: "Rat Hill" })).toBe("Rat Hill"); + expect(recipientLabel({ kind: "FROZEN", name: null })).toBe("Held by staff"); + expect(recipientLabel({ kind: "WAITING", name: null })).toBe("Waiting for a guild leader"); + expect(recipientLabel({ kind: "SENDER", name: null })).toBe("Returned to senders"); + expect(recipientLabel({ kind: "CHARACTER", name: null })).toBeNull(); + expect(recipientLabel(undefined)).toBeNull(); +}); + +it("sums what each part of the server moved", () => { + const moved = estateMoved( + estate({ + participants: { + thievery: { outcome: "DONE", revision: 0, counts: { locks: 290, doors: 0 } }, + "thievery-furniture": { outcome: "DONE", revision: 0, counts: { locks: 7 } }, + "simplefactions-vehicles": { outcome: "RETRY", revision: 0, counts: { vehicles: 2 } }, + }, + }), + ); + expect(moved).toBe("297 locks, 2 vehicles"); + expect(estateMoved(estate())).toBeNull(); + const one = estateMoved( + estate({ participants: { bartershops: { outcome: "DONE", revision: 0, counts: { shops: 1 } } } }), + ); + expect(one).toBe("1 shop"); + const entity = estateMoved( + estate({ participants: { thievery: { outcome: "DONE", revision: 0, counts: { entities: 1, doors: 3 } } } }), + ); + expect(entity).toBe("1 entity, 3 doors"); +}); + +it("words will results and refusals", () => { + expect(willResultMessage("applied")).toBeNull(); + expect(willResultMessage("invalid heir")).toBe("Not saved: that heir can’t inherit."); + expect(willErrorMessage("will_changed")).toBe("The will changed in game. Refresh and try again."); + expect(willErrorMessage("Something else")).toBe("Something else"); + expect(formatDay("nonsense")).toBeNull(); +}); diff --git a/frontend/lib/characters/wills.ts b/frontend/lib/characters/wills.ts new file mode 100644 index 00000000..5dfa8ec9 --- /dev/null +++ b/frontend/lib/characters/wills.ts @@ -0,0 +1,108 @@ +import type { CharacterEstate, EstateRecipient, WillResult } from "./api"; + +/** Why the server refused a will change made here. */ +export function willResultMessage(result: WillResult | null): string | null { + switch (result) { + case "stale": + return "Not saved: the will changed in game."; + case "invalid heir": + return "Not saved: that heir can’t inherit."; + case "invalid": + return "Not saved: the server doesn’t know this character."; + case "character dead": + return "Not saved: this character has died."; + default: + return null; + } +} + +/** The site's refusals, in words. */ +export function willErrorMessage(detail: string): string { + switch (detail) { + case "will_changed": + return "The will changed in game. Refresh and try again."; + case "request_pending": + return "A change is already waiting for the server."; + case "character_not_alive": + return "This character has died."; + case "invalid_heir": + return "That character can’t inherit."; + default: + return detail; + } +} + +const DATE = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }); + +export function formatDay(iso: string | null | undefined): string | null { + if (!iso) return null; + const moment = new Date(iso); + return Number.isNaN(moment.getTime()) ? null : DATE.format(moment); +} + +/** "Passes on 14 Oct 2026", "Passed on 14 Oct 2026", and so on. */ +export function estateStatusLine(estate: CharacterEstate): string { + switch (estate.status) { + case "OPEN": { + const due = formatDay(estate.due_at); + return due ? `Passes on ${due}` : "Not passed on yet"; + } + case "HELD": + return "Held by staff"; + case "CANCELLED": + return "Cancelled: brought back to life"; + case "DECIDED": { + const decided = formatDay(estate.decided_at); + return decided ? `Passed on ${decided}` : "Passed on"; + } + case "REVERTED": + return "Stopped by staff"; + default: + return ""; + } +} + +export function recipientLabel(recipient: EstateRecipient | undefined): string | null { + if (!recipient) return null; + switch (recipient.kind) { + case "FROZEN": + return "Held by staff"; + case "WAITING": + return "Waiting for a guild leader"; + case "SENDER": + return "Returned to senders"; + case "NONE": + return "Nothing"; + default: + return recipient.name || null; + } +} + +export const ESTATE_PARTS = [ + ["money", "Money"], + ["property", "Property"], + ["guild_locks", "Guild locks"], + ["letters", "Letters"], +] as const; + +export function formatDenars(cents: number): string { + return (cents / 100).toLocaleString("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +/** Labels arrive plural ("locks", "entities"); one of a kind reads "1 lock", "1 entity". */ +function singular(label: string): string { + if (label.endsWith("ies")) return `${label.slice(0, -3)}y`; + return label.endsWith("s") ? label.slice(0, -1) : label; +} + +/** What the estate moved, summed over every part of the server: "297 locks, 2 vehicles". */ +export function estateMoved(estate: CharacterEstate): string | null { + const totals = new Map(); + for (const participant of Object.values(estate.participants || {})) { + for (const [label, count] of Object.entries(participant?.counts || {})) { + if (Number.isFinite(count) && count > 0) totals.set(label, (totals.get(label) ?? 0) + count); + } + } + if (!totals.size) return null; + return [...totals].map(([label, count]) => `${count} ${count === 1 ? singular(label) : label}`).join(", "); +}