feat: full per-function reference for Architecture-Server (48 RPCs, 4 hooks, all modules)

2026-07-05 13:46:43 +08:00
parent 68cefaf263
commit 7bffc9d381
+2802 -54
@@ -1,78 +1,2826 @@
<a id="top"></a> <a id="top"></a>
# 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) [Back to top](#top)
## Table of Contents
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
[Back to top](#top) [Back to top](#top)
- **`economy.lua`:** 1. [Module Loading (main.lua)](#1-module-loading-mainluau)
- `buy_currency`: Validates mock/Google/Apple receipts and directly adds Gold or subtracts Gold for Stars via `nk.wallet_update`. 2. [Core / Auth Hooks (core.lua)](#2-core--auth-hooks-coreluau)
- `purchase_item`: Buys cosmetic items from the shop catalog. Validates wallet funds and writes to `inventory` storage. 3. [Utilities (utils.lua)](#3-utilities-utilsluau)
- **`gacha.lua`:** 4. [User Module (user.lua)](#4-user-module-userluau)
- `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. 5. [Economy Module (economy.lua)](#5-economy-module-economyluau)
- **`inbox.lua`:** 6. [Gacha Module (gacha.lua)](#6-gacha-module-gachaluau)
- `get_mail` / `claim_mail_reward`: Server-authoritative mail system. Rewards attached to mail are granted directly to the wallet on claim. 7. [Leaderboard Module (leaderboard.lua)](#7-leaderboard-module-leaderboardluau)
- **`user.lua` & `admin.lua`:** 8. [Inbox/Mail Module (inbox.lua)](#8-inboxmail-module-inboxluau)
- `admin_topup_gold`: Admin-only RPC to grant 999,999 gold. 9. [Daily Rewards Module (daily_rewards.lua)](#9-daily-rewards-module-daily_rewardsluau)
- `admin_ban_player`: Banning tool. 10. [Admin Module (admin.lua)](#10-admin-module-adminluau)
- Profile and friend sync logic. 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) [Back to top](#top)
## 1. Module Loading (`main.lua`)
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/`)
[Back to top](#top) [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) [Back to top](#top)
- **Trigger:** Pushing a git tag (e.g. `v3.0.0`). ### 1.1 Registered RPCs (complete index)
- **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`)
[Back to top](#top) [Back to top](#top)
- **Trigger:** Manual `workflow_dispatch` button from the Gitea UI. All 48 RPCs registered by the backend:
- **Purpose:** Pushing lightweight Godot asset changes (Scripts, Scenes, VFX, Sounds) instantly.
- **Flow:** | RPC Name | Lua Function | Module | Auth Required |
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. | `get_user_profile` | `user.rpc_get_user_profile` | user.lua | No (fallback to caller) |
3. Force-pushes `patch.pck` and `version.json` to the orphan `patches` git branch. | `update_user_profile` | `user.rpc_update_user_profile` | user.lua | Yes |
4. A `gitea-pages` Docker container serves this branch publicly at `https://raw.klud.top`. | `search_users` | `user.rpc_search_users` | user.lua | Yes |
- **Player Experience:** | `change_credentials` | `user.rpc_change_credentials` | user.lua | Yes |
1. Game boots to `boot_screen.tscn`. | `send_lobby_invite` | `user.rpc_send_lobby_invite` | user.lua | Yes |
2. Fetches `https://raw.klud.top/danchie/tekton/version.json`. | `send_friend_request` | `user.rpc_send_friend_request` | user.lua | Yes |
3. Downloads `patch.pck` if the version is newer. | `admin_get_user_history` | `user.rpc_admin_get_user_history` | user.lua | Admin |
4. Mounts it into `user://` to override the base `res://` filesystem seamlessly. | `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 <date>. Reason: <reason>"` -- user is banned (absolute or expiry)
- `"Account permanently banned. Reason: <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 `"%<query>%"`
**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: <error>"` -- `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** | `"<senderName> 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"` / `<idempotencyKey>` / user scope (duplicate check)
- **Write (pending):** `"receipts"` / `<idempotencyKey>` / user scope `{type, package_id, status="pending", created_at}`
- **Write (verified):** `"receipts"` / `<idempotencyKey>` / 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"` / `<idempotencyKey>` / user scope
- **Write:** `"inventory"` / `<itemId>` / user scope `{category, purchased_at, quantity}`
- **Write:** `"receipts"` / `<idempotencyKey>` / 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: <itemId>"`
**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: <id>"`
- `"Could not read account"`
- `"Insufficient currency"`
- `"Failed to update wallet"`
- `"Failed to write storage: <error>"`
**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"` / `<item_id>` / 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: <error>"` -- `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"` / `<skin_id>` `{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"` / `<skin_id>` (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"` / `<user_id>` / 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"` / `<user_id>` / 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: <error>"`
**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: <error>"`
**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` | `<item_id>` | 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` | `<idempotency_key>` | 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` | `<user_id>` | 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 <date>. Reason: <reason>"` | get_user_profile |
| `"Account permanently banned. Reason: <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: <error>"` | 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: <item_id>"` | 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: <id>"` | 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: <error>"` | perform_gacha_pull |
### Leaderboard
[Back to top](#top)
| Error String | Source |
|---|---|
| `"Failed to submit score"` | submit_score |
| `"Sync failed: <error>"` | 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: <error>"` | admin_delete_channel_message |
| `"Failed to list messages: <error>"` | 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.*