Files
tekton/scripts/managers/user_profile_manager.gd
T

511 lines
15 KiB
GDScript

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:
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