From 15043b5655e61de540540059285d16197509def2 Mon Sep 17 00:00:00 2001 From: adtpdn Date: Fri, 19 Jun 2026 17:13:24 +0800 Subject: [PATCH] feat: take_powerup VFX, rank fix, admin chat management - Wire take_powerup AnimatedSprite3D on powerup pickup via add_powerup_from_item() - Make take_powerup animation one-shot (loop: false) - Fix rank Position label hidden at game start (visible = false, only shows when score > 0) - Competition ranking for tied scores in main.gd - Lobby Chat admin tab: system prefix, max messages, wipe, purge old, save config - Chat Storage admin tab: list/browse/delete individual channel messages - Backend RPCs: admin_get_chat_config, admin_set_chat_config, admin_purge_old_messages, admin_list_channel_messages, admin_delete_channel_message - Chat config applied on lobby join (max_messages, prefix injection) --- assets/graphics/vfx/effects/powerup.tres | 221 ++++++++++++++++++++++ scenes/lobby.gd | 25 +++ scenes/main.gd | 14 +- scenes/player.gd | 10 +- scenes/player.tscn | 13 ++ scenes/ui/admin_panel.tscn | 138 ++++++++++++++ scenes/ui/lobby_chat.gd | 24 ++- scripts/managers/special_tiles_manager.gd | 6 + scripts/services/backend_service.gd | 15 ++ scripts/ui/admin_panel.gd | 202 ++++++++++++++++++++ server/nakama/lua/admin.lua | 156 +++++++++++++++ server/nakama/lua/utils.lua | 1 + 12 files changed, 818 insertions(+), 7 deletions(-) create mode 100644 assets/graphics/vfx/effects/powerup.tres diff --git a/assets/graphics/vfx/effects/powerup.tres b/assets/graphics/vfx/effects/powerup.tres new file mode 100644 index 0000000..5bbbcd8 --- /dev/null +++ b/assets/graphics/vfx/effects/powerup.tres @@ -0,0 +1,221 @@ +[gd_resource type="SpriteFrames" format=3 uid="uid://bq8ifua64lag2"] + +[ext_resource type="Texture2D" uid="uid://cwmqu5wsing63" path="res://assets/graphics/vfx/take_powerup.png" id="1_j5q3j"] + +[sub_resource type="AtlasTexture" id="AtlasTexture_fs20j"] +atlas = ExtResource("1_j5q3j") +region = Rect2(0, 0, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_mgj3q"] +atlas = ExtResource("1_j5q3j") +region = Rect2(386, 0, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_0cqc8"] +atlas = ExtResource("1_j5q3j") +region = Rect2(772, 0, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_6lpcc"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1158, 0, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_sufku"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1544, 0, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_m7myb"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1930, 0, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_85pl8"] +atlas = ExtResource("1_j5q3j") +region = Rect2(0, 386, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_tum65"] +atlas = ExtResource("1_j5q3j") +region = Rect2(386, 386, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_v7us7"] +atlas = ExtResource("1_j5q3j") +region = Rect2(772, 386, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_y04gx"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1158, 386, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_uwml5"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1544, 386, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_ltsqf"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1930, 386, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_4st5b"] +atlas = ExtResource("1_j5q3j") +region = Rect2(0, 772, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_vnofb"] +atlas = ExtResource("1_j5q3j") +region = Rect2(386, 772, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_fculp"] +atlas = ExtResource("1_j5q3j") +region = Rect2(772, 772, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_n4swe"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1158, 772, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_7kcav"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1544, 772, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_6b6x4"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1930, 772, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_sx3tb"] +atlas = ExtResource("1_j5q3j") +region = Rect2(0, 1158, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_vcvbu"] +atlas = ExtResource("1_j5q3j") +region = Rect2(386, 1158, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_h7b8t"] +atlas = ExtResource("1_j5q3j") +region = Rect2(772, 1158, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_g0810"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1158, 1158, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_3t50e"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1544, 1158, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_yk8ef"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1930, 1158, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_5m1o2"] +atlas = ExtResource("1_j5q3j") +region = Rect2(0, 1544, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_xp42j"] +atlas = ExtResource("1_j5q3j") +region = Rect2(386, 1544, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_3ai78"] +atlas = ExtResource("1_j5q3j") +region = Rect2(772, 1544, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_xsn24"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1158, 1544, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_eprgv"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1544, 1544, 386, 386) + +[sub_resource type="AtlasTexture" id="AtlasTexture_uc241"] +atlas = ExtResource("1_j5q3j") +region = Rect2(1930, 1544, 386, 386) + +[resource] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_fs20j") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_mgj3q") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_0cqc8") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6lpcc") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_sufku") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_m7myb") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_85pl8") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_tum65") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_v7us7") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_y04gx") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_uwml5") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ltsqf") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_4st5b") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_vnofb") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_fculp") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_n4swe") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7kcav") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_6b6x4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_sx3tb") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_vcvbu") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_h7b8t") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_g0810") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_3t50e") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_yk8ef") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5m1o2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_xp42j") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_3ai78") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_xsn24") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_eprgv") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_uc241") +}], +"loop": false, +"name": &"take_powerup", +"speed": 15.0 +}] diff --git a/scenes/lobby.gd b/scenes/lobby.gd index 636c3dd..4d93f69 100644 --- a/scenes/lobby.gd +++ b/scenes/lobby.gd @@ -556,3 +556,28 @@ func _apply_loadout_character() -> void: if idx != -1: LobbyManager.local_character_index = idx print("[Lobby] Loadout character applied: ", saved_char) + +# ============================================================================= +# Admin Chat Actions (called from Admin Panel) +# ============================================================================= +func admin_wipe_chat() -> void: + """Wipe the entire global lobby chat. Called by admin panel.""" + if not chat or not chat._chat_channel: + push_warning("[Lobby] admin_wipe_chat: chat not connected.") + return + var payload = JSON.stringify({"channel_id": chat._chat_channel.id}) + var result = await BackendService.admin_clear_global_chat(payload) + if result.get("success", false): + chat._chat_messages.clear() + chat._refresh_chat_display() + chat._inject_local_message("[SYSTEM] : Global chat cleared by admin.") + else: + push_warning("[Lobby] admin_wipe_chat failed: " + str(result.get("message", ""))) + +func admin_purge_chat(max_age_days: int) -> int: + """Purge messages older than max_age_days. Returns count deleted. Called by admin panel.""" + if not chat or not chat._chat_channel: + push_warning("[Lobby] admin_purge_chat: chat not connected.") + return 0 + var result = await BackendService.admin_purge_old_messages(chat._chat_channel.id, max_age_days) + return result.get("deleted", 0) diff --git a/scenes/main.gd b/scenes/main.gd index 18b5f79..ffac8e4 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -2262,12 +2262,22 @@ func _on_leaderboard_updated(sorted_scores: Array): else: sorted_players.sort_custom(func(a, b): return a.score > b.score) - # Assign rank + # Assign rank. Players sharing a score share a rank (standard competition + # ranking), and zero-score players get no rank at all — this prevents the + # match from starting with everyone displaying a position. + var prev_score = null + var prev_rank = 0 for i in range(sorted_players.size()): var p_node = sorted_players[i].node + var p_score = sorted_players[i].score var rank = i + 1 + # Tie: reuse the rank of the player above with the same score. + if prev_score != null and p_score == prev_score: + rank = prev_rank + prev_score = p_score + prev_rank = rank if p_node.has_method("update_rank_visuals"): - p_node.update_rank_visuals(rank) + p_node.update_rank_visuals(rank, p_score) func _on_global_timer_updated(time_remaining: float): """Update the global match timer display.""" diff --git a/scenes/player.gd b/scenes/player.gd index cf89590..c950571 100644 --- a/scenes/player.gd +++ b/scenes/player.gd @@ -928,11 +928,17 @@ func _refresh_player_visuals(): if active_character: apply_loadout(active_character) -func update_rank_visuals(rank: int): +func update_rank_visuals(rank: int, score: int = -1): var pos_label = get_node_or_null("Position") if not pos_label: return - + + # Hide rank until the player has actually scored, so the match doesn't + # start with everyone showing a position (e.g. all "1st"). + if score == 0: + pos_label.visible = false + return + if rank <= 4: pos_label.visible = true if race_manager: diff --git a/scenes/player.tscn b/scenes/player.tscn index f18bc5e..fa5f822 100644 --- a/scenes/player.tscn +++ b/scenes/player.tscn @@ -9,6 +9,7 @@ [ext_resource type="AnimationLibrary" path="res://assets/characters/animations/animation-pack.res" id="6_5oq5w"] [ext_resource type="Script" uid="uid://cwwwixc07jc86" path="res://scripts/bot_controller.gd" id="7_botctrl"] [ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="8_y4r1p"] +[ext_resource type="SpriteFrames" uid="uid://bq8ifua64lag2" path="res://assets/graphics/vfx/effects/powerup.tres" id="10_d2wvv"] [ext_resource type="SpriteFrames" uid="uid://7r0qbbm88vfy" path="res://assets/graphics/vfx/effects/animation-head.tres" id="10_y4r1p"] [sub_resource type="TorusMesh" id="TorusMesh_ur7pv"] @@ -81,6 +82,7 @@ autowrap_mode = 2 [node name="Position" type="Label3D" parent="." unique_id=482425681] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.537, 0) +visible = false billboard = 1 no_depth_test = true render_priority = 2 @@ -116,6 +118,17 @@ width = 700.0 [node name="BotController" type="Node" parent="." unique_id=723259755] script = ExtResource("7_botctrl") +[node name="take_powerup" type="AnimatedSprite3D" parent="." unique_id=1497442994] +transform = Transform3D(0.54, 0, 0, 0, 0.54, 0, 0, 0, 0.54, 0, 0.21994019, 0) +visible = false +modulate = Color(1, 1, 1, 0.8) +billboard = 2 +no_depth_test = true +render_priority = 3 +sprite_frames = ExtResource("10_d2wvv") +animation = &"take_powerup" +frame_progress = 0.5033338 + [node name="skill_freeze" type="AnimatedSprite3D" parent="." unique_id=674916570] transform = Transform3D(1, 0, 0, 0, -4.371139e-08, -1, 0, 1, -4.371139e-08, 0, 1.5653763, 0) visible = false diff --git a/scenes/ui/admin_panel.tscn b/scenes/ui/admin_panel.tscn index d613000..70af1da 100644 --- a/scenes/ui/admin_panel.tscn +++ b/scenes/ui/admin_panel.tscn @@ -544,6 +544,144 @@ text = "Load Current" unique_name_in_owner = true custom_minimum_size = Vector2(160, 36) layout_mode = 2 + +[node name="Lobby Chat" type="VBoxContainer" parent="Margin/VBox/Tabs"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="PrefixRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/PrefixRow"] +layout_mode = 2 +custom_minimum_size = Vector2(220, 0) +text = "System Prefix:" + +[node name="PrefixEdit" type="LineEdit" parent="Margin/VBox/Tabs/Lobby Chat/PrefixRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "[SERVER]" + +[node name="MaxMsgRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/MaxMsgRow"] +layout_mode = 2 +custom_minimum_size = Vector2(220, 0) +text = "Max messages loaded:" + +[node name="MaxMsgSpin" type="SpinBox" parent="Margin/VBox/Tabs/Lobby Chat/MaxMsgRow"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(120, 0) +min_value = 10.0 +max_value = 200.0 +step = 10.0 +value = 50.0 + +[node name="MaxAgeRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/MaxAgeRow"] +layout_mode = 2 +custom_minimum_size = Vector2(220, 0) +text = "Delete messages older than (days):" + +[node name="MaxAgeSpin" type="SpinBox" parent="Margin/VBox/Tabs/Lobby Chat/MaxAgeRow"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(120, 0) +min_value = 0.0 +max_value = 365.0 +step = 1.0 +value = 0.0 +tooltip_text = "0 = don't auto-delete, use manual purge only" + +[node name="ChatActions" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="WipeChatBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(140, 36) +layout_mode = 2 +text = "Wipe Chat" + +[node name="PurgeOldBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(140, 36) +layout_mode = 2 +text = "Purge Old" + +[node name="SaveConfigBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions"] +unique_name_in_owner = true +custom_minimum_size = Vector2(140, 36) +layout_mode = 2 +text = "Save Config" + +[node name="ChatStatusLabel" type="Label" parent="Margin/VBox/Tabs/Lobby Chat"] +unique_name_in_owner = true +layout_mode = 2 +text = "" + +[node name="Chat Storage" type="VBoxContainer" parent="Margin/VBox/Tabs"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="ChannelIdRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Chat Storage"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Label" type="Label" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow"] +layout_mode = 2 +text = "Channel ID:" + +[node name="ChannelIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Enter channel ID..." + +[node name="LoadMessagesBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow"] +unique_name_in_owner = true +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +text = "Load" + +[node name="ChatTree" type="Tree" parent="Margin/VBox/Tabs/Chat Storage"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +columns = 4 +column_titles_visible = true +allow_reselect = true +hide_root = true +select_mode = 1 + +[node name="ChatStorageActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Chat Storage"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="RefreshChatBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar"] +unique_name_in_owner = true +custom_minimum_size = Vector2(100, 36) +layout_mode = 2 +text = "Refresh" + +[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="DeleteSelectedBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar"] +unique_name_in_owner = true +custom_minimum_size = Vector2(120, 36) +layout_mode = 2 +text = "Delete Selected" + [node name="HistoryDialog" type="AcceptDialog" parent="."] unique_name_in_owner = true title = "User History" diff --git a/scenes/ui/lobby_chat.gd b/scenes/ui/lobby_chat.gd index 0714655..99af33f 100644 --- a/scenes/ui/lobby_chat.gd +++ b/scenes/ui/lobby_chat.gd @@ -9,6 +9,7 @@ var _chat_messages: Array = [] var _active_chat_context: String = "global" var _dm_tabs: Dictionary = {} var _dm_messages: Dictionary = {} +var _chat_config: Dictionary = {"prefix": "", "max_messages": 50, "max_age_days": 0} func _init(p_lobby: Control): lobby = p_lobby @@ -40,17 +41,34 @@ func join_global_chat() -> void: _chat_channel = result print("[Chat] Joined global channel: ", _chat_channel.id) - + if not socket.received_channel_message.is_connected(_on_chat_message_received): socket.received_channel_message.connect(_on_chat_message_received) - + + # Fetch admin chat config (prefix, max_messages, etc.) + if BackendService.has_method("admin_get_chat_config"): + var cfg_res = await BackendService.admin_get_chat_config() + if cfg_res.has("config"): + _chat_config = cfg_res["config"] + _chat_messages.clear() - var history_result = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, _chat_channel.id, 50, false) + var limit: int = _chat_config.get("max_messages", 50) + var history_result = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, _chat_channel.id, limit, false) if not history_result.is_exception() and history_result.messages: var msgs = history_result.messages.duplicate() msgs.reverse() for msg in msgs: _add_chat_message(msg, false) + + # Inject admin system prefix if configured + var prefix: String = _chat_config.get("prefix", "") + if not prefix.is_empty(): + _chat_messages.insert(0, { + "sender": "SYSTEM", + "content": prefix, + "ts": _get_local_time(), + "date": Time.get_date_string_from_system() + }) _trim_old_messages() _refresh_chat_display() diff --git a/scripts/managers/special_tiles_manager.gd b/scripts/managers/special_tiles_manager.gd index 26e1719..d0ef011 100644 --- a/scripts/managers/special_tiles_manager.gd +++ b/scripts/managers/special_tiles_manager.gd @@ -153,6 +153,12 @@ func add_powerup_from_item(item_id: int): var effect = get_effect_from_item(item_id) if effect == -1: return + # VFX: show pickup burst on all peers (mirrors skill VFX pattern) + if player.is_multiplayer_authority() and player.has_method("can_rpc") and player.can_rpc(): + player.rpc("play_skill_vfx", "take_powerup") + elif player.has_method("play_skill_vfx"): + player.play_skill_vfx("take_powerup") + # 1-PowerUp Rule: If this is a DIFFERENT power-up, clear the old one var is_different = not inventory.get(effect, false) var already_has_any = false diff --git a/scripts/services/backend_service.gd b/scripts/services/backend_service.gd index 4443e7c..48ee7db 100644 --- a/scripts/services/backend_service.gd +++ b/scripts/services/backend_service.gd @@ -149,6 +149,21 @@ func _parse_error_msg(msg: String) -> String: func admin_clear_global_chat(payload: String) -> Dictionary: return await api_rpc_async("admin_clear_global_chat", payload) +func admin_get_chat_config() -> Dictionary: + return await api_rpc_async("admin_get_chat_config", "{}") + +func admin_set_chat_config(config: Dictionary) -> Dictionary: + return await api_rpc_async("admin_set_chat_config", JSON.stringify(config)) + +func admin_purge_old_messages(channel_id: String, max_age_days: int) -> Dictionary: + return await api_rpc_async("admin_purge_old_messages", JSON.stringify({"channel_id": channel_id, "max_age_days": max_age_days})) + +func admin_list_channel_messages(channel_id: String, limit: int = 50, cursor: String = "", forward: bool = true) -> Dictionary: + return await api_rpc_async("admin_list_channel_messages", JSON.stringify({"channel_id": channel_id, "limit": limit, "cursor": cursor, "forward": forward})) + +func admin_delete_channel_message(channel_id: String, message_id: String) -> Dictionary: + return await api_rpc_async("admin_delete_channel_message", JSON.stringify({"channel_id": channel_id, "message_id": message_id})) + 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) diff --git a/scripts/ui/admin_panel.gd b/scripts/ui/admin_panel.gd index 8d526cc..5527e73 100644 --- a/scripts/ui/admin_panel.gd +++ b/scripts/ui/admin_panel.gd @@ -69,6 +69,27 @@ var _all_server_mails: Array = [] @onready var load_banners_btn := %LoadBannersBtn as Button @onready var save_banners_btn := %SaveBannersBtn as Button +# Tab: Lobby Chat +@onready var chat_prefix_edit := %PrefixEdit as LineEdit +@onready var chat_max_msg_spin := %MaxMsgSpin as SpinBox +@onready var chat_max_age_spin := %MaxAgeSpin as SpinBox +@onready var chat_wipe_btn := %WipeChatBtn as Button +@onready var chat_purge_btn := %PurgeOldBtn as Button +@onready var chat_save_btn := %SaveConfigBtn as Button +@onready var chat_status_label := %ChatStatusLabel as Label + +# Tab: Chat Storage +@onready var chat_channel_id_edit := %ChannelIdEdit as LineEdit +@onready var load_messages_btn := %LoadMessagesBtn as Button +@onready var chat_tree := %ChatTree as Tree +@onready var refresh_chat_btn := %RefreshChatBtn as Button +@onready var delete_selected_btn := %DeleteSelectedBtn as Button + +var _chat_tree_root: TreeItem +var _chat_channel_id: String = "" +var _chat_cursor: String = "" +var _chat_messages_data: Array = [] + const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] # -- Data -- @@ -175,6 +196,17 @@ func _setup_columns() -> void: mail_tree.set_column_expand(5, false) _mail_root = mail_tree.create_item() + # Chat Storage + chat_tree.set_column_title(0, "Sender") + chat_tree.set_column_title(1, "Content") + chat_tree.set_column_title(2, "Date") + chat_tree.set_column_title(3, "ID") + chat_tree.set_column_custom_minimum_width(0, 100) + chat_tree.set_column_expand(1, true) + chat_tree.set_column_custom_minimum_width(2, 120) + chat_tree.set_column_custom_minimum_width(3, 100) + _chat_tree_root = chat_tree.create_item() + func _connect_signals() -> void: close_btn.pressed.connect(_on_close) refresh_btn.pressed.connect(_on_refresh) @@ -217,6 +249,16 @@ func _connect_signals() -> void: load_banners_btn.pressed.connect(func(): await _load_featured_banners()) save_banners_btn.pressed.connect(func(): await _save_featured_banners()) + # Chat actions + chat_wipe_btn.pressed.connect(_on_wipe_chat) + chat_purge_btn.pressed.connect(_on_purge_old_chat) + chat_save_btn.pressed.connect(_on_save_chat_config) + + # Chat Storage actions + load_messages_btn.pressed.connect(_on_load_chat_messages) + refresh_chat_btn.pressed.connect(_on_load_chat_messages) + delete_selected_btn.pressed.connect(_on_delete_chat_message) + # ============================================================================= # Core Panel Logic # ============================================================================= @@ -243,6 +285,10 @@ func _on_tab_changed(tab_index: int) -> void: await _load_mail() elif tab_index == 5: await _load_featured_banners() + elif tab_index == 6: + await _load_chat_config() + elif tab_index == 7: + await _on_load_chat_messages() # ============================================================================= # RPC Helper @@ -1193,3 +1239,159 @@ func _save_featured_banners() -> void: _set_status("Save failed: " + str(res.error), CLR_STATUS_ERR) elif res.has("success"): _set_status("Banners saved! (%d slots)" % banners.size(), CLR_STATUS_OK) + +# ============================================================================= +# TAB 7: LOBBY CHAT +# ============================================================================= +func _load_chat_config() -> void: + chat_status_label.text = "Loading config..." + var res := await _rpc("admin_get_chat_config", {}) + if res.has("error"): + chat_status_label.text = "Failed: " + str(res.error) + return + + var config: Dictionary = res.get("config", {}) + chat_prefix_edit.text = config.get("prefix", "") + chat_max_msg_spin.value = config.get("max_messages", 50) + chat_max_age_spin.value = config.get("max_age_days", 0) + chat_status_label.text = "" + +func _on_wipe_chat() -> void: + var confirm := ConfirmationDialog.new() + confirm.title = "Wipe Entire Lobby Chat?" + confirm.dialog_text = "This will delete ALL messages in the global lobby chat for everyone. Continue?" + add_child(confirm) + confirm.popup_centered() + confirm.confirmed.connect(func(): + chat_status_label.text = "Wiping chat..." + var lobby = get_tree().get_first_node_in_group("Lobby") + if lobby and lobby.has_method("admin_wipe_chat"): + lobby.admin_wipe_chat() + chat_status_label.text = "Chat wiped!" + else: + chat_status_label.text = "Lobby not found — cannot wipe." + confirm.queue_free() + ) + +func _on_purge_old_chat() -> void: + var max_age: int = int(chat_max_age_spin.value) + if max_age <= 0: + chat_status_label.text = "Set 'Delete older than' to > 0 days first." + return + + var confirm := ConfirmationDialog.new() + confirm.title = "Purge Old Messages?" + confirm.dialog_text = "Delete all messages older than %d days?" % max_age + add_child(confirm) + confirm.popup_centered() + confirm.confirmed.connect(func(): + chat_status_label.text = "Purging old messages..." + var lobby = get_tree().get_first_node_in_group("Lobby") + if lobby and lobby.has_method("admin_purge_chat"): + var deleted: int = await lobby.admin_purge_chat(max_age) + chat_status_label.text = "Purged %d old messages." % deleted + else: + chat_status_label.text = "Lobby not found — cannot purge." + confirm.queue_free() + ) + +func _on_save_chat_config() -> void: + chat_status_label.text = "Saving..." + var config := { + "prefix": chat_prefix_edit.text.strip_edges(), + "max_messages": int(chat_max_msg_spin.value), + "max_age_days": int(chat_max_age_spin.value) + } + var res := await _rpc("admin_set_chat_config", config) + if res.has("error"): + chat_status_label.text = "Failed: " + str(res.error) + else: + chat_status_label.text = "Chat config saved!" + +# ============================================================================= +# TAB 8: CHAT STORAGE +# ============================================================================= +func _on_load_chat_messages() -> void: + var channel_id := chat_channel_id_edit.text.strip_edges() + if channel_id.is_empty(): + _set_status("Enter a Channel ID first.", CLR_STATUS_ERR) + return + + _chat_channel_id = channel_id + _chat_cursor = "" + _chat_messages_data.clear() + _clear_tree(chat_tree, _chat_tree_root) + + await _fetch_chat_messages_batch() + +func _fetch_chat_messages_batch() -> void: + _set_status("Loading messages...") + var payload := { + "channel_id": _chat_channel_id, + "limit": 50, + "cursor": _chat_cursor, + "forward": false + } + var res := await _rpc("admin_list_channel_messages", payload) + + if res.has("error"): + _set_status("Failed: " + str(res.error), CLR_STATUS_ERR) + return + + var msgs = res.get("messages", []) + var next_cursor = res.get("next_cursor", "") + + for msg in msgs: + _chat_messages_data.append(msg) + var item := _chat_tree_root.create_child() + item.set_text(0, msg.get("username", msg.get("sender_id", "?").substr(0, 8))) + item.set_text(1, msg.get("content", "")) + item.set_text(2, msg.get("create_time", "").substr(0, 19).replace("T", " ")) + var mid = msg.get("message_id", "") + item.set_text(3, mid) + item.set_tooltip_text(3, mid) + item.set_metadata(0, msg) + + count_label.text = "%d messages loaded" % _chat_messages_data.size() + + if not next_cursor.is_empty(): + _chat_cursor = next_cursor + _set_status("Loaded page. Click Refresh to load more.", CLR_STATUS_OK) + else: + _chat_cursor = "" + _set_status("All messages loaded.", CLR_STATUS_OK) + +func _on_delete_chat_message() -> void: + var item = chat_tree.get_selected() + if not item: + _set_status("Select a message to delete.", CLR_STATUS_ERR) + return + + var msg = item.get_metadata(0) + if not msg: + return + + var msg_id = msg.get("message_id", "") + if msg_id.is_empty(): + return + + var confirm := ConfirmationDialog.new() + confirm.title = "Delete Message?" + confirm.dialog_text = "Permanently delete message from " + msg.get("username", "?") + "?" + add_child(confirm) + confirm.popup_centered() + confirm.confirmed.connect(func(): + _set_status("Deleting message...") + var res = await _rpc("admin_delete_channel_message", { + "channel_id": _chat_channel_id, + "message_id": msg_id + }) + if res.get("success", false): + _set_status("Message deleted!", CLR_STATUS_OK) + chat_tree.get_root().remove_child(item) + item.free() + count_label.text = "%d messages loaded" % _chat_messages_data.size() + else: + _set_status("Failed: " + str(res.get("error", "")), CLR_STATUS_ERR) + confirm.queue_free() + ) diff --git a/server/nakama/lua/admin.lua b/server/nakama/lua/admin.lua index aa0463c..5055466 100644 --- a/server/nakama/lua/admin.lua +++ b/server/nakama/lua/admin.lua @@ -336,6 +336,157 @@ function admin.rpc_admin_get_player_list(context, payload) return nk.json_encode({ players = players }) end +-- ============================================================================= +-- Lobby Chat Management +-- ============================================================================= + +function admin.rpc_admin_get_chat_config(context, payload) + utils.require_admin(context) + local configObjs = nk.storage_read({{ + collection = "config", + key = "lobby_chat", + user_id = utils.SYSTEM_USER_ID + }}) + local config = { prefix = "", max_messages = 50, max_age_days = 0 } + if configObjs and #configObjs > 0 and configObjs[1].value then + local val = configObjs[1].value + config.prefix = val.prefix or "" + config.max_messages = val.max_messages or 50 + config.max_age_days = val.max_age_days or 0 + end + return nk.json_encode({ config = config }) +end + +function admin.rpc_admin_set_chat_config(context, payload) + utils.require_admin(context) + local request = nk.json_decode(payload or "{}") + local config = { + prefix = request.prefix or "", + max_messages = request.max_messages or 50, + max_age_days = request.max_age_days or 0 + } + nk.storage_write({{ + collection = "config", + key = "lobby_chat", + user_id = utils.SYSTEM_USER_ID, + value = config, + permission_read = 2, + permission_write = 0 + }}) + nk.logger_info("[AdminChat] Chat config updated by " .. context.user_id) + return nk.json_encode({ success = true }) +end + +function admin.rpc_admin_purge_old_messages(context, payload) + utils.require_admin(context) + local request = nk.json_decode(payload or "{}") + local channelId = request.channel_id or "" + local maxAgeDays = request.max_age_days or 0 + + if channelId == "" then + error("channel_id is required") + end + if maxAgeDays <= 0 then + error("max_age_days must be > 0") + end + + local cutoff = os.time() - (maxAgeDays * 86400) + local deleted = 0 + local cursor = "" + + repeat + local status, result = pcall(nk.channel_messages_list, channelId, 100, false, cursor) + if not status then break end + + local messages = result.messages or {} + for _, msg in ipairs(messages) do + -- Parse create_time to compare against cutoff + local msgTime = 0 + if msg.create_time then + -- Try ISO format: "2024-01-15T10:30:00Z" + local y, m, d, hh, mm, ss = msg.create_time:match("(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)") + if y then + msgTime = os.time({ + year = tonumber(y), month = tonumber(m), day = tonumber(d), + hour = tonumber(hh), min = tonumber(mm), sec = tonumber(ss) + }) + else + -- Try unix timestamp string + msgTime = tonumber(msg.create_time) or 0 + end + end + + if msgTime > 0 and msgTime < cutoff then + pcall(nk.channel_message_remove, channelId, msg.message_id) + deleted = deleted + 1 + end + end + + cursor = result.next_cursor or "" + until cursor == "" + + nk.logger_info("[AdminChat] Purged " .. deleted .. " messages older than " .. maxAgeDays .. " days by " .. context.user_id) + return nk.json_encode({ success = true, deleted = deleted }) +end + +function admin.rpc_admin_list_channel_messages(context, payload) + utils.require_admin(context) + local request = nk.json_decode(payload or "{}") + local channelId = request.channel_id or "" + local limit = request.limit or 50 + local cursor = request.cursor or "" + local forward = request.forward == nil and true or request.forward + + if channelId == "" then + error("channel_id is required") + end + + local status, result = pcall(nk.channel_messages_list, channelId, limit, forward, cursor) + if not status then + error("Failed to list messages: " .. tostring(result)) + end + + local msgs = {} + if result and result.messages then + for _, msg in ipairs(result.messages) do + table.insert(msgs, { + message_id = msg.message_id, + sender_id = msg.sender_id, + username = msg.username, + content = msg.content, + create_time = msg.create_time, + update_time = msg.update_time, + channel_id = msg.channel_id + }) + end + end + + return nk.json_encode({ + messages = msgs, + next_cursor = result.next_cursor or "", + cache_cursor = result.cache_cursor or "" + }) +end + +function admin.rpc_admin_delete_channel_message(context, payload) + utils.require_admin(context) + local request = nk.json_decode(payload or "{}") + local channelId = request.channel_id or "" + local messageId = request.message_id or "" + + if channelId == "" or messageId == "" then + error("channel_id and message_id are required") + end + + local status, err = pcall(nk.channel_message_remove, channelId, messageId) + if not status then + error("Failed to delete message: " .. tostring(err)) + end + + nk.logger_info("[AdminChat] Deleted message " .. messageId .. " from channel " .. channelId .. " by " .. context.user_id) + return nk.json_encode({ success = true }) +end + -- Register RPCs nk.register_rpc(admin.rpc_admin_kick_player, "admin_kick_player") nk.register_rpc(admin.rpc_admin_ban_player, "admin_ban_player") @@ -349,6 +500,11 @@ nk.register_rpc(admin.rpc_admin_topup_gold, "admin_topup_gold") nk.register_rpc(admin.rpc_admin_clear_global_chat, "admin_clear_global_chat") nk.register_rpc(admin.rpc_admin_list_users, "admin_list_users") nk.register_rpc(admin.rpc_admin_delete_users, "admin_delete_users") +nk.register_rpc(admin.rpc_admin_get_chat_config, "admin_get_chat_config") +nk.register_rpc(admin.rpc_admin_set_chat_config, "admin_set_chat_config") +nk.register_rpc(admin.rpc_admin_purge_old_messages, "admin_purge_old_messages") +nk.register_rpc(admin.rpc_admin_list_channel_messages, "admin_list_channel_messages") +nk.register_rpc(admin.rpc_admin_delete_channel_message, "admin_delete_channel_message") nk.logger_info("LUA TEST: admin module loaded successfully") diff --git a/server/nakama/lua/utils.lua b/server/nakama/lua/utils.lua index 6e51342..810157f 100644 --- a/server/nakama/lua/utils.lua +++ b/server/nakama/lua/utils.lua @@ -3,6 +3,7 @@ local nk = require("nakama") local utils = {} utils.ADMIN_ROLES = { ["admin"] = true, ["moderator"] = true, ["owner"] = true } +utils.SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000" function utils.is_admin(context) if not context.user_id then return false end