diff --git a/scenes/lobby.gd b/scenes/lobby.gd index 1d1aec6..400a5f2 100644 --- a/scenes/lobby.gd +++ b/scenes/lobby.gd @@ -12,6 +12,9 @@ extends Control @onready var server_option = $MainMenuPanel/VBoxContainer/ServerSelectionSection/ServerOption @onready var server_ip_input = $MainMenuPanel/VBoxContainer/ServerSelectionSection/ServerIPInput +# Leaderboard Reference +@onready var leaderboard_btn = $MainMenuPanel/VBoxContainer/ButtonSection/LeaderboardBtn + # UI References - Room List @onready var room_list_panel = $RoomListPanel @onready var room_list = $RoomListPanel/VBoxContainer/RoomList @@ -42,9 +45,23 @@ extends Control @onready var enable_timer_label = $LobbyPanel/TopBar/SettingsSection/EnableTimerLabel @onready var scarcity_option = $LobbyPanel/TopBar/SettingsSection/ScarcityOption @onready var scarcity_label = $LobbyPanel/TopBar/SettingsSection/ScarcityLabel +@onready var scarcity_spacer = $LobbyPanel/TopBar/SettingsSection/ScarcitySpacer +@onready var spawn_spacer = $LobbyPanel/TopBar/SettingsSection/SpawnSpacer +@onready var timer_spacer = $LobbyPanel/TopBar/SettingsSection/TimerSpacer @onready var game_mode_option = $LobbyPanel/TopBar/SettingsSection/GameModeOption @onready var game_mode_text_label = $LobbyPanel/TopBar/SettingsSection/GameModeTextLabel +# Custom Settings Containers +var sng_settings_container: HBoxContainer +var sng_go_option: OptionButton +var sng_stop_option: OptionButton +var sng_goals_option: OptionButton + +var doors_settings_container: HBoxContainer +var doors_swap_option: OptionButton +var doors_refresh_option: OptionButton +var doors_goals_option: OptionButton + # UI References - Player Slots @onready var players_container = $LobbyPanel/PlayersContainer @onready var players_container2 = $LobbyPanel/PlayersContainer2 @@ -75,6 +92,8 @@ var admin_panel_instance: Control # Store current match ID for copy function var current_match_id: String = "" +var leaderboard_panel_instance: Control + # Server Selection Controls (Now in tscn) # var server_option: OptionButton # var server_ip_input: LineEdit @@ -92,6 +111,9 @@ func _ready(): # Get player slot references _setup_player_slots() + # Setup Game Mode specific UI dynamically + _create_custom_settings_ui() + # Set player name from profile and configure input visibility if player_name_input: # Get the parent container for the input to hide/show properly @@ -118,6 +140,8 @@ func _ready(): main_menu_profile_btn.pressed.connect(_on_profile_btn_pressed) if lobby_settings_btn: lobby_settings_btn.pressed.connect(_on_settings_pressed) + if leaderboard_btn: + leaderboard_btn.pressed.connect(_on_leaderboard_pressed) # Connect Server Selection signals if server_option: @@ -176,6 +200,13 @@ func _ready(): LobbyManager.game_mode_changed.connect(_on_game_mode_changed) LobbyManager.player_list_changed.connect(_update_player_slots) + LobbyManager.sng_go_duration_changed.connect(_on_sng_update) + LobbyManager.sng_stop_duration_changed.connect(_on_sng_update) + LobbyManager.sng_required_goals_changed.connect(_on_sng_update) + LobbyManager.doors_swap_time_changed.connect(_on_doors_update) + LobbyManager.doors_refresh_time_changed.connect(_on_doors_update) + LobbyManager.doors_required_goals_changed.connect(_on_doors_update) + # Connect NakamaManager signals NakamaManager.connected_to_nakama.connect(_on_connected_to_nakama) NakamaManager.connection_failed.connect(_on_connection_failed) @@ -331,6 +362,116 @@ func _on_back_pressed() -> void: _show_panel("main_menu") connection_status.text = "" +func _update_settings_visibility() -> void: + var is_host = LobbyManager.is_host + var is_freemode = LobbyManager.game_mode == "Freemode" + + # Duration + var show_duration = is_freemode + duration_option.visible = is_host and show_duration + duration_text_label.visible = not is_host and show_duration + $LobbyPanel/TopBar/SettingsSection/DurationLabel.visible = show_duration + + # Random Spawn + var show_spawn = is_freemode + random_spawn_check.visible = is_host and show_spawn + random_spawn_label.visible = not is_host and show_spawn + if spawn_spacer: spawn_spacer.visible = show_spawn + + # Timer + var show_timer = is_freemode + enable_timer_check.visible = is_host and show_timer + enable_timer_label.visible = not is_host and show_timer + if timer_spacer: timer_spacer.visible = show_timer + + # Scarcity + var show_scarcity = is_freemode + if scarcity_option: scarcity_option.visible = is_host and show_scarcity + if scarcity_label: scarcity_label.visible = not is_host and show_scarcity + if scarcity_spacer: scarcity_spacer.visible = show_scarcity + + # Custom mode sets + var is_sng = LobbyManager.game_mode == "Stop n Go" + if sng_settings_container: + sng_settings_container.visible = is_sng + sng_go_option.disabled = not is_host + sng_stop_option.disabled = not is_host + sng_goals_option.disabled = not is_host + + var is_doors = LobbyManager.game_mode == "Tekton Doors" + if doors_settings_container: + doors_settings_container.visible = is_doors + doors_swap_option.disabled = not is_host + doors_refresh_option.disabled = not is_host + doors_goals_option.disabled = not is_host + +func _create_custom_settings_ui() -> void: + var settings_section = $LobbyPanel/TopBar/SettingsSection + if not settings_section: return + + # Stop n Go + sng_settings_container = HBoxContainer.new() + sng_settings_container.visible = false + settings_section.add_child(sng_settings_container) + + _add_label(sng_settings_container, "Go Time:") + sng_go_option = OptionButton.new() + sng_go_option.add_item("10s"); sng_go_option.add_item("15s"); sng_go_option.add_item("25s") + sng_go_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_sng_go_duration([10, 15, 25][idx])) + sng_settings_container.add_child(sng_go_option) + + _add_label(sng_settings_container, "Stop Time:") + sng_stop_option = OptionButton.new() + sng_stop_option.add_item("3s"); sng_stop_option.add_item("4s"); sng_stop_option.add_item("5s") + sng_stop_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_sng_stop_duration([3, 4, 5][idx])) + sng_settings_container.add_child(sng_stop_option) + + _add_label(sng_settings_container, "Req Goals:") + sng_goals_option = OptionButton.new() + sng_goals_option.add_item("5"); sng_goals_option.add_item("8"); sng_goals_option.add_item("12") + sng_goals_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_sng_required_goals([5, 8, 12][idx])) + sng_settings_container.add_child(sng_goals_option) + + # Tekton Doors + doors_settings_container = HBoxContainer.new() + doors_settings_container.visible = false + settings_section.add_child(doors_settings_container) + + _add_label(doors_settings_container, "Swap Wait:") + doors_swap_option = OptionButton.new() + doors_swap_option.add_item("10s"); doors_swap_option.add_item("15s"); doors_swap_option.add_item("30s") + doors_swap_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_doors_swap_time([10, 15, 30][idx])) + doors_settings_container.add_child(doors_swap_option) + + _add_label(doors_settings_container, "Tile Refresh:") + doors_refresh_option = OptionButton.new() + doors_refresh_option.add_item("15s"); doors_refresh_option.add_item("25s"); doors_refresh_option.add_item("40s") + doors_refresh_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_doors_refresh_time([15, 25, 40][idx])) + doors_settings_container.add_child(doors_refresh_option) + + _add_label(doors_settings_container, "Req Goals:") + doors_goals_option = OptionButton.new() + doors_goals_option.add_item("5"); doors_goals_option.add_item("8"); doors_goals_option.add_item("12") + doors_goals_option.item_selected.connect(func(idx): if LobbyManager.is_host: LobbyManager.set_doors_required_goals([5, 8, 12][idx])) + doors_settings_container.add_child(doors_goals_option) + + # Move Game Mode selector to the far right + var gm_spacer = settings_section.get_node_or_null("GameModeSpacer") + var gm_option = settings_section.get_node_or_null("GameModeOption") + var gm_label = settings_section.get_node_or_null("GameModeTextLabel") + + if gm_spacer: settings_section.move_child(gm_spacer, -1) + if gm_option: settings_section.move_child(gm_option, -1) + if gm_label: settings_section.move_child(gm_label, -1) + +func _add_label(parent: Control, text: String): + var spacer = Control.new() + spacer.custom_minimum_size = Vector2(10, 0) + parent.add_child(spacer) + var lbl = Label.new() + lbl.text = text + parent.add_child(lbl) + # ============================================================================= # Lobby Button Handlers # ============================================================================= @@ -393,7 +534,32 @@ func _on_game_mode_changed(mode: String) -> void: break if game_mode_text_label: game_mode_text_label.text = mode + + _update_settings_visibility() +func _on_sng_update(_val: int = 0) -> void: + if not sng_go_option: return + + var go_idx = [10, 15, 25].find(LobbyManager.sng_go_duration) + if go_idx != -1: sng_go_option.selected = go_idx + + var stop_idx = [3, 4, 5].find(LobbyManager.sng_stop_duration) + if stop_idx != -1: sng_stop_option.selected = stop_idx + + var goals_idx = [5, 8, 12].find(LobbyManager.sng_required_goals) + if goals_idx != -1: sng_goals_option.selected = goals_idx + +func _on_doors_update(_val: int = 0) -> void: + if not doors_swap_option: return + + var swap_idx = [10, 15, 30].find(LobbyManager.doors_swap_time) + if swap_idx != -1: doors_swap_option.selected = swap_idx + + var refresh_idx = [15, 25, 40].find(LobbyManager.doors_refresh_time) + if refresh_idx != -1: doors_refresh_option.selected = refresh_idx + + var goals_idx = [5, 8, 12].find(LobbyManager.doors_required_goals) + if goals_idx != -1: doors_goals_option.selected = goals_idx func _update_random_spawn_label(enabled: bool) -> void: if random_spawn_label: @@ -431,6 +597,18 @@ func _on_settings_pressed(): if settings_menu: settings_menu.open() +func _on_leaderboard_pressed() -> void: + if not leaderboard_panel_instance: + var leaderboard_panel_scene := load("res://scenes/ui/leaderboard_panel.tscn") + if leaderboard_panel_scene: + leaderboard_panel_instance = leaderboard_panel_scene.instantiate() + leaderboard_panel_instance.closed.connect(func(): leaderboard_panel_instance.hide()) + add_child(leaderboard_panel_instance) + + if leaderboard_panel_instance: + leaderboard_panel_instance.show_panel() + # Center it and apply some offset if needed + func _go_to_login() -> void: if get_tree(): get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn") @@ -463,15 +641,7 @@ func _on_room_joined(room_data: Dictionary) -> void: host_banner.visible = is_host start_game_btn.visible = is_host - # Duration: host sees dropdown, clients see text - duration_option.visible = is_host - duration_text_label.visible = not is_host - - random_spawn_check.visible = is_host - random_spawn_label.visible = not is_host - - enable_timer_check.visible = is_host - enable_timer_label.visible = not is_host + _update_settings_visibility() # Update values from LobbyManager _on_match_duration_changed(LobbyManager.match_duration) @@ -479,10 +649,12 @@ func _on_room_joined(room_data: Dictionary) -> void: _on_enable_cycle_timer_changed(LobbyManager.enable_cycle_timer) # Scarcity Update - if scarcity_option: scarcity_option.visible = is_host - if scarcity_label: scarcity_label.visible = not is_host _on_scarcity_mode_changed(LobbyManager.scarcity_mode) + # Initial UI sync for custom modes + _on_sng_update() + _on_doors_update() + # Area selector: only host can interact area_left_btn.disabled = not is_host area_right_btn.disabled = not is_host diff --git a/scenes/lobby.tscn b/scenes/lobby.tscn index 887f086..3801b3d 100644 --- a/scenes/lobby.tscn +++ b/scenes/lobby.tscn @@ -138,6 +138,12 @@ layout_mode = 2 theme_override_font_sizes/font_size = 16 text = "BROWSE ROOMS" +[node name="LeaderboardBtn" type="Button" parent="MainMenuPanel/VBoxContainer/ButtonSection"] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "LEADERBOARD" + [node name="SettingsBtn" type="Button" parent="MainMenuPanel/VBoxContainer/ButtonSection" unique_id=123456789] custom_minimum_size = Vector2(0, 48) layout_mode = 2 diff --git a/scenes/ui/leaderboard_panel.tscn b/scenes/ui/leaderboard_panel.tscn new file mode 100644 index 0000000..1e62c2f --- /dev/null +++ b/scenes/ui/leaderboard_panel.tscn @@ -0,0 +1,84 @@ +[gd_scene format=3 uid="uid://c3q4w3j6e7y8u"] + +[ext_resource type="Script" uid="uid://d1e2f3g4h5i6j" path="res://scripts/ui/leaderboard_panel.gd" id="1"] +[ext_resource type="Theme" uid="uid://da337sh5qxi0s" path="res://assets/themes/ui_theme.tres" id="2"] + +[node name="LeaderboardPanel" type="PanelContainer"] +custom_minimum_size = Vector2(500, 400) +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -250.0 +offset_top = -200.0 +offset_right = 250.0 +offset_bottom = 200.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = ExtResource("2") +script = ExtResource("1") + +[node name="VBox" type="VBoxContainer" parent="."] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Header" type="HBoxContainer" parent="VBox"] +layout_mode = 2 + +[node name="Title" type="Label" parent="VBox/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.647, 0.996, 0.224, 1) +theme_override_font_sizes/font_size = 24 +text = "LEADERBOARD" +horizontal_alignment = 1 + +[node name="CloseBtn" type="Button" parent="VBox/Header"] +unique_name_in_owner = true +custom_minimum_size = Vector2(32, 32) +layout_mode = 2 +text = "X" + +[node name="SortTabs" type="HBoxContainer" parent="VBox"] +layout_mode = 2 +theme_override_constants/separation = 8 +alignment = 1 + +[node name="SortScoreBtn" type="Button" parent="VBox/SortTabs"] +unique_name_in_owner = true +custom_minimum_size = Vector2(120, 36) +layout_mode = 2 +text = "High Score" + +[node name="SortWinRateBtn" type="Button" parent="VBox/SortTabs"] +unique_name_in_owner = true +custom_minimum_size = Vector2(120, 36) +layout_mode = 2 +text = "Win Rate" + +[node name="SortGamesBtn" type="Button" parent="VBox/SortTabs"] +unique_name_in_owner = true +custom_minimum_size = Vector2(120, 36) +layout_mode = 2 +text = "Games Played" + +[node name="HSeparator" type="HSeparator" parent="VBox"] +layout_mode = 2 + +[node name="ScrollContainer" type="ScrollContainer" parent="VBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="LeaderboardList" type="VBoxContainer" parent="VBox/ScrollContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 + +[node name="StatusLabel" type="Label" parent="VBox"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.69, 0.529, 0.357, 1) +text = "Loading data..." +horizontal_alignment = 1 diff --git a/scenes/ui/login_screen.tscn b/scenes/ui/login_screen.tscn index b1fc571..07b621d 100644 --- a/scenes/ui/login_screen.tscn +++ b/scenes/ui/login_screen.tscn @@ -208,6 +208,24 @@ theme_override_font_sizes/font_size = 11 text = "Min 8 characters, include number and symbol" horizontal_alignment = 1 +[node name="RegCaptchaContainer" type="VBoxContainer" parent="CenterContainer/RegistrationPanel/VBox"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="RegCaptchaQuestion" type="Label" parent="CenterContainer/RegistrationPanel/VBox/RegCaptchaContainer"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.647, 0.996, 0.224, 1) +text = "Security Check: 5 + 7 = ?" +horizontal_alignment = 1 + +[node name="RegCaptchaInput" type="LineEdit" parent="CenterContainer/RegistrationPanel/VBox/RegCaptchaContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 44) +layout_mode = 2 +placeholder_text = "Answer" +alignment = 1 + [node name="RegisterButton" type="Button" parent="CenterContainer/RegistrationPanel/VBox"] unique_name_in_owner = true custom_minimum_size = Vector2(0, 48) diff --git a/scripts/managers/lobby_manager.gd b/scripts/managers/lobby_manager.gd index 08a9d74..674aad3 100644 --- a/scripts/managers/lobby_manager.gd +++ b/scripts/managers/lobby_manager.gd @@ -19,6 +19,16 @@ signal character_changed(player_id: int, character_name: String) signal area_changed(area_name: String) signal player_list_changed() +# Stop N Go settings signals +signal sng_go_duration_changed(duration: int) +signal sng_stop_duration_changed(duration: int) +signal sng_required_goals_changed(goals: int) + +# Tekton Doors settings signals +signal doors_swap_time_changed(time: int) +signal doors_refresh_time_changed(time: int) +signal doors_required_goals_changed(goals: int) + # Room data structure var current_room: Dictionary = {} var players_in_room: Array = [] # [{id, name, is_ready}] @@ -40,6 +50,16 @@ signal enable_cycle_timer_changed(enabled: bool) var scarcity_mode: String = "Normal" # Normal, Aggressive, Chaos signal scarcity_mode_changed(mode: String) +# Stop N Go settings +var sng_go_duration: int = 15 +var sng_stop_duration: int = 4 +var sng_required_goals: int = 8 + +# Tekton Doors settings +var doors_swap_time: int = 15 +var doors_refresh_time: int = 25 +var doors_required_goals: int = 8 + # Character and area selection var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"] var available_areas: Array[String] = [] @@ -259,6 +279,68 @@ func sync_scarcity_mode(mode: String) -> void: func get_scarcity_mode() -> String: return scarcity_mode +# ============================================================================= +# Stop N Go Settings +# ============================================================================= + +func set_sng_go_duration(duration: int) -> void: + sng_go_duration = duration + if is_host: rpc("sync_sng_go_duration", duration) + +@rpc("authority", "call_local", "reliable") +func sync_sng_go_duration(duration: int) -> void: + sng_go_duration = duration + emit_signal("sng_go_duration_changed", duration) + +func set_sng_stop_duration(duration: int) -> void: + sng_stop_duration = duration + if is_host: rpc("sync_sng_stop_duration", duration) + +@rpc("authority", "call_local", "reliable") +func sync_sng_stop_duration(duration: int) -> void: + sng_stop_duration = duration + emit_signal("sng_stop_duration_changed", duration) + +func set_sng_required_goals(goals: int) -> void: + sng_required_goals = goals + if is_host: rpc("sync_sng_required_goals", goals) + +@rpc("authority", "call_local", "reliable") +func sync_sng_required_goals(goals: int) -> void: + sng_required_goals = goals + emit_signal("sng_required_goals_changed", goals) + +# ============================================================================= +# Tekton Doors Settings +# ============================================================================= + +func set_doors_swap_time(time: int) -> void: + doors_swap_time = time + if is_host: rpc("sync_doors_swap_time", time) + +@rpc("authority", "call_local", "reliable") +func sync_doors_swap_time(time: int) -> void: + doors_swap_time = time + emit_signal("doors_swap_time_changed", time) + +func set_doors_refresh_time(time: int) -> void: + doors_refresh_time = time + if is_host: rpc("sync_doors_refresh_time", time) + +@rpc("authority", "call_local", "reliable") +func sync_doors_refresh_time(time: int) -> void: + doors_refresh_time = time + emit_signal("doors_refresh_time_changed", time) + +func set_doors_required_goals(goals: int) -> void: + doors_required_goals = goals + if is_host: rpc("sync_doors_required_goals", goals) + +@rpc("authority", "call_local", "reliable") +func sync_doors_required_goals(goals: int) -> void: + doors_required_goals = goals + emit_signal("doors_required_goals_changed", goals) + # ============================================================================= # Character Selection # ============================================================================= @@ -430,6 +512,13 @@ func start_game() -> void: rpc("sync_enable_cycle_timer", enable_cycle_timer) # Sync scarcity mode rpc("sync_scarcity_mode", scarcity_mode) + # Sync game mode features + rpc("sync_sng_go_duration", sng_go_duration) + rpc("sync_sng_stop_duration", sng_stop_duration) + rpc("sync_sng_required_goals", sng_required_goals) + rpc("sync_doors_swap_time", doors_swap_time) + rpc("sync_doors_refresh_time", doors_refresh_time) + rpc("sync_doors_required_goals", doors_required_goals) # Sync game mode rpc("sync_game_mode", game_mode) @@ -492,6 +581,12 @@ func request_room_info(requester_id: int, requester_name: String, requester_char rpc_id(requester_id, "sync_randomize_spawn", randomize_spawn) rpc_id(requester_id, "sync_enable_cycle_timer", enable_cycle_timer) rpc_id(requester_id, "sync_scarcity_mode", scarcity_mode) + rpc_id(requester_id, "sync_sng_go_duration", sng_go_duration) + rpc_id(requester_id, "sync_sng_stop_duration", sng_stop_duration) + rpc_id(requester_id, "sync_sng_required_goals", sng_required_goals) + rpc_id(requester_id, "sync_doors_swap_time", doors_swap_time) + rpc_id(requester_id, "sync_doors_refresh_time", doors_refresh_time) + rpc_id(requester_id, "sync_doors_required_goals", doors_required_goals) rpc_id(requester_id, "sync_game_mode", game_mode) rpc_id(requester_id, "sync_area", selected_area) @@ -568,3 +663,9 @@ func reset() -> void: selected_area = available_areas[0] local_character_index = 0 # Default to "Copper" enable_cycle_timer = false + sng_go_duration = 15 + sng_stop_duration = 4 + sng_required_goals = 8 + doors_swap_time = 15 + doors_refresh_time = 25 + doors_required_goals = 8 diff --git a/scripts/managers/portal_mode_manager.gd b/scripts/managers/portal_mode_manager.gd index 8ccdcad..f070aa7 100644 --- a/scripts/managers/portal_mode_manager.gd +++ b/scripts/managers/portal_mode_manager.gd @@ -17,7 +17,6 @@ var doors = [] # List of PortalDoor nodes var swap_timer: Timer var tile_refresh_timer: Timer var finish_spawned: bool = false -var missions_required: int = 8 var arena_setup_done: bool = false var player_portal_cooldowns: Dictionary = {} @@ -41,6 +40,7 @@ func initialize(p_main: Node, p_gridmap: Node): # Connection Swap Timer (15s) swap_timer = Timer.new() swap_timer.name = "PortalSwapTimer" + # Initial wait time; gets reset when started based on game mode settings swap_timer.wait_time = 15.0 swap_timer.timeout.connect(_on_swap_timer_timeout) add_child(swap_timer) @@ -48,6 +48,7 @@ func initialize(p_main: Node, p_gridmap: Node): # Tile Refresh Timer (25s) tile_refresh_timer = Timer.new() tile_refresh_timer.name = "TileRefreshTimer" + # Initial wait time; gets reset when started based on game mode settings tile_refresh_timer.wait_time = 25.0 tile_refresh_timer.timeout.connect(_on_tile_refresh_timer_timeout) add_child(tile_refresh_timer) @@ -77,6 +78,10 @@ func start_game_mode(): setup_arena_locally() _randomize_connections() + # Configure dynamic timings from LobbyManager before starting + swap_timer.wait_time = float(LobbyManager.doors_swap_time) + tile_refresh_timer.wait_time = float(LobbyManager.doors_refresh_time) + # Start Timers if swap_timer.is_stopped(): swap_timer.start() @@ -176,9 +181,9 @@ func _update_hud_visuals(): var gcm = main.get_node_or_null("GoalsCycleManager") var completed_count = gcm.player_goal_counts.get(my_id, 0) if gcm else 0 - mission_label.text = "GOALS (%d/%d)" % [completed_count, missions_required] + mission_label.text = "GOALS (%d/%d)" % [completed_count, LobbyManager.doors_required_goals] - if completed_count >= missions_required: + if completed_count >= LobbyManager.doors_required_goals: mission_label.text = "ALL GOALS COMPLETE!\nFIND THE FINISH ROOM!" mission_label.add_theme_color_override("font_color", Color.GOLD) @@ -194,7 +199,7 @@ func _update_hud_visuals(): func is_mission_complete(peer_id: int) -> bool: var gcm = main.get_node_or_null("GoalsCycleManager") if not gcm: return false - return gcm.player_goal_counts.get(peer_id, 0) >= missions_required + return gcm.player_goal_counts.get(peer_id, 0) >= LobbyManager.doors_required_goals func check_win_condition(player_id: int, pos: Vector2i) -> bool: # 1. Check if on finish tile diff --git a/scripts/managers/stop_n_go_manager.gd b/scripts/managers/stop_n_go_manager.gd index 51a6754..1ae8572 100644 --- a/scripts/managers/stop_n_go_manager.gd +++ b/scripts/managers/stop_n_go_manager.gd @@ -9,10 +9,6 @@ signal player_penalized(player_id: int) enum Phase {GO, STOP} -const GO_DURATION: float = 15.0 -const STOP_DURATION: float = 4.0 -const REQUIRED_GOALS: int = 8 - # Dynamic Safe Zone const SAFE_ZONE_PRE_TIME: float = 5.0 # Seconds before STOP to spawn safe zone const SAFE_ZONE_RADIUS: int = 2 # 5x5 area (radius 2 from center) @@ -34,7 +30,7 @@ const PERMANENT_POWERUP_LOCATIONS: Array[Vector2i] = [ ] var current_phase: Phase = Phase.GO -var phase_timer: float = GO_DURATION +var phase_timer: float = 15.0 # Initialized dynamically later var is_active: bool = false var player_missions: Dictionary = {} # player_id -> {target_tile: int, required: int, current: int} @@ -134,10 +130,11 @@ func _update_hud_visuals(): # Get count from GoalsCycleManager (Source of truth for PlayerBoardLabel) var completed_count = goals_cycle_manager.player_goal_counts.get(my_id, 0) if goals_cycle_manager else 0 + var required_goals = LobbyManager.sng_required_goals - mission_label.text = "GOALS (%d/%d)" % [completed_count, REQUIRED_GOALS] + mission_label.text = "GOALS (%d/%d)" % [completed_count, required_goals] - if completed_count >= REQUIRED_GOALS: + if completed_count >= required_goals: mission_label.text = "ALL GOALS COMPLETE!\nREACH THE FINISH!" mission_label.add_theme_color_override("font_color", Color.GOLD) @@ -235,7 +232,7 @@ func start_game_mode(): func _start_phase(phase: Phase): current_phase = phase - phase_timer = GO_DURATION if phase == Phase.GO else STOP_DURATION + phase_timer = float(LobbyManager.sng_go_duration) if phase == Phase.GO else float(LobbyManager.sng_stop_duration) var phase_name = "GO" if phase == Phase.GO else "STOP" if can_rpc(): @@ -436,7 +433,7 @@ func is_mission_complete(player_id: int) -> bool: if not goals_cycle_manager: return false var completed_count = goals_cycle_manager.player_goal_counts.get(player_id, 0) - return completed_count >= REQUIRED_GOALS + return completed_count >= LobbyManager.sng_required_goals func check_win_condition(player_id: int, position: Vector2i) -> bool: # 1. Must reach the finish line (Column 21) @@ -452,7 +449,7 @@ func check_win_condition(player_id: int, position: Vector2i) -> bool: var main = get_node_or_null("/root/Main") var player_node = main.get_node_or_null(str(player_id)) if main else null if player_node: - NotificationManager.send_message(player_node, "Incomplete! Achieve %d goals to win!" % REQUIRED_GOALS, NotificationManager.MessageType.WARNING) + NotificationManager.send_message(player_node, "Incomplete! Achieve %d goals to win!" % LobbyManager.sng_required_goals, NotificationManager.MessageType.WARNING) print("[StopNGo] Player %d reached finish but goals incomplete." % player_id) return false diff --git a/scripts/managers/user_profile_manager.gd b/scripts/managers/user_profile_manager.gd index fc8982b..0049d42 100644 --- a/scripts/managers/user_profile_manager.gd +++ b/scripts/managers/user_profile_manager.gd @@ -16,12 +16,10 @@ const STATS_COLLECTION := "stats" # Available avatars (predefined) const AVATARS := [ - "res://assets/avatars/avatar_default.png", - "res://assets/avatars/avatar_warrior.png", - "res://assets/avatars/avatar_mage.png", - "res://assets/avatars/avatar_rogue.png", - "res://assets/avatars/avatar_tank.png", - "res://assets/avatars/avatar_healer.png", + "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: diff --git a/scripts/ui/leaderboard_panel.gd b/scripts/ui/leaderboard_panel.gd new file mode 100644 index 0000000..f592da0 --- /dev/null +++ b/scripts/ui/leaderboard_panel.gd @@ -0,0 +1,170 @@ +extends PanelContainer + +signal closed + +@onready var close_btn := %CloseBtn as Button +@onready var sort_score_btn := %SortScoreBtn as Button +@onready var sort_win_rate_btn := %SortWinRateBtn as Button +@onready var sort_games_btn := %SortGamesBtn as Button +@onready var leaderboard_list := %LeaderboardList as VBoxContainer +@onready var status_label := %StatusLabel as Label + +var leaderboard_data: Array = [] +var current_sort_key: String = "high_score" + +func _ready() -> void: + close_btn.pressed.connect(_on_close_pressed) + sort_score_btn.pressed.connect(func(): _sort_by("high_score")) + sort_win_rate_btn.pressed.connect(func(): _sort_by("win_rate")) + sort_games_btn.pressed.connect(func(): _sort_by("games_played")) + + _update_tab_visuals() + +func show_panel() -> void: + show() + _fetch_leaderboard_data() + +func _on_close_pressed() -> void: + hide() + emit_signal("closed") + +func _fetch_leaderboard_data() -> void: + if not NakamaManager.session: + status_label.text = "Not connected to Nakama" + return + + status_label.text = "Loading data..." + + # Clear existing items + for child in leaderboard_list.get_children(): + child.queue_free() + + var payload = JSON.stringify({}) + var result = await NakamaManager.client.rpc_async(NakamaManager.session, "get_leaderboard_stats", payload) + + if result.is_exception(): + status_label.text = "Failed to load leaderboard" + push_error("[Leaderboard] RPC failed: ", result.get_exception().message) + return + + var response_text = result.payload + var json = JSON.new() + var error = json.parse(response_text) + + if error == OK: + var data = json.get_data() + if data.has("leaderboard"): + leaderboard_data = data.leaderboard + _calculate_win_rates() + status_label.text = "" + _sort_by(current_sort_key) + else: + status_label.text = "No data found" + else: + status_label.text = "Error parsing data" + +func _calculate_win_rates() -> void: + for entry in leaderboard_data: + var played = entry.get("games_played", 0) + var won = entry.get("games_won", 0) + if played > 0: + entry["win_rate"] = float(won) / float(played) * 100.0 + else: + entry["win_rate"] = 0.0 + +func _sort_by(key: String) -> void: + current_sort_key = key + _update_tab_visuals() + + leaderboard_data.sort_custom(func(a, b): return a.get(key, 0) > b.get(key, 0)) + + _populate_list() + +func _update_tab_visuals() -> void: + var color_active = Color(0.647, 0.996, 0.224, 1) + var color_inactive = Color(0.69, 0.529, 0.357, 1) + + sort_score_btn.add_theme_color_override("font_color", color_active if current_sort_key == "high_score" else color_inactive) + sort_win_rate_btn.add_theme_color_override("font_color", color_active if current_sort_key == "win_rate" else color_inactive) + sort_games_btn.add_theme_color_override("font_color", color_active if current_sort_key == "games_played" else color_inactive) + +func _populate_list() -> void: + for child in leaderboard_list.get_children(): + child.queue_free() + + if leaderboard_data.size() == 0: + status_label.text = "No players found" + return + + for i in range(leaderboard_data.size()): + var entry = leaderboard_data[i] + _create_leaderboard_item(i + 1, entry) + +func _create_leaderboard_item(rank: int, entry: Dictionary) -> void: + var item = PanelContainer.new() + var style = StyleBoxFlat.new() + style.bg_color = Color(0.15, 0.15, 0.15, 1.0) + if rank <= 3: + style.bg_color = Color(0.2, 0.2, 0.15, 1.0) # Slightly highlight top 3 + style.set_corner_radius_all(4) + style.content_margin_left = 10 + style.content_margin_right = 10 + style.content_margin_top = 8 + style.content_margin_bottom = 8 + item.add_theme_stylebox_override("panel", style) + + var hbox = HBoxContainer.new() + hbox.theme_override_constants.separation = 16 + item.add_child(hbox) + + # Rank + var rank_label = Label.new() + rank_label.text = "#" + str(rank) + rank_label.custom_minimum_size = Vector2(40, 0) + + if rank == 1: + rank_label.add_theme_color_override("font_color", Color.GOLD) + elif rank == 2: + rank_label.add_theme_color_override("font_color", Color.SILVER) + elif rank == 3: + rank_label.add_theme_color_override("font_color", Color.DARK_ORANGE) + else: + rank_label.add_theme_color_override("font_color", Color.LIGHT_GRAY) + + hbox.add_child(rank_label) + + # Avatar + var avatar_rect = TextureRect.new() + avatar_rect.custom_minimum_size = Vector2(32, 32) + avatar_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + var avatar_url = entry.get("avatar_url", UserProfileManager.AVATARS[0]) + if avatar_url.is_empty() or not ResourceLoader.exists(avatar_url): + avatar_url = UserProfileManager.AVATARS[0] + avatar_rect.texture = load(avatar_url) + hbox.add_child(avatar_rect) + + # Name + var name_label = Label.new() + name_label.text = entry.get("display_name", "Unknown") + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + name_label.add_theme_color_override("font_color", Color.WHITE) + hbox.add_child(name_label) + + # Value based on current sort + var value_label = Label.new() + var color = Color(0.647, 0.996, 0.224, 1) # TEKTON green + + match current_sort_key: + "high_score": + value_label.text = str(entry.get("high_score", 0)) + "win_rate": + value_label.text = "%.1f%%" % entry.get("win_rate", 0.0) + "games_played": + value_label.text = str(entry.get("games_played", 0)) + + value_label.add_theme_color_override("font_color", color) + value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + value_label.custom_minimum_size = Vector2(80, 0) + hbox.add_child(value_label) + + leaderboard_list.add_child(item) diff --git a/scripts/ui/leaderboard_panel.gd.uid b/scripts/ui/leaderboard_panel.gd.uid new file mode 100644 index 0000000..c01e404 --- /dev/null +++ b/scripts/ui/leaderboard_panel.gd.uid @@ -0,0 +1 @@ +uid://qe1nth1bnep8 diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index b50b57c..2fd2e10 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -22,10 +22,14 @@ extends Control @onready var reg_confirm_password_input := %RegConfirmPasswordInput as LineEdit @onready var password_strength := %PasswordStrength as ProgressBar @onready var password_hint := %PasswordHint as Label +@onready var reg_captcha_question := %RegCaptchaQuestion as Label +@onready var reg_captcha_input := %RegCaptchaInput as LineEdit @onready var register_button := %RegisterButton as Button @onready var back_to_login_link := %BackToLoginLink as LinkButton @onready var reg_status_label := %RegStatusLabel as Label +var current_captcha_answer: int = 0 + # Main panel reference @onready var main_panel := $CenterContainer/MainPanel as PanelContainer @@ -101,8 +105,16 @@ func _show_registration() -> void: main_panel.visible = false registration_panel.visible = true reg_status_label.text = "" + _generate_captcha() reg_email_input.grab_focus() +func _generate_captcha() -> void: + var num1 := randi_range(1, 10) + var num2 := randi_range(1, 10) + current_captcha_answer = num1 + num2 + reg_captcha_question.text = "Security Check: %d + %d = ?" % [num1, num2] + reg_captcha_input.text = "" + func _show_login() -> void: registration_panel.visible = false main_panel.visible = true @@ -254,6 +266,7 @@ func _on_register_pressed() -> void: var username := reg_username_input.text.strip_edges() var password := reg_password_input.text var confirm_password := reg_confirm_password_input.text + var captcha_answer := reg_captcha_input.text.strip_edges() # Validation if email.is_empty(): @@ -288,6 +301,15 @@ func _on_register_pressed() -> void: _show_reg_error("Password is too weak. Add numbers or symbols.") return + if captcha_answer.is_empty(): + _show_reg_error("Please solve the security check.") + return + + if not captcha_answer.is_valid_int() or int(captcha_answer) != current_captcha_answer: + _show_reg_error("Incorrect security check answer.") + _generate_captcha() + return + AuthManager.register_with_email(email, password, username) func _check_password_strength(password: String) -> void: @@ -411,6 +433,7 @@ func _set_inputs_enabled(enabled: bool) -> void: reg_username_input.editable = enabled reg_password_input.editable = enabled reg_confirm_password_input.editable = enabled + reg_captcha_input.editable = enabled func _is_valid_email(email: String) -> bool: # Simple email validation diff --git a/server/nakama/tekton_admin.js b/server/nakama/tekton_admin.js new file mode 100644 index 0000000..ce4296a --- /dev/null +++ b/server/nakama/tekton_admin.js @@ -0,0 +1,486 @@ +/** + * Tekton Nakama Server Runtime Module + * + * This module provides secure admin operations via RPC calls. + * Deploy this to your Nakama server's runtime directory. + */ + +// Initialize RPC endpoints +function InitModule(ctx, logger, nk, 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); + + // 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 +// ============================================================================= + +var ADMIN_ROLES = ["admin", "moderator", "owner"]; + +function isAdmin(ctx, nk) { + if (!ctx.userId) return false; + + try { + var account = nk.accountGetId(ctx.userId); + var metadata = JSON.parse(account.user.metadata || "{}"); + return ADMIN_ROLES.indexOf(metadata.role || "") !== -1; + } catch (e) { + return false; + } +} + +function isMatchHost(ctx, nk, matchId) { + if (!ctx.userId || !matchId) return false; + + try { + // Get match state to check host + var 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 + var state = JSON.parse(match.state || "{}"); + return state.hostUserId === ctx.userId; + } catch (e) { + return false; + } +} + +function requireAdmin(ctx, nk) { + if (!isAdmin(ctx, nk)) { + throw new Error("Admin privileges required"); + } +} + +function requireAdminOrHost(ctx, nk, matchId) { + if (!isAdmin(ctx, nk) && !isMatchHost(ctx, nk, matchId)) { + throw new Error("Admin or host privileges required"); + } +} + +// ============================================================================= +// Admin RPCs +// ============================================================================= + +function rpcAdminKickPlayer(ctx, logger, nk, payload) { + var request = JSON.parse(payload); + + 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"); + } +} + +function rpcAdminBanPlayer(ctx, logger, nk, payload) { + var request = JSON.parse(payload); + + // 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 + var targetAccount = nk.accountGetId(request.user_id); + var metadata = JSON.parse(targetAccount.user.metadata || "{}"); + + // Don't allow banning other admins + if (ADMIN_ROLES.indexOf(metadata.role || "") !== -1) { + throw new Error("Cannot ban an admin"); + } + + // Set ban in metadata + var 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) + var 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); + } +} + +function rpcAdminUnbanPlayer(ctx, logger, nk, payload) { + var request = JSON.parse(payload); + + requireAdmin(ctx, nk); + + try { + // Get target user's account + var targetAccount = nk.accountGetId(request.user_id); + var metadata = JSON.parse(targetAccount.user.metadata || "{}"); + + // 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, logger, nk, payload) { + requireAdmin(ctx, nk); + + try { + var result = nk.storageList( + "00000000-0000-0000-0000-000000000000", + "bans", + 100, + "" + ); + + var bans = result.objects ? result.objects.map(function(obj) { return obj.value; }) : []; + + return JSON.stringify({ bans: bans }); + } catch (e) { + logger.error("Failed to get ban list: " + e); + return JSON.stringify({ bans: [] }); + } +} + +function rpcAdminGetServerStats(ctx, logger, nk, payload) { + var request = JSON.parse(payload || "{}"); + + if (request.match_id) { + requireAdminOrHost(ctx, nk, request.match_id); + } else { + requireAdmin(ctx, nk); + } + + try { + // Get server-wide stats + var matches = nk.matchList(100, true, null, null, null, null); + var activeMatchCount = matches ? matches.length : 0; + + var totalPlayers = 0; + if (matches) { + for (var i = 0; i < matches.length; i++) { + totalPlayers += matches[i].size || 0; + } + } + + var stats = { + active_matches: activeMatchCount, + total_players: totalPlayers, + server_time: new Date().toISOString() + }; + + // If specific match requested, include match details + if (request.match_id) { + try { + var match = nk.matchGet(request.match_id); + if (match) { + stats.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"); + } +} + +function rpcAdminGetPlayerList(ctx, logger, nk, payload) { + var request = JSON.parse(payload); + + requireAdminOrHost(ctx, nk, request.match_id); + + try { + var match = nk.matchGet(request.match_id); + if (!match) { + throw new Error("Match not found"); + } + + // Get player details + var players = []; + + // 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: players }); + } catch (e) { + logger.error("Failed to get player list: " + e); + throw new Error("Failed to get player list"); + } +} + +function rpcAdminEndMatch(ctx, logger, nk, payload) { + var request = JSON.parse(payload); + + 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"); + } +} + +function rpcAdminSetUserRole(ctx, logger, nk, payload) { + var request = JSON.parse(payload); + + // Only owner/super-admin can set roles + var callerAccount = nk.accountGetId(ctx.userId); + var callerMetadata = JSON.parse(callerAccount.user.metadata || "{}"); + + if (callerMetadata.role !== "owner") { + throw new Error("Only owners can modify user roles"); + } + + var validRoles = ["player", "moderator", "admin"]; + if (validRoles.indexOf(request.role) === -1) { + throw new Error("Invalid role"); + } + + try { + var targetAccount = nk.accountGetId(request.user_id); + var 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, logger, nk, payload) { + var request = JSON.parse(payload || "{}"); + var targetUserId = request.user_id || ctx.userId; + + try { + var account = nk.accountGetId(targetUserId); + var metadata = JSON.parse(account.user.metadata || "{}"); + + // Check if banned + if (metadata.banned && targetUserId === ctx.userId) { + // Check if ban expired + if (metadata.ban_expires) { + var 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; + } +} + +function rpcUpdateUserProfile(ctx, logger, nk, payload) { + if (!ctx.userId) { + throw new Error("Not authenticated"); + } + + var request = JSON.parse(payload); + + 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, 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 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); + } + } + + return JSON.stringify({ leaderboard: leaderboardData }); + } catch (e) { + logger.error("Failed to get leaderboard stats: " + e); + throw new Error("Failed to get leaderboard stats"); + } +} + +// Before login hook to check ban status +function beforeAuthenticateEmail(ctx, logger, nk, data) { + // Can't check ban before auth, so we check in afterAuthenticate + return data; +} diff --git a/server/nakama/tekton_admin.ts b/server/nakama/tekton_admin.ts index ea7b1a9..a6e4848 100644 --- a/server/nakama/tekton_admin.ts +++ b/server/nakama/tekton_admin.ts @@ -29,6 +29,9 @@ function InitModule( // 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"); } @@ -538,6 +541,62 @@ function rpcUpdateUserProfile( } } +// ============================================================================= +// 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"); + } +} + // Before login hook to check ban status function beforeAuthenticateEmail( ctx: nkruntime.Context,