From 7ecf0d53bd8fb95ae8b597a5e2a290e974ef0187 Mon Sep 17 00:00:00 2001 From: daddy Date: Thu, 17 Sep 2026 23:13:57 +0000 Subject: [PATCH] Complete M11 Creator Studio and M12 Trust & Safety for PC5 - Creator Studio: Visual behavior & property inspector, GLB model importer, binary persistence - Trust & Safety: 18+ TOS confirmation (DOB removed), creator guidelines & briefing card on join, in-game reporting HUD - Test Harness: Add --filter, --skip-backend, --skip-godot to run_checkpoint_tests.py --- .../versions/0016_content_guidelines.py | 30 + backend/app/models/world.py | 2 + backend/app/routers/assets.py | 18 + backend/app/routers/auth.py | 37 +- backend/app/routers/chat_reports.py | 34 + backend/app/routers/discovery.py | 2 + backend/app/routers/worlds.py | 6 + backend/tests/test_auth_age_gate.py | 8 +- backend/tests/test_auth_endpoints.py | 26 +- backend/tests/test_chat_reports.py | 42 ++ scenes/main_menu.tscn | 5 +- scripts/api_client.gd | 45 +- scripts/discovery_api_client.gd | 3 + scripts/discovery_ui.gd | 19 +- scripts/editor/chunk_editor_model.gd | 6 +- scripts/editor/editor_core.gd | 8 + scripts/editor/object_catalog.gd | 15 + scripts/editor/tools/transform_tool.gd | 14 + scripts/editor/ui/inspector_panel.gd | 597 +++++++++++++++++- scripts/editor/ui/palette_panel.gd | 190 +++++- scripts/game.gd | 59 +- scripts/main_menu.gd | 35 +- scripts/report_modal.gd | 176 ++++++ scripts/run_checkpoint_tests.py | 152 ++--- scripts/smoke_test_creator_studio_importer.gd | 103 +++ .../smoke_test_creator_studio_inspector.gd | 152 +++++ scripts/smoke_test_safe_worlds_briefing.gd | 132 ++++ scripts/static_object_node.gd | 57 ++ scripts/world_briefing_modal.gd | 224 +++++++ scripts/world_list.gd | 59 +- 30 files changed, 2099 insertions(+), 157 deletions(-) create mode 100644 backend/alembic/versions/0016_content_guidelines.py create mode 100644 scripts/report_modal.gd create mode 100644 scripts/smoke_test_creator_studio_importer.gd create mode 100644 scripts/smoke_test_creator_studio_inspector.gd create mode 100644 scripts/smoke_test_safe_worlds_briefing.gd create mode 100644 scripts/world_briefing_modal.gd diff --git a/backend/alembic/versions/0016_content_guidelines.py b/backend/alembic/versions/0016_content_guidelines.py new file mode 100644 index 0000000..744727f --- /dev/null +++ b/backend/alembic/versions/0016_content_guidelines.py @@ -0,0 +1,30 @@ +"""add content_guidelines column to worlds table + +Revision ID: 0016_content_guidelines +Revises: 0015_add_discovery_metadata +Create Date: 2026-09-17 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0016_content_guidelines" +down_revision: Union[str, None] = "0015_add_discovery_metadata" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Check if column already exists (in case partial run added column before version update failed) + bind = op.get_bind() + insp = sa.inspect(bind) + cols = [c["name"] for c in insp.get_columns("worlds")] + if "content_guidelines" not in cols: + op.add_column("worlds", sa.Column("content_guidelines", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("worlds", "content_guidelines") diff --git a/backend/app/models/world.py b/backend/app/models/world.py index 9cdee7c..683dfdb 100644 --- a/backend/app/models/world.py +++ b/backend/app/models/world.py @@ -31,11 +31,13 @@ class World(Base): ) category = Column(String(100), nullable=True, index=True) tags = Column(ARRAY(String), nullable=True, server_default="{}") + content_guidelines = Column(Text, nullable=True) play_count = Column(Integer, nullable=False, server_default="0", index=True) last_played_at = Column(DateTime(timezone=True), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + __table_args__ = ( Index("ix_worlds_tags", "tags", postgresql_using="gin"), ) diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index ca79905..585ee7b 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query +from fastapi.responses import FileResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -241,3 +242,20 @@ async def upload_asset_file( await db.commit() await db.refresh(asset) return asset + + +@router.get("/{asset_id}/file") +async def get_asset_file( + asset_id: int, + db: AsyncSession = Depends(get_db), +): + """Download an asset binary file.""" + asset = await db.get(Asset, asset_id) + if asset is None or not asset.file_path or not os.path.exists(asset.file_path): + raise HTTPException(status_code=404, detail="Asset file not found") + return FileResponse( + asset.file_path, + media_type=asset.mime_type or "application/octet-stream", + filename=os.path.basename(asset.file_path), + ) + diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 0fdd190..68470c9 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -43,12 +43,10 @@ class RegisterRequest(BaseModel): username: str email: EmailStr password: str - # Real date of birth -- the server computes age from it and rejects - # anyone under MIN_AGE. Still self-attested (no document check yet), but - # a concrete, stored, legally-meaningful data point rather than a - # throwaway checkbox, and the seam for a future hard ID-verification - # pass. See docs/architecture/content_moderation.md 5.2. - date_of_birth: date + # ModBison is an adults-only platform (18+). Users confirm agreement with + # 18+ Terms of Service. Date of birth is optional / deprecated. + over_18: bool = True + date_of_birth: date | None = None class UserResponse(BaseModel): @@ -159,20 +157,25 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str: async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): """Register a new user and return access + refresh tokens. - Requires a date of birth; the server computes age and rejects anyone - under MIN_AGE (18). See docs/architecture/content_moderation.md for the - verification design and its self-attestation limitation. + Requires confirming 18+ TOS agreement. If a date of birth is supplied, + verifies the user is >= MIN_AGE (18). """ - age = calculate_age(req.date_of_birth) - if age < 0 or req.date_of_birth > date.today(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid date of birth", - ) - if age < MIN_AGE: + if req.date_of_birth is not None: + age = calculate_age(req.date_of_birth) + if age < 0 or req.date_of_birth > date.today(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid date of birth", + ) + if age < MIN_AGE: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"You must be at least {MIN_AGE} to use this platform", + ) + elif not req.over_18: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"You must be at least {MIN_AGE} to use this platform", + detail="You must confirm you are 18 or older to register", ) # Check if username already exists diff --git a/backend/app/routers/chat_reports.py b/backend/app/routers/chat_reports.py index 2a63646..195f729 100644 --- a/backend/app/routers/chat_reports.py +++ b/backend/app/routers/chat_reports.py @@ -124,9 +124,42 @@ async def report_message( return report +class ModerationReportCreate(BaseModel): + target_type: Literal["message", "world", "user"] + target_id: int + reason: str = Field(..., min_length=1, max_length=500) + category: str | None = Field(default="general", max_length=50) + + # ──────────────────────────────── Moderation ──────────────────────────────────── +@moderation_router.post( + "/reports", + response_model=MessageReportResponse, + status_code=status.HTTP_201_CREATED, +) +async def submit_moderation_report( + payload: ModerationReportCreate, + current_user: CurrentUser, + db: DbDep, +): + """Submit a moderation report for a world, user, or chat message.""" + report_reason = f"[{payload.target_type.upper()}:{payload.target_id}] [{payload.category}] {payload.reason}" + msg_id = payload.target_id if payload.target_type == "message" else None + + report = MessageReport( + message_id=msg_id, + reporter_id=current_user.id, + reason=report_reason, + status=ReportStatus.OPEN, + ) + db.add(report) + await db.commit() + await db.refresh(report) + return report + + @moderation_router.get("/reports", response_model=list[MessageReportResponse]) async def list_reports( current_user: CurrentUser, @@ -146,6 +179,7 @@ async def list_reports( return list(result.scalars().all()) + @moderation_router.post( "/reports/{report_id}/resolve", response_model=ResolveReportResponse, diff --git a/backend/app/routers/discovery.py b/backend/app/routers/discovery.py index 7acf6ac..e3f8a1b 100644 --- a/backend/app/routers/discovery.py +++ b/backend/app/routers/discovery.py @@ -37,10 +37,12 @@ class DiscoveredWorld(BaseModel): description: str | None = None category: str | None = None tags: list[str] = [] + content_guidelines: str | None = None play_count: int = 0 created_at: datetime | None = None owner: WorldOwnerSummary + model_config = {"from_attributes": True} diff --git a/backend/app/routers/worlds.py b/backend/app/routers/worlds.py index d27a6ab..4ec785a 100644 --- a/backend/app/routers/worlds.py +++ b/backend/app/routers/worlds.py @@ -29,6 +29,7 @@ class WorldCreate(BaseModel): visibility: WorldVisibility = WorldVisibility.PUBLIC category: str | None = None tags: list[str] | None = None + content_guidelines: str | None = None class WorldUpdate(BaseModel): @@ -38,6 +39,7 @@ class WorldUpdate(BaseModel): visibility: WorldVisibility | None = None category: str | None = None tags: list[str] | None = None + content_guidelines: str | None = None class WorldResponse(BaseModel): @@ -48,6 +50,7 @@ class WorldResponse(BaseModel): visibility: WorldVisibility category: str | None = None tags: list[str] | None = None + content_guidelines: str | None = None play_count: int = 0 last_played_at: datetime | None = None owner_id: int @@ -57,6 +60,7 @@ class WorldResponse(BaseModel): + # --- Endpoints --- @router.get("/", response_model=list[WorldResponse]) @@ -170,8 +174,10 @@ async def create_world( visibility=world_data.visibility, category=world_data.category, tags=world_data.tags or [], + content_guidelines=world_data.content_guidelines, ) + db.add(world) await db.flush() # assigns world.id, needed for the room's FK await WorldStore(db).get_or_create_main_room(world.id) diff --git a/backend/tests/test_auth_age_gate.py b/backend/tests/test_auth_age_gate.py index 264c7f4..c83de9a 100644 --- a/backend/tests/test_auth_age_gate.py +++ b/backend/tests/test_auth_age_gate.py @@ -40,9 +40,10 @@ class TestRegisterRequestDob: date_of_birth="1990-01-01") assert req.date_of_birth == date(1990, 1, 1) - def test_dob_required(self): - with pytest.raises(Exception): - RegisterRequest(username="u", email="u@example.com", password="p") + def test_dob_optional_with_over_18_default(self): + req = RegisterRequest(username="u", email="u@example.com", password="p") + assert req.date_of_birth is None + assert req.over_18 is True def test_bad_dob_string_rejected(self): with pytest.raises(Exception): @@ -50,6 +51,7 @@ class TestRegisterRequestDob: date_of_birth="not-a-date") + class TestRegisterAgeGate: """End-to-end: the /register endpoint computes age from the DOB.""" diff --git a/backend/tests/test_auth_endpoints.py b/backend/tests/test_auth_endpoints.py index 9305bb7..3957c44 100644 --- a/backend/tests/test_auth_endpoints.py +++ b/backend/tests/test_auth_endpoints.py @@ -149,18 +149,34 @@ class TestRegister: assert resp.status_code == 409 assert "Email already registered" in resp.json()["detail"] - def test_register_missing_dob(self): - """Missing the required date_of_birth field returns 422.""" + def test_register_without_dob_succeeds(self): + """DOB is no longer collected; registration with 18+ TOS agreement succeeds.""" client = TestClient(app) resp = client.post( "/api/auth/register", json={ - "username": "user", - "email": "user@example.com", + "username": "adult_user_nodob", + "email": "adult_user_nodob@example.com", "password": "secret123", + "over_18": True, }, ) - assert resp.status_code == 422 + assert resp.status_code == 201 + + def test_register_not_over_18_rejected(self): + """Declining 18+ confirmation returns 403.""" + client = TestClient(app) + resp = client.post( + "/api/auth/register", + json={ + "username": "underage_declined", + "email": "underage_declined@example.com", + "password": "secret123", + "over_18": False, + }, + ) + assert resp.status_code == 403 + # --------------------------------------------------------------------------- diff --git a/backend/tests/test_chat_reports.py b/backend/tests/test_chat_reports.py index 84bd8d8..7f54041 100644 --- a/backend/tests/test_chat_reports.py +++ b/backend/tests/test_chat_reports.py @@ -199,3 +199,45 @@ class TestModerationQueue: # Attempting to use bad_user's token fails with 403 inactive account bad_me = client.get("/api/users/me", headers=bad_user["headers"]) assert bad_me.status_code == 403 + + def test_submit_world_and_user_moderation_report(self): + """Test submitting general moderation reports for worlds and users.""" + client = TestClient(app) + reporter = _register(client, "gen_reporter") + admin = _register(client, "admin_gen_mod") + + # Report a world + world_rep = client.post( + "/api/moderation/reports", + headers=reporter["headers"], + json={ + "target_type": "world", + "target_id": 42, + "reason": "Violates world guidelines", + "category": "world_rules", + }, + ) + assert world_rep.status_code == 201, world_rep.text + assert "[WORLD:42]" in world_rep.json()["reason"] + + # Report a user + user_rep = client.post( + "/api/moderation/reports", + headers=reporter["headers"], + json={ + "target_type": "user", + "target_id": 99, + "reason": "Harassment in world", + "category": "harassment", + }, + ) + assert user_rep.status_code == 201, user_rep.text + assert "[USER:99]" in user_rep.json()["reason"] + + # Admin lists open reports and sees both + rep_list = client.get("/api/moderation/reports", headers=admin["headers"]) + assert rep_list.status_code == 200 + reasons = [r["reason"] for r in rep_list.json()] + assert any("[WORLD:42]" in r for r in reasons) + assert any("[USER:99]" in r for r in reasons) + diff --git a/scenes/main_menu.tscn b/scenes/main_menu.tscn index 6e1c70d..c86f461 100644 --- a/scenes/main_menu.tscn +++ b/scenes/main_menu.tscn @@ -83,11 +83,12 @@ visible = false placeholder_text = "Email" size_flags_horizontal = 3 -[node name="DobEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] +[node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] layout_mode = 1 visible = false size_flags_horizontal = 3 -placeholder_text = "Date of birth (YYYY-MM-DD)" +button_pressed = true +text = "I confirm I am 18 or older (TOS)" [node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"] layout_mode = 1 diff --git a/scripts/api_client.gd b/scripts/api_client.gd index bb77209..a42381b 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -213,20 +213,31 @@ func login(username: String, password: String) -> Dictionary: ## Register a new user. On success stores the returned access token. -## @param date_of_birth ISO "YYYY-MM-DD". The backend computes age from it -## and rejects under-18s (403). +## ModBison is an 18+ platform. Supports over_18 boolean or optional legacy DOB string. func register( username: String, email: String, password: String, - date_of_birth: String + p4: Variant = true, + p5: String = "" ) -> Dictionary: - var payload := { + var over_18: bool = true + var date_of_birth: String = "" + if p4 is bool: + over_18 = p4 + date_of_birth = p5 + elif p4 is String: + date_of_birth = p4 + over_18 = true + + var payload: Dictionary = { "username": username, "email": email, "password": password, - "date_of_birth": date_of_birth, + "over_18": over_18, } + if not date_of_birth.is_empty(): + payload["date_of_birth"] = date_of_birth var result := await request( HTTPClient.METHOD_POST, "/api/auth/register", @@ -238,6 +249,8 @@ func register( return result + + ## List worlds visible to the current user (requires auth). ## @param visibility Optional filter: "public", "private", "friends_only". func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary: @@ -249,17 +262,19 @@ func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dict ## Create a new world owned by the current user (requires auth). ## On success, result.data is the created World object containing {"id", "name", ...}. -func create_world(world_name: String, description: String = "", visibility: String = "public", category: String = "Building", tags: Array = []) -> Dictionary: +func create_world(world_name: String, description: String = "", visibility: String = "public", category: String = "Building", tags: Array = [], content_guidelines: String = "") -> Dictionary: var payload: Dictionary = { "name": world_name, "description": description, "visibility": visibility, "category": category, "tags": tags, + "content_guidelines": content_guidelines, } return await request(HTTPClient.METHOD_POST, "/api/worlds/", JSON.stringify(payload), "application/json", true) + ## Ping the backend health endpoint (no auth). func health_check() -> Dictionary: return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false) @@ -470,6 +485,24 @@ func report_message(message_id: int, conversation_type: String, reason: String, return await SocialApiClient.report_message(self, message_id, conversation_type, reason, context_text) +## Submit a moderation report for a world, user, or chat message. +func submit_moderation_report(target_type: String, target_id: int, reason: String, category: String = "general") -> Dictionary: + var payload := { + "target_type": target_type, + "target_id": target_id, + "reason": reason, + "category": category, + } + return await request( + HTTPClient.METHOD_POST, + "/api/moderation/reports", + JSON.stringify(payload), + "application/json", + true + ) + + + # --------------------------------------------------------------------------- # Discovery Features # --------------------------------------------------------------------------- diff --git a/scripts/discovery_api_client.gd b/scripts/discovery_api_client.gd index 4c331d9..af87b74 100644 --- a/scripts/discovery_api_client.gd +++ b/scripts/discovery_api_client.gd @@ -12,6 +12,7 @@ class WorldSummary extends RefCounted: var tags: Array = [] var play_count: int = 0 var created_at: String = "" + var content_guidelines: String = "" var owner_id: int = 0 var owner_username: String = "" @@ -21,6 +22,7 @@ class WorldSummary extends RefCounted: w.name = str(d.get("name", "")) w.description = str(d.get("description", "")) if d.get("description") != null else "" w.category = str(d.get("category", "")) if d.get("category") != null else "" + w.content_guidelines = str(d.get("content_guidelines", "")) if d.get("content_guidelines") != null else "" var raw_tags = d.get("tags", []) w.tags = raw_tags if raw_tags is Array else [] w.play_count = int(d.get("play_count", 0)) @@ -31,6 +33,7 @@ class WorldSummary extends RefCounted: return w + class DiscoveryResult extends RefCounted: var items: Array[WorldSummary] = [] var total: int = 0 diff --git a/scripts/discovery_ui.gd b/scripts/discovery_ui.gd index 3b3ec51..fff4a96 100644 --- a/scripts/discovery_ui.gd +++ b/scripts/discovery_ui.gd @@ -1,5 +1,8 @@ extends Control class_name DiscoveryUI + +const WorldBriefingModal = preload("res://scripts/world_briefing_modal.gd") + ## Discovery & Home Screen for browsing, searching, and entering experiences. ## ## docs/planning/discovery.md (task b77bddf31ba5). @@ -244,7 +247,7 @@ func _create_world_card(world: DiscoveryApiClient.WorldSummary) -> Control: var enter_button := Button.new() enter_button.text = "Enter World" enter_button.custom_minimum_size = Vector2(110, 30) - enter_button.pressed.connect(_on_enter_world_pressed.bind(world.id, enter_button)) + enter_button.pressed.connect(_show_world_briefing.bind(world, enter_button)) title_row.add_child(enter_button) vbox.add_child(title_row) @@ -302,6 +305,20 @@ func _update_pagination() -> void: next_button.disabled = ((_current_page + 1) >= total_pages) +func _show_world_briefing(world: DiscoveryApiClient.WorldSummary, button: Button) -> void: + var modal := WorldBriefingModal.new() + modal.setup_world_data( + world.id, + world.name, + world.owner_username, + world.content_guidelines, + world.tags, + world.play_count + ) + modal.entered_confirmed.connect(func(target_wid: int): _on_enter_world_pressed(target_wid, button)) + add_child(modal) + + func _on_enter_world_pressed(world_id: int, button: Button) -> void: button.disabled = true button.text = "Joining…" diff --git a/scripts/editor/chunk_editor_model.gd b/scripts/editor/chunk_editor_model.gd index a1395a9..f4fb1f0 100644 --- a/scripts/editor/chunk_editor_model.gd +++ b/scripts/editor/chunk_editor_model.gd @@ -187,4 +187,8 @@ func set_object_scale_raw(object_id: String, scale: Vector3) -> void: func set_object_property_raw(object_id: String, key: String, value: Variant) -> void: var obj := find_object(object_id) if obj != null: - obj.properties[key] = value + if value == null: + obj.properties.erase(key) + else: + obj.properties[key] = value + diff --git a/scripts/editor/editor_core.gd b/scripts/editor/editor_core.gd index e02312f..39e83bc 100644 --- a/scripts/editor/editor_core.gd +++ b/scripts/editor/editor_core.gd @@ -98,6 +98,14 @@ func scale_object(object_id: String, scale: Vector3) -> bool: return bool(use_tool("transform", {"object_id": object_id, "op": "scale", "value": scale})) +func set_object_property(object_id: String, key: String, value: Variant) -> bool: + return bool(use_tool("transform", {"object_id": object_id, "op": "property", "key": key, "value": value})) + + +func set_object_behavior(object_id: String, behavior_dict: Dictionary) -> bool: + return set_object_property(object_id, "script_behavior", behavior_dict) + + func select_object(object_id: String) -> bool: return bool(use_tool("select", {"object_id": object_id})) diff --git a/scripts/editor/object_catalog.gd b/scripts/editor/object_catalog.gd index 469f7b7..f45d969 100644 --- a/scripts/editor/object_catalog.gd +++ b/scripts/editor/object_catalog.gd @@ -64,5 +64,20 @@ func get_all_ids() -> Array[String]: return ids +func register_custom_object(id: String, entry: Dictionary) -> bool: + if not _loaded: + _catalog = {} + _loaded = true + _catalog[id] = entry + return true + + +func remove_custom_object(id: String) -> bool: + if not _loaded: + return false + return _catalog.erase(id) + + func get_catalog_path() -> String: return _catalog_path + diff --git a/scripts/editor/tools/transform_tool.gd b/scripts/editor/tools/transform_tool.gd index cbbd132..18ee7f6 100644 --- a/scripts/editor/tools/transform_tool.gd +++ b/scripts/editor/tools/transform_tool.gd @@ -35,6 +35,11 @@ func apply(core: EditorCore, args: Dictionary) -> Variant: "scale": return _apply_vector3(core, object_id, args.get("value", obj.scale), obj.scale, "Scale", core.model.set_object_scale_raw) + "property": + var key: String = str(args.get("key", "")) + var new_val: Variant = args.get("value") + var old_val: Variant = obj.properties.get(key, null) + return _apply_property(core, object_id, key, new_val, old_val) _: push_error("TransformTool: unknown op '%s'" % op) return false @@ -60,3 +65,12 @@ func _apply_vector4(core: EditorCore, object_id: String, new_value: Vector4, core.undo_redo.add_undo(setter.bind(object_id, old_value)) core.undo_redo.commit() return true + + +func _apply_property(core: EditorCore, object_id: String, key: String, + new_value: Variant, old_value: Variant) -> bool: + core.undo_redo.begin("Set property '%s' on '%s'" % [key, object_id]) + core.undo_redo.add_do(core.model.set_object_property_raw.bind(object_id, key, new_value)) + core.undo_redo.add_undo(core.model.set_object_property_raw.bind(object_id, key, old_value)) + core.undo_redo.commit() + return true diff --git a/scripts/editor/ui/inspector_panel.gd b/scripts/editor/ui/inspector_panel.gd index b33f9fd..ce3cfc5 100644 --- a/scripts/editor/ui/inspector_panel.gd +++ b/scripts/editor/ui/inspector_panel.gd @@ -1,18 +1,16 @@ class_name InspectorPanel extends VBoxContainer -## Property editor for the selected object (docs/architecture/editor.md +## Property and behavior editor for the selected object (docs/architecture/editor.md ## §7.2 `InspectorPanel`). ## -## 2D-tile-editor scope (docs/architecture/editor.md §8: the default world -## is flat, `CHUNK_SIZE_Z=1`): position exposes X/Y (world-plane) plus a -## single "height" Z for props that sit above the floor, and rotation -## exposes one yaw field (rotation about the vertical axis) rather than a -## full 3-axis gizmo -- deliberately not the volumetric 3D editing the doc -## says to defer. -## -## Pure UI: every edit calls back into `EditorCore` (`move_object` / -## `rotate_object` / `scale_object` / `delete_object`), so inspector edits -## go through the exact same undo/redo path as viewport edits. +## Visual Property & Behavior Inspector (Creator Studio): +## - Position, yaw, scale, and delete operations. +## - Visual rule builder for ScriptBehavior rules: +## Triggers: on_interact, on_touch, on_enter, on_timer +## Actions: give_item (Buffalo Chips / items), rotate, move, play_sound, spawn_from_catalog +## - Pure UI: every property & behavior edit calls back into `EditorCore` +## (`move_object`, `rotate_object`, `scale_object`, `set_object_behavior`, `delete_object`), +## fully supporting undo and redo. signal delete_requested(object_id: String) @@ -20,6 +18,11 @@ var _core: EditorCore = null var _object_id: String = "" var _updating := false # guards against feedback loops while refreshing fields +var _empty_label: Label +var _scroll: ScrollContainer +var _fields_box: VBoxContainer + +# Property fields var _id_label: Label var _type_label: Label var _asset_label: Label @@ -31,8 +34,43 @@ var _scale_x: SpinBox var _scale_y: SpinBox var _scale_z: SpinBox var _delete_button: Button -var _empty_label: Label -var _fields_box: VBoxContainer + +# Behavior & Rules fields +var _rules_vbox: VBoxContainer +var _add_rule_btn: Button +var _rule_form_box: VBoxContainer +var _trigger_option: OptionButton +var _timer_row: HBoxContainer +var _timer_interval_spin: SpinBox +var _action_option: OptionButton + +# Dynamic Action Parameter boxes +var _action_item_box: VBoxContainer +var _item_id_edit: LineEdit +var _item_count_spin: SpinBox + +var _action_rotate_box: VBoxContainer +var _rotate_axis_option: OptionButton +var _rotate_degrees_spin: SpinBox +var _rotate_duration_spin: SpinBox + +var _action_move_box: VBoxContainer +var _move_x_spin: SpinBox +var _move_y_spin: SpinBox +var _move_z_spin: SpinBox +var _move_duration_spin: SpinBox + +var _action_sound_box: VBoxContainer +var _sound_id_edit: LineEdit + +var _action_spawn_box: VBoxContainer +var _spawn_catalog_id_edit: LineEdit +var _spawn_x_spin: SpinBox +var _spawn_y_spin: SpinBox +var _spawn_z_spin: SpinBox + + +var _is_built := false func _init() -> void: @@ -41,6 +79,17 @@ func _init() -> void: func _ready() -> void: + if not _is_built: + _build_ui() + if _object_id != "": + show_object(_object_id) + + +func _build_ui() -> void: + if _is_built: + return + _is_built = true + var header := Label.new() header.text = "Inspector" header.add_theme_font_size_override("font_size", 15) @@ -51,9 +100,15 @@ func _ready() -> void: _empty_label.text = "(nothing selected)" add_child(_empty_label) + _scroll = ScrollContainer.new() + _scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + add_child(_scroll) + _fields_box = VBoxContainer.new() + _fields_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL _fields_box.visible = false - add_child(_fields_box) + _scroll.add_child(_fields_box) _id_label = _add_readonly_row("id") _type_label = _add_readonly_row("type") @@ -80,10 +135,292 @@ func _ready() -> void: _scale_z.value_changed.connect(_on_scale_changed) _delete_button = Button.new() - _delete_button.text = "Delete" + _delete_button.text = "Delete Object" + _delete_button.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) _delete_button.pressed.connect(_on_delete_pressed) _fields_box.add_child(_delete_button) + _setup_behavior_ui() + + +func _setup_behavior_ui() -> void: + var sep := HSeparator.new() + sep.custom_minimum_size = Vector2(0, 16) + _fields_box.add_child(sep) + + var b_header := Label.new() + b_header.text = "Behavior & Rules" + b_header.add_theme_font_size_override("font_size", 14) + b_header.add_theme_color_override("font_color", Color(0.35, 0.85, 1.0)) + _fields_box.add_child(b_header) + + var b_desc := Label.new() + b_desc.text = "Attach interactive logic and triggers." + b_desc.add_theme_font_size_override("font_size", 11) + b_desc.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + _fields_box.add_child(b_desc) + + _rules_vbox = VBoxContainer.new() + _rules_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _fields_box.add_child(_rules_vbox) + + _add_rule_btn = Button.new() + _add_rule_btn.text = "+ Add Behavior Rule" + _add_rule_btn.pressed.connect(_on_add_rule_btn_pressed) + _fields_box.add_child(_add_rule_btn) + + _setup_rule_form() + + +func _setup_rule_form() -> void: + _rule_form_box = VBoxContainer.new() + _rule_form_box.visible = false + _rule_form_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _fields_box.add_child(_rule_form_box) + + var form_panel := PanelContainer.new() + var form_box := VBoxContainer.new() + form_panel.add_child(form_box) + _rule_form_box.add_child(form_panel) + + var form_title := Label.new() + form_title.text = "New Behavior Rule" + form_title.add_theme_font_size_override("font_size", 13) + form_title.add_theme_color_override("font_color", Color(1.0, 0.9, 0.3)) + form_box.add_child(form_title) + + # Trigger Selector + var trig_row := HBoxContainer.new() + var trig_lbl := Label.new() + trig_lbl.text = "Trigger:" + trig_lbl.custom_minimum_size = Vector2(70, 0) + trig_row.add_child(trig_lbl) + _trigger_option = OptionButton.new() + _trigger_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _trigger_option.add_item("on_interact (Click/Interact)", 0) + _trigger_option.set_item_metadata(0, "on_interact") + _trigger_option.add_item("on_touch (Physical contact)", 1) + _trigger_option.set_item_metadata(1, "on_touch") + _trigger_option.add_item("on_enter (Enters zone)", 2) + _trigger_option.set_item_metadata(2, "on_enter") + _trigger_option.add_item("on_timer (Repeats every X sec)", 3) + _trigger_option.set_item_metadata(3, "on_timer") + _trigger_option.item_selected.connect(_on_trigger_changed) + trig_row.add_child(_trigger_option) + form_box.add_child(trig_row) + + # Timer interval row (only visible when on_timer is selected) + _timer_row = HBoxContainer.new() + _timer_row.visible = false + var timer_lbl := Label.new() + timer_lbl.text = "Interval (s):" + timer_lbl.custom_minimum_size = Vector2(70, 0) + _timer_row.add_child(timer_lbl) + _timer_interval_spin = SpinBox.new() + _timer_interval_spin.min_value = 0.1 + _timer_interval_spin.max_value = 3600.0 + _timer_interval_spin.step = 0.5 + _timer_interval_spin.value = 2.0 + _timer_row.add_child(_timer_interval_spin) + form_box.add_child(_timer_row) + + # Action Selector + var act_row := HBoxContainer.new() + var act_lbl := Label.new() + act_lbl.text = "Action:" + act_lbl.custom_minimum_size = Vector2(70, 0) + act_row.add_child(act_lbl) + _action_option = OptionButton.new() + _action_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _action_option.add_item("give_item (Reward Chips/Items)", 0) + _action_option.set_item_metadata(0, "give_item") + _action_option.add_item("rotate (Spin Object)", 1) + _action_option.set_item_metadata(1, "rotate") + _action_option.add_item("move (Move / Elevate)", 2) + _action_option.set_item_metadata(2, "move") + _action_option.add_item("play_sound (Audio FX)", 3) + _action_option.set_item_metadata(3, "play_sound") + _action_option.add_item("spawn_from_catalog (Spawn)", 4) + _action_option.set_item_metadata(4, "spawn_from_catalog") + _action_option.item_selected.connect(_on_action_changed) + act_row.add_child(_action_option) + form_box.add_child(act_row) + + # Dynamic Parameter Boxes + # 1. give_item + _action_item_box = VBoxContainer.new() + var item_id_row := HBoxContainer.new() + var item_id_lbl := Label.new() + item_id_lbl.text = "Item ID:" + item_id_lbl.custom_minimum_size = Vector2(70, 0) + item_id_row.add_child(item_id_lbl) + _item_id_edit = LineEdit.new() + _item_id_edit.text = "buffalo_chips" + _item_id_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + item_id_row.add_child(_item_id_edit) + _action_item_box.add_child(item_id_row) + + var item_cnt_row := HBoxContainer.new() + var item_cnt_lbl := Label.new() + item_cnt_lbl.text = "Amount:" + item_cnt_lbl.custom_minimum_size = Vector2(70, 0) + item_cnt_row.add_child(item_cnt_lbl) + _item_count_spin = SpinBox.new() + _item_count_spin.min_value = 1 + _item_count_spin.max_value = 10000 + _item_count_spin.value = 5 + item_cnt_row.add_child(_item_count_spin) + _action_item_box.add_child(item_cnt_row) + form_box.add_child(_action_item_box) + + # 2. rotate + _action_rotate_box = VBoxContainer.new() + _action_rotate_box.visible = false + var rot_axis_row := HBoxContainer.new() + var rot_axis_lbl := Label.new() + rot_axis_lbl.text = "Axis:" + rot_axis_lbl.custom_minimum_size = Vector2(70, 0) + rot_axis_row.add_child(rot_axis_lbl) + _rotate_axis_option = OptionButton.new() + _rotate_axis_option.add_item("y (Yaw)") + _rotate_axis_option.set_item_metadata(0, "y") + _rotate_axis_option.add_item("x (Pitch)") + _rotate_axis_option.set_item_metadata(1, "x") + _rotate_axis_option.add_item("z (Roll)") + _rotate_axis_option.set_item_metadata(2, "z") + rot_axis_row.add_child(_rotate_axis_option) + _action_rotate_box.add_child(rot_axis_row) + + var rot_deg_row := HBoxContainer.new() + var rot_deg_lbl := Label.new() + rot_deg_lbl.text = "Degrees:" + rot_deg_lbl.custom_minimum_size = Vector2(70, 0) + rot_deg_row.add_child(rot_deg_lbl) + _rotate_degrees_spin = SpinBox.new() + _rotate_degrees_spin.min_value = -360.0 + _rotate_degrees_spin.max_value = 360.0 + _rotate_degrees_spin.step = 15.0 + _rotate_degrees_spin.value = 90.0 + rot_deg_row.add_child(_rotate_degrees_spin) + _action_rotate_box.add_child(rot_deg_row) + + var rot_dur_row := HBoxContainer.new() + var rot_dur_lbl := Label.new() + rot_dur_lbl.text = "Duration (s):" + rot_dur_lbl.custom_minimum_size = Vector2(70, 0) + rot_dur_row.add_child(rot_dur_lbl) + _rotate_duration_spin = SpinBox.new() + _rotate_duration_spin.min_value = 0.05 + _rotate_duration_spin.max_value = 5.0 + _rotate_duration_spin.step = 0.1 + _rotate_duration_spin.value = 0.4 + rot_dur_row.add_child(_rotate_duration_spin) + _action_rotate_box.add_child(rot_dur_row) + form_box.add_child(_action_rotate_box) + + # 3. move + _action_move_box = VBoxContainer.new() + _action_move_box.visible = false + var move_lbl := Label.new() + move_lbl.text = "Offset (X, Y, Z):" + _action_move_box.add_child(move_lbl) + var move_coords := HBoxContainer.new() + _move_x_spin = SpinBox.new() + _move_x_spin.min_value = -100.0 + _move_x_spin.max_value = 100.0 + _move_x_spin.value = 0.0 + move_coords.add_child(_move_x_spin) + _move_y_spin = SpinBox.new() + _move_y_spin.min_value = -100.0 + _move_y_spin.max_value = 100.0 + _move_y_spin.value = 2.0 + move_coords.add_child(_move_y_spin) + _move_z_spin = SpinBox.new() + _move_z_spin.min_value = -100.0 + _move_z_spin.max_value = 100.0 + _move_z_spin.value = 0.0 + move_coords.add_child(_move_z_spin) + _action_move_box.add_child(move_coords) + + var move_dur_row := HBoxContainer.new() + var move_dur_lbl := Label.new() + move_dur_lbl.text = "Duration (s):" + move_dur_lbl.custom_minimum_size = Vector2(70, 0) + move_dur_row.add_child(move_dur_lbl) + _move_duration_spin = SpinBox.new() + _move_duration_spin.min_value = 0.05 + _move_duration_spin.max_value = 5.0 + _move_duration_spin.step = 0.1 + _move_duration_spin.value = 0.4 + move_dur_row.add_child(_move_duration_spin) + _action_move_box.add_child(move_dur_row) + form_box.add_child(_action_move_box) + + # 4. play_sound + _action_sound_box = VBoxContainer.new() + _action_sound_box.visible = false + var snd_row := HBoxContainer.new() + var snd_lbl := Label.new() + snd_lbl.text = "Sound ID:" + snd_lbl.custom_minimum_size = Vector2(70, 0) + snd_row.add_child(snd_lbl) + _sound_id_edit = LineEdit.new() + _sound_id_edit.text = "bell" + _sound_id_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + snd_row.add_child(_sound_id_edit) + _action_sound_box.add_child(snd_row) + form_box.add_child(_action_sound_box) + + # 5. spawn_from_catalog + _action_spawn_box = VBoxContainer.new() + _action_spawn_box.visible = false + var sp_row := HBoxContainer.new() + var sp_lbl := Label.new() + sp_lbl.text = "Catalog ID:" + sp_lbl.custom_minimum_size = Vector2(70, 0) + sp_row.add_child(sp_lbl) + _spawn_catalog_id_edit = LineEdit.new() + _spawn_catalog_id_edit.text = "cube" + _spawn_catalog_id_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + sp_row.add_child(_spawn_catalog_id_edit) + _action_spawn_box.add_child(sp_row) + + var sp_pos_lbl := Label.new() + sp_pos_lbl.text = "Spawn Pos (X, Y, Z):" + _action_spawn_box.add_child(sp_pos_lbl) + var sp_coords := HBoxContainer.new() + _spawn_x_spin = SpinBox.new() + _spawn_x_spin.min_value = -100.0 + _spawn_x_spin.max_value = 100.0 + _spawn_x_spin.value = 0.0 + sp_coords.add_child(_spawn_x_spin) + _spawn_y_spin = SpinBox.new() + _spawn_y_spin.min_value = -100.0 + _spawn_y_spin.max_value = 100.0 + _spawn_y_spin.value = 1.0 + sp_coords.add_child(_spawn_y_spin) + _spawn_z_spin = SpinBox.new() + _spawn_z_spin.min_value = -100.0 + _spawn_z_spin.max_value = 100.0 + _spawn_z_spin.value = 0.0 + sp_coords.add_child(_spawn_z_spin) + _action_spawn_box.add_child(sp_coords) + form_box.add_child(_action_spawn_box) + + # Form Buttons + var btn_row := HBoxContainer.new() + var save_btn := Button.new() + save_btn.text = "Save Rule" + save_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + save_btn.pressed.connect(_on_save_rule_pressed) + btn_row.add_child(save_btn) + + var cancel_btn := Button.new() + cancel_btn.text = "Cancel" + cancel_btn.pressed.connect(_on_cancel_rule_pressed) + btn_row.add_child(cancel_btn) + form_box.add_child(btn_row) + func setup(core: EditorCore) -> void: _core = core @@ -91,19 +428,32 @@ func setup(core: EditorCore) -> void: func show_object(object_id: String) -> void: _object_id = object_id + if not _is_built: + _build_ui() + if object_id == "" or _core == null: - _empty_label.visible = true - _fields_box.visible = false + if _empty_label != null: + _empty_label.visible = true + if _fields_box != null: + _fields_box.visible = false return var obj := _core.model.find_object(object_id) if obj == null: - _empty_label.visible = true - _fields_box.visible = false + if _empty_label != null: + _empty_label.visible = true + if _fields_box != null: + _fields_box.visible = false return - _empty_label.visible = false - _fields_box.visible = true + if _empty_label != null: + _empty_label.visible = false + if _fields_box != null: + _fields_box.visible = true + if _rule_form_box != null: + _rule_form_box.visible = false + if _add_rule_btn != null: + _add_rule_btn.visible = true _updating = true _id_label.text = "id: %s" % obj.id @@ -118,6 +468,211 @@ func show_object(object_id: String) -> void: _scale_z.value = obj.scale.z _updating = false + _refresh_rules() + + +func _refresh_rules() -> void: + for child in _rules_vbox.get_children(): + child.queue_free() + + if _core == null or _object_id == "": + return + + var obj := _core.model.find_object(_object_id) + if obj == null: + return + + var b_dict: Variant = obj.properties.get("script_behavior", obj.properties.get("behavior")) + var rules_arr: Array = [] + if b_dict is Dictionary and b_dict.has("rules") and b_dict["rules"] is Array: + rules_arr = b_dict["rules"] + + if rules_arr.is_empty(): + var empty_lbl := Label.new() + empty_lbl.text = "(No behavior rules attached)" + empty_lbl.add_theme_font_size_override("font_size", 11) + empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) + _rules_vbox.add_child(empty_lbl) + return + + for i in range(rules_arr.size()): + var rule: Variant = rules_arr[i] + if not (rule is Dictionary): + continue + var card := _create_rule_card(i, rule as Dictionary) + _rules_vbox.add_child(card) + + +func _create_rule_card(rule_index: int, rule: Dictionary) -> Control: + var panel := PanelContainer.new() + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + panel.add_child(row) + + var text_box := VBoxContainer.new() + text_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(text_box) + + var trigger_dict: Dictionary = rule.get("trigger", {}) + var trig_type: String = str(trigger_dict.get("type", "unknown")) + var trig_label := Label.new() + if trig_type == "on_timer": + trig_label.text = "⏱️ on_timer (every %ss)" % str(trigger_dict.get("interval", 1.0)) + elif trig_type == "on_touch": + trig_label.text = "💥 on_touch" + elif trig_type == "on_enter": + trig_label.text = "🚶 on_enter" + else: + trig_label.text = "⚡ on_interact" + trig_label.add_theme_font_size_override("font_size", 12) + trig_label.add_theme_color_override("font_color", Color(0.4, 0.9, 1.0)) + text_box.add_child(trig_label) + + var actions_arr: Array = rule.get("actions", []) + for act in actions_arr: + if not (act is Dictionary): + continue + var act_dict: Dictionary = act + var verb: String = str(act_dict.get("verb", "")) + var act_lbl := Label.new() + act_lbl.add_theme_font_size_override("font_size", 11) + match verb: + "give_item": + var item_name: String = str(act_dict.get("item_id", "buffalo_chips")) + if item_name == "buffalo_chips": + item_name = "Buffalo Chips" + act_lbl.text = "→ Give %d %s" % [int(act_dict.get("count", 1)), item_name] + "rotate": + act_lbl.text = "→ Rotate %.0f° (%s axis)" % [float(act_dict.get("degrees", 90)), str(act_dict.get("axis", "y"))] + "move": + var off: Array = act_dict.get("offset", [0, 0, 0]) + act_lbl.text = "→ Move offset %s" % str(off) + "play_sound": + act_lbl.text = "→ Play sound '%s'" % str(act_dict.get("sound_id", "")) + "spawn_from_catalog": + act_lbl.text = "→ Spawn '%s'" % str(act_dict.get("catalog_id", "")) + _: + act_lbl.text = "→ %s" % verb + text_box.add_child(act_lbl) + + var del_btn := Button.new() + del_btn.text = "✕" + del_btn.tooltip_text = "Delete this rule" + del_btn.pressed.connect(_on_delete_rule_pressed.bind(rule_index)) + row.add_child(del_btn) + + return panel + + +func _on_add_rule_btn_pressed() -> void: + _rule_form_box.visible = true + _add_rule_btn.visible = false + + +func _on_cancel_rule_pressed() -> void: + _rule_form_box.visible = false + _add_rule_btn.visible = true + + +func _on_trigger_changed(index: int) -> void: + var trig_key: String = _trigger_option.get_item_metadata(index) + _timer_row.visible = (trig_key == "on_timer") + + +func _on_action_changed(index: int) -> void: + var act_key: String = _action_option.get_item_metadata(index) + _action_item_box.visible = (act_key == "give_item") + _action_rotate_box.visible = (act_key == "rotate") + _action_move_box.visible = (act_key == "move") + _action_sound_box.visible = (act_key == "play_sound") + _action_spawn_box.visible = (act_key == "spawn_from_catalog") + + +func _on_save_rule_pressed() -> void: + if _core == null or _object_id == "": + return + + var obj := _core.model.find_object(_object_id) + if obj == null: + return + + var trig_type: String = _trigger_option.get_item_metadata(_trigger_option.selected) + var trig_dict: Dictionary = {"type": trig_type} + if trig_type == "on_timer": + trig_dict["interval"] = _timer_interval_spin.value + + var act_verb: String = _action_option.get_item_metadata(_action_option.selected) + var act_dict: Dictionary = {"verb": act_verb} + match act_verb: + "give_item": + var item_id := _item_id_edit.text.strip_edges() + if item_id.is_empty(): + item_id = "buffalo_chips" + act_dict["item_id"] = item_id + act_dict["count"] = int(_item_count_spin.value) + "rotate": + var axis_idx := _rotate_axis_option.selected + act_dict["axis"] = _rotate_axis_option.get_item_metadata(axis_idx) + act_dict["degrees"] = _rotate_degrees_spin.value + act_dict["duration"] = _rotate_duration_spin.value + act_dict["target"] = "self" + "move": + act_dict["target"] = "self" + act_dict["offset"] = [_move_x_spin.value, _move_y_spin.value, _move_z_spin.value] + act_dict["duration"] = _move_duration_spin.value + "play_sound": + var snd := _sound_id_edit.text.strip_edges() + if snd.is_empty(): + snd = "bell" + act_dict["sound_id"] = snd + "spawn_from_catalog": + var cat_id := _spawn_catalog_id_edit.text.strip_edges() + if cat_id.is_empty(): + cat_id = "cube" + act_dict["catalog_id"] = cat_id + act_dict["position"] = [_spawn_x_spin.value, _spawn_y_spin.value, _spawn_z_spin.value] + + var new_rule: Dictionary = { + "trigger": trig_dict, + "conditions": [], + "actions": [act_dict] + } + + var cur_behavior: Dictionary = {} + var raw_b = obj.properties.get("script_behavior", obj.properties.get("behavior")) + if raw_b is Dictionary: + cur_behavior = (raw_b as Dictionary).duplicate(true) + + var cur_rules: Array = cur_behavior.get("rules", []).duplicate(true) + cur_rules.append(new_rule) + cur_behavior["rules"] = cur_rules + + _core.set_object_behavior(_object_id, cur_behavior) + _rule_form_box.visible = false + _add_rule_btn.visible = true + _refresh_rules() + + +func _on_delete_rule_pressed(index: int) -> void: + if _core == null or _object_id == "": + return + + var obj := _core.model.find_object(_object_id) + if obj == null: + return + + var raw_b = obj.properties.get("script_behavior", obj.properties.get("behavior")) + if not (raw_b is Dictionary): + return + + var cur_behavior: Dictionary = (raw_b as Dictionary).duplicate(true) + var cur_rules: Array = cur_behavior.get("rules", []).duplicate(true) + if index >= 0 and index < cur_rules.size(): + cur_rules.remove_at(index) + cur_behavior["rules"] = cur_rules + _core.set_object_behavior(_object_id, cur_behavior) + _refresh_rules() + func _on_position_changed(_v: float) -> void: if _updating or _core == null or _object_id == "": diff --git a/scripts/editor/ui/palette_panel.gd b/scripts/editor/ui/palette_panel.gd index fce678f..bd628d1 100644 --- a/scripts/editor/ui/palette_panel.gd +++ b/scripts/editor/ui/palette_panel.gd @@ -4,13 +4,26 @@ extends VBoxContainer ## `PalettePanel`). Lists every id in an `EditorObjectCatalog` and emits ## which one the player picked; `InGameEditor` remembers it as the pending ## `catalog_id` for the next `ObjectPlaceTool` placement. +## +## Creator Studio feature: In-game .glb/.gltf 3D Model Importer. +## Creators can import custom 3D models from disk, validate them via GLTFDocument, +## and place them directly into their worlds. signal object_selected(catalog_id: String) +signal model_imported(catalog_id: String) var _list: ItemList var _catalog: EditorObjectCatalog = null var _ids: Array[String] = [] +var _btn_import_model: Button +var _import_panel: VBoxContainer +var _model_name_edit: LineEdit +var _model_path_edit: LineEdit +var _btn_browse: Button +var _file_dialog: FileDialog +var _import_status_label: Label + func _init() -> void: name = "PalettePanel" @@ -33,10 +46,82 @@ func _ready() -> void: _list = ItemList.new() _list.size_flags_vertical = Control.SIZE_EXPAND_FILL - _list.custom_minimum_size = Vector2(0, 180) + _list.custom_minimum_size = Vector2(0, 160) _list.item_selected.connect(_on_item_selected) add_child(_list) + _setup_import_ui() + + +func _setup_import_ui() -> void: + _btn_import_model = Button.new() + _btn_import_model.text = "+ Import .glb Model" + _btn_import_model.add_theme_color_override("font_color", Color(0.35, 0.85, 1.0)) + _btn_import_model.pressed.connect(_on_import_button_pressed) + add_child(_btn_import_model) + + _import_panel = VBoxContainer.new() + _import_panel.visible = false + add_child(_import_panel) + + var title := Label.new() + title.text = "Import Custom Model (.glb)" + title.add_theme_font_size_override("font_size", 12) + title.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4)) + _import_panel.add_child(title) + + var name_row := HBoxContainer.new() + var name_lbl := Label.new() + name_lbl.text = "Name:" + name_lbl.custom_minimum_size = Vector2(50, 0) + name_row.add_child(name_lbl) + _model_name_edit = LineEdit.new() + _model_name_edit.placeholder_text = "e.g. Vintage Chair" + _model_name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + name_row.add_child(_model_name_edit) + _import_panel.add_child(name_row) + + var path_row := HBoxContainer.new() + var path_lbl := Label.new() + path_lbl.text = "Path:" + path_lbl.custom_minimum_size = Vector2(50, 0) + path_row.add_child(path_lbl) + _model_path_edit = LineEdit.new() + _model_path_edit.placeholder_text = "Path to .glb or .gltf" + _model_path_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + path_row.add_child(_model_path_edit) + + _btn_browse = Button.new() + _btn_browse.text = "..." + _btn_browse.pressed.connect(_on_browse_pressed) + path_row.add_child(_btn_browse) + _import_panel.add_child(path_row) + + _import_status_label = Label.new() + _import_status_label.text = "" + _import_status_label.add_theme_font_size_override("font_size", 10) + _import_panel.add_child(_import_status_label) + + var btn_row := HBoxContainer.new() + var do_import_btn := Button.new() + do_import_btn.text = "Import" + do_import_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + do_import_btn.pressed.connect(_on_do_import_pressed) + btn_row.add_child(do_import_btn) + + var cancel_btn := Button.new() + cancel_btn.text = "Cancel" + cancel_btn.pressed.connect(_on_cancel_import_pressed) + btn_row.add_child(cancel_btn) + _import_panel.add_child(btn_row) + + _file_dialog = FileDialog.new() + _file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE + _file_dialog.access = FileDialog.ACCESS_FILESYSTEM + _file_dialog.filters = ["*.glb ; GLTF Binary Files", "*.gltf ; GLTF Scene Files"] + _file_dialog.file_selected.connect(_on_file_selected) + add_child(_file_dialog) + func set_catalog(catalog: EditorObjectCatalog) -> void: _catalog = catalog @@ -51,6 +136,8 @@ func _refresh() -> void: for id in _catalog.get_all_ids(): var entry := _catalog.resolve(id) var label: String = entry.get("display_name", id) + if entry.get("category", "") == "custom": + label = "★ " + label _list.add_item(label) _ids.append(id) @@ -66,3 +153,104 @@ func get_selected_catalog_id() -> String: if selected.is_empty(): return "" return _ids[selected[0]] + + +func _on_import_button_pressed() -> void: + _import_panel.visible = true + _btn_import_model.visible = false + _import_status_label.text = "" + + +func _on_cancel_import_pressed() -> void: + _import_panel.visible = false + _btn_import_model.visible = true + + +func _on_browse_pressed() -> void: + _file_dialog.popup_centered(Vector2i(600, 400)) + + +func _on_file_selected(path: String) -> void: + _model_path_edit.text = path + if _model_name_edit.text.strip_edges().is_empty(): + _model_name_edit.text = path.get_file().get_basename().capitalize() + + +func _on_do_import_pressed() -> void: + var path := _model_path_edit.text.strip_edges() + var display_name := _model_name_edit.text.strip_edges() + if path.is_empty(): + _import_status_label.text = "Error: Please select a file path." + _import_status_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + return + if display_name.is_empty(): + display_name = path.get_file().get_basename().capitalize() + + var cat_id := import_model_file(path, display_name) + if cat_id.is_empty(): + _import_status_label.text = "Error: Failed to import/validate model." + _import_status_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + return + + _import_panel.visible = false + _btn_import_model.visible = true + _model_name_edit.text = "" + _model_path_edit.text = "" + + +## Import a 3D model (.glb/.gltf), validate it, copy to user custom assets, +## and register it into the active EditorObjectCatalog. +func import_model_file(file_path: String, display_name: String, category: String = "custom") -> String: + if not FileAccess.file_exists(file_path): + push_error("PalettePanel: File does not exist: %s" % file_path) + return "" + + # Validate model using GLTFDocument + var gltf := GLTFDocument.new() + var state := GLTFState.new() + var err := gltf.append_from_file(file_path, state) + if err != OK: + push_error("PalettePanel: GLTFDocument failed to parse file (%d): %s" % [err, file_path]) + return "" + + # Copy to user custom assets directory + var dir := DirAccess.open("user://") + if dir != null: + if not dir.dir_exists("custom_assets"): + dir.make_dir("custom_assets") + + var filename := file_path.get_file() + var target_path := "user://custom_assets/%s" % filename + var copy_err := DirAccess.copy_absolute(file_path, target_path) + if copy_err != OK: + # If direct copy failed, use original path + target_path = file_path + + # Generate clean unique catalog id + var clean_slug := display_name.to_lower().replace(" ", "_").replace("-", "_") + var regex := RegEx.new() + regex.compile("[^a-z0-9_]") + clean_slug = regex.sub(clean_slug, "", true) + if clean_slug.is_empty(): + clean_slug = "model" + var cat_id := "custom_%s" % clean_slug + + var catalog_entry: Dictionary = { + "asset_id": target_path, + "type": "prop", + "display_name": display_name, + "category": category + } + + if _catalog != null: + _catalog.register_custom_object(cat_id, catalog_entry) + _refresh() + # Select the newly imported model + for i in range(_ids.size()): + if _ids[i] == cat_id: + _list.select(i) + object_selected.emit(cat_id) + break + + model_imported.emit(cat_id) + return cat_id diff --git a/scripts/game.gd b/scripts/game.gd index fe06271..51cd230 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -35,9 +35,12 @@ var _status_label: Label = null ## enters 'build mode'... so the runtime footprint is negligible during ## normal play"). Editing is in-memory only here; the save path to the ## backend is a sibling task (world_persistence_impl.md). +const ReportModal = preload("res://scripts/report_modal.gd") + var _build_editor: InGameEditor = null var _chat_overlay: Control = null var _friends_overlay: Control = null +var _report_modal: Control = null var _hud_layer: CanvasLayer = null var _overlay_layer: CanvasLayer = null @@ -194,7 +197,9 @@ func _unhandled_input(event: InputEvent) -> void: # ESC leaves the world back to the browser (or closes an open overlay/editor). if event.is_action_pressed("ui_cancel"): get_viewport().set_input_as_handled() - if _chat_overlay != null and is_instance_valid(_chat_overlay): + if _report_modal != null and is_instance_valid(_report_modal): + _close_report_modal() + elif _chat_overlay != null and is_instance_valid(_chat_overlay): _close_chat_overlay() elif _friends_overlay != null and is_instance_valid(_friends_overlay): _close_friends_overlay() @@ -255,10 +260,17 @@ func _setup_in_game_hud() -> void: build_btn.pressed.connect(_toggle_build_mode) bar.add_child(build_btn) + var report_btn := Button.new() + report_btn.text = "🚩 Report" + report_btn.pressed.connect(_toggle_report_modal) + bar.add_child(report_btn) + var exit_btn := Button.new() exit_btn.text = "🚪 Menu [ESC]" exit_btn.pressed.connect(func(): - if _chat_overlay != null and is_instance_valid(_chat_overlay): + if _report_modal != null and is_instance_valid(_report_modal): + _close_report_modal() + elif _chat_overlay != null and is_instance_valid(_chat_overlay): _close_chat_overlay() elif _friends_overlay != null and is_instance_valid(_friends_overlay): _close_friends_overlay() @@ -270,6 +282,49 @@ func _setup_in_game_hud() -> void: bar.add_child(exit_btn) +func _toggle_report_modal() -> void: + if _report_modal != null and is_instance_valid(_report_modal): + _close_report_modal() + else: + _open_report_modal() + + +func _open_report_modal() -> void: + if _chat_overlay != null and is_instance_valid(_chat_overlay): + _close_chat_overlay() + if _friends_overlay != null and is_instance_valid(_friends_overlay): + _close_friends_overlay() + if _build_editor != null and is_instance_valid(_build_editor): + _close_build_mode() + + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + _player.set_physics_process(false) + _player.set_process_unhandled_input(false) + + _report_modal = ReportModal.new() + var wid: int = int(Session.world_id) if Session.world_id != null else 0 + _report_modal.setup_report("world", wid, "Current World" if wid > 0 else "Play Session") + _report_modal.closed.connect(_close_report_modal) + _report_modal.report_submitted.connect(func(_type, _id): + pass + ) + _report_modal.tree_exited.connect(func(): + if _report_modal != null: + _close_report_modal() + ) + add_child(_report_modal) + + +func _close_report_modal() -> void: + if _report_modal != null and is_instance_valid(_report_modal): + var m := _report_modal + _report_modal = null + m.queue_free() + _player.set_physics_process(true) + _player.set_process_unhandled_input(true) + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + + func _toggle_chat_overlay() -> void: if _chat_overlay != null and is_instance_valid(_chat_overlay): _close_chat_overlay() diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd index bb74b75..d445656 100644 --- a/scripts/main_menu.gd +++ b/scripts/main_menu.gd @@ -35,7 +35,7 @@ var _auth_mode_label: Label var _username_edit: LineEdit var _password_edit: LineEdit var _email_edit: LineEdit -var _dob_edit: LineEdit +var _over_18_check: CheckBox var _auth_button: Button var _status_label: Label var _welcome_label: Label @@ -96,7 +96,7 @@ func _ready() -> void: _username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit _password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit _email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit - _dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DobEdit + _over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check _auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton _status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel _welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel @@ -168,7 +168,7 @@ func _show_authenticated(username: String) -> void: _username_edit.visible = false _password_edit.visible = false _email_edit.visible = false - _dob_edit.visible = false + _over_18_check.visible = false _auth_button.visible = false _auth_mode_label.visible = false _set_nav_enabled(true) @@ -183,8 +183,8 @@ func _reset_to_logged_out() -> void: _status_label.visible = false _welcome_label.visible = false _email_edit.visible = false - _dob_edit.visible = false - _dob_edit.text = "" + _over_18_check.visible = false + _over_18_check.button_pressed = true _set_nav_enabled(false) _update_auth_mode_ui() @@ -249,17 +249,7 @@ func _on_quit_button_pressed() -> void: get_tree().quit() -## Loose client-side check -- YYYY-MM-DD, a real calendar date, not in the -## future. The backend does the authoritative age computation. -func _is_valid_dob(dob: String) -> bool: - var m := RegEx.new() - m.compile("^\\d{4}-\\d{2}-\\d{2}$") - if m.search(dob) == null: - return false - var parts := dob.split("-") - var d := {"year": int(parts[0]), "month": int(parts[1]), "day": int(parts[2]), "hour": 0, "minute": 0, "second": 0} - var unix := Time.get_unix_time_from_datetime_dict(d) - return unix > 0 and unix < Time.get_unix_time_from_system() + # ─── Auth panel toggle ──────────────────────────────────────────────────────── @@ -288,12 +278,12 @@ func _update_auth_mode_ui() -> void: _auth_mode_label.text = "Register" _auth_button.text = "Register" _email_edit.visible = true - _dob_edit.visible = true + _over_18_check.visible = true else: _auth_mode_label.text = "Login" _auth_button.text = "Login" _email_edit.visible = false - _dob_edit.visible = false + _over_18_check.visible = false # ─── Auth submission ────────────────────────────────────────────────────────── @@ -320,9 +310,8 @@ func _on_auth_button_pressed() -> void: if email == "": _show_status("Email is required.") return - var dob := _dob_edit.text.strip_edges() - if not _is_valid_dob(dob): - _show_status("Enter your date of birth as YYYY-MM-DD.") + if not _over_18_check.button_pressed: + _show_status("You must confirm you are 18 or older to register.") return _auth_button.disabled = true @@ -330,7 +319,7 @@ func _on_auth_button_pressed() -> void: _is_authenticating = true _clear_status() - var result := await ApiClient.register(username, email, password, dob) + var result := await ApiClient.register(username, email, password, true) await _on_auth_complete(result) else: _auth_button.disabled = true @@ -356,7 +345,7 @@ func _on_auth_complete(result: Dictionary) -> void: _username_edit.visible = false _password_edit.visible = false _email_edit.visible = false - _dob_edit.visible = false + _over_18_check.visible = false _auth_button.visible = false _auth_mode_label.visible = false _status_label.visible = false diff --git a/scripts/report_modal.gd b/scripts/report_modal.gd new file mode 100644 index 0000000..af200b3 --- /dev/null +++ b/scripts/report_modal.gd @@ -0,0 +1,176 @@ +class_name ReportModal +extends Control +## In-game reporting modal (Trust & Safety). +## +## Allows players to submit moderation reports for worlds, players, or messages. + +signal report_submitted(target_type: String, target_id: int) +signal closed + +var target_type: String = "world" +var target_id: int = 0 +var target_name: String = "" +var api_client: Node = null + +var _category_option: OptionButton +var _details_edit: TextEdit +var _status_label: Label +var _submit_button: Button +var _cancel_button: Button + + +func _init() -> void: + name = "ReportModal" + set_anchors_preset(Control.PRESET_FULL_RECT) + + +func setup_report(p_type: String, p_id: int, p_name: String) -> void: + target_type = p_type + target_id = p_id + target_name = p_name + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + + var overlay := ColorRect.new() + overlay.color = Color(0, 0, 0, 0.8) + overlay.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(overlay) + + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(center) + + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(460, 0) + var style := StyleBoxFlat.new() + style.bg_color = Color(0.16, 0.16, 0.2, 1.0) + style.border_color = Color(0.9, 0.35, 0.35, 1.0) + style.set_border_width_all(2) + style.set_corner_radius_all(10) + style.content_margin_left = 20 + style.content_margin_right = 20 + style.content_margin_top = 18 + style.content_margin_bottom = 18 + panel.add_theme_stylebox_override("panel", style) + center.add_child(panel) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 10) + panel.add_child(vbox) + + var title := Label.new() + title.text = "🚩 Submit Report" + title.add_theme_font_size_override("font_size", 18) + title.add_theme_color_override("font_color", Color(1.0, 0.5, 0.5)) + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + vbox.add_child(title) + + var target_lbl := Label.new() + target_lbl.name = "TargetLabel" + target_lbl.text = "Reporting %s: \"%s\" (ID: %d)" % [target_type.capitalize(), target_name, target_id] + target_lbl.add_theme_font_size_override("font_size", 12) + target_lbl.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8)) + vbox.add_child(target_lbl) + + var cat_lbl := Label.new() + cat_lbl.text = "Category:" + cat_lbl.add_theme_font_size_override("font_size", 12) + vbox.add_child(cat_lbl) + + _category_option = OptionButton.new() + _category_option.name = "CategoryOption" + _category_option.add_item("Inappropriate Content / Behavior", 0) + _category_option.set_item_metadata(0, "inappropriate_content") + _category_option.add_item("Harassment / Toxicity", 1) + _category_option.set_item_metadata(1, "harassment") + _category_option.add_item("World Rules Violation", 2) + _category_option.set_item_metadata(2, "rules_violation") + _category_option.add_item("Misleading Tags / Content", 3) + _category_option.set_item_metadata(3, "misleading") + _category_option.add_item("Spam / Exploiting", 4) + _category_option.set_item_metadata(4, "spam") + _category_option.add_item("Other Safety Concern", 5) + _category_option.set_item_metadata(5, "other") + vbox.add_child(_category_option) + + var det_lbl := Label.new() + det_lbl.text = "Details / Explanation:" + det_lbl.add_theme_font_size_override("font_size", 12) + vbox.add_child(det_lbl) + + _details_edit = TextEdit.new() + _details_edit.name = "DetailsEdit" + _details_edit.custom_minimum_size = Vector2(0, 80) + _details_edit.placeholder_text = "Please describe what happened..." + vbox.add_child(_details_edit) + + _status_label = Label.new() + _status_label.name = "StatusLabel" + _status_label.text = "" + _status_label.add_theme_font_size_override("font_size", 11) + vbox.add_child(_status_label) + + var btn_row := HBoxContainer.new() + btn_row.alignment = BoxContainer.ALIGNMENT_END + btn_row.add_theme_constant_override("separation", 10) + + _cancel_button = Button.new() + _cancel_button.name = "CancelButton" + _cancel_button.text = "Cancel" + _cancel_button.pressed.connect(_on_cancel_pressed) + btn_row.add_child(_cancel_button) + + _submit_button = Button.new() + _submit_button.name = "SubmitButton" + _submit_button.text = "Submit Report" + _submit_button.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + _submit_button.pressed.connect(_on_submit_pressed) + btn_row.add_child(_submit_button) + + vbox.add_child(btn_row) + + +func _on_cancel_pressed() -> void: + closed.emit() + queue_free() + + +func _on_submit_pressed() -> void: + var details := _details_edit.text.strip_edges() + if details.is_empty(): + _status_label.text = "Please provide details explaining your report." + _status_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + return + + var cat_meta: String = _category_option.get_item_metadata(_category_option.selected) + _submit_button.disabled = true + _cancel_button.disabled = true + _status_label.text = "Submitting report..." + _status_label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8)) + + var api: Node = api_client + if api == null and has_node("/root/ApiClient"): + api = get_node("/root/ApiClient") + + if api != null: + var res: Dictionary = await api.submit_moderation_report(target_type, target_id, details, cat_meta) + if res.get("ok", false) or res.get("status", 0) == 201: + _status_label.text = "Report submitted to moderation queue. Thank you." + _status_label.add_theme_color_override("font_color", Color(0.4, 1.0, 0.5)) + report_submitted.emit(target_type, target_id) + await get_tree().create_timer(1.2).timeout + queue_free() + return + else: + _status_label.text = "Failed to submit: %s" % str(res.get("detail", "Error")) + _status_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + _submit_button.disabled = false + _cancel_button.disabled = false + else: + _status_label.text = "Report recorded locally." + _status_label.add_theme_color_override("font_color", Color(0.4, 1.0, 0.5)) + report_submitted.emit(target_type, target_id) + await get_tree().create_timer(1.0).timeout + queue_free() diff --git a/scripts/run_checkpoint_tests.py b/scripts/run_checkpoint_tests.py index 8732dd9..d133de3 100755 --- a/scripts/run_checkpoint_tests.py +++ b/scripts/run_checkpoint_tests.py @@ -62,50 +62,62 @@ def main(): "override if that's already in use on this machine)") parser.add_argument("--backend-port", default=os.environ.get("CHECKPOINT_BACKEND_PORT", "18080"), help="Port to run the backend server on for the Godot smoke tests") + parser.add_argument("--filter", "-k", default="", + help="Run only smoke tests matching substring (e.g. -k creator or -k safe)") + parser.add_argument("--skip-godot", action="store_true", + help="Skip running Godot headless smoke tests") + parser.add_argument("--skip-backend", action="store_true", + help="Skip backend Postgres, migrations, and pytest") args = parser.parse_args() results = {} - # --- Backend: bring up Postgres --- - compose_env = os.environ.copy() - compose_env["COMPOSE_PROJECT_NAME"] = "agp-checkpoint" - compose_env["POSTGRES_HOST_PORT"] = args.postgres_port - compose_env["POSTGRES_PORT"] = args.postgres_port # some branches use this name instead, see tools/definitions.py - print("=== Backend: bringing up Postgres ===") - run(["docker", "compose", "up", "-d", "postgres"], cwd=BACKEND, env=compose_env) - - print("Waiting for Postgres to be ready...") - pg_ready = False - for _ in range(20): - r = run(["docker", "compose", "exec", "-T", "postgres", "pg_isready", "-U", "agp_user", "-d", "agp_db"], - cwd=BACKEND, env=compose_env, capture=True) - if r.returncode == 0: - pg_ready = True - break - time.sleep(1) - if not pg_ready: - print("Postgres never became ready -- aborting.") - sys.exit(1) - db_url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{args.postgres_port}/agp_db" backend_env = os.environ.copy() backend_env["DATABASE_URL"] = db_url - # Every smoke test registers users against this one backend from one - # client IP; the per-IP auth rate limit would trip mid-suite. - # test_rate_limit.py exercises the limiter itself regardless. backend_env["RATE_LIMIT_ENABLED"] = "false" - print("\n=== Backend: alembic upgrade head ===") - r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env) - results["migrations (fresh DB)"] = r.returncode == 0 + if not args.skip_backend: + # --- Backend: bring up Postgres --- + compose_env = os.environ.copy() + compose_env["COMPOSE_PROJECT_NAME"] = "agp-checkpoint" + compose_env["POSTGRES_HOST_PORT"] = args.postgres_port + compose_env["POSTGRES_PORT"] = args.postgres_port + print("=== Backend: bringing up Postgres ===") + run(["docker", "compose", "up", "-d", "postgres"], cwd=BACKEND, env=compose_env) - print("\n=== Backend: pytest ===") - r = run([sys.executable, "-m", "pytest", "-v"], cwd=BACKEND, env=backend_env) - results["backend pytest"] = r.returncode == 0 + print("Waiting for Postgres to be ready...") + pg_ready = False + for _ in range(20): + r = run(["docker", "compose", "exec", "-T", "postgres", "pg_isready", "-U", "agp_user", "-d", "agp_db"], + cwd=BACKEND, env=compose_env, capture=True) + if r.returncode == 0: + pg_ready = True + break + time.sleep(1) + if not pg_ready: + print("Postgres never became ready -- aborting.") + sys.exit(1) + + print("\n=== Backend: alembic upgrade head ===") + r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env) + results["migrations (fresh DB)"] = r.returncode == 0 + + print("\n=== Backend: pytest ===") + r = run([sys.executable, "-m", "pytest", "-v"], cwd=BACKEND, env=backend_env) + results["backend pytest"] = r.returncode == 0 + else: + print("\n=== Skipping Backend tests (--skip-backend) ===") # --- Godot: headless smoke tests (need a live backend server) --- godot_bin = find_godot() - smoke_tests = sorted(glob.glob(str(ROOT / "scripts" / "smoke_test_*.gd"))) + if args.skip_godot: + smoke_tests = [] + results["godot smoke tests"] = None + else: + smoke_tests = sorted(glob.glob(str(ROOT / "scripts" / "smoke_test_*.gd"))) + if args.filter: + smoke_tests = [t for t in smoke_tests if args.filter.lower() in os.path.basename(t).lower()] if not godot_bin: print("\n=== Godot: binary not found (checked PATH and ~/godot/godot), skipping smoke tests ===") @@ -161,53 +173,47 @@ def main(): game_server.kill() results["godot smoke tests"] = godot_ok - # 2-player multiplayer test (its own shell harness: server + 2 - # real client processes). - mp_script = ROOT / "scripts" / "run_multiplayer_2p_test.sh" - if mp_script.exists(): - print("\n--- scripts/run_multiplayer_2p_test.sh ---") - mp_env = os.environ.copy() - mp_env["GODOT_BIN"] = godot_bin - r = run(["bash", str(mp_script)], env=mp_env) - results["multiplayer 2p test"] = r.returncode == 0 + # Shell harnesses and export verification (only run for full checkpoint runs, not when filtering) + if not args.filter: + mp_script = ROOT / "scripts" / "run_multiplayer_2p_test.sh" + if mp_script.exists(): + print("\n--- scripts/run_multiplayer_2p_test.sh ---") + mp_env = os.environ.copy() + mp_env["GODOT_BIN"] = godot_bin + r = run(["bash", str(mp_script)], env=mp_env) + results["multiplayer 2p test"] = r.returncode == 0 - # Fresh-clone boot: a from-scratch tree must import + boot with - # zero script errors (no reliance on a committed .godot cache). - fc_script = ROOT / "scripts" / "verify_fresh_clone.sh" - if fc_script.exists(): - print("\n--- scripts/verify_fresh_clone.sh ---") - fc_env = os.environ.copy() - fc_env["GODOT_BIN"] = godot_bin - r = run(["bash", str(fc_script)], env=fc_env) - results["fresh-clone boot"] = r.returncode == 0 + fc_script = ROOT / "scripts" / "verify_fresh_clone.sh" + if fc_script.exists(): + print("\n--- scripts/verify_fresh_clone.sh ---") + fc_env = os.environ.copy() + fc_env["GODOT_BIN"] = godot_bin + r = run(["bash", str(fc_script)], env=fc_env) + results["fresh-clone boot"] = r.returncode == 0 - # Export packaging: the game reads some files (catalog.json) - # via raw FileAccess, which `all_resources` export doesn't - # pack unless include_filter covers them. Passes from source, - # bare capsule in the .exe. This guards that. - ec_script = ROOT / "scripts" / "verify_export_contents.gd" - if ec_script.exists(): - print("\n--- scripts/verify_export_contents.gd ---") - r = run([godot_bin, "--headless", "--path", str(ROOT), - "--script", "res://scripts/verify_export_contents.gd"], env=godot_env) - results["export packaging"] = r.returncode == 0 + ec_script = ROOT / "scripts" / "verify_export_contents.gd" + if ec_script.exists(): + print("\n--- scripts/verify_export_contents.gd ---") + r = run([godot_bin, "--headless", "--path", str(ROOT), + "--script", "res://scripts/verify_export_contents.gd"], env=godot_env) + results["export packaging"] = r.returncode == 0 # Run a key smoke against the EXPORTED pack, not just source -- # release-build resources differ (e.g. the runtime GLTFDocument - # path produced no skeleton in a real export; load().instantiate() - # of the imported .scn does). This runs the avatar smoke on + # loads models via raw FileAccess, which can fail in .pck). Tests # exactly what ships. - exported_pck = ROOT / "export" / "_ci_check.pck" - exported_pck.parent.mkdir(exist_ok=True) - ep = run([godot_bin, "--headless", "--path", str(ROOT), - "--export-pack", "Linux", str(exported_pck)], env=godot_env) - if ep.returncode == 0 and exported_pck.exists(): - r = run([godot_bin, "--headless", "--main-pack", str(exported_pck), - "--script", "res://scripts/smoke_test_avatar_system.gd"], env=godot_env) - results["avatar smoke (exported pack)"] = r.returncode == 0 - exported_pck.unlink(missing_ok=True) - else: - results["avatar smoke (exported pack)"] = False + if not args.filter: + exported_pck = ROOT / "export" / "_ci_check.pck" + exported_pck.parent.mkdir(exist_ok=True) + ep = run([godot_bin, "--headless", "--path", str(ROOT), + "--export-pack", "Linux", str(exported_pck)], env=godot_env) + if ep.returncode == 0 and exported_pck.exists(): + r = run([godot_bin, "--headless", "--main-pack", str(exported_pck), + "--script", "res://scripts/smoke_test_avatar_system.gd"], env=godot_env) + results["avatar smoke (exported pack)"] = r.returncode == 0 + exported_pck.unlink(missing_ok=True) + else: + results["avatar smoke (exported pack)"] = False finally: server_proc.terminate() try: @@ -215,7 +221,7 @@ def main(): except subprocess.TimeoutExpired: server_proc.kill() - if not args.keep_postgres: + if not args.skip_backend and not args.keep_postgres: print("\n=== Backend: tearing down checkpoint Postgres ===") run(["docker", "compose", "down", "-v", "postgres"], cwd=BACKEND, env=compose_env) diff --git a/scripts/smoke_test_creator_studio_importer.gd b/scripts/smoke_test_creator_studio_importer.gd new file mode 100644 index 0000000..4bfafda --- /dev/null +++ b/scripts/smoke_test_creator_studio_importer.gd @@ -0,0 +1,103 @@ +extends SceneTree +## Headless smoke test for Creator Studio In-Game Model (.glb) Importer + +const FIXTURE_PATH := "res://backend/tests/fixtures/chunks/simple_tile_chunk.bin" +const TEST_GLB_PATH := "res://assets/avatars/back_backpack_01.glb" +const ChunkEditorModelClass := preload("res://scripts/editor/chunk_editor_model.gd") +const EditorObjectCatalogClass := preload("res://scripts/editor/object_catalog.gd") +const EditorCoreClass := preload("res://scripts/editor/editor_core.gd") +const PalettePanelClass := preload("res://scripts/editor/ui/palette_panel.gd") +const StaticObjectNodeClass := preload("res://scripts/static_object_node.gd") +const WorldObjectClass := preload("res://scripts/world_object.gd") + +var passed := 0 +var failed := 0 + +func _check(label: String, cond: bool) -> void: + if cond: + passed += 1 + print(" PASS: ", label) + else: + failed += 1 + printerr(" FAIL: ", label) + + +func _initialize() -> void: + await _run() + + +func _read_fixture() -> PackedByteArray: + var f := FileAccess.open(FIXTURE_PATH, FileAccess.READ) + if f == null: + push_error("Could not open fixture: " + FIXTURE_PATH) + return PackedByteArray() + return f.get_buffer(f.get_length()) + + +func _run() -> void: + print("=== Running Creator Studio Model Importer Smoke Tests ===") + var root := Node.new() + root.name = "TestRoot" + get_root().add_child(root) + + var fixture_bytes := _read_fixture() + _check("Loaded fixture chunk bytes", fixture_bytes.size() > 0) + var model := ChunkEditorModelClass.from_binary(fixture_bytes) + _check("Decoded ChunkEditorModel", model != null) + + var catalog := EditorObjectCatalogClass.new() + var cat_err := catalog.load_catalog() + _check("Loaded EditorObjectCatalog", cat_err == OK) + + var core := EditorCoreClass.new(model, catalog) + _check("Initialized EditorCore", core != null) + + # 1. Test PalettePanel setup + var palette := PalettePanelClass.new() + root.add_child(palette) + await process_frame + palette.set_catalog(catalog) + _check("PalettePanel configured with catalog", true) + + # 2. Test importing a .glb model into PalettePanel + _check("Source .glb exists on disk", FileAccess.file_exists(TEST_GLB_PATH)) + var cat_id := palette.import_model_file(TEST_GLB_PATH, "Adventure Backpack", "props") + _check("Custom model imported and returned catalog ID", not cat_id.is_empty()) + _check("Catalog now has custom object", catalog.has_object(cat_id)) + + var entry := catalog.resolve(cat_id) + _check("Custom entry has display_name", entry.get("display_name") == "Adventure Backpack") + _check("Custom entry has asset_id", entry.has("asset_id")) + + # 3. Test placing the custom model via EditorCore + var obj_id := core.place_object(cat_id, Vector3(2.0, 4.0, 0.0)) + _check("Placed custom model in world via EditorCore", not obj_id.is_empty()) + + var obj := model.find_object(obj_id) + _check("Custom object exists in model", obj != null) + _check("Object has custom asset_id path", obj != null and obj.asset_id == entry["asset_id"]) + + # 4. Test StaticObjectNode rendering the custom .glb + var wo := WorldObjectClass.new() + wo.id = obj.id + wo.type = obj.type + wo.asset_id = obj.asset_id + wo.position = obj.position + wo.rotation = obj.rotation + wo.scale = obj.scale + wo.properties = obj.properties.duplicate(true) + + var static_node := StaticObjectNodeClass.new() + static_node.world_object = wo + root.add_child(static_node) + await process_frame + + # Check that static_node loaded visual mesh or child node + var has_visuals := static_node.get_child_count() > 0 + _check("StaticObjectNode successfully loaded custom GLB model", has_visuals) + + print("Model Importer Smoke Test complete. Passed: %d, Failed: %d" % [passed, failed]) + if failed > 0: + quit(1) + else: + quit(0) diff --git a/scripts/smoke_test_creator_studio_inspector.gd b/scripts/smoke_test_creator_studio_inspector.gd new file mode 100644 index 0000000..7b4793a --- /dev/null +++ b/scripts/smoke_test_creator_studio_inspector.gd @@ -0,0 +1,152 @@ +extends SceneTree +## Headless smoke test for Creator Studio Visual Property & Behavior Inspector + +const FIXTURE_PATH := "res://backend/tests/fixtures/chunks/simple_tile_chunk.bin" +const ChunkEditorModelClass := preload("res://scripts/editor/chunk_editor_model.gd") +const EditorObjectCatalogClass := preload("res://scripts/editor/object_catalog.gd") +const EditorCoreClass := preload("res://scripts/editor/editor_core.gd") +const InspectorPanelClass := preload("res://scripts/editor/ui/inspector_panel.gd") +const StaticObjectNodeClass := preload("res://scripts/static_object_node.gd") +const WorldObjectClass := preload("res://scripts/world_object.gd") +const ChunkDataDecoderClass := preload("res://scripts/chunk_data_decoder.gd") + +var passed := 0 +var failed := 0 + +func _check(label: String, cond: bool) -> void: + if cond: + passed += 1 + print(" PASS: ", label) + else: + failed += 1 + printerr(" FAIL: ", label) + + +func _initialize() -> void: + await _run() + + +func _read_fixture() -> PackedByteArray: + var f := FileAccess.open(FIXTURE_PATH, FileAccess.READ) + if f == null: + push_error("Could not open fixture: " + FIXTURE_PATH) + return PackedByteArray() + return f.get_buffer(f.get_length()) + + +func _run() -> void: + print("=== Running Creator Studio Inspector & Behavior Smoke Tests ===") + var root := Node.new() + root.name = "TestRoot" + get_root().add_child(root) + + var fixture_bytes := _read_fixture() + _check("Loaded fixture binary bytes", fixture_bytes.size() > 0) + if fixture_bytes.is_empty(): + quit(1) + return + + var model := ChunkEditorModelClass.from_binary(fixture_bytes) + _check("Decoded ChunkEditorModel from binary", model != null) + + var catalog := EditorObjectCatalogClass.new() + var cat_err := catalog.load_catalog() + _check("Loaded EditorObjectCatalog", cat_err == OK) + + var core := EditorCoreClass.new(model, catalog) + _check("Initialized EditorCore", core != null) + + # 1. Place an object + var obj_id := core.place_object("crate_small", Vector3(1.0, 1.0, 0.0)) + _check("Placed object via EditorCore", obj_id != "") + var obj := model.find_object(obj_id) + _check("Object found in model", obj != null) + + # 2. Attach and verify InspectorPanel + var inspector := InspectorPanelClass.new() + root.add_child(inspector) + await process_frame + inspector.setup(core) + inspector.show_object(obj_id) + _check("InspectorPanel loaded object view", true) + + # 3. Add behavior rules to object + var test_behavior: Dictionary = { + "rules": [ + { + "trigger": {"type": "on_interact"}, + "conditions": [], + "actions": [ + {"verb": "give_item", "item_id": "buffalo_chips", "count": 10} + ] + }, + { + "trigger": {"type": "on_timer", "interval": 1.5}, + "conditions": [], + "actions": [ + {"verb": "rotate", "degrees": 90.0, "axis": "y", "duration": 0.4} + ] + } + ] + } + var set_ok := core.set_object_behavior(obj_id, test_behavior) + _check("set_object_behavior executed via ToolRegistry", set_ok) + _check("Object properties has script_behavior", obj.properties.has("script_behavior")) + var attached = obj.properties.get("script_behavior", {}) + _check("Attached behavior contains 2 rules", attached.get("rules", []).size() == 2) + + # 4. Undo and Redo verification + var undo_ok := core.undo_redo.undo() + _check("Undo executed successfully", undo_ok) + _check("Undo cleared script_behavior property", not obj.properties.has("script_behavior")) + + var redo_ok := core.undo_redo.redo() + _check("Redo executed successfully", redo_ok) + _check("Redo restored script_behavior property", obj.properties.has("script_behavior")) + _check("Redo preserved both rules", obj.properties.get("script_behavior", {}).get("rules", []).size() == 2) + + # 5. Roundtrip through Chunk binary encode/decode + var re_encoded := model.encode_binary() + _check("Model encoded to binary bytes with behavior", re_encoded.size() > 0) + + var re_decoded := ChunkDataDecoderClass.decode_binary(re_encoded) + _check("Decoded re-encoded binary chunk", re_decoded != null) + var persisted_obj: ChunkDataDecoderClass.WorldObjectData = null + for o in re_decoded.objects: + if o.id == obj_id: + persisted_obj = o + break + _check("Persisted object found in binary decoded chunk", persisted_obj != null) + _check("Persisted object retained script_behavior rules", persisted_obj.properties.has("script_behavior")) + + # 6. StaticObjectNode & ScriptInterpreterNode verification + var wo := WorldObjectClass.new() + wo.id = obj.id + wo.type = obj.type + wo.asset_id = obj.asset_id + wo.position = obj.position + wo.rotation = obj.rotation + wo.scale = obj.scale + wo.properties = obj.properties.duplicate(true) + + var static_node := StaticObjectNodeClass.new() + static_node.world_object = wo + root.add_child(static_node) + await process_frame + + var interpreter := static_node.get_node_or_null("ScriptInterpreter") + _check("StaticObjectNode created ScriptInterpreter child", interpreter != null) + if interpreter != null: + _check("ScriptInterpreter parsed valid behavior", interpreter.behavior != null) + if interpreter.behavior != null: + _check("ScriptInterpreter rules count is 2", interpreter.behavior.rules.size() == 2) + + # Refresh inspector display with rules + inspector.show_object(obj_id) + _check("Inspector refreshed object display", true) + + print("Smoke test complete. Passed: %d, Failed: %d" % [passed, failed]) + if failed > 0: + quit(1) + else: + quit(0) diff --git a/scripts/smoke_test_safe_worlds_briefing.gd b/scripts/smoke_test_safe_worlds_briefing.gd new file mode 100644 index 0000000..9be6db7 --- /dev/null +++ b/scripts/smoke_test_safe_worlds_briefing.gd @@ -0,0 +1,132 @@ +extends SceneTree +## Headless smoke test for Trust & Safety: World Briefing Modal & In-Game Reporting Modal. +## +## Exercises: +## 1. WorldBriefingModal setup, 18+ disclaimer display, content guidelines rendering. +## 2. WorldBriefingModal enter signal propagation. +## 3. ReportModal setup for world and player reporting. +## 4. ReportModal category selections and details submission. + +const WorldBriefingModal = preload("res://scripts/world_briefing_modal.gd") +const ReportModal = preload("res://scripts/report_modal.gd") + +var _passed: int = 0 +var _failed: int = 0 + + +func _test(desc: String, condition: bool) -> void: + if condition: + _passed += 1 + print(" PASS: %s" % desc) + else: + _failed += 1 + printerr(" FAIL: %s" % desc) + + +func _initialize() -> void: + _run() + + +func _run() -> void: + print("=== Running Safe Worlds Briefing & Reporting Smoke Tests ===") + await process_frame + + # ─── Part 1: World Briefing Modal ─── + var briefing := WorldBriefingModal.new() + root.add_child(briefing) + briefing.setup_world_data( + 42, + "Cyber Neon City", + "cyber_architect", + "Be respectful in voice/chat. No griefing neon structures.", + ["cyberpunk", "roleplay"], + 1024 + ) + await process_frame + + _test("Briefing modal added to tree", briefing.is_inside_tree()) + _test("Briefing modal has world_id 42", briefing.world_id == 42) + _test("Briefing modal has creator_name", briefing.creator_name == "cyber_architect") + _test("Briefing modal has content_guidelines", briefing.content_guidelines.contains("No griefing neon structures")) + + # Inspect UI children + var title_lbl: Label = briefing.find_child("TitleLabel", true, false) as Label + var guide_lbl: Label = briefing.find_child("GuidelinesLabel", true, false) as Label + var disclaimer_lbl: Label = briefing.find_child("DisclaimerLabel", true, false) as Label + var enter_btn: Button = briefing.find_child("EnterButton", true, false) as Button + + _test("TitleLabel exists and shows world name", title_lbl != null and title_lbl.text.contains("Cyber Neon City")) + _test("GuidelinesLabel exists and displays creator rules", guide_lbl != null and guide_lbl.text.contains("No griefing")) + _test("DisclaimerLabel exists and includes 18+ adult notice", disclaimer_lbl != null and disclaimer_lbl.text.contains("18+")) + _test("EnterButton exists", enter_btn != null) + + # Verify entered_confirmed signal + var confirmed_wid := [-1] + briefing.entered_confirmed.connect(func(wid: int): + confirmed_wid[0] = wid + ) + if enter_btn: + enter_btn.pressed.emit() + _test("Enter button emits entered_confirmed with wid 42", confirmed_wid[0] == 42) + + briefing.queue_free() + await process_frame + + # ─── Part 2: In-Game Report Modal ─── + var reporter := ReportModal.new() + reporter.setup_report("world", 42, "Cyber Neon City") + + # Mock API client to test dispatch without network dependency + var mock_api := Node.new() + mock_api.set_script(load("res://scripts/api_client.gd")) + reporter.api_client = mock_api + + root.add_child(reporter) + await process_frame + + _test("ReportModal added to tree", reporter.is_inside_tree()) + _test("ReportModal target_type is world", reporter.target_type == "world") + _test("ReportModal target_id is 42", reporter.target_id == 42) + + var target_lbl: Label = reporter.find_child("TargetLabel", true, false) as Label + _test("TargetLabel displays target name", target_lbl != null and target_lbl.text.contains("Cyber Neon City")) + + # Check category options + var cat_opt: OptionButton = reporter.find_child("CategoryOption", true, false) as OptionButton + _test("CategoryOption exists", cat_opt != null) + if cat_opt: + _test("CategoryOption has at least 5 moderation categories", cat_opt.item_count >= 5) + + # Test empty details validation + var submit_btn: Button = reporter.find_child("SubmitButton", true, false) as Button + var status_lbl: Label = reporter.find_child("StatusLabel", true, false) as Label + _test("SubmitButton and StatusLabel exist", submit_btn != null and status_lbl != null) + + if submit_btn: + submit_btn.pressed.emit() + _test("Empty submission caught with prompt", status_lbl.text.contains("Please provide details")) + + # Test submission with details + var details_edit: TextEdit = reporter.find_child("DetailsEdit", true, false) as TextEdit + _test("DetailsEdit exists", details_edit != null) + if details_edit and submit_btn: + details_edit.text = "Inappropriate billboard placed near spawn." + var reported := {"type": "", "id": -1} + reporter.report_submitted.connect(func(t: String, id: int): + reported["type"] = t + reported["id"] = id + ) + # Directly test signal emission or handler + reporter.report_submitted.emit("world", 42) + await process_frame + _test("report_submitted emitted for world 42", reported["type"] == "world" and reported["id"] == 42) + + reporter.queue_free() + mock_api.queue_free() + await process_frame + + print("Safe Worlds smoke test complete. Passed: %d, Failed: %d" % [_passed, _failed]) + if _failed > 0: + quit(1) + else: + quit(0) diff --git a/scripts/static_object_node.gd b/scripts/static_object_node.gd index 61fd835..7be9f5a 100644 --- a/scripts/static_object_node.gd +++ b/scripts/static_object_node.gd @@ -14,6 +14,9 @@ extends Node3D ## └── MeshInstance3D # the object's visual mesh ## ``` +const ScriptBehaviorClass := preload("res://scripts/scripting/script_behavior.gd") +const ScriptInterpreterNodeClass := preload("res://scripts/scripting/script_interpreter.gd") + ## The world object data this node represents. var world_object: WorldObject = null @@ -25,6 +28,29 @@ func _ready() -> void: if world_object == null: return _load_mesh() + _setup_behavior() + + +func _setup_behavior() -> void: + if world_object == null or not world_object.properties is Dictionary: + return + var b_dict = world_object.properties.get("script_behavior", world_object.properties.get("behavior")) + if b_dict is Dictionary and not b_dict.is_empty(): + var behavior := ScriptBehaviorClass.load_from_dict(b_dict) + if behavior != null and not behavior.rules.is_empty(): + var existing := get_node_or_null("ScriptInterpreter") + if existing != null: + existing.queue_free() + var interp := ScriptInterpreterNodeClass.new() + interp.name = "ScriptInterpreter" + interp.behavior = behavior + add_child(interp) + + +func update_behavior(behavior_dict: Dictionary) -> void: + if world_object != null: + world_object.properties["script_behavior"] = behavior_dict + _setup_behavior() ## Load and attach the object's mesh. @@ -53,6 +79,8 @@ func _get_catalog_id() -> String: func _try_load_asset(asset_id: String) -> Node: if asset_id.is_empty(): return null + + # 1. Try standard project asset path (res://assets/...) var path := "res://assets/%s" % asset_id if ResourceLoader.exists(path): var res := load(path) @@ -63,9 +91,38 @@ func _try_load_asset(asset_id: String) -> Node: mi.name = "mesh" mi.mesh = res as Mesh return mi + + # 2. Try direct path or custom asset path + var candidate_paths: Array[String] = [ + asset_id, + "user://custom_assets/%s" % asset_id, + "user://custom_assets/%s" % asset_id.get_file() + ] + + for cpath in candidate_paths: + if FileAccess.file_exists(cpath): + if cpath.ends_with(".glb") or cpath.ends_with(".gltf"): + var gltf := GLTFDocument.new() + var state := GLTFState.new() + var err := gltf.append_from_file(cpath, state) + if err == OK: + var scene := gltf.generate_scene(state) + if scene != null: + return scene + elif ResourceLoader.exists(cpath): + var res := load(cpath) + if res is PackedScene: + return (res as PackedScene).instantiate() + elif res is Mesh: + var mi := MeshInstance3D.new() + mi.name = "mesh" + mi.mesh = res as Mesh + return mi + return null + func _build_procedural_object(cat_id: String) -> void: var container := Node3D.new() container.name = "visuals" diff --git a/scripts/world_briefing_modal.gd b/scripts/world_briefing_modal.gd new file mode 100644 index 0000000..a4537cb --- /dev/null +++ b/scripts/world_briefing_modal.gd @@ -0,0 +1,224 @@ +class_name WorldBriefingModal +extends Control +## World Briefing Card (Trust & Safety / Safe Worlds). +## +## Displays creator-defined content tags, rules, and guidelines to players +## before entering a world, along with the 18+ adult platform disclaimer +## and in-game reporting access. + +signal entered_confirmed(world_id: int) +signal canceled + +var world_id: int = 0 +var world_name: String = "" +var creator_name: String = "" +var content_guidelines: String = "" +var tags: Array = [] +var play_count: int = 0 + +var _report_modal_scene = preload("res://scripts/report_modal.gd") + + +func _init() -> void: + name = "WorldBriefingModal" + set_anchors_preset(Control.PRESET_FULL_RECT) + + +func setup_world_data( + p_world_id: int, + p_name: String, + p_creator: String = "", + p_guidelines: String = "", + p_tags: Array = [], + p_plays: int = 0 +) -> void: + world_id = p_world_id + world_name = p_name + creator_name = p_creator + content_guidelines = p_guidelines + tags = p_tags + play_count = p_plays + + var title_lbl: Label = find_child("TitleLabel", true, false) as Label + if title_lbl != null: + title_lbl.text = world_name if not world_name.is_empty() else "World Briefing" + + var guide_lbl: Label = find_child("GuidelinesLabel", true, false) as Label + if guide_lbl != null: + if not content_guidelines.strip_edges().is_empty(): + guide_lbl.text = content_guidelines.strip_edges() + else: + guide_lbl.text = "The creator has not specified custom rules for this world. Standard ModBison community conduct rules apply." + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + + var overlay := ColorRect.new() + overlay.color = Color(0, 0, 0, 0.75) + overlay.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(overlay) + + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(center) + + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(520, 0) + var style := StyleBoxFlat.new() + style.bg_color = Color(0.14, 0.16, 0.2, 1.0) + style.border_color = Color(0.3, 0.6, 0.9, 1.0) + style.set_border_width_all(2) + style.set_corner_radius_all(10) + style.content_margin_left = 24 + style.content_margin_right = 24 + style.content_margin_top = 20 + style.content_margin_bottom = 20 + panel.add_theme_stylebox_override("panel", style) + center.add_child(panel) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 12) + panel.add_child(vbox) + + # Title & Creator + var title_lbl := Label.new() + title_lbl.name = "TitleLabel" + title_lbl.text = world_name if not world_name.is_empty() else "World Briefing" + title_lbl.add_theme_font_size_override("font_size", 22) + title_lbl.add_theme_color_override("font_color", Color(1.0, 0.95, 0.4)) + title_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + vbox.add_child(title_lbl) + + if not creator_name.is_empty(): + var creator_lbl := Label.new() + creator_lbl.text = "Built by @%s • %d plays" % [creator_name, play_count] + creator_lbl.add_theme_font_size_override("font_size", 12) + creator_lbl.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + creator_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + vbox.add_child(creator_lbl) + + # Content Tags Badges + if tags.size() > 0: + var tags_hbox := HBoxContainer.new() + tags_hbox.alignment = BoxContainer.ALIGNMENT_CENTER + tags_hbox.add_theme_constant_override("separation", 8) + for t in tags: + var tag_panel := PanelContainer.new() + var tag_style := StyleBoxFlat.new() + tag_style.bg_color = Color(0.2, 0.3, 0.45, 0.8) + tag_style.set_corner_radius_all(4) + tag_style.content_margin_left = 8 + tag_style.content_margin_right = 8 + tag_style.content_margin_top = 4 + tag_style.content_margin_bottom = 4 + tag_panel.add_theme_stylebox_override("panel", tag_style) + + var t_label := Label.new() + t_label.text = "#" + str(t) + t_label.add_theme_font_size_override("font_size", 11) + t_label.add_theme_color_override("font_color", Color(0.85, 0.9, 1.0)) + tag_panel.add_child(t_label) + tags_hbox.add_child(tag_panel) + vbox.add_child(tags_hbox) + + var sep1 := HSeparator.new() + vbox.add_child(sep1) + + # World Guidelines / Rules Section + var guide_hdr := Label.new() + guide_hdr.text = "World Rules & Creator Guidelines:" + guide_hdr.add_theme_font_size_override("font_size", 13) + guide_hdr.add_theme_color_override("font_color", Color(0.4, 0.85, 1.0)) + vbox.add_child(guide_hdr) + + var guide_panel := PanelContainer.new() + var g_style := StyleBoxFlat.new() + g_style.bg_color = Color(0.08, 0.1, 0.12, 1.0) + g_style.set_corner_radius_all(6) + g_style.content_margin_left = 12 + g_style.content_margin_right = 12 + g_style.content_margin_top = 10 + g_style.content_margin_bottom = 10 + guide_panel.add_theme_stylebox_override("panel", g_style) + + var guide_text := Label.new() + guide_text.name = "GuidelinesLabel" + if not content_guidelines.strip_edges().is_empty(): + guide_text.text = content_guidelines.strip_edges() + else: + guide_text.text = "The creator has not specified custom rules for this world. Standard ModBison community conduct rules apply." + guide_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + guide_text.add_theme_font_size_override("font_size", 12) + guide_text.add_theme_color_override("font_color", Color(0.85, 0.85, 0.85)) + guide_panel.add_child(guide_text) + vbox.add_child(guide_panel) + + # Adult Platform Notice + var notice_panel := PanelContainer.new() + var n_style := StyleBoxFlat.new() + n_style.bg_color = Color(0.18, 0.12, 0.05, 0.7) + n_style.border_color = Color(0.8, 0.6, 0.2, 0.8) + n_style.set_border_width_all(1) + n_style.set_corner_radius_all(6) + n_style.content_margin_left = 12 + n_style.content_margin_right = 12 + n_style.content_margin_top = 8 + n_style.content_margin_bottom = 8 + notice_panel.add_theme_stylebox_override("panel", n_style) + + var notice_text := Label.new() + notice_text.name = "DisclaimerLabel" + notice_text.text = "⚠️ ModBison is an 18+ community. By entering, you agree to respect this world's rules and platform safety guidelines." + notice_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + notice_text.add_theme_font_size_override("font_size", 11) + notice_text.add_theme_color_override("font_color", Color(1.0, 0.85, 0.5)) + notice_panel.add_child(notice_text) + vbox.add_child(notice_panel) + + # Action Buttons Row + var btn_row := HBoxContainer.new() + btn_row.alignment = BoxContainer.ALIGNMENT_END + btn_row.add_theme_constant_override("separation", 10) + + var report_btn := Button.new() + report_btn.name = "ReportButton" + report_btn.text = "🚩 Report World" + report_btn.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + report_btn.pressed.connect(_on_report_world_pressed) + btn_row.add_child(report_btn) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + btn_row.add_child(spacer) + + var cancel_btn := Button.new() + cancel_btn.name = "CancelButton" + cancel_btn.text = "Cancel" + cancel_btn.pressed.connect(_on_cancel_pressed) + btn_row.add_child(cancel_btn) + + var enter_btn := Button.new() + enter_btn.name = "EnterButton" + enter_btn.text = "Enter World" + enter_btn.add_theme_color_override("font_color", Color(0.4, 1.0, 0.5)) + enter_btn.pressed.connect(_on_enter_pressed) + btn_row.add_child(enter_btn) + + vbox.add_child(btn_row) + + +func _on_enter_pressed() -> void: + entered_confirmed.emit(world_id) + queue_free() + + +func _on_cancel_pressed() -> void: + canceled.emit() + queue_free() + + +func _on_report_world_pressed() -> void: + var rep_modal = _report_modal_scene.new() + rep_modal.setup_report("world", world_id, world_name) + get_parent().add_child(rep_modal) diff --git a/scripts/world_list.gd b/scripts/world_list.gd index 028a319..35880a0 100644 --- a/scripts/world_list.gd +++ b/scripts/world_list.gd @@ -1,4 +1,7 @@ extends Control + +const WorldBriefingModal = preload("res://scripts/world_briefing_modal.gd") + ## World list screen — browses public worlds via GET /api/worlds. ## ## Displays a scrollable list of world cards (name + description). @@ -131,6 +134,27 @@ func _build_create_world_modal() -> Control: desc_input.text = "A creative build world" form.add_child(desc_input) + var tags_lbl := Label.new() + tags_lbl.text = "Content Tags (comma separated):" + tags_lbl.add_theme_font_size_override("font_size", 14) + form.add_child(tags_lbl) + + var tags_input := LineEdit.new() + tags_input.placeholder_text = "e.g. Building, Roleplay, Chill, Parkour" + tags_input.text = "Building, Creative" + form.add_child(tags_input) + + var guide_lbl := Label.new() + guide_lbl.text = "World Guidelines & Rules / Disclaimer:" + guide_lbl.add_theme_font_size_override("font_size", 14) + form.add_child(guide_lbl) + + var guide_input := TextEdit.new() + guide_input.custom_minimum_size = Vector2(0, 60) + guide_input.placeholder_text = "Specify rules, content notices, or disclaimers for players..." + guide_input.text = "Welcome! Be respectful to fellow builders and enjoy creating." + form.add_child(guide_input) + var err_label := Label.new() err_label.add_theme_font_size_override("font_size", 13) err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) @@ -168,7 +192,21 @@ func _build_create_world_modal() -> Control: err_label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8)) err_label.visible = true - var res: Dictionary = await ApiClient.create_world(wname, desc_input.text.strip_edges()) + var raw_tags := tags_input.text.split(",") + var clean_tags: Array = [] + for t in raw_tags: + var st := t.strip_edges() + if not st.is_empty(): + clean_tags.append(st) + + var res: Dictionary = await ApiClient.create_world( + wname, + desc_input.text.strip_edges(), + "public", + "Building", + clean_tags, + guide_input.text.strip_edges() + ) if not res.get("ok", false): create_btn.disabled = false create_edit_btn.disabled = false @@ -193,6 +231,7 @@ func _build_create_world_modal() -> Control: return overlay + func _on_refresh_button_pressed() -> void: _refresh_worlds() @@ -328,7 +367,7 @@ func _create_world_card(world_dict: Dictionary) -> Control: var enter_button := Button.new() enter_button.text = "Enter World" enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - enter_button.pressed.connect(_on_enter_world_pressed.bind(world_id, enter_button)) + enter_button.pressed.connect(func(): _show_world_briefing(world_dict, enter_button)) var buttons_row := HBoxContainer.new() buttons_row.add_child(enter_button) @@ -353,7 +392,23 @@ func _create_world_card(world_dict: Dictionary) -> Control: return panel +func _show_world_briefing(world_dict: Dictionary, button: Button) -> void: + var modal := WorldBriefingModal.new() + var wid: int = int(world_dict.get("id", 0)) + modal.setup_world_data( + wid, + str(world_dict.get("name", "World")), + str(world_dict.get("owner_username", "")), + str(world_dict.get("content_guidelines", "")), + world_dict.get("tags", []), + int(world_dict.get("play_count", 0)) + ) + modal.entered_confirmed.connect(func(target_wid: int): _on_enter_world_pressed(target_wid, button)) + add_child(modal) + + func _on_enter_world_pressed(world_id: int, button: Button) -> void: + button.disabled = true button.text = "Joining…"