Mastodon Politics, Power, and Science: Player Portal
Showing posts with label Player Portal. Show all posts
Showing posts with label Player Portal. Show all posts

Thursday, November 12, 2026

Player Portal in Mega Mappers

Codex Engine: Player Portal, Auth, Rules-as-MCP & AI NPCs (Pure Node-Tree Edition)

Defining game rulesets (D&D 5e, Pathfinder, Cyberpunk, or custom homebrew) as MCP (Model Context Protocol) servers keeps The Codex Engine rules-agnostic while letting campaigns dynamically discover, render, validate, and compute character sheets over a standard JSON-RPC protocol.

All state—users, auth tokens, campaigns, player characters, AI NPCs, and world lore—lives inside one unified node tree (persisted via your existing node store / codex.db blob without adding any SQL tables or schemas).


1. Architecture & End-to-End Flow

Text
[Player Scans QR Code: GET /?campaign=<invite_code>]
                          │
                          ▼
  ┌───────────────────────────────────────────────────────────┐
  │ Player Portal & Auth Gateway (Python / NodeStore)         │
  │  ├── Auth & Email Verification (/System/Users & Tokens)   │
  │  ├── Character Roster (/Characters/<char_id>)             │
  │  └── Player Projection (strips gm_secret before sending)  │
  └───────────────────────────┬───────────────────────────────┘
                              │
        ┌─────────────────────┴─────────────────────┐
        ▼                                           ▼
  ┌───────────────────────────┐       ┌───────────────────────────┐
  │ Codex Unified Node Tree   │       │ Ruleset MCP Server(s)     │
  │  ├── /System              │◄─────►│  ├── http://127.0.0.1:8090│
  │  ├── /Campaigns/<id>      │       │  ├── get_sheet_schema()   │
  │  ├── /Characters/<char_id>│       │  ├── compute_sheet()      │
  │  └── /World/...           │       │  └── resolve_action()     │
  └───────────────────────────┘       └───────────────────────────┘
  1. QR Entry (: Browser stores campaignCode in sessionStorage so it survives registration, login, and email verification redirects.

  2. Auth Gate: Sign In, Register (with email verification), or Forgot Password—all stored as nodes under /System.

  3. Character Roster & Campaign Mounting: Players choose an existing character (including unattached "tavern reserve" characters) or create a new one from the campaign's bound Ruleset MCP schema. Mounting links <char_id> into /Campaigns/<campaign_id>/Roster.

  4. Live Sheet + Node Inspector Toggle: Edits to editable: true properties run through the Ruleset MCP compute_sheet tool, persist to /Characters/<char_id>/Sheet, and appear immediately in both the Player Portal and GM Console.


2. Canonical Node Tree Layout (Zero SQL Tables)

Every entity is directly addressable by path in your existing Node Inspector:

Text
/Root
 ├── /System
 │    ├── /Users
 │    │    └── /<user_id>
 │    │         ├── email: "player@example.com"
 │    │         ├── password_hash: "pbkdf2:sha256:<salt>:<hex>"
 │    │         ├── is_verified: false
 │    │         └── created_at: "2026-11-12T23:00:00Z"
 │    └── /AuthTokens
 │         └── /<token_hash>
 │              ├── user_id: "<user_id>"
 │              ├── purpose: "verify_email"   # "verify_email" | "reset_password" | "session"
 │              ├── expires_at: 1794610800
 │              └── used_at: null
 │
 ├── /Campaigns
 │    └── /<campaign_id>
 │         ├── name: "The Sunken Citadel"
 │         ├── invite_code: "CITADEL99"
 │         ├── ruleset_mcp_uri: "http://127.0.0.1:8090"
 │         ├── /Roster                       # Mounted character IDs
 │         │    └── /<char_id>: true
 │         └── /Log                          # GM/system audit & toast feed
 │              └── /<event_id>
 │                   ├── ts: 1794610800
 │                   └── message: "GM granted +450 XP to Kaelen"
 │
 ├── /Characters                             # Unified store for PCs & AI NPCs
 │    └── /<char_id>
 │         ├── user_id: "<user_id>"          # null for AI-generated NPCs
 │         ├── entity_kind: "player"         # "player" | "npc"
 │         ├── campaign_id: "<campaign_id>"  # null if unattached reserve
 │         ├── location_node_id: null        # Optional map marker / room ID (for NPCs)
 │         ├── ruleset_mcp_uri: "http://127.0.0.1:8090"
 │         ├── name: "Kaelen"
 │         ├── updated_at: "2026-11-12T23:05:00Z"
 │         └── /Sheet                        # Populated & computed by Ruleset MCP
 │              ├── node_type: "Character"   # "Character" | "NPC"
 │              ├── /properties/...
 │              └── /children
 │                   ├── /Progression/...
 │                   ├── /Wealth/...
 │                   ├── /Attributes/...
 │                   ├── /Derived/...
 │                   ├── /Supplies/...
 │                   └── /MagicItems/...
 │
 └── /World/...

Role-Based Permissions & Secret Stripping

  • Player vs. GM writes: Players edit Identity, spend Wealth, consume Supplies, and equip MagicItems. GMs can override any node (Progression/XP, Level, Attributes, loot drops, confiscations).

  • Hidden : GMs can attach gm_secret to any item or note node. When projecting a character or NPC subtree to the Player Portal, project_for_player() recursively strips gm_secret keys and Notes/GM branches.


3. Unified Node Store (node_store.py)

Replaces all SQL queries with atomic path-based node operations. If integrating directly into codex_engine/core/db_manager.py, expose these same methods on your existing root_nodes dictionary before saving.

Python
import copy
import json
import os
from pathlib import Path


class NodeStore:
    def __init__(self, file_path: str = "data/world_nodes.json"):
        self.file_path = Path(file_path)
        self.file_path.parent.mkdir(parents=True, exist_ok=True)
        if self.file_path.exists():
            self.root = json.loads(self.file_path.read_text(encoding="utf-8"))
        else:
            self.root = {}

        # Ensure required root branches exist
        sys_branch = self.root.setdefault("System", {})
        sys_branch.setdefault("Users", {})
        sys_branch.setdefault("AuthTokens", {})
        self.root.setdefault("Campaigns", {})
        self.root.setdefault("Characters", {})
        self.root.setdefault("World", {})
        self.save()

    def save(self):
        tmp = self.file_path.with_suffix(".tmp")
        tmp.write_text(json.dumps(self.root, indent=2), encoding="utf-8")
        os.replace(tmp, self.file_path)

    def get_node(self, path: str, default=None):
        cur = self.root
        for part in path.strip("/").split("/"):
            if not part:
                continue
            if not isinstance(cur, dict) or part not in cur:
                return default
            cur = cur[part]
        return cur

    def set_node(self, path: str, value, persist: bool = True):
        parts = [p for p in path.strip("/").split("/") if p]
        if not parts:
            raise ValueError("Cannot overwrite root node directly")
        cur = self.root
        for part in parts[:-1]:
            cur = cur.setdefault(part, {})
        cur[parts[-1]] = value
        if persist:
            self.save()

    def delete_node(self, path: str, persist: bool = True) -> bool:
        parts = [p for p in path.strip("/").split("/") if p]
        if not parts:
            return False
        parent = self.get_node("/".join(parts[:-1]))
        if isinstance(parent, dict) and parts[-1] in parent:
            del parent[parts[-1]]
            if persist:
                self.save()
            return True
        return False

    def find_node_by_prop(self, branch_path: str, prop: str, value: str) -> tuple[str, dict] | None:
        branch = self.get_node(branch_path, {})
        target = str(value).strip().lower()
        for node_id, node in branch.items():
            if isinstance(node, dict) and str(node.get(prop, "")).strip().lower() == target:
                return node_id, node
        return None

    def filter_nodes(self, branch_path: str, prop: str, value) -> dict[str, dict]:
        branch = self.get_node(branch_path, {})
        return {
            nid: node
            for nid, node in branch.items()
            if isinstance(node, dict) and node.get(prop) == value
        }


def project_for_player(node):
    """Deep-copies a node subtree and strips GM-only fields before sending to Player Portal."""
    if isinstance(node, dict):
        return {
            k: project_for_player(v)
            for k, v in node.items()
            if k not in ("gm_secret", "GMNotes")
        }
    if isinstance(node, list):
        return [project_for_player(item) for item in node]
    return copy.deepcopy(node)

4. Node-Based Auth, Verification & Password Reset (portal_auth.py)

Uses Python stdlib (hashlib.pbkdf2_hmac, secrets, smtplib) and stores all users and single-use/session tokens directly under /System/Users and /System/AuthTokens.

Python
import hashlib
import os
import secrets
import smtplib
import time
from email.message import EmailMessage
from urllib.parse import quote

from node_store import NodeStore

SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASS = os.getenv("SMTP_PASS", "")
PORTAL_BASE_URL = os.getenv("PORTAL_BASE_URL", "http://localhost:8083")


def hash_password(password: str, salt: str | None = None) -> str:
    salt = salt or secrets.token_hex(16)
    dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 200_000)
    return f"pbkdf2:sha256:{salt}:{dk.hex()}"


def verify_password(password: str, stored: str) -> bool:
    try:
        _, _, salt, _ = stored.split(":", 3)
    except ValueError:
        return False
    return secrets.compare_digest(hash_password(password, salt), stored)


def hash_token(raw_token: str) -> str:
    return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()


def send_portal_email(to_email: str, subject: str, body: str):
    if not SMTP_HOST:
        print(f"\n[PORTAL EMAIL DEV MODE] To: {to_email} | {subject}\n{body}\n")
        return
    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = SMTP_USER
    msg["To"] = to_email
    msg.set_content(body)
    with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
        server.starttls()
        server.login(SMTP_USER, SMTP_PASS)
        server.send_message(msg)


class PortalAuth:
    def __init__(self, store: NodeStore):
        self.store = store

    def register_user(self, email: str, password: str, campaign_code: str = "") -> str:
        email = email.strip().lower()
        if self.store.find_node_by_prop("/System/Users", "email", email):
            raise ValueError("An account with this email already exists.")

        user_id = secrets.token_hex(8)
        raw_token = secrets.token_urlsafe(32)

        self.store.set_node(
            f"/System/Users/{user_id}",
            {
                "email": email,
                "password_hash": hash_password(password),
                "is_verified": False,
                "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            },
            persist=False,
        )
        self.store.set_node(
            f"/System/AuthTokens/{hash_token(raw_token)}",
            {
                "user_id": user_id,
                "purpose": "verify_email",
                "expires_at": int(time.time()) + 86400,
                "used_at": None,
            },
        )

        verify_url = f"{PORTAL_BASE_URL}/api/verify?token={quote(raw_token)}&campaign={quote(campaign_code)}"
        send_portal_email(
            email,
            "Confirm your Codex Player Portal account",
            f"Click to verify your email and enter the campaign:\n{verify_url}",
        )
        return user_id

    def verify_email(self, raw_token: str) -> bool:
        t_hash = hash_token(raw_token)
        token_node = self.store.get_node(f"/System/AuthTokens/{t_hash}")
        now = int(time.time())
        if (
            not token_node
            or token_node.get("purpose") != "verify_email"
            or token_node.get("used_at") is not None
            or token_node.get("expires_at", 0) < now
        ):
            return False

        user_id = token_node["user_id"]
        self.store.set_node(f"/System/Users/{user_id}/is_verified", True, persist=False)
        self.store.set_node(f"/System/AuthTokens/{t_hash}/used_at", now)
        return True

    def login(self, email: str, password: str) -> str | None:
        match = self.store.find_node_by_prop("/System/Users", "email", email)
        if not match:
            return None
        user_id, user_node = match
        if not user_node.get("is_verified"):
            raise PermissionError("Email not verified. Check your inbox.")
        if not verify_password(password, user_node.get("password_hash", "")):
            return None

        session_token = secrets.token_urlsafe(32)
        self.store.set_node(
            f"/System/AuthTokens/{hash_token(session_token)}",
            {
                "user_id": user_id,
                "purpose": "session",
                "expires_at": int(time.time()) + 604800,  # 7 days
                "used_at": None,
            },
        )
        return session_token

    def get_session_user(self, session_token: str) -> tuple[str, dict] | None:
        token_node = self.store.get_node(f"/System/AuthTokens/{hash_token(session_token)}")
        if (
            not token_node
            or token_node.get("purpose") != "session"
            or token_node.get("expires_at", 0) < int(time.time())
        ):
            return None
        user_id = token_node["user_id"]
        user_node = self.store.get_node(f"/System/Users/{user_id}")
        return (user_id, user_node) if user_node else None

    def request_password_reset(self, email: str):
        match = self.store.find_node_by_prop("/System/Users", "email", email)
        if not match:
            return  # Silent return prevents email enumeration
        user_id, _ = match
        raw_token = secrets.token_urlsafe(32)
        self.store.set_node(
            f"/System/AuthTokens/{hash_token(raw_token)}",
            {
                "user_id": user_id,
                "purpose": "reset_password",
                "expires_at": int(time.time()) + 1800,  # 30 mins
                "used_at": None,
            },
        )
        reset_url = f"{PORTAL_BASE_URL}/?reset_token={quote(raw_token)}"
        send_portal_email(
            email.strip().lower(),
            "Reset your Codex Player Portal password",
            f"Click to set a new password (expires in 30 minutes):\n{reset_url}",
        )

    def complete_password_reset(self, raw_token: str, new_password: str) -> bool:
        t_hash = hash_token(raw_token)
        token_node = self.store.get_node(f"/System/AuthTokens/{t_hash}")
        now = int(time.time())
        if (
            not token_node
            or token_node.get("purpose") != "reset_password"
            or token_node.get("used_at") is not None
            or token_node.get("expires_at", 0) < now
        ):
            return False

        user_id = token_node["user_id"]
        self.store.set_node(f"/System/Users/{user_id}/password_hash", hash_password(new_password), persist=False)
        self.store.set_node(f"/System/AuthTokens/{t_hash}/used_at", now, persist=False)

        # Invalidate all active sessions for this user
        all_tokens = self.store.get_node("/System/AuthTokens", {})
        stale = [
            th
            for th, tnode in all_tokens.items()
            if tnode.get("user_id") == user_id and tnode.get("purpose") == "session"
        ]
        for th in stale:
            self.store.delete_node(f"/System/AuthTokens/{th}", persist=False)
        self.store.save()
        return True

5. Ruleset-as-MCP Bridge & Sample Server

Bridge Client (ruleset_mcp_bridge.py)

Python
import json
import urllib.request


def call_ruleset_mcp(mcp_url: str, tool_name: str, arguments: dict) -> dict:
    payload = {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {"name": tool_name, "arguments": arguments},
    }
    req = urllib.request.Request(
        mcp_url,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=5) as resp:
        result = json.loads(resp.read().decode("utf-8"))
        content = result.get("result", {}).get("content", [{}])[0].get("text", "{}")
        return json.loads(content)

Sample Fantasy Ruleset MCP Server (sample_fantasy_mcp.py)

Implements get_sheet_schema and compute_sheet. Note: For node_type == "NPC", compute_sheet preserves the NPC's assigned Level instead of overwriting it from XP.

Python
import json
import math
from http.server import BaseHTTPRequestHandler, HTTPServer

XP_THRESHOLDS = [0, 300, 900, 2700, 6500, 14000]


def initial_sheet_schema() -> dict:
    return {
        "node_type": "Character",
        "properties": {
            "name": {"value": "Unnamed Hero", "editable": True, "widget": "text"},
            "ancestry": {
                "value": "Human",
                "editable": True,
                "widget": "select",
                "options": ["Human", "Elf", "Dwarf", "Halfling"],
            },
            "class": {
                "value": "Fighter",
                "editable": True,
                "widget": "select",
                "options": ["Fighter", "Ranger", "Rogue", "Wizard"],
            },
        },
        "children": {
            "Progression": {
                "XP": {"value": 0, "editable": False, "widget": "number"},
                "Level": {"value": 1, "editable": False, "widget": "badge"},
            },
            "Wealth": {
                "Gold": {"value": 15, "editable": True, "widget": "number"},
            },
            "Attributes": {
                "STR": {"value": 14, "editable": True, "widget": "number"},
                "DEX": {"value": 12, "editable": True, "widget": "number"},
                "CON": {"value": 13, "editable": True, "widget": "number"},
            },
            "Derived": {
                "MaxHP": {"value": 11, "editable": False, "widget": "badge"},
                "AC": {"value": 11, "editable": False, "widget": "badge"},
                "CarryWeight": {"value": 0, "editable": False, "widget": "badge"},
                "Encumbered": {"value": False, "editable": False, "widget": "flag"},
            },
            "Supplies": {
                "Rations": {"qty": 5, "weight": 2},
                "Torch": {"qty": 3, "weight": 1},
            },
            "MagicItems": {},
        },
    }


def compute_sheet(tree: dict) -> dict:
    children = tree.setdefault("children", {})
    attrs = children.get("Attributes", {})
    prog = children.setdefault("Progression", {})
    derived = children.setdefault("Derived", {})

    str_score = attrs.get("STR", {}).get("value", 10)
    dex_score = attrs.get("DEX", {}).get("value", 10)
    con_score = attrs.get("CON", {}).get("value", 10)

    dex_mod = math.floor((dex_score - 10) / 2)
    con_mod = math.floor((con_score - 10) / 2)

    if tree.get("node_type") == "NPC":
        level = max(1, int(prog.get("Level", {}).get("value", 1)))
    else:
        xp = prog.get("XP", {}).get("value", 0)
        level = max(1, sum(1 for threshold in XP_THRESHOLDS if xp >= threshold))

    prog.setdefault("Level", {"editable": False, "widget": "badge"})["value"] = level

    total_weight = 0
    for bucket_name in ("Supplies", "MagicItems", "Inventory"):
        for item_data in children.get(bucket_name, {}).values():
            total_weight += item_data.get("qty", 1) * item_data.get("weight", 0)

    derived["MaxHP"] = {"value": (10 + con_mod) + (level - 1) * (6 + con_mod), "editable": False, "widget": "badge"}
    derived["AC"] = {"value": 10 + dex_mod, "editable": False, "widget": "badge"}
    derived["CarryWeight"] = {"value": total_weight, "editable": False, "widget": "badge"}
    derived["Encumbered"] = {"value": total_weight > (str_score * 15), "editable": False, "widget": "flag"}
    return tree


class MCPHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        req = json.loads(self.rfile.read(length).decode("utf-8"))
        tool = req.get("params", {}).get("name")
        args = req.get("params", {}).get("arguments", {})

        if tool == "get_sheet_schema":
            out = initial_sheet_schema()
        elif tool == "compute_sheet":
            out = compute_sheet(args.get("node_tree", {}))
        else:
            out = {"error": f"Unknown tool {tool}"}

        resp = {
            "jsonrpc": "2.0",
            "id": req.get("id", 1),
            "result": {"content": [{"type": "text", "text": json.dumps(out)}]},
        }
        body = json.dumps(resp).encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


if __name__ == "__main__":
    print("Fantasy Ruleset MCP Server listening on http://127.0.0.1:8090")
    HTTPServer(("127.0.0.1", 8090), MCPHandler).serve_forever()

6. GM Character Control & Live Node Mutation (portal_gm_api.py)

Operates directly on /Characters/<char_id> and /Campaigns/<campaign_id>/Log inside NodeStore—zero SQL queries.

Python
import secrets
import time

from node_store import NodeStore
from ruleset_mcp_bridge import call_ruleset_mcp


class GMCharacterController:
    def __init__(self, store: NodeStore):
        self.store = store

    def list_campaign_characters(self, campaign_id: str) -> dict[str, dict]:
        """Returns all active player and NPC character subtrees mounted in the campaign."""
        return self.store.filter_nodes("/Characters", "campaign_id", campaign_id)

    def mount_character(self, char_id: str, campaign_id: str):
        """Assigns a character (or promotes an NPC) into a campaign's /Roster."""
        char_node = self.store.get_node(f"/Characters/{char_id}")
        if not char_node:
            raise KeyError(f"Character {char_id} not found")
        self.store.set_node(f"/Characters/{char_id}/campaign_id", campaign_id, persist=False)
        self.store.set_node(f"/Campaigns/{campaign_id}/Roster/{char_id}", True)

    def apply_gm_action(self, char_id: str, action: dict) -> dict:
        """
        Executes a GM action on /Characters/<char_id>/Sheet:
          - {"op": "delta_stat", "path": ["Progression", "XP"], "delta": 500}
          - {"op": "delta_stat", "path": ["Wealth", "Gold"], "delta": -25}
          - {"op": "add_item", "bucket": "MagicItems", "item": {"name": "Flame Tongue", "qty": 1, "weight": 3, "gm_secret": "Sentient"}}
          - {"op": "remove_item", "bucket": "Supplies", "name": "Rations", "qty": 2}
          - {"op": "set_node", "path": ["Attributes", "STR"], "value": 18}
        """
        char_node = self.store.get_node(f"/Characters/{char_id}")
        if not char_node:
            raise KeyError(f"Character {char_id} not found")

        sheet = char_node.setdefault("Sheet", {})
        children = sheet.setdefault("children", {})
        op = action["op"]
        log_msg = ""

        if op == "delta_stat":
            section, key = action["path"]
            node = children.setdefault(section, {}).setdefault(key, {"value": 0, "editable": False})
            node["value"] = max(0, node.get("value", 0) + action["delta"])
            log_msg = f"GM adjusted {key} by {action['delta']:+d} for {char_node.get('name', char_id)}"

        elif op == "set_node":
            section, key = action["path"]
            node = children.setdefault(section, {}).setdefault(key, {"value": None, "editable": True})
            node["value"] = action["value"]
            log_msg = f"GM set {section}.{key} = {action['value']} for {char_node.get('name', char_id)}"

        elif op == "add_item":
            bucket = children.setdefault(action.get("bucket", "Inventory"), {})
            item = action["item"]
            name = item["name"]
            if name in bucket:
                bucket[name]["qty"] = bucket[name].get("qty", 1) + item.get("qty", 1)
            else:
                bucket[name] = {k: v for k, v in item.items() if k != "name"}
            log_msg = f"GM granted {item.get('qty', 1)}x {name} to {char_node.get('name', char_id)}"

        elif op == "remove_item":
            bucket = children.setdefault(action.get("bucket", "Inventory"), {})
            name = action["name"]
            if name in bucket:
                new_qty = bucket[name].get("qty", 1) - action.get("qty", 1)
                if new_qty <= 0:
                    del bucket[name]
                else:
                    bucket[name]["qty"] = new_qty
                log_msg = f"GM removed {action.get('qty', 1)}x {name} from {char_node.get('name', char_id)}"

        # Re-evaluate derived stats via Ruleset MCP
        mcp_url = char_node["ruleset_mcp_uri"]
        updated_sheet = call_ruleset_mcp(mcp_url, "compute_sheet", {"node_tree": sheet})

        self.store.set_node(f"/Characters/{char_id}/Sheet", updated_sheet, persist=False)
        self.store.set_node(
            f"/Characters/{char_id}/updated_at",
            time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            persist=False,
        )

        # Append to Campaign Toast / Audit Log if mounted
        campaign_id = char_node.get("campaign_id")
        if campaign_id and log_msg:
            event_id = f"{int(time.time())}_{secrets.token_hex(3)}"
            self.store.set_node(
                f"/Campaigns/{campaign_id}/Log/{event_id}",
                {"ts": int(time.time()), "message": log_msg},
                persist=False,
            )

        self.store.save()
        return updated_sheet

7. Unified AI NPC Generation (npc_generator.py)

NPCs use the exact same /Characters/<char_id> structure with user_id: None, entity_kind: "npc", and an optional location_node_id property linking them to a map marker or room node. Because they share the same node schema:

  • Zero-Friction Looting: Defeating or pickpocketing an NPC transfers items directly from their /Sheet/children/MagicItems or Supplies to a player's node tree.

  • One-Click Promotion to PC: If a player recruits a hireling or joins late, setting user_id = "<player_id>" and entity_kind = "player" turns the NPC into a playable sheet immediately.

Python
import json
import secrets
import time

from node_store import NodeStore
from ruleset_mcp_bridge import call_ruleset_mcp


def generate_and_mount_ai_npc(
    store: NodeStore,
    gemini_client,
    campaign_id: str,
    campaign_context: dict,
    role_prompt: str,
    target_level: int = 1,
    location_node_id: str | None = None,
) -> tuple[str, dict]:
    """
    1. Reads the campaign's ruleset_mcp_uri from /Campaigns/<campaign_id>.
    2. Calls get_sheet_schema on the MCP server.
    3. Prompts Gemini with horizontal map/room/theme context to populate the sheet.
    4. Runs compute_sheet (preserving target_level for NPCs).
    5. Mounts the NPC under /Characters/<char_id> and /Campaigns/<campaign_id>/Roster.
    """
    campaign = store.get_node(f"/Campaigns/{campaign_id}")
    if not campaign:
        raise KeyError(f"Campaign {campaign_id} not found")

    mcp_url = campaign["ruleset_mcp_uri"]
    blank_schema = call_ruleset_mcp(mcp_url, "get_sheet_schema", {})

    prompt = f"""
    You are generating an RPG NPC for a campaign with this context:
    - Theme & Tone: {campaign_context.get('theme', 'Classic Fantasy')}
    - Location / Room Context: {campaign_context.get('room_context', 'Village Tavern')}
    - NPC Concept: {role_prompt}
    - Target Level: {target_level}

    Populate the editable properties, Attributes, Wealth, Supplies, and MagicItems
    in the JSON node tree below. Include a 'gm_secret' field on notable items or
    inside a 'GMNotes' child node for hidden motives. Return ONLY valid JSON matching
    this exact structure:

    {json.dumps(blank_schema, indent=2)}
    """

    raw_json = gemini_client.generate_json(prompt)
    populated_sheet = json.loads(raw_json)

    populated_sheet["node_type"] = "NPC"
    populated_sheet.setdefault("children", {}).setdefault("Progression", {})["Level"] = {
        "value": target_level,
        "editable": False,
        "widget": "badge",
    }

    validated_sheet = call_ruleset_mcp(mcp_url, "compute_sheet", {"node_tree": populated_sheet})

    char_id = f"npc_{secrets.token_hex(6)}"
    npc_name = validated_sheet.get("properties", {}).get("name", {}).get("value", "Unnamed NPC")
    npc_node = {
        "user_id": None,
        "entity_kind": "npc",
        "campaign_id": campaign_id,
        "location_node_id": location_node_id,
        "ruleset_mcp_uri": mcp_url,
        "name": npc_name,
        "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "Sheet": validated_sheet,
    }

    store.set_node(f"/Characters/{char_id}", npc_node, persist=False)
    store.set_node(f"/Campaigns/{campaign_id}/Roster/{char_id}", True)
    return char_id, npc_node

8. UI Integration Notes (Player Portal & GM Console)

  • Player Portal (:

    • Save campaign query param into sessionStorage immediately on load.

    • Render Sign In | Register | Forgot Password tabs when unauthenticated.

    • Once authenticated, render Campaign Switcher, Character Selector (+ New Character calls get_sheet_schema), Dynamic MCP Sheet Form, and a Raw Node Inspector toggle.

    • Always serve character and encountered NPC trees through project_for_player() so gm_secret and GMNotes never reach the player's browser.

  • GM Console:

    • Party & NPC Roster Strip: Reads /Characters filtered by campaign_id, displaying live HP, AC, Level, Gold, and CarryWeight.

    • Quick-Action Drawer: One-click +100 XP, +500 XP, +/- Gold, and loot dropper forms posting to GMCharacterController.apply_gm_action().

    • Map Spawn: Right-clicking a room/marker spawns an NPC via generate_and_mount_ai_npc(..., location_node_id=marker_id).

    • GM Node Inspector: Views the exact same tree without project_for_player() filtering, keeping gm_secret annotations visible and editable.

Player Portal in Mega Mappers

Codex Engine: Player Portal, Auth, Rules-as-MCP & AI NPCs (Pure Node-Tree Edition) Defining game rulesets (D&D 5e, Pathfinder, Cyber...