Files
tekton/server/nakama/lua/README.md
T

1640 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).