diff --git a/Architecture-Server.md b/Architecture-Server.md
index 2d76767..2e58d2c 100644
--- a/Architecture-Server.md
+++ b/Architecture-Server.md
@@ -1,78 +1,2826 @@
-# Tekton Dash Armageddon - Server Architecture
+# Tekton Armageddon - Server Architecture (Full Function Reference)
-## Nakama Backend (`server/nakama/lua/`)
+Complete per-function reference for the Nakama Lua backend at `server/nakama/lua/`. Every RPC, hook, and utility is documented with parameters, returns, errors, storage collections, wallet changesets, and dependencies.
[Back to top](#top)
-
-The backend runs on Nakama, written in Lua. It provides authoritative server-side logic for the economy, anti-cheat, mail, and gacha pulls.
-
-Godot clients call these endpoints via `BackendService.api_rpc_async()`.
-
-### Key Lua Modules
+## Table of Contents
[Back to top](#top)
-- **`economy.lua`:**
- - `buy_currency`: Validates mock/Google/Apple receipts and directly adds Gold or subtracts Gold for Stars via `nk.wallet_update`.
- - `purchase_item`: Buys cosmetic items from the shop catalog. Validates wallet funds and writes to `inventory` storage.
-- **`gacha.lua`:**
- - `perform_gacha_pull`: Reads banner drop rates, rolls RNG on the server, manages pity counters, and deducts the pull cost from the wallet. Prevents client-side RNG manipulation.
-- **`inbox.lua`:**
- - `get_mail` / `claim_mail_reward`: Server-authoritative mail system. Rewards attached to mail are granted directly to the wallet on claim.
-- **`user.lua` & `admin.lua`:**
- - `admin_topup_gold`: Admin-only RPC to grant 999,999 gold.
- - `admin_ban_player`: Banning tool.
- - Profile and friend sync logic.
+1. [Module Loading (main.lua)](#1-module-loading-mainluau)
+2. [Core / Auth Hooks (core.lua)](#2-core--auth-hooks-coreluau)
+3. [Utilities (utils.lua)](#3-utilities-utilsluau)
+4. [User Module (user.lua)](#4-user-module-userluau)
+5. [Economy Module (economy.lua)](#5-economy-module-economyluau)
+6. [Gacha Module (gacha.lua)](#6-gacha-module-gachaluau)
+7. [Leaderboard Module (leaderboard.lua)](#7-leaderboard-module-leaderboardluau)
+8. [Inbox/Mail Module (inbox.lua)](#8-inboxmail-module-inboxluau)
+9. [Daily Rewards Module (daily_rewards.lua)](#9-daily-rewards-module-daily_rewardsluau)
+10. [Admin Module (admin.lua)](#10-admin-module-adminluau)
+11. [Storage Collections Reference](#11-storage-collections-reference)
+12. [Wallet Keys and Currency](#12-wallet-keys-and-currency)
+13. [Leaderboard Configuration](#13-leaderboard-configuration)
+14. [Error Strings Reference](#14-error-strings-reference)
+15. [Permission Levels](#15-permission-levels)
-## Client / Server Wallet Synchronization
+---
[Back to top](#top)
-
-There is **only one authoritative wallet** per user stored on the Nakama backend.
-
-1. Lua calls `nk.wallet_update(changeset)` upon any transaction.
-2. The Godot client calls `UserProfileManager._reload_wallet()`.
-3. Godot fetches the new JSON string from `account.wallet`.
-4. `UserProfileManager` emits `profile_updated`.
-5. All connected UI components (Lobby Top Bar, Shop Panel, Gacha Panel) instantly update their text labels to reflect the unified `gold` and `star` balances.
-
-## CI/CD and Hot-Patching Pipeline (`.gitea/workflows/`)
+## 1. Module Loading (`main.lua`)
[Back to top](#top)
+Entry point for the Nakama Lua runtime. Requires all sub-modules from `lua/` in the following order:
-The game features two distinct automated deployment pipelines.
+```
+1. lua.utils
+2. lua.economy
+3. lua.core
+4. lua.admin
+5. lua.daily_rewards
+6. lua.user
+7. lua.leaderboard
+8. lua.inbox
+9. lua.gacha
+```
-### Full Releases (`ci.yml`)
+Each sub-module registers its own RPCs and hooks via `nk.register_rpc()` and `nk.register_req_after()` as a side-effect of being required. The module files are cached by Lua's `require()` so repeated requires are no-ops.
+
+**Startup side-effect (from core.lua and leaderboard.lua):**
+- `nk.leaderboard_create("global_high_score", true, "desc", "best", nil, {})` -- both files call this via `pcall`, so the second call is a safe no-op.
[Back to top](#top)
-- **Trigger:** Pushing a git tag (e.g. `v3.0.0`).
-- **Purpose:** Compiling heavy native binaries (Windows `.exe`, Linux binaries, macOS `.app`).
-- **Flow:**
- 1. Clones repository.
- 2. Pulls cached Godot 4.7 binary and export templates from `/cache/` on the runner to skip the 1.3GB download.
- 3. Runs `godot --headless --export-release` for each platform.
- 4. Zips outputs and uploads to Gitea's release asset API.
-- **Player Experience:** Must download and replace their entire game folder/executable.
-
-### Hot-Patching (`deploy_patch.yml`)
+### 1.1 Registered RPCs (complete index)
[Back to top](#top)
-- **Trigger:** Manual `workflow_dispatch` button from the Gitea UI.
-- **Purpose:** Pushing lightweight Godot asset changes (Scripts, Scenes, VFX, Sounds) instantly.
-- **Flow:**
- 1. Python script `generate_version_json.py` consumes `CHANGELOG_DRAFT.md` and bumps `version.json`.
- 2. Runs `godot --headless --export-pack` to build a single, shared `patch.pck` file.
- 3. Force-pushes `patch.pck` and `version.json` to the orphan `patches` git branch.
- 4. A `gitea-pages` Docker container serves this branch publicly at `https://raw.klud.top`.
-- **Player Experience:**
- 1. Game boots to `boot_screen.tscn`.
- 2. Fetches `https://raw.klud.top/danchie/tekton/version.json`.
- 3. Downloads `patch.pck` if the version is newer.
- 4. Mounts it into `user://` to override the base `res://` filesystem seamlessly.
+All 48 RPCs registered by the backend:
+
+| RPC Name | Lua Function | Module | Auth Required |
+|---|---|---|---|
+| `get_user_profile` | `user.rpc_get_user_profile` | user.lua | No (fallback to caller) |
+| `update_user_profile` | `user.rpc_update_user_profile` | user.lua | Yes |
+| `search_users` | `user.rpc_search_users` | user.lua | Yes |
+| `change_credentials` | `user.rpc_change_credentials` | user.lua | Yes |
+| `send_lobby_invite` | `user.rpc_send_lobby_invite` | user.lua | Yes |
+| `send_friend_request` | `user.rpc_send_friend_request` | user.lua | Yes |
+| `admin_get_user_history` | `user.rpc_admin_get_user_history` | user.lua | Admin |
+| `get_shop_catalog` | `economy.rpc_get_shop_catalog` | economy.lua | Yes |
+| `buy_currency` | `economy.rpc_buy_currency` | economy.lua | Yes |
+| `purchase_item` | `economy.rpc_purchase_item` | economy.lua | Yes |
+| `admin_set_featured_banners` | `economy.rpc_admin_set_featured_banners` | economy.lua | Admin |
+| `admin_get_featured_banners` | `economy.rpc_admin_get_featured_banners` | economy.lua | Admin |
+| `perform_gacha_pull` | `gacha.rpc_perform_gacha_pull` | gacha.lua | Yes |
+| `get_leaderboard_stats` | `leaderboard.rpc_get_leaderboard_stats` | leaderboard.lua | No |
+| `submit_score` | `leaderboard.rpc_submit_score` | leaderboard.lua | Yes |
+| `sync_leaderboard` | `leaderboard.rpc_sync_leaderboard` | leaderboard.lua | Yes |
+| `reset_stats` | `leaderboard.rpc_reset_stats` | leaderboard.lua | Yes |
+| `admin_update_stats` | `leaderboard.rpc_admin_update_stats` | leaderboard.lua | Admin |
+| `admin_delete_stats` | `leaderboard.rpc_admin_delete_stats` | leaderboard.lua | Admin |
+| `admin_sync_leaderboard` | `leaderboard.rpc_admin_sync_leaderboard` | leaderboard.lua | Admin |
+| `admin_send_mail` | `inbox.rpc_admin_send_mail` | inbox.lua | Admin |
+| `get_mail` | `inbox.rpc_get_mail` | inbox.lua | Yes |
+| `claim_mail_reward` | `inbox.rpc_claim_mail_reward` | inbox.lua | Yes |
+| `delete_mail` | `inbox.rpc_delete_mail` | inbox.lua | Yes |
+| `save_mail_state` | `inbox.rpc_save_mail_state` | inbox.lua | Yes |
+| `admin_list_mail` | `inbox.rpc_admin_list_mail` | inbox.lua | Admin |
+| `admin_update_mail` | `inbox.rpc_admin_update_mail` | inbox.lua | Admin |
+| `admin_delete_mail_server` | `inbox.rpc_admin_delete_mail_server` | inbox.lua | Admin |
+| `claim_daily_reward` | `daily_rewards.rpc_claim_daily_reward` | daily_rewards.lua | Yes |
+| `get_daily_reward_state` | `daily_rewards.rpc_get_daily_reward_state` | daily_rewards.lua | Yes |
+| `set_daily_reward_config` | `daily_rewards.rpc_set_daily_reward_config` | daily_rewards.lua | Admin |
+| `get_daily_reward_config_admin` | `daily_rewards.rpc_get_daily_reward_config_admin` | daily_rewards.lua | Admin |
+| `admin_kick_player` | `admin.rpc_admin_kick_player` | admin.lua | Admin or Host |
+| `admin_ban_player` | `admin.rpc_admin_ban_player` | admin.lua | Admin |
+| `admin_unban_player` | `admin.rpc_admin_unban_player` | admin.lua | Admin |
+| `admin_get_ban_list` | `admin.rpc_admin_get_ban_list` | admin.lua | Admin |
+| `admin_get_server_stats` | `admin.rpc_admin_get_server_stats` | admin.lua | Admin (or Host w/ match_id) |
+| `admin_get_player_list` | `admin.rpc_admin_get_player_list` | admin.lua | Admin or Host |
+| `admin_end_match` | `admin.rpc_admin_end_match` | admin.lua | Admin or Host |
+| `admin_set_user_role` | `admin.rpc_admin_set_user_role` | admin.lua | Owner only |
+| `admin_topup_gold` | `admin.rpc_admin_topup_gold` | admin.lua | Admin |
+| `admin_clear_global_chat` | `admin.rpc_admin_clear_global_chat` | admin.lua | Admin |
+| `admin_list_users` | `admin.rpc_admin_list_users` | admin.lua | Admin |
+| `admin_delete_users` | `admin.rpc_admin_delete_users` | admin.lua | Admin |
+| `admin_get_user_detail` | `admin.rpc_admin_get_user_detail` | admin.lua | Admin |
+| `admin_update_user_identity` | `admin.rpc_admin_update_user_identity` | admin.lua | Admin |
+| `admin_set_user_password` | `admin.rpc_admin_set_user_password` | admin.lua | Admin |
+| `admin_get_chat_config` | `admin.rpc_admin_get_chat_config` | admin.lua | Admin |
+| `admin_set_chat_config` | `admin.rpc_admin_set_chat_config` | admin.lua | Admin |
+| `admin_purge_old_messages` | `admin.rpc_admin_purge_old_messages` | admin.lua | Admin |
+| `admin_list_channel_messages` | `admin.rpc_admin_list_channel_messages` | admin.lua | Admin |
+| `admin_delete_channel_message` | `admin.rpc_admin_delete_channel_message` | admin.lua | Admin |
+
+[Back to top](#top)
+
+### 1.2 Registered After-Hooks
+
+[Back to top](#top)
+
+| Trigger | Lua Function | Module | Purpose |
+|---|---|---|---|
+| `AuthenticateSteam` | `after_authenticate_steam` | core.lua | Initialize Steam user profile |
+| `AuthenticateDevice` | `user.after_authenticate` | user.lua | Record login history |
+| `AuthenticateEmail` | `user.after_authenticate` | user.lua | Record login history |
+| `AuthenticateCustom` | `user.after_authenticate` | user.lua | Record login history |
+
+[Back to top](#top)
+
+---
+
+[Back to top](#top)
+
+## 2. Core / Auth Hooks (`core.lua`)
+
+[Back to top](#top)
+
+**Dependencies:** None beyond standard `nk` module.
+
+**Collections read/written:** None (operates on user account metadata only).
+
+**Wallet changesets:** None.
+
+### 2.1 `after_authenticate_steam` (After-Hook, registered on `AuthenticateSteam`)
+
+[Back to top](#top)
+
+Triggered after a successful Steam authentication.
+
+**Purpose:** Initialize first-time Steam users with a display name and default player role.
+
+**Parameters (from Nakama hook):**
+| Param | Type | Description |
+|---|---|---|
+| `context` | table | Nakama RPC context (`context.user_id` must exist) |
+| `output` | table | Authentication output (unused) |
+| `input` | table | Authentication input (`input.username` = Steam username) |
+
+**Auth:** None (internal hook, runs server-side after auth).
+
+**Behavior:**
+1. Skip if `context.user_id` is nil.
+2. Fetch account via `nk.account_get_id(context.user_id)`.
+3. If `account.user.display_name` is empty/nil, set it from `input.username` (fallback: `"SteamPlayer"`) via `nk.account_update_id`.
+4. If user metadata has no `role`, set `metadata.role = "player"` and persist via `nk.account_update_id`.
+
+**Errors:** None surfaced (wrapped in `pcall`).
+
+**Collections:** User account metadata (via `nk.account_get_id` / `nk.account_update_id`).
+
+**Wallet changes:** None.
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 2.2 Startup Leaderboard Creation
+
+[Back to top](#top)
+
+```
+nk.leaderboard_create("global_high_score", true, "desc", "best", nil, {})
+```
+
+| Field | Value |
+|---|---|
+| **ID** | `"global_high_score"` |
+| **Authoritative** | `true` |
+| **Sort** | `"desc"` (highest score first) |
+| **Operator** | `"best"` (keep best score) |
+| **Reset schedule** | `nil` (never resets) |
+
+Also created in leaderboard.lua; second call is a no-op via `pcall`.
+
+[Back to top](#top)
+
+---
+
+[Back to top](#top)
+
+## 3. Utilities (`utils.lua`)
+
+[Back to top](#top)
+
+**Dependencies:** None.
+
+**Collections read/written:** Reads user metadata (via `nk.account_get_id` in `is_admin`). Reads match state (via `nk.match_get` in `is_match_host`).
+
+### 3.1 Constants
+
+[Back to top](#top)
+
+| Constant | Value | Purpose |
+|---|---|---|
+| `utils.ADMIN_ROLES` | `{["admin"]=true, ["moderator"]=true, ["owner"]=true}` | Roles with elevated privileges |
+| `utils.SYSTEM_USER_ID` | `"00000000-0000-0000-0000-000000000000"` | System/nil user for global storage objects |
+| `utils.CHANNEL_TYPE_ROOM` | `1` | Nakama channel type for chat rooms |
+| `utils.CHANNEL_TYPE_DIRECT` | `2` | Nakama channel type for direct messages |
+| `utils.CHANNEL_TYPE_GROUP` | `3` | Nakama channel type for group chats |
+
+[Back to top](#top)
+
+### 3.2 `utils.is_admin(context)`
+
+[Back to top](#top)
+
+Check if caller has admin/moderator/owner role.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `context` | table | Yes | Nakama RPC context (must have `user_id`) |
+
+**Returns:** `boolean`
+
+**Logic:**
+1. Look up `context.user_id` via `nk.account_get_id`.
+2. Decode metadata JSON (handles both string and table formats).
+3. Check if `metadata.role` exists in `ADMIN_ROLES`.
+
+**Error strings:** None (returns `false` on failure).
+
+**Collections:** User account metadata (read via `nk.account_get_id`).
+
+**Dependencies:** None (standalone).
+
+[Back to top](#top)
+
+### 3.3 `utils.is_match_host(context, match_id)`
+
+[Back to top](#top)
+
+Check if caller is the host of an authoritative match.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `context` | table | Yes | -- | Nakama RPC context |
+| `match_id` | string | Yes | -- | Nakama match ID to check |
+
+**Returns:** `boolean`
+
+**Logic:**
+1. Fetch match via `nk.match_get(match_id)`.
+2. Decode `match.state` JSON.
+3. Return `true` if `state.hostUserId == context.user_id`.
+
+**Error strings:** None (returns `false` on failure).
+
+**Collections:** Match state (via `nk.match_get`).
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 3.4 `utils.require_admin(context)`
+
+[Back to top](#top)
+
+Guard function that errors if caller is not an admin/moderator/owner.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `context` | table | Yes | Nakama RPC context |
+
+**Error strings:** `"Admin privileges required"`
+
+**Dependencies:** `utils.is_admin(context)`
+
+[Back to top](#top)
+
+### 3.5 `utils.require_admin_or_host(context, match_id)`
+
+[Back to top](#top)
+
+Guard function that errors if caller is neither admin nor match host.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `context` | table | Yes | Nakama RPC context |
+| `match_id` | string | Yes | Nakama match ID |
+
+**Error strings:** `"Admin or host privileges required"`
+
+**Dependencies:** `utils.is_admin(context)`, `utils.is_match_host(context, match_id)`
+
+[Back to top](#top)
+
+### 3.6 `utils.resolve_channel_id(channel_id)`
+
+[Back to top](#top)
+
+Resolve a human-readable channel name to a hashed Nakama channel ID for chat RPCs.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `channel_id` | string | Yes | -- | Raw channel name or already-hashed ID |
+
+**Returns:** `string` -- resolved channel ID
+
+**Logic:**
+1. If empty, return `""`.
+2. If contains `"."` (hashed format), return as-is.
+3. Otherwise, build via `nk.channel_id_build("", channel_id, utils.CHANNEL_TYPE_ROOM)`.
+4. If build fails, return original value.
+
+**Error strings:** None.
+
+**Dependencies:** None (pure utility).
+
+[Back to top](#top)
+
+---
+
+[Back to top](#top)
+
+## 4. User Module (`user.lua`)
+
+[Back to top](#top)
+
+**Dependencies:** `utils` (for `admin_get_user_history`).
+
+**Wallet changesets:** None (all user RPCs are informational or identity operations).
+
+### 4.1 RPC: `get_user_profile`
+
+[Back to top](#top)
+
+**Function:** `user.rpc_get_user_profile(context, payload)`
+
+Get user profile information, with ban check for the requesting user.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.user_id` | string | No | caller's `context.user_id` | Target user ID to fetch |
+
+**Auth:** None (works without auth; if unauthenticated, uses provided `user_id`).
+
+**Return:**
+```json
+{
+ "user_id": "string",
+ "username": "string",
+ "display_name": "string",
+ "avatar_url": "string",
+ "create_time": "integer (Unix timestamp)",
+ "role": "string"
+}
+```
+
+**Error strings:**
+- `"Account not found"` -- target user does not exist
+- `"Account banned until . Reason: "` -- user is banned (absolute or expiry)
+- `"Account permanently banned. Reason: "` -- no expiry set
+
+**Collections read:** User account metadata via `nk.account_get_id()`.
+
+**Collections written:** User account metadata (if clearing expired bans).
+
+**Logic:**
+1. If caller is banned (self-check), error with ban details.
+2. If ban expired (Unix timestamp in `metadata.ban_expires` <= `os.time()`), clear ban flags and return profile.
+3. Return profile fields.
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 4.2 RPC: `update_user_profile`
+
+[Back to top](#top)
+
+**Function:** `user.rpc_update_user_profile(context, payload)`
+
+Update the caller's display name and/or avatar URL.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.display_name` | string | No | unchanged | New display name |
+| `payload.avatar_url` | string | No | unchanged | New avatar URL |
+
+**Auth:** Required (`context.user_id` must exist).
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Not authenticated"` -- no `context.user_id`
+- `"Failed to update profile"` -- `nk.account_update_id` failed
+
+**Collections:** User account (via `nk.account_update_id`).
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 4.3 RPC: `search_users`
+
+[Back to top](#top)
+
+**Function:** `user.rpc_search_users(context, payload)`
+
+Search users by username or display name. Returns up to 100 results.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.query` | string | No | `""` | Search query (ILIKE match on username and display_name) |
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "users": [
+ {
+ "user_id": "string",
+ "username": "string",
+ "display_name": "string",
+ "avatar_url": "string"
+ }
+ ]
+}
+```
+
+**Error strings:** `"Not authenticated"`
+
+**Collections read:** SQL `users` table via `nk.sql_query`.
+
+**SQL queries:**
+- Empty query: `SELECT id, username, display_name, metadata FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100`
+- With query: Same but `WHERE (username ILIKE $1 OR display_name ILIKE $1)` with param `"%%"`
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 4.4 RPC: `change_credentials`
+
+[Back to top](#top)
+
+**Function:** `user.rpc_change_credentials(context, payload)`
+
+Change the caller's email and password credentials.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.current_password` | string | Cond. | -- | Required if user has existing email |
+| `payload.new_email` | string | Yes | -- | New email address |
+| `payload.new_password` | string | Yes | -- | New password |
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Not authenticated"`
+- `"Current password required"` -- has existing email but no current_password provided
+- `"Incorrect current password."` -- `nk.authenticate_email` failed
+- `"Failed to set new credentials: "` -- `nk.link_email` failed
+
+**Collections:** User authentication links (via `nk.unlink_email`, `nk.link_email`, `nk.authenticate_email`).
+
+**Note:** If setting new credentials fails after unlinking old email, it tries to re-link the old email as rollback.
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 4.5 RPC: `send_lobby_invite`
+
+[Back to top](#top)
+
+**Function:** `user.rpc_send_lobby_invite(context, payload)`
+
+Send a lobby invite notification to another user.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.to_user_id` | string | Yes | Target user to invite |
+| `payload.match_id` | string | Yes | Match/lobby ID to join |
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Not authenticated"`
+- `"Missing to_user_id or match_id"`
+
+**Notification sent:**
+| Field | Value |
+|---|---|
+| **Recipient** | `req.to_user_id` |
+| **Subject** | `" invited you to their lobby"` |
+| **Content** | `{"match_id": req.match_id, "from_name": senderName}` |
+| **Code** | `1001` |
+| **Sender** | `context.user_id` |
+| **Persistent** | `true` |
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 4.6 RPC: `send_friend_request`
+
+[Back to top](#top)
+
+**Function:** `user.rpc_send_friend_request(context, payload)`
+
+Send a friend request notification to another user.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.user_id` | string | Yes | Target user to friend |
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Not authenticated"`
+- `"user_id is required"`
+- `"Cannot add yourself"`
+
+**Notification sent:**
+| Field | Value |
+|---|---|
+| **Recipient** | `req.user_id` |
+| **Subject** | `"Friend Request"` |
+| **Content** | `{"from_user_id": context.user_id, "from_name": senderName}` |
+| **Code** | `1002` |
+| **Sender** | `context.user_id` |
+| **Persistent** | `true` |
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 4.7 After-Hook: `after_authenticate`
+
+[Back to top](#top)
+
+**Function:** `user.after_authenticate(context, out, payload)`
+
+Record login history after Device, Email, or Custom authentication.
+
+**Trigger:** `AuthenticateDevice`, `AuthenticateEmail`, `AuthenticateCustom`.
+
+**Parameters:** Standard Nakama after-hook (context, output, input).
+
+**Auth:** None (internal hook).
+
+**Behavior:**
+1. Skip if no `context.user_id`.
+2. Create login entry `{time=os.time(), ip=context.client_ip or "unknown"}`.
+3. Read existing logins from storage collection `"history"`, key `"logins"`, user's scope.
+4. Prepend new entry, keep last 20 entries.
+5. Write back to storage.
+
+**Collections:**
+- **Read:** `"history"` / `"logins"` / user scope
+- **Write:** `"history"` / `"logins"` / user scope (permission_read=0, permission_write=0)
+
+**Wallet changes:** None.
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 4.8 RPC: `admin_get_user_history`
+
+[Back to top](#top)
+
+**Function:** `user.rpc_admin_get_user_history(context, payload)`
+
+Admin-only: get wallet ledger, login history, and match history for a user.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.user_id` | string | Yes | Target user ID |
+
+**Auth:** Admin (calls `utils.require_admin(context)`).
+
+**Return:**
+```json
+{
+ "history": {
+ "wallet_ledger": [ ... ],
+ "logins": [ ... ],
+ "matches": [ ... ]
+ }
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"` (from `utils.require_admin`)
+- `"user_id is required"`
+
+**Collections read:**
+- Wallet ledger via `nk.wallet_ledger_list(targetUserId, 50)`
+- `"history"` / `"logins"` / user scope
+- `"matches"` collection list via `nk.storage_list(targetUserId, "matches", 50, "")`
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+---
+
+[Back to top](#top)
+
+## 5. Economy Module (`economy.lua`)
+
+[Back to top](#top)
+
+**Dependencies:** `utils` (for admin RPCs: `require_admin`).
+
+**Wallet keys used:** `gold`, `star`.
+
+### 5.1 Shop Catalog (embedded data)
+
+[Back to top](#top)
+
+`SHOP_CATALOG_DEFS` -- 12 cosmetic items across 3 categories:
+
+**Head items (Oldpop hats):**
+| Item ID | Name | Category | Gold Price | Rarity |
+|---|---|---|---|---|
+| `oldpop-blue-hat` | Oldpop Blue Hat | head | 100 | Common |
+| `oldpop-green-hat` | Oldpop Green Hat | head | 100 | Common |
+| `oldpop-red-hat` | Oldpop Red Hat | head | 100 | Common |
+| `oldpop-yellow-hat` | Oldpop Yellow Hat | head | 100 | Common |
+
+**Costume items (pants):**
+| Item ID | Name | Category | Gold Price | Rarity |
+|---|---|---|---|---|
+| `oldpop-og-pant` | Copper OG Pant | costume | 0 (free) | Common |
+| `oldpop-grey-pant` | Copper Grey Pant | costume | 150 | Common |
+| `oldpop-red-pant` | Copper Red Pant | costume | 150 | Common |
+| `oldpop-yellow-pant` | Copper Yellow Pant | costume | 150 | Common |
+
+**Glove items:**
+| Item ID | Name | Category | Gold Price | Rarity |
+|---|---|---|---|---|
+| `oldpop-blue-gloves` | Oldpop Blue Gloves | glove | 75 | Common |
+| `oldpop-green-gloves` | Oldpop Green Gloves | glove | 75 | Common |
+| `oldpop-red-gloves` | Oldpop Red Gloves | glove | 75 | Common |
+| `oldpop-yellow-gloves` | Oldpop Yellow Gloves | glove | 75 | Common |
+
+[Back to top](#top)
+
+### 5.2 RPC: `get_shop_catalog`
+
+[Back to top](#top)
+
+**Function:** `economy.rpc_get_shop_catalog(context, payload)`
+
+Get the shop catalog with featured banners.
+
+**Parameters:** None (payload is ignored).
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "catalog": {
+ "head": [
+ { "id": "string", "name": "string", "gold": 100, "star": 0, "rarity": "Common", "character": "Oldpop" }
+ ],
+ "costume": [ ... ],
+ "glove": [ ... ]
+ },
+ "featured_banners": [ ... ]
+}
+```
+
+**Collections read:** `"shop_config"` / `"featured_banners"` / system user scope.
+
+**Error strings:** `"Not authenticated"`
+
+**Dependencies:** None (no utils call).
+
+[Back to top](#top)
+
+### 5.3 RPC: `buy_currency`
+
+[Back to top](#top)
+
+**Function:** `economy.rpc_buy_currency(context, payload)`
+
+Purchase currency packs via IAP. Supports Google, Apple, and test receipts.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.package_id` | string | Yes | -- | One of: `gold_100`, `gold_500`, `gold_1000`, `gold_2000`, `gold_5000`, `gold_10000`, `star_100`, `star_250`, `star_600` |
+| `payload.receipt` | string | No | -- | Platform receipt string (required for gold packages) |
+| `payload.idempotency_key` | string | Yes | -- | Unique key to prevent duplicate processing |
+| `payload.store_type` | string | No | `"test"` | `"google"`, `"apple"`, or `"test"` |
+
+**Auth:** Required.
+
+**Package definitions:**
+| Package ID | Gold Gain | Star Gain | Requires Verification |
+|---|---|---|---|
+| `gold_100` | +100 | 0 | Yes |
+| `gold_500` | +550 | 0 | Yes |
+| `gold_1000` | +1150 | 0 | Yes |
+| `gold_2000` | +2400 | 0 | Yes |
+| `gold_5000` | +6250 | 0 | Yes |
+| `gold_10000` | +13000 | 0 | Yes |
+| `star_100` | -500 | +100 | No |
+| `star_250` | -1100 | +250 | No |
+| `star_600` | -2500 | +600 | No |
+
+**Return (pending verification):**
+```json
+{
+ "success": true,
+ "status": "pending",
+ "package_id": "string"
+}
+```
+
+**Return (verified/complete):**
+```json
+{
+ "success": true,
+ "status": "verified",
+ "package_id": "string"
+}
+```
+
+**Return (duplicate idempotency key):**
+```json
+{
+ "success": true,
+ "package_id": "string",
+ "duplicate": true,
+ "status": "string"
+}
+```
+
+**Error strings:**
+- `"Not authenticated"`
+- `"Package ID required"`
+- `"Idempotency key required"`
+- `"Invalid package ID"` -- unknown package_id
+- `"InvalidReceipt"` -- receipt validation failed
+- `"NotEnoughFunds"` -- wallet update failure (star purchases deduct gold)
+
+**Collections:**
+- **Read:** `"receipts"` / `` / user scope (duplicate check)
+- **Write (pending):** `"receipts"` / `` / user scope `{type, package_id, status="pending", created_at}`
+- **Write (verified):** `"receipts"` / `` / user scope `{type, package_id, changeset, receipt, status="verified", processed_at}`
+
+**Wallet changesets:**
+- Gold packages: `{gold: +N}` (verified receipt)
+- Star packages using gold conversion: `{star: +N, gold: -price}`
+
+**Receipt verification flow:**
+1. `store_type="google"`: calls `nk.purchase_validate_google(context.user_id, receipt)`
+2. `store_type="apple"`: calls `nk.purchase_validate_apple(context.user_id, receipt, "")`
+3. `store_type="test"`: passes if `receipt == "mock_receipt_for_now"`
+
+**Dependencies:** None (standalone).
+
+[Back to top](#top)
+
+### 5.4 RPC: `purchase_item`
+
+[Back to top](#top)
+
+**Function:** `economy.rpc_purchase_item(context, payload)`
+
+Buy cosmetic items from the shop catalog using wallet currency.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.item_id` | string | Yes | -- | Item ID from shop catalog |
+| `payload.quantity` | integer | No | `1` | Quantity to purchase |
+| `payload.idempotency_key` | string | Yes | -- | Unique key to prevent duplicate purchases |
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "success": true,
+ "item": "string"
+}
+```
+
+**Return (duplicate):**
+```json
+{
+ "success": true,
+ "item": "string",
+ "duplicate": true
+}
+```
+
+**Error strings:**
+- `"Not authenticated"`
+- `"Item ID required"`
+- `"Invalid quantity"`
+- `"Idempotency key required"`
+- `"ItemNotFound"` -- item_id not in catalog
+- `"NotEnoughFunds"` -- wallet insufficient
+- `"PurchaseFailed"` -- storage write failure
+
+**Collections:**
+- **Read:** `"receipts"` / `` / user scope
+- **Write:** `"inventory"` / `` / user scope `{category, purchased_at, quantity}`
+- **Write:** `"receipts"` / `` / user scope `{type="item", item_id, quantity, cost, processed_at}`
+
+**Wallet changesets:**
+- Gold items: `{gold: -priceGold}` (if priceGold > 0)
+- Star items: `{star: -priceStar}` (if priceStar > 0)
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 5.5 RPC: `admin_set_featured_banners`
+
+[Back to top](#top)
+
+**Function:** `economy.rpc_admin_set_featured_banners(context, payload)`
+
+Admin-only: set up to 3 featured banners for the shop. Validates item IDs against catalog.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.banners` | array | No | `[]` | Array of banner objects (max 3) |
+
+**Each banner object:**
+| Field | Type | Description |
+|---|---|---|
+| `item_id` | string | Valid catalog item ID |
+| Any other display fields | any | Passed through |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true,
+ "banners": [ ... ]
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"Item not found in catalog: "`
+
+**Collections written:** `"shop_config"` / `"featured_banners"` / system user scope.
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 5.6 RPC: `admin_get_featured_banners`
+
+[Back to top](#top)
+
+**Function:** `economy.rpc_admin_get_featured_banners(context, payload)`
+
+Admin-only: get current featured banners.
+
+**Parameters:** None.
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "banners": [ ... ]
+}
+```
+
+**Error strings:** `"Admin privileges required"`
+
+**Collections read:** `"shop_config"` / `"featured_banners"` / system user scope.
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+---
+
+[Back to top](#top)
+
+## 6. Gacha Module (`gacha.lua`)
+
+[Back to top](#top)
+
+**Dependencies:** None (standalone, no utils require).
+
+**Wallet keys used:** Varies by banner (`"star"` or `"gold"`).
+
+### 6.1 Embedded Gacha Data
+
+[Back to top](#top)
+
+**Banners:**
+
+| Banner ID | Name | Currency | Pull 1 Cost | Pull 10 Cost | Pity At |
+|---|---|---|---|---|---|
+| `star` | Star Banner | star | 160 | 1440 | 90 |
+| `gold` | Gold Banner | gold | 50 | 450 | 90 |
+
+**Drop rates (both banners):**
+| Rarity | Probability |
+|---|---|
+| `common` | 60% |
+| `uncommon` | 25% |
+| `rare` | 14% |
+| `real_prize` | 1% |
+
+**Fragment prizes:**
+| ID | Name | Rarity |
+|---|---|---|
+| `frag_common` | Common Fragment | common |
+| `frag_uncommon` | Uncommon Fragment | uncommon |
+| `frag_rare` | Rare Fragment | rare |
+
+**Real prize pool (skins):**
+| ID | Name | Category |
+|---|---|---|
+| `skin_gacha_rainbow_suit` | Rainbow Suit | costume |
+| `skin_gacha_dragon_hat` | Dragon Hat | head |
+| `skin_gacha_phantom_gloves` | Phantom Gloves | glove |
+| `skin_gacha_neon_acc` | Neon Accessory | accessory |
+
+[Back to top](#top)
+
+### 6.2 Internal: `roll_rarity(rates)`
+
+[Back to top](#top)
+
+Roll RNG to determine rarity tier.
+
+**Parameters:**
+| Param | Type | Description |
+|---|---|---|
+| `rates` | table | Rate table with keys: `real_prize`, `rare`, `uncommon`, `common` |
+
+**Returns:** `string` -- one of `"real_prize"`, `"rare"`, `"uncommon"`, `"common"`.
+
+**Logic:** Uses `math.random()` + cumulative probability.
+
+[Back to top](#top)
+
+### 6.3 Internal: `pick_from_pool(rarity)`
+
+[Back to top](#top)
+
+Pick a random item from the pool for a given rarity.
+
+**Parameters:**
+| Param | Type | Description |
+|---|---|---|
+| `rarity` | string | Rarity tier key |
+
+**Returns:** `string` -- item/fragment ID.
+
+**Logic:** Random index into `GACHA_DATA.pools[rarity]`.
+
+[Back to top](#top)
+
+### 6.4 RPC: `perform_gacha_pull`
+
+[Back to top](#top)
+
+**Function:** `gacha.rpc_perform_gacha_pull(context, payload)`
+
+Perform gacha pulls on a banner. Uses server-authoritative RNG with pity system.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.banner_id` | string | Yes | -- | `"star"` or `"gold"` |
+| `payload.count` | integer | No | `1` | Number of pulls (1 or 10 recommended) |
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "success": true,
+ "results": [
+ {
+ "id": "string",
+ "rarity": "string",
+ "name": "string"
+ }
+ ],
+ "new_pity": "integer"
+}
+```
+
+**Error strings:**
+- `"Not authenticated"`
+- `"Banner ID required"`
+- `"Invalid count"`
+- `"Unknown banner: "`
+- `"Could not read account"`
+- `"Insufficient currency"`
+- `"Failed to update wallet"`
+- `"Failed to write storage: "`
+
+**Collections:**
+- **Read:** User wallet via `nk.account_get_id(context.user_id)`
+- **Read:** `"profiles"` / `"pity_counters"` / user scope
+- **Read:** `"profiles"` / `"fragments"` / user scope
+- **Write:** `"profiles"` / `"pity_counters"` / user scope
+- **Write:** `"profiles"` / `"fragments"` / user scope
+- **Write (on real prize):** `"inventory"` / `` / user scope `{category, purchased_at, quantity: 1}`
+
+**Wallet changesets:**
+- Star banner: `{star: -cost}`
+- Gold banner: `{gold: -cost}`
+
+**Pity system:**
+- At 90 pulls without a `real_prize`, the next pull is forced `real_prize`.
+- Pity counter resets to 0 on any `real_prize` drop.
+- Each banner has its own independent pity counter.
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+---
+
+[Back to top](#top)
+
+## 7. Leaderboard Module (`leaderboard.lua`)
+
+[Back to top](#top)
+
+**Dependencies:** `utils` (for admin RPCs: `require_admin`).
+
+**Leaderboard:** `"global_high_score"` (authoritative, desc, best score, no reset).
+
+### 7.1 RPC: `get_leaderboard_stats`
+
+[Back to top](#top)
+
+**Function:** `leaderboard.rpc_get_leaderboard_stats(context, payload)`
+
+Get top 50 leaderboard entries.
+
+**Parameters:** None (payload ignored).
+
+**Auth:** None (public).
+
+**Return:**
+```json
+{
+ "leaderboard": [
+ {
+ "user_id": "string",
+ "username": "string",
+ "display_name": "string",
+ "avatar_url": "string",
+ "loadout_character": "string",
+ "high_score": "integer",
+ "games_played": "integer",
+ "games_won": "integer"
+ }
+ ]
+}
+```
+
+**Error strings:** None (returns `{leaderboard: {}}` on failure).
+
+**Collections read:** `nk.leaderboard_records_list("global_high_score", nil, 50, nil)`.
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 7.2 RPC: `submit_score`
+
+[Back to top](#top)
+
+**Function:** `leaderboard.rpc_submit_score(context, payload)`
+
+Submit a score to the global leaderboard.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.score` | number | No | `0` | Score value |
+| `payload.games_played` | integer | No | `0` | Total games played |
+| `payload.games_won` | integer | No | `0` | Total games won |
+| `payload.avatar_url` | string | No | account avatar | Avatar URL |
+| `payload.loadout_character` | string | No | `"Copper"` | Current character |
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Not authenticated"`
+- `"Failed to submit score"` -- `nk.leaderboard_record_write` failed
+
+**Leaderboard write:** `nk.leaderboard_record_write("global_high_score", userId, username, score, 0, metadata)`.
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 7.3 RPC: `sync_leaderboard`
+
+[Back to top](#top)
+
+**Function:** `leaderboard.rpc_sync_leaderboard(context, payload)`
+
+Sync all users' stats from storage to the native leaderboard. Iterates storage and rewrites records.
+
+**Parameters:** None (payload ignored).
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "success": true,
+ "synced": "integer (count)",
+ "objects_found": "integer",
+ "debug": [ "error messages" ]
+}
+```
+
+**Error strings:**
+- `"Not authenticated"`
+- `"Sync failed: "` -- `nk.storage_list` failed
+
+**Collections read:**
+- `"stats"` collection (all users, up to 100 objects)
+- `"profiles"` collection (all users, up to 100 objects)
+
+**Leaderboard writes:** For each user, writes `nk.leaderboard_record_write("global_high_score", uid, username, high_score, 0, metadata)`.
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 7.4 RPC: `reset_stats`
+
+[Back to top](#top)
+
+**Function:** `leaderboard.rpc_reset_stats(context, payload)`
+
+Reset the caller's leaderboard record and stats to zero.
+
+**Parameters:** None (payload ignored).
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:** `"Not authenticated"`
+
+**Collections:**
+- **Delete:** Leaderboard record via `nk.leaderboard_record_delete("global_high_score", userId)`
+- **Write:** `"stats"` / `"game_stats"` / user scope `{games_played:0, games_won:0, high_score:0, total_kills:0, total_deaths:0}`
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 7.5 RPC: `admin_update_stats`
+
+[Back to top](#top)
+
+**Function:** `leaderboard.rpc_admin_update_stats(context, payload)`
+
+Admin: overwrite any user's stats and update leaderboard record.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.user_id` | string | Yes | Target user |
+| `payload.stats` | table | Yes | Full stats object with `high_score`, `games_played`, `games_won`, etc. |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"User ID and stats are required"`
+
+**Collections:**
+- **Write:** `"stats"` / `"game_stats"` / target user scope
+- **Leaderboard write:** `nk.leaderboard_record_write("global_high_score", ...)`
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 7.6 RPC: `admin_delete_stats`
+
+[Back to top](#top)
+
+**Function:** `leaderboard.rpc_admin_delete_stats(context, payload)`
+
+Admin: delete a user's stats and leaderboard record.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.user_id` | string | Yes | Target user |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"User ID is required"`
+
+**Collections:**
+- **Delete:** `"stats"` / `"stats"` / user scope
+- **Delete:** `"stats"` / `"game_stats"` / user scope
+- **Delete:** Leaderboard record via `nk.leaderboard_record_delete`
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 7.7 RPC: `admin_sync_leaderboard`
+
+[Back to top](#top)
+
+**Function:** `leaderboard.rpc_admin_sync_leaderboard(context, payload)`
+
+Admin: trigger a full sync (delegates to `rpc_sync_leaderboard`).
+
+**Parameters:** Same as `sync_leaderboard`.
+
+**Auth:** Admin.
+
+**Return:** Same as `sync_leaderboard`.
+
+**Error strings:** Same as `sync_leaderboard` + `"Admin privileges required"`.
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+---
+
+[Back to top](#top)
+
+## 8. Inbox/Mail Module (`inbox.lua`)
+
+[Back to top](#top)
+
+**Dependencies:** `utils` (for admin RPCs: `require_admin`).
+
+**Wallet keys used:** `star`, `gold` (in mail rewards).
+
+### 8.1 RPC: `admin_send_mail`
+
+[Back to top](#top)
+
+**Function:** `inbox.rpc_admin_send_mail(context, payload)`
+
+Admin: send global or personal mail.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.title` | string | No | `"Announcement"` | Mail title |
+| `payload.content` | string | No | `""` | Mail body |
+| `payload.rewards` | array/table | No | `{}` | Reward items (star, gold, frag, skin) |
+| `payload.target_user_id` | string | No | `""` | If set, send personal mail to this user. If omitted/empty, sends global mail. |
+| `payload.start_date` | string | No | now | ISO8601 start date |
+| `payload.end_date` | string | No | `""` | ISO8601 global mail expiry |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true,
+ "mail": {
+ "id": "string (UUID v4)",
+ "title": "string",
+ "content": "string",
+ "sender": "TEKTON DEV TEAM",
+ "date": "ISO8601",
+ "start_date": "ISO8601",
+ "end_date": "ISO8601",
+ "expiry_date": "ISO8601 (+30 days)",
+ "rewards": [ ... ],
+ "type": "global|personal"
+ }
+}
+```
+
+**Error strings:** `"Admin privileges required"`
+
+**Collections:**
+- **Personal:** `"inbox"` / `"personal"` / target user scope
+- **Global:** `"config"` / `"global_mail"` / system user scope
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 8.2 RPC: `get_mail`
+
+[Back to top](#top)
+
+**Function:** `inbox.rpc_get_mail(context, payload)`
+
+Get the caller's mail (personal + global), filtered by state (deleted/expired/not-yet-started).
+
+**Parameters:** None (payload ignored).
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "mails": [ ... ],
+ "state": {
+ "claimed_ids": [ "string" ],
+ "deleted_ids": [ "string" ],
+ "read_ids": [ "string" ]
+ }
+}
+```
+
+**Filtering rules:**
+1. Exclude any mail whose ID is in `state.deleted_ids`.
+2. Exclude if `expiry_date` is past.
+3. Exclude if `start_date` is in the future.
+4. Exclude global mail if `end_date` is past.
+
+**Collections read:**
+- `"inbox"` / `"personal"` / user scope
+- `"config"` / `"global_mail"` / system scope
+- `"inbox"` / `"state"` / user scope
+
+**Error strings:** `"Not authenticated"`
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 8.3 RPC: `claim_mail_reward`
+
+[Back to top](#top)
+
+**Function:** `inbox.rpc_claim_mail_reward(context, payload)`
+
+Claim rewards attached to a mail message.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.mail_id` | string | Yes | Mail UUID |
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "success": true,
+ "claimed_ids": [ "string" ]
+}
+```
+
+**Error strings:**
+- `"Not authenticated"`
+- `"mail_id required"`
+- `"Reward already claimed"` -- mail_id in claimed_ids
+- `"Mail not found"` -- mail_id not in personal or global mail lists
+
+**Reward processing:**
+| Reward Type | Handling |
+|---|---|
+| `"star"` | Adds to wallet: `{star: amount}` |
+| `"gold"` | Adds to wallet: `{gold: amount}` |
+| `"frag_*"` / `"item"` | Increments fragment count in `"inventory"` / `"fragments"` |
+| `"skin"` | Writes `"inventory"` / `` `{acquired_via: "mail", purchased_at: ...}` |
+
+**Collections:**
+- **Read:** `"inbox"` / `"personal"` / user scope, `"config"` / `"global_mail"` / system scope, `"inbox"` / `"state"` / user scope, `"inventory"` / `"fragments"` / user scope
+- **Write:** `"inbox"` / `"state"` / user scope, `"inventory"` / `"fragments"` (if frags), `"inventory"` / `` (if skins)
+
+**Wallet changesets:** `{star: +N}`, `{gold: +N}`.
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 8.4 RPC: `delete_mail`
+
+[Back to top](#top)
+
+**Function:** `inbox.rpc_delete_mail(context, payload)`
+
+Soft-delete a mail for the caller (marks in state).
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.mail_id` | string | Yes | Mail UUID |
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "success": true,
+ "deleted_ids": [ "string" ]
+}
+```
+
+**Error strings:**
+- `"Not authenticated"`
+- `"mail_id required"`
+
+**Collections:**
+- **Read:** `"inbox"` / `"state"` / user scope
+- **Write:** `"inbox"` / `"state"` / user scope (adds to `deleted_ids` and `read_ids`)
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 8.5 RPC: `save_mail_state`
+
+[Back to top](#top)
+
+**Function:** `inbox.rpc_save_mail_state(context, payload)`
+
+Mark mail as read by saving read_ids to state.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.read_ids` | array of strings | No | `[]` | Mail IDs to mark as read |
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:** `"Not authenticated"`
+
+**Collections:**
+- **Read:** `"inbox"` / `"state"` / user scope
+- **Write:** `"inbox"` / `"state"` / user scope
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 8.6 RPC: `admin_list_mail`
+
+[Back to top](#top)
+
+**Function:** `inbox.rpc_admin_list_mail(context, payload)`
+
+Admin: list all mail (global + personal) across all users.
+
+**Parameters:** None (payload ignored).
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "mails": [
+ {
+ "id": "string",
+ "title": "string",
+ "content": "string",
+ "type": "global|personal",
+ "target_user_id": "string (personal only)",
+ ...
+ }
+ ]
+}
+```
+
+**Error strings:** `"Admin privileges required"`
+
+**Collections read:**
+- `"config"` / `"global_mail"` / system scope
+- `"inbox"` collection (all users, paginated via cursor until exhausted)
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 8.7 RPC: `admin_update_mail`
+
+[Back to top](#top)
+
+**Function:** `inbox.rpc_admin_update_mail(context, payload)`
+
+Admin: update an existing mail (title, content, dates, or reassign between global/personal).
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.mail_id` | string | Yes | Mail UUID to update |
+| `payload.type` | string | No | `"global"` or `"personal"` (determines source) |
+| `payload.target_user_id` | string | Cond. | Required for personal mail |
+| `payload.new_target_user_id` | string | No | Move to different user |
+| `payload.title` | string | No | New title |
+| `payload.content` | string | No | New content |
+| `payload.end_date` | string | No | New end date |
+| `payload.expiry_date` | string | No | New expiry date |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"mail_id required"`
+- `"Mail not found in global"` / `"Mail not found in personal inbox"`
+- `"target_user_id required for personal mail"`
+
+**Collections read/written:** Same as `admin_send_mail` but with removal from old location and insertion into new.
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 8.8 RPC: `admin_delete_mail_server`
+
+[Back to top](#top)
+
+**Function:** `inbox.rpc_admin_delete_mail_server(context, payload)`
+
+Admin: permanently delete mail from the server (global or personal).
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.mail_id` | string | Yes | Mail UUID |
+| `payload.type` | string | No | `"global"` or `"personal"` (default: global) |
+| `payload.target_user_id` | string | Cond. | Required for personal mail |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"mail_id required"`
+- `"Mail not found"` -- not in global mail list
+- `"target_user_id required for personal mail"`
+- `"Mail not found in personal inbox"` -- not in personal mail list
+
+**Collections:**
+- **Write:** `"config"` / `"global_mail"` / system scope (filtered) OR `"inbox"` / `"personal"` / user scope (filtered)
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+---
+
+[Back to top](#top)
+
+## 9. Daily Rewards Module (`daily_rewards.lua`)
+
+[Back to top](#top)
+
+**Dependencies:** `utils` (for admin RPCs: `require_admin`).
+
+**Wallet keys used:** `star`, `gold`.
+
+### 9.1 Reward Schedule (default, per month)
+
+[Back to top](#top)
+
+31 days (index 0-30), rewards scale from day 1 to 30:
+
+| Day | Star Reward | Formula |
+|---|---|---|
+| 1 | 10 | `10 + 0 * 5` |
+| 2 | 15 | `10 + 1 * 5` |
+| ... | ... | ... |
+| 19 | 100 (capped) | `10 + 18 * 5 = 100` |
+| 20-31 | 100 (capped) | `min(10 + day * 5, 100)` |
+
+[Back to top](#top)
+
+### 9.2 Reward Config Format
+
+[Back to top](#top)
+
+Admin can override per-month via `set_daily_reward_config`:
+
+```json
+{
+ "config": {
+ "01": [ // January (month number)
+ { "type": "star", "amount": 50 },
+ { "type": "gold", "amount": 100 },
+ { "type": "frag_common", "amount": 3 }
+ ],
+ "02": [ ... ], // February
+ ...
+ }
+}
+```
+
+Reward entries may be a number (treated as star-only) or a `{type, amount}` object.
+
+Reward types support: `"star"`, `"gold"`, `"frag_*"` (fragment ID, e.g. `frag_common`).
+
+[Back to top](#top)
+
+### 9.3 RPC: `claim_daily_reward`
+
+[Back to top](#top)
+
+**Function:** `daily_rewards.rpc_claim_daily_reward(context, payload)`
+
+Claim today's daily reward.
+
+**Parameters:** None (payload ignored; reward determined by server date).
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "success": true,
+ "reward_type": "string",
+ "reward_amount": "integer",
+ "day": "integer (1-31)"
+}
+```
+
+**Error strings:**
+- `"Not authenticated"`
+- `"Already claimed today"` -- `last_claim_date == today`
+- `"Already claimed all rewards for this month"` -- `dayIndex >= #monthRewards`
+- `"Already claimed today's reward"` -- dayIndex already in `claimed_days`
+
+**Collections:**
+- **Read:** `"daily_rewards"` / `"state"` / user scope, `"config"` / `"daily_rewards"` / system scope
+- **Write:** `"daily_rewards"` / `"state"` / user scope (updates claimed_days, last_claim_date, month)
+- **Write (frags):** `"inventory"` / `"fragments"` / user scope (if reward type is frag)
+
+**Wallet changesets:**
+- Star reward: `{star: +amount}`
+- Gold reward: `{gold: +amount}`
+
+**Month tracking:**
+- Resets `claimed_days` when month changes.
+- Uses `os.date("!*t")` for UTC dates.
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 9.4 RPC: `get_daily_reward_state`
+
+[Back to top](#top)
+
+**Function:** `daily_rewards.rpc_get_daily_reward_state(context, payload)`
+
+Get the caller's daily reward progress and schedule.
+
+**Parameters:** None (payload ignored).
+
+**Auth:** Required.
+
+**Return:**
+```json
+{
+ "state": {
+ "claimed_days": [ ... ],
+ "last_claim_date": "YYYY-MM-DD",
+ "month": "MM"
+ },
+ "month_rewards": [
+ { "type": "star", "amount": 10 },
+ ...
+ ],
+ "can_claim_today": "boolean",
+ "today_date": "YYYY-MM-DD",
+ "today_index": "integer (0-30)",
+ "server_month": "integer (1-12)"
+}
+```
+
+**Error strings:** `"Not authenticated"`
+
+**Collections read:** Same as `claim_daily_reward` (state + config).
+
+**Dependencies:** None.
+
+[Back to top](#top)
+
+### 9.5 RPC: `set_daily_reward_config`
+
+[Back to top](#top)
+
+**Function:** `daily_rewards.rpc_set_daily_reward_config(context, payload)`
+
+Admin: set the monthly reward schedule.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.config` | table | Yes | Month-keyed reward config object |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:** `"Admin privileges required"`
+
+**Collections written:** `"config"` / `"daily_rewards"` / system user scope.
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 9.6 RPC: `get_daily_reward_config_admin`
+
+[Back to top](#top)
+
+**Function:** `daily_rewards.rpc_get_daily_reward_config_admin(context, payload)`
+
+Admin: get the current daily reward configuration.
+
+**Parameters:** None.
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "config": { ... }
+}
+```
+
+**Error strings:** `"Admin privileges required"`
+
+**Collections read:** `"config"` / `"daily_rewards"` / system user scope.
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+---
+
+[Back to top](#top)
+
+## 10. Admin Module (`admin.lua`)
+
+[Back to top](#top)
+
+**Dependencies:** `utils` (many RPCs use `require_admin`, `require_admin_or_host`, `resolve_channel_id`, `SYSTEM_USER_ID`, `ADMIN_ROLES`).
+
+### 10.1 RPC: `admin_kick_player`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_kick_player(context, payload)`
+
+Kick a player from a match.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.match_id` | string | Yes | -- | Match ID |
+| `payload.user_id` | string | Yes | -- | Player to kick |
+| `payload.reason` | string | No | `"Kicked by admin"` | Kick reason |
+
+**Auth:** Admin or Host.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Admin or host privileges required"`
+- `"Cannot kick yourself"`
+- `"Failed to kick player"` -- `nk.match_signal` failed
+
+**Collect actions:** Sends signal `{action="kick", user_id=..., reason=...}` to the match via `nk.match_signal`.
+
+**Dependencies:** `utils.require_admin_or_host`.
+
+[Back to top](#top)
+
+### 10.2 RPC: `admin_ban_player`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_ban_player(context, payload)`
+
+Ban a player account with optional duration.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.user_id` | string | Yes | -- | Player to ban |
+| `payload.reason` | string | No | `"Banned by admin"` | Ban reason |
+| `payload.duration_hours` | number | No | `nil` (permanent) | Ban duration in hours |
+| `payload.match_id` | string | No | `nil` | Optional match to kick from |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true,
+ "ban": {
+ "user_id": "string",
+ "username": "string",
+ "banned_by": "string",
+ "banned_at": "integer (Unix time)",
+ "reason": "string",
+ "expires": "integer|null"
+ }
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"Cannot ban yourself"`
+- `"Target account not found"`
+- `"Cannot ban an admin"` -- target has admin/moderator/owner role
+
+**Collections:**
+- **Read:** User account metadata (check role)
+- **Write:** User account metadata via `nk.account_update_id` (sets `banned=true`, `ban_reason`, `ban_expires`)
+- **Write:** `"bans"` / `` / system scope `{user_id, username, banned_by, banned_at, reason, expires}`
+- **Signal (optional):** `{action="kick", user_id=..., reason="Banned: ..."}` via `nk.match_signal`
+
+**Ban expiry:** Stored as Unix timestamp (`os.time() + duration_hours * 3600`). Checked in `get_user_profile`.
+
+**Dependencies:** `utils.require_admin`, `utils.ADMIN_ROLES`.
+
+[Back to top](#top)
+
+### 10.3 RPC: `admin_unban_player`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_unban_player(context, payload)`
+
+Unban a previously banned player.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.user_id` | string | Yes | Player to unban |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"Target account not found"`
+
+**Collections:**
+- **Write:** User account metadata (clears `banned`, `ban_reason`, `ban_expires`)
+- **Delete:** `"bans"` / `` / system scope
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 10.4 RPC: `admin_get_ban_list`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_get_ban_list(context, payload)`
+
+Get all active ban records.
+
+**Parameters:** None (payload ignored).
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "bans": [ ... ]
+}
+```
+
+**Error strings:** `"Admin privileges required"`
+
+**Collections read:** `nk.storage_list("00000000-0000-0000-0000-000000000000", "bans", 100)`.
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 10.5 RPC: `admin_get_server_stats`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_get_server_stats(context, payload)`
+
+Get server statistics (active matches, players). Optionally get a specific match's details.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.match_id` | string | No | If provided, includes match detail and uses host-gated auth |
+
+**Auth:** Admin (or Host if match_id provided).
+
+**Return:**
+```json
+{
+ "active_matches": "integer",
+ "total_players": "integer",
+ "server_time": "integer (Unix timestamp)",
+ "match": {
+ "id": "string",
+ "size": "integer",
+ "tick_rate": "integer",
+ "authoritative": "boolean"
+ }
+}
+```
+
+**Error strings:** `"Admin privileges required"` or `"Admin or host privileges required"`.
+
+**Nakama API calls:** `nk.match_list(100, true)`, optionally `nk.match_get(match_id)`.
+
+**Dependencies:** `utils.require_admin`, `utils.require_admin_or_host`.
+
+[Back to top](#top)
+
+### 10.6 RPC: `admin_end_match`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_end_match(context, payload)`
+
+End a match by sending an end_match signal.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.match_id` | string | Yes | -- | Match to end |
+| `payload.reason` | string | No | `"Ended by admin"` | Reason |
+
+**Auth:** Admin or Host.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:** `"Admin or host privileges required"`
+
+**Actions:** Sends `{action="end_match", reason=...}` via `nk.match_signal`.
+
+**Dependencies:** `utils.require_admin_or_host`.
+
+[Back to top](#top)
+
+### 10.7 RPC: `admin_set_user_role`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_set_user_role(context, payload)`
+
+Set a user's role. Only `owner` role can use this.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.user_id` | string | Yes | Target user |
+| `payload.role` | string | Yes | One of: `"player"`, `"moderator"`, `"admin"` |
+
+**Auth:** Owner only (`context.user_id` metadata.role must be `"owner"`).
+
+**Return:**
+```json
+{
+ "success": true,
+ "role": "string"
+}
+```
+
+**Error strings:**
+- `"Only owners can modify user roles"` -- caller is not owner
+- `"Invalid role"` -- not one of player/moderator/admin
+
+**Collections:** User account metadata via `nk.account_update_id` (sets `metadata.role`).
+
+**Dependencies:** None (uses direct metadata check, not utils).
+
+[Back to top](#top)
+
+### 10.8 RPC: `admin_topup_gold`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_topup_gold(context, payload)`
+
+Add 999,999 gold to the admin's wallet (self-topup).
+
+**Parameters:** None (payload ignored).
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true,
+ "gold_added": 999999
+}
+```
+
+**Error strings:** `"Admin privileges required"`
+
+**Wallet changesets:** `{gold: 999999}`.
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 10.9 RPC: `admin_clear_global_chat`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_clear_global_chat(context, payload)`
+
+Delete all messages from a chat channel.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.channel_id` | string | Yes | Channel ID (or room name to resolve) |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true,
+ "deleted": "integer"
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"channel_id is required. Pass the channel ID from the client."`
+
+**Nakama API calls:**
+- `nk.channel_messages_list(channelId, 100, false, cursor)` -- paginated
+- `nk.channel_message_remove(channelId, msg.message_id)` -- per message
+
+**Dependencies:** `utils.require_admin`, `utils.resolve_channel_id`.
+
+[Back to top](#top)
+
+### 10.10 RPC: `admin_list_users`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_list_users(context, payload)`
+
+List up to 500 users with their roles and ban status.
+
+**Parameters:** None (payload ignored).
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "users": [
+ {
+ "user_id": "string",
+ "username": "string",
+ "display_name": "string",
+ "create_time": "datetime",
+ "role": "string",
+ "banned": "boolean",
+ "ban_reason": "string"
+ }
+ ],
+ "count": "integer"
+}
+```
+
+**Error strings:** `"Admin privileges required"`
+
+**SQL query:** `SELECT id, username, display_name, metadata, create_time FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 500`
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 10.11 RPC: `admin_delete_users`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_delete_users(context, payload)`
+
+Delete user accounts. Cannot delete admins or own account.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.user_ids` | array of strings | Yes | User IDs to delete |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true,
+ "deleted": [ "user_id", ... ],
+ "failed": [
+ { "user_id": "string", "reason": "string" }
+ ]
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"No user IDs provided"`
+- `"Cannot delete your own account"`
+- `"Cannot delete admin account"` -- per-user failure
+
+**Nakama API calls:** `nk.account_delete_id(uid, false)`.
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 10.12 RPC: `admin_get_user_detail`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_get_user_detail(context, payload)`
+
+Get comprehensive user details including account, friends, purchases, storage, wallet ledger.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.user_id` | string | Yes | -- | Target user |
+| `payload.collections` | array of strings | No | `["profiles","stats","inventory","receipts","history","matches","inbox"]` | Storage collections to include |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "user": {
+ "user_id": "string",
+ "username": "string",
+ "display_name": "string",
+ "avatar_url": "string",
+ "lang_tag": "string",
+ "location": "string",
+ "timezone": "string",
+ "create_time": "string",
+ "update_time": "string",
+ "metadata": { ... },
+ "wallet": { ... },
+ "email": "string",
+ "email_verified": "boolean"
+ },
+ "friends": [ ... ],
+ "purchases": [ ... ],
+ "wallet_ledger": [ ... ],
+ "storage": {
+ "profiles": [ { "key": "...", "value": ..., "version": "...", "update_time": "..." } ],
+ ...
+ },
+ "subscription": { ... }
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"user_id is required"`
+
+**Collections read:**
+- Account via `nk.account_get_id(userId)`
+- Friends via `nk.friends_list(userId, nil, 100, "")`
+- Receipts via `nk.storage_list(userId, "receipts", 100, "")`
+- Each requested collection via `nk.storage_list(userId, collection, 100, "")`
+- Wallet ledger via `nk.wallet_ledger_list(userId, 50)`
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 10.13 RPC: `admin_update_user_identity`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_update_user_identity(context, payload)`
+
+Admin: update a user's identity fields (username, display_name, avatar, timezone, location, lang_tag, metadata).
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.user_id` | string | Yes | Target user |
+| `payload.username` | string | No | New username |
+| `payload.display_name` | string | No | New display name |
+| `payload.timezone` | string | No | New timezone |
+| `payload.location` | string | No | New location |
+| `payload.lang_tag` | string | No | New language tag |
+| `payload.avatar_url` | string | No | New avatar URL |
+| `payload.metadata` | table | No | Metadata fields to merge |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:** `"Admin privileges required"`, `"user_id is required"`
+
+**Collections:** User account via `nk.account_update_id`. Merges metadata fields.
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 10.14 RPC: `admin_set_user_password`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_set_user_password(context, payload)`
+
+Admin: set a user's email password (requires user to have email credential).
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.user_id` | string | Yes | Target user |
+| `payload.password` | string | Yes | New password |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"user_id and password are required"`
+- `"User has no email credential"` -- account has no email
+
+**Collections:** User account via `nk.account_update_id(userId, ..., email, password)`.
+
+**Dependencies:** `utils.require_admin`.
+
+[Back to top](#top)
+
+### 10.15 RPC: `admin_get_player_list`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_get_player_list(context, payload)`
+
+Get players in a match (simplified -- rerturns empty array in current implementation).
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.match_id` | string | Yes | Match ID |
+
+**Auth:** Admin or Host.
+
+**Return:**
+```json
+{
+ "players": []
+}
+```
+
+**Error strings:**
+- `"Admin or host privileges required"`
+- `"Match not found"` -- `nk.match_get` failed
+
+**Nakama API calls:** `nk.match_get(match_id)`.
+
+**Note:** Player presence tracking depends on the match handler implementation; currently returns `{players=[]}`.
+
+**Dependencies:** `utils.require_admin_or_host`.
+
+[Back to top](#top)
+
+### 10.16 RPC: `admin_get_chat_config`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_get_chat_config(context, payload)`
+
+Get lobby chat configuration.
+
+**Parameters:** None (payload ignored).
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "config": {
+ "prefix": "string",
+ "max_messages": "integer",
+ "max_age_days": "integer"
+ }
+}
+```
+
+**Error strings:** `"Admin privileges required"`
+
+**Collections read:** `"config"` / `"lobby_chat"` / system user scope.
+
+**Dependencies:** `utils.require_admin`, `utils.SYSTEM_USER_ID`.
+
+[Back to top](#top)
+
+### 10.17 RPC: `admin_set_chat_config`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_set_chat_config(context, payload)`
+
+Set lobby chat configuration.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.prefix` | string | No | `""` | Chat command prefix |
+| `payload.max_messages` | integer | No | `50` | Max messages in history |
+| `payload.max_age_days` | integer | No | `0` | Max message age in days |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:** `"Admin privileges required"`
+
+**Collections written:** `"config"` / `"lobby_chat"` / system user scope.
+
+**Dependencies:** `utils.require_admin`, `utils.SYSTEM_USER_ID`.
+
+[Back to top](#top)
+
+### 10.18 RPC: `admin_purge_old_messages`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_purge_old_messages(context, payload)`
+
+Delete chat messages older than a specified age.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.channel_id` | string | Yes | Channel ID or room name |
+| `payload.max_age_days` | integer | Yes | Messages older than this (days) will be deleted |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true,
+ "deleted": "integer"
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"channel_id is required"` -- empty
+- `"max_age_days must be > 0"`
+
+**Logic:**
+1. Resolve channel_id via `utils.resolve_channel_id`.
+2. Calculate cutoff: `os.time() - maxAgeDays * 86400`.
+3. Paginate messages via `nk.channel_messages_list`.
+4. Parse each message's `create_time` (ISO or Unix), compare to cutoff.
+5. Remove old messages via `nk.channel_message_remove`.
+
+**Dependencies:** `utils.require_admin`, `utils.resolve_channel_id`.
+
+[Back to top](#top)
+
+### 10.19 RPC: `admin_list_channel_messages`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_list_channel_messages(context, payload)`
+
+List messages in a chat channel with pagination.
+
+**Parameters:**
+| Param | Type | Required | Default | Description |
+|---|---|---|---|---|
+| `payload.channel_id` | string | Yes | -- | Channel ID or room name |
+| `payload.limit` | integer | No | `50` | Max messages to return |
+| `payload.cursor` | string | No | `""` | Pagination cursor |
+| `payload.forward` | boolean | No | `true` | Direction |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "messages": [
+ {
+ "message_id": "string",
+ "sender_id": "string",
+ "username": "string",
+ "content": "string",
+ "create_time": "string",
+ "update_time": "string",
+ "channel_id": "string"
+ }
+ ],
+ "channel_id": "string",
+ "next_cursor": "string",
+ "cache_cursor": "string"
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"channel_id is required"`
+- `"Failed to list messages: "`
+
+**Nakama API calls:** `nk.channel_messages_list(channelId, limit, forward, cursor)`.
+
+**Dependencies:** `utils.require_admin`, `utils.resolve_channel_id`.
+
+[Back to top](#top)
+
+### 10.20 RPC: `admin_delete_channel_message`
+
+[Back to top](#top)
+
+**Function:** `admin.rpc_admin_delete_channel_message(context, payload)`
+
+Delete a single chat message.
+
+**Parameters:**
+| Param | Type | Required | Description |
+|---|---|---|---|
+| `payload.channel_id` | string | Yes | Channel ID or room name |
+| `payload.message_id` | string | Yes | Message UUID |
+
+**Auth:** Admin.
+
+**Return:**
+```json
+{
+ "success": true
+}
+```
+
+**Error strings:**
+- `"Admin privileges required"`
+- `"channel_id and message_id are required"`
+- `"Failed to delete message: "`
+
+**Nakama API calls:** `nk.channel_message_remove(channelId, messageId)`.
+
+**Dependencies:** `utils.require_admin`, `utils.resolve_channel_id`.
+
+[Back to top](#top)
+
+---
+
+[Back to top](#top)
+
+## 11. Storage Collections Reference
+
+[Back to top](#top)
+
+Complete map of all storage collections, keys, and access scopes used by the server.
+
+### User-Scoped Collections (per-user storage)
+
+[Back to top](#top)
+
+| Collection | Key | Description | Read/Write | Permission |
+|---|---|---|---|---|
+| `history` | `logins` | Login history (last 20 entries) | user.lua | read=0, write=0 |
+| `inbox` | `personal` | Personal mail messages | inbox.lua | read=1, write=0 |
+| `inbox` | `state` | Mail state (claimed/deleted/read IDs) | inbox.lua | read=1, write=0 |
+| `inventory` | `` | Purchased items/skins | economy.lua, gacha.lua, inbox.lua | read=1, write=0 |
+| `inventory` | `fragments` | Fragment counts | inbox.lua, daily_rewards.lua | read=1, write=0 |
+| `profiles` | `pity_counters` | Gacha pity per banner | gacha.lua | read=1, write=0 |
+| `profiles` | `fragments` | Gacha fragments | gacha.lua | read=1, write=0 |
+| `receipts` | `` | Purchase receipts / idempotency | economy.lua | read=1, write=0 |
+| `stats` | `game_stats` | User game stats | leaderboard.lua | read=2/1, write=1/0 |
+| `matches` | various | Match history | user.lua | -- |
+| `daily_rewards` | `state` | Daily reward claim state | daily_rewards.lua | read=1, write=0 |
+
+### System-Scoped Collections (user_id = SYSTEM_USER_ID `"00000000-0000-0000-0000-000000000000"`)
+
+[Back to top](#top)
+
+| Collection | Key | Description | Read/Write | Permission |
+|---|---|---|---|---|
+| `config` | `global_mail` | Global mail messages | inbox.lua | read=2, write=0 |
+| `config` | `daily_rewards` | Daily reward configuration | daily_rewards.lua | read=2, write=0 |
+| `config` | `lobby_chat` | Lobby chat configuration | admin.lua | read=2, write=0 |
+| `shop_config` | `featured_banners` | Shop featured banners | economy.lua | read=2, write=0 |
+| `bans` | `` | Ban records | admin.lua | read=2, write=0 |
+
+[Back to top](#top)
+
+## 12. Wallet Keys and Currency
+
+[Back to top](#top)
+
+| Key | Type | Description | Operations |
+|---|---|---|---|
+| `gold` | integer | Gold currency (soft currency, earnable, bought via IAP) | + from buy_currency, purchase_item, mail, daily_rewards, admin_topup; - from gacha, star conversion |
+| `star` | integer | Star currency (premium currency, bought with gold) | + from buy_currency, mail, daily_rewards; - from gacha |
+
+[Back to top](#top)
+
+## 13. Leaderboard Configuration
+
+[Back to top](#top)
+
+| Property | Value |
+|---|---|
+| **ID** | `"global_high_score"` |
+| **Authoritative** | `true` (server-only writes) |
+| **Sort order** | `"desc"` (highest score first) |
+| **Operator** | `"best"` (keep the best score per user) |
+| **Reset schedule** | `nil` (never auto-resets) |
+| **Created at** | Module load (core.lua, leaderboard.lua) via `nk.leaderboard_create` |
+
+[Back to top](#top)
+
+## 14. Error Strings Reference
+
+[Back to top](#top)
+
+Complete list of every error string raised by Lua backend RPCs.
+
+### Authentication
+
+[Back to top](#top)
+
+| Error String | Source |
+|---|---|
+| `"Not authenticated"` | Multiple RPCs |
+| `"Admin privileges required"` | `utils.require_admin` |
+| `"Admin or host privileges required"` | `utils.require_admin_or_host` |
+| `"Only owners can modify user roles"` | admin:set_user_role |
+
+### User
+
+[Back to top](#top)
+
+| Error String | Source |
+|---|---|
+| `"Account not found"` | get_user_profile |
+| `"Account banned until . Reason: "` | get_user_profile |
+| `"Account permanently banned. Reason: "` | get_user_profile |
+| `"Failed to update profile"` | update_user_profile |
+| `"Current password required"` | change_credentials |
+| `"Incorrect current password."` | change_credentials |
+| `"Failed to set new credentials: "` | change_credentials |
+| `"Missing to_user_id or match_id"` | send_lobby_invite |
+| `"user_id is required"` | send_friend_request |
+| `"Cannot add yourself"` | send_friend_request |
+
+### Economy
+
+[Back to top](#top)
+
+| Error String | Source |
+|---|---|
+| `"Package ID required"` | buy_currency |
+| `"Idempotency key required"` | buy_currency |
+| `"Invalid package ID"` | buy_currency |
+| `"InvalidReceipt"` | buy_currency |
+| `"NotEnoughFunds"` | buy_currency, purchase_item |
+| `"Item ID required"` | purchase_item |
+| `"Invalid quantity"` | purchase_item |
+| `"ItemNotFound"` | purchase_item |
+| `"PurchaseFailed"` | purchase_item |
+| `"Item not found in catalog: "` | admin_set_featured_banners |
+
+### Gacha
+
+[Back to top](#top)
+
+| Error String | Source |
+|---|---|
+| `"Banner ID required"` | perform_gacha_pull |
+| `"Invalid count"` | perform_gacha_pull |
+| `"Unknown banner: "` | perform_gacha_pull |
+| `"Could not read account"` | perform_gacha_pull |
+| `"Insufficient currency"` | perform_gacha_pull |
+| `"Failed to update wallet"` | perform_gacha_pull |
+| `"Failed to write storage: "` | perform_gacha_pull |
+
+### Leaderboard
+
+[Back to top](#top)
+
+| Error String | Source |
+|---|---|
+| `"Failed to submit score"` | submit_score |
+| `"Sync failed: "` | sync_leaderboard |
+| `"User ID and stats are required"` | admin_update_stats |
+| `"User ID is required"` | admin_delete_stats |
+
+### Inbox/Mail
+
+[Back to top](#top)
+
+| Error String | Source |
+|---|---|
+| `"mail_id required"` | claim_mail_reward, delete_mail, admin_update_mail, admin_delete_mail_server |
+| `"Reward already claimed"` | claim_mail_reward |
+| `"Mail not found"` | claim_mail_reward, admin_delete_mail_server |
+| `"Mail not found in global"` | admin_update_mail |
+| `"Mail not found in personal inbox"` | admin_update_mail, admin_delete_mail_server |
+| `"target_user_id required for personal mail"` | admin_update_mail, admin_delete_mail_server |
+
+### Daily Rewards
+
+[Back to top](#top)
+
+| Error String | Source |
+|---|---|
+| `"Already claimed today"` | claim_daily_reward |
+| `"Already claimed all rewards for this month"` | claim_daily_reward |
+| `"Already claimed today's reward"` | claim_daily_reward |
+
+### Admin
+
+[Back to top](#top)
+
+| Error String | Source |
+|---|---|
+| `"Cannot kick yourself"` | admin_kick_player |
+| `"Failed to kick player"` | admin_kick_player |
+| `"Cannot ban yourself"` | admin_ban_player |
+| `"Target account not found"` | admin_ban_player, admin_unban_player |
+| `"Cannot ban an admin"` | admin_ban_player |
+| `"Invalid role"` | admin_set_user_role |
+| `"channel_id is required. Pass the channel ID from the client."` | admin_clear_global_chat |
+| `"No user IDs provided"` | admin_delete_users |
+| `"Cannot delete your own account"` | admin_delete_users |
+| `"Cannot delete admin account"` | admin_delete_users |
+| `"user_id and password are required"` | admin_set_user_password |
+| `"User has no email credential"` | admin_set_user_password |
+| `"Match not found"` | admin_get_player_list |
+| `"channel_id is required"` | admin_purge_old_messages, admin_list_channel_messages |
+| `"max_age_days must be > 0"` | admin_purge_old_messages |
+| `"channel_id and message_id are required"` | admin_delete_channel_message |
+| `"Failed to delete message: "` | admin_delete_channel_message |
+| `"Failed to list messages: "` | admin_list_channel_messages |
+
+[Back to top](#top)
+
+## 15. Permission Levels
+
+[Back to top](#top)
+
+| Level | metadata.role | Can Access |
+|---|---|---|
+| **Player** | `"player"` (default) | All non-admin RPCs |
+| **Moderator** | `"moderator"` | All admin RPCs except `set_user_role` |
+| **Admin** | `"admin"` | All admin RPCs except `set_user_role` |
+| **Owner** | `"owner"` | All RPCs including `admin_set_user_role` |
+
+**Guard functions:**
+- `utils.require_admin(context)` -- requires admin, moderator, or owner
+- `utils.require_admin_or_host(context, match_id)` -- requires admin/moderator/owner, OR be the match host
+- `admin_set_user_role` -- requires `metadata.role == "owner"` (checked inline, not via utils)
+
+**Match host check:**
+- `utils.is_match_host(context, match_id)` checks if `state.hostUserId == context.user_id` in the match's decoded state.
+
+[Back to top](#top)
+
+---
+
+*End of function reference. Generated from source code. 48 registered RPCs, 4 after-hooks. Last updated: July 2026.*