From 7bffc9d381559011d0d20577f0740f425b76dee3 Mon Sep 17 00:00:00 2001 From: adtpdn Date: Sun, 5 Jul 2026 13:46:43 +0800 Subject: [PATCH] feat: full per-function reference for Architecture-Server (48 RPCs, 4 hooks, all modules) --- Architecture-Server.md | 2856 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 2802 insertions(+), 54 deletions(-) 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.*