224 lines
7.1 KiB
GDScript
224 lines
7.1 KiB
GDScript
extends Node
|
|
|
|
## Unified interface for backend services
|
|
## All platforms use Nakama for achievements, leaderboards, and shop
|
|
## Steam is only used for authentication (auth session ticket for Nakama login)
|
|
|
|
enum Platform {
|
|
DESKTOP_STEAM,
|
|
DESKTOP_NAKAMA,
|
|
MOBILE_NAKAMA
|
|
}
|
|
|
|
enum ErrorCode {
|
|
NONE,
|
|
NETWORK_ERROR,
|
|
UNAUTHORIZED,
|
|
FORBIDDEN,
|
|
NOT_FOUND,
|
|
INTERNAL_ERROR,
|
|
UNKNOWN_ERROR,
|
|
INSUFFICIENT_FUNDS
|
|
}
|
|
|
|
var current_platform: Platform = Platform.DESKTOP_STEAM
|
|
var steamworks_manager: Node # Only for auth ticket retrieval
|
|
var nakama_backend: Node
|
|
|
|
const MAX_RETRIES = 3
|
|
const RETRY_BACKOFF_BASE = 0.5
|
|
|
|
# Error mapping based on HTTP status codes returned by Nakama
|
|
var error_map := {
|
|
401: ErrorCode.UNAUTHORIZED,
|
|
403: ErrorCode.FORBIDDEN,
|
|
404: ErrorCode.NOT_FOUND,
|
|
500: ErrorCode.INTERNAL_ERROR,
|
|
}
|
|
|
|
func _ready() -> void:
|
|
_detect_platform()
|
|
_initialize_backend()
|
|
|
|
func _detect_platform() -> void:
|
|
if OS.has_feature("android") or OS.has_feature("ios"):
|
|
current_platform = Platform.MOBILE_NAKAMA
|
|
else:
|
|
if ClassDB.class_exists("Steam"):
|
|
current_platform = Platform.DESKTOP_STEAM
|
|
else:
|
|
current_platform = Platform.DESKTOP_NAKAMA
|
|
|
|
func _initialize_backend() -> void:
|
|
if current_platform == Platform.DESKTOP_STEAM:
|
|
_initialize_steamworks_for_auth()
|
|
|
|
_initialize_nakama()
|
|
|
|
func _initialize_steamworks_for_auth() -> void:
|
|
var steamworks_script = load("res://scripts/services/steamworks_manager.gd")
|
|
if steamworks_script:
|
|
steamworks_manager = steamworks_script.new()
|
|
add_child(steamworks_manager)
|
|
print("BackendService: Initialized Steamworks for auth only")
|
|
else:
|
|
push_error("BackendService: Failed to load Steamworks manager")
|
|
|
|
func _initialize_nakama() -> void:
|
|
nakama_backend = NakamaManager
|
|
if nakama_backend:
|
|
_connect_nakama_signals()
|
|
print("BackendService: Initialized Nakama backend")
|
|
else:
|
|
push_error("BackendService: NakamaManager not found")
|
|
|
|
func _connect_nakama_signals() -> void:
|
|
pass
|
|
|
|
func is_initialized() -> bool:
|
|
if nakama_backend != null:
|
|
return true
|
|
return false
|
|
|
|
func get_platform_name() -> String:
|
|
return Platform.keys()[current_platform]
|
|
|
|
func get_steamworks_manager() -> Node:
|
|
return steamworks_manager
|
|
|
|
## Unified RPC with Retry and Error Mapping
|
|
func api_rpc_async(rpc_id: String, payload: String = "{}") -> Dictionary:
|
|
if not nakama_backend or not nakama_backend.client or not nakama_backend.session:
|
|
return { "success": false, "error": ErrorCode.UNAUTHORIZED, "message": "Not authenticated" }
|
|
|
|
var retries := 0
|
|
while retries <= MAX_RETRIES:
|
|
var result = await nakama_backend.client.rpc_async(nakama_backend.session, rpc_id, payload)
|
|
|
|
# NakamaAPI.ApiRpc has is_exception()
|
|
if result.is_exception():
|
|
var ex = result.get_exception()
|
|
# Transient network error or internal server logic error (500)
|
|
# Typically Nakama exception status returns HTTP matching codes or grpc codes
|
|
if retries < MAX_RETRIES and _is_transient_error(ex):
|
|
retries += 1
|
|
await get_tree().create_timer(RETRY_BACKOFF_BASE * pow(2.0, retries - 1)).timeout
|
|
continue
|
|
|
|
var parsed_msg := _parse_error_msg(ex.message)
|
|
var err_code = ErrorCode.UNKNOWN_ERROR
|
|
if error_map.has(ex.status_code):
|
|
err_code = error_map[ex.status_code]
|
|
else:
|
|
if "insufficient funds" in ex.message.to_lower():
|
|
err_code = ErrorCode.INSUFFICIENT_FUNDS
|
|
|
|
return {
|
|
"success": false,
|
|
"error": err_code,
|
|
"message": parsed_msg,
|
|
"raw_exception": ex
|
|
}
|
|
|
|
# Success
|
|
return {
|
|
"success": true,
|
|
"payload": result.payload
|
|
}
|
|
|
|
return { "success": false, "error": ErrorCode.NETWORK_ERROR, "message": "Max retries exceeded" }
|
|
|
|
func _is_transient_error(ex) -> bool:
|
|
# Retry on network failures (usually status 0 or 5xx)
|
|
if ex.status_code == 0 or ex.status_code >= 500:
|
|
return true
|
|
return false
|
|
|
|
func _parse_error_msg(msg: String) -> String:
|
|
# Parse JSON error from Nakama if any
|
|
if msg.begins_with("{"):
|
|
var test_json_conv = JSON.new()
|
|
if test_json_conv.parse(msg) == OK:
|
|
var parsed = test_json_conv.get_data()
|
|
if parsed.has("message"):
|
|
return parsed["message"]
|
|
return msg
|
|
|
|
## Typed Method Signatures
|
|
|
|
func admin_clear_global_chat(payload: String) -> Dictionary:
|
|
return await api_rpc_async("admin_clear_global_chat", payload)
|
|
|
|
func send_friend_request(target_id: String) -> Dictionary:
|
|
var payload = JSON.stringify({"target_user_id": target_id})
|
|
return await api_rpc_async("send_friend_request", payload)
|
|
|
|
func respond_friend_request(target_id: String, accept: bool) -> Dictionary:
|
|
var payload = JSON.stringify({"target_user_id": target_id, "accept": accept})
|
|
return await api_rpc_async("respond_friend_request", payload)
|
|
|
|
func perform_gacha_pull(gacha_id: String, count: int) -> Dictionary:
|
|
var payload = JSON.stringify({"gacha_id": gacha_id, "count": count})
|
|
return await api_rpc_async("perform_gacha_pull", payload)
|
|
|
|
func get_mail(payload: String = "{}") -> Dictionary:
|
|
return await api_rpc_async("get_mail", payload)
|
|
|
|
func claim_mail_reward(mail_id: String) -> Dictionary:
|
|
return await api_rpc_async("claim_mail_reward", JSON.stringify({"mail_id": mail_id}))
|
|
|
|
func delete_mail(mail_id: String) -> Dictionary:
|
|
return await api_rpc_async("delete_mail", JSON.stringify({"mail_id": mail_id}))
|
|
|
|
func send_mail(payload: String) -> Dictionary:
|
|
return await api_rpc_async("send_mail", payload)
|
|
|
|
func change_avatar(avatar_url: String) -> Dictionary:
|
|
return await api_rpc_async("change_avatar", JSON.stringify({"avatar_url": avatar_url}))
|
|
|
|
func change_username(new_username: String) -> Dictionary:
|
|
return await api_rpc_async("change_username", JSON.stringify({"new_username": new_username}))
|
|
|
|
func change_status(new_status: String) -> Dictionary:
|
|
return await api_rpc_async("change_status", JSON.stringify({"new_status": new_status}))
|
|
|
|
func change_bio(new_bio: String) -> Dictionary:
|
|
return await api_rpc_async("change_bio", JSON.stringify({"new_bio": new_bio}))
|
|
|
|
func query_users(payload: String) -> Dictionary:
|
|
return await api_rpc_async("query_users", payload)
|
|
|
|
func admin_give_currency(payload: String) -> Dictionary:
|
|
return await api_rpc_async("admin_give_currency", payload)
|
|
|
|
func get_daily_reward_config_admin() -> Dictionary:
|
|
return await api_rpc_async("get_daily_reward_config_admin", "{}")
|
|
|
|
func set_daily_reward_config(req: Dictionary) -> Dictionary:
|
|
return await api_rpc_async("set_daily_reward_config", JSON.stringify(req))
|
|
|
|
func get_daily_reward_state() -> Dictionary:
|
|
return await api_rpc_async("get_daily_reward_state", "{}")
|
|
|
|
func claim_daily_reward() -> Dictionary:
|
|
return await api_rpc_async("claim_daily_reward", "{}")
|
|
|
|
func sync_leaderboard() -> Dictionary:
|
|
return await api_rpc_async("sync_leaderboard", "{}")
|
|
|
|
func get_leaderboard_stats() -> Dictionary:
|
|
return await api_rpc_async("get_leaderboard_stats", "{}")
|
|
|
|
func debug_add_exp(exp_amount: int) -> Dictionary:
|
|
return await api_rpc_async("debug_add_exp", JSON.stringify({"exp": exp_amount}))
|
|
|
|
func reset_stats() -> Dictionary:
|
|
return await api_rpc_async("reset_stats", "{}")
|
|
|
|
func search_users(payload: String) -> Dictionary:
|
|
return await api_rpc_async("search_users", payload)
|
|
|
|
func send_lobby_invite(to_user_id: String, match_id: String) -> Dictionary:
|
|
var payload = JSON.stringify({"to_user_id": to_user_id, "match_id": match_id})
|
|
return await api_rpc_async("send_lobby_invite", payload)
|