Complete M11 Creator Studio and M12 Trust & Safety for PC5
checkpoint / checkpoint (push) Failing after 3h14m9s

- 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
This commit is contained in:
2026-09-17 23:13:57 +00:00
parent 920354705a
commit 7ecf0d53bd
30 changed files with 2099 additions and 157 deletions
@@ -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")
+2
View File
@@ -31,11 +31,13 @@ class World(Base):
) )
category = Column(String(100), nullable=True, index=True) category = Column(String(100), nullable=True, index=True)
tags = Column(ARRAY(String), nullable=True, server_default="{}") 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) play_count = Column(Integer, nullable=False, server_default="0", index=True)
last_played_at = Column(DateTime(timezone=True), nullable=True) last_played_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
__table_args__ = ( __table_args__ = (
Index("ix_worlds_tags", "tags", postgresql_using="gin"), Index("ix_worlds_tags", "tags", postgresql_using="gin"),
) )
+18
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import FileResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -241,3 +242,20 @@ async def upload_asset_file(
await db.commit() await db.commit()
await db.refresh(asset) await db.refresh(asset)
return 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),
)
+12 -9
View File
@@ -43,12 +43,10 @@ class RegisterRequest(BaseModel):
username: str username: str
email: EmailStr email: EmailStr
password: str password: str
# Real date of birth -- the server computes age from it and rejects # ModBison is an adults-only platform (18+). Users confirm agreement with
# anyone under MIN_AGE. Still self-attested (no document check yet), but # 18+ Terms of Service. Date of birth is optional / deprecated.
# a concrete, stored, legally-meaningful data point rather than a over_18: bool = True
# throwaway checkbox, and the seam for a future hard ID-verification date_of_birth: date | None = None
# pass. See docs/architecture/content_moderation.md 5.2.
date_of_birth: date
class UserResponse(BaseModel): class UserResponse(BaseModel):
@@ -159,10 +157,10 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return access + refresh tokens. """Register a new user and return access + refresh tokens.
Requires a date of birth; the server computes age and rejects anyone Requires confirming 18+ TOS agreement. If a date of birth is supplied,
under MIN_AGE (18). See docs/architecture/content_moderation.md for the verifies the user is >= MIN_AGE (18).
verification design and its self-attestation limitation.
""" """
if req.date_of_birth is not None:
age = calculate_age(req.date_of_birth) age = calculate_age(req.date_of_birth)
if age < 0 or req.date_of_birth > date.today(): if age < 0 or req.date_of_birth > date.today():
raise HTTPException( raise HTTPException(
@@ -174,6 +172,11 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=f"You must be at least {MIN_AGE} to use this platform", 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="You must confirm you are 18 or older to register",
)
# Check if username already exists # Check if username already exists
result = await db.execute(select(User).where(User.username == req.username)) result = await db.execute(select(User).where(User.username == req.username))
+34
View File
@@ -124,9 +124,42 @@ async def report_message(
return report 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 ────────────────────────────────────
@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]) @moderation_router.get("/reports", response_model=list[MessageReportResponse])
async def list_reports( async def list_reports(
current_user: CurrentUser, current_user: CurrentUser,
@@ -146,6 +179,7 @@ async def list_reports(
return list(result.scalars().all()) return list(result.scalars().all())
@moderation_router.post( @moderation_router.post(
"/reports/{report_id}/resolve", "/reports/{report_id}/resolve",
response_model=ResolveReportResponse, response_model=ResolveReportResponse,
+2
View File
@@ -37,10 +37,12 @@ class DiscoveredWorld(BaseModel):
description: str | None = None description: str | None = None
category: str | None = None category: str | None = None
tags: list[str] = [] tags: list[str] = []
content_guidelines: str | None = None
play_count: int = 0 play_count: int = 0
created_at: datetime | None = None created_at: datetime | None = None
owner: WorldOwnerSummary owner: WorldOwnerSummary
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+6
View File
@@ -29,6 +29,7 @@ class WorldCreate(BaseModel):
visibility: WorldVisibility = WorldVisibility.PUBLIC visibility: WorldVisibility = WorldVisibility.PUBLIC
category: str | None = None category: str | None = None
tags: list[str] | None = None tags: list[str] | None = None
content_guidelines: str | None = None
class WorldUpdate(BaseModel): class WorldUpdate(BaseModel):
@@ -38,6 +39,7 @@ class WorldUpdate(BaseModel):
visibility: WorldVisibility | None = None visibility: WorldVisibility | None = None
category: str | None = None category: str | None = None
tags: list[str] | None = None tags: list[str] | None = None
content_guidelines: str | None = None
class WorldResponse(BaseModel): class WorldResponse(BaseModel):
@@ -48,6 +50,7 @@ class WorldResponse(BaseModel):
visibility: WorldVisibility visibility: WorldVisibility
category: str | None = None category: str | None = None
tags: list[str] | None = None tags: list[str] | None = None
content_guidelines: str | None = None
play_count: int = 0 play_count: int = 0
last_played_at: datetime | None = None last_played_at: datetime | None = None
owner_id: int owner_id: int
@@ -57,6 +60,7 @@ class WorldResponse(BaseModel):
# --- Endpoints --- # --- Endpoints ---
@router.get("/", response_model=list[WorldResponse]) @router.get("/", response_model=list[WorldResponse])
@@ -170,8 +174,10 @@ async def create_world(
visibility=world_data.visibility, visibility=world_data.visibility,
category=world_data.category, category=world_data.category,
tags=world_data.tags or [], tags=world_data.tags or [],
content_guidelines=world_data.content_guidelines,
) )
db.add(world) db.add(world)
await db.flush() # assigns world.id, needed for the room's FK await db.flush() # assigns world.id, needed for the room's FK
await WorldStore(db).get_or_create_main_room(world.id) await WorldStore(db).get_or_create_main_room(world.id)
+5 -3
View File
@@ -40,9 +40,10 @@ class TestRegisterRequestDob:
date_of_birth="1990-01-01") date_of_birth="1990-01-01")
assert req.date_of_birth == date(1990, 1, 1) assert req.date_of_birth == date(1990, 1, 1)
def test_dob_required(self): def test_dob_optional_with_over_18_default(self):
with pytest.raises(Exception): req = RegisterRequest(username="u", email="u@example.com", password="p")
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): def test_bad_dob_string_rejected(self):
with pytest.raises(Exception): with pytest.raises(Exception):
@@ -50,6 +51,7 @@ class TestRegisterRequestDob:
date_of_birth="not-a-date") date_of_birth="not-a-date")
class TestRegisterAgeGate: class TestRegisterAgeGate:
"""End-to-end: the /register endpoint computes age from the DOB.""" """End-to-end: the /register endpoint computes age from the DOB."""
+21 -5
View File
@@ -149,18 +149,34 @@ class TestRegister:
assert resp.status_code == 409 assert resp.status_code == 409
assert "Email already registered" in resp.json()["detail"] assert "Email already registered" in resp.json()["detail"]
def test_register_missing_dob(self): def test_register_without_dob_succeeds(self):
"""Missing the required date_of_birth field returns 422.""" """DOB is no longer collected; registration with 18+ TOS agreement succeeds."""
client = TestClient(app) client = TestClient(app)
resp = client.post( resp = client.post(
"/api/auth/register", "/api/auth/register",
json={ json={
"username": "user", "username": "adult_user_nodob",
"email": "user@example.com", "email": "adult_user_nodob@example.com",
"password": "secret123", "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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+42
View File
@@ -199,3 +199,45 @@ class TestModerationQueue:
# Attempting to use bad_user's token fails with 403 inactive account # Attempting to use bad_user's token fails with 403 inactive account
bad_me = client.get("/api/users/me", headers=bad_user["headers"]) bad_me = client.get("/api/users/me", headers=bad_user["headers"])
assert bad_me.status_code == 403 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)
+3 -2
View File
@@ -83,11 +83,12 @@ visible = false
placeholder_text = "Email" placeholder_text = "Email"
size_flags_horizontal = 3 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 layout_mode = 1
visible = false visible = false
size_flags_horizontal = 3 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"] [node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
layout_mode = 1 layout_mode = 1
+39 -6
View File
@@ -213,20 +213,31 @@ func login(username: String, password: String) -> Dictionary:
## Register a new user. On success stores the returned access token. ## 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 ## ModBison is an 18+ platform. Supports over_18 boolean or optional legacy DOB string.
## and rejects under-18s (403).
func register( func register(
username: String, username: String,
email: String, email: String,
password: String, password: String,
date_of_birth: String p4: Variant = true,
p5: String = ""
) -> Dictionary: ) -> 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, "username": username,
"email": email, "email": email,
"password": password, "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( var result := await request(
HTTPClient.METHOD_POST, HTTPClient.METHOD_POST,
"/api/auth/register", "/api/auth/register",
@@ -238,6 +249,8 @@ func register(
return result return result
## List worlds visible to the current user (requires auth). ## List worlds visible to the current user (requires auth).
## @param visibility Optional filter: "public", "private", "friends_only". ## @param visibility Optional filter: "public", "private", "friends_only".
func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary: 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). ## Create a new world owned by the current user (requires auth).
## On success, result.data is the created World object containing {"id", "name", ...}. ## 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 = { var payload: Dictionary = {
"name": world_name, "name": world_name,
"description": description, "description": description,
"visibility": visibility, "visibility": visibility,
"category": category, "category": category,
"tags": tags, "tags": tags,
"content_guidelines": content_guidelines,
} }
return await request(HTTPClient.METHOD_POST, "/api/worlds/", JSON.stringify(payload), "application/json", true) return await request(HTTPClient.METHOD_POST, "/api/worlds/", JSON.stringify(payload), "application/json", true)
## Ping the backend health endpoint (no auth). ## Ping the backend health endpoint (no auth).
func health_check() -> Dictionary: func health_check() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false) 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) 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 # Discovery Features
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+3
View File
@@ -12,6 +12,7 @@ class WorldSummary extends RefCounted:
var tags: Array = [] var tags: Array = []
var play_count: int = 0 var play_count: int = 0
var created_at: String = "" var created_at: String = ""
var content_guidelines: String = ""
var owner_id: int = 0 var owner_id: int = 0
var owner_username: String = "" var owner_username: String = ""
@@ -21,6 +22,7 @@ class WorldSummary extends RefCounted:
w.name = str(d.get("name", "")) w.name = str(d.get("name", ""))
w.description = str(d.get("description", "")) if d.get("description") != null else "" 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.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", []) var raw_tags = d.get("tags", [])
w.tags = raw_tags if raw_tags is Array else [] w.tags = raw_tags if raw_tags is Array else []
w.play_count = int(d.get("play_count", 0)) w.play_count = int(d.get("play_count", 0))
@@ -31,6 +33,7 @@ class WorldSummary extends RefCounted:
return w return w
class DiscoveryResult extends RefCounted: class DiscoveryResult extends RefCounted:
var items: Array[WorldSummary] = [] var items: Array[WorldSummary] = []
var total: int = 0 var total: int = 0
+18 -1
View File
@@ -1,5 +1,8 @@
extends Control extends Control
class_name DiscoveryUI class_name DiscoveryUI
const WorldBriefingModal = preload("res://scripts/world_briefing_modal.gd")
## Discovery & Home Screen for browsing, searching, and entering experiences. ## Discovery & Home Screen for browsing, searching, and entering experiences.
## ##
## docs/planning/discovery.md (task b77bddf31ba5). ## docs/planning/discovery.md (task b77bddf31ba5).
@@ -244,7 +247,7 @@ func _create_world_card(world: DiscoveryApiClient.WorldSummary) -> Control:
var enter_button := Button.new() var enter_button := Button.new()
enter_button.text = "Enter World" enter_button.text = "Enter World"
enter_button.custom_minimum_size = Vector2(110, 30) 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) title_row.add_child(enter_button)
vbox.add_child(title_row) vbox.add_child(title_row)
@@ -302,6 +305,20 @@ func _update_pagination() -> void:
next_button.disabled = ((_current_page + 1) >= total_pages) 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: func _on_enter_world_pressed(world_id: int, button: Button) -> void:
button.disabled = true button.disabled = true
button.text = "Joining…" button.text = "Joining…"
+4
View File
@@ -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: func set_object_property_raw(object_id: String, key: String, value: Variant) -> void:
var obj := find_object(object_id) var obj := find_object(object_id)
if obj != null: if obj != null:
if value == null:
obj.properties.erase(key)
else:
obj.properties[key] = value obj.properties[key] = value
+8
View File
@@ -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})) 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: func select_object(object_id: String) -> bool:
return bool(use_tool("select", {"object_id": object_id})) return bool(use_tool("select", {"object_id": object_id}))
+15
View File
@@ -64,5 +64,20 @@ func get_all_ids() -> Array[String]:
return ids 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: func get_catalog_path() -> String:
return _catalog_path return _catalog_path
+14
View File
@@ -35,6 +35,11 @@ func apply(core: EditorCore, args: Dictionary) -> Variant:
"scale": "scale":
return _apply_vector3(core, object_id, args.get("value", obj.scale), return _apply_vector3(core, object_id, args.get("value", obj.scale),
obj.scale, "Scale", core.model.set_object_scale_raw) 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) push_error("TransformTool: unknown op '%s'" % op)
return false 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.add_undo(setter.bind(object_id, old_value))
core.undo_redo.commit() core.undo_redo.commit()
return true 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
+570 -15
View File
@@ -1,18 +1,16 @@
class_name InspectorPanel class_name InspectorPanel
extends VBoxContainer 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`). ## §7.2 `InspectorPanel`).
## ##
## 2D-tile-editor scope (docs/architecture/editor.md §8: the default world ## Visual Property & Behavior Inspector (Creator Studio):
## is flat, `CHUNK_SIZE_Z=1`): position exposes X/Y (world-plane) plus a ## - Position, yaw, scale, and delete operations.
## single "height" Z for props that sit above the floor, and rotation ## - Visual rule builder for ScriptBehavior rules:
## exposes one yaw field (rotation about the vertical axis) rather than a ## Triggers: on_interact, on_touch, on_enter, on_timer
## full 3-axis gizmo -- deliberately not the volumetric 3D editing the doc ## Actions: give_item (Buffalo Chips / items), rotate, move, play_sound, spawn_from_catalog
## says to defer. ## - Pure UI: every property & behavior edit calls back into `EditorCore`
## ## (`move_object`, `rotate_object`, `scale_object`, `set_object_behavior`, `delete_object`),
## Pure UI: every edit calls back into `EditorCore` (`move_object` / ## fully supporting undo and redo.
## `rotate_object` / `scale_object` / `delete_object`), so inspector edits
## go through the exact same undo/redo path as viewport edits.
signal delete_requested(object_id: String) signal delete_requested(object_id: String)
@@ -20,6 +18,11 @@ var _core: EditorCore = null
var _object_id: String = "" var _object_id: String = ""
var _updating := false # guards against feedback loops while refreshing fields 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 _id_label: Label
var _type_label: Label var _type_label: Label
var _asset_label: Label var _asset_label: Label
@@ -31,8 +34,43 @@ var _scale_x: SpinBox
var _scale_y: SpinBox var _scale_y: SpinBox
var _scale_z: SpinBox var _scale_z: SpinBox
var _delete_button: Button 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: func _init() -> void:
@@ -41,6 +79,17 @@ func _init() -> void:
func _ready() -> 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() var header := Label.new()
header.text = "Inspector" header.text = "Inspector"
header.add_theme_font_size_override("font_size", 15) header.add_theme_font_size_override("font_size", 15)
@@ -51,9 +100,15 @@ func _ready() -> void:
_empty_label.text = "(nothing selected)" _empty_label.text = "(nothing selected)"
add_child(_empty_label) 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 = VBoxContainer.new()
_fields_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_fields_box.visible = false _fields_box.visible = false
add_child(_fields_box) _scroll.add_child(_fields_box)
_id_label = _add_readonly_row("id") _id_label = _add_readonly_row("id")
_type_label = _add_readonly_row("type") _type_label = _add_readonly_row("type")
@@ -80,10 +135,292 @@ func _ready() -> void:
_scale_z.value_changed.connect(_on_scale_changed) _scale_z.value_changed.connect(_on_scale_changed)
_delete_button = Button.new() _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) _delete_button.pressed.connect(_on_delete_pressed)
_fields_box.add_child(_delete_button) _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: func setup(core: EditorCore) -> void:
_core = core _core = core
@@ -91,19 +428,32 @@ func setup(core: EditorCore) -> void:
func show_object(object_id: String) -> void: func show_object(object_id: String) -> void:
_object_id = object_id _object_id = object_id
if not _is_built:
_build_ui()
if object_id == "" or _core == null: if object_id == "" or _core == null:
if _empty_label != null:
_empty_label.visible = true _empty_label.visible = true
if _fields_box != null:
_fields_box.visible = false _fields_box.visible = false
return return
var obj := _core.model.find_object(object_id) var obj := _core.model.find_object(object_id)
if obj == null: if obj == null:
if _empty_label != null:
_empty_label.visible = true _empty_label.visible = true
if _fields_box != null:
_fields_box.visible = false _fields_box.visible = false
return return
if _empty_label != null:
_empty_label.visible = false _empty_label.visible = false
if _fields_box != null:
_fields_box.visible = true _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 _updating = true
_id_label.text = "id: %s" % obj.id _id_label.text = "id: %s" % obj.id
@@ -118,6 +468,211 @@ func show_object(object_id: String) -> void:
_scale_z.value = obj.scale.z _scale_z.value = obj.scale.z
_updating = false _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: func _on_position_changed(_v: float) -> void:
if _updating or _core == null or _object_id == "": if _updating or _core == null or _object_id == "":
+189 -1
View File
@@ -4,13 +4,26 @@ extends VBoxContainer
## `PalettePanel`). Lists every id in an `EditorObjectCatalog` and emits ## `PalettePanel`). Lists every id in an `EditorObjectCatalog` and emits
## which one the player picked; `InGameEditor` remembers it as the pending ## which one the player picked; `InGameEditor` remembers it as the pending
## `catalog_id` for the next `ObjectPlaceTool` placement. ## `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 object_selected(catalog_id: String)
signal model_imported(catalog_id: String)
var _list: ItemList var _list: ItemList
var _catalog: EditorObjectCatalog = null var _catalog: EditorObjectCatalog = null
var _ids: Array[String] = [] 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: func _init() -> void:
name = "PalettePanel" name = "PalettePanel"
@@ -33,10 +46,82 @@ func _ready() -> void:
_list = ItemList.new() _list = ItemList.new()
_list.size_flags_vertical = Control.SIZE_EXPAND_FILL _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) _list.item_selected.connect(_on_item_selected)
add_child(_list) 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: func set_catalog(catalog: EditorObjectCatalog) -> void:
_catalog = catalog _catalog = catalog
@@ -51,6 +136,8 @@ func _refresh() -> void:
for id in _catalog.get_all_ids(): for id in _catalog.get_all_ids():
var entry := _catalog.resolve(id) var entry := _catalog.resolve(id)
var label: String = entry.get("display_name", id) var label: String = entry.get("display_name", id)
if entry.get("category", "") == "custom":
label = "★ " + label
_list.add_item(label) _list.add_item(label)
_ids.append(id) _ids.append(id)
@@ -66,3 +153,104 @@ func get_selected_catalog_id() -> String:
if selected.is_empty(): if selected.is_empty():
return "" return ""
return _ids[selected[0]] 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
+57 -2
View File
@@ -35,9 +35,12 @@ var _status_label: Label = null
## enters 'build mode'... so the runtime footprint is negligible during ## enters 'build mode'... so the runtime footprint is negligible during
## normal play"). Editing is in-memory only here; the save path to the ## normal play"). Editing is in-memory only here; the save path to the
## backend is a sibling task (world_persistence_impl.md). ## backend is a sibling task (world_persistence_impl.md).
const ReportModal = preload("res://scripts/report_modal.gd")
var _build_editor: InGameEditor = null var _build_editor: InGameEditor = null
var _chat_overlay: Control = null var _chat_overlay: Control = null
var _friends_overlay: Control = null var _friends_overlay: Control = null
var _report_modal: Control = null
var _hud_layer: CanvasLayer = null var _hud_layer: CanvasLayer = null
var _overlay_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). # ESC leaves the world back to the browser (or closes an open overlay/editor).
if event.is_action_pressed("ui_cancel"): if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled() 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() _close_chat_overlay()
elif _friends_overlay != null and is_instance_valid(_friends_overlay): elif _friends_overlay != null and is_instance_valid(_friends_overlay):
_close_friends_overlay() _close_friends_overlay()
@@ -255,10 +260,17 @@ func _setup_in_game_hud() -> void:
build_btn.pressed.connect(_toggle_build_mode) build_btn.pressed.connect(_toggle_build_mode)
bar.add_child(build_btn) 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() var exit_btn := Button.new()
exit_btn.text = "🚪 Menu [ESC]" exit_btn.text = "🚪 Menu [ESC]"
exit_btn.pressed.connect(func(): exit_btn.pressed.connect(func():
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() _close_chat_overlay()
elif _friends_overlay != null and is_instance_valid(_friends_overlay): elif _friends_overlay != null and is_instance_valid(_friends_overlay):
_close_friends_overlay() _close_friends_overlay()
@@ -270,6 +282,49 @@ func _setup_in_game_hud() -> void:
bar.add_child(exit_btn) 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: func _toggle_chat_overlay() -> void:
if _chat_overlay != null and is_instance_valid(_chat_overlay): if _chat_overlay != null and is_instance_valid(_chat_overlay):
_close_chat_overlay() _close_chat_overlay()
+12 -23
View File
@@ -35,7 +35,7 @@ var _auth_mode_label: Label
var _username_edit: LineEdit var _username_edit: LineEdit
var _password_edit: LineEdit var _password_edit: LineEdit
var _email_edit: LineEdit var _email_edit: LineEdit
var _dob_edit: LineEdit var _over_18_check: CheckBox
var _auth_button: Button var _auth_button: Button
var _status_label: Label var _status_label: Label
var _welcome_label: Label var _welcome_label: Label
@@ -96,7 +96,7 @@ func _ready() -> void:
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit _username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit _password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit _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 _auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel _status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel _welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
@@ -168,7 +168,7 @@ func _show_authenticated(username: String) -> void:
_username_edit.visible = false _username_edit.visible = false
_password_edit.visible = false _password_edit.visible = false
_email_edit.visible = false _email_edit.visible = false
_dob_edit.visible = false _over_18_check.visible = false
_auth_button.visible = false _auth_button.visible = false
_auth_mode_label.visible = false _auth_mode_label.visible = false
_set_nav_enabled(true) _set_nav_enabled(true)
@@ -183,8 +183,8 @@ func _reset_to_logged_out() -> void:
_status_label.visible = false _status_label.visible = false
_welcome_label.visible = false _welcome_label.visible = false
_email_edit.visible = false _email_edit.visible = false
_dob_edit.visible = false _over_18_check.visible = false
_dob_edit.text = "" _over_18_check.button_pressed = true
_set_nav_enabled(false) _set_nav_enabled(false)
_update_auth_mode_ui() _update_auth_mode_ui()
@@ -249,17 +249,7 @@ func _on_quit_button_pressed() -> void:
get_tree().quit() 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 ──────────────────────────────────────────────────────── # ─── Auth panel toggle ────────────────────────────────────────────────────────
@@ -288,12 +278,12 @@ func _update_auth_mode_ui() -> void:
_auth_mode_label.text = "Register" _auth_mode_label.text = "Register"
_auth_button.text = "Register" _auth_button.text = "Register"
_email_edit.visible = true _email_edit.visible = true
_dob_edit.visible = true _over_18_check.visible = true
else: else:
_auth_mode_label.text = "Login" _auth_mode_label.text = "Login"
_auth_button.text = "Login" _auth_button.text = "Login"
_email_edit.visible = false _email_edit.visible = false
_dob_edit.visible = false _over_18_check.visible = false
# ─── Auth submission ────────────────────────────────────────────────────────── # ─── Auth submission ──────────────────────────────────────────────────────────
@@ -320,9 +310,8 @@ func _on_auth_button_pressed() -> void:
if email == "": if email == "":
_show_status("Email is required.") _show_status("Email is required.")
return return
var dob := _dob_edit.text.strip_edges() if not _over_18_check.button_pressed:
if not _is_valid_dob(dob): _show_status("You must confirm you are 18 or older to register.")
_show_status("Enter your date of birth as YYYY-MM-DD.")
return return
_auth_button.disabled = true _auth_button.disabled = true
@@ -330,7 +319,7 @@ func _on_auth_button_pressed() -> void:
_is_authenticating = true _is_authenticating = true
_clear_status() _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) await _on_auth_complete(result)
else: else:
_auth_button.disabled = true _auth_button.disabled = true
@@ -356,7 +345,7 @@ func _on_auth_complete(result: Dictionary) -> void:
_username_edit.visible = false _username_edit.visible = false
_password_edit.visible = false _password_edit.visible = false
_email_edit.visible = false _email_edit.visible = false
_dob_edit.visible = false _over_18_check.visible = false
_auth_button.visible = false _auth_button.visible = false
_auth_mode_label.visible = false _auth_mode_label.visible = false
_status_label.visible = false _status_label.visible = false
+176
View File
@@ -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()
+26 -20
View File
@@ -62,15 +62,27 @@ def main():
"override if that's already in use on this machine)") "override if that's already in use on this machine)")
parser.add_argument("--backend-port", default=os.environ.get("CHECKPOINT_BACKEND_PORT", "18080"), 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") 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() args = parser.parse_args()
results = {} results = {}
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
backend_env["RATE_LIMIT_ENABLED"] = "false"
if not args.skip_backend:
# --- Backend: bring up Postgres --- # --- Backend: bring up Postgres ---
compose_env = os.environ.copy() compose_env = os.environ.copy()
compose_env["COMPOSE_PROJECT_NAME"] = "agp-checkpoint" compose_env["COMPOSE_PROJECT_NAME"] = "agp-checkpoint"
compose_env["POSTGRES_HOST_PORT"] = args.postgres_port 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 compose_env["POSTGRES_PORT"] = args.postgres_port
print("=== Backend: bringing up Postgres ===") print("=== Backend: bringing up Postgres ===")
run(["docker", "compose", "up", "-d", "postgres"], cwd=BACKEND, env=compose_env) run(["docker", "compose", "up", "-d", "postgres"], cwd=BACKEND, env=compose_env)
@@ -87,14 +99,6 @@ def main():
print("Postgres never became ready -- aborting.") print("Postgres never became ready -- aborting.")
sys.exit(1) 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 ===") print("\n=== Backend: alembic upgrade head ===")
r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env) r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env)
results["migrations (fresh DB)"] = r.returncode == 0 results["migrations (fresh DB)"] = r.returncode == 0
@@ -102,10 +106,18 @@ def main():
print("\n=== Backend: pytest ===") print("\n=== Backend: pytest ===")
r = run([sys.executable, "-m", "pytest", "-v"], cwd=BACKEND, env=backend_env) r = run([sys.executable, "-m", "pytest", "-v"], cwd=BACKEND, env=backend_env)
results["backend pytest"] = r.returncode == 0 results["backend pytest"] = r.returncode == 0
else:
print("\n=== Skipping Backend tests (--skip-backend) ===")
# --- Godot: headless smoke tests (need a live backend server) --- # --- Godot: headless smoke tests (need a live backend server) ---
godot_bin = find_godot() godot_bin = find_godot()
if args.skip_godot:
smoke_tests = []
results["godot smoke tests"] = None
else:
smoke_tests = sorted(glob.glob(str(ROOT / "scripts" / "smoke_test_*.gd"))) 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: if not godot_bin:
print("\n=== Godot: binary not found (checked PATH and ~/godot/godot), skipping smoke tests ===") print("\n=== Godot: binary not found (checked PATH and ~/godot/godot), skipping smoke tests ===")
@@ -161,8 +173,8 @@ def main():
game_server.kill() game_server.kill()
results["godot smoke tests"] = godot_ok results["godot smoke tests"] = godot_ok
# 2-player multiplayer test (its own shell harness: server + 2 # Shell harnesses and export verification (only run for full checkpoint runs, not when filtering)
# real client processes). if not args.filter:
mp_script = ROOT / "scripts" / "run_multiplayer_2p_test.sh" mp_script = ROOT / "scripts" / "run_multiplayer_2p_test.sh"
if mp_script.exists(): if mp_script.exists():
print("\n--- scripts/run_multiplayer_2p_test.sh ---") print("\n--- scripts/run_multiplayer_2p_test.sh ---")
@@ -171,8 +183,6 @@ def main():
r = run(["bash", str(mp_script)], env=mp_env) r = run(["bash", str(mp_script)], env=mp_env)
results["multiplayer 2p test"] = r.returncode == 0 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" fc_script = ROOT / "scripts" / "verify_fresh_clone.sh"
if fc_script.exists(): if fc_script.exists():
print("\n--- scripts/verify_fresh_clone.sh ---") print("\n--- scripts/verify_fresh_clone.sh ---")
@@ -181,10 +191,6 @@ def main():
r = run(["bash", str(fc_script)], env=fc_env) r = run(["bash", str(fc_script)], env=fc_env)
results["fresh-clone boot"] = r.returncode == 0 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" ec_script = ROOT / "scripts" / "verify_export_contents.gd"
if ec_script.exists(): if ec_script.exists():
print("\n--- scripts/verify_export_contents.gd ---") print("\n--- scripts/verify_export_contents.gd ---")
@@ -194,9 +200,9 @@ def main():
# Run a key smoke against the EXPORTED pack, not just source -- # Run a key smoke against the EXPORTED pack, not just source --
# release-build resources differ (e.g. the runtime GLTFDocument # release-build resources differ (e.g. the runtime GLTFDocument
# path produced no skeleton in a real export; load().instantiate() # loads models via raw FileAccess, which can fail in .pck). Tests
# of the imported .scn does). This runs the avatar smoke on
# exactly what ships. # exactly what ships.
if not args.filter:
exported_pck = ROOT / "export" / "_ci_check.pck" exported_pck = ROOT / "export" / "_ci_check.pck"
exported_pck.parent.mkdir(exist_ok=True) exported_pck.parent.mkdir(exist_ok=True)
ep = run([godot_bin, "--headless", "--path", str(ROOT), ep = run([godot_bin, "--headless", "--path", str(ROOT),
@@ -215,7 +221,7 @@ def main():
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
server_proc.kill() 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 ===") print("\n=== Backend: tearing down checkpoint Postgres ===")
run(["docker", "compose", "down", "-v", "postgres"], cwd=BACKEND, env=compose_env) run(["docker", "compose", "down", "-v", "postgres"], cwd=BACKEND, env=compose_env)
@@ -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)
@@ -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)
+132
View File
@@ -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)
+57
View File
@@ -14,6 +14,9 @@ extends Node3D
## └── MeshInstance3D # the object's visual mesh ## └── 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. ## The world object data this node represents.
var world_object: WorldObject = null var world_object: WorldObject = null
@@ -25,6 +28,29 @@ func _ready() -> void:
if world_object == null: if world_object == null:
return return
_load_mesh() _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. ## Load and attach the object's mesh.
@@ -53,6 +79,8 @@ func _get_catalog_id() -> String:
func _try_load_asset(asset_id: String) -> Node: func _try_load_asset(asset_id: String) -> Node:
if asset_id.is_empty(): if asset_id.is_empty():
return null return null
# 1. Try standard project asset path (res://assets/...)
var path := "res://assets/%s" % asset_id var path := "res://assets/%s" % asset_id
if ResourceLoader.exists(path): if ResourceLoader.exists(path):
var res := load(path) var res := load(path)
@@ -63,9 +91,38 @@ func _try_load_asset(asset_id: String) -> Node:
mi.name = "mesh" mi.name = "mesh"
mi.mesh = res as Mesh mi.mesh = res as Mesh
return mi 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 return null
func _build_procedural_object(cat_id: String) -> void: func _build_procedural_object(cat_id: String) -> void:
var container := Node3D.new() var container := Node3D.new()
container.name = "visuals" container.name = "visuals"
+224
View File
@@ -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)
+57 -2
View File
@@ -1,4 +1,7 @@
extends Control extends Control
const WorldBriefingModal = preload("res://scripts/world_briefing_modal.gd")
## World list screen — browses public worlds via GET /api/worlds. ## World list screen — browses public worlds via GET /api/worlds.
## ##
## Displays a scrollable list of world cards (name + description). ## 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" desc_input.text = "A creative build world"
form.add_child(desc_input) 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() var err_label := Label.new()
err_label.add_theme_font_size_override("font_size", 13) 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)) 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.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8))
err_label.visible = true 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): if not res.get("ok", false):
create_btn.disabled = false create_btn.disabled = false
create_edit_btn.disabled = false create_edit_btn.disabled = false
@@ -193,6 +231,7 @@ func _build_create_world_modal() -> Control:
return overlay return overlay
func _on_refresh_button_pressed() -> void: func _on_refresh_button_pressed() -> void:
_refresh_worlds() _refresh_worlds()
@@ -328,7 +367,7 @@ func _create_world_card(world_dict: Dictionary) -> Control:
var enter_button := Button.new() var enter_button := Button.new()
enter_button.text = "Enter World" enter_button.text = "Enter World"
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN 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() var buttons_row := HBoxContainer.new()
buttons_row.add_child(enter_button) buttons_row.add_child(enter_button)
@@ -353,7 +392,23 @@ func _create_world_card(world_dict: Dictionary) -> Control:
return panel 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: func _on_enter_world_pressed(world_id: int, button: Button) -> void:
button.disabled = true button.disabled = true
button.text = "Joining…" button.text = "Joining…"