/** * Tekton Nakama Server Runtime Module * * 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 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); initializer.registerRpc("admin_clear_global_chat", rpcAdminClearGlobalChat); // User management RPCs initializer.registerRpc("get_user_profile", rpcGetUserProfile); initializer.registerRpc("update_user_profile", rpcUpdateUserProfile); initializer.registerRpc("search_users", rpcSearchUsers); // 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); initializer.registerRpc("send_friend_request", rpcSendFriendRequest); // Daily Rewards RPCs initializer.registerRpc("claim_daily_reward", rpcClaimDailyReward); initializer.registerRpc("get_daily_reward_state", rpcGetDailyRewardState); initializer.registerRpc("set_daily_reward_config", rpcSetDailyRewardConfig); initializer.registerRpc("get_daily_reward_config_admin", rpcGetDailyRewardConfigAdmin); // Inbox System RPCs initializer.registerRpc("admin_send_mail", rpcAdminSendMail); initializer.registerRpc("admin_list_mail", rpcAdminListMail); initializer.registerRpc("admin_update_mail", rpcAdminUpdateMail); initializer.registerRpc("admin_delete_mail_server", rpcAdminDeleteMailServer); initializer.registerRpc("get_mail", rpcGetMail); initializer.registerRpc("claim_mail_reward", rpcClaimMailReward); 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) { } logger.info("Tekton core module loaded"); } // ============================================================================= // Authorization Helpers // ============================================================================= var ADMIN_ROLES = ["admin", "moderator", "owner"]; 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"); } } // ============================================================================= // Admin Wallet RPCs // ============================================================================= 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); } } // ============================================================================= // Admin Clear Global Chat RPC // ============================================================================= function rpcAdminClearGlobalChat(ctx, logger, nk, payload) { requireAdmin(ctx, nk); // Nakama's channel message list uses the channel ID, not the room name. // We need to find the channel ID for "social_global" Room type. // Room channel IDs are deterministic: we can list messages by cursor. var req = JSON.parse(payload || "{}"); var channelId = req.channel_id || ""; if (!channelId) { throw new Error("channel_id is required. Pass the channel ID from the client."); } var deleted = 0; var cursor = ""; try { // Paginate through all messages and remove each one do { var result = nk.channelMessagesList(channelId, 100, false, cursor); var messages = result.messages || []; for (var i = 0; i < messages.length; i++) { try { nk.channelMessageRemove(channelId, messages[i].messageId); deleted++; } catch (e2) { logger.warn("Failed to remove message " + messages[i].messageId + ": " + e2); } } cursor = result.nextCursor || ""; } while (cursor !== ""); logger.info("[AdminClearGlobalChat] Deleted " + deleted + " messages by " + ctx.userId); return JSON.stringify({ success: true, deleted: deleted }); } catch (e) { logger.error("admin_clear_global_chat failed: " + e); throw new Error("Failed to clear global chat: " + e); } } // ============================================================================= // Daily Rewards RPCs // ============================================================================= function rpcClaimDailyReward(ctx, logger, nk, payload) { if (!ctx.userId) throw new Error("Not authenticated"); var now = new Date(); 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) { var val = stateObjs[0].value; state.last_claim_date = val.last_claim_date || ""; state.month = val.month || ""; if (typeof val.claimed_days === 'number') { var arr = []; 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; } else { 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) }); } } 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; nk.walletUpdate(ctx.userId, changes, {}, true); } else if (rewardType.startsWith("frag_")) { var invObjs = nk.storageRead([{ collection: "inventory", key: "fragments", userId: ctx.userId }]); var frags = {}; if (invObjs && invObjs.length > 0) { frags = invObjs[0].value; } frags[rewardType] = (frags[rewardType] || 0) + rewardAmount; nk.storageWrite([{ collection: "inventory", key: "fragments", userId: ctx.userId, value: frags, permissionRead: 1, permissionWrite: 0 }]); } state.claimed_days.push(dayIndex); state.last_claim_date = todayStr; nk.storageWrite([{ collection: "daily_rewards", key: "state", userId: ctx.userId, value: state, permissionRead: 1, permissionWrite: 0 }]); return JSON.stringify({ success: true, reward_type: rewardType, reward_amount: rewardAmount, day: dayIndex + 1 }); } function rpcGetDailyRewardState(ctx, logger, nk, payload) { if (!ctx.userId) throw new Error("Not authenticated"); var now = new Date(); 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) { var val = stateObjs[0].value; state.last_claim_date = val.last_claim_date || ""; state.month = val.month || ""; if (typeof val.claimed_days === 'number') { var arr = []; 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; } else { state.claimed_days = []; } } if (state.month !== currentMonth) { 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) { 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) }); } } return JSON.stringify({ state: state, month_rewards: monthRewards, can_claim_today: state.last_claim_date !== todayStr && state.claimed_days.indexOf(todayIndex) === -1 && todayIndex < monthRewards.length, today_date: todayStr, today_index: todayIndex, server_month: now.getUTCMonth() + 1 }); } function rpcSetDailyRewardConfig(ctx, logger, nk, payload) { requireAdmin(ctx, nk); var request = JSON.parse(payload || "{}"); nk.storageWrite([{ collection: "config", key: "daily_rewards", userId: "00000000-0000-0000-0000-000000000000", value: request.config, permissionRead: 2, permissionWrite: 0 }]); return JSON.stringify({ success: true }); } function rpcGetDailyRewardConfigAdmin(ctx, logger, nk, payload) { requireAdmin(ctx, nk); 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; } return JSON.stringify({ config: config }); } // ============================================================================= // 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"); } } function rpcSearchUsers(ctx, logger, nk, payload) { if (!ctx.userId) { throw new Error("Not authenticated"); } var request = {}; try { request = JSON.parse(payload || "{}"); } catch (e) { } var query = request.query || ""; try { 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) { } users.push({ user_id: row.id, username: row.username || "", display_name: row.display_name || row.username || "", avatar_url: metadata.avatar_url || "" }); } return JSON.stringify({ users: users }); } catch (e) { logger.error("Failed to search users: " + e); return JSON.stringify({ users: [] }); } } // ============================================================================= // 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; } // ============================================================================= // Social / Friend RPCs // ============================================================================= // Sends a real-time notification (code 1002) to the target so their client // can refresh the friends list immediately. The actual friend relationship // is added by the client via add_friends_async BEFORE calling this RPC. function rpcSendFriendRequest(ctx, logger, nk, payload) { if (!ctx.userId) throw new Error("Not authenticated"); var request = {}; try { request = JSON.parse(payload || "{}"); } catch (e) { } var targetUserId = request.user_id || ""; if (!targetUserId) throw new Error("user_id is required"); if (targetUserId === ctx.userId) throw new Error("Cannot add yourself"); var senderAccount = nk.accountGetId(ctx.userId); var senderName = senderAccount.user.displayName || senderAccount.user.username || "Someone"; // Send a persistent notification to target so they see it on next login too nk.notificationSend( targetUserId, "Friend Request", { from_user_id: ctx.userId, from_name: senderName }, 1002, // code: friend request ctx.userId, // sender true // persistent (survives offline) ); logger.info("Friend request notification sent from " + ctx.userId + " to " + targetUserId); return JSON.stringify({ success: true }); } function rpcSendLobbyInvite(ctx, logger, nk, payload) { if (!ctx.userId) throw new Error("Not authenticated"); var request = {}; try { request = JSON.parse(payload || "{}"); } catch (e) { } var toUserId = request.to_user_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"); var senderAccount = nk.accountGetId(ctx.userId); var senderName = senderAccount.user.displayName || senderAccount.user.username || "Someone"; nk.notificationSend( toUserId, "Lobby Invite", { from_name: senderName, match_id: matchId }, 1001, // code: 1001 = lobby invite ctx.userId, false // not persistent ); logger.info("Lobby invite sent from " + ctx.userId + " to " + toUserId + " for match " + matchId); return JSON.stringify({ success: true }); } // ============================================================================= // Inbox System RPCs // ============================================================================= 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", content: request.content || "", sender: "TEKTON DEV TEAM", date: startDate, start_date: startDate, end_date: endDate, 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 }]); var personalMails = []; if (invObjs && invObjs.length > 0) { personalMails = invObjs[0].value.mails || []; } personalMails.push(mailObj); nk.storageWrite([{ collection: "inbox", key: "personal", userId: request.target_user_id, value: { mails: personalMails }, permissionRead: 1, permissionWrite: 0 }]); logger.info("Personal mail sent to " + request.target_user_id); } else { mailObj.type = "global"; var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]); var globalMails = []; if (globalObjs && globalObjs.length > 0) { globalMails = globalObjs[0].value.mails || []; } globalMails.push(mailObj); nk.storageWrite([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000", value: { mails: globalMails }, permissionRead: 2, permissionWrite: 0 }]); 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; state.claimed_ids = val.claimed_ids || []; 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 }); } function rpcClaimMailReward(ctx, logger, nk, payload) { if (!ctx.userId) throw new Error("Not authenticated"); 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; state.claimed_ids = val.claimed_ids || []; 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) { targetMail = allMails[i]; 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") { var fragId = r.id || type; fragsToUpdate[fragId] = (fragsToUpdate[fragId] || 0) + amount; } else if (type === "skin") { 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 = {}; if (invObjs && invObjs.length > 0) { frags = invObjs[0].value; } for (var fId in fragsToUpdate) { frags[fId] = (frags[fId] || 0) + fragsToUpdate[fId]; } nk.storageWrite([{ collection: "inventory", key: "fragments", userId: ctx.userId, value: frags, permissionRead: 1, permissionWrite: 0 }]); } if (skinsToAdd.length > 0) { var skinWrites = []; for (var s = 0; s < skinsToAdd.length; s++) { skinWrites.push({ collection: "inventory", key: skinsToAdd[s], userId: ctx.userId, value: { acquired_via: "mail", purchased_at: new Date().toISOString() }, permissionRead: 1, permissionWrite: 0 }); } 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", userId: ctx.userId, value: state, permissionRead: 1, permissionWrite: 0 }]); return JSON.stringify({ success: true, claimed_ids: state.claimed_ids }); } function rpcDeleteMail(ctx, logger, nk, payload) { if (!ctx.userId) throw new Error("Not authenticated"); 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) { var val = stateObjs[0].value; state.claimed_ids = val.claimed_ids || []; 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", userId: ctx.userId, value: state, permissionRead: 1, permissionWrite: 0 }]); return JSON.stringify({ success: true, deleted_ids: state.deleted_ids }); } function rpcSaveMailState(ctx, logger, nk, payload) { if (!ctx.userId) throw new Error("Not authenticated"); var request = JSON.parse(payload || "{}"); // Load existing state to merge (don't clobber claimed_ids from client) 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; state.claimed_ids = val.claimed_ids || []; state.deleted_ids = val.deleted_ids || []; state.read_ids = val.read_ids || []; } // Merge read_ids from client (add any new ones) var newReadIds = request.read_ids || []; for (var i = 0; i < newReadIds.length; i++) { if (state.read_ids.indexOf(newReadIds[i]) === -1) { state.read_ids.push(newReadIds[i]); } } nk.storageWrite([{ collection: "inbox", key: "state", userId: ctx.userId, value: state, permissionRead: 1, permissionWrite: 0 }]); return JSON.stringify({ success: true }); } // ============================================================================= // Admin Mail Management RPCs // ============================================================================= function rpcAdminListMail(ctx, logger, nk, payload) { requireAdmin(ctx, nk); // --- Global mails --- 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 || []) : []; for (var i = 0; i < globalMails.length; i++) { globalMails[i].type = "global"; } // --- Personal mails: scan ALL users' inbox/personal objects --- var personalMails = []; var cursor = null; try { do { // storageList(userId, collection, limit, cursor) // Using empty string userId means list across all users for this collection var listResult = nk.storageList("", "inbox", 100, cursor); var objects = listResult.objects || []; for (var j = 0; j < objects.length; j++) { var obj = objects[j]; if (obj.key !== "personal") continue; var ownerUserId = obj.userId; var mails = obj.value.mails || []; for (var k = 0; k < mails.length; k++) { var m = mails[k]; m.type = "personal"; m.target_user_id = ownerUserId; personalMails.push(m); } } cursor = listResult.cursor || null; } while (cursor); } catch (e) { logger.warn("admin_list_mail: could not list personal inboxes: " + e); } var allMails = globalMails.concat(personalMails); // Sort newest first allMails.sort(function (a, b) { return (b.date || "").localeCompare(a.date || ""); }); return JSON.stringify({ mails: allMails }); } function rpcAdminUpdateMail(ctx, logger, nk, payload) { requireAdmin(ctx, nk); var request = JSON.parse(payload || "{}"); var mailId = request.mail_id; if (!mailId) throw new Error("mail_id required"); var isGlobal = request.type !== "personal"; var targetUserId = request.target_user_id || ""; var newTargetUserId = request.new_target_user_id; var hasNewTarget = (newTargetUserId !== undefined && newTargetUserId !== null); // Step 1: Find and extract the mail object from its current location var mailObj = null; if (isGlobal) { 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 || []) : []; for (var i = 0; i < globalMails.length; i++) { if (globalMails[i].id === mailId) { mailObj = globalMails.splice(i, 1)[0]; break; } } if (!mailObj) throw new Error("Mail not found in global"); // Write back without this mail (it may move) nk.storageWrite([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000", value: { mails: globalMails }, permissionRead: 2, permissionWrite: 0 }]); } else { if (!targetUserId) throw new Error("target_user_id required for personal mail"); var pObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: targetUserId }]); var personalMails = (pObjs && pObjs.length > 0) ? (pObjs[0].value.mails || []) : []; for (var j = 0; j < personalMails.length; j++) { if (personalMails[j].id === mailId) { mailObj = personalMails.splice(j, 1)[0]; break; } } if (!mailObj) throw new Error("Mail not found in personal inbox"); // Write back without this mail nk.storageWrite([{ collection: "inbox", key: "personal", userId: targetUserId, value: { mails: personalMails }, permissionRead: 1, permissionWrite: 0 }]); } // Step 2: Apply field updates to the mail object if (request.title !== undefined) mailObj.title = request.title; if (request.content !== undefined) mailObj.content = request.content; if (request.end_date !== undefined) mailObj.end_date = request.end_date; if (request.expiry_date !== undefined) mailObj.expiry_date = request.expiry_date; // Step 3: Determine destination var destUserId = hasNewTarget ? newTargetUserId : (isGlobal ? "" : targetUserId); if (destUserId === "") { // Write to global mailObj.type = "global"; var gObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]); var gMails = (gObjs && gObjs.length > 0) ? (gObjs[0].value.mails || []) : []; gMails.push(mailObj); nk.storageWrite([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000", value: { mails: gMails }, permissionRead: 2, permissionWrite: 0 }]); } else { // Write to personal inbox of destUserId mailObj.type = "personal"; var dObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: destUserId }]); var dMails = (dObjs && dObjs.length > 0) ? (dObjs[0].value.mails || []) : []; dMails.push(mailObj); nk.storageWrite([{ collection: "inbox", key: "personal", userId: destUserId, value: { mails: dMails }, permissionRead: 1, permissionWrite: 0 }]); } logger.info("Admin updated mail " + mailId + " by " + ctx.userId + (hasNewTarget ? " (moved to " + destUserId + ")" : "")); return JSON.stringify({ success: true }); } function rpcAdminDeleteMailServer(ctx, logger, nk, payload) { requireAdmin(ctx, nk); var request = JSON.parse(payload || "{}"); var mailId = request.mail_id; if (!mailId) throw new Error("mail_id required"); var isGlobal = request.type !== "personal"; var targetUserId = request.target_user_id || ""; if (isGlobal) { 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; }); if (globalMails.length === before) throw new Error("Mail not found"); nk.storageWrite([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000", value: { mails: globalMails }, permissionRead: 2, permissionWrite: 0 }]); } else { if (!targetUserId) throw new Error("target_user_id required for personal mail"); 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; }); if (personalMails.length === pBefore) throw new Error("Mail not found"); nk.storageWrite([{ collection: "inbox", key: "personal", userId: targetUserId, value: { mails: personalMails }, permissionRead: 1, permissionWrite: 0 }]); } 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: [] }); }