chore: clean stale docs, add lua server reference, update steamworks guide

This commit is contained in:
god
2026-07-05 12:47:47 +08:00
parent dcb252f412
commit 644f2f0f95
8 changed files with 1639 additions and 5805 deletions
+1639
View File
@@ -0,0 +1,1639 @@
# Tekton Nakama Lua Server API Reference
> Auto-generated from source code. For AI agents — call these RPCs without reading Lua.
## Table of Contents
1. [Authentication & Core (core.lua)](#1-authentication--core-coreluau)
2. [Utilities (utils.lua)](#2-utilities-utilsluau)
3. [User Module (user.lua)](#3-user-module-userluau)
4. [Economy Module (economy.lua)](#4-economy-module-economyluau)
5. [Gacha Module (gacha.lua)](#5-gacha-module-gachaluau)
6. [Leaderboard Module (leaderboard.lua)](#6-leaderboard-module-leaderboardluau)
7. [Inbox/Mail Module (inbox.lua)](#7-inboxmail-module-inboxluau)
8. [Daily Rewards Module (daily_rewards.lua)](#8-daily-rewards-module-daily_rewardsluau)
9. [Admin Module (admin.lua)](#9-admin-module-adminluau)
10. [Storage Collections Reference](#10-storage-collections-reference)
11. [Leaderboard Config](#11-leaderboard-config)
12. [Error Strings Reference](#12-error-strings-reference)
13. [Permission Levels](#13-permission-levels)
---
## 1. Authentication & Core (`core.lua`)
### After-Hook: `after_authenticate_steam`
| Field | Value |
|---|---|
| **Trigger** | `AuthenticateSteam` |
| **Purpose** | On first Steam login: sets display_name from Steam username, default role to `"player"` |
| **Auth** | None (internal hook) |
**Behavior:**
- If user has no `display_name`, sets it from `input.username` (fallback: `"SteamPlayer"`)
- If user metadata has no `role`, sets `metadata.role = "player"`
### Startup (not an RPC)
```
nk.leaderboard_create("global_high_score", true, "desc", "best", nil, {})
```
Creates native leaderboard `"global_high_score"` on module load (also created in `leaderboard.lua` — pcall wraps both so duplicate is safe).
---
## 2. Utilities (`utils.lua`)
Shared helpers used by other modules.
### Constants
| Constant | Value | Purpose |
|---|---|---|
| `ADMIN_ROLES` | `{admin=true, moderator=true, owner=true}` | Roles with admin privileges |
| `SYSTEM_USER_ID` | `"00000000-0000-0000-0000-000000000000"` | System/nil user for global storage |
| `CHANNEL_TYPE_ROOM` | `1` | Nakama channel type for rooms |
| `CHANNEL_TYPE_DIRECT` | `2` | Nakama channel type for DMs |
| `CHANNEL_TYPE_GROUP` | `3` | Nakama channel type for groups |
### `utils.is_admin(context)`
Checks if caller has admin/moderator/owner role.
| Param | Type | Description |
|---|---|---|
| `context` | table | Nakama RPC context |
**Returns:** `boolean`
### `utils.is_match_host(context, match_id)`
Checks if caller is the host of a match.
| Param | Type | Description |
|---|---|---|
| `context` | table | Nakama RPC context |
| `match_id` | string | Match ID to check |
**Returns:** `boolean`
**Note:** Reads `match.state` and checks `state.hostUserId` against `context.user_id`.
### `utils.require_admin(context)`
Errors with `"Admin privileges required"` if caller is not admin/moderator/owner.
### `utils.require_admin_or_host(context, match_id)`
Errors with `"Admin or host privileges required"` if caller is neither admin nor match host.
### `utils.resolve_channel_id(channel_id)`
Resolves a friendly room name (e.g. `"social_global"`) to a hashed Nakama channel ID.
| Param | Type | Description |
|---|---|---|
| `channel_id` | string | Raw channel ID or friendly room name |
**Returns:** `string` — resolved channel ID (passes through if already hashed, i.e. contains `"."`)
**Logic:** If no `"."` in value, calls `nk.channel_id_build("", name, CHANNEL_TYPE_ROOM)`.
---
## 3. User Module (`user.lua`)
### RPC: `get_user_profile`
| Field | Value |
|---|---|
| **Function** | `user.rpc_get_user_profile` |
| **Auth** | None required (can view any profile) |
| **Purpose** | Get a user's public profile |
**Params (JSON):**
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `user_id` | string | No | `context.user_id` | Target user ID |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `user_id` | string | User ID |
| `username` | string | Username |
| `display_name` | string | Display name |
| `avatar_url` | string | Avatar URL |
| `create_time` | string | Account creation time |
| `role` | string | Role from metadata (`"player"` default) |
**Errors:**
- `"Account not found"` — user doesn't exist
- `"Account banned until <time>. Reason: <reason>"` — temporary ban still active
- `"Account permanently banned. Reason: <reason>"` — permanent ban
**Ban logic:** Reads `metadata.banned`, `metadata.ban_expires` (Unix timestamp), `metadata.ban_reason`. If `ban_expires <= os.time()`, auto-clears the ban on read.
### RPC: `update_user_profile`
| Field | Value |
|---|---|
| **Function** | `user.rpc_update_user_profile` |
| **Auth** | Required |
| **Purpose** | Update own display name and/or avatar |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `display_name` | string | No | New display name |
| `avatar_url` | string | No | New avatar URL |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | Always true |
**Errors:**
- `"Not authenticated"`
- `"Failed to update profile"`
### RPC: `search_users`
| Field | Value |
|---|---|
| **Function** | `user.rpc_search_users` |
| **Auth** | Required |
| **Purpose** | Search users by username or display name |
**Params (JSON):**
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `query` | string | No | `""` | Search term (ILike match on username/display_name). Empty = list all (limit 100). |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `users` | array | Array of user objects |
**User object:**
| Field | Type | Description |
|---|---|---|
| `user_id` | string | User ID |
| `username` | string | Username |
| `display_name` | string | Display name |
| `avatar_url` | string | Avatar URL from metadata |
**SQL:** Queries `users` table with `ILIKE`. Excludes system user `00000000-0000-0000-0000-000000000000`. Limit 100.
### RPC: `change_credentials`
| Field | Value |
|---|---|
| **Function** | `user.rpc_change_credentials` |
| **Auth** | Required |
| **Purpose** | Change email + password credentials |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `new_email` | string | No | New email |
| `new_password` | string | No | New password |
| `current_password` | string | If email set | Current password (required to unlink old email) |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | Always true |
**Errors:**
- `"Not authenticated"`
- `"Current password required"` — account has email but no `current_password` provided
- `"Incorrect current password."` — email auth re-verify failed
- `"Failed to set new credentials: <err>"` — link_email failed (rolls back old link)
**Logic:** Verifies current password via `nk.authenticate_email`, unlinks old email, links new email+password. On failure, re-links old credentials.
### RPC: `send_lobby_invite`
| Field | Value |
|---|---|
| **Function** | `user.rpc_send_lobby_invite` |
| **Auth** | Required |
| **Purpose** | Send a lobby invitation notification |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `to_user_id` | string | Yes | Recipient user ID |
| `match_id` | string | Yes | Match/lobby ID to invite to |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | Always true |
**Errors:**
- `"Not authenticated"`
- `"Missing to_user_id or match_id"`
**Notification:** Code `1001`, persistent (`true`). Content: `{match_id, from_name}`.
### RPC: `send_friend_request`
| Field | Value |
|---|---|
| **Function** | `user.rpc_send_friend_request` |
| **Auth** | Required |
| **Purpose** | Send a friend request notification |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `user_id` | string | Yes | Target user ID |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | Always true |
**Errors:**
- `"Not authenticated"`
- `"user_id is required"`
- `"Cannot add yourself"`
**Notification:** Code `1002`, persistent (`true`). Content: `{from_user_id, from_name}`.
### RPC: `admin_get_user_history`
| Field | Value |
|---|---|
| **Function** | `user.rpc_admin_get_user_history` |
| **Auth** | Admin required |
| **Purpose** | Get wallet ledger, login history, and match history for a user |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `user_id` | string | Yes | Target user ID |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `history.wallet_ledger` | array | Last 50 wallet ledger entries |
| `history.logins` | array | Login history (up to 20) |
| `history.matches` | array | Match history from storage collection `"matches"` |
**Errors:**
- `"Admin privileges required"` (via `utils.require_admin`)
- `"user_id is required"`
### After-Hook: `after_authenticate`
| Field | Value |
|---|---|
| **Trigger** | `AuthenticateDevice`, `AuthenticateEmail`, `AuthenticateCustom` |
| **Purpose** | Record login timestamp + IP to user storage |
**Storage write:** Collection `"history"`, key `"logins"`, permission_read=`0`, permission_write=`0`. Keeps last 20 entries. Each entry: `{time, ip}`.
---
## 4. Economy Module (`economy.lua`)
### Shop Catalog
Hardcoded items available for purchase:
| ID | Name | Category | Gold | Star | Rarity | Character |
|---|---|---|---|---|---|---|
| `oldpop-blue-hat` | Oldpop Blue Hat | head | 100 | 0 | Common | Oldpop |
| `oldpop-green-hat` | Oldpop Green Hat | head | 100 | 0 | Common | Oldpop |
| `oldpop-red-hat` | Oldpop Red Hat | head | 100 | 0 | Common | Oldpop |
| `oldpop-yellow-hat` | Oldpop Yellow Hat | head | 100 | 0 | Common | Oldpop |
| `oldpop-og-pant` | Copper OG Pant | costume | 0 | 0 | Common | Oldpop |
| `oldpop-grey-pant` | Copper Grey Pant | costume | 150 | 0 | Common | Oldpop |
| `oldpop-red-pant` | Copper Red Pant | costume | 150 | 0 | Common | Oldpop |
| `oldpop-yellow-pant` | Copper Yellow Pant | costume | 150 | 0 | Common | Oldpop |
| `oldpop-blue-gloves` | Oldpop Blue Gloves | glove | 75 | 0 | Common | Oldpop |
| `oldpop-green-gloves` | Oldpop Green Gloves | glove | 75 | 0 | Common | Oldpop |
| `oldpop-red-gloves` | Oldpop Red Gloves | glove | 75 | 0 | Common | Oldpop |
| `oldpop-yellow-gloves` | Oldpop Yellow Gloves | glove | 75 | 0 | Common | Oldpop |
### RPC: `get_shop_catalog`
| Field | Value |
|---|---|
| **Function** | `economy.rpc_get_shop_catalog` |
| **Auth** | Required |
| **Purpose** | Get the shop catalog grouped by category, plus featured banners |
**Params:** None (ignore payload)
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `catalog` | object | Grouped by category (`head`, `costume`, `glove`). Each entry: `{id, name, gold, star, rarity, character}` |
| `featured_banners` | array | Banners from `shop_config`/`featured_banners` (max 3 items, each: `{item_id, ...}`) |
**Errors:** `"Not authenticated"`
### RPC: `buy_currency`
| Field | Value |
|---|---|
| **Function** | `economy.rpc_buy_currency` |
| **Auth** | Required |
| **Purpose** | Purchase a currency package (IAP) |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `package_id` | string | Yes | One of: `gold_100`, `gold_500`, `gold_1000`, `gold_2000`, `gold_5000`, `gold_10000`, `star_100`, `star_250`, `star_600` |
| `receipt` | string | For gold packages | Store receipt for validation |
| `idempotency_key` | string | Yes | Idempotency key (prevents double-claim) |
| `store_type` | string | No | `"google"`, `"apple"`, or `"test"` (default: `"test"`) |
**Package table:**
| Package ID | Gold Change | Star Change | 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 |
**Returns (JSON) — success:**
| Field | Type | Description |
|---|---|---|
| `success` | bool | `true` |
| `status` | string | `"verified"` (receipt validated) or `"pending"` (no receipt, awaiting verification) |
| `package_id` | string | Package ID |
| `duplicate` | bool | (optional) `true` if idempotency key already used |
**Errors:**
- `"Not authenticated"`
- `"Package ID required"`
- `"Idempotency key required"`
- `"Invalid package ID"`
- `"InvalidReceipt"` — IAP validation failed
- `"NotEnoughFunds"` — wallet update failed (e.g. insufficient gold for star packages)
**Idempotency:** Checks `receipts` collection with idempotency_key. Returns existing result on duplicate.
**Receipt validation:** For `store_type="test"`, accepts `receipt == "mock_receipt_for_now"`.
### RPC: `purchase_item`
| Field | Value |
|---|---|
| **Function** | `economy.rpc_purchase_item` |
| **Auth** | Required |
| **Purpose** | Purchase an item from the shop catalog |
**Params (JSON):**
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `item_id` | string | Yes | — | Item ID from catalog |
| `quantity` | number | No | 1 | Quantity (min 1) |
| `idempotency_key` | string | Yes | — | Idempotency key |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | `true` |
| `item` | string | Item ID purchased |
| `duplicate` | bool | (optional) `true` if idempotency key already used |
**Errors:**
- `"Not authenticated"`
- `"Item ID required"`
- `"Invalid quantity"`
- `"Idempotency key required"`
- `"ItemNotFound"`
- `"NotEnoughFunds"` — insufficient gold/star balance
- `"PurchaseFailed"` — storage write failed
**Storage writes:**
- Collection `"inventory"`, key `item_id`: `{category, purchased_at, quantity}`
- Collection `"receipts"`, key `idempotencyKey`: `{type="item", item_id, quantity, cost, processed_at}`
### RPC: `admin_set_featured_banners`
| Field | Value |
|---|---|
| **Function** | `economy.rpc_admin_set_featured_banners` |
| **Auth** | Admin required |
| **Purpose** | Set up to 3 featured banner items for the shop |
**Params (JSON):**
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `banners` | array | No | `[]` | Array of banner objects, each with `{item_id, ...}` (max 3) |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | `true` |
| `banners` | array | Final banners array (max 3) |
**Errors:**
- `"Admin privileges required"`
- `"Item not found in catalog: <itemId>"` — banner references a non-catalog item
**Storage:** Collection `"shop_config"`, key `"featured_banners"`, user `SYSTEM_USER_ID`, permission_read=`2`, permission_write=`0`.
### RPC: `admin_get_featured_banners`
| Field | Value |
|---|---|
| **Function** | `economy.rpc_admin_get_featured_banners` |
| **Auth** | Admin required |
| **Purpose** | Get current featured banners config |
**Params:** None (ignore payload)
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `banners` | array | Current banners (may be empty) |
---
## 5. Gacha Module (`gacha.lua`)
### Gacha 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
| Rarity | Probability | Pool Items |
|---|---|---|
| `real_prize` | 1.0% | `skin_gacha_rainbow_suit`, `skin_gacha_dragon_hat`, `skin_gacha_phantom_gloves`, `skin_gacha_neon_acc` |
| `rare` | 14.0% | `frag_rare` |
| `uncommon` | 25.0% | `frag_uncommon` |
| `common` | 60.0% | `frag_common` |
### Real Prize Catalog
| Item 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 |
### Fragment Items
| ID | Name | Rarity |
|---|---|---|
| `frag_common` | Common Fragment | common |
| `frag_uncommon` | Uncommon Fragment | uncommon |
| `frag_rare` | Rare Fragment | rare |
### RPC: `perform_gacha_pull`
| Field | Value |
|---|---|
| **Function** | `gacha.rpc_perform_gacha_pull` |
| **Auth** | Required |
| **Purpose** | Perform 1 or 10 gacha pulls on a banner |
**Params (JSON):**
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `banner_id` | string | Yes | — | `"star"` or `"gold"` |
| `count` | number | No | 1 | Number of pulls (1 or 10) |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | Always true |
| `results` | array | Array of pull results: `{id, rarity, name}` |
| `new_pity` | number | Updated pity counter for this banner |
**Errors:**
- `"Not authenticated"`
- `"Banner ID required"`
- `"Invalid count"`
- `"Unknown banner: <banner_id>"`
- `"Could not read account"`
- `"Insufficient currency"`
- `"Failed to update wallet"`
- `"Failed to write storage: <err>"`
**Pity system:** After `pity_at` (90) pulls without a `real_prize`, the next pull is guaranteed `real_prize`. Pity counter resets on `real_prize` drop. Pity counter persists per banner in storage.
**Storage reads/writes:**
- Collection `"profiles"`, key `"pity_counters"`: per-banner pity counts
- Collection `"profiles"`, key `"fragments"`: fragment inventory
- Collection `"inventory"`, key = item_id: real prize items
**Wallet:** Deducts currency cost. For `count=10`, uses `pull_10_cost` if available, otherwise `pull_1_cost * count`.
---
## 6. Leaderboard Module (`leaderboard.lua`)
### Leaderboard Config
| Property | Value |
|---|---|
| **ID** | `"global_high_score"` |
| **Authoritative** | `true` |
| **Sort Order** | `"desc"` (highest first) |
| **Operator** | `"best"` (best score kept) |
| **Reset Schedule** | `nil` (never resets) |
### RPC: `get_leaderboard_stats`
| Field | Value |
|---|---|
| **Function** | `leaderboard.rpc_get_leaderboard_stats` |
| **Auth** | None |
| **Purpose** | Get top 50 leaderboard entries |
**Params:** None (ignore payload)
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `leaderboard` | array | Top 50 leaderboard entries |
**Leaderboard entry object:**
| Field | Type | Description |
|---|---|---|
| `user_id` | string | Owner user ID |
| `username` | string | Username |
| `display_name` | string | Username (fallback — actually `record.username`) |
| `avatar_url` | string | Avatar URL from metadata |
| `loadout_character` | string | Character from metadata (default `"Copper"`) |
| `high_score` | number | Score |
| `games_played` | number | From metadata |
| `games_won` | number | From metadata |
### RPC: `submit_score`
| Field | Value |
|---|---|
| **Function** | `leaderboard.rpc_submit_score` |
| **Auth** | Required |
| **Purpose** | Submit/update own score on the leaderboard |
**Params (JSON):**
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `score` | number | No | 0 | Score to submit |
| `games_played` | number | No | 0 | Games played count |
| `games_won` | number | No | 0 | Games won count |
| `avatar_url` | string | No | From account | Avatar URL |
| `loadout_character` | string | No | `"Copper"` | Character selection |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | `true` |
**Errors:**
- `"Not authenticated"`
- `"Failed to submit score"`
### RPC: `sync_leaderboard`
| Field | Value |
|---|---|
| **Function** | `leaderboard.rpc_sync_leaderboard` |
| **Auth** | Required |
| **Purpose** | Bulk-sync all users' stats from `"stats"` and `"profiles"` collections to the native leaderboard |
**Params:** None (ignore payload)
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | Always true |
| `synced` | number | Number of records written |
| `objects_found` | number | Stats objects found |
| `debug` | array | Error messages per user |
**Errors:**
- `"Not authenticated"`
- `"Sync failed: <err>"`
**Storage reads:** Collection `"stats"` (all users, limit 100), Collection `"profiles"` (all users, limit 100), key `"profile"`.
### RPC: `reset_stats`
| Field | Value |
|---|---|
| **Function** | `leaderboard.rpc_reset_stats` |
| **Auth** | Required |
| **Purpose** | Delete own leaderboard record and reset stats to zero |
**Params:** None (ignore payload)
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | `true` |
**Storage write:** Collection `"stats"`, key `"game_stats"` — zeros out `games_played`, `games_won`, `high_score`, `total_kills`, `total_deaths`. permission_read=`2`, permission_write=`1`.
### RPC: `admin_update_stats`
| Field | Value |
|---|---|
| **Function** | `leaderboard.rpc_admin_update_stats` |
| **Auth** | Admin required |
| **Purpose** | Force-overwrite a user's stats and leaderboard record |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `user_id` | string | Yes | Target user ID |
| `stats` | object | Yes | Stats object: `{high_score, games_played, games_won, loadout_character, ...}` |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | `true` |
**Errors:**
- `"Admin privileges required"`
- `"User ID and stats are required"`
**Storage write:** Collection `"stats"`, key `"game_stats"`, permission_read=`1`, permission_write=`0`.
### RPC: `admin_delete_stats`
| Field | Value |
|---|---|
| **Function** | `leaderboard.rpc_admin_delete_stats` |
| **Auth** | Admin required |
| **Purpose** | Delete a user's stats storage and leaderboard record |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `user_id` | string | Yes | Target user ID |
**Storage deletes:** `{collection="stats", key="stats"}`, `{collection="stats", key="game_stats"}`, plus leaderboard record.
### RPC: `admin_sync_leaderboard`
| Field | Value |
|---|---|
| **Function** | `leaderboard.rpc_admin_sync_leaderboard` |
| **Auth** | Admin required |
| **Purpose** | Same as `sync_leaderboard` but requires admin |
**Delegates to:** `leaderboard.rpc_sync_leaderboard` (same params, same returns).
---
## 7. Inbox/Mail Module (`inbox.lua`)
### RPC: `admin_send_mail`
| Field | Value |
|---|---|
| **Function** | `inbox.rpc_admin_send_mail` |
| **Auth** | Admin required |
| **Purpose** | Send a personal (targeted) or global mail |
**Params (JSON):**
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `title` | string | No | `"Announcement"` | Mail title |
| `content` | string | No | `""` | Mail body |
| `start_date` | string | No | Current UTC | ISO8601 start date |
| `end_date` | string | No | `""` | ISO8601 end date (global only) |
| `rewards` | array/object | No | `{}` | Attached rewards (see claim_mail_reward for format) |
| `target_user_id` | string | No | `""` | If set → personal mail to this user. Omit → global mail |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | `true` |
| `mail` | object | The created mail object |
**Mail object fields:** `id` (UUIDv4), `title`, `content`, `sender` (`"TEKTON DEV TEAM"`), `date`, `start_date`, `end_date`, `expiry_date` (30 days from now), `rewards`, `type` (`"personal"` or `"global"`).
**Personal storage:** Collection `"inbox"`, key `"personal"`, permission_read=`1`, permission_write=`0`.
**Global storage:** Collection `"config"`, key `"global_mail"`, user `SYSTEM_USER_ID`, permission_read=`2`, permission_write=`0`.
### RPC: `get_mail`
| Field | Value |
|---|---|
| **Function** | `inbox.rpc_get_mail` |
| **Auth** | Required |
| **Purpose** | Get all non-deleted, non-expired personal + global mail for the user |
**Params:** None (ignore payload)
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `mails` | array | Filtered mail objects (excludes deleted, expired, not-yet-started, ended global) |
| `state` | object | `{claimed_ids, deleted_ids, read_ids}` |
**Filtering logic:** Excludes mail where:
- ID is in `state.deleted_ids`
- `expiry_date` is in the past
- `start_date` is in the future
- Global mail with `end_date` in the past
**Storage reads:**
- Collection `"inbox"`, key `"personal"`, user = caller
- Collection `"config"`, key `"global_mail"`, user = `SYSTEM_USER_ID`
- Collection `"inbox"`, key `"state"`, user = caller
### RPC: `claim_mail_reward`
| Field | Value |
|---|---|
| **Function** | `inbox.rpc_claim_mail_reward` |
| **Auth** | Required |
| **Purpose** | Claim rewards attached to a mail |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `mail_id` | string | Yes | Mail ID to claim rewards from |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | `true` |
| `claimed_ids` | array | Updated list of claimed mail IDs |
**Reward format (in the mail object):**
Legacy dict format: `{star=100, gold=50}`
Array format: `[{type="star", amount=100}, {type="gold", amount=50}, {type="frag_rare", id="frag_rare", amount=1}, {type="skin", id="skin_gacha_rainbow_suit"}]`
| Reward `type` | Behavior |
|---|---|
| `"star"` | Added to wallet |
| `"gold"` | Added to wallet |
| `"frag_"` prefix | Added to inventory fragments (key: `"fragments"`) |
| `"item"` | Added to inventory fragments |
| `"skin"` | Added to inventory (key = skin item ID) |
**Errors:**
- `"Not authenticated"`
- `"mail_id required"`
- `"Reward already claimed"`
- `"Mail not found"`
**Storage writes:** Wallet update, then collection `"inventory"` (fragments or skins), then `"inbox"`/`"state"`.
### RPC: `delete_mail`
| Field | Value |
|---|---|
| **Function** | `inbox.rpc_delete_mail` |
| **Auth** | Required |
| **Purpose** | Soft-delete a mail (adds ID to state.deleted_ids) |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `mail_id` | string | Yes | Mail ID to delete |
### RPC: `save_mail_state`
| Field | Value |
|---|---|
| **Function** | `inbox.rpc_save_mail_state` |
| **Auth** | Required |
| **Purpose** | Mark mails as read |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `read_ids` | array | No | Array of mail IDs to mark as read |
### RPC: `admin_list_mail`
| Field | Value |
|---|---|
| **Function** | `inbox.rpc_admin_list_mail` |
| **Auth** | Admin required |
| **Purpose** | List all mail (global + personal) across all users |
**Params:** None (ignore payload)
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `mails` | array | All mails sorted by date descending. Personal mails have extra fields: `type="personal"`, `target_user_id` |
### RPC: `admin_update_mail`
| Field | Value |
|---|---|
| **Function** | `inbox.rpc_admin_update_mail` |
| **Auth** | Admin required |
| **Purpose** | Edit or move a mail between users |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `mail_id` | string | Yes | Mail ID to update |
| `type` | string | No | `"global"` or `"personal"` |
| `target_user_id` | string | If personal | Current owner (for lookup) |
| `new_target_user_id` | string | No | Transfer to a different user |
| `title` | string | No | New title |
| `content` | string | No | New content |
| `end_date` | string | No | New end date |
| `expiry_date` | string | No | New expiry date |
### RPC: `admin_delete_mail_server`
| Field | Value |
|---|---|
| **Function** | `inbox.rpc_admin_delete_mail_server` |
| **Auth** | Admin required |
| **Purpose** | Permanently delete mail from server storage |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `mail_id` | string | Yes | Mail ID to delete |
| `type` | string | No | `"global"` or `"personal"` |
| `target_user_id` | string | If personal | Owner user ID |
---
## 8. Daily Rewards Module (`daily_rewards.lua`)
### Default Reward Schedule
Days 131, each day gives `star` currency: `min(10 + (day-1)*5, 100)`.
| Day | Reward |
|---|---|
| 1 | 10 star |
| 2 | 15 star |
| ... | ... |
| 19 | 100 star (capped) |
| 2031 | 100 star each |
### RPC: `claim_daily_reward`
| Field | Value |
|---|---|
| **Function** | `daily_rewards.rpc_claim_daily_reward` |
| **Auth** | Required |
| **Purpose** | Claim today's daily reward |
**Params:** None (ignore payload)
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `success` | bool | `true` |
| `reward_type` | string | `"star"`, `"gold"`, or a fragment ID (starts with `"frag_"`) |
| `reward_amount` | number | Amount rewarded |
| `day` | number | Day number (1-indexed) |
**Errors:**
- `"Not authenticated"`
- `"Already claimed today"``last_claim_date == today`
- `"Already claimed all rewards for this month"` — day index >= rewards array length
- `"Already claimed today's reward"` — day index already in `claimed_days`
**Storage reads/writes:**
- Read: Collection `"daily_rewards"`, key `"state"` (per-user)
- Read: Collection `"config"`, key `"daily_rewards"`, user `SYSTEM_USER_ID` (config)
- Write: Collection `"daily_rewards"`, key `"state"` (updates claimed days)
- Write (if fragment): Collection `"inventory"`, key `"fragments"`
- Wallet update for star/gold rewards
**Reward types:** `"star"` or `"gold"` → wallet update. `"frag_"` prefix → inventory fragments.
### RPC: `get_daily_reward_state`
| Field | Value |
|---|---|
| **Function** | `daily_rewards.rpc_get_daily_reward_state` |
| **Auth** | Required |
| **Purpose** | Get current daily reward state and month config |
**Params:** None (ignore payload)
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `state` | object | `{claimed_days, last_claim_date, month}` |
| `month_rewards` | array | Reward config for current month (array of `{type, amount}`) |
| `can_claim_today` | bool | Whether user can claim today |
| `today_date` | string | Today's UTC date (YYYY-MM-DD) |
| `today_index` | number | Today's 0-based index |
| `server_month` | number | Current month number |
### RPC: `set_daily_reward_config` [Admin]
| Field | Value |
|---|---|
| **Function** | `daily_rewards.rpc_set_daily_reward_config` |
| **Auth** | Admin required |
| **Purpose** | Set monthly daily reward configuration |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `config` | object | Yes | Map of month → array of `{type, amount}` rewards. E.g. `{"01": [{type="star", amount=50}, ...]}` |
### RPC: `get_daily_reward_config_admin` [Admin]
| Field | Value |
|---|---|
| **Function** | `daily_rewards.rpc_get_daily_reward_config_admin` |
| **Auth** | Admin required |
| **Purpose** | Get the current daily reward config |
---
## 9. Admin Module (`admin.lua`)
### RPC: `admin_kick_player`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_kick_player` |
| **Auth** | Admin **or** match host |
| **Purpose** | Kick a player from a match |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `match_id` | string | Yes | Match ID |
| `user_id` | string | Yes | Player to kick |
| `reason` | string | No | `"Kicked by admin"` |
**Errors:**
- `"Admin or host privileges required"`
- `"Cannot kick yourself"`
- `"Failed to kick player"`
**Mechanism:** Sends `nk.match_signal` with `{action="kick", user_id, reason}`.
### RPC: `admin_ban_player`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_ban_player` |
| **Auth** | Admin required |
| **Purpose** | Ban a player (sets metadata.banned, kicks from match) |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `user_id` | string | Yes | User to ban |
| `reason` | string | No | `"Banned by admin"` |
| `duration_hours` | number | No | Hours until ban expires (omitted = permanent) |
| `match_id` | string | No | Also kick from this match |
**Errors:**
- `"Admin privileges required"`
- `"Cannot ban yourself"`
- `"Target account not found"`
- `"Cannot ban an admin"`
**Storage:** Writes ban record to collection `"bans"`, key = `user_id`, user = `SYSTEM_USER_ID`, permission_read=`2`, permission_write=`0`.
### RPC: `admin_unban_player`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_unban_player` |
| **Auth** | Admin required |
| **Purpose** | Remove ban from a player |
**Params (JSON):**
| Field | Type | Required |
|---|---|---|
| `user_id` | string | Yes |
### RPC: `admin_get_ban_list`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_get_ban_list` |
| **Auth** | Admin required |
| **Purpose** | List all ban records |
### RPC: `admin_get_server_stats`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_get_server_stats` |
| **Auth** | Admin (or host if `match_id` provided) |
| **Purpose** | Get server stats (active matches, players) |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `match_id` | string | No | If set, includes match-specific stats |
**Returns (JSON):**
| Field | Type | Description |
|---|---|---|
| `active_matches` | number | Count of active matches |
| `total_players` | number | Total players in matches |
| `server_time` | number | `os.time()` |
| `match` | object | (optional) `{id, size, tick_rate, authoritative}` |
### RPC: `admin_end_match`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_end_match` |
| **Auth** | Admin **or** match host |
| **Purpose** | Signal a match to end |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `match_id` | string | Yes | Match ID |
| `reason` | string | No | `"Ended by admin"` |
### RPC: `admin_set_user_role`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_set_user_role` |
| **Auth** | **Owner only** (role must be `"owner"`) |
| **Purpose** | Set a user's role |
**Params (JSON):**
| Field | Type | Required | Valid Values |
|---|---|---|---|
| `user_id` | string | Yes | Target user |
| `role` | string | Yes | `"player"`, `"moderator"`, `"admin"` |
**Errors:**
- `"Only owners can modify user roles"`
- `"Invalid role"`
### RPC: `admin_topup_gold`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_topup_gold` |
| **Auth** | Admin required |
| **Purpose** | Add 999,999 gold to own wallet (dev quick-fill) |
**Wallet update:** `{gold = 999999}`
### RPC: `admin_clear_global_chat`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_clear_global_chat` |
| **Auth** | Admin required |
| **Purpose** | Delete all messages from a chat channel |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `channel_id` | string | Yes | Channel ID or room name |
**Returns (JSON):** `{success, deleted}`
### RPC: `admin_list_users`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_list_users` |
| **Auth** | Admin required |
| **Purpose** | List all users (limit 500) with role and ban info |
**Returns (JSON):** `{users: [{user_id, username, display_name, create_time, role, banned, ban_reason}], count}`
### RPC: `admin_delete_users`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_delete_users` |
| **Auth** | Admin required |
| **Purpose** | Permanently delete user accounts |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `user_ids` | array | Yes | Array of user IDs to delete |
**Errors:**
- `"Cannot delete your own account"`
- `"Cannot delete admin account"` (prevents deleting admin/moderator/owner)
**Returns (JSON):** `{success, deleted: [user_ids], failed: [{user_id, reason}]}`
### RPC: `admin_get_user_detail`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_get_user_detail` |
| **Auth** | Admin required |
| **Purpose** | Get comprehensive user details (profile, friends, purchases, storage, wallet ledger) |
**Params (JSON):**
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `user_id` | string | Yes | — | Target user ID |
| `collections` | array | No | `["profiles","stats","inventory","receipts","history","matches","inbox"]` | Storage collections to include |
**Returns (JSON):** Rich object with `{user, friends, purchases, wallet_ledger, storage, subscription}`.
### RPC: `admin_update_user_identity`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_update_user_identity` |
| **Auth** | Admin required |
| **Purpose** | Update a user's identity fields (username, display_name, timezone, location, lang_tag, avatar_url, metadata) |
**Params (JSON):**
| Field | Type | Required |
|---|---|---|
| `user_id` | string | Yes |
| `username` | string | No |
| `display_name` | string | No |
| `timezone` | string | No |
| `location` | string | No |
| `lang_tag` | string | No |
| `avatar_url` | string | No |
| `metadata` | object | No (merged into existing metadata) |
### RPC: `admin_set_user_password`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_set_user_password` |
| **Auth** | Admin required |
| **Purpose** | Force-set a user's password (user must have email credential) |
**Params (JSON):**
| Field | Type | Required |
|---|---|---|
| `user_id` | string | Yes |
| `password` | string | Yes |
**Errors:**
- `"User has no email credential"`
- `"user_id and password are required"`
### RPC: `admin_get_player_list`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_get_player_list` |
| **Auth** | Admin **or** match host |
| **Purpose** | Get player list for a match (stub — returns empty) |
### RPC: `admin_get_chat_config`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_get_chat_config` |
| **Auth** | Admin required |
| **Purpose** | Get lobby chat configuration |
**Returns (JSON):** `{config: {prefix, max_messages, max_age_days}}`
### RPC: `admin_set_chat_config`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_set_chat_config` |
| **Auth** | Admin required |
| **Purpose** | Set lobby chat configuration |
**Params (JSON):**
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `prefix` | string | No | `""` | Chat prefix |
| `max_messages` | number | No | 50 | Max messages |
| `max_age_days` | number | No | 0 | Max message age |
**Storage:** Collection `"config"`, key `"lobby_chat"`, user `SYSTEM_USER_ID`.
### RPC: `admin_purge_old_messages`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_purge_old_messages` |
| **Auth** | Admin required |
| **Purpose** | Delete chat messages older than specified days |
**Params (JSON):**
| Field | Type | Required | Description |
|---|---|---|---|
| `channel_id` | string | Yes | Channel ID or room name |
| `max_age_days` | number | Yes | Delete messages older than this |
### RPC: `admin_list_channel_messages`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_list_channel_messages` |
| **Auth** | Admin required |
| **Purpose** | List messages in a chat channel |
**Params (JSON):**
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `channel_id` | string | Yes | — | Channel ID or room name |
| `limit` | number | No | 50 | Messages per page |
| `cursor` | string | No | `""` | Pagination cursor |
| `forward` | bool | No | `true` | Direction |
### RPC: `admin_delete_channel_message`
| Field | Value |
|---|---|
| **Function** | `admin.rpc_admin_delete_channel_message` |
| **Auth** | Admin required |
| **Purpose** | Delete a specific chat message |
**Params (JSON):**
| Field | Type | Required |
|---|---|---|
| `channel_id` | string | Yes |
| `message_id` | string | Yes |
---
## 10. Storage Collections Reference
### Collection: `"shop_config"`
| Key | User | Permission Read | Permission Write | Schema |
|---|---|---|---|---|
| `featured_banners` | SYSTEM | 2 (public) | 0 (owner) | `{banners: [{item_id, ...}]}` |
### Collection: `"receipts"`
| Key | User | Permission Read | Permission Write | Schema |
|---|---|---|---|---|
| `<idempotency_key>` | Owner | 1 (owner-read) | 0 (owner) | `{type, package_id/ item_id, status, changeset, receipt, processed_at, ...}` |
### Collection: `"inventory"`
| Key | User | Permission Read | Permission Write | Schema |
|---|---|---|---|---|
| `<item_id>` | Owner | 1 (owner-read) | 0 (owner) | `{category, purchased_at, quantity}` (or `{acquired_via, purchased_at}` for skins) |
| `"fragments"` | Owner | 1 (owner-read) | 0 (owner) | `{frag_common: N, frag_uncommon: N, frag_rare: N, ...}` |
### Collection: `"profiles"`
| Key | User | Permission Read | Permission Write | Schema |
|---|---|---|---|---|
| `"pity_counters"` | Owner | 1 (owner-read) | 0 (owner) | `{star: N, gold: N}` (per-banner pity) |
| `"fragments"` | Owner | 1 (owner-read) | 0 (owner) | Fragment counts (same as inventory fragments) |
| `"profile"` | Owner | 2 (public) | 0 (owner) | Profile data (avatar_url, loadout_character, etc.) |
### Collection: `"history"`
| Key | User | Permission Read | Permission Write | Schema |
|---|---|---|---|---|
| `"logins"` | Owner | 0 (no-read) | 0 (owner) | `{logins: [{time, ip}, ...]}` (max 20) |
### Collection: `"stats"`
| Key | User | Permission Read | Permission Write | Schema |
|---|---|---|---|---|
| `"game_stats"` | Owner | 1 (owner-read) or 2 (public) | 1 (owner-write) or 0 (owner) | `{games_played, games_won, high_score, total_kills, total_deaths, avatar_url, loadout_character}` |
| `"stats"` | Owner | Varies | 0 (owner) | Legacy/user-level stats |
### Collection: `"inbox"`
| Key | User | Permission Read | Permission Write | Schema |
|---|---|---|---|---|
| `"personal"` | Owner | 1 (owner-read) | 0 (owner) | `{mails: [{id, title, content, sender, date, start_date, end_date, expiry_date, rewards, type}]}` |
| `"state"` | Owner | 1 (owner-read) | 0 (owner) | `{claimed_ids: [string], deleted_ids: [string], read_ids: [string]}` |
### Collection: `"daily_rewards"`
| Key | User | Permission Read | Permission Write | Schema |
|---|---|---|---|---|
| `"state"` | Owner | 1 (owner-read) | 0 (owner) | `{claimed_days: [number], last_claim_date: "YYYY-MM-DD", month: "MM"}` |
### Collection: `"config"` (system-level)
| Key | User | Permission Read | Permission Write | Schema |
|---|---|---|---|---|
| `"daily_rewards"` | SYSTEM | 2 (public) | 0 (owner) | `{"MM": [{type, amount}, ...]}` (month→rewards map) |
| `"global_mail"` | SYSTEM | 2 (public) | 0 (owner) | `{mails: [{id, title, content, ...}]}` |
| `"lobby_chat"` | SYSTEM | 2 (public) | 0 (owner) | `{prefix, max_messages, max_age_days}` |
### Collection: `"bans"`
| Key | User | Permission Read | Permission Write | Schema |
|---|---|---|---|---|
| `<user_id>` | SYSTEM | 2 (public) | 0 (owner) | `{user_id, username, banned_by, banned_at, reason, expires}` |
### Collection: `"matches"` (per-user)
| Key | User | Permission | Schema |
|---|---|---|---|
| `<match_id>` | Owner | 0 (owner) | Match result data (keyed by match ID, varies) |
---
## 11. Leaderboard Config
| Property | Value |
|---|---|
| **ID** | `"global_high_score"` |
| **Authoritative** | `true` (can only be written server-side) |
| **Sort Order** | `"desc"` (highest → lowest) |
| **Operator** | `"best"` (keep best score per user) |
| **Reset** | `nil` (never auto-resets) |
| **Created By** | Both `core.lua` and `leaderboard.lua` on module load |
---
## 12. Error Strings Reference
### Auth errors (raised by utils)
| Error String | Trigger |
|---|---|
| `"Admin privileges required"` | Non-admin calls admin RPC |
| `"Admin or host privileges required"` | Non-admin, non-host calls admin/host RPC |
| `"Not authenticated"` | `context.user_id` is nil |
### Economy errors
| Error String | Trigger |
|---|---|
| `"Package ID required"` | `buy_currency` called without `package_id` |
| `"Idempotency key required"` | Missing `idempotency_key` |
| `"Invalid package ID"` | Unknown `package_id` |
| `"InvalidReceipt"` | IAP receipt validation failed |
| `"NotEnoughFunds"` | Insufficient wallet balance for purchase |
| `"ItemNotFound"` | Item ID not in shop catalog |
| `"Invalid quantity"` | Quantity < 1 |
| `"PurchaseFailed"` | Storage write failed during item purchase |
| `"Item not found in catalog: <id>"` | Admin tried to banner a non-catalog item |
### User errors
| Error String | Trigger |
|---|---|
| `"Account not found"` | User ID doesn't exist |
| `"Account banned until <time>. Reason: <reason>"` | Temporary ban |
| `"Account permanently banned. Reason: <reason>"` | Permanent ban |
| `"Failed to update profile"` | `nk.account_update_id` failed |
| `"Current password required"` | Email change without current password |
| `"Incorrect current password."` | Email re-auth failed |
| `"Failed to set new credentials: <err>"` | Link email failed |
| `"Missing to_user_id or match_id"` | Lobby invite missing fields |
| `"Cannot add yourself"` | Friend request to self |
| `"user_id is required"` | Missing target in friend request |
### Gacha errors
| Error String | Trigger |
|---|---|
| `"Banner ID required"` | Missing `banner_id` |
| `"Invalid count"` | Count < 1 |
| `"Unknown banner: <id>"` | Invalid banner |
| `"Could not read account"` | Account lookup failed |
| `"Insufficient currency"` | Not enough star/gold |
| `"Failed to update wallet"` | Wallet update failed |
| `"Failed to write storage: <err>"` | Storage write failed |
### Leaderboard errors
| Error String | Trigger |
|---|---|
| `"Failed to submit score"` | Leaderboard write failed |
| `"Sync failed: <err>"` | Storage list failed |
| `"User ID and stats are required"` | Missing params in admin_update_stats |
### Inbox errors
| Error String | Trigger |
|---|---|
| `"mail_id required"` | Missing mail ID |
| `"Reward already claimed"` | Mail already in claimed_ids |
| `"Mail not found"` | Mail ID not in any mailbox |
| `"Mail not found in global"` | Update/delete: mail not in global |
| `"Mail not found in personal inbox"` | Update/delete: mail not in personal |
| `"target_user_id required for personal mail"` | Missing owner for personal mail operations |
### Daily reward errors
| Error String | Trigger |
|---|---|
| `"Already claimed today"` | `last_claim_date == today` |
| `"Already claimed all rewards for this month"` | Day index >= reward array length |
| `"Already claimed today's reward"` | Day index already in `claimed_days` |
### Admin errors
| Error String | Trigger |
|---|---|
| `"Cannot kick yourself"` | Self-kick attempt |
| `"Failed to kick player"` | Match signal failed |
| `"Cannot ban yourself"` | Self-ban attempt |
| `"Target account not found"` | Account lookup failed |
| `"Cannot ban an admin"` | Target is admin/moderator/owner |
| `"Only owners can modify user roles"` | Non-owner tries role change |
| `"Invalid role"` | Role not in `{player, moderator, admin}` |
| `"channel_id is required"` | Missing channel ID for chat operations |
| `"channel_id and message_id are required"` | Missing chat message params |
| `"Failed to delete message: <err>"` | Channel message remove failed |
| `"Failed to list messages: <err>"` | Channel list failed |
| `"max_age_days must be > 0"` | Invalid purge param |
| `"Match not found"` | Match lookup failed |
| `"User has no email credential"` | Cannot set password for non-email user |
| `"Cannot delete your own account"` | Self-deletion attempt |
| `"Cannot delete admin account"` | Deleting admin/moderator/owner |
| `"No user IDs provided"` | Empty `user_ids` array |
---
## 13. Permission Levels
### Role Hierarchy
| Role | Can Call Admin RPCs | Can Modify Roles | Notes |
|---|---|---|---|
| `"player"` | No | No | Default role |
| `"moderator"` | Yes | No | Same as admin for auth checks |
| `"admin"` | Yes | No | Standard admin |
| `"owner"` | Yes | Yes (only owner) | Can set `player`, `moderator`, `admin` roles |
### Auth Check Functions Summary
| Check | What It Allows |
|---|---|
| `utils.require_admin` | Any RPC gated by this. Roles: admin, moderator, owner |
| `utils.require_admin_or_host` | Admin/moderator/owner, OR match host (state.hostUserId) |
| `metadata.role == "owner"` | Only `admin_set_user_role` |
### Storage Permission Bits
| Value | Meaning |
|---|---|
| `0` | Owner only (no one can read/write except server) |
| `1` | Owner read, server write |
| `2` | Public read, server write |
### System User ID
```
00000000-0000-0000-0000-000000000000
```
Used for global/shared storage collections (bans, config, global_mail, shop_config).
---
## RPC Summary Table
| RPC Name | Module | Auth | Admin? |
|---|---|---|---|
| `get_user_profile` | user | None | No |
| `update_user_profile` | user | Required | No |
| `search_users` | user | Required | No |
| `change_credentials` | user | Required | No |
| `send_lobby_invite` | user | Required | No |
| `send_friend_request` | user | Required | No |
| `admin_get_user_history` | user | Required | Yes |
| `get_shop_catalog` | economy | Required | No |
| `buy_currency` | economy | Required | No |
| `purchase_item` | economy | Required | No |
| `admin_set_featured_banners` | economy | Required | Yes |
| `admin_get_featured_banners` | economy | Required | Yes |
| `perform_gacha_pull` | gacha | Required | No |
| `get_leaderboard_stats` | leaderboard | None | No |
| `submit_score` | leaderboard | Required | No |
| `sync_leaderboard` | leaderboard | Required | No |
| `reset_stats` | leaderboard | Required | No |
| `admin_update_stats` | leaderboard | Required | Yes |
| `admin_delete_stats` | leaderboard | Required | Yes |
| `admin_sync_leaderboard` | leaderboard | Required | Yes |
| `admin_send_mail` | inbox | Required | Yes |
| `get_mail` | inbox | Required | No |
| `claim_mail_reward` | inbox | Required | No |
| `delete_mail` | inbox | Required | No |
| `save_mail_state` | inbox | Required | No |
| `admin_list_mail` | inbox | Required | Yes |
| `admin_update_mail` | inbox | Required | Yes |
| `admin_delete_mail_server` | inbox | Required | Yes |
| `claim_daily_reward` | daily_rewards | Required | No |
| `get_daily_reward_state` | daily_rewards | Required | No |
| `set_daily_reward_config` | daily_rewards | Required | Yes |
| `get_daily_reward_config_admin` | daily_rewards | Required | Yes |
| `admin_kick_player` | admin | Required | Admin or host |
| `admin_ban_player` | admin | Required | Yes |
| `admin_unban_player` | admin | Required | Yes |
| `admin_get_ban_list` | admin | Required | Yes |
| `admin_get_server_stats` | admin | Required | Admin or host |
| `admin_end_match` | admin | Required | Admin or host |
| `admin_set_user_role` | admin | Required | Owner only |
| `admin_topup_gold` | admin | Required | Yes |
| `admin_clear_global_chat` | admin | Required | Yes |
| `admin_list_users` | admin | Required | Yes |
| `admin_delete_users` | admin | Required | Yes |
| `admin_get_user_detail` | admin | Required | Yes |
| `admin_update_user_identity` | admin | Required | Yes |
| `admin_set_user_password` | admin | Required | Yes |
| `admin_get_player_list` | admin | Required | Admin or host |
| `admin_get_chat_config` | admin | Required | Yes |
| `admin_set_chat_config` | admin | Required | Yes |
| `admin_purge_old_messages` | admin | Required | Yes |
| `admin_list_channel_messages` | admin | Required | Yes |
| `admin_delete_channel_message` | admin | Required | Yes |
## Wallet Update Summary
| Operation | Changeset | Context |
|---|---|---|
| IAP: gold packages (verified) | `{gold: +N}` | `buy_currency` |
| IAP: star packages | `{star: +N, gold: -N}` | `buy_currency` (costs gold) |
| Purchase item | `{gold: -N}` or `{star: -N}` | `purchase_item` |
| Gacha pull | `{<currency>: -cost}` | `perform_gacha_pull` |
| Claim mail rewards | `{star: +N}` and/or `{gold: +N}` | `claim_mail_reward` |
| Daily reward | `{star: +N}` or `{gold: +N}` | `claim_daily_reward` |
| Admin gold top-up | `{gold: 999999}` | `admin_topup_gold` |
All wallet updates use `nk.wallet_update(user_id, changeset, {}, true)` (third arg `true` = update only, no metadata).