From 7abf37ea8117af19de52cb8208fd0e496a1c7512 Mon Sep 17 00:00:00 2001 From: daddy Date: Sat, 19 Sep 2026 13:38:23 +0000 Subject: [PATCH] fix(chat): support body and content fields in world and DM chat messages; feat(audio): add sound toggle switch and mute controls --- backend/app/routers/chat_dm.py | 97 +++++++++++- backend/app/routers/chat_world.py | 31 +++- scripts/api_client.gd | 23 ++- scripts/audio_manager.gd | 57 +++++++ scripts/chat_ui.gd | 2 +- scripts/controls_modal.gd | 1 + scripts/game.gd | 42 ++++- scripts/main_menu.gd | 39 +++++ scripts/pause_menu.gd | 38 ++++- scripts/smoke_test_audio_manager.gd | 48 +++++- scripts/smoke_test_chat_ui.gd | 16 ++ scripts/smoke_test_world_chat_and_sound.gd | 172 +++++++++++++++++++++ scripts/social_api_client.gd | 6 +- 13 files changed, 558 insertions(+), 14 deletions(-) create mode 100644 scripts/smoke_test_world_chat_and_sound.gd diff --git a/backend/app/routers/chat_dm.py b/backend/app/routers/chat_dm.py index c12fb66..b778ca2 100644 --- a/backend/app/routers/chat_dm.py +++ b/backend/app/routers/chat_dm.py @@ -71,7 +71,7 @@ from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, computed_field, model_validator from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -97,7 +97,33 @@ _BODY_MAX_LEN = 4000 class SendDMRequest(BaseModel): - body: str = Field(..., min_length=1, max_length=_BODY_MAX_LEN) + body: str | None = Field(default=None, max_length=_BODY_MAX_LEN) + content: str | None = Field(default=None, max_length=_BODY_MAX_LEN) + + @model_validator(mode="before") + @classmethod + def resolve_body_or_content(cls, data: object) -> object: + if isinstance(data, dict): + if not data.get("body") and data.get("content"): + data["body"] = data["content"] + elif not data.get("content") and data.get("body"): + data["content"] = data["body"] + return data + + @model_validator(mode="after") + def check_non_empty(self) -> SendDMRequest: + text = self.body or self.content + if not text or len(text.strip()) < 1: + raise ValueError("Message body or content must not be empty") + if not self.body: + self.body = text + if not self.content: + self.content = text + return self + + +class StartConversationRequest(BaseModel): + target_user_id: int class DMMessageResponse(BaseModel): @@ -111,6 +137,11 @@ class DMMessageResponse(BaseModel): model_config = {"from_attributes": True} + @computed_field + @property + def content(self) -> str: + return self.body + class DMMessageListResponse(BaseModel): messages: list[DMMessageResponse] @@ -397,3 +428,65 @@ async def list_conversations( ) return DMConversationListResponse(conversations=summaries, total=total) +@router.post("/conversations") +async def start_or_get_conversation_endpoint( + body: StartConversationRequest, + current_user: CurrentUser, + db: DbDep, +): + """Start or get existing conversation with target_user_id.""" + if body.target_user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot start a conversation with yourself" + ) + await _get_target_user(db, body.target_user_id) + conversation = await _get_or_create_conversation(db, current_user.id, body.target_user_id) + return {"id": conversation.id, "target_user_id": body.target_user_id} + + +@router.get("/conversations/{conversation_id}/messages", response_model=DMMessageListResponse) +async def get_dm_conversation_history( + conversation_id: int, + current_user: CurrentUser, + db: DbDep, + limit: int = Query(50, ge=1, le=100), + before: int | None = Query(None), +): + """Get messages for a conversation by conversation_id.""" + conv_result = await db.execute( + select(Conversation).where( + Conversation.id == conversation_id, + or_(Conversation.user_a_id == current_user.id, Conversation.user_b_id == current_user.id), + ) + ) + conversation = conv_result.scalar_one_or_none() + if conversation is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found") + peer_id = _peer_id(conversation, current_user.id) + return await get_dm_history(peer_id, current_user, db, limit=limit, before=before) + + +@router.post( + "/conversations/{conversation_id}/messages", + response_model=DMMessageResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(chat_dm_send_rate_limit)], +) +async def send_dm_to_conversation( + conversation_id: int, + body: SendDMRequest, + current_user: CurrentUser, + db: DbDep, +): + """Send a message to a conversation by conversation_id.""" + conv_result = await db.execute( + select(Conversation).where( + Conversation.id == conversation_id, + or_(Conversation.user_a_id == current_user.id, Conversation.user_b_id == current_user.id), + ) + ) + conversation = conv_result.scalar_one_or_none() + if conversation is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found") + peer_id = _peer_id(conversation, current_user.id) + return await send_dm(peer_id, body, current_user, db) diff --git a/backend/app/routers/chat_world.py b/backend/app/routers/chat_world.py index 9eda708..3436ad8 100644 --- a/backend/app/routers/chat_world.py +++ b/backend/app/routers/chat_world.py @@ -39,7 +39,7 @@ from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, computed_field, model_validator from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload @@ -68,9 +68,31 @@ _BODY_MAX_LEN = 4000 class SendWorldMessageRequest(BaseModel): - body: str = Field(..., min_length=1, max_length=_BODY_MAX_LEN) + body: str | None = Field(default=None, max_length=_BODY_MAX_LEN) + content: str | None = Field(default=None, max_length=_BODY_MAX_LEN) room_id: int | None = None + @model_validator(mode="before") + @classmethod + def resolve_body_or_content(cls, data: object) -> object: + if isinstance(data, dict): + if not data.get("body") and data.get("content"): + data["body"] = data["content"] + elif not data.get("content") and data.get("body"): + data["content"] = data["body"] + return data + + @model_validator(mode="after") + def check_non_empty(self) -> SendWorldMessageRequest: + text = self.body or self.content + if not text or len(text.strip()) < 1: + raise ValueError("Message body or content must not be empty") + if not self.body: + self.body = text + if not self.content: + self.content = text + return self + class AuthorInfo(BaseModel): id: int | None @@ -92,6 +114,11 @@ class WorldMessageResponse(BaseModel): model_config = {"from_attributes": True} + @computed_field + @property + def content(self) -> str: + return self.body + class WorldMessageListResponse(BaseModel): messages: list[WorldMessageResponse] diff --git a/scripts/api_client.gd b/scripts/api_client.gd index 25e0a7c..fe388ac 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -195,7 +195,28 @@ func request( var detail := "HTTP error %d" % response_code var parsed: Variant = _parse_json(json_string) if parsed is Dictionary and (parsed as Dictionary).has("detail"): - detail = str((parsed as Dictionary)["detail"]) + var raw_detail = (parsed as Dictionary)["detail"] + if raw_detail is Array and not raw_detail.is_empty(): + var parts: Array[String] = [] + for val_err in raw_detail: + if val_err is Dictionary and val_err.has("msg"): + var loc_arr = val_err.get("loc", []) + var loc_str := "" + if loc_arr is Array: + var filtered_loc: Array[String] = [] + for x in loc_arr: + if str(x) != "body": + filtered_loc.append(str(x)) + loc_str = ".".join(filtered_loc) + if loc_str != "": + parts.append("%s: %s" % [loc_str, str(val_err.get("msg"))]) + else: + parts.append(str(val_err.get("msg"))) + else: + parts.append(str(val_err)) + detail = ", ".join(parts) + else: + detail = str(raw_detail) return _make_result(false, response_code, null, detail) diff --git a/scripts/audio_manager.gd b/scripts/audio_manager.gd index f1f002b..6340043 100644 --- a/scripts/audio_manager.gd +++ b/scripts/audio_manager.gd @@ -13,10 +13,16 @@ const STREAM_BELL: AudioStream = preload("res://assets/audio/bell.wav") const STREAM_DOOR: AudioStream = preload("res://assets/audio/door_open.wav") const POOL_SIZE: int = 16 +const SETTINGS_FILE := "user://audio_settings.json" + +signal sound_toggled(enabled: bool) var _players: Array[AudioStreamPlayer] = [] var _player_index: int = 0 +## Whether sound effects and audio output are enabled. +var sound_enabled: bool = true + ## Number of active AudioStreamPlayers in the pool. var pool_size: int: get: @@ -26,12 +32,59 @@ var pool_size: int: func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS _init_pool() + _load_settings() var tree := get_tree() if tree != null: tree.node_added.connect(_on_node_added) _hook_existing_buttons(tree.root) +func _load_settings() -> void: + if FileAccess.file_exists(SETTINGS_FILE): + var file := FileAccess.open(SETTINGS_FILE, FileAccess.READ) + if file != null: + var text := file.get_as_text() + var parsed = JSON.parse_string(text) + if parsed is Dictionary and parsed.has("sound_enabled"): + set_sound_enabled(bool(parsed["sound_enabled"]), false) + return + set_sound_enabled(true, false) + + +func _save_settings() -> void: + var file := FileAccess.open(SETTINGS_FILE, FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify({"sound_enabled": sound_enabled})) + + +## Check whether sound is currently enabled. +func is_sound_enabled() -> bool: + return sound_enabled + + +## Enable or disable sound output. +func set_sound_enabled(enabled: bool, save: bool = true) -> void: + sound_enabled = enabled + if not sound_enabled: + for player in _players: + if player != null and is_instance_valid(player) and player.playing: + player.stop() + var master_idx := AudioServer.get_bus_index("Master") + if master_idx >= 0: + AudioServer.set_bus_mute(master_idx, not sound_enabled) + if save: + _save_settings() + sound_toggled.emit(sound_enabled) + + +## Toggle sound between ON and OFF. Returns new state. +func toggle_sound() -> bool: + set_sound_enabled(not sound_enabled) + if sound_enabled: + play_click() + return sound_enabled + + func _init_pool() -> void: if not _players.is_empty(): return @@ -45,6 +98,8 @@ func _init_pool() -> void: ## Play an audio stream on the specified bus with volume and pitch settings. func play_sound(stream: AudioStream, bus: String = "Master", volume_db: float = 0.0, pitch_scale: float = 1.0) -> AudioStreamPlayer: + if not sound_enabled: + return null if stream == null: return null if _players.is_empty(): @@ -120,6 +175,8 @@ func _hook_button(btn: BaseButton) -> void: func _on_button_pressed(btn: BaseButton) -> void: + if not sound_enabled: + return if not is_instance_valid(btn): return if btn.is_in_group("no_click_sound"): diff --git a/scripts/chat_ui.gd b/scripts/chat_ui.gd index b95dd4e..79f1568 100644 --- a/scripts/chat_ui.gd +++ b/scripts/chat_ui.gd @@ -280,7 +280,7 @@ func _add_message_row(container: VBoxContainer, msg_data: Dictionary, conv_type: elif msg_data.has("sender") and msg_data["sender"] is Dictionary: author = str(msg_data["sender"].get("username", author)) - var content: String = str(msg_data.get("content", "")) + var content: String = str(msg_data.get("content", msg_data.get("body", ""))) var label := Label.new() label.text = "[%s]: %s" % [author, content] diff --git a/scripts/controls_modal.gd b/scripts/controls_modal.gd index f7c4175..1c71013 100644 --- a/scripts/controls_modal.gd +++ b/scripts/controls_modal.gd @@ -13,6 +13,7 @@ const CONTROLS_DATA = [ {"key": "E", "action": "Interact with objects & chests"}, {"key": "T / Enter", "action": "Open In-World Chat"}, {"key": "Tab / F1", "action": "Friends & Social Panel"}, + {"key": "M", "action": "Toggle Sound (Mute / Unmute)"}, {"key": "F2", "action": "Creator Studio / World Editor"}, {"key": "F11 / Alt+Enter", "action": "Toggle Fullscreen"}, {"key": "ESC", "action": "Pause Menu / Go Back / Close Modal"}, diff --git a/scripts/game.gd b/scripts/game.gd index 4601bf4..7234e69 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -188,6 +188,13 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() _toggle_friends_overlay() return + elif event.keycode == KEY_M: + if not (_build_editor != null and is_instance_valid(_build_editor)) and not (_chat_overlay != null and is_instance_valid(_chat_overlay)): + get_viewport().set_input_as_handled() + var audio_mgr = get_node_or_null("/root/AudioManager") + if audio_mgr and audio_mgr.has_method("toggle_sound"): + audio_mgr.toggle_sound() + return if event.is_action_pressed("toggle_build_mode"): get_viewport().set_input_as_handled() @@ -263,6 +270,27 @@ func _setup_in_game_hud() -> void: report_btn.pressed.connect(_toggle_report_modal) bar.add_child(report_btn) + var sound_btn := Button.new() + sound_btn.name = "SoundButton" + _update_hud_sound_button(sound_btn) + sound_btn.pressed.connect(func(): + var audio_mgr = get_node_or_null("/root/AudioManager") + if audio_mgr and audio_mgr.has_method("toggle_sound"): + audio_mgr.toggle_sound() + _update_hud_sound_button(sound_btn) + ) + var audio_singleton = get_node_or_null("/root/AudioManager") + if audio_singleton and audio_singleton.has_signal("sound_toggled"): + var on_toggled = func(_enabled: bool): + if is_instance_valid(sound_btn): + _update_hud_sound_button(sound_btn) + audio_singleton.sound_toggled.connect(on_toggled) + sound_btn.tree_exiting.connect(func(): + if is_instance_valid(audio_singleton) and audio_singleton.sound_toggled.is_connected(on_toggled): + audio_singleton.sound_toggled.disconnect(on_toggled) + ) + bar.add_child(sound_btn) + var exit_btn := Button.new() exit_btn.text = "🚪 Menu [ESC]" exit_btn.pressed.connect(func(): @@ -301,7 +329,7 @@ func _setup_in_game_hud() -> void: controls_pill.offset_right = -220 var hint_lbl := Label.new() - hint_lbl.text = "[W][A][S][D] Move [Space] Jump [E] Interact [F1] Friends [T] Chat [F2] Build [ESC] Menu" + hint_lbl.text = "[W][A][S][D] Move [Space] Jump [E] Interact [F1] Friends [T] Chat [M] Sound [F2] Build [ESC] Menu" hint_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER hint_lbl.add_theme_font_size_override("font_size", 12) hint_lbl.add_theme_color_override("font_color", Color(0.85, 0.9, 0.95, 0.85)) @@ -336,6 +364,18 @@ func _setup_in_game_hud() -> void: _hud_layer.add_child(creator_pill) +static func _update_hud_sound_button(btn: Button) -> void: + if btn == null or not is_instance_valid(btn): + return + var main_loop = Engine.get_main_loop() + var audio_mgr: Node = main_loop.root.get_node_or_null("AudioManager") if main_loop is SceneTree else null + var enabled: bool = audio_mgr.is_sound_enabled() if audio_mgr and audio_mgr.has_method("is_sound_enabled") else true + if enabled: + btn.text = "🔊 Sound [M]" + else: + btn.text = "🔇 Muted [M]" + + func _toggle_report_modal() -> void: if _report_modal != null and is_instance_valid(_report_modal): _close_report_modal() diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd index ee8c349..ff3008c 100644 --- a/scripts/main_menu.gd +++ b/scripts/main_menu.gd @@ -87,6 +87,18 @@ static func _update_fs_button(btn: Button) -> void: btn.text = "⛶ Fullscreen (F11)" +static func _update_sound_button(btn: Button) -> void: + if btn == null or not is_instance_valid(btn): + return + var main_loop = Engine.get_main_loop() + var audio_mgr: Node = main_loop.root.get_node_or_null("AudioManager") if main_loop is SceneTree else null + var enabled: bool = audio_mgr.is_sound_enabled() if audio_mgr and audio_mgr.has_method("is_sound_enabled") else true + if enabled: + btn.text = "🔊 Sound: ON" + else: + btn.text = "🔇 Sound: OFF" + + func _ready() -> void: var v := Label.new() var bl := _build_label() @@ -134,6 +146,33 @@ func _ready() -> void: ctrl_btn.pressed.connect(_on_controls_button_pressed) add_child(ctrl_btn) + var sound_btn := Button.new() + sound_btn.name = "SoundButton" + sound_btn.set_anchors_preset(Control.PRESET_TOP_RIGHT) + sound_btn.offset_left = -440 + sound_btn.offset_top = 10 + sound_btn.offset_right = -310 + sound_btn.offset_bottom = 38 + sound_btn.add_theme_font_size_override("font_size", 12) + _update_sound_button(sound_btn) + sound_btn.pressed.connect(func(): + var audio_mgr = get_node_or_null("/root/AudioManager") + if audio_mgr and audio_mgr.has_method("toggle_sound"): + audio_mgr.toggle_sound() + _update_sound_button(sound_btn) + ) + var audio_singleton = get_node_or_null("/root/AudioManager") + if audio_singleton and audio_singleton.has_signal("sound_toggled"): + var on_toggled = func(_enabled: bool): + if is_instance_valid(sound_btn): + _update_sound_button(sound_btn) + audio_singleton.sound_toggled.connect(on_toggled) + sound_btn.tree_exiting.connect(func(): + if is_instance_valid(audio_singleton) and audio_singleton.sound_toggled.is_connected(on_toggled): + audio_singleton.sound_toggled.disconnect(on_toggled) + ) + add_child(sound_btn) + # Cache node references. _auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow _login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton diff --git a/scripts/pause_menu.gd b/scripts/pause_menu.gd index 4bfcb03..abd0ef4 100644 --- a/scripts/pause_menu.gd +++ b/scripts/pause_menu.gd @@ -17,6 +17,7 @@ var world_title: String = "World" var _confirm_box: VBoxContainer var _main_menu_vbox: VBoxContainer var _fs_button: Button +var _sound_button: Button func _init(p_can_edit: bool = false, p_world_title: String = "World") -> void: @@ -101,6 +102,21 @@ func _ready() -> void: _update_fs_label() _main_menu_vbox.add_child(_fs_button) + _sound_button = _make_menu_btn("🔊 Sound FX: ON (M)", _on_toggle_sound) + _update_sound_label() + _main_menu_vbox.add_child(_sound_button) + + var audio_mgr = get_node_or_null("/root/AudioManager") + if audio_mgr and audio_mgr.has_signal("sound_toggled"): + var on_toggled = func(_enabled: bool): + if is_instance_valid(_sound_button): + _update_sound_label() + audio_mgr.sound_toggled.connect(on_toggled) + _sound_button.tree_exiting.connect(func(): + if is_instance_valid(audio_mgr) and audio_mgr.sound_toggled.is_connected(on_toggled): + audio_mgr.sound_toggled.disconnect(on_toggled) + ) + var leave_btn := _make_menu_btn("🚪 Leave World", _on_prompt_leave, Color(1.0, 0.45, 0.45)) _main_menu_vbox.add_child(leave_btn) @@ -152,7 +168,9 @@ func _on_resume_pressed() -> void: func _on_toggle_fs() -> void: - Session.toggle_fullscreen() + var sess = Engine.get_main_loop().root.get_node_or_null("Session") if Engine.get_main_loop() else null + if sess and sess.has_method("toggle_fullscreen"): + sess.toggle_fullscreen() _update_fs_label() @@ -169,6 +187,24 @@ func _update_fs_label() -> void: _fs_button.text = "⛶ Fullscreen Mode (F11)" +func _on_toggle_sound() -> void: + var audio_mgr = get_node_or_null("/root/AudioManager") + if audio_mgr and audio_mgr.has_method("toggle_sound"): + audio_mgr.toggle_sound() + _update_sound_label() + + +func _update_sound_label() -> void: + if _sound_button == null or not is_instance_valid(_sound_button): + return + var audio_mgr = get_node_or_null("/root/AudioManager") + var enabled: bool = audio_mgr.is_sound_enabled() if audio_mgr and audio_mgr.has_method("is_sound_enabled") else true + if enabled: + _sound_button.text = "🔊 Sound FX: ON (M)" + else: + _sound_button.text = "🔇 Sound FX: OFF (M)" + + func _on_prompt_leave() -> void: _main_menu_vbox.visible = false _confirm_box.visible = true diff --git a/scripts/smoke_test_audio_manager.gd b/scripts/smoke_test_audio_manager.gd index fde0f9b..e0100e5 100644 --- a/scripts/smoke_test_audio_manager.gd +++ b/scripts/smoke_test_audio_manager.gd @@ -15,6 +15,7 @@ func _run() -> void: quit(1) return print("PASS: AudioManager autoload exists in scene tree.") + audio_mgr.set_sound_enabled(true, false) # 1. Verify streams are valid AudioStream objects var streams := [ @@ -117,9 +118,50 @@ func _run() -> void: # Button in no_click_sound group var silent_btn := Button.new() silent_btn.add_to_group("no_click_sound") - root.add_child(silent_btn) - await process_frame - silent_btn.emit_signal("pressed") + # 6. Verify sound toggle & mute functionality + var tracker := {"received": false, "val": false} + audio_mgr.sound_toggled.connect(func(val: bool): + tracker["received"] = true + tracker["val"] = val + ) + + # Toggle OFF + var new_state: bool = audio_mgr.toggle_sound() + if new_state != false or audio_mgr.is_sound_enabled() != false: + printerr("FAIL: toggle_sound() should disable sound") + quit(1) + return + if not tracker["received"] or tracker["val"] != false: + printerr("FAIL: sound_toggled signal not fired on disable") + quit(1) + return + + # When sound disabled, play_click should return null + var disabled_p: AudioStreamPlayer = audio_mgr.play_click() + if disabled_p != null: + printerr("FAIL: play_click() returned non-null when sound disabled") + quit(1) + return + print("PASS: Sound disable successfully suppressed audio playback.") + + # Toggle back ON + tracker["received"] = false + new_state = audio_mgr.toggle_sound() + if new_state != true or audio_mgr.is_sound_enabled() != true: + printerr("FAIL: toggle_sound() should re-enable sound") + quit(1) + return + if not tracker["received"] or tracker["val"] != true: + printerr("FAIL: sound_toggled signal not fired on re-enable") + quit(1) + return + + var reenabled_p: AudioStreamPlayer = audio_mgr.play_click() + if reenabled_p == null: + printerr("FAIL: play_click() returned null after re-enabling sound") + quit(1) + return + print("PASS: Sound re-enable restored audio playback and signal notifications.") print("SMOKE RESULT: PASS") quit(0) diff --git a/scripts/smoke_test_chat_ui.gd b/scripts/smoke_test_chat_ui.gd index 859d895..7eaf845 100644 --- a/scripts/smoke_test_chat_ui.gd +++ b/scripts/smoke_test_chat_ui.gd @@ -58,6 +58,14 @@ func _run() -> void: } ui._add_message_row(ui.world_messages_container, mock_msg, "world") + # Test message row with body field fallback + var mock_msg_body := { + "id": 102, + "author": {"username": "BodyUser"}, + "body": "Fallback body content." + } + ui._add_message_row(ui.world_messages_container, mock_msg_body, "world") + if ui.world_messages_container.get_child_count() == 0: printerr("FAIL: Message row was not added to world_messages_container") quit(1) @@ -75,6 +83,14 @@ func _run() -> void: quit(1) return + var row2: HBoxContainer = ui.world_messages_container.get_child(1) + var lbl2: Label = row2.get_child(0) as Label + if not lbl2 or not lbl2.text.contains("Fallback body content."): + printerr("FAIL: Fallback body text was not displayed in message row") + quit(1) + return + print("PASS: Message row with body fallback verified.") + print("PASS: Message row with Report button verified.") # Test report dialog preparation diff --git a/scripts/smoke_test_world_chat_and_sound.gd b/scripts/smoke_test_world_chat_and_sound.gd new file mode 100644 index 0000000..9885e38 --- /dev/null +++ b/scripts/smoke_test_world_chat_and_sound.gd @@ -0,0 +1,172 @@ +extends SceneTree +## Headless verification for world chat message send and sound toggle. + +const PauseMenuScript = preload("res://scripts/pause_menu.gd") + +func _initialize() -> void: + _run() + +func _run() -> void: + await process_frame + + print("SMOKE: Starting World Chat Send & Sound Toggle verification...") + + # 1. Verify AudioManager and sound toggle + var audio_mgr = root.get_node_or_null("AudioManager") + if audio_mgr == null: + printerr("FAIL: AudioManager not found in root") + quit(1) + return + + # Test initial state + audio_mgr.set_sound_enabled(true, false) + if not audio_mgr.is_sound_enabled(): + printerr("FAIL: sound_enabled should be true") + quit(1) + return + + var p = audio_mgr.play_click() + if p == null: + printerr("FAIL: play_click should return player when sound enabled") + quit(1) + return + print("PASS: Sound plays when enabled.") + + # Test toggle OFF + var toggled: bool = audio_mgr.toggle_sound() + if toggled != false or audio_mgr.is_sound_enabled() != false: + printerr("FAIL: toggle_sound should return false") + quit(1) + return + if audio_mgr.play_click() != null: + printerr("FAIL: play_click should return null when sound disabled") + quit(1) + return + print("PASS: Sound disabled and suppressed.") + + # Test toggle ON + toggled = audio_mgr.toggle_sound() + if toggled != true or audio_mgr.is_sound_enabled() != true: + printerr("FAIL: toggle_sound should return true") + quit(1) + return + if audio_mgr.play_click() == null: + printerr("FAIL: play_click should return player after re-enabling") + quit(1) + return + print("PASS: Sound re-enabled and restored.") + + # 2. Verify MainMenu SoundButton + var main_menu_scene := load("res://scenes/main_menu.tscn") + if main_menu_scene: + var menu = main_menu_scene.instantiate() + root.add_child(menu) + await process_frame + var sound_btn = menu.get_node_or_null("SoundButton") + if sound_btn == null: + printerr("FAIL: SoundButton not found on MainMenu") + quit(1) + return + if not sound_btn.text.contains("Sound: ON"): + printerr("FAIL: SoundButton text should be ON, got: ", sound_btn.text) + quit(1) + return + # Click button + sound_btn.emit_signal("pressed") + await process_frame + if not sound_btn.text.contains("Sound: OFF"): + printerr("FAIL: SoundButton text should be OFF after click, got: ", sound_btn.text) + quit(1) + return + audio_mgr.set_sound_enabled(true, false) + menu.queue_free() + await process_frame + print("PASS: MainMenu SoundButton toggle verified.") + + # 3. Verify PauseMenu sound button + var pause_menu = PauseMenuScript.new(true, "Test World") + root.add_child(pause_menu) + await process_frame + if pause_menu._sound_button == null: + printerr("FAIL: _sound_button not found in PauseMenu") + quit(1) + return + if not pause_menu._sound_button.text.contains("Sound FX: ON"): + printerr("FAIL: PauseMenu sound button should say ON, got: ", pause_menu._sound_button.text) + quit(1) + return + pause_menu._sound_button.emit_signal("pressed") + await process_frame + if not pause_menu._sound_button.text.contains("Sound FX: OFF"): + printerr("FAIL: PauseMenu sound button should say OFF after toggle, got: ", pause_menu._sound_button.text) + quit(1) + return + audio_mgr.set_sound_enabled(true, false) + pause_menu.queue_free() + await process_frame + print("PASS: PauseMenu sound button toggle verified.") + + # 4. Verify in-game Game HUD SoundButton + var game_scene := load("res://scenes/game.tscn") + if game_scene: + var game_node = game_scene.instantiate() + root.add_child(game_node) + await process_frame + var hud_sound_btn = game_node.find_child("SoundButton", true, false) as Button + if hud_sound_btn == null: + printerr("FAIL: SoundButton not found in game HUD") + quit(1) + return + if not hud_sound_btn.text.contains("Sound [M]"): + printerr("FAIL: HUD sound button should say Sound [M], got: ", hud_sound_btn.text) + quit(1) + return + hud_sound_btn.emit_signal("pressed") + await process_frame + if not hud_sound_btn.text.contains("Muted [M]"): + printerr("FAIL: HUD sound button should say Muted [M] after click, got: ", hud_sound_btn.text) + quit(1) + return + audio_mgr.set_sound_enabled(true, false) + game_node.queue_free() + await process_frame + print("PASS: In-game HUD sound button toggle verified.") + + # 5. Verify world chat API send & retrieve (if backend reachable) + var api_client = root.get_node_or_null("ApiClient") + if api_client != null: + var reg_res = await api_client.register("chat_u_%d" % int(Time.get_unix_time_from_system()), "testpass123", "chat_%d@test.com" % int(Time.get_unix_time_from_system()), true) + if reg_res.ok: + var world_res = await api_client.create_world("ChatTestWorld_%d" % int(Time.get_unix_time_from_system()), "Chat test world") + if world_res.ok and world_res.data is Dictionary: + var wid: int = int(world_res.data.get("id", 0)) + var send_res = await api_client.send_world_chat_message(wid, "Test message from smoke test!") + if not send_res.ok: + printerr("FAIL: send_world_chat_message failed: ", send_res.detail) + quit(1) + return + print("PASS: send_world_chat_message succeeded.") + var list_res = await api_client.get_world_chat_messages(wid) + if not list_res.ok: + printerr("FAIL: get_world_chat_messages failed: ", list_res.detail) + quit(1) + return + var msgs: Array = [] + if list_res.data is Dictionary and list_res.data.has("messages"): + msgs = list_res.data["messages"] + elif list_res.data is Array: + msgs = list_res.data + if msgs.is_empty(): + printerr("FAIL: world chat messages empty after send") + quit(1) + return + var msg_obj = msgs[0] + var body_text: String = str(msg_obj.get("content", msg_obj.get("body", ""))) + if not body_text.contains("Test message from smoke test!"): + printerr("FAIL: message text mismatch: ", body_text) + quit(1) + return + print("PASS: World chat message sent and verified in history.") + + print("SMOKE RESULT: PASS") + quit(0) diff --git a/scripts/social_api_client.gd b/scripts/social_api_client.gd index a52deee..e2fba21 100644 --- a/scripts/social_api_client.gd +++ b/scripts/social_api_client.gd @@ -150,7 +150,7 @@ static func get_dm_messages(api: Node, conversation_id: int, before_id: int = 0, return await api.request(HTTPClient.METHOD_GET, path, "", "application/json", true) static func send_dm_message(api: Node, conversation_id: int, content: String) -> Dictionary: - return await api.request(HTTPClient.METHOD_POST, "/api/chat/dm/conversations/%d/messages" % conversation_id, JSON.stringify({"content": content}), "application/json", true) + return await api.request(HTTPClient.METHOD_POST, "/api/chat/dm/conversations/%d/messages" % conversation_id, JSON.stringify({"body": content, "content": content}), "application/json", true) static func get_world_chat_messages(api: Node, world_id: int, before_id: int = 0, limit: int = 50) -> Dictionary: var path := "/api/chat/world/%d/messages?limit=%d" % [world_id, limit] @@ -159,7 +159,7 @@ static func get_world_chat_messages(api: Node, world_id: int, before_id: int = 0 return await api.request(HTTPClient.METHOD_GET, path, "", "application/json", true) static func send_world_chat_message(api: Node, world_id: int, content: String) -> Dictionary: - return await api.request(HTTPClient.METHOD_POST, "/api/chat/world/%d/messages" % world_id, JSON.stringify({"content": content}), "application/json", true) + return await api.request(HTTPClient.METHOD_POST, "/api/chat/world/%d/messages" % world_id, JSON.stringify({"body": content, "content": content}), "application/json", true) static func get_room_chat_messages(api: Node, room_id: String, before_id: int = 0, limit: int = 50) -> Dictionary: var path := "/api/chat/room/%s/messages?limit=%d" % [room_id.uri_encode(), limit] @@ -168,7 +168,7 @@ static func get_room_chat_messages(api: Node, room_id: String, before_id: int = return await api.request(HTTPClient.METHOD_GET, path, "", "application/json", true) static func send_room_chat_message(api: Node, room_id: String, content: String) -> Dictionary: - return await api.request(HTTPClient.METHOD_POST, "/api/chat/room/%s/messages" % room_id.uri_encode(), JSON.stringify({"content": content}), "application/json", true) + return await api.request(HTTPClient.METHOD_POST, "/api/chat/room/%s/messages" % room_id.uri_encode(), JSON.stringify({"body": content, "content": content}), "application/json", true) static func report_message(api: Node, message_id: int, conversation_type: String, reason: String, context_text: String = "") -> Dictionary: var payload := {