extends Node # --------------- # Old vs New Name # --------------- # Masbro is Dabro # Bob is Pip # Gatot is Gatot # Oldpop is Copper # --------------- ## UserProfileManager - Manages user profile data with Nakama storage signal profile_loaded(profile: Dictionary) signal profile_updated signal profile_update_failed(error: String) signal avatar_changed(url: String) signal stats_updated # Profile data var profile: Dictionary = {} var stats: Dictionary = {} var wallet: Dictionary = {"gold": 0, "star": 0} var fragments: Dictionary = {} # frag_common, frag_uncommon, frag_rare var inventory: Array = [] var loadout: Dictionary = {"head": "", "costume": "", "glove": "", "accessory": ""} var shop_catalog: Dictionary = {} var featured_banners: Array = [] var is_profile_loaded: bool = false # Nakama storage collection names const PROFILE_COLLECTION := "profiles" const STATS_COLLECTION := "stats" # Available avatars (predefined) const AVATARS := [ "res://assets/graphics/character_selection/sc_characters/sc_pip.png", "res://assets/graphics/character_selection/sc_characters/sc_gatot.png", "res://assets/graphics/character_selection/sc_characters/sc_dabro.png", "res://assets/graphics/character_selection/sc_characters/sc_copper.png" ] func _ready() -> void: # Connect to auth signals if has_node("/root/AuthManager"): var auth := get_node("/root/AuthManager") auth.auth_completed.connect(_on_auth_completed) auth.logged_out.connect(_on_logged_out) func _on_auth_completed(success: bool, _user_data: Dictionary) -> void: if success: await load_profile() func _on_logged_out() -> void: profile = {} stats = {} fragments = {} is_profile_loaded = false # ============================================================================= # Profile Loading # ============================================================================= func load_profile() -> Dictionary: # Reset state first to ensure no old account data carries over profile = {} stats = {} wallet = {"gold": 0, "star": 0} fragments = {} inventory = [] is_profile_loaded = false if not NakamaManager.session: push_error("[UserProfileManager] No session available") return {} # First get basic account info var account = await NakamaManager.client.get_account_async(NakamaManager.session) if account.is_exception(): push_error("[UserProfileManager] Failed to load account") return {} profile = { "user_id": account.user.id, "username": account.user.username, "display_name": account.user.display_name if account.user.display_name else account.user.username, "avatar_url": account.user.avatar_url, "avatar_index": 0, "email": account.email, "created_at": account.user.create_time, "online": account.user.online, "bio": "", "country": "", "language": "en" } # Load custom profile data from storage var storage_result = await NakamaManager.client.read_storage_objects_async( NakamaManager.session, [NakamaStorageObjectId.new(PROFILE_COLLECTION, "profile", account.user.id)] ) if not storage_result.is_exception() and storage_result.objects.size() > 0: var stored_data = JSON.parse_string(storage_result.objects[0].value) if stored_data: profile.merge(stored_data, true) if stored_data.has("loadout"): loadout = stored_data["loadout"] # Parse Wallet if account.wallet: var w_data = JSON.parse_string(account.wallet) if w_data: wallet["gold"] = w_data.get("gold", 0) wallet["star"] = w_data.get("star", 0) # Load Inventory await load_inventory() # Load stats await load_stats() # Load fragments from storage var frag_result = await NakamaManager.client.read_storage_objects_async( NakamaManager.session, [NakamaStorageObjectId.new(PROFILE_COLLECTION, "fragments", account.user.id)] ) if not frag_result.is_exception() and frag_result.objects.size() > 0: var fdata = JSON.parse_string(frag_result.objects[0].value) if fdata is Dictionary: fragments = fdata is_profile_loaded = true emit_signal("profile_loaded", profile) print("[UserProfileManager] Profile loaded: ", profile.get("display_name", "Unknown")) # Auto-sync existing score to native Nakama leaderboard in background if stats.get("high_score", 0) > 0 and NakamaManager.session and not AuthManager.is_guest: submit_to_leaderboard.call_deferred() return profile func load_inventory() -> void: if not NakamaManager.session: return inventory.clear() var result = await NakamaManager.client.list_storage_objects_async( NakamaManager.session, "inventory", NakamaManager.session.user_id, 100 ) if not result.is_exception() and result.objects: for obj in result.objects: if not inventory.has(obj.key): inventory.append(obj.key) func load_stats() -> Dictionary: # Reset stats first to ensure fresh data for new logins stats = {} if not NakamaManager.session: return {} var user_id: String = NakamaManager.session.user_id var storage_result = await NakamaManager.client.read_storage_objects_async( NakamaManager.session, [NakamaStorageObjectId.new(STATS_COLLECTION, "game_stats", user_id)] ) print("[UserProfileManager] Loading stats for user_id: ", user_id) if not storage_result.is_exception() and storage_result.objects.size() > 0: var stored_data = JSON.parse_string(storage_result.objects[0].value) if stored_data: stats = stored_data print("[UserProfileManager] Stats loaded from Nakama: ", stats) else: # Initialize default stats print("[UserProfileManager] No stats found in Nakama, creating defaults") stats = { "games_played": 0, "games_won": 0, "games_lost": 0, "total_score": 0, "high_score": 0, "play_time_minutes": 0 } return stats # ============================================================================= # Profile Updates # ============================================================================= func update_display_name(new_name: String) -> bool: if new_name.strip_edges().is_empty(): emit_signal("profile_update_failed", "Display name cannot be empty") return false if new_name.length() > 6: emit_signal("profile_update_failed", "Display name too long (max 6 characters)") return false var regex = RegEx.new() regex.compile("^[A-Za-z0-9 ]+$") if not regex.search(new_name): emit_signal("profile_update_failed", "Name can only contain letters, numbers, and spaces") return false # Offline fallback (no session) if not NakamaManager.session: profile["display_name"] = new_name emit_signal("profile_updated") return true var formatted_username = new_name.replace(" ", "_").to_lower() var result: NakamaAsyncResult = await NakamaManager.client.update_account_async( NakamaManager.session, formatted_username, # username (sync to display name) new_name # display_name ) if result.is_exception(): emit_signal("profile_update_failed", result.get_exception().message) return false profile["display_name"] = new_name emit_signal("profile_updated") return true func update_avatar(avatar_index: int) -> bool: if avatar_index < 0 or avatar_index >= AVATARS.size(): emit_signal("profile_update_failed", "Invalid avatar index") return false if not NakamaManager.session: emit_signal("profile_update_failed", "Not authenticated") return false # Store avatar in custom profile data profile["avatar_index"] = avatar_index profile["avatar_url"] = AVATARS[avatar_index] # Update native avatar URL for the leaderboard var avatar_url = AVATARS[avatar_index] var result = await NakamaManager.client.update_account_async( NakamaManager.session, null, null, avatar_url ) if result.is_exception(): emit_signal("profile_update_failed", "Failed to update native avatar") return false var success := await _save_profile_data() if success: emit_signal("avatar_changed", avatar_url) emit_signal("profile_updated") # Immediately update leaderboard with new avatar if stats.get("high_score", 0) > 0: submit_to_leaderboard.call_deferred() return success func update_bio(new_bio: String) -> bool: if new_bio.length() > 200: emit_signal("profile_update_failed", "Bio too long (max 200 characters)") return false profile["bio"] = new_bio return await _save_profile_data() func _save_profile_data() -> bool: if not NakamaManager.session: return false var custom_data := { "avatar_index": profile.get("avatar_index", 0), "bio": profile.get("bio", ""), "country": profile.get("country", ""), "language": profile.get("language", "en"), "loadout": loadout } var write_obj := NakamaWriteStorageObject.new( PROFILE_COLLECTION, "profile", 2, # Public read 1, # Owner write JSON.stringify(custom_data), "" # Version (empty = overwrite) ) var result = await NakamaManager.client.write_storage_objects_async( NakamaManager.session, [write_obj] ) if result.is_exception(): emit_signal("profile_update_failed", result.get_exception().message) return false emit_signal("profile_updated") return true func update_loadout(category: String, item_id: String) -> bool: if not loadout.has(category): return false loadout[category] = item_id return await _save_profile_data() func purchase_item(item_id: String) -> String: if not NakamaManager.session: return "Not authenticated" var payload = JSON.stringify({ "item_id": item_id, "quantity": 1, "idempotency_key": str(randi()) + "_" + str(Time.get_ticks_usec()) }) var result = await BackendService.api_rpc_async("purchase_item", payload) if result.get("success", false) == false: var msg = str(result.get("message", "Unknown error")) push_error("[UserProfileManager] Purchase failed: ", msg) return msg var response = result.get("data", {}) if typeof(response) == TYPE_DICTIONARY and response.has("success") and response.success == true: await _reload_wallet() if not inventory.has(item_id): inventory.append(item_id) emit_signal("profile_updated") return "" return "Unknown error" func fetch_shop_catalog() -> void: if not NakamaManager.session: return var result = await BackendService.api_rpc_async("get_shop_catalog", "{}") if result.get("success", false) == false: push_error("[UserProfileManager] Failed to fetch shop catalog: " + str(result.get("message", ""))) return var payload: Dictionary = result.get("data", {}) if payload and payload.has("catalog"): shop_catalog = payload.catalog if payload.has("featured_banners"): var _banners = payload.get("featured_banners", []) featured_banners = _banners if typeof(_banners) == TYPE_ARRAY else [] emit_signal("profile_updated") ## Admin-only: grants a large amount of gold via a server-authoritative RPC. ## The Nakama function requireAdmin() on the server prevents non-admin abuse. func admin_topup_gold() -> bool: if not NakamaManager.session: return false var result = await BackendService.api_rpc_async("admin_topup_gold", "{}") if result.get("success", false) == false: push_error("[UserProfileManager] Topup failed: " + str(result.get("message", ""))) return false await _reload_wallet() return true func buy_currency(package_id: String) -> bool: if not NakamaManager.session: return false var payload = JSON.stringify({ "package_id": package_id, "idempotency_key": str(randi()) + "_" + str(Time.get_ticks_usec()), "receipt": "mock_receipt_for_now", "store_type": "test" }) var result = await BackendService.api_rpc_async("buy_currency", payload) if result.get("success", false) == false: var msg = str(result.get("message", "Unknown error")) if "NotEnoughFunds" in msg: push_error("[UserProfileManager] Failed to buy currency: Not enough funds.") else: push_error("[UserProfileManager] Failed to buy currency: ", msg) return false var response = result.get("data", {}) if typeof(response) == TYPE_DICTIONARY and response.has("status") and response.status == "pending": print("[UserProfileManager] Currency purchase pending verification.") await _reload_wallet() return true func _reload_wallet() -> void: if not NakamaManager.session: return var account = await NakamaManager.client.get_account_async(NakamaManager.session) if account.is_exception(): return if account.wallet: var w_data = JSON.parse_string(account.wallet) if w_data: wallet["gold"] = w_data.get("gold", 0) wallet["star"] = w_data.get("star", 0) emit_signal("profile_updated") func save_wallet() -> void: """Persist wallet deductions and fragment counts to Nakama storage.""" if not NakamaManager.session: print("[UserProfileManager] save_wallet: no session, saved in-memory only.") return var write_objs: Array = [ NakamaWriteStorageObject.new(PROFILE_COLLECTION, "fragments", 1, 1, JSON.stringify(fragments), "") ] var result = await NakamaManager.client.write_storage_objects_async(NakamaManager.session, write_objs) if result.is_exception(): push_warning("[UserProfileManager] save_wallet failed: " + result.get_exception().message) else: print("[UserProfileManager] Fragments saved.") # ============================================================================= # Stats Management # ============================================================================= func update_stats(new_stats: Dictionary) -> bool: stats.merge(new_stats, true) emit_signal("stats_updated") if not NakamaManager.session: return false var write_obj := NakamaWriteStorageObject.new( STATS_COLLECTION, "game_stats", 2, # Public read 1, # Owner write JSON.stringify(stats), "" ) var result = await NakamaManager.client.write_storage_objects_async( NakamaManager.session, [write_obj] ) return not result.is_exception() func record_game_result(won: bool, score: int) -> void: stats["games_played"] = stats.get("games_played", 0) + 1 if won: stats["games_won"] = stats.get("games_won", 0) + 1 else: stats["games_lost"] = stats.get("games_lost", 0) + 1 stats["total_score"] = stats.get("total_score", 0) + score if score > stats.get("high_score", 0): stats["high_score"] = score await update_stats(stats) # Also submit to Nakama native leaderboard so global_high_score is populated await submit_to_leaderboard() func submit_to_leaderboard() -> void: """Submits the current high_score via server RPC (required for authoritative leaderboards).""" if not NakamaManager.session: return # We allow syncing even with 0 score, so the player appears on the leaderboard with their avatar/loadout var payload = JSON.stringify({ "score": int(stats.get("high_score", 0)), "games_played": int(stats.get("games_played", 0)), "games_won": int(stats.get("games_won", 0)), "avatar_url": profile.get("avatar_url", ""), "loadout_character": profile.get("loadout_character", "Copper") }) var result = await BackendService.api_rpc_async("submit_score", payload) if result.get("success", false) == false: push_warning("[UserProfileManager] Leaderboard RPC failed: " + str(result.get("message", ""))) else: print("[UserProfileManager] Leaderboard score submitted: ", stats.get("high_score", 0)) # ============================================================================= # Getters # ============================================================================= func get_display_name(fallback: String = "Guest") -> String: if not is_profile_loaded: # Profile storage (inventory/stats/fragments) may still be loading, but # AuthManager.current_user is populated before auth_completed fires, so the # real name is already available — prefer it over the guest fallback to # avoid a logged-in host being registered as "Guest" when acting quickly. if AuthManager.current_user.has("display_name"): return AuthManager.current_user["display_name"] return fallback return profile.get("display_name", fallback) func get_avatar_url() -> String: var index: int = profile.get("avatar_index", 0) if index >= 0 and index < AVATARS.size(): return AVATARS[index] return AVATARS[0] func get_stats() -> Dictionary: return stats func get_win_rate() -> float: var played: int = stats.get("games_played", 0) if played == 0: return 0.0 return float(stats.get("games_won", 0)) / float(played) * 100.0