feat : update backend

This commit is contained in:
2026-04-08 03:12:55 +08:00
parent 7e22f48c57
commit e222cc49ee
11 changed files with 619 additions and 935 deletions
+145 -2
View File
@@ -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);
-699
View File
@@ -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;
}