feat: 2.3.2

This commit is contained in:
2026-05-19 17:30:29 +08:00
parent 7ca11c6534
commit 8430d1054e
39 changed files with 6581 additions and 738 deletions
+6 -16
View File
@@ -4,7 +4,7 @@ This guide explains how to deploy the admin module to your Nakama server.
## Files
- `tekton_admin.ts` - TypeScript server runtime module
- `core.js` — Admin, user management, leaderboard, daily rewards, inbox, auth, shop catalog, purchases, and economy RPCs
## Prerequisites
@@ -23,12 +23,7 @@ This guide explains how to deploy the admin module to your Nakama server.
2. **Compile TypeScript to JavaScript:**
```bash
cd server/nakama
tsc tekton_admin.ts --outDir dist --lib ES2020 --types nakama-runtime
```
3. **Copy to Nakama modules directory:**
```bash
cp dist/tekton_admin.js /path/to/nakama/data/modules/
cp core.js /path/to/nakama/data/modules/
```
4. **Restart Nakama server**
@@ -43,15 +38,11 @@ In your `nakama.yml` or `nakama-docker.yml`:
```yaml
runtime:
js_entrypoint: "tekton_admin.js"
path: /nakama/data/modules
```
Or for multiple modules:
```yaml
runtime:
js_entrypoint: "index.js"
```
`core.js` has an `InitModule` function.
Nakama auto-discovers and calls it — no `js_entrypoint` needed.
## Role System
@@ -109,8 +100,7 @@ curl -X PUT "http://localhost:7350/v2/console/account/{user_id}" \
## Troubleshooting
### RPC Not Found
- Check module is loaded: `nakama --help` or check logs
- Verify js_entrypoint in config
- Check modules are loaded: look for "Tekton core module loaded" in logs
### Permission Denied
- Check user has correct role in metadata
@@ -3,6 +3,9 @@
*
* This module provides secure admin operations via RPC calls.
* Deploy this to your Nakama server's runtime directory.
*
* NOTE: Economy RPCs (shop, currency, purchase, featured banners)
* are registered by economy.js — each file has its own InitModule.
*/
// Initialize RPC endpoints
@@ -25,12 +28,8 @@ function InitModule(ctx, logger, nk, initializer) {
initializer.registerRpc("get_user_profile", rpcGetUserProfile);
initializer.registerRpc("update_user_profile", rpcUpdateUserProfile);
initializer.registerRpc("search_users", rpcSearchUsers);
// Store RPCs
initializer.registerRpc("purchase_item", rpcPurchaseItem);
initializer.registerRpc("get_shop_catalog", rpcGetShopCatalog);
initializer.registerRpc("buy_currency", rpcBuyCurrency);
// Leaderboard RPCs
initializer.registerRpc("get_leaderboard_stats", rpcGetLeaderboardStats);
initializer.registerRpc("admin_update_stats", rpcAdminUpdateStats);
@@ -60,75 +59,32 @@ function InitModule(ctx, logger, nk, initializer) {
initializer.registerRpc("delete_mail", rpcDeleteMail);
initializer.registerRpc("save_mail_state", rpcSaveMailState);
// Steam auth hooks
initializer.registerAfterAuthenticateSteam(afterAuthenticateSteam);
// Shop and Economy RPCs
initializer.registerRpc("purchase_item", rpcPurchaseItem);
initializer.registerRpc("get_shop_catalog", rpcGetShopCatalog);
initializer.registerRpc("buy_currency", rpcBuyCurrency);
initializer.registerRpc("admin_set_featured_banners", rpcAdminSetFeaturedBanners);
initializer.registerRpc("admin_get_featured_banners", rpcAdminGetFeaturedBanners);
// Create default native leaderboard
// id: "global_high_score", authoritative: true, sort: "desc", operator: "best", reset: None
try { nk.leaderboardCreate("global_high_score", true, "desc", "best", null, {}); } catch(e) {}
try { nk.leaderboardCreate("global_high_score", true, "desc", "best", null, {}); } catch (e) { }
logger.info("Tekton admin module loaded");
logger.info("Tekton core module loaded");
}
// =============================================================================
// Authorization Helpers
// =============================================================================
var ADMIN_ROLES = ["admin", "moderator", "owner"];
// =============================================================================
// Shop Catalog Definitions
// =============================================================================
// To add a new item: append ONE entry to SHOP_CATALOG_DEFS.
// Fields:
// id (String) — must match item_id in game inventory + SkinManager
// name (String) — display name shown in shop
// category (String) — "head" | "costume" | "glove" | "accessory"
// gold (Number) — gold price (0 = not sold for gold)
// star (Number) — star price (0 = not sold for star)
// rarity (String) — "Common" | "Rare" | "Epic" | "Legendary"
// character (String) — (optional) which character the skin targets, e.g. "Oldpop"
// [BEGIN_SHOP_CATALOG_DEFS]
var SHOP_CATALOG_DEFS = [
// ── HEAD ────────────────────────────────────────────────────────────
{ id: "oldpop-blue-hat", name: "Oldpop Blue Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-green-hat", name: "Oldpop Green Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-red-hat", name: "Oldpop Red Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-yellow-hat", name: "Oldpop Yellow Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
// ── COSTUME ────────────────────────────────────────────────────────────
{ id: "oldpop-og-pant", name: "Copper OG Pant", category: "costume", gold: 0, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-grey-pant", name: "Copper Grey Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-red-pant", name: "Copper Red Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-yellow-pant", name: "Copper Yellow Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
// ── GLOVE ────────────────────────────────────────────────────────────
{ id: "oldpop-blue-gloves", name: "Oldpop Blue Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-green-gloves", name: "Oldpop Green Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-red-gloves", name: "Oldpop Red Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-yellow-gloves", name: "Oldpop Yellow Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
];
// [END_SHOP_CATALOG_DEFS]
/** Groups SHOP_CATALOG_DEFS by category for the shop RPC response. */
function buildShopCatalog() {
var catalog = {};
for (var i = 0; i < SHOP_CATALOG_DEFS.length; i++) {
var def = SHOP_CATALOG_DEFS[i];
var cat = def.category;
if (!catalog[cat]) catalog[cat] = [];
var entry = {
id: def.id,
name: def.name,
gold: def.gold || 0,
star: def.star || 0,
rarity: def.rarity || "Common"
};
if (def.character) entry.character = def.character;
catalog[cat].push(entry);
}
return catalog;
}
function isAdmin(ctx, nk) {
if (!ctx.userId) return false;
@@ -185,7 +141,7 @@ function rpcSendLobbyInvite(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var req = JSON.parse(payload || "{}");
var toUserId = req.to_user_id;
var matchId = req.match_id;
var matchId = req.match_id;
if (!toUserId || !matchId) throw new Error("Missing to_user_id or match_id");
var sender = nk.accountGetId(ctx.userId);
@@ -227,7 +183,7 @@ function afterAuthenticateSteam(ctx, logger, nk, out, request) {
metadata = typeof account.user.metadata === "string"
? JSON.parse(account.user.metadata || "{}")
: (account.user.metadata || {});
} catch (e) {}
} catch (e) { }
if (!metadata.role) {
metadata.role = "player";
@@ -383,7 +339,7 @@ function rpcAdminGetBanList(ctx, logger, nk, payload) {
""
);
var bans = result.objects ? result.objects.map(function(obj) { return obj.value; }) : [];
var bans = result.objects ? result.objects.map(function (obj) { return obj.value; }) : [];
return JSON.stringify({ bans: bans });
} catch (e) {
@@ -522,14 +478,9 @@ function rpcAdminSetUserRole(ctx, logger, nk, payload) {
}
// =============================================================================
// Store / Economy RPCs
// Admin Wallet RPCs
// =============================================================================
function rpcGetShopCatalog(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
return JSON.stringify({ catalog: buildShopCatalog() });
}
function rpcAdminTopupGold(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
try {
@@ -542,6 +493,7 @@ function rpcAdminTopupGold(ctx, logger, nk, payload) {
}
}
// =============================================================================
// Admin Clear Global Chat RPC
// =============================================================================
@@ -589,73 +541,7 @@ function rpcAdminClearGlobalChat(ctx, logger, nk, payload) {
}
function rpcBuyCurrency(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var request = JSON.parse(payload);
var packageId = request.package_id;
var changeset = { "gold": 0, "star": 0 };
if (packageId === "gold_100") changeset["gold"] = 100;
else if (packageId === "gold_500") changeset["gold"] = 550;
else if (packageId === "gold_1000") changeset["gold"] = 1150;
else if (packageId === "gold_2000") changeset["gold"] = 2400;
else if (packageId === "gold_5000") changeset["gold"] = 6250;
else if (packageId === "gold_10000") changeset["gold"] = 13000;
else if (packageId === "star_100") { changeset["star"] = 100; changeset["gold"] = -500; }
else if (packageId === "star_250") { changeset["star"] = 250; changeset["gold"] = -1100; }
else if (packageId === "star_600") { changeset["star"] = 600; changeset["gold"] = -2500; }
else throw new Error("Invalid package ID");
try {
nk.walletUpdate(ctx.userId, changeset, {}, true);
logger.info("User " + ctx.userId + " bought currency package " + packageId);
return JSON.stringify({ success: true, package_id: packageId });
} catch (e) {
logger.error("Currency purchase failed: " + e.message);
throw new Error("Currency purchase failed: " + e.message);
}
}
function rpcPurchaseItem(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var request = JSON.parse(payload);
var itemId = request.item_id;
var priceGold = request.price_gold || 0;
var priceStar = request.price_star || 0;
var category = request.category || "accessory"; // head, costume, glove, accessory
if (!itemId) throw new Error("Item ID required");
try {
var changeset = {};
if (priceGold > 0) changeset["gold"] = -priceGold;
if (priceStar > 0) changeset["star"] = -priceStar;
// Check wallet and deduct
// nk.walletUpdate throws an error if insufficient funds
nk.walletUpdate(ctx.userId, changeset, {}, true);
// Record purchase in inventory
var inventoryObj = {
collection: "inventory",
key: itemId,
userId: ctx.userId,
value: { category: category, purchased_at: new Date().toISOString() },
permissionRead: 1,
permissionWrite: 0
};
nk.storageWrite([inventoryObj]);
logger.info("User " + ctx.userId + " purchased " + itemId);
return JSON.stringify({ success: true, item: itemId });
} catch (e) {
logger.error("Purchase failed: " + e.message);
throw new Error("Purchase failed: " + e.message);
}
}
// =============================================================================
// Daily Rewards RPCs
@@ -667,7 +553,7 @@ function rpcClaimDailyReward(ctx, logger, nk, payload) {
var currentMonth = now.toISOString().substring(5, 7); // e.g. "05"
var todayStr = now.toISOString().substring(0, 10);
var todayIndex = now.getUTCDate() - 1; // 0 to 30
var stateObjs = nk.storageRead([{ collection: "daily_rewards", key: "state", userId: ctx.userId }]);
var state = { claimed_days: [], last_claim_date: "", month: "" };
if (stateObjs && stateObjs.length > 0) {
@@ -676,7 +562,7 @@ function rpcClaimDailyReward(ctx, logger, nk, payload) {
state.month = val.month || "";
if (typeof val.claimed_days === 'number') {
var arr = [];
for (var i=0; i<val.claimed_days; i++) arr.push(i);
for (var i = 0; i < val.claimed_days; i++) arr.push(i);
state.claimed_days = arr;
} else if (Array.isArray(val.claimed_days)) {
state.claimed_days = val.claimed_days;
@@ -684,46 +570,46 @@ function rpcClaimDailyReward(ctx, logger, nk, payload) {
state.claimed_days = [];
}
}
if (state.month !== currentMonth) {
state.claimed_days = [];
state.month = currentMonth;
}
if (state.last_claim_date === todayStr) {
throw new Error("Already claimed today");
}
var configObjs = nk.storageRead([{ collection: "config", key: "daily_rewards", userId: "00000000-0000-0000-0000-000000000000" }]);
var config = {};
if (configObjs && configObjs.length > 0) {
config = configObjs[0].value;
}
var monthRewards = config[currentMonth];
if (!monthRewards || monthRewards.length === 0) {
monthRewards = [];
for (var i = 0; i < 31; i++) {
monthRewards.push({ type: "star", amount: Math.min(10 + i * 5, 100) });
monthRewards.push({ type: "star", amount: Math.min(10 + i * 5, 100) });
}
}
var dayIndex = todayIndex;
var dayIndex = todayIndex;
if (dayIndex >= monthRewards.length) {
throw new Error("Already claimed all rewards for this month");
}
if (state.claimed_days.indexOf(dayIndex) !== -1) {
throw new Error("Already claimed today's reward");
}
var rewardData = monthRewards[dayIndex];
if (typeof rewardData === "number") {
rewardData = { type: "star", amount: rewardData };
}
var rewardType = rewardData.type || "star";
var rewardAmount = rewardData.amount || 0;
if (rewardType === "star" || rewardType === "gold") {
var changes = {};
changes[rewardType] = rewardAmount;
@@ -744,10 +630,10 @@ function rpcClaimDailyReward(ctx, logger, nk, payload) {
permissionWrite: 0
}]);
}
state.claimed_days.push(dayIndex);
state.last_claim_date = todayStr;
nk.storageWrite([{
collection: "daily_rewards",
key: "state",
@@ -756,7 +642,7 @@ function rpcClaimDailyReward(ctx, logger, nk, payload) {
permissionRead: 1,
permissionWrite: 0
}]);
return JSON.stringify({ success: true, reward_type: rewardType, reward_amount: rewardAmount, day: dayIndex + 1 });
}
@@ -766,7 +652,7 @@ function rpcGetDailyRewardState(ctx, logger, nk, payload) {
var currentMonth = now.toISOString().substring(5, 7); // e.g. "05"
var todayStr = now.toISOString().substring(0, 10);
var todayIndex = now.getUTCDate() - 1;
var stateObjs = nk.storageRead([{ collection: "daily_rewards", key: "state", userId: ctx.userId }]);
var state = { claimed_days: [], last_claim_date: "", month: "" };
if (stateObjs && stateObjs.length > 0) {
@@ -775,7 +661,7 @@ function rpcGetDailyRewardState(ctx, logger, nk, payload) {
state.month = val.month || "";
if (typeof val.claimed_days === 'number') {
var arr = [];
for (var i=0; i<val.claimed_days; i++) arr.push(i);
for (var i = 0; i < val.claimed_days; i++) arr.push(i);
state.claimed_days = arr;
} else if (Array.isArray(val.claimed_days)) {
state.claimed_days = val.claimed_days;
@@ -787,7 +673,7 @@ function rpcGetDailyRewardState(ctx, logger, nk, payload) {
state.claimed_days = [];
state.month = currentMonth;
}
var configObjs = nk.storageRead([{ collection: "config", key: "daily_rewards", userId: "00000000-0000-0000-0000-000000000000" }]);
var config = {};
if (configObjs && configObjs.length > 0) {
@@ -797,10 +683,10 @@ function rpcGetDailyRewardState(ctx, logger, nk, payload) {
if (!monthRewards || monthRewards.length === 0) {
monthRewards = [];
for (var i = 0; i < 31; i++) {
monthRewards.push({ type: "star", amount: Math.min(10 + i * 5, 100) });
monthRewards.push({ type: "star", amount: Math.min(10 + i * 5, 100) });
}
}
return JSON.stringify({
state: state,
month_rewards: monthRewards,
@@ -913,7 +799,7 @@ function rpcSearchUsers(ctx, logger, nk, payload) {
var request = {};
try {
request = JSON.parse(payload || "{}");
} catch (e) {}
} catch (e) { }
var query = request.query || "";
@@ -921,21 +807,21 @@ function rpcSearchUsers(ctx, logger, nk, payload) {
var users = [];
var sql = "";
var params = [];
if (query === "") {
sql = "SELECT id, username, display_name, metadata FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100";
} else {
sql = "SELECT id, username, display_name, metadata FROM users WHERE (username ILIKE $1 OR display_name ILIKE $1) AND id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100";
params = ["%" + query + "%"];
}
var rows = nk.sqlQuery(sql, params);
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var metadata = {};
try { metadata = JSON.parse(row.metadata || "{}"); } catch(e) {}
try { metadata = JSON.parse(row.metadata || "{}"); } catch (e) { }
users.push({
user_id: row.id,
username: row.username || "",
@@ -960,13 +846,13 @@ function rpcGetLeaderboardStats(ctx, logger, nk, payload) {
var limit = 50;
var records = nk.leaderboardRecordsList("global_high_score", null, limit, "");
var leaderboardData = [];
var ownerRecords = records.records || [];
for (var i = 0; i < ownerRecords.length; i++) {
var record = ownerRecords[i];
var metadata = {};
try { metadata = JSON.parse(record.metadata || "{}"); } catch (e) {}
try { metadata = JSON.parse(record.metadata || "{}"); } catch (e) { }
leaderboardData.push({
user_id: record.ownerId,
username: record.username,
@@ -978,7 +864,7 @@ function rpcGetLeaderboardStats(ctx, logger, nk, payload) {
games_won: metadata.games_won || 0
});
}
return JSON.stringify({ leaderboard: leaderboardData });
} catch (e) {
logger.error("Failed to get native leaderboard stats: " + e);
@@ -1023,7 +909,7 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
var result = nk.storageList(null, "stats", 100, "");
var statsObjects = result.objects || [];
var userGroup = {};
for (var i = 0; i < statsObjects.length; i++) {
var obj = statsObjects[i];
var userId = obj.userId;
@@ -1032,7 +918,7 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
value = (typeof obj.value === "string") ? JSON.parse(obj.value) : obj.value;
} catch (e) { continue; }
if (!value) continue;
if (!userGroup[userId]) {
userGroup[userId] = {
high_score: value.high_score || 0,
@@ -1046,14 +932,14 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
userGroup[userId].games_played = Math.max(userGroup[userId].games_played, value.games_played || 0);
userGroup[userId].games_won = Math.max(userGroup[userId].games_won, value.games_won || 0);
}
// Prioritize avatar and character from game_stats or if current is empty
if (obj.key === "game_stats" || !userGroup[userId].avatar_url) {
if (value.avatar_url) userGroup[userId].avatar_url = value.avatar_url;
if (value.loadout_character) userGroup[userId].loadout_character = value.loadout_character;
}
}
// Phase 2: Read profiles collection to get loadout and avatars!
var profileResult = nk.storageList(null, "profiles", 100, "");
var profileObjects = profileResult.objects || [];
@@ -1065,7 +951,7 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
try {
value = (typeof obj.value === "string") ? JSON.parse(obj.value) : obj.value;
} catch (e) { continue; }
if (!userGroup[userId]) {
userGroup[userId] = {
high_score: 0, games_played: 0, games_won: 0,
@@ -1076,16 +962,16 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
if (value.avatar_url && !userGroup[userId].avatar_url) userGroup[userId].avatar_url = value.avatar_url;
if (value.loadout_character && !userGroup[userId].loadout_character) userGroup[userId].loadout_character = value.loadout_character;
}
var count = 0;
var debugLogs = [];
for (var uid in userGroup) {
try {
var stats = userGroup[uid];
var account = nk.accountGetId(uid);
var meta = {
games_played: stats.games_played || 0,
games_won: stats.games_won || 0,
var meta = {
games_played: stats.games_played || 0,
games_won: stats.games_won || 0,
avatar_url: stats.avatar_url || account.user.avatarUrl || "res://assets/graphics/character_selection/sc_characters/sc_copper.png",
loadout_character: stats.loadout_character || "Copper"
};
@@ -1108,10 +994,10 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
function rpcChangeCredentials(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var req = {};
try { req = JSON.parse(payload || "{}"); } catch (e) {}
try { req = JSON.parse(payload || "{}"); } catch (e) { }
var account = nk.accountGetId(ctx.userId);
// If not a guest (has email), verify current password and unlink
if (account.email) {
if (!req.current_password) throw new Error("Current password required");
@@ -1122,7 +1008,7 @@ function rpcChangeCredentials(ctx, logger, nk, payload) {
}
nk.unlinkEmail(ctx.userId, account.email, req.current_password);
}
try {
nk.linkEmail(ctx.userId, req.new_email, req.new_password);
} catch (e) {
@@ -1130,7 +1016,7 @@ function rpcChangeCredentials(ctx, logger, nk, payload) {
if (account.email) nk.linkEmail(ctx.userId, account.email, req.current_password);
throw new Error("Failed to set new credentials: " + e.message);
}
return JSON.stringify({ success: true });
}
@@ -1138,10 +1024,10 @@ function rpcChangeCredentials(ctx, logger, nk, payload) {
function rpcResetStats(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var account = nk.accountGetId(ctx.userId);
// Delete native leaderboard rank
try { nk.leaderboardRecordDelete("global_high_score", ctx.userId); } catch (e) {}
try { nk.leaderboardRecordDelete("global_high_score", ctx.userId); } catch (e) { }
// Wipe storage stats
var zeros = { games_played: 0, games_won: 0, high_score: 0, total_kills: 0, total_deaths: 0 };
nk.storageWrite([{
@@ -1152,7 +1038,7 @@ function rpcResetStats(ctx, logger, nk, payload) {
permissionRead: 2,
permissionWrite: 1
}]);
return JSON.stringify({ success: true });
}
@@ -1166,11 +1052,11 @@ function rpcAdminListUsers(ctx, logger, nk, payload) {
try {
var users = [];
var rows = nk.sqlQuery("SELECT id, username, display_name, metadata, create_time FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 500", []);
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var metadata = {};
try { metadata = JSON.parse(row.metadata || "{}"); } catch(e) {}
try { metadata = JSON.parse(row.metadata || "{}"); } catch (e) { }
users.push({
user_id: row.id,
username: row.username || "",
@@ -1215,7 +1101,7 @@ function rpcAdminDeleteUsers(ctx, logger, nk, payload) {
// Check if target is admin — don't allow deleting admins
var account = nk.accountGetId(uid);
var meta = {};
try { meta = JSON.parse(account.user.metadata || "{}"); } catch(e) {}
try { meta = JSON.parse(account.user.metadata || "{}"); } catch (e) { }
if (ADMIN_ROLES.indexOf(meta.role || "") !== -1) {
failed.push({ user_id: uid, reason: "Cannot delete admin account" });
continue;
@@ -1304,26 +1190,26 @@ function rpcAdminDeleteStats(ctx, logger, nk, payload) {
function rpcAdminSyncLeaderboard(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
try {
var result = nk.storageList(null, "stats", 100, "");
var statsObjects = result.objects || [];
var userGroup = {}; // [userId] = { highScore, gamesPlayed, gamesWon, avatar }
// Phase 1: Group and merge
for (var i = 0; i < statsObjects.length; i++) {
var obj = statsObjects[i];
var userId = obj.userId;
var key = obj.key; // "stats" or "game_stats"
var value;
try {
value = JSON.parse(obj.value || "{}");
} catch (jsonErr) {
logger.error("Skipping key " + key + " for user " + userId + " due to corrupt JSON");
continue;
}
if (!userGroup[userId]) {
userGroup[userId] = {
high_score: value.high_score || 0,
@@ -1338,37 +1224,37 @@ function rpcAdminSyncLeaderboard(ctx, logger, nk, payload) {
userGroup[userId].games_played += (value.games_played || 0);
userGroup[userId].games_won += (value.games_won || 0);
}
// Prioritize avatar and character from game_stats
if (key === "game_stats" || !userGroup[userId].avatar_url) {
try {
if (value.avatar_url) userGroup[userId].avatar_url = value.avatar_url;
if (value.loadout_character) userGroup[userId].loadout_character = value.loadout_character;
} catch (e) {}
} catch (e) { }
}
}
// Phase 2: Write to native leaderboard
var count = 0;
for (var userId in userGroup) {
try {
var stats = userGroup[userId];
var account = nk.accountGetId(userId);
var metadata = {
games_played: stats.games_played,
games_won: stats.games_won,
avatar_url: stats.avatar_url || account.user.avatarUrl || "",
loadout_character: stats.loadout_character || "Copper"
};
nk.leaderboardRecordWrite("global_high_score", userId, account.user.username, stats.high_score, 0, metadata);
count++;
} catch (inner) {
logger.error("Failed to sync merged record for " + userId + ": " + inner);
}
}
return JSON.stringify({ success: true, synced: count });
} catch (e) {
logger.error("Leaderboard sync failed: " + e);
@@ -1393,7 +1279,7 @@ function rpcSendFriendRequest(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var request = {};
try { request = JSON.parse(payload || "{}"); } catch (e) {}
try { request = JSON.parse(payload || "{}"); } catch (e) { }
var targetUserId = request.user_id || "";
if (!targetUserId) throw new Error("user_id is required");
@@ -1420,12 +1306,12 @@ function rpcSendLobbyInvite(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var request = {};
try { request = JSON.parse(payload || "{}"); } catch (e) {}
try { request = JSON.parse(payload || "{}"); } catch (e) { }
var toUserId = request.to_user_id || "";
var matchId = request.match_id || "";
var matchId = request.match_id || "";
if (!toUserId) throw new Error("to_user_id is required");
if (!matchId) throw new Error("match_id is required");
if (!matchId) throw new Error("match_id is required");
var senderAccount = nk.accountGetId(ctx.userId);
var senderName = senderAccount.user.displayName || senderAccount.user.username || "Someone";
@@ -1450,16 +1336,16 @@ function rpcSendLobbyInvite(ctx, logger, nk, payload) {
function rpcAdminSendMail(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
var request = JSON.parse(payload || "{}");
var nowStr = new Date().toISOString();
var startDate = request.start_date || nowStr;
var endDate = request.end_date || "";
// Auto-delete / expire after 30 days from start
var startObj = new Date(startDate);
startObj.setDate(startObj.getDate() + 30);
var expiryDate = startObj.toISOString();
var mailObj = {
id: nk.uuidv4(),
title: request.title || "Announcement",
@@ -1471,7 +1357,7 @@ function rpcAdminSendMail(ctx, logger, nk, payload) {
expiry_date: expiryDate,
rewards: request.rewards || []
};
if (request.target_user_id) {
mailObj.type = "personal";
var invObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: request.target_user_id }]);
@@ -1507,20 +1393,20 @@ function rpcAdminSendMail(ctx, logger, nk, payload) {
}]);
logger.info("Global mail sent");
}
return JSON.stringify({ success: true, mail: mailObj });
}
function rpcGetMail(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var personalObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: ctx.userId }]);
var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
var stateObjs = nk.storageRead([{ collection: "inbox", key: "state", userId: ctx.userId }]);
var personalMails = (personalObjs && personalObjs.length > 0) ? (personalObjs[0].value.mails || []) : [];
var globalMails = (globalObjs && globalObjs.length > 0) ? (globalObjs[0].value.mails || []) : [];
var state = { claimed_ids: [], deleted_ids: [], read_ids: [] };
if (stateObjs && stateObjs.length > 0) {
var val = stateObjs[0].value;
@@ -1528,33 +1414,33 @@ function rpcGetMail(ctx, logger, nk, payload) {
state.deleted_ids = val.deleted_ids || [];
state.read_ids = val.read_ids || [];
}
var allMails = personalMails.concat(globalMails);
var filteredMails = [];
var nowStr = new Date().toISOString();
for (var i = 0; i < allMails.length; i++) {
var mail = allMails[i];
if (state.deleted_ids.indexOf(mail.id) !== -1) continue;
// Expiry check
if (mail.expiry_date && nowStr > mail.expiry_date) {
continue;
}
// Scheduled start
if (mail.start_date && nowStr < mail.start_date) {
continue;
}
// Scheduled end
if (mail.type === "global" && mail.end_date && nowStr > mail.end_date) {
continue;
}
filteredMails.push(mail);
}
return JSON.stringify({ mails: filteredMails, state: state });
}
@@ -1563,12 +1449,12 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
var request = JSON.parse(payload || "{}");
var mailId = request.mail_id;
if (!mailId) throw new Error("mail_id required");
// fetch all mails to find it
var personalObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: ctx.userId }]);
var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
var stateObjs = nk.storageRead([{ collection: "inbox", key: "state", userId: ctx.userId }]);
var state = { claimed_ids: [], deleted_ids: [], read_ids: [] };
if (stateObjs && stateObjs.length > 0) {
var val = stateObjs[0].value;
@@ -1576,15 +1462,15 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
state.deleted_ids = val.deleted_ids || [];
state.read_ids = val.read_ids || [];
}
if (state.claimed_ids.indexOf(mailId) !== -1) {
throw new Error("Reward already claimed");
}
var personalMails = (personalObjs && personalObjs.length > 0) ? (personalObjs[0].value.mails || []) : [];
var globalMails = (globalObjs && globalObjs.length > 0) ? (globalObjs[0].value.mails || []) : [];
var allMails = personalMails.concat(globalMails);
var targetMail = null;
for (var i = 0; i < allMails.length; i++) {
if (allMails[i].id === mailId) {
@@ -1592,28 +1478,28 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
break;
}
}
if (!targetMail) throw new Error("Mail not found");
var rewards = targetMail.rewards || [];
var starTotal = 0;
var goldTotal = 0;
// Support legacy dictionary if it exists
if (!Array.isArray(rewards)) {
starTotal = rewards.star || 0;
goldTotal = rewards.gold || 0;
rewards = []; // prevent array loop
}
var fragsToUpdate = {};
var skinsToAdd = [];
for (var j = 0; j < rewards.length; j++) {
var r = rewards[j];
var type = r.type || "star";
var amount = r.amount || 0;
if (type === "star") starTotal += amount;
else if (type === "gold") goldTotal += amount;
else if (type.startsWith("frag_") || type === "item") {
@@ -1624,14 +1510,14 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
if (r.id) skinsToAdd.push(r.id);
}
}
if (starTotal > 0 || goldTotal > 0) {
var changes = {};
if (starTotal > 0) changes["star"] = starTotal;
if (goldTotal > 0) changes["gold"] = goldTotal;
nk.walletUpdate(ctx.userId, changes, {}, true);
}
if (Object.keys(fragsToUpdate).length > 0) {
var invObjs = nk.storageRead([{ collection: "inventory", key: "fragments", userId: ctx.userId }]);
var frags = {};
@@ -1650,7 +1536,7 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
permissionWrite: 0
}]);
}
if (skinsToAdd.length > 0) {
var skinWrites = [];
for (var s = 0; s < skinsToAdd.length; s++) {
@@ -1665,12 +1551,12 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
}
nk.storageWrite(skinWrites);
}
state.claimed_ids.push(mailId);
if (state.read_ids.indexOf(mailId) === -1) {
state.read_ids.push(mailId);
}
nk.storageWrite([{
collection: "inbox",
key: "state",
@@ -1679,7 +1565,7 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
permissionRead: 1,
permissionWrite: 0
}]);
return JSON.stringify({ success: true, claimed_ids: state.claimed_ids });
}
@@ -1688,7 +1574,7 @@ function rpcDeleteMail(ctx, logger, nk, payload) {
var request = JSON.parse(payload || "{}");
var mailId = request.mail_id;
if (!mailId) throw new Error("mail_id required");
var stateObjs = nk.storageRead([{ collection: "inbox", key: "state", userId: ctx.userId }]);
var state = { claimed_ids: [], deleted_ids: [], read_ids: [] };
if (stateObjs && stateObjs.length > 0) {
@@ -1697,14 +1583,14 @@ function rpcDeleteMail(ctx, logger, nk, payload) {
state.deleted_ids = val.deleted_ids || [];
state.read_ids = val.read_ids || [];
}
if (state.deleted_ids.indexOf(mailId) === -1) {
state.deleted_ids.push(mailId);
}
if (state.read_ids.indexOf(mailId) === -1) {
state.read_ids.push(mailId);
}
nk.storageWrite([{
collection: "inbox",
key: "state",
@@ -1713,7 +1599,7 @@ function rpcDeleteMail(ctx, logger, nk, payload) {
permissionRead: 1,
permissionWrite: 0
}]);
return JSON.stringify({ success: true, deleted_ids: state.deleted_ids });
}
@@ -1794,7 +1680,7 @@ function rpcAdminListMail(ctx, logger, nk, payload) {
var allMails = globalMails.concat(personalMails);
// Sort newest first
allMails.sort(function(a, b) {
allMails.sort(function (a, b) {
return (b.date || "").localeCompare(a.date || "");
});
@@ -1912,7 +1798,7 @@ function rpcAdminDeleteMailServer(ctx, logger, nk, payload) {
var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
var globalMails = (globalObjs && globalObjs.length > 0) ? (globalObjs[0].value.mails || []) : [];
var before = globalMails.length;
globalMails = globalMails.filter(function(m) { return m.id !== mailId; });
globalMails = globalMails.filter(function (m) { return m.id !== mailId; });
if (globalMails.length === before) throw new Error("Mail not found");
nk.storageWrite([{
collection: "config",
@@ -1927,7 +1813,7 @@ function rpcAdminDeleteMailServer(ctx, logger, nk, payload) {
var pObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: targetUserId }]);
var personalMails = (pObjs && pObjs.length > 0) ? (pObjs[0].value.mails || []) : [];
var pBefore = personalMails.length;
personalMails = personalMails.filter(function(m) { return m.id !== mailId; });
personalMails = personalMails.filter(function (m) { return m.id !== mailId; });
if (personalMails.length === pBefore) throw new Error("Mail not found");
nk.storageWrite([{
collection: "inbox",
@@ -1942,3 +1828,273 @@ function rpcAdminDeleteMailServer(ctx, logger, nk, payload) {
logger.info("Admin deleted mail " + mailId + " from server by " + ctx.userId);
return JSON.stringify({ success: true });
}
// =============================================================================
// Shop Catalog Definitions
// =============================================================================
// [BEGIN_SHOP_CATALOG_DEFS]
var SHOP_CATALOG_DEFS = [
// ── HEAD ────────────────────────────────────────────────────────────
{ id: "oldpop-blue-hat", name: "Oldpop Blue Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-green-hat", name: "Oldpop Green Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-red-hat", name: "Oldpop Red Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-yellow-hat", name: "Oldpop Yellow Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
// ── COSTUME ────────────────────────────────────────────────────────────
{ id: "oldpop-og-pant", name: "Copper OG Pant", category: "costume", gold: 0, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-grey-pant", name: "Copper Grey Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-red-pant", name: "Copper Red Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-yellow-pant", name: "Copper Yellow Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
// ── GLOVE ────────────────────────────────────────────────────────────
{ id: "oldpop-blue-gloves", name: "Oldpop Blue Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-green-gloves", name: "Oldpop Green Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-red-gloves", name: "Oldpop Red Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
{ id: "oldpop-yellow-gloves", name: "Oldpop Yellow Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
];
// [END_SHOP_CATALOG_DEFS]
// =============================================================================
// Shop RPCs
// =============================================================================
function buildShopCatalog() {
var catalog = {};
for (var i = 0; i < SHOP_CATALOG_DEFS.length; i++) {
var def = SHOP_CATALOG_DEFS[i];
var cat = def.category;
if (!catalog[cat]) catalog[cat] = [];
var entry = {
id: def.id,
name: def.name,
gold: def.gold || 0,
star: def.star || 0,
rarity: def.rarity || "Common"
};
if (def.character) entry.character = def.character;
catalog[cat].push(entry);
}
return catalog;
}
function rpcGetShopCatalog(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var result = { catalog: buildShopCatalog(), featured_banners: [] };
try {
var objs = nk.storageRead([{ collection: "shop_config", key: "featured_banners", userId: "00000000-0000-0000-0000-000000000000" }]);
if (objs && objs.length > 0) {
var data = JSON.parse(objs[0].value);
if (data && data.banners) result.featured_banners = data.banners;
}
} catch (e) {
logger.warn("No featured banners configured: " + e);
}
return JSON.stringify(result);
}
// =============================================================================
// Currency Purchase RPC
// =============================================================================
function rpcBuyCurrency(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var request = JSON.parse(payload);
var packageId = request.package_id;
var receipt = request.receipt;
var idempotencyKey = request.idempotency_key;
if (!packageId) throw new Error("Package ID required");
if (!idempotencyKey) throw new Error("Idempotency key required");
try {
var existing = nk.storageRead([{ collection: "receipts", key: idempotencyKey, userId: ctx.userId }]);
if (existing && existing.length > 0) {
return JSON.stringify({ success: true, package_id: packageId, duplicate: true, status: existing[0].value.status });
}
} catch (e) { }
var changeset = { "gold": 0, "star": 0 };
var requiresVerification = false;
if (packageId === "gold_100") { changeset["gold"] = 100; requiresVerification = true; }
else if (packageId === "gold_500") { changeset["gold"] = 550; requiresVerification = true; }
else if (packageId === "gold_1000") { changeset["gold"] = 1150; requiresVerification = true; }
else if (packageId === "gold_2000") { changeset["gold"] = 2400; requiresVerification = true; }
else if (packageId === "gold_5000") { changeset["gold"] = 6250; requiresVerification = true; }
else if (packageId === "gold_10000") { changeset["gold"] = 13000; requiresVerification = true; }
else if (packageId === "star_100") { changeset["star"] = 100; changeset["gold"] = -500; }
else if (packageId === "star_250") { changeset["star"] = 250; changeset["gold"] = -1100; }
else if (packageId === "star_600") { changeset["star"] = 600; changeset["gold"] = -2500; }
else throw new Error("Invalid package ID");
if (requiresVerification && !receipt) {
var pendingObj = {
collection: "receipts",
key: idempotencyKey,
userId: ctx.userId,
value: { type: "currency", package_id: packageId, status: "pending", created_at: new Date().toISOString() },
permissionRead: 1,
permissionWrite: 0
};
nk.storageWrite([pendingObj]);
return JSON.stringify({ success: true, status: "pending", package_id: packageId });
}
try {
if (changeset["gold"] !== 0 || changeset["star"] !== 0) {
nk.walletUpdate(ctx.userId, changeset, {}, true);
}
var receiptObj = {
collection: "receipts",
key: idempotencyKey,
userId: ctx.userId,
value: { type: "currency", package_id: packageId, changeset: changeset, receipt: receipt || null, status: "verified", processed_at: new Date().toISOString() },
permissionRead: 1,
permissionWrite: 0
};
nk.storageWrite([receiptObj]);
logger.info("User " + ctx.userId + " bought currency package " + packageId);
return JSON.stringify({ success: true, status: "verified", package_id: packageId });
} catch (e) {
logger.error("Currency purchase failed: " + e.message);
throw new Error("NotEnoughFunds");
}
}
// =============================================================================
// Item Purchase RPC
// =============================================================================
function rpcPurchaseItem(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var request = JSON.parse(payload);
var itemId = request.item_id;
var quantity = request.quantity || 1;
var idempotencyKey = request.idempotency_key;
if (!itemId) throw new Error("Item ID required");
if (quantity < 1) throw new Error("Invalid quantity");
if (!idempotencyKey) throw new Error("Idempotency key required");
try {
var existing = nk.storageRead([{ collection: "receipts", key: idempotencyKey, userId: ctx.userId }]);
if (existing && existing.length > 0) {
return JSON.stringify({ success: true, item: itemId, duplicate: true });
}
} catch (e) { }
var itemDef = null;
for (var i = 0; i < SHOP_CATALOG_DEFS.length; i++) {
if (SHOP_CATALOG_DEFS[i].id === itemId) {
itemDef = SHOP_CATALOG_DEFS[i];
break;
}
}
if (!itemDef) throw new Error("ItemNotFound");
var priceGold = (itemDef.gold || 0) * quantity;
var priceStar = (itemDef.star || 0) * quantity;
var category = itemDef.category || "accessory";
try {
var changeset = {};
if (priceGold > 0) changeset["gold"] = -priceGold;
if (priceStar > 0) changeset["star"] = -priceStar;
if (priceGold > 0 || priceStar > 0) {
nk.walletUpdate(ctx.userId, changeset, {}, true);
}
} catch (e) {
logger.error("Wallet update failed: " + e.message);
throw new Error("NotEnoughFunds");
}
try {
var writes = [];
writes.push({
collection: "inventory",
key: itemId,
userId: ctx.userId,
value: { category: category, purchased_at: new Date().toISOString(), quantity: quantity },
permissionRead: 1,
permissionWrite: 0
});
writes.push({
collection: "receipts",
key: idempotencyKey,
userId: ctx.userId,
value: { type: "item", item_id: itemId, quantity: quantity, cost: { gold: priceGold, star: priceStar }, processed_at: new Date().toISOString() },
permissionRead: 1,
permissionWrite: 0
});
nk.storageWrite(writes);
logger.info("User " + ctx.userId + " purchased " + itemId);
return JSON.stringify({ success: true, item: itemId });
} catch (e) {
logger.error("Purchase failed: " + e.message);
throw new Error("PurchaseFailed");
}
}
// =============================================================================
// Featured Banners (Shop) RPCs
// =============================================================================
/**
* Admin sets featured banner slots.
* Payload: { banners: [ { item_id, label }, ... ] } (max 3 slots)
* Stored in system-owned storage: shop_config / featured_banners
*/
function rpcAdminSetFeaturedBanners(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
var req = JSON.parse(payload || "{}");
var banners = req.banners || [];
if (banners.length > 3) banners = banners.slice(0, 3);
// Validate each banner references a real catalog item
for (var i = 0; i < banners.length; i++) {
var itemId = banners[i].item_id || "";
if (itemId === "") continue; // empty slot
var found = false;
for (var j = 0; j < SHOP_CATALOG_DEFS.length; j++) {
if (SHOP_CATALOG_DEFS[j].id === itemId) { found = true; break; }
}
if (!found) throw new Error("Item not found in catalog: " + itemId);
}
nk.storageWrite([{
collection: "shop_config",
key: "featured_banners",
userId: "00000000-0000-0000-0000-000000000000",
value: JSON.stringify({ banners: banners }),
permissionRead: 2,
permissionWrite: 0
}]);
logger.info("Featured banners updated by admin " + ctx.userId);
return JSON.stringify({ success: true, banners: banners });
}
/**
* Admin reads current featured banner config.
*/
function rpcAdminGetFeaturedBanners(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
try {
var objs = nk.storageRead([{ collection: "shop_config", key: "featured_banners", userId: "00000000-0000-0000-0000-000000000000" }]);
if (objs && objs.length > 0) {
var data = JSON.parse(objs[0].value);
return JSON.stringify({ banners: data.banners || [] });
}
} catch (e) {
logger.warn("Error reading featured banners: " + e);
}
return JSON.stringify({ banners: [] });
}
+355
View File
@@ -0,0 +1,355 @@
local nk = require("nakama")
local utils = require("lua.utils")
local admin = {}
function admin.rpc_admin_kick_player(context, payload)
local request = nk.json_decode(payload)
utils.require_admin_or_host(context, request.match_id)
if request.user_id == context.user_id then
error("Cannot kick yourself")
end
local status, err = pcall(nk.match_signal, request.match_id, nk.json_encode({
action = "kick",
user_id = request.user_id,
reason = request.reason or "Kicked by admin"
}))
if not status then
nk.logger_error("Failed to kick player: " .. tostring(err))
error("Failed to kick player")
end
nk.logger_info("Player " .. request.user_id .. " kicked from match " .. request.match_id .. " by " .. context.user_id)
return nk.json_encode({ success = true })
end
function admin.rpc_admin_ban_player(context, payload)
local request = nk.json_decode(payload)
utils.require_admin(context)
if request.user_id == context.user_id then
error("Cannot ban yourself")
end
local status, targetAccount = pcall(nk.account_get_id, request.user_id)
if not status then error("Target account not found") end
local metadata = {}
if targetAccount.user.metadata then
status, metadata = pcall(nk.json_decode, targetAccount.user.metadata)
if not status then metadata = {} end
end
if utils.ADMIN_ROLES[metadata.role or ""] then
error("Cannot ban an admin")
end
local banExpires = nil
if request.duration_hours and request.duration_hours > 0 then
-- Unix time in seconds for lua, Nakama might expect ISO string or unix
banExpires = os.time() + (request.duration_hours * 60 * 60)
end
metadata.banned = true
metadata.ban_reason = request.reason or "Banned by admin"
if banExpires then metadata.ban_expires = banExpires end
nk.account_update_id(request.user_id, nil, nil, nil, nil, nil, nil, nk.json_encode(metadata))
if request.match_id then
pcall(nk.match_signal, request.match_id, nk.json_encode({
action = "kick",
user_id = request.user_id,
reason = "Banned: " .. (request.reason or "")
}))
end
local banRecord = {
user_id = request.user_id,
username = targetAccount.user.username,
banned_by = context.user_id,
banned_at = os.time(),
reason = request.reason,
expires = banExpires
}
nk.storage_write({{
collection = "bans",
key = request.user_id,
user_id = "00000000-0000-0000-0000-000000000000",
value = banRecord,
permission_read = 2,
permission_write = 0
}})
nk.logger_warn("Player " .. request.user_id .. " banned by " .. context.user_id)
return nk.json_encode({ success = true, ban = banRecord })
end
function admin.rpc_admin_unban_player(context, payload)
local request = nk.json_decode(payload)
utils.require_admin(context)
local status, targetAccount = pcall(nk.account_get_id, request.user_id)
if not status then error("Target account not found") end
local metadata = {}
if targetAccount.user.metadata then
status, metadata = pcall(nk.json_decode, targetAccount.user.metadata)
if not status then metadata = {} end
end
metadata.banned = nil
metadata.ban_reason = nil
metadata.ban_expires = nil
nk.account_update_id(request.user_id, nil, nil, nil, nil, nil, nil, nk.json_encode(metadata))
nk.storage_delete({{
collection = "bans",
key = request.user_id,
user_id = "00000000-0000-0000-0000-000000000000"
}})
nk.logger_info("Player " .. request.user_id .. " unbanned by " .. context.user_id)
return nk.json_encode({ success = true })
end
function admin.rpc_admin_get_ban_list(context, payload)
utils.require_admin(context)
local status, result = pcall(nk.storage_list, "00000000-0000-0000-0000-000000000000", "bans", 100)
local bans = {}
if status and result and result.objects then
for _, obj in ipairs(result.objects) do
table.insert(bans, obj.value)
end
end
return nk.json_encode({ bans = bans })
end
function admin.rpc_admin_get_server_stats(context, payload)
local request = nk.json_decode(payload or "{}")
if request.match_id then
utils.require_admin_or_host(context, request.match_id)
else
utils.require_admin(context)
end
local matches = nk.match_list(100, true)
local activeMatchCount = matches and #matches or 0
local totalPlayers = 0
if matches then
for _, match in ipairs(matches) do
totalPlayers = totalPlayers + (match.size or 0)
end
end
local stats = {
active_matches = activeMatchCount,
total_players = totalPlayers,
server_time = os.time()
}
if request.match_id then
local status, match = pcall(nk.match_get, request.match_id)
if status and match then
stats.match = {
id = match.match_id,
size = match.size,
tick_rate = match.tick_rate,
authoritative = match.authoritative
}
end
end
return nk.json_encode(stats)
end
function admin.rpc_admin_end_match(context, payload)
local request = nk.json_decode(payload)
utils.require_admin_or_host(context, request.match_id)
nk.match_signal(request.match_id, nk.json_encode({
action = "end_match",
reason = request.reason or "Ended by admin"
}))
nk.logger_info("Match " .. request.match_id .. " ended by " .. context.user_id)
return nk.json_encode({ success = true })
end
function admin.rpc_admin_set_user_role(context, payload)
local request = nk.json_decode(payload)
local callerAccount = nk.account_get_id(context.user_id)
local callerMetadata = nk.json_decode(callerAccount.user.metadata or "{}")
if callerMetadata.role ~= "owner" then
error("Only owners can modify user roles")
end
local validRoles = { player = true, moderator = true, admin = true }
if not validRoles[request.role] then
error("Invalid role")
end
local targetAccount = nk.account_get_id(request.user_id)
local metadata = nk.json_decode(targetAccount.user.metadata or "{}")
metadata.role = request.role
nk.account_update_id(request.user_id, nil, nil, nil, nil, nil, nil, nk.json_encode(metadata))
nk.logger_info("User " .. request.user_id .. " role set to " .. request.role .. " by " .. context.user_id)
return nk.json_encode({ success = true, role = request.role })
end
function admin.rpc_admin_topup_gold(context, payload)
utils.require_admin(context)
nk.wallet_update(context.user_id, { gold = 999999 })
nk.logger_info("Admin gold top-up applied for user " .. context.user_id)
return nk.json_encode({ success = true, gold_added = 999999 })
end
function admin.rpc_admin_clear_global_chat(context, payload)
utils.require_admin(context)
local request = nk.json_decode(payload or "{}")
local channelId = request.channel_id or ""
if channelId == "" then
error("channel_id is required. Pass the channel ID from the client.")
end
local deleted = 0
local cursor = ""
repeat
local status, result = pcall(nk.channel_messages_list, channelId, 100, false, cursor)
if not status then break end
local messages = result.messages or {}
for _, msg in ipairs(messages) do
pcall(nk.channel_message_remove, channelId, msg.message_id)
deleted = deleted + 1
end
cursor = result.next_cursor or ""
until cursor == ""
nk.logger_info("[AdminClearGlobalChat] Deleted " .. deleted .. " messages by " .. context.user_id)
return nk.json_encode({ success = true, deleted = deleted })
end
function admin.rpc_admin_list_users(context, payload)
utils.require_admin(context)
local users = {}
local sql = "SELECT id, username, display_name, metadata, create_time FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 500"
local status, rows = pcall(nk.sql_query, sql, {})
if status and rows then
for _, row in ipairs(rows) do
local metadata = {}
if row.metadata then
local s, m = pcall(nk.json_decode, row.metadata)
if s then metadata = m end
end
table.insert(users, {
user_id = row.id,
username = row.username or "",
display_name = row.display_name or row.username or "",
create_time = row.create_time,
role = metadata.role or "player",
banned = metadata.banned or false,
ban_reason = metadata.ban_reason or ""
})
end
end
return nk.json_encode({ users = users, count = #users })
end
function admin.rpc_admin_delete_users(context, payload)
utils.require_admin(context)
local request = nk.json_decode(payload)
local userIds = request.user_ids or {}
if #userIds == 0 then error("No user IDs provided") end
for _, uid in ipairs(userIds) do
if uid == context.user_id then
error("Cannot delete your own account")
end
end
local deleted = {}
local failed = {}
for _, uid in ipairs(userIds) do
local status, err = pcall(function()
local account = nk.account_get_id(uid)
local meta = {}
if account.user.metadata then
local s, m = pcall(nk.json_decode, account.user.metadata)
if s then meta = m end
end
if meta.role == "admin" or meta.role == "moderator" or meta.role == "owner" then
error("Cannot delete admin account")
end
nk.account_delete_id(uid, false)
table.insert(deleted, uid)
nk.logger_warn("User " .. uid .. " deleted by " .. context.user_id)
end)
if not status then
table.insert(failed, { user_id = uid, reason = tostring(err) })
end
end
return nk.json_encode({ success = true, deleted = deleted, failed = failed })
end
function admin.rpc_admin_get_player_list(context, payload)
local request = nk.json_decode(payload)
utils.require_admin_or_host(context, request.match_id)
local status, match = pcall(nk.match_get, request.match_id)
if not status or not match then
error("Match not found")
end
-- Get player details
-- Note: In actual implementation, you'd need to track presences
-- This is a simplified version - adjust based on your match handler
local players = {}
return nk.json_encode({ players = players })
end
-- Register RPCs
nk.register_rpc(admin.rpc_admin_kick_player, "admin_kick_player")
nk.register_rpc(admin.rpc_admin_ban_player, "admin_ban_player")
nk.register_rpc(admin.rpc_admin_unban_player, "admin_unban_player")
nk.register_rpc(admin.rpc_admin_get_ban_list, "admin_get_ban_list")
nk.register_rpc(admin.rpc_admin_get_server_stats, "admin_get_server_stats")
nk.register_rpc(admin.rpc_admin_get_player_list, "admin_get_player_list")
nk.register_rpc(admin.rpc_admin_end_match, "admin_end_match")
nk.register_rpc(admin.rpc_admin_set_user_role, "admin_set_user_role")
nk.register_rpc(admin.rpc_admin_topup_gold, "admin_topup_gold")
nk.register_rpc(admin.rpc_admin_clear_global_chat, "admin_clear_global_chat")
nk.register_rpc(admin.rpc_admin_list_users, "admin_list_users")
nk.register_rpc(admin.rpc_admin_delete_users, "admin_delete_users")
nk.logger_info("LUA TEST: admin module loaded successfully")
return admin
+42
View File
@@ -0,0 +1,42 @@
local nk = require("nakama")
-- =============================================================================
-- Steam Auth Hook
-- =============================================================================
-- On first Steam login: set display_name from Steam username, default role to "player"
local function after_authenticate_steam(context, output, input)
if not context.user_id then return end
local status, account = pcall(nk.account_get_id, context.user_id)
if not status or not account then return end
-- On first login (no display name set), use Steam username
if not account.user.display_name or account.user.display_name == "" then
local steamName = input.username or "SteamPlayer"
pcall(nk.account_update_id, context.user_id, nil, steamName, nil, nil, nil, nil, nil)
nk.logger_info("Steam user " .. context.user_id .. " display name set to: " .. steamName)
end
-- Set default role if not set
local metadata = {}
if type(account.user.metadata) == "string" then
local s, m = pcall(nk.json_decode, account.user.metadata or "{}")
if s then metadata = m end
else
metadata = account.user.metadata or {}
end
if not metadata.role or metadata.role == "" then
metadata.role = "player"
pcall(nk.account_update_id, context.user_id, nil, nil, nil, nil, nil, nil, nk.json_encode(metadata))
end
end
-- Register the Steam auth after-hook
nk.register_req_after(after_authenticate_steam, "AuthenticateSteam")
-- Create default native leaderboard on startup
pcall(nk.leaderboard_create, "global_high_score", true, "desc", "best", nil, {})
nk.logger_info("LUA TEST: core module loaded successfully")
+205
View File
@@ -0,0 +1,205 @@
local nk = require("nakama")
local utils = require("lua.utils")
local daily_rewards = {}
function daily_rewards.rpc_claim_daily_reward(context, payload)
if not context.user_id then error("Not authenticated") end
local now = os.date("!*t")
local currentMonth = string.format("%02d", now.month)
local todayStr = string.format("%04d-%02d-%02d", now.year, now.month, now.day)
local todayIndex = now.day - 1 -- 0 to 30
local stateObjs = nk.storage_read({{ collection = "daily_rewards", key = "state", user_id = context.user_id }})
local state = { claimed_days = {}, last_claim_date = "", month = "" }
if stateObjs and #stateObjs > 0 then
local val = stateObjs[1].value
state.last_claim_date = val.last_claim_date or ""
state.month = val.month or ""
if type(val.claimed_days) == "number" then
for i = 0, val.claimed_days - 1 do
table.insert(state.claimed_days, i)
end
elseif type(val.claimed_days) == "table" then
state.claimed_days = val.claimed_days
end
end
if state.month ~= currentMonth then
state.claimed_days = {}
state.month = currentMonth
end
if state.last_claim_date == todayStr then
error("Already claimed today")
end
local configObjs = nk.storage_read({{ collection = "config", key = "daily_rewards", user_id = "00000000-0000-0000-0000-000000000000" }})
local config = {}
if configObjs and #configObjs > 0 then
config = configObjs[1].value
end
local monthRewards = config[currentMonth]
if not monthRewards or #monthRewards == 0 then
monthRewards = {}
for i = 0, 30 do
table.insert(monthRewards, { type = "star", amount = math.min(10 + i * 5, 100) })
end
end
local dayIndex = todayIndex
-- In lua array size is #monthRewards
if dayIndex >= #monthRewards then
error("Already claimed all rewards for this month")
end
local hasClaimed = false
for _, claimed_day in ipairs(state.claimed_days) do
if claimed_day == dayIndex then
hasClaimed = true
break
end
end
if hasClaimed then
error("Already claimed today's reward")
end
-- Lua arrays are 1-indexed!
local rewardData = monthRewards[dayIndex + 1]
if type(rewardData) == "number" then
rewardData = { type = "star", amount = rewardData }
end
local rewardType = rewardData.type or "star"
local rewardAmount = rewardData.amount or 0
if rewardType == "star" or rewardType == "gold" then
local changes = {}
changes[rewardType] = rewardAmount
nk.wallet_update(context.user_id, changes, {}, true)
elseif string.sub(rewardType, 1, 5) == "frag_" then
local invObjs = nk.storage_read({{ collection = "inventory", key = "fragments", user_id = context.user_id }})
local frags = {}
if invObjs and #invObjs > 0 then
frags = invObjs[1].value
end
frags[rewardType] = (frags[rewardType] or 0) + rewardAmount
nk.storage_write({{
collection = "inventory",
key = "fragments",
user_id = context.user_id,
value = frags,
permission_read = 1,
permission_write = 0
}})
end
table.insert(state.claimed_days, dayIndex)
state.last_claim_date = todayStr
nk.storage_write({{
collection = "daily_rewards",
key = "state",
user_id = context.user_id,
value = state,
permission_read = 1,
permission_write = 0
}})
return nk.json_encode({ success = true, reward_type = rewardType, reward_amount = rewardAmount, day = dayIndex + 1 })
end
function daily_rewards.rpc_get_daily_reward_state(context, payload)
if not context.user_id then error("Not authenticated") end
local now = os.date("!*t")
local currentMonth = string.format("%02d", now.month)
local todayStr = string.format("%04d-%02d-%02d", now.year, now.month, now.day)
local todayIndex = now.day - 1
local stateObjs = nk.storage_read({{ collection = "daily_rewards", key = "state", user_id = context.user_id }})
local state = { claimed_days = {}, last_claim_date = "", month = "" }
if stateObjs and #stateObjs > 0 then
local val = stateObjs[1].value
state.last_claim_date = val.last_claim_date or ""
state.month = val.month or ""
if type(val.claimed_days) == "number" then
for i = 0, val.claimed_days - 1 do
table.insert(state.claimed_days, i)
end
elseif type(val.claimed_days) == "table" then
state.claimed_days = val.claimed_days
end
end
if state.month ~= currentMonth then
state.claimed_days = {}
state.month = currentMonth
end
local configObjs = nk.storage_read({{ collection = "config", key = "daily_rewards", user_id = "00000000-0000-0000-0000-000000000000" }})
local config = {}
if configObjs and #configObjs > 0 then
config = configObjs[1].value
end
local monthRewards = config[currentMonth]
if not monthRewards or #monthRewards == 0 then
monthRewards = {}
for i = 0, 30 do
table.insert(monthRewards, { type = "star", amount = math.min(10 + i * 5, 100) })
end
end
local hasClaimedToday = false
for _, claimed_day in ipairs(state.claimed_days) do
if claimed_day == todayIndex then hasClaimedToday = true break end
end
local canClaimToday = (state.last_claim_date ~= todayStr) and (not hasClaimedToday) and (todayIndex < #monthRewards)
return nk.json_encode({
state = state,
month_rewards = monthRewards,
can_claim_today = canClaimToday,
today_date = todayStr,
today_index = todayIndex,
server_month = now.month
})
end
function daily_rewards.rpc_set_daily_reward_config(context, payload)
utils.require_admin(context)
local request = nk.json_decode(payload or "{}")
nk.storage_write({{
collection = "config",
key = "daily_rewards",
user_id = "00000000-0000-0000-0000-000000000000",
value = request.config,
permission_read = 2,
permission_write = 0
}})
return nk.json_encode({ success = true })
end
function daily_rewards.rpc_get_daily_reward_config_admin(context, payload)
utils.require_admin(context)
local configObjs = nk.storage_read({{ collection = "config", key = "daily_rewards", user_id = "00000000-0000-0000-0000-000000000000" }})
local config = {}
if configObjs and #configObjs > 0 then
config = configObjs[1].value
end
return nk.json_encode({ config = config })
end
nk.register_rpc(daily_rewards.rpc_claim_daily_reward, "claim_daily_reward")
nk.register_rpc(daily_rewards.rpc_get_daily_reward_state, "get_daily_reward_state")
nk.register_rpc(daily_rewards.rpc_set_daily_reward_config, "set_daily_reward_config")
nk.register_rpc(daily_rewards.rpc_get_daily_reward_config_admin, "get_daily_reward_config_admin")
nk.logger_info("LUA TEST: daily rewards module loaded")
return daily_rewards
+244
View File
@@ -0,0 +1,244 @@
local nk = require("nakama")
local utils = require("lua.utils")
local economy = {}
local SHOP_CATALOG_DEFS = {
{ id = "oldpop-blue-hat", name = "Oldpop Blue Hat", category = "head", gold = 100, star = 0, rarity = "Common", character = "Oldpop" },
{ id = "oldpop-green-hat", name = "Oldpop Green Hat", category = "head", gold = 100, star = 0, rarity = "Common", character = "Oldpop" },
{ id = "oldpop-red-hat", name = "Oldpop Red Hat", category = "head", gold = 100, star = 0, rarity = "Common", character = "Oldpop" },
{ id = "oldpop-yellow-hat", name = "Oldpop Yellow Hat", category = "head", gold = 100, star = 0, rarity = "Common", character = "Oldpop" },
{ id = "oldpop-og-pant", name = "Copper OG Pant", category = "costume", gold = 0, star = 0, rarity = "Common", character = "Oldpop" },
{ id = "oldpop-grey-pant", name = "Copper Grey Pant", category = "costume", gold = 150, star = 0, rarity = "Common", character = "Oldpop" },
{ id = "oldpop-red-pant", name = "Copper Red Pant", category = "costume", gold = 150, star = 0, rarity = "Common", character = "Oldpop" },
{ id = "oldpop-yellow-pant", name = "Copper Yellow Pant", category = "costume", gold = 150, star = 0, rarity = "Common", character = "Oldpop" },
{ id = "oldpop-blue-gloves", name = "Oldpop Blue Gloves", category = "glove", gold = 75, star = 0, rarity = "Common", character = "Oldpop" },
{ id = "oldpop-green-gloves", name = "Oldpop Green Gloves", category = "glove", gold = 75, star = 0, rarity = "Common", character = "Oldpop" },
{ id = "oldpop-red-gloves", name = "Oldpop Red Gloves", category = "glove", gold = 75, star = 0, rarity = "Common", character = "Oldpop" },
{ id = "oldpop-yellow-gloves", name = "Oldpop Yellow Gloves", category = "glove", gold = 75, star = 0, rarity = "Common", character = "Oldpop" }
}
local function build_shop_catalog()
local catalog = {}
for _, def in ipairs(SHOP_CATALOG_DEFS) do
local cat = def.category
if not catalog[cat] then catalog[cat] = {} end
local entry = {
id = def.id,
name = def.name,
gold = def.gold or 0,
star = def.star or 0,
rarity = def.rarity or "Common",
character = def.character
}
table.insert(catalog[cat], entry)
end
return catalog
end
function economy.rpc_get_shop_catalog(context, payload)
if not context.user_id then error("Not authenticated") end
local result = { catalog = build_shop_catalog(), featured_banners = {} }
local status, objs = pcall(nk.storage_read, {{ collection = "shop_config", key = "featured_banners", user_id = "00000000-0000-0000-0000-000000000000" }})
if status and objs and #objs > 0 then
local val = objs[1].value
if val.banners then result.featured_banners = val.banners end
end
return nk.json_encode(result)
end
function economy.rpc_buy_currency(context, payload)
if not context.user_id then error("Not authenticated") end
local request = nk.json_decode(payload)
local packageId = request.package_id
local receipt = request.receipt
local idempotencyKey = request.idempotency_key
if not packageId or packageId == "" then error("Package ID required") end
if not idempotencyKey or idempotencyKey == "" then error("Idempotency key required") end
local status, existing = pcall(nk.storage_read, {{ collection = "receipts", key = idempotencyKey, user_id = context.user_id }})
if status and existing and #existing > 0 then
return nk.json_encode({ success = true, package_id = packageId, duplicate = true, status = existing[1].value.status })
end
local changeset = { gold = 0, star = 0 }
local requiresVerification = false
if packageId == "gold_100" then changeset.gold = 100; requiresVerification = true
elseif packageId == "gold_500" then changeset.gold = 550; requiresVerification = true
elseif packageId == "gold_1000" then changeset.gold = 1150; requiresVerification = true
elseif packageId == "gold_2000" then changeset.gold = 2400; requiresVerification = true
elseif packageId == "gold_5000" then changeset.gold = 6250; requiresVerification = true
elseif packageId == "gold_10000" then changeset.gold = 13000; requiresVerification = true
elseif packageId == "star_100" then changeset.star = 100; changeset.gold = -500
elseif packageId == "star_250" then changeset.star = 250; changeset.gold = -1100
elseif packageId == "star_600" then changeset.star = 600; changeset.gold = -2500
else error("Invalid package ID") end
if requiresVerification and not receipt then
nk.storage_write({{
collection = "receipts",
key = idempotencyKey,
user_id = context.user_id,
value = { type = "currency", package_id = packageId, status = "pending", created_at = os.date("!%Y-%m-%dT%H:%M:%S.000Z") },
permission_read = 1,
permission_write = 0
}})
return nk.json_encode({ success = true, status = "pending", package_id = packageId })
end
local s, err = pcall(function()
if changeset.gold ~= 0 or changeset.star ~= 0 then
nk.wallet_update(context.user_id, changeset, {}, true)
end
nk.storage_write({{
collection = "receipts",
key = idempotencyKey,
user_id = context.user_id,
value = { type = "currency", package_id = packageId, changeset = changeset, receipt = receipt or nk.json_null(), status = "verified", processed_at = os.date("!%Y-%m-%dT%H:%M:%S.000Z") },
permission_read = 1,
permission_write = 0
}})
end)
if not s then
nk.logger_error("Currency purchase failed: " .. tostring(err))
error("NotEnoughFunds")
end
nk.logger_info("User " .. context.user_id .. " bought currency package " .. packageId)
return nk.json_encode({ success = true, status = "verified", package_id = packageId })
end
function economy.rpc_purchase_item(context, payload)
if not context.user_id then error("Not authenticated") end
local request = nk.json_decode(payload)
local itemId = request.item_id
local quantity = request.quantity or 1
local idempotencyKey = request.idempotency_key
if not itemId or itemId == "" then error("Item ID required") end
if quantity < 1 then error("Invalid quantity") end
if not idempotencyKey or idempotencyKey == "" then error("Idempotency key required") end
local status, existing = pcall(nk.storage_read, {{ collection = "receipts", key = idempotencyKey, user_id = context.user_id }})
if status and existing and #existing > 0 then
return nk.json_encode({ success = true, item = itemId, duplicate = true })
end
local itemDef = nil
for _, def in ipairs(SHOP_CATALOG_DEFS) do
if def.id == itemId then
itemDef = def
break
end
end
if not itemDef then error("ItemNotFound") end
local priceGold = (itemDef.gold or 0) * quantity
local priceStar = (itemDef.star or 0) * quantity
local category = itemDef.category or "accessory"
local s, err = pcall(function()
local changeset = {}
if priceGold > 0 then changeset.gold = -priceGold end
if priceStar > 0 then changeset.star = -priceStar end
if priceGold > 0 or priceStar > 0 then
nk.wallet_update(context.user_id, changeset, {}, true)
end
end)
if not s then
nk.logger_error("Wallet update failed: " .. tostring(err))
error("NotEnoughFunds")
end
local s2, err2 = pcall(function()
local writes = {
{
collection = "inventory",
key = itemId,
user_id = context.user_id,
value = { category = category, purchased_at = os.date("!%Y-%m-%dT%H:%M:%S.000Z"), quantity = quantity },
permission_read = 1,
permission_write = 0
},
{
collection = "receipts",
key = idempotencyKey,
user_id = context.user_id,
value = { type = "item", item_id = itemId, quantity = quantity, cost = { gold = priceGold, star = priceStar }, processed_at = os.date("!%Y-%m-%dT%H:%M:%S.000Z") },
permission_read = 1,
permission_write = 0
}
}
nk.storage_write(writes)
end)
if not s2 then
nk.logger_error("Purchase failed: " .. tostring(err2))
error("PurchaseFailed")
end
nk.logger_info("User " .. context.user_id .. " purchased " .. itemId)
return nk.json_encode({ success = true, item = itemId })
end
function economy.rpc_admin_set_featured_banners(context, payload)
utils.require_admin(context)
local req = nk.json_decode(payload or "{}")
local banners = req.banners or {}
local finalBanners = {}
for i = 1, math.min(#banners, 3) do
table.insert(finalBanners, banners[i])
end
for _, b in ipairs(finalBanners) do
local itemId = b.item_id or ""
if itemId ~= "" then
local found = false
for _, def in ipairs(SHOP_CATALOG_DEFS) do
if def.id == itemId then found = true; break end
end
if not found then error("Item not found in catalog: " .. itemId) end
end
end
nk.storage_write({{
collection = "shop_config",
key = "featured_banners",
user_id = "00000000-0000-0000-0000-000000000000",
value = { banners = finalBanners },
permission_read = 2,
permission_write = 0
}})
nk.logger_info("Featured banners updated by admin " .. context.user_id)
return nk.json_encode({ success = true, banners = finalBanners })
end
function economy.rpc_admin_get_featured_banners(context, payload)
utils.require_admin(context)
local status, objs = pcall(nk.storage_read, {{ collection = "shop_config", key = "featured_banners", user_id = "00000000-0000-0000-0000-000000000000" }})
if status and objs and #objs > 0 then
return nk.json_encode({ banners = objs[1].value.banners or {} })
end
return nk.json_encode({ banners = {} })
end
nk.register_rpc(economy.rpc_get_shop_catalog, "get_shop_catalog")
nk.register_rpc(economy.rpc_buy_currency, "buy_currency")
nk.register_rpc(economy.rpc_purchase_item, "purchase_item")
nk.register_rpc(economy.rpc_admin_set_featured_banners, "admin_set_featured_banners")
nk.register_rpc(economy.rpc_admin_get_featured_banners, "admin_get_featured_banners")
nk.logger_info("LUA TEST: economy module loaded successfully")
return economy
+535
View File
@@ -0,0 +1,535 @@
local nk = require("nakama")
local utils = require("lua.utils")
local inbox = {}
function inbox.rpc_admin_send_mail(context, payload)
utils.require_admin(context)
local request = nk.json_decode(payload or "{}")
local nowStr = os.date("!%Y-%m-%dT%H:%M:%S.000Z") -- approximate ISO8601
local startDate = request.start_date or nowStr
local endDate = request.end_date or ""
-- 30 days from now in seconds for expiry_date fallback if not specified
local expiryDate = os.date("!%Y-%m-%dT%H:%M:%S.000Z", os.time() + 30 * 24 * 60 * 60)
local mailObj = {
id = nk.uuid_v4(),
title = request.title or "Announcement",
content = request.content or "",
sender = "TEKTON DEV TEAM",
date = startDate,
start_date = startDate,
end_date = endDate,
expiry_date = expiryDate,
rewards = request.rewards or {}
}
if request.target_user_id and request.target_user_id ~= "" then
mailObj.type = "personal"
local invObjs = nk.storage_read({{ collection = "inbox", key = "personal", user_id = request.target_user_id }})
local personalMails = {}
if #invObjs > 0 then
personalMails = invObjs[1].value.mails or {}
end
table.insert(personalMails, mailObj)
nk.storage_write({{
collection = "inbox",
key = "personal",
user_id = request.target_user_id,
value = { mails = personalMails },
permission_read = 1,
permission_write = 0
}})
nk.logger_info("Personal mail sent to " .. request.target_user_id)
else
mailObj.type = "global"
local globalObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
local globalMails = {}
if #globalObjs > 0 then
globalMails = globalObjs[1].value.mails or {}
end
table.insert(globalMails, mailObj)
nk.storage_write({{
collection = "config",
key = "global_mail",
user_id = "00000000-0000-0000-0000-000000000000",
value = { mails = globalMails },
permission_read = 2,
permission_write = 0
}})
nk.logger_info("Global mail sent")
end
return nk.json_encode({ success = true, mail = mailObj })
end
function inbox.rpc_get_mail(context, payload)
if not context.user_id then error("Not authenticated") end
local personalObjs = nk.storage_read({{ collection = "inbox", key = "personal", user_id = context.user_id }})
local globalObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
local stateObjs = nk.storage_read({{ collection = "inbox", key = "state", user_id = context.user_id }})
local personalMails = (#personalObjs > 0) and (personalObjs[1].value.mails or {}) or {}
local globalMails = (#globalObjs > 0) and (globalObjs[1].value.mails or {}) or {}
local state = { claimed_ids = {}, deleted_ids = {}, read_ids = {} }
if #stateObjs > 0 then
local val = stateObjs[1].value
state.claimed_ids = val.claimed_ids or {}
state.deleted_ids = val.deleted_ids or {}
state.read_ids = val.read_ids or {}
end
local function array_contains(arr, val)
for _, v in ipairs(arr) do
if v == val then return true end
end
return false
end
local allMails = {}
for _, m in ipairs(personalMails) do table.insert(allMails, m) end
for _, m in ipairs(globalMails) do table.insert(allMails, m) end
local filteredMails = {}
local nowStr = os.date("!%Y-%m-%dT%H:%M:%S.000Z")
for _, mail in ipairs(allMails) do
if not array_contains(state.deleted_ids, mail.id) then
local skip = false
if mail.expiry_date and mail.expiry_date ~= "" and nowStr > mail.expiry_date then
skip = true
end
if not skip and mail.start_date and mail.start_date ~= "" and nowStr < mail.start_date then
skip = true
end
if not skip and mail.type == "global" and mail.end_date and mail.end_date ~= "" and nowStr > mail.end_date then
skip = true
end
if not skip then
table.insert(filteredMails, mail)
end
end
end
return nk.json_encode({ mails = filteredMails, state = state })
end
function inbox.rpc_claim_mail_reward(context, payload)
if not context.user_id then error("Not authenticated") end
local request = nk.json_decode(payload or "{}")
local mailId = request.mail_id
if not mailId then error("mail_id required") end
local personalObjs = nk.storage_read({{ collection = "inbox", key = "personal", user_id = context.user_id }})
local globalObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
local stateObjs = nk.storage_read({{ collection = "inbox", key = "state", user_id = context.user_id }})
local state = { claimed_ids = {}, deleted_ids = {}, read_ids = {} }
if #stateObjs > 0 then
local val = stateObjs[1].value
state.claimed_ids = val.claimed_ids or {}
state.deleted_ids = val.deleted_ids or {}
state.read_ids = val.read_ids or {}
end
local function array_contains(arr, val)
for _, v in ipairs(arr) do
if v == val then return true end
end
return false
end
if array_contains(state.claimed_ids, mailId) then
error("Reward already claimed")
end
local personalMails = (#personalObjs > 0) and (personalObjs[1].value.mails or {}) or {}
local globalMails = (#globalObjs > 0) and (globalObjs[1].value.mails or {}) or {}
local allMails = {}
for _, m in ipairs(personalMails) do table.insert(allMails, m) end
for _, m in ipairs(globalMails) do table.insert(allMails, m) end
local targetMail = nil
for _, mail in ipairs(allMails) do
if mail.id == mailId then
targetMail = mail
break
end
end
if not targetMail then error("Mail not found") end
local rewards = targetMail.rewards or {}
local starTotal = 0
local goldTotal = 0
local fragsToUpdate = {}
local skinsToAdd = {}
if type(rewards) == "table" and not rewards[1] and (rewards.star or rewards.gold) then
-- Handle legacy dictionary format
starTotal = rewards.star or 0
goldTotal = rewards.gold or 0
rewards = {}
end
for _, r in ipairs(rewards) do
local rType = r.type or "star"
local amount = r.amount or 0
if rType == "star" then
starTotal = starTotal + amount
elseif rType == "gold" then
goldTotal = goldTotal + amount
elseif string.sub(rType, 1, 5) == "frag_" or rType == "item" then
local fragId = r.id or rType
fragsToUpdate[fragId] = (fragsToUpdate[fragId] or 0) + amount
elseif rType == "skin" then
if r.id then table.insert(skinsToAdd, r.id) end
end
end
if starTotal > 0 or goldTotal > 0 then
local changes = {}
if starTotal > 0 then changes.star = starTotal end
if goldTotal > 0 then changes.gold = goldTotal end
nk.wallet_update(context.user_id, changes, {}, true)
end
local fragKeysCount = 0
for _ in pairs(fragsToUpdate) do fragKeysCount = fragKeysCount + 1 end
if fragKeysCount > 0 then
local invObjs = nk.storage_read({{ collection = "inventory", key = "fragments", user_id = context.user_id }})
local frags = (#invObjs > 0) and invObjs[1].value or {}
for fId, count in pairs(fragsToUpdate) do
frags[fId] = (frags[fId] or 0) + count
end
nk.storage_write({{
collection = "inventory",
key = "fragments",
user_id = context.user_id,
value = frags,
permission_read = 1,
permission_write = 0
}})
end
if #skinsToAdd > 0 then
local skinWrites = {}
for _, sId in ipairs(skinsToAdd) do
table.insert(skinWrites, {
collection = "inventory",
key = sId,
user_id = context.user_id,
value = { acquired_via = "mail", purchased_at = os.date("!%Y-%m-%dT%H:%M:%S.000Z") },
permission_read = 1,
permission_write = 0
})
end
nk.storage_write(skinWrites)
end
table.insert(state.claimed_ids, mailId)
if not array_contains(state.read_ids, mailId) then
table.insert(state.read_ids, mailId)
end
nk.storage_write({{
collection = "inbox",
key = "state",
user_id = context.user_id,
value = state,
permission_read = 1,
permission_write = 0
}})
return nk.json_encode({ success = true, claimed_ids = state.claimed_ids })
end
function inbox.rpc_delete_mail(context, payload)
if not context.user_id then error("Not authenticated") end
local request = nk.json_decode(payload or "{}")
local mailId = request.mail_id
if not mailId then error("mail_id required") end
local stateObjs = nk.storage_read({{ collection = "inbox", key = "state", user_id = context.user_id }})
local state = { claimed_ids = {}, deleted_ids = {}, read_ids = {} }
if #stateObjs > 0 then
local val = stateObjs[1].value
state.claimed_ids = val.claimed_ids or {}
state.deleted_ids = val.deleted_ids or {}
state.read_ids = val.read_ids or {}
end
local function array_contains(arr, val)
for _, v in ipairs(arr) do if v == val then return true end end
return false
end
if not array_contains(state.deleted_ids, mailId) then table.insert(state.deleted_ids, mailId) end
if not array_contains(state.read_ids, mailId) then table.insert(state.read_ids, mailId) end
nk.storage_write({{
collection = "inbox",
key = "state",
user_id = context.user_id,
value = state,
permission_read = 1,
permission_write = 0
}})
return nk.json_encode({ success = true, deleted_ids = state.deleted_ids })
end
function inbox.rpc_save_mail_state(context, payload)
if not context.user_id then error("Not authenticated") end
local request = nk.json_decode(payload or "{}")
local stateObjs = nk.storage_read({{ collection = "inbox", key = "state", user_id = context.user_id }})
local state = { claimed_ids = {}, deleted_ids = {}, read_ids = {} }
if #stateObjs > 0 then
local val = stateObjs[1].value
state.claimed_ids = val.claimed_ids or {}
state.deleted_ids = val.deleted_ids or {}
state.read_ids = val.read_ids or {}
end
local function array_contains(arr, val)
for _, v in ipairs(arr) do if v == val then return true end end
return false
end
local newReadIds = request.read_ids or {}
for _, rid in ipairs(newReadIds) do
if not array_contains(state.read_ids, rid) then
table.insert(state.read_ids, rid)
end
end
nk.storage_write({{
collection = "inbox",
key = "state",
user_id = context.user_id,
value = state,
permission_read = 1,
permission_write = 0
}})
return nk.json_encode({ success = true })
end
function inbox.rpc_admin_list_mail(context, payload)
utils.require_admin(context)
local globalObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
local globalMails = (#globalObjs > 0) and (globalObjs[1].value.mails or {}) or {}
for _, m in ipairs(globalMails) do m.type = "global" end
local personalMails = {}
local cursor = nil
repeat
local status, listResult = pcall(nk.storage_list, "", "inbox", 100, cursor)
if status and listResult then
local objects = listResult.objects or {}
for _, obj in ipairs(objects) do
if obj.key == "personal" then
local ownerUserId = obj.user_id
local mails = obj.value.mails or {}
for _, m in ipairs(mails) do
m.type = "personal"
m.target_user_id = ownerUserId
table.insert(personalMails, m)
end
end
end
cursor = listResult.cursor
else
cursor = nil
end
until not cursor or cursor == ""
local allMails = {}
for _, m in ipairs(globalMails) do table.insert(allMails, m) end
for _, m in ipairs(personalMails) do table.insert(allMails, m) end
table.sort(allMails, function(a, b)
local d1 = a.date or ""
local d2 = b.date or ""
return d1 > d2
end)
return nk.json_encode({ mails = allMails })
end
function inbox.rpc_admin_update_mail(context, payload)
utils.require_admin(context)
local request = nk.json_decode(payload or "{}")
local mailId = request.mail_id
if not mailId then error("mail_id required") end
local isGlobal = request.type ~= "personal"
local targetUserId = request.target_user_id or ""
local newTargetUserId = request.new_target_user_id
local hasNewTarget = (newTargetUserId ~= nil)
local mailObj = nil
if isGlobal then
local globalObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
local globalMails = (#globalObjs > 0) and (globalObjs[1].value.mails or {}) or {}
for i, m in ipairs(globalMails) do
if m.id == mailId then
mailObj = table.remove(globalMails, i)
break
end
end
if not mailObj then error("Mail not found in global") end
nk.storage_write({{
collection = "config",
key = "global_mail",
user_id = "00000000-0000-0000-0000-000000000000",
value = { mails = globalMails },
permission_read = 2,
permission_write = 0
}})
else
if targetUserId == "" then error("target_user_id required for personal mail") end
local pObjs = nk.storage_read({{ collection = "inbox", key = "personal", user_id = targetUserId }})
local personalMails = (#pObjs > 0) and (pObjs[1].value.mails or {}) or {}
for i, m in ipairs(personalMails) do
if m.id == mailId then
mailObj = table.remove(personalMails, i)
break
end
end
if not mailObj then error("Mail not found in personal inbox") end
nk.storage_write({{
collection = "inbox",
key = "personal",
user_id = targetUserId,
value = { mails = personalMails },
permission_read = 1,
permission_write = 0
}})
end
if request.title ~= nil then mailObj.title = request.title end
if request.content ~= nil then mailObj.content = request.content end
if request.end_date ~= nil then mailObj.end_date = request.end_date end
if request.expiry_date ~= nil then mailObj.expiry_date = request.expiry_date end
local destUserId = ""
if hasNewTarget then destUserId = newTargetUserId else
if not isGlobal then destUserId = targetUserId end
end
if destUserId == "" then
mailObj.type = "global"
local gObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
local gMails = (#gObjs > 0) and (gObjs[1].value.mails or {}) or {}
table.insert(gMails, mailObj)
nk.storage_write({{
collection = "config",
key = "global_mail",
user_id = "00000000-0000-0000-0000-000000000000",
value = { mails = gMails },
permission_read = 2,
permission_write = 0
}})
else
mailObj.type = "personal"
local dObjs = nk.storage_read({{ collection = "inbox", key = "personal", user_id = destUserId }})
local dMails = (#dObjs > 0) and (dObjs[1].value.mails or {}) or {}
table.insert(dMails, mailObj)
nk.storage_write({{
collection = "inbox",
key = "personal",
user_id = destUserId,
value = { mails = dMails },
permission_read = 1,
permission_write = 0
}})
end
nk.logger_info("Admin updated mail " .. mailId .. " by " .. context.user_id)
return nk.json_encode({ success = true })
end
function inbox.rpc_admin_delete_mail_server(context, payload)
utils.require_admin(context)
local request = nk.json_decode(payload or "{}")
local mailId = request.mail_id
if not mailId then error("mail_id required") end
local isGlobal = request.type ~= "personal"
local targetUserId = request.target_user_id or ""
if isGlobal then
local globalObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
local globalMails = (#globalObjs > 0) and (globalObjs[1].value.mails or {}) or {}
local before = #globalMails
local filtered = {}
for _, m in ipairs(globalMails) do
if m.id ~= mailId then table.insert(filtered, m) end
end
if #filtered == before then error("Mail not found") end
nk.storage_write({{
collection = "config",
key = "global_mail",
user_id = "00000000-0000-0000-0000-000000000000",
value = { mails = filtered },
permission_read = 2,
permission_write = 0
}})
else
if targetUserId == "" then error("target_user_id required for personal mail") end
local pObjs = nk.storage_read({{ collection = "inbox", key = "personal", user_id = targetUserId }})
local personalMails = (#pObjs > 0) and (pObjs[1].value.mails or {}) or {}
local before = #personalMails
local filtered = {}
for _, m in ipairs(personalMails) do
if m.id ~= mailId then table.insert(filtered, m) end
end
if #filtered == before then error("Mail not found") end
nk.storage_write({{
collection = "inbox",
key = "personal",
user_id = targetUserId,
value = { mails = filtered },
permission_read = 1,
permission_write = 0
}})
end
nk.logger_info("Admin deleted mail " .. mailId .. " from server by " .. context.user_id)
return nk.json_encode({ success = true })
end
nk.register_rpc(inbox.rpc_admin_send_mail, "admin_send_mail")
nk.register_rpc(inbox.rpc_get_mail, "get_mail")
nk.register_rpc(inbox.rpc_claim_mail_reward, "claim_mail_reward")
nk.register_rpc(inbox.rpc_delete_mail, "delete_mail")
nk.register_rpc(inbox.rpc_save_mail_state, "save_mail_state")
nk.register_rpc(inbox.rpc_admin_list_mail, "admin_list_mail")
nk.register_rpc(inbox.rpc_admin_update_mail, "admin_update_mail")
nk.register_rpc(inbox.rpc_admin_delete_mail_server, "admin_delete_mail_server")
nk.logger_info("LUA TEST: inbox module loaded")
return inbox
+249
View File
@@ -0,0 +1,249 @@
local nk = require("nakama")
local utils = require("lua.utils")
local leaderboard = {}
function leaderboard.rpc_get_leaderboard_stats(context, payload)
local status, records_or_err = pcall(nk.leaderboard_records_list, "global_high_score", nil, 50, nil)
if not status then
nk.logger_error("Failed to get native leaderboard stats: " .. tostring(records_or_err))
return nk.json_encode({ leaderboard = {} })
end
local leaderboardData = {}
local ownerRecords = records_or_err.records or {}
for _, record in ipairs(ownerRecords) do
local metadata = {}
if record.metadata then
local s, m = pcall(nk.json_decode, record.metadata)
if s then metadata = m end
end
table.insert(leaderboardData, {
user_id = record.owner_id,
username = record.username,
display_name = record.username, -- Native lua leaderboard returns owner_id, username, score, subscore, num_score, max_num_score, metadata, create_time, update_time
avatar_url = metadata.avatar_url or "",
loadout_character = metadata.loadout_character or "Copper",
high_score = record.score or 0,
games_played = metadata.games_played or 0,
games_won = metadata.games_won or 0
})
end
return nk.json_encode({ leaderboard = leaderboardData })
end
function leaderboard.rpc_submit_score(context, payload)
if not context.user_id then error("Not authenticated") end
local request = nk.json_decode(payload or "{}")
local score = tonumber(request.score) or 0
local account = nk.account_get_id(context.user_id)
local metadata = {
games_played = request.games_played or 0,
games_won = request.games_won or 0,
avatar_url = request.avatar_url or account.user.avatar_url or "",
loadout_character = request.loadout_character or "Copper"
}
local status, err = pcall(nk.leaderboard_record_write,
"global_high_score",
context.user_id,
account.user.username,
score,
0,
metadata
)
if not status then
nk.logger_error("Failed to submit score for " .. context.user_id .. ": " .. tostring(err))
error("Failed to submit score")
end
nk.logger_info("Score submitted for user " .. context.user_id .. ": " .. score)
return nk.json_encode({ success = true })
end
function leaderboard.rpc_sync_leaderboard(context, payload)
if not context.user_id then error("Not authenticated") end
local status, result = pcall(nk.storage_list, nil, "stats", 100, "")
if not status then error("Sync failed: " .. tostring(result)) end
local statsObjects = result.objects or {}
local userGroup = {}
for _, obj in ipairs(statsObjects) do
local userId = obj.user_id
local value = obj.value
if not userGroup[userId] then
userGroup[userId] = {
high_score = value.high_score or 0,
games_played = value.games_played or 0,
games_won = value.games_won or 0,
avatar_url = value.avatar_url or "",
loadout_character = value.loadout_character or ""
}
else
userGroup[userId].high_score = math.max(userGroup[userId].high_score, value.high_score or 0)
userGroup[userId].games_played = math.max(userGroup[userId].games_played, value.games_played or 0)
userGroup[userId].games_won = math.max(userGroup[userId].games_won, value.games_won or 0)
end
if obj.key == "game_stats" or userGroup[userId].avatar_url == "" then
if value.avatar_url then userGroup[userId].avatar_url = value.avatar_url end
if value.loadout_character then userGroup[userId].loadout_character = value.loadout_character end
end
end
local statusProf, profileResult = pcall(nk.storage_list, nil, "profiles", 100, "")
if statusProf and profileResult and profileResult.objects then
for _, obj in ipairs(profileResult.objects) do
if obj.key == "profile" then
local userId = obj.user_id
local value = obj.value
if not userGroup[userId] then
userGroup[userId] = { high_score = 0, games_played = 0, games_won = 0, avatar_url = "", loadout_character = "" }
end
if value.avatar_url and userGroup[userId].avatar_url == "" then
userGroup[userId].avatar_url = value.avatar_url
end
if value.loadout_character and userGroup[userId].loadout_character == "" then
userGroup[userId].loadout_character = value.loadout_character
end
end
end
end
local count = 0
local debugLogs = {}
for uid, stats in pairs(userGroup) do
local s, err = pcall(function()
local account = nk.account_get_id(uid)
local avatar = stats.avatar_url
if not avatar or avatar == "" then
avatar = account.user.avatar_url
end
if not avatar or avatar == "" then
avatar = "res://assets/graphics/character_selection/sc_characters/sc_copper.png"
end
local meta = {
games_played = stats.games_played or 0,
games_won = stats.games_won or 0,
avatar_url = avatar,
loadout_character = stats.loadout_character or "Copper"
}
nk.leaderboard_record_write("global_high_score", uid, account.user.username, stats.high_score, 0, meta)
count = count + 1
end)
if not s then
table.insert(debugLogs, "Error user " .. uid .. ": " .. tostring(err))
nk.logger_error("Failed to sync record for " .. uid .. ": " .. tostring(err))
end
end
nk.logger_info("Synced " .. count .. " records to leaderboard by user " .. context.user_id)
return nk.json_encode({ success = true, synced = count, objects_found = #statsObjects, debug = debugLogs })
end
function leaderboard.rpc_reset_stats(context, payload)
if not context.user_id then error("Not authenticated") end
pcall(nk.leaderboard_record_delete, "global_high_score", context.user_id)
local zeros = { games_played = 0, games_won = 0, high_score = 0, total_kills = 0, total_deaths = 0 }
nk.storage_write({{
collection = "stats",
key = "game_stats",
user_id = context.user_id,
value = zeros,
permission_read = 2,
permission_write = 1
}})
return nk.json_encode({ success = true })
end
function leaderboard.rpc_admin_update_stats(context, payload)
utils.require_admin(context)
local request = nk.json_decode(payload)
local targetUserId = request.user_id
local stats = request.stats
if not targetUserId or not stats then
error("User ID and stats are required")
end
nk.storage_write({{
collection = "stats",
key = "game_stats",
user_id = targetUserId,
value = stats,
permission_read = 1,
permission_write = 0
}})
local account = nk.account_get_id(targetUserId)
local score = stats.high_score or 0
local metadata = {
games_played = stats.games_played or 0,
games_won = stats.games_won or 0,
avatar_url = account.user.avatar_url or "",
loadout_character = stats.loadout_character or "Copper"
}
nk.leaderboard_record_write("global_high_score", targetUserId, account.user.username, score, 0, metadata)
nk.logger_info("Stats updated for user " .. targetUserId .. " by admin " .. context.user_id)
return nk.json_encode({ success = true })
end
function leaderboard.rpc_admin_delete_stats(context, payload)
utils.require_admin(context)
local request = nk.json_decode(payload)
local targetUserId = request.user_id
if not targetUserId then error("User ID is required") end
nk.storage_delete({
{ collection = "stats", key = "stats", user_id = targetUserId },
{ collection = "stats", key = "game_stats", user_id = targetUserId }
})
pcall(nk.leaderboard_record_delete, "global_high_score", targetUserId)
nk.logger_info("Stats deleted for user " .. targetUserId .. " by admin " .. context.user_id)
return nk.json_encode({ success = true })
end
function leaderboard.rpc_admin_sync_leaderboard(context, payload)
utils.require_admin(context)
return leaderboard.rpc_sync_leaderboard(context, payload)
end
nk.register_rpc(leaderboard.rpc_get_leaderboard_stats, "get_leaderboard_stats")
nk.register_rpc(leaderboard.rpc_submit_score, "submit_score")
nk.register_rpc(leaderboard.rpc_sync_leaderboard, "sync_leaderboard")
nk.register_rpc(leaderboard.rpc_reset_stats, "reset_stats")
nk.register_rpc(leaderboard.rpc_admin_update_stats, "admin_update_stats")
nk.register_rpc(leaderboard.rpc_admin_delete_stats, "admin_delete_stats")
nk.register_rpc(leaderboard.rpc_admin_sync_leaderboard, "admin_sync_leaderboard")
-- Create default native leaderboard
-- id: "global_high_score", authoritative: true, sort: "desc", operator: "best", reset: None
pcall(nk.leaderboard_create, "global_high_score", true, "desc", "best", nil, {})
nk.logger_info("LUA TEST: leaderboard module loaded")
return leaderboard
+260
View File
@@ -0,0 +1,260 @@
local nk = require("nakama")
local user = {}
function user.rpc_get_user_profile(context, payload)
local request = nk.json_decode(payload or "{}")
local targetUserId = request.user_id or context.user_id
local status, account = pcall(nk.account_get_id, targetUserId)
if not status then error("Account not found") end
local metadata = {}
if account.user.metadata then
status, metadata = pcall(nk.json_decode, account.user.metadata)
if not status then metadata = {} end
end
if metadata.banned and targetUserId == context.user_id then
if metadata.ban_expires then
-- Note: ban_expires stored as Unix time in Lua (seconds) or ISO string depending on how it was stored
-- Let's check against current os.time() assuming Unix time
local expiresAt = tonumber(metadata.ban_expires)
if not expiresAt and type(metadata.ban_expires) == "string" then
-- basic check if we stored iso string
-- We assume it's valid ISO string and lua os.time might not parse it easily without custom function
-- As a fallback, we'll keep the ban if we can't parse it
error("Account banned until " .. metadata.ban_expires .. ". Reason: " .. (metadata.ban_reason or ""))
end
if expiresAt and expiresAt <= os.time() then
metadata.banned = nil
metadata.ban_reason = nil
metadata.ban_expires = nil
nk.account_update_id(targetUserId, nil, nil, nil, nil, nil, nil, nk.json_encode(metadata))
else
error("Account banned until " .. tostring(metadata.ban_expires) .. ". Reason: " .. (metadata.ban_reason or ""))
end
else
error("Account permanently banned. Reason: " .. (metadata.ban_reason or ""))
end
end
return nk.json_encode({
user_id = account.user.id,
username = account.user.username,
display_name = account.user.display_name,
avatar_url = account.user.avatar_url,
create_time = account.user.create_time,
role = metadata.role or "player"
})
end
function user.rpc_update_user_profile(context, payload)
if not context.user_id then error("Not authenticated") end
local request = nk.json_decode(payload)
local status, err = pcall(nk.account_update_id,
context.user_id,
nil,
request.display_name or nil,
nil,
nil,
nil,
request.avatar_url or nil,
nil
)
if not status then
nk.logger_error("Failed to update profile: " .. tostring(err))
error("Failed to update profile")
end
return nk.json_encode({ success = true })
end
function user.rpc_search_users(context, payload)
if not context.user_id then error("Not authenticated") end
local request = nk.json_decode(payload or "{}")
local query = request.query or ""
local users = {}
local sql = ""
local params = {}
if query == "" then
sql = "SELECT id, username, display_name, metadata FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100"
else
sql = "SELECT id, username, display_name, metadata FROM users WHERE (username ILIKE $1 OR display_name ILIKE $1) AND id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100"
params = {"%" .. query .. "%"}
end
local status, rows = pcall(nk.sql_query, sql, params)
if status and rows then
for _, row in ipairs(rows) do
local metadata = {}
if row.metadata then
local s, m = pcall(nk.json_decode, row.metadata)
if s then metadata = m end
end
table.insert(users, {
user_id = row.id,
username = row.username or "",
display_name = row.display_name or row.username or "",
avatar_url = metadata.avatar_url or ""
})
end
end
return nk.json_encode({ users = users })
end
function user.rpc_change_credentials(context, payload)
if not context.user_id then error("Not authenticated") end
local req = nk.json_decode(payload or "{}")
local account = nk.account_get_id(context.user_id)
if account.email then
if not req.current_password then error("Current password required") end
local status = pcall(nk.authenticate_email, account.email, req.current_password, false)
if not status then error("Incorrect current password.") end
nk.unlink_email(context.user_id, account.email, req.current_password)
end
local status, err = pcall(nk.link_email, context.user_id, req.new_email, req.new_password)
if not status then
if account.email then pcall(nk.link_email, context.user_id, account.email, req.current_password) end
error("Failed to set new credentials: " .. tostring(err))
end
return nk.json_encode({ success = true })
end
function user.rpc_send_lobby_invite(context, payload)
if not context.user_id then error("Not authenticated") end
local req = nk.json_decode(payload or "{}")
if not req.to_user_id or not req.match_id then error("Missing to_user_id or match_id") end
local sender = nk.account_get_id(context.user_id)
local senderName = sender.user.display_name or sender.user.username or "Someone"
nk.notification_send(
req.to_user_id,
senderName .. " invited you to their lobby",
nk.json_encode({ match_id = req.match_id, from_name = senderName }),
1001,
context.user_id,
true
)
nk.logger_info("Lobby invite sent from " .. context.user_id .. " to " .. req.to_user_id .. " for match " .. req.match_id)
return nk.json_encode({ success = true })
end
function user.rpc_send_friend_request(context, payload)
if not context.user_id then error("Not authenticated") end
local request = nk.json_decode(payload or "{}")
local targetUserId = request.user_id or ""
if targetUserId == "" then error("user_id is required") end
if targetUserId == context.user_id then error("Cannot add yourself") end
local senderAccount = nk.account_get_id(context.user_id)
local senderName = senderAccount.user.display_name or senderAccount.user.username or "Someone"
nk.notification_send(
targetUserId,
"Friend Request",
nk.json_encode({ from_user_id = context.user_id, from_name = senderName }),
1002,
context.user_id,
true
)
nk.logger_info("Friend request notification sent from " .. context.user_id .. " to " .. targetUserId)
return nk.json_encode({ success = true })
end
function user.after_authenticate(context, out, payload)
if not context.user_id then return end
-- We store the last 10 logins in user metadata or a dedicated collection
local login_entry = {
time = os.time(),
ip = context.client_ip or "unknown"
}
local status, result = pcall(nk.storage_read, {{collection = "history", key = "logins", user_id = context.user_id}})
local logins = {}
if status and result and #result > 0 then
logins = result[1].value.logins or {}
end
table.insert(logins, 1, login_entry)
-- Keep only last 20 logins to save space
while #logins > 20 do table.remove(logins) end
pcall(nk.storage_write, {{
collection = "history",
key = "logins",
user_id = context.user_id,
value = { logins = logins },
permission_read = 0,
permission_write = 0
}})
end
function user.rpc_admin_get_user_history(context, payload)
local utils = require("lua.utils")
utils.require_admin(context)
local request = nk.json_decode(payload or "{}")
local targetUserId = request.user_id
if not targetUserId then error("user_id is required") end
local history = {
wallet_ledger = {},
logins = {},
matches = {}
}
-- 1. Fetch Wallet Ledger (Economy History)
local status_wallet, wallet_result = pcall(nk.wallet_ledger_list, targetUserId, 50)
if status_wallet and wallet_result then
history.wallet_ledger = wallet_result.items or {}
end
-- 2. Fetch Login History
local status_logins, login_result = pcall(nk.storage_read, {{collection = "history", key = "logins", user_id = targetUserId}})
if status_logins and login_result and #login_result > 0 then
history.logins = login_result[1].value.logins or {}
end
-- 3. Fetch Match History (If stored in collection 'matches')
local status_matches, match_result = pcall(nk.storage_list, targetUserId, "matches", 50, "")
if status_matches and match_result then
for _, obj in ipairs(match_result.objects or {}) do
table.insert(history.matches, obj.value)
end
end
return nk.json_encode({ history = history })
end
nk.register_rpc(user.rpc_get_user_profile, "get_user_profile")
nk.register_rpc(user.rpc_update_user_profile, "update_user_profile")
nk.register_rpc(user.rpc_search_users, "search_users")
nk.register_rpc(user.rpc_change_credentials, "change_credentials")
nk.register_rpc(user.rpc_send_lobby_invite, "send_lobby_invite")
nk.register_rpc(user.rpc_send_friend_request, "send_friend_request")
nk.register_rpc(user.rpc_admin_get_user_history, "admin_get_user_history")
nk.register_req_after(user.after_authenticate, "AuthenticateDevice")
nk.register_req_after(user.after_authenticate, "AuthenticateEmail")
nk.register_req_after(user.after_authenticate, "AuthenticateCustom")
nk.logger_info("LUA TEST: user module loaded")
return user
+52
View File
@@ -0,0 +1,52 @@
local nk = require("nakama")
local utils = {}
utils.ADMIN_ROLES = { ["admin"] = true, ["moderator"] = true, ["owner"] = true }
function utils.is_admin(context)
if not context.user_id then return false end
local status, account = pcall(nk.account_get_id, context.user_id)
if not status or not account then return false end
local metadata = {}
if type(account.user.metadata) == "string" then
status, metadata = pcall(nk.json_decode, account.user.metadata)
if not status then metadata = {} end
else
metadata = account.user.metadata or {}
end
local role = metadata.role or ""
return utils.ADMIN_ROLES[role] == true
end
function utils.is_match_host(context, match_id)
if not context.user_id or not match_id then return false end
local status, match = pcall(nk.match_get, match_id)
if not status or not match then return false end
-- Needs to decode match.state if you're using authoritative matches
-- Simplified for lua translation:
local state = {}
if match.state then
status, state = pcall(nk.json_decode, match.state)
if not status then state = {} end
end
return state.hostUserId == context.user_id
end
function utils.require_admin(context)
if not utils.is_admin(context) then
error("Admin privileges required")
end
end
function utils.require_admin_or_host(context, match_id)
if not utils.is_admin(context) and not utils.is_match_host(context, match_id) then
error("Admin or host privileges required")
end
end
return utils
+13
View File
@@ -0,0 +1,13 @@
local nk = require("nakama")
-- Require our lua modules from the lua subfolder so they are executed and their RPCs are registered
require("lua.utils")
require("lua.economy")
require("lua.core")
require("lua.admin")
require("lua.daily_rewards")
require("lua.user")
require("lua.leaderboard")
require("lua.inbox")
nk.logger_info("LUA TEST: main.lua entrypoint loaded successfully")