feat: overhaul the nakama user management, leaderboard, prep for 2.1
This commit is contained in:
+218
-39
@@ -16,6 +16,8 @@ function InitModule(ctx, logger, nk, initializer) {
|
||||
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);
|
||||
@@ -23,6 +25,13 @@ function InitModule(ctx, logger, nk, initializer) {
|
||||
|
||||
// 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);
|
||||
|
||||
// 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, {});
|
||||
|
||||
logger.info("Tekton admin module loaded");
|
||||
}
|
||||
@@ -38,8 +47,14 @@ function isAdmin(ctx, nk) {
|
||||
|
||||
try {
|
||||
var account = nk.accountGetId(ctx.userId);
|
||||
var metadata = JSON.parse(account.user.metadata || "{}");
|
||||
return ADMIN_ROLES.indexOf(metadata.role || "") !== -1;
|
||||
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;
|
||||
}
|
||||
@@ -433,49 +448,213 @@ function rpcUpdateUserProfile(ctx, logger, nk, payload) {
|
||||
|
||||
function rpcGetLeaderboardStats(ctx, logger, nk, payload) {
|
||||
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.
|
||||
var limit = 100;
|
||||
var result = nk.storageList(null, "stats", limit, "");
|
||||
|
||||
var statsObjects = result.objects || [];
|
||||
var limit = 50;
|
||||
var records = nk.leaderboardRecordsList("global_high_score", null, limit, "");
|
||||
var leaderboardData = [];
|
||||
|
||||
for (var i = 0; i < statsObjects.length; i++) {
|
||||
var obj = statsObjects[i];
|
||||
try {
|
||||
var stats = JSON.parse(obj.value);
|
||||
var userId = obj.userId;
|
||||
|
||||
// Get the user's profile to retrieve their display name
|
||||
var displayName = "Unknown";
|
||||
var avatarUrl = "";
|
||||
try {
|
||||
var 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);
|
||||
}
|
||||
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 || "",
|
||||
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 leaderboard stats: " + e);
|
||||
throw new Error("Failed to get leaderboard stats");
|
||||
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: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 (for legacy support/redundancy)
|
||||
nk.storageWrite([{
|
||||
collection: "stats",
|
||||
key: "stats",
|
||||
userId: targetUserId,
|
||||
value: JSON.stringify(stats),
|
||||
permissionRead: 1, // Public read
|
||||
permissionWrite: 0 // No one can write (except server)
|
||||
}]);
|
||||
|
||||
// 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 || ""
|
||||
};
|
||||
|
||||
nk.leaderboardRecordWrite("global_high_score", targetUserId, account.user.username, score, subscore, JSON.stringify(metadata));
|
||||
|
||||
logger.info("Stats updated for user " + targetUserId + " by admin " + ctx.userId + " (Storage + 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
|
||||
nk.storageDelete([{
|
||||
collection: "stats",
|
||||
key: "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 count = 0;
|
||||
|
||||
for (var i = 0; i < statsObjects.length; i++) {
|
||||
var obj = statsObjects[i];
|
||||
var userId = obj.userId;
|
||||
var stats = JSON.parse(obj.value);
|
||||
|
||||
try {
|
||||
var account = nk.accountGetId(userId);
|
||||
var metadata = {
|
||||
games_played: stats.games_played || 0,
|
||||
games_won: stats.games_won || 0,
|
||||
avatar_url: account.user.avatarUrl || ""
|
||||
};
|
||||
|
||||
nk.leaderboardRecordWrite("global_high_score", userId, account.user.username, stats.high_score || 0, 0, JSON.stringify(metadata));
|
||||
count++;
|
||||
} catch (inner) {
|
||||
logger.error("Failed to sync 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ function InitModule(
|
||||
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);
|
||||
@@ -597,6 +599,94 @@ function rpcGetLeaderboardStats(
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user