feat : update backend
This commit is contained in:
@@ -28,10 +28,15 @@ function InitModule(ctx, logger, nk, initializer) {
|
||||
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);
|
||||
|
||||
// Create default native leaderboard
|
||||
// id: "global_high_score", authoritative: true, sort: "desc", operator: "best", reset: None
|
||||
nk.leaderboardCreate("global_high_score", true, "desc", "best", null, {});
|
||||
try { nk.leaderboardCreate("global_high_score", true, "desc", "best", null, {}); } catch(e) {}
|
||||
|
||||
logger.info("Tekton admin module loaded");
|
||||
}
|
||||
@@ -477,6 +482,144 @@ function rpcGetLeaderboardStats(ctx, logger, nk, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 || ""
|
||||
};
|
||||
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 {
|
||||
// obj.value may already be an object or a string depending on Nakama version
|
||||
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 || ""
|
||||
};
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
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 || ""
|
||||
};
|
||||
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
|
||||
// =============================================================================
|
||||
@@ -680,7 +823,7 @@ function rpcAdminSyncLeaderboard(ctx, logger, nk, payload) {
|
||||
avatar_url: stats.avatar_url || account.user.avatarUrl || ""
|
||||
};
|
||||
|
||||
nk.leaderboardRecordWrite("global_high_score", userId, account.user.username, stats.high_score, 0, JSON.stringify(metadata));
|
||||
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);
|
||||
|
||||
@@ -1,699 +0,0 @@
|
||||
/**
|
||||
* Tekton Nakama Server Runtime Module
|
||||
*
|
||||
* This module provides secure admin operations via RPC calls.
|
||||
* Deploy this to your Nakama server's runtime directory.
|
||||
*
|
||||
* For TypeScript modules, compile to JavaScript and place in:
|
||||
* - data/modules/ (for Nakama Docker)
|
||||
* - Or configure in nakama config.yml
|
||||
*/
|
||||
|
||||
// Initialize RPC endpoints
|
||||
function InitModule(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
initializer: nkruntime.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);
|
||||
|
||||
// User management RPCs
|
||||
initializer.registerRpc("get_user_profile", rpcGetUserProfile);
|
||||
initializer.registerRpc("update_user_profile", rpcUpdateUserProfile);
|
||||
|
||||
// Leaderboard RPCs
|
||||
initializer.registerRpc("get_leaderboard_stats", rpcGetLeaderboardStats);
|
||||
|
||||
logger.info("Tekton admin module loaded");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Authorization Helpers
|
||||
// =============================================================================
|
||||
|
||||
interface UserMetadata {
|
||||
role?: string;
|
||||
banned?: boolean;
|
||||
ban_reason?: string;
|
||||
ban_expires?: string;
|
||||
}
|
||||
|
||||
const ADMIN_ROLES = ["admin", "moderator", "owner"];
|
||||
|
||||
function isAdmin(ctx: nkruntime.Context, nk: nkruntime.Nakama): boolean {
|
||||
if (!ctx.userId) return false;
|
||||
|
||||
try {
|
||||
const account = nk.accountGetId(ctx.userId);
|
||||
const metadata = JSON.parse(account.user.metadata || "{}") as UserMetadata;
|
||||
return ADMIN_ROLES.includes(metadata.role || "");
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isMatchHost(
|
||||
ctx: nkruntime.Context,
|
||||
nk: nkruntime.Nakama,
|
||||
matchId: string
|
||||
): boolean {
|
||||
if (!ctx.userId || !matchId) return false;
|
||||
|
||||
try {
|
||||
// Get match state to check host
|
||||
const 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
|
||||
const state = JSON.parse(match.state || "{}");
|
||||
return state.hostUserId === ctx.userId;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function requireAdmin(ctx: nkruntime.Context, nk: nkruntime.Nakama): void {
|
||||
if (!isAdmin(ctx, nk)) {
|
||||
throw new Error("Admin privileges required");
|
||||
}
|
||||
}
|
||||
|
||||
function requireAdminOrHost(
|
||||
ctx: nkruntime.Context,
|
||||
nk: nkruntime.Nakama,
|
||||
matchId: string
|
||||
): void {
|
||||
if (!isAdmin(ctx, nk) && !isMatchHost(ctx, nk, matchId)) {
|
||||
throw new Error("Admin or host privileges required");
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Admin RPCs
|
||||
// =============================================================================
|
||||
|
||||
interface KickPlayerRequest {
|
||||
match_id: string;
|
||||
user_id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function rpcAdminKickPlayer(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
const request = JSON.parse(payload) as KickPlayerRequest;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
interface BanPlayerRequest {
|
||||
user_id: string;
|
||||
reason?: string;
|
||||
duration_hours?: number; // 0 = permanent
|
||||
match_id?: string; // Optional: also kick from current match
|
||||
}
|
||||
|
||||
function rpcAdminBanPlayer(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
const request = JSON.parse(payload) as BanPlayerRequest;
|
||||
|
||||
// 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
|
||||
const targetAccount = nk.accountGetId(request.user_id);
|
||||
const metadata = JSON.parse(targetAccount.user.metadata || "{}") as UserMetadata;
|
||||
|
||||
// Don't allow banning other admins
|
||||
if (ADMIN_ROLES.includes(metadata.role || "")) {
|
||||
throw new Error("Cannot ban an admin");
|
||||
}
|
||||
|
||||
// Set ban in metadata
|
||||
const 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)
|
||||
const 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface UnbanPlayerRequest {
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
function rpcAdminUnbanPlayer(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
const request = JSON.parse(payload) as UnbanPlayerRequest;
|
||||
|
||||
requireAdmin(ctx, nk);
|
||||
|
||||
try {
|
||||
// Get target user's account
|
||||
const targetAccount = nk.accountGetId(request.user_id);
|
||||
const metadata = JSON.parse(targetAccount.user.metadata || "{}") as UserMetadata;
|
||||
|
||||
// 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: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
requireAdmin(ctx, nk);
|
||||
|
||||
try {
|
||||
const result = nk.storageList(
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
"bans",
|
||||
100,
|
||||
""
|
||||
);
|
||||
|
||||
const bans = result.objects?.map(obj => obj.value) || [];
|
||||
|
||||
return JSON.stringify({ bans });
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get ban list: ${e}`);
|
||||
return JSON.stringify({ bans: [] });
|
||||
}
|
||||
}
|
||||
|
||||
interface GetServerStatsRequest {
|
||||
match_id?: string;
|
||||
}
|
||||
|
||||
function rpcAdminGetServerStats(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
const request = JSON.parse(payload || "{}") as GetServerStatsRequest;
|
||||
|
||||
if (request.match_id) {
|
||||
requireAdminOrHost(ctx, nk, request.match_id);
|
||||
} else {
|
||||
requireAdmin(ctx, nk);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get server-wide stats
|
||||
const matches = nk.matchList(100, true, null, null, null, null);
|
||||
const activeMatchCount = matches?.length || 0;
|
||||
|
||||
let totalPlayers = 0;
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
totalPlayers += match.size || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
active_matches: activeMatchCount,
|
||||
total_players: totalPlayers,
|
||||
server_time: new Date().toISOString()
|
||||
};
|
||||
|
||||
// If specific match requested, include match details
|
||||
if (request.match_id) {
|
||||
try {
|
||||
const match = nk.matchGet(request.match_id);
|
||||
if (match) {
|
||||
(stats as any).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");
|
||||
}
|
||||
}
|
||||
|
||||
interface GetPlayerListRequest {
|
||||
match_id: string;
|
||||
}
|
||||
|
||||
function rpcAdminGetPlayerList(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
const request = JSON.parse(payload) as GetPlayerListRequest;
|
||||
|
||||
requireAdminOrHost(ctx, nk, request.match_id);
|
||||
|
||||
try {
|
||||
const match = nk.matchGet(request.match_id);
|
||||
if (!match) {
|
||||
throw new Error("Match not found");
|
||||
}
|
||||
|
||||
// Get player details
|
||||
const players: any[] = [];
|
||||
|
||||
// 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 });
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get player list: ${e}`);
|
||||
throw new Error("Failed to get player list");
|
||||
}
|
||||
}
|
||||
|
||||
interface EndMatchRequest {
|
||||
match_id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function rpcAdminEndMatch(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
const request = JSON.parse(payload) as EndMatchRequest;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
interface SetUserRoleRequest {
|
||||
user_id: string;
|
||||
role: string; // "player", "moderator", "admin"
|
||||
}
|
||||
|
||||
function rpcAdminSetUserRole(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
const request = JSON.parse(payload) as SetUserRoleRequest;
|
||||
|
||||
// Only owner/super-admin can set roles
|
||||
const callerAccount = nk.accountGetId(ctx.userId!);
|
||||
const callerMetadata = JSON.parse(callerAccount.user.metadata || "{}");
|
||||
|
||||
if (callerMetadata.role !== "owner") {
|
||||
throw new Error("Only owners can modify user roles");
|
||||
}
|
||||
|
||||
const validRoles = ["player", "moderator", "admin"];
|
||||
if (!validRoles.includes(request.role)) {
|
||||
throw new Error("Invalid role");
|
||||
}
|
||||
|
||||
try {
|
||||
const targetAccount = nk.accountGetId(request.user_id);
|
||||
const 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");
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Profile RPCs
|
||||
// =============================================================================
|
||||
|
||||
function rpcGetUserProfile(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
const request = JSON.parse(payload || "{}");
|
||||
const targetUserId = request.user_id || ctx.userId;
|
||||
|
||||
try {
|
||||
const account = nk.accountGetId(targetUserId!);
|
||||
const metadata = JSON.parse(account.user.metadata || "{}");
|
||||
|
||||
// Check if banned
|
||||
if (metadata.banned && targetUserId === ctx.userId) {
|
||||
// Check if ban expired
|
||||
if (metadata.ban_expires) {
|
||||
const 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;
|
||||
}
|
||||
}
|
||||
|
||||
interface UpdateProfileRequest {
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
function rpcUpdateUserProfile(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
if (!ctx.userId) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
const request = JSON.parse(payload) as UpdateProfileRequest;
|
||||
|
||||
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: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
try {
|
||||
// Query the "stats" collection to get all user stats
|
||||
// Warning: For a large production game this should be indexed using Nakama's actual Leaderboard system,
|
||||
// but this works for listing all users' custom storage stats.
|
||||
const limit = 100;
|
||||
const result = nk.storageList(null, "stats", limit, "");
|
||||
|
||||
const statsObjects = result.objects || [];
|
||||
const leaderboardData: any[] = [];
|
||||
|
||||
for (const obj of statsObjects) {
|
||||
try {
|
||||
const stats = JSON.parse(obj.value);
|
||||
const userId = obj.userId;
|
||||
|
||||
// Get the user's profile to retrieve their display name
|
||||
let displayName = "Unknown";
|
||||
let avatarUrl = "";
|
||||
try {
|
||||
const account = nk.accountGetId(userId);
|
||||
displayName = account.user.displayName || account.user.username;
|
||||
avatarUrl = account.user.avatarUrl;
|
||||
} catch (e) {
|
||||
// Ignore if account fetch fails
|
||||
}
|
||||
|
||||
leaderboardData.push({
|
||||
user_id: userId,
|
||||
display_name: displayName,
|
||||
avatar_url: avatarUrl,
|
||||
games_played: stats.games_played || 0,
|
||||
games_won: stats.games_won || 0,
|
||||
high_score: stats.high_score || 0
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(`Error parsing stats for object: ${obj.key}`);
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({ leaderboard: leaderboardData });
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get leaderboard stats: ${e}`);
|
||||
throw new Error("Failed to get leaderboard stats");
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Admin User Management RPCs
|
||||
// =============================================================================
|
||||
|
||||
function rpcAdminListUsers(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
requireAdmin(ctx, nk);
|
||||
|
||||
try {
|
||||
const users: any[] = [];
|
||||
let cursor = "";
|
||||
for (let page = 0; page < 10; page++) {
|
||||
const result = nk.usersList("", "", 100, cursor);
|
||||
if (!result || !result.users || result.users.length === 0) break;
|
||||
for (const u of result.users) {
|
||||
let metadata: UserMetadata = {};
|
||||
try { metadata = JSON.parse(u.metadata || "{}"); } catch(e) {}
|
||||
users.push({
|
||||
user_id: u.id,
|
||||
username: u.username || "",
|
||||
display_name: u.displayName || u.username || "",
|
||||
create_time: u.createTime,
|
||||
role: metadata.role || "player",
|
||||
banned: metadata.banned || false,
|
||||
ban_reason: metadata.ban_reason || ""
|
||||
});
|
||||
}
|
||||
if (!result.cursor) break;
|
||||
cursor = result.cursor;
|
||||
}
|
||||
return JSON.stringify({ users, count: users.length });
|
||||
} catch (e) {
|
||||
logger.error(`Failed to list users: ${e}`);
|
||||
throw new Error(`Failed to list users: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface DeleteUsersRequest {
|
||||
user_ids: string[];
|
||||
}
|
||||
|
||||
function rpcAdminDeleteUsers(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
payload: string
|
||||
): string {
|
||||
requireAdmin(ctx, nk);
|
||||
|
||||
const request = JSON.parse(payload) as DeleteUsersRequest;
|
||||
const userIds = request.user_ids || [];
|
||||
|
||||
if (userIds.length === 0) {
|
||||
throw new Error("No user IDs provided");
|
||||
}
|
||||
|
||||
for (const uid of userIds) {
|
||||
if (uid === ctx.userId) {
|
||||
throw new Error("Cannot delete your own account");
|
||||
}
|
||||
}
|
||||
|
||||
const deleted: string[] = [];
|
||||
const failed: { user_id: string; reason: string }[] = [];
|
||||
|
||||
for (const uid of userIds) {
|
||||
try {
|
||||
const account = nk.accountGetId(uid);
|
||||
const meta = JSON.parse(account.user.metadata || "{}") as UserMetadata;
|
||||
if (ADMIN_ROLES.includes(meta.role || "")) {
|
||||
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, failed });
|
||||
}
|
||||
|
||||
// Before login hook to check ban status
|
||||
function beforeAuthenticateEmail(
|
||||
ctx: nkruntime.Context,
|
||||
logger: nkruntime.Logger,
|
||||
nk: nkruntime.Nakama,
|
||||
data: nkruntime.AuthenticateEmailRequest
|
||||
): nkruntime.AuthenticateEmailRequest {
|
||||
// Can't check ban before auth, so we check in afterAuthenticate
|
||||
return data;
|
||||
}
|
||||
Reference in New Issue
Block a user