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 ?{lineage}
: null} ++ {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}
: null} + {error ? ( ++ {error} +
+ ) : null} + {picking && !pending ? ( +{search.error}
: null} + {search && !search.error && !search.matches.length ? ( +No living character by that name.
+ ) : null} + {search?.matches.length ? ( +