Files
tekton/server/nakama/tekton_admin.js
T
2026-04-29 01:36:49 +08:00

1094 lines
41 KiB
JavaScript

/**
* Tekton Nakama Server Runtime Module
*
* This module provides secure admin operations via RPC calls.
* Deploy this to your Nakama server's runtime directory.
*/
// Initialize RPC endpoints
function InitModule(ctx, logger, nk, initializer) {
// Admin RPCs
initializer.registerRpc("admin_kick_player", rpcAdminKickPlayer);
initializer.registerRpc("admin_ban_player", rpcAdminBanPlayer);
initializer.registerRpc("admin_unban_player", rpcAdminUnbanPlayer);
initializer.registerRpc("admin_get_ban_list", rpcAdminGetBanList);
initializer.registerRpc("admin_get_server_stats", rpcAdminGetServerStats);
initializer.registerRpc("admin_get_player_list", rpcAdminGetPlayerList);
initializer.registerRpc("admin_end_match", rpcAdminEndMatch);
initializer.registerRpc("admin_set_user_role", rpcAdminSetUserRole);
initializer.registerRpc("admin_list_users", rpcAdminListUsers);
initializer.registerRpc("admin_delete_users", rpcAdminDeleteUsers);
initializer.registerRpc("admin_topup_gold", rpcAdminTopupGold);
// User management RPCs
initializer.registerRpc("get_user_profile", rpcGetUserProfile);
initializer.registerRpc("update_user_profile", rpcUpdateUserProfile);
// 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);
initializer.registerRpc("admin_delete_stats", rpcAdminDeleteStats);
initializer.registerRpc("admin_sync_leaderboard", rpcAdminSyncLeaderboard);
// Client-accessible score submission (authoritative leaderboard requires server-side writes)
initializer.registerRpc("submit_score", rpcSubmitScore);
initializer.registerRpc("sync_leaderboard", rpcSyncLeaderboard);
initializer.registerRpc("change_credentials", rpcChangeCredentials);
initializer.registerRpc("reset_stats", rpcResetStats);
initializer.registerRpc("send_lobby_invite", rpcSendLobbyInvite);
// Steam auth hooks
initializer.registerAfterAuthenticateSteam(afterAuthenticateSteam);
// 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) {}
logger.info("Tekton admin 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;
try {
var account = nk.accountGetId(ctx.userId);
var metadata;
if (typeof account.user.metadata === "string") {
metadata = JSON.parse(account.user.metadata || "{}");
} else {
metadata = account.user.metadata || {};
}
var role = metadata.role || "";
return ADMIN_ROLES.indexOf(role) !== -1;
} catch (e) {
return false;
}
}
function isMatchHost(ctx, nk, matchId) {
if (!ctx.userId || !matchId) return false;
try {
// Get match state to check host
var match = nk.matchGet(matchId);
if (!match) return false;
// The first user to join (presence) is typically the host
// This logic may need adjustment based on your match handler
var state = JSON.parse(match.state || "{}");
return state.hostUserId === ctx.userId;
} catch (e) {
return false;
}
}
function requireAdmin(ctx, nk) {
if (!isAdmin(ctx, nk)) {
throw new Error("Admin privileges required");
}
}
function requireAdminOrHost(ctx, nk, matchId) {
if (!isAdmin(ctx, nk) && !isMatchHost(ctx, nk, matchId)) {
throw new Error("Admin or host privileges required");
}
}
// =============================================================================
// Lobby Invite RPC
// =============================================================================
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;
if (!toUserId || !matchId) throw new Error("Missing to_user_id or match_id");
var sender = nk.accountGetId(ctx.userId);
var senderName = sender.user.displayName || sender.user.username || "Someone";
nk.notificationSend(
toUserId,
senderName + " invited you to their lobby",
JSON.stringify({ match_id: matchId, from_name: senderName }),
1001,
ctx.userId,
true
);
logger.info("Lobby invite sent from " + ctx.userId + " to " + toUserId + " for match " + matchId);
return JSON.stringify({ success: true });
}
// =============================================================================
// Steam Auth Hook
// =============================================================================
function afterAuthenticateSteam(ctx, logger, nk, out, request) {
if (!ctx.userId) return;
try {
var account = nk.accountGetId(ctx.userId);
// On first login (no display name set), use Steam username
if (!account.user.displayName) {
var steamName = request.username || "SteamPlayer";
nk.accountUpdateId(ctx.userId, null, steamName, null, null, null, null, null);
logger.info("Steam user " + ctx.userId + " display name set to: " + steamName);
}
// Set default role if not set
var metadata = {};
try {
metadata = typeof account.user.metadata === "string"
? JSON.parse(account.user.metadata || "{}")
: (account.user.metadata || {});
} catch (e) {}
if (!metadata.role) {
metadata.role = "player";
nk.accountUpdateId(ctx.userId, null, null, null, null, null, null, JSON.stringify(metadata));
}
} catch (e) {
logger.error("afterAuthenticateSteam error: " + e);
}
}
// =============================================================================
// Admin RPCs
// =============================================================================
function rpcAdminKickPlayer(ctx, logger, nk, payload) {
var request = JSON.parse(payload);
requireAdminOrHost(ctx, nk, request.match_id);
// Can't kick yourself
if (request.user_id === ctx.userId) {
throw new Error("Cannot kick yourself");
}
try {
// Signal the match to kick the player
nk.matchSignal(request.match_id, JSON.stringify({
action: "kick",
user_id: request.user_id,
reason: request.reason || "Kicked by admin"
}));
logger.info("Player " + request.user_id + " kicked from match " + request.match_id + " by " + ctx.userId);
return JSON.stringify({ success: true });
} catch (e) {
logger.error("Failed to kick player: " + e);
throw new Error("Failed to kick player");
}
}
function rpcAdminBanPlayer(ctx, logger, nk, payload) {
var request = JSON.parse(payload);
// Only full admins can ban (not just match hosts)
requireAdmin(ctx, nk);
if (request.user_id === ctx.userId) {
throw new Error("Cannot ban yourself");
}
try {
// Get target user's account
var targetAccount = nk.accountGetId(request.user_id);
var metadata = JSON.parse(targetAccount.user.metadata || "{}");
// Don't allow banning other admins
if (ADMIN_ROLES.indexOf(metadata.role || "") !== -1) {
throw new Error("Cannot ban an admin");
}
// Set ban in metadata
var banExpires = request.duration_hours && request.duration_hours > 0
? new Date(Date.now() + request.duration_hours * 60 * 60 * 1000).toISOString()
: null;
metadata.banned = true;
metadata.ban_reason = request.reason || "Banned by admin";
if (banExpires) {
metadata.ban_expires = banExpires;
}
nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata));
// Also kick from current match if specified
if (request.match_id) {
nk.matchSignal(request.match_id, JSON.stringify({
action: "kick",
user_id: request.user_id,
reason: "Banned: " + (request.reason || "")
}));
}
// Store in ban list (for quick lookup)
var banRecord = {
user_id: request.user_id,
username: targetAccount.user.username,
banned_by: ctx.userId,
banned_at: new Date().toISOString(),
reason: request.reason,
expires: banExpires
};
nk.storageWrite([{
collection: "bans",
key: request.user_id,
userId: "00000000-0000-0000-0000-000000000000", // System-owned
value: banRecord,
permissionRead: 2, // Public read
permissionWrite: 0 // No one can write (except server)
}]);
logger.warn("Player " + request.user_id + " banned by " + ctx.userId + ". Reason: " + request.reason);
return JSON.stringify({ success: true, ban: banRecord });
} catch (e) {
logger.error("Failed to ban player: " + e);
throw new Error("Failed to ban player: " + e);
}
}
function rpcAdminUnbanPlayer(ctx, logger, nk, payload) {
var request = JSON.parse(payload);
requireAdmin(ctx, nk);
try {
// Get target user's account
var targetAccount = nk.accountGetId(request.user_id);
var metadata = JSON.parse(targetAccount.user.metadata || "{}");
// Remove ban
delete metadata.banned;
delete metadata.ban_reason;
delete metadata.ban_expires;
nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata));
// Remove from ban list
nk.storageDelete([{
collection: "bans",
key: request.user_id,
userId: "00000000-0000-0000-0000-000000000000"
}]);
logger.info("Player " + request.user_id + " unbanned by " + ctx.userId);
return JSON.stringify({ success: true });
} catch (e) {
logger.error("Failed to unban player: " + e);
throw new Error("Failed to unban player");
}
}
function rpcAdminGetBanList(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
try {
var result = nk.storageList(
"00000000-0000-0000-0000-000000000000",
"bans",
100,
""
);
var bans = result.objects ? result.objects.map(function(obj) { return obj.value; }) : [];
return JSON.stringify({ bans: bans });
} catch (e) {
logger.error("Failed to get ban list: " + e);
return JSON.stringify({ bans: [] });
}
}
function rpcAdminGetServerStats(ctx, logger, nk, payload) {
var request = JSON.parse(payload || "{}");
if (request.match_id) {
requireAdminOrHost(ctx, nk, request.match_id);
} else {
requireAdmin(ctx, nk);
}
try {
// Get server-wide stats
var matches = nk.matchList(100, true, null, null, null, null);
var activeMatchCount = matches ? matches.length : 0;
var totalPlayers = 0;
if (matches) {
for (var i = 0; i < matches.length; i++) {
totalPlayers += matches[i].size || 0;
}
}
var stats = {
active_matches: activeMatchCount,
total_players: totalPlayers,
server_time: new Date().toISOString()
};
// If specific match requested, include match details
if (request.match_id) {
try {
var match = nk.matchGet(request.match_id);
if (match) {
stats.match = {
id: match.matchId,
size: match.size,
tick_rate: match.tickRate,
authoritative: match.authoritative
};
}
} catch (e) {
// Match not found
}
}
return JSON.stringify(stats);
} catch (e) {
logger.error("Failed to get server stats: " + e);
throw new Error("Failed to get server stats");
}
}
function rpcAdminGetPlayerList(ctx, logger, nk, payload) {
var request = JSON.parse(payload);
requireAdminOrHost(ctx, nk, request.match_id);
try {
var match = nk.matchGet(request.match_id);
if (!match) {
throw new Error("Match not found");
}
// Get player details
var players = [];
// Note: In actual implementation, you'd need to track presences
// This is a simplified version - adjust based on your match handler
return JSON.stringify({ players: players });
} catch (e) {
logger.error("Failed to get player list: " + e);
throw new Error("Failed to get player list");
}
}
function rpcAdminEndMatch(ctx, logger, nk, payload) {
var request = JSON.parse(payload);
requireAdminOrHost(ctx, nk, request.match_id);
try {
// Signal match to end
nk.matchSignal(request.match_id, JSON.stringify({
action: "end_match",
reason: request.reason || "Ended by admin"
}));
logger.info("Match " + request.match_id + " ended by " + ctx.userId);
return JSON.stringify({ success: true });
} catch (e) {
logger.error("Failed to end match: " + e);
throw new Error("Failed to end match");
}
}
function rpcAdminSetUserRole(ctx, logger, nk, payload) {
var request = JSON.parse(payload);
// Only owner/super-admin can set roles
var callerAccount = nk.accountGetId(ctx.userId);
var callerMetadata = JSON.parse(callerAccount.user.metadata || "{}");
if (callerMetadata.role !== "owner") {
throw new Error("Only owners can modify user roles");
}
var validRoles = ["player", "moderator", "admin"];
if (validRoles.indexOf(request.role) === -1) {
throw new Error("Invalid role");
}
try {
var targetAccount = nk.accountGetId(request.user_id);
var metadata = JSON.parse(targetAccount.user.metadata || "{}");
metadata.role = request.role;
nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata));
logger.info("User " + request.user_id + " role set to " + request.role + " by " + ctx.userId);
return JSON.stringify({ success: true, role: request.role });
} catch (e) {
logger.error("Failed to set user role: " + e);
throw new Error("Failed to set user role");
}
}
// =============================================================================
// Store / Economy 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 {
nk.walletUpdate(ctx.userId, { "gold": 999999 }, {}, true);
logger.info("Admin gold top-up applied for user " + ctx.userId);
return JSON.stringify({ success: true, gold_added: 999999 });
} catch (e) {
logger.error("Top-up failed: " + e);
throw new Error("Top-up failed: " + e);
}
}
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);
}
}
// =============================================================================
// User Profile RPCs
// =============================================================================
function rpcGetUserProfile(ctx, logger, nk, payload) {
var request = JSON.parse(payload || "{}");
var targetUserId = request.user_id || ctx.userId;
try {
var account = nk.accountGetId(targetUserId);
var metadata = JSON.parse(account.user.metadata || "{}");
// Check if banned
if (metadata.banned && targetUserId === ctx.userId) {
// Check if ban expired
if (metadata.ban_expires) {
var expiresAt = new Date(metadata.ban_expires);
if (expiresAt <= new Date()) {
// Ban expired, remove it
delete metadata.banned;
delete metadata.ban_reason;
delete metadata.ban_expires;
nk.accountUpdateId(targetUserId, null, null, null, null, null, null, JSON.stringify(metadata));
} else {
throw new Error("Account banned until " + metadata.ban_expires + ". Reason: " + metadata.ban_reason);
}
} else {
throw new Error("Account permanently banned. Reason: " + metadata.ban_reason);
}
}
return JSON.stringify({
user_id: account.user.id,
username: account.user.username,
display_name: account.user.displayName,
avatar_url: account.user.avatarUrl,
create_time: account.user.createTime,
role: metadata.role || "player"
});
} catch (e) {
throw e;
}
}
function rpcUpdateUserProfile(ctx, logger, nk, payload) {
if (!ctx.userId) {
throw new Error("Not authenticated");
}
var request = JSON.parse(payload);
try {
nk.accountUpdateId(
ctx.userId,
null, // username
request.display_name || null,
null, // timezone
null, // location
null, // lang
request.avatar_url || null,
null // metadata
);
return JSON.stringify({ success: true });
} catch (e) {
logger.error("Failed to update profile: " + e);
throw new Error("Failed to update profile");
}
}
// =============================================================================
// Leaderboard RPCs
// =============================================================================
function rpcGetLeaderboardStats(ctx, logger, nk, payload) {
try {
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) {}
leaderboardData.push({
user_id: record.ownerId,
username: record.username,
display_name: record.displayName || record.username,
avatar_url: metadata.avatar_url || "",
loadout_character: metadata.loadout_character || "Copper",
high_score: record.score || 0,
games_played: metadata.games_played || 0,
games_won: metadata.games_won || 0
});
}
return JSON.stringify({ leaderboard: leaderboardData });
} catch (e) {
logger.error("Failed to get native leaderboard stats: " + e);
// Fallback to storage-based if native fails (e.g. not created yet)
return JSON.stringify({ leaderboard: [] });
}
}
// Any authenticated user can submit their own score (server writes on their behalf)
function rpcSubmitScore(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
try {
var request = JSON.parse(payload || "{}");
var score = parseInt(request.score) || 0;
var account = nk.accountGetId(ctx.userId);
var metadata = {
games_played: request.games_played || 0,
games_won: request.games_won || 0,
avatar_url: account.user.avatarUrl || request.avatar_url || "",
loadout_character: request.loadout_character || "Copper"
};
nk.leaderboardRecordWrite(
"global_high_score",
ctx.userId,
account.user.username,
score,
0,
metadata
);
logger.info("Score submitted for user " + ctx.userId + ": " + score);
return JSON.stringify({ success: true });
} catch (e) {
logger.error("Failed to submit score for " + ctx.userId + ": " + e);
throw new Error("Failed to submit score: " + e);
}
}
// Any authenticated user can trigger a bulk sync (reads all public stats, populates leaderboard)
function rpcSyncLeaderboard(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
try {
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;
var value;
try {
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,
games_played: value.games_played || 0,
games_won: value.games_won || 0,
avatar_url: value.avatar_url || "",
loadout_character: value.loadout_character || ""
};
} else {
userGroup[userId].high_score = Math.max(userGroup[userId].high_score, value.high_score || 0);
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 || [];
for (var i = 0; i < profileObjects.length; i++) {
var obj = profileObjects[i];
var userId = obj.userId;
if (obj.key !== "profile") continue;
var value;
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,
avatar_url: "", loadout_character: ""
};
}
// If the profile has avatar or loadout, merge them. Natively preferred over empty
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,
avatar_url: stats.avatar_url || account.user.avatarUrl || "res://assets/graphics/character_selection/sc_characters/sc_copper.png",
loadout_character: stats.loadout_character || "Copper"
};
nk.leaderboardRecordWrite("global_high_score", uid, account.user.username, stats.high_score, 0, meta);
count++;
} catch (inner) {
debugLogs.push("Error user " + uid + ": " + inner);
logger.error("Failed to sync record for " + uid + ": " + inner);
}
}
logger.info("Synced " + count + " records to leaderboard by user " + ctx.userId);
return JSON.stringify({ success: true, synced: count, objects_found: statsObjects.length, debug: debugLogs });
} catch (e) {
logger.error("Leaderboard sync failed: " + e);
throw new Error("Sync failed: " + e);
}
}
// Change Email / Password securely
function rpcChangeCredentials(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var req = {};
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");
try {
nk.authenticateEmail(account.email, req.current_password, false);
} catch (e) {
throw new Error("Incorrect current password.");
}
nk.unlinkEmail(ctx.userId, account.email, req.current_password);
}
try {
nk.linkEmail(ctx.userId, req.new_email, req.new_password);
} catch (e) {
// Safe rollback
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 });
}
// Reset Game Stats
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) {}
// Wipe storage stats
var zeros = { games_played: 0, games_won: 0, high_score: 0, total_kills: 0, total_deaths: 0 };
nk.storageWrite([{
collection: "stats",
key: "game_stats",
userId: ctx.userId,
value: zeros,
permissionRead: 2,
permissionWrite: 1
}]);
return JSON.stringify({ success: true });
}
// =============================================================================
// Admin User Management RPCs
// =============================================================================
function rpcAdminListUsers(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
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) {}
users.push({
user_id: row.id,
username: row.username || "",
display_name: row.display_name || row.username || "",
create_time: row.create_time,
role: metadata.role || "player",
banned: metadata.banned || false,
ban_reason: metadata.ban_reason || ""
});
}
return JSON.stringify({ users: users, count: users.length });
} catch (e) {
logger.error("Failed to list users: " + e);
throw new Error("Failed to list users: " + e);
}
}
function rpcAdminDeleteUsers(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
var request = JSON.parse(payload);
var userIds = request.user_ids || [];
if (userIds.length === 0) {
throw new Error("No user IDs provided");
}
// Don't allow deleting yourself
for (var i = 0; i < userIds.length; i++) {
if (userIds[i] === ctx.userId) {
throw new Error("Cannot delete your own account");
}
}
var deleted = [];
var failed = [];
for (var j = 0; j < userIds.length; j++) {
var uid = userIds[j];
try {
// 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) {}
if (ADMIN_ROLES.indexOf(meta.role || "") !== -1) {
failed.push({ user_id: uid, reason: "Cannot delete admin account" });
continue;
}
nk.accountDeleteId(uid, false);
deleted.push(uid);
logger.warn("User " + uid + " deleted by " + ctx.userId);
} catch (e) {
failed.push({ user_id: uid, reason: "" + e });
}
}
return JSON.stringify({ success: true, deleted: deleted, failed: failed });
}
function rpcAdminUpdateStats(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
var request = JSON.parse(payload);
var targetUserId = request.user_id;
var stats = request.stats; // { high_score: X, games_played: Y, games_won: Z }
if (!targetUserId || !stats) {
throw new Error("User ID and stats are required");
}
try {
// 1. Update Storage (priority: game_stats)
nk.storageWrite([{
collection: "stats",
key: "game_stats",
userId: targetUserId,
value: JSON.stringify(stats),
permissionRead: 1, // Public read
permissionWrite: 0
}]);
// 2. Update Native Leaderboard
var account = nk.accountGetId(targetUserId);
var score = stats.high_score || 0;
var subscore = 0;
var metadata = {
games_played: stats.games_played || 0,
games_won: stats.games_won || 0,
avatar_url: account.user.avatarUrl || "",
loadout_character: stats.loadout_character || "Copper"
};
nk.leaderboardRecordWrite("global_high_score", targetUserId, account.user.username, score, subscore, metadata);
logger.info("Stats updated for user " + targetUserId + " by admin " + ctx.userId + " (game_stats + Native)");
return JSON.stringify({ success: true });
} catch (e) {
logger.error("Failed to update stats: " + e);
throw new Error("Failed to update stats: " + e);
}
}
function rpcAdminDeleteStats(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
var request = JSON.parse(payload);
var targetUserId = request.user_id;
if (!targetUserId) {
throw new Error("User ID is required");
}
try {
// 1. Delete Storage (Both keys)
nk.storageDelete([
{ collection: "stats", key: "stats", userId: targetUserId },
{ collection: "stats", key: "game_stats", userId: targetUserId }
]);
// 2. Delete Native Leaderboard Record
nk.leaderboardRecordDelete("global_high_score", targetUserId);
logger.info("Stats deleted for user " + targetUserId + " by admin " + ctx.userId);
return JSON.stringify({ success: true });
} catch (e) {
logger.error("Failed to delete stats: " + e);
throw new Error("Failed to delete stats");
}
}
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,
games_played: value.games_played || 0,
games_won: value.games_won || 0,
avatar_url: "",
loadout_character: value.loadout_character || "Copper"
};
} else {
// Merge logic: sum counts, max high score
userGroup[userId].high_score = Math.max(userGroup[userId].high_score, value.high_score || 0);
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) {}
}
}
// 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);
throw new Error("Sync failed: " + e);
}
}
// Before login hook to check ban status
function beforeAuthenticateEmail(ctx, logger, nk, data) {
// Can't check ban before auth, so we check in afterAuthenticate
return data;
}