fix(chat): support body and content fields in world and DM chat messages; feat(audio): add sound toggle switch and mute controls
checkpoint / checkpoint (push) Successful in 6m11s

This commit is contained in:
2026-09-19 13:38:23 +00:00
parent 7b693899a7
commit 7abf37ea81
13 changed files with 558 additions and 14 deletions
+95 -2
View File
@@ -71,7 +71,7 @@ from datetime import datetime, timezone
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status 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 import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -97,7 +97,33 @@ _BODY_MAX_LEN = 4000
class SendDMRequest(BaseModel): 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): class DMMessageResponse(BaseModel):
@@ -111,6 +137,11 @@ class DMMessageResponse(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@computed_field
@property
def content(self) -> str:
return self.body
class DMMessageListResponse(BaseModel): class DMMessageListResponse(BaseModel):
messages: list[DMMessageResponse] messages: list[DMMessageResponse]
@@ -397,3 +428,65 @@ async def list_conversations(
) )
return DMConversationListResponse(conversations=summaries, total=total) 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)
+29 -2
View File
@@ -39,7 +39,7 @@ from datetime import datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status 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 import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@@ -68,9 +68,31 @@ _BODY_MAX_LEN = 4000
class SendWorldMessageRequest(BaseModel): 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 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): class AuthorInfo(BaseModel):
id: int | None id: int | None
@@ -92,6 +114,11 @@ class WorldMessageResponse(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@computed_field
@property
def content(self) -> str:
return self.body
class WorldMessageListResponse(BaseModel): class WorldMessageListResponse(BaseModel):
messages: list[WorldMessageResponse] messages: list[WorldMessageResponse]
+22 -1
View File
@@ -195,7 +195,28 @@ func request(
var detail := "HTTP error %d" % response_code var detail := "HTTP error %d" % response_code
var parsed: Variant = _parse_json(json_string) var parsed: Variant = _parse_json(json_string)
if parsed is Dictionary and (parsed as Dictionary).has("detail"): 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) return _make_result(false, response_code, null, detail)
+57
View File
@@ -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 STREAM_DOOR: AudioStream = preload("res://assets/audio/door_open.wav")
const POOL_SIZE: int = 16 const POOL_SIZE: int = 16
const SETTINGS_FILE := "user://audio_settings.json"
signal sound_toggled(enabled: bool)
var _players: Array[AudioStreamPlayer] = [] var _players: Array[AudioStreamPlayer] = []
var _player_index: int = 0 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. ## Number of active AudioStreamPlayers in the pool.
var pool_size: int: var pool_size: int:
get: get:
@@ -26,12 +32,59 @@ var pool_size: int:
func _ready() -> void: func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS process_mode = Node.PROCESS_MODE_ALWAYS
_init_pool() _init_pool()
_load_settings()
var tree := get_tree() var tree := get_tree()
if tree != null: if tree != null:
tree.node_added.connect(_on_node_added) tree.node_added.connect(_on_node_added)
_hook_existing_buttons(tree.root) _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: func _init_pool() -> void:
if not _players.is_empty(): if not _players.is_empty():
return return
@@ -45,6 +98,8 @@ func _init_pool() -> void:
## Play an audio stream on the specified bus with volume and pitch settings. ## 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: 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: if stream == null:
return null return null
if _players.is_empty(): if _players.is_empty():
@@ -120,6 +175,8 @@ func _hook_button(btn: BaseButton) -> void:
func _on_button_pressed(btn: BaseButton) -> void: func _on_button_pressed(btn: BaseButton) -> void:
if not sound_enabled:
return
if not is_instance_valid(btn): if not is_instance_valid(btn):
return return
if btn.is_in_group("no_click_sound"): if btn.is_in_group("no_click_sound"):
+1 -1
View File
@@ -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: elif msg_data.has("sender") and msg_data["sender"] is Dictionary:
author = str(msg_data["sender"].get("username", author)) 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() var label := Label.new()
label.text = "[%s]: %s" % [author, content] label.text = "[%s]: %s" % [author, content]
+1
View File
@@ -13,6 +13,7 @@ const CONTROLS_DATA = [
{"key": "E", "action": "Interact with objects & chests"}, {"key": "E", "action": "Interact with objects & chests"},
{"key": "T / Enter", "action": "Open In-World Chat"}, {"key": "T / Enter", "action": "Open In-World Chat"},
{"key": "Tab / F1", "action": "Friends & Social Panel"}, {"key": "Tab / F1", "action": "Friends & Social Panel"},
{"key": "M", "action": "Toggle Sound (Mute / Unmute)"},
{"key": "F2", "action": "Creator Studio / World Editor"}, {"key": "F2", "action": "Creator Studio / World Editor"},
{"key": "F11 / Alt+Enter", "action": "Toggle Fullscreen"}, {"key": "F11 / Alt+Enter", "action": "Toggle Fullscreen"},
{"key": "ESC", "action": "Pause Menu / Go Back / Close Modal"}, {"key": "ESC", "action": "Pause Menu / Go Back / Close Modal"},
+41 -1
View File
@@ -188,6 +188,13 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
_toggle_friends_overlay() _toggle_friends_overlay()
return 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"): if event.is_action_pressed("toggle_build_mode"):
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
@@ -263,6 +270,27 @@ func _setup_in_game_hud() -> void:
report_btn.pressed.connect(_toggle_report_modal) report_btn.pressed.connect(_toggle_report_modal)
bar.add_child(report_btn) 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() var exit_btn := Button.new()
exit_btn.text = "🚪 Menu [ESC]" exit_btn.text = "🚪 Menu [ESC]"
exit_btn.pressed.connect(func(): exit_btn.pressed.connect(func():
@@ -301,7 +329,7 @@ func _setup_in_game_hud() -> void:
controls_pill.offset_right = -220 controls_pill.offset_right = -220
var hint_lbl := Label.new() 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.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint_lbl.add_theme_font_size_override("font_size", 12) 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)) 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) _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: func _toggle_report_modal() -> void:
if _report_modal != null and is_instance_valid(_report_modal): if _report_modal != null and is_instance_valid(_report_modal):
_close_report_modal() _close_report_modal()
+39
View File
@@ -87,6 +87,18 @@ static func _update_fs_button(btn: Button) -> void:
btn.text = "⛶ Fullscreen (F11)" 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: func _ready() -> void:
var v := Label.new() var v := Label.new()
var bl := _build_label() var bl := _build_label()
@@ -134,6 +146,33 @@ func _ready() -> void:
ctrl_btn.pressed.connect(_on_controls_button_pressed) ctrl_btn.pressed.connect(_on_controls_button_pressed)
add_child(ctrl_btn) 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. # Cache node references.
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow _auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton _login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
+37 -1
View File
@@ -17,6 +17,7 @@ var world_title: String = "World"
var _confirm_box: VBoxContainer var _confirm_box: VBoxContainer
var _main_menu_vbox: VBoxContainer var _main_menu_vbox: VBoxContainer
var _fs_button: Button var _fs_button: Button
var _sound_button: Button
func _init(p_can_edit: bool = false, p_world_title: String = "World") -> void: func _init(p_can_edit: bool = false, p_world_title: String = "World") -> void:
@@ -101,6 +102,21 @@ func _ready() -> void:
_update_fs_label() _update_fs_label()
_main_menu_vbox.add_child(_fs_button) _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)) 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) _main_menu_vbox.add_child(leave_btn)
@@ -152,7 +168,9 @@ func _on_resume_pressed() -> void:
func _on_toggle_fs() -> 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() _update_fs_label()
@@ -169,6 +187,24 @@ func _update_fs_label() -> void:
_fs_button.text = "⛶ Fullscreen Mode (F11)" _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: func _on_prompt_leave() -> void:
_main_menu_vbox.visible = false _main_menu_vbox.visible = false
_confirm_box.visible = true _confirm_box.visible = true
+45 -3
View File
@@ -15,6 +15,7 @@ func _run() -> void:
quit(1) quit(1)
return return
print("PASS: AudioManager autoload exists in scene tree.") print("PASS: AudioManager autoload exists in scene tree.")
audio_mgr.set_sound_enabled(true, false)
# 1. Verify streams are valid AudioStream objects # 1. Verify streams are valid AudioStream objects
var streams := [ var streams := [
@@ -117,9 +118,50 @@ func _run() -> void:
# Button in no_click_sound group # Button in no_click_sound group
var silent_btn := Button.new() var silent_btn := Button.new()
silent_btn.add_to_group("no_click_sound") silent_btn.add_to_group("no_click_sound")
root.add_child(silent_btn) # 6. Verify sound toggle & mute functionality
await process_frame var tracker := {"received": false, "val": false}
silent_btn.emit_signal("pressed") 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") print("SMOKE RESULT: PASS")
quit(0) quit(0)
+16
View File
@@ -58,6 +58,14 @@ func _run() -> void:
} }
ui._add_message_row(ui.world_messages_container, mock_msg, "world") 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: if ui.world_messages_container.get_child_count() == 0:
printerr("FAIL: Message row was not added to world_messages_container") printerr("FAIL: Message row was not added to world_messages_container")
quit(1) quit(1)
@@ -75,6 +83,14 @@ func _run() -> void:
quit(1) quit(1)
return 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.") print("PASS: Message row with Report button verified.")
# Test report dialog preparation # Test report dialog preparation
+172
View File
@@ -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)
+3 -3
View File
@@ -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) return await api.request(HTTPClient.METHOD_GET, path, "", "application/json", true)
static func send_dm_message(api: Node, conversation_id: int, content: String) -> Dictionary: 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: 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] 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) 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: 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: 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] 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) 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: 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: static func report_message(api: Node, message_id: int, conversation_type: String, reason: String, context_text: String = "") -> Dictionary:
var payload := { var payload := {