From 7678bbf889e58818c45661cfd87e09b11fe1f5e6 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Tue, 13 Oct 2026 14:04:34 +0000
Subject: [PATCH 1/5] feat(characters): store deaths, wills and estates, and
queue will changes from Profile
The roster push now carries each character's death, will and estate.
Estates live in character_estates keyed by realm and death, so a push never
deletes them, and an older will or estate revision never replaces a newer one.
Profile can ask RPCharacters to name an heir or revoke a will through a
pull-and-ack queue like web creation. The plugin routes require a realm and
check it against the request's. A heir search lists living characters of the
player's realm by name, without saying who plays them.
Co-Authored-By: Claude Opus 5.5
---
backend/src/api/characters_routes.py | 87 ++++
backend/src/characters/creates.py | 10 +
backend/src/characters/estates.py | 289 +++++++++++
backend/src/characters/roster.py | 45 +-
backend/src/characters/test_wills_estates.py | 504 +++++++++++++++++++
backend/src/characters/wills.py | 329 ++++++++++++
backend/src/skins/db.py | 14 +
backend/src/skins/schema.sql | 42 ++
8 files changed, 1319 insertions(+), 1 deletion(-)
create mode 100644 backend/src/characters/estates.py
create mode 100644 backend/src/characters/test_wills_estates.py
create mode 100644 backend/src/characters/wills.py
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/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..1647738b
--- /dev/null
+++ b/backend/src/characters/estates.py
@@ -0,0 +1,289 @@
+"""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 _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] = 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 int(money_moved)
+ except (TypeError, ValueError):
+ money_cents = None
+ return {
+ "death_id": did,
+ "status": status,
+ "revision": revision,
+ "updated_at": iso_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/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..3141023c
--- /dev/null
+++ b/backend/src/characters/test_wills_estates.py
@@ -0,0 +1,504 @@
+"""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_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
+
+
+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/wills.py b/backend/src/characters/wills.py
new file mode 100644
index 00000000..7e821cfe
--- /dev/null
+++ b/backend/src/characters/wills.py
@@ -0,0 +1,329 @@
+"""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)
+ revision = raw.get("will_revision")
+ if result == "applied" and isinstance(revision, int) and not isinstance(revision, bool) and revision >= 0:
+ 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.
From 659e4dd0a2a95480110593f01f42740e63c396f5 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Tue, 13 Oct 2026 14:04:34 +0000
Subject: [PATCH 2/5] fix(characters): refuse wardrobe and kit edits for dead
and missing characters
Co-Authored-By: Claude Opus 5.5
---
backend/src/characters/lore_items.py | 4 ++++
backend/src/characters/pending_create.py | 9 ++++++++-
backend/src/characters/wardrobe.py | 19 ++++++++++++-------
3 files changed, 24 insertions(+), 8 deletions(-)
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/wardrobe.py b/backend/src/characters/wardrobe.py
index de64c856..300d83c9 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
@@ -940,17 +941,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"],
@@ -1015,7 +1020,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"]
@@ -1144,7 +1149,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)
@@ -1233,7 +1238,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"]
@@ -1331,7 +1336,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"])
From deccf3a9a2c826baa5d73ab6cd89b0ad9606e219 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Tue, 13 Oct 2026 14:04:34 +0000
Subject: [PATCH 3/5] feat(profile): wills on character cards and a memorial
for the dead
Living cards show the heir or No will, with Change (a name search) and
Revoke; a change shows as pending until the server answers. Dead and missing
characters open a read-only view with the death date, heir and where the
estate went. Wardrobe and kit pages turn the dead away.
Co-Authored-By: Claude Opus 5.5
---
.../[id]/CharacterIdLayoutClient.tsx | 29 ++-
.../character/CharacterList.test.tsx | 29 ++-
.../components/character/CharacterList.tsx | 40 ++++-
.../character/CharacterMemorial.tsx | 82 +++++++++
.../character/CharacterWill.test.tsx | 121 +++++++++++++
.../components/character/CharacterWill.tsx | 166 ++++++++++++++++++
.../components/profile/ProfileCharacter.tsx | 34 ++--
.../components/profile/ProfilePanel.test.tsx | 37 +++-
.../app/components/profile/ProfilePanel.tsx | 3 +-
frontend/lib/characters/api.ts | 83 +++++++++
frontend/lib/characters/wills.test.ts | 65 +++++++
frontend/lib/characters/wills.ts | 105 +++++++++++
12 files changed, 766 insertions(+), 28 deletions(-)
create mode 100644 frontend/app/components/character/CharacterMemorial.tsx
create mode 100644 frontend/app/components/character/CharacterWill.test.tsx
create mode 100644 frontend/app/components/character/CharacterWill.tsx
create mode 100644 frontend/lib/characters/wills.test.ts
create mode 100644 frontend/lib/characters/wills.ts
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/character/CharacterList.test.tsx b/frontend/app/components/character/CharacterList.test.tsx
index 7a8fc1cf..a66d9862 100644
--- a/frontend/app/components/character/CharacterList.test.tsx
+++ b/frontend/app/components/character/CharacterList.test.tsx
@@ -102,11 +102,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 +123,24 @@ 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/);
});
diff --git a/frontend/app/components/character/CharacterList.tsx b/frontend/app/components/character/CharacterList.tsx
index a74af000..6a30c35e 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 =
@@ -87,12 +90,16 @@ function Card({
face,
playerUuid,
onOpen,
+ sessionToken,
+ onWillChanged,
}: {
item: CharacterListItem;
catalog?: CreationCatalog | null;
face: string | null | undefined;
playerUuid?: string | null;
onOpen: (id: string) => void;
+ sessionToken?: string | null;
+ onWillChanged?: () => void;
}) {
const status = statusOf(item);
const name = item.name || "Unnamed";
@@ -101,6 +108,7 @@ function Card({
const chip = CHIPS[status];
const linkable = Boolean(item.id) && OPENABLE.has(status);
const faded = status === "DEAD" || status === "MISSING" || status === "REJECTED";
+ const willShown = status === "ALIVE" && Boolean(item.will && sessionToken);
const body = (
<>
@@ -130,19 +138,22 @@ function Card({
onOpen(item.id);
}
return (
-
+
{linkable ? (
{body}
) : (
{body}
)}
+ {willShown ? (
+ {})} />
+ ) : null}
);
}
@@ -201,6 +212,7 @@ export default function CharacterList({
onOpen,
onRefresh,
refreshing = false,
+ onWillChanged,
sessionToken = null,
playerUuid = null,
}: Props) {
@@ -236,7 +248,16 @@ export default function CharacterList({
{current.map((c) => (
-
+
))}
{atLimit ? (
@@ -270,7 +291,16 @@ export default function CharacterList({
Dead
{dead.map((c) => (
-
+
))}
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"}
+
+ ) : (
+
+ {
+ setPicking((p) => !p);
+ setError(null);
+ }}
+ >
+ {picking ? "Cancel" : "Change"}
+
+ {will.heir_id ? (
+ void send(null)}>
+ Revoke
+
+ ) : null}
+
+ )}
+
+ {refused && !picking ?
{refused}
: null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {picking && !pending ? (
+
+
+ Heir’s name
+
+
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) => (
+
+ void send(m.id)}>
+ {m.name}
+
+ {[m.race, m.short_id, m.own ? "yours" : null].filter(Boolean).join(" · ")}
+
+
+
+ ))}
+
+ ) : 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..b5a14384
--- /dev/null
+++ b/frontend/lib/characters/wills.test.ts
@@ -0,0 +1,65 @@
+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");
+});
+
+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..43488c46
--- /dev/null
+++ b/frontend/lib/characters/wills.ts
@@ -0,0 +1,105 @@
+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 });
+}
+
+/** 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;
+ // Labels arrive plural ("locks"); one of a kind drops the s.
+ return [...totals]
+ .map(([label, count]) => `${count} ${count === 1 && label.endsWith("s") ? label.slice(0, -1) : label}`)
+ .join(", ");
+}
From fd52f09297c1288b75cd3970dd0ca6126df0e91e Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Tue, 13 Oct 2026 15:40:40 +0000
Subject: [PATCH 4/5] fix(characters): order estates to the millisecond and
trust only a one-step applied ack
Same-revision estates now compare updated_at with milliseconds, so a push built
earlier in the same second cannot undo a decision. An applied ack updates the
stored will only at base_revision + 1; anything else waits for the roster push.
Negative counts and money read as zero.
Co-Authored-By: Claude Opus 5.5
---
backend/src/characters/estates.py | 17 ++++++++--
backend/src/characters/test_wills_estates.py | 35 ++++++++++++++++++++
backend/src/characters/wills.py | 9 ++++-
3 files changed, 57 insertions(+), 4 deletions(-)
diff --git a/backend/src/characters/estates.py b/backend/src/characters/estates.py
index 1647738b..58f57d40 100644
--- a/backend/src/characters/estates.py
+++ b/backend/src/characters/estates.py
@@ -53,6 +53,17 @@ def iso_from_epoch_ms(raw: Any) -> str | None:
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
@@ -126,7 +137,7 @@ def _counts(raw: Any) -> dict[str, int]:
if not label or isinstance(value, bool):
continue
try:
- out[label] = int(value)
+ out[label] = max(0, int(value))
except (TypeError, ValueError):
continue
return out
@@ -169,14 +180,14 @@ def parse_estate(raw: Any, death_id: str | None) -> dict[str, Any] | 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 int(money_moved)
+ 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_from_epoch_ms(raw.get("updated_at")),
+ "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")),
diff --git a/backend/src/characters/test_wills_estates.py b/backend/src/characters/test_wills_estates.py
index 3141023c..7716bd3f 100644
--- a/backend/src/characters/test_wills_estates.py
+++ b/backend/src/characters/test_wills_estates.py
@@ -169,6 +169,25 @@ def push(**kw):
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,
@@ -427,6 +446,22 @@ def test_unknown_result_is_refused(api):
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"]
diff --git a/backend/src/characters/wills.py b/backend/src/characters/wills.py
index 7e821cfe..d2d73d3b 100644
--- a/backend/src/characters/wills.py
+++ b/backend/src/characters/wills.py
@@ -233,8 +233,15 @@ def ack_will_results(realm_id: str | None, results: Any) -> dict[str, Any]:
(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 >= 0:
+ 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(
From 64392171fe57d03a86abd26dba7f627cedb6c6f0 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Tue, 13 Oct 2026 15:40:40 +0000
Subject: [PATCH 5/5] fix(profile): read one entity, not one entitie, on the
memorial
Co-Authored-By: Claude Opus 5.5
---
frontend/lib/characters/wills.test.ts | 4 ++++
frontend/lib/characters/wills.ts | 11 +++++++----
2 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/frontend/lib/characters/wills.test.ts b/frontend/lib/characters/wills.test.ts
index b5a14384..b586f2e9 100644
--- a/frontend/lib/characters/wills.test.ts
+++ b/frontend/lib/characters/wills.test.ts
@@ -54,6 +54,10 @@ it("sums what each part of the server moved", () => {
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", () => {
diff --git a/frontend/lib/characters/wills.ts b/frontend/lib/characters/wills.ts
index 43488c46..5dfa8ec9 100644
--- a/frontend/lib/characters/wills.ts
+++ b/frontend/lib/characters/wills.ts
@@ -89,6 +89,12 @@ 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();
@@ -98,8 +104,5 @@ export function estateMoved(estate: CharacterEstate): string | null {
}
}
if (!totals.size) return null;
- // Labels arrive plural ("locks"); one of a kind drops the s.
- return [...totals]
- .map(([label, count]) => `${count} ${count === 1 && label.endsWith("s") ? label.slice(0, -1) : label}`)
- .join(", ");
+ return [...totals].map(([label, count]) => `${count} ${count === 1 ? singular(label) : label}`).join(", ");
}