Compare commits

..

4 Commits

4 changed files with 679 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
# Package Checkpoint 4 (PC4) — "A world you can build and be social in"
> **Status: Complete & Verified.** PC4 delivers the integrated multi-user social world platform: in-game building and persistence (M2, M7), comprehensive social graph, chat, and presence (M8), virtual economy, marketplace, and creator payouts (M9), and home discovery with search, category filtering, and trending heuristics (M10).
Built and verified by the Antigravity fleet, branch `agent/2b4212c24690-pc4-checkpoint` → `main`.
---
## 1. What's Real and Verified (`scripts/run_checkpoint_tests.py`)
| Check / Subsystem | Status | Verification Detail |
|---|---|---|
| Alembic Migrations (0001 → 0015) | PASS | Hermetic clean run, head revision `0015_add_discovery_metadata` |
| Backend API Test Suite | PASS | 100+ tests across auth, worlds, social, chat, wallet, marketplace, discovery |
| Godot Headless Smoke Tests | PASS | 20+ headless smoke suites covering all UIs, network, avatars, economy, discovery |
| 2-Player Multiplayer (`run_multiplayer_2p_test.sh`) | PASS | Real dedicated server + 2 simultaneous Godot client processes |
| Fresh-Clone Boot (`verify_fresh_clone.sh`) | PASS | Clean clone imports, compiles, and boots without script errors |
| Windows Desktop .exe Export | PASS | Packaged release binary with baked-in backend endpoint via `checkpoint_ops.py` |
---
## 2. Milestone Capabilities Included
### M2 & M7 — World Persistence & In-Game Editor
- **Server Persistence**: World rooms, chunk coordinates (cx, cy, cz), binary chunk payload codec, chunk revision audit trail.
- **In-Game Editor**: Tools (Brush, Erase, Object Placement, Select, Transform), chunk edit model, undo/redo stack, non-blocking HTTP chunk saving.
### M8 — Social: Friends, Presence, Chat, Groups & Profiles
- **Social Graph**: Friend requests (pending, accepted, declined), follow/unfollow, bidirectional block enforcement.
- **Presence & Status**: Real-time Redis-backed heartbeats with custom status messages, friends presence queries.
- **Chat System**: 1-on-1 direct messaging, in-world spatial chat, chat moderation report queue with profanity filtering.
- **Groups**: Group creation, membership roles (owner, admin, member), member management, group transfer.
- **Public Profiles**: Bio, custom avatar rendering, favorite worlds listing, privacy controls.
- **WebSocket Gateway**: Dual endpoint (`/api/ws`, `/v1/ws`) broadcasting presence and real-time chat messages.
- **Godot Client & UIs**: `FriendsPresenceUI`, `ChatUI`, `ProfileUI`, `GroupsUI`.
### M9 — Economy: Wallets, Marketplace & Creator Payouts
- **Wallets & Ledger**: Double-entry accounting for balance credits, debits, transfers, and escrow.
- **Purchases**: `PaymentProvider` abstraction with simulated and external payment confirmations.
- **Marketplace**: Catalog item models, active listings, 30/70 platform/creator fee splits with atomic escrow settlement.
- **Creator Payouts**: Payout request lifecycle (`pending` → `approved` / `rejected` → `paid`), admin audit review.
- **Godot Client & UIs**: `WalletUI` (balance & transaction history), `MarketplaceUI` (browse & buy), `SellingPayoutUI` (create listings & request payouts).
### M10 — Discovery: Browse, Search & Home Feed
- **World Discovery Metadata**: Categories, tags (ARRAY with GIN indexing), play count, and last played timestamp.
- **Visit Tracking**: Atomic increment of `play_count` and `world_visits` log with request deduplication.
- **Browse & Search API**: Full-text name ILIKE query, category exact match, tag overlap (`&&`), sorting (`newest`, `most_played`, `trending`).
- **Trending Heuristic**: Time-decay weighted scoring over recent visits (last 24h: 3x, 3d: 2x, 7d: 1x) via single aggregate outer-join query.
- **Godot Client & UI**: `DiscoveryApiClient` and `DiscoveryUI` (`scenes/discovery_ui.tscn`) with live search, dropdown filters, pagination, and one-click world entry.
---
## 3. Playtest Guide
1. **Dashboard Start**:
- Open http://localhost:7070/checkpoints
- Locate **PC4: A world you can build and be social in**
- Click **Start Backend** (launches backend on `:28004`, Postgres on `:25544`, ENet game servers on `27100–27149`)
- The backend automatically seeds the `playtester` account and 5 sample worlds across categories.
2. **Download & Play**:
- Click **Download Windows .exe**
- Launch `agp-game.exe` (automatically targets the PC4 backend instance)
- Browse the **Discover** tab to see curated, trending, and categorized worlds
- Enter worlds, socialize, chat, trade in the marketplace, and build.
+150
View File
@@ -0,0 +1,150 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/discovery_ui.gd" id="1_disc"]
[node name="DiscoveryUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_disc")
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 24.0
offset_right = -32.0
offset_bottom = -24.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="Header" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBox/Header"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBox/Header"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Discover Experiences"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBox/Header"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="StatusLabel" type="Label" parent="VBox"]
visible = false
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.3, 0.3, 1)
text = ""
[node name="Filters" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="SearchEdit" type="LineEdit" parent="VBox/Filters"]
custom_minimum_size = Vector2(240, 36)
layout_mode = 2
placeholder_text = "Search by name or tag..."
[node name="CategoryOption" type="OptionButton" parent="VBox/Filters"]
custom_minimum_size = Vector2(160, 36)
layout_mode = 2
[node name="SortOption" type="OptionButton" parent="VBox/Filters"]
custom_minimum_size = Vector2(140, 36)
layout_mode = 2
[node name="FilterButton" type="Button" parent="VBox/Filters"]
custom_minimum_size = Vector2(90, 36)
layout_mode = 2
text = "Filter"
[node name="Content" type="Control" parent="VBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="LoadingLabel" type="Label" parent="VBox/Content"]
visible = false
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -12.0
offset_bottom = 12.0
text = "Loading experiences..."
horizontal_alignment = 1
[node name="ErrorLabel" type="Label" parent="VBox/Content"]
visible = false
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -12.0
offset_bottom = 12.0
theme_override_colors/font_color = Color(1, 0.4, 0.4, 1)
text = ""
horizontal_alignment = 1
[node name="EmptyLabel" type="Label" parent="VBox/Content"]
visible = false
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -12.0
offset_bottom = 12.0
theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1)
text = "No experiences found."
horizontal_alignment = 1
[node name="Scroll" type="ScrollContainer" parent="VBox/Content"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="WorldsVBox" type="VBoxContainer" parent="VBox/Content/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 10
[node name="Pagination" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 16
alignment = 1
[node name="PrevButton" type="Button" parent="VBox/Pagination"]
custom_minimum_size = Vector2(90, 32)
layout_mode = 2
text = "< Prev"
[node name="PageLabel" type="Label" parent="VBox/Pagination"]
layout_mode = 2
text = "Page 1 of 1"
horizontal_alignment = 1
[node name="NextButton" type="Button" parent="VBox/Pagination"]
custom_minimum_size = Vector2(90, 32)
layout_mode = 2
text = "Next >"
+376
View File
@@ -0,0 +1,376 @@
extends Control
class_name DiscoveryUI
## Discovery & Home Screen for browsing, searching, and entering experiences.
##
## docs/planning/discovery.md (task b77bddf31ba5).
signal back_requested
signal world_selected(world_id: int)
signal worlds_loaded(count: int)
@onready var back_button: Button = $VBox/Header/BackButton
@onready var refresh_button: Button = $VBox/Header/RefreshButton
@onready var status_label: Label = $VBox/StatusLabel
# Filter Controls
@onready var search_edit: LineEdit = $VBox/Filters/SearchEdit
@onready var category_option: OptionButton = $VBox/Filters/CategoryOption
@onready var sort_option: OptionButton = $VBox/Filters/SortOption
@onready var filter_button: Button = $VBox/Filters/FilterButton
# Content
@onready var loading_label: Label = $VBox/Content/LoadingLabel
@onready var error_label: Label = $VBox/Content/ErrorLabel
@onready var empty_label: Label = $VBox/Content/EmptyLabel
@onready var worlds_container: VBoxContainer = $VBox/Content/Scroll/WorldsVBox
# Pagination
@onready var prev_button: Button = $VBox/Pagination/PrevButton
@onready var next_button: Button = $VBox/Pagination/NextButton
@onready var page_label: Label = $VBox/Pagination/PageLabel
var _api_client: Node = null
var _current_page: int = 0
var _page_size: int = 10
var _total_count: int = 0
var _loaded_worlds: Array[DiscoveryApiClient.WorldSummary] = []
func _ready() -> void:
_api_client = _get_api()
_wire_signals()
_setup_sort_options()
_setup_initial_ui_state()
# Populate categories then load initial worlds feed
await load_categories()
await load_worlds(0)
func _get_api() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
var api_script = load("res://scripts/api_client.gd")
if api_script:
var api = api_script.new()
add_child(api)
return api
return null
func _setup_initial_ui_state() -> void:
if loading_label: loading_label.visible = false
if error_label: error_label.visible = false
if empty_label: empty_label.visible = false
if prev_button: prev_button.disabled = true
if next_button: next_button.disabled = true
if page_label: page_label.text = "Page 1"
func _setup_sort_options() -> void:
if not sort_option:
return
sort_option.clear()
sort_option.add_item("Newest", 0)
sort_option.add_item("Most Played", 1)
sort_option.add_item("Trending", 2)
sort_option.selected = 0
func _wire_signals() -> void:
if back_button and not back_button.pressed.is_connected(_on_back_pressed):
back_button.pressed.connect(_on_back_pressed)
if refresh_button and not refresh_button.pressed.is_connected(_on_refresh_pressed):
refresh_button.pressed.connect(_on_refresh_pressed)
if filter_button and not filter_button.pressed.is_connected(_on_filter_pressed):
filter_button.pressed.connect(_on_filter_pressed)
if search_edit and not search_edit.text_submitted.is_connected(_on_search_submitted):
search_edit.text_submitted.connect(_on_search_submitted)
if category_option and not category_option.item_selected.is_connected(_on_category_selected):
category_option.item_selected.connect(_on_category_selected)
if sort_option and not sort_option.item_selected.is_connected(_on_sort_selected):
sort_option.item_selected.connect(_on_sort_selected)
if prev_button and not prev_button.pressed.is_connected(_on_prev_page_pressed):
prev_button.pressed.connect(_on_prev_page_pressed)
if next_button and not next_button.pressed.is_connected(_on_next_page_pressed):
next_button.pressed.connect(_on_next_page_pressed)
func load_categories() -> void:
if not category_option:
return
category_option.clear()
category_option.add_item("All Categories", 0)
if _api_client == null:
return
var res: Dictionary = await DiscoveryApiClient.get_categories(_api_client)
if res.ok and res.data is Array:
var idx := 1
for cat in res.data:
var cat_str := str(cat).strip_edges()
if cat_str != "":
category_option.add_item(cat_str, idx)
idx += 1
func load_worlds(page: int = 0) -> void:
_current_page = page
if loading_label: loading_label.visible = true
if error_label: error_label.visible = false
if empty_label: empty_label.visible = false
var q := search_edit.text.strip_edges() if search_edit else ""
var category := ""
if category_option and category_option.selected > 0:
category = category_option.get_item_text(category_option.selected)
var sort := "newest"
if sort_option:
match sort_option.selected:
0: sort = "newest"
1: sort = "most_played"
2: sort = "trending"
var skip := _current_page * _page_size
var res: Dictionary = await DiscoveryApiClient.get_worlds(
_api_client,
q,
[],
category,
sort,
skip,
_page_size
)
if loading_label: loading_label.visible = false
if not res.ok:
if error_label:
error_label.text = "Error: %s" % res.detail
error_label.visible = true
_clear_container()
return
var parsed: DiscoveryApiClient.DiscoveryResult = res.get("parsed")
if parsed == null and res.data is Dictionary:
parsed = DiscoveryApiClient.DiscoveryResult.from_dict(res.data)
if parsed == null:
if error_label:
error_label.text = "Invalid discovery response"
error_label.visible = true
_clear_container()
return
_total_count = parsed.total
_loaded_worlds = parsed.items
_render_worlds(_loaded_worlds)
_update_pagination()
worlds_loaded.emit(_loaded_worlds.size())
func _clear_container() -> void:
if not worlds_container:
return
for c in worlds_container.get_children():
worlds_container.remove_child(c)
c.queue_free()
func _render_worlds(worlds: Array[DiscoveryApiClient.WorldSummary]) -> void:
_clear_container()
if worlds.is_empty():
if empty_label:
empty_label.text = "No experiences found matching your criteria."
empty_label.visible = true
return
if empty_label: empty_label.visible = false
for world in worlds:
var card := _create_world_card(world)
worlds_container.add_child(card)
func _create_world_card(world: DiscoveryApiClient.WorldSummary) -> Control:
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.custom_minimum_size = Vector2(0, 90)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.14, 0.16, 0.20, 1.0)
style.corner_radius_top_left = 6
style.corner_radius_top_right = 6
style.corner_radius_bottom_left = 6
style.corner_radius_bottom_right = 6
style.content_margin_left = 16
style.content_margin_right = 16
style.content_margin_top = 10
style.content_margin_bottom = 10
panel.add_theme_stylebox_override("panel", style)
var vbox := VBoxContainer.new()
vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vbox.add_theme_constant_override("separation", 4)
panel.add_child(vbox)
# Title & Category Row
var title_row := HBoxContainer.new()
var name_label := Label.new()
name_label.text = world.name
name_label.add_theme_font_size_override("font_size", 18)
name_label.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0, 1.0))
title_row.add_child(name_label)
if world.category != "":
var cat_label := Label.new()
cat_label.text = " [%s]" % world.category
cat_label.add_theme_font_size_override("font_size", 14)
cat_label.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0, 1.0))
title_row.add_child(cat_label)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
title_row.add_child(spacer)
# Enter World Button
var enter_button := Button.new()
enter_button.text = "Enter World"
enter_button.custom_minimum_size = Vector2(110, 30)
enter_button.pressed.connect(_on_enter_world_pressed.bind(world.id, enter_button))
title_row.add_child(enter_button)
vbox.add_child(title_row)
# Description
var desc_label := Label.new()
desc_label.text = world.description if world.description != "" else "No description provided."
desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD
desc_label.add_theme_font_size_override("font_size", 13)
desc_label.add_theme_color_override("font_color", Color(0.75, 0.75, 0.75, 1.0))
vbox.add_child(desc_label)
# Metadata Row: Tags, Plays, Owner
var meta_row := HBoxContainer.new()
meta_row.add_theme_constant_override("separation", 16)
var play_label := Label.new()
play_label.text = "%d plays" % world.play_count
play_label.add_theme_font_size_override("font_size", 12)
play_label.add_theme_color_override("font_color", Color(0.6, 0.9, 0.6, 1.0))
meta_row.add_child(play_label)
if world.tags.size() > 0:
var tags_str: String = ""
for t in world.tags:
tags_str += "#" + str(t) + " "
var tags_label := Label.new()
tags_label.text = tags_str.strip_edges()
tags_label.add_theme_font_size_override("font_size", 12)
tags_label.add_theme_color_override("font_color", Color(0.8, 0.7, 0.4, 1.0))
meta_row.add_child(tags_label)
if world.owner_username != "":
var owner_label := Label.new()
owner_label.text = "Creator: %s" % world.owner_username
owner_label.add_theme_font_size_override("font_size", 12)
owner_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6, 1.0))
meta_row.add_child(owner_label)
vbox.add_child(meta_row)
return panel
func _update_pagination() -> void:
var total_pages := int(ceil(float(_total_count) / float(_page_size)))
if total_pages <= 0:
total_pages = 1
if page_label:
page_label.text = "Page %d of %d (%d experiences)" % [_current_page + 1, total_pages, _total_count]
if prev_button:
prev_button.disabled = (_current_page <= 0)
if next_button:
next_button.disabled = ((_current_page + 1) >= total_pages)
func _on_enter_world_pressed(world_id: int, button: Button) -> void:
button.disabled = true
button.text = "Joining…"
world_selected.emit(world_id)
# 1. Record visit metric
if _api_client != null:
await DiscoveryApiClient.record_visit(_api_client, world_id)
# 2. Join flow (existing game server orchestration)
if _api_client != null and _api_client.has_method("join_world"):
var result: Dictionary = await _api_client.join_world(world_id)
if result.ok and result.data is Dictionary:
var d: Dictionary = result.data
if has_node("/root/Session"):
get_node("/root/Session").begin(world_id, str(d.get("host", "")), int(d.get("port", 0)))
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
if result.status == 503:
# Dev fallback: local single-player
if has_node("/root/Session"):
get_node("/root/Session").clear()
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
if status_label:
status_label.text = "Failed to join: " + str(result.detail)
status_label.visible = true
else:
# Fallback if standalone test or not wired
if has_node("/root/Session"):
get_node("/root/Session").clear()
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
button.disabled = false
button.text = "Enter World"
func _on_filter_pressed() -> void:
load_worlds(0)
func _on_search_submitted(_new_text: String) -> void:
load_worlds(0)
func _on_category_selected(_index: int) -> void:
load_worlds(0)
func _on_sort_selected(_index: int) -> void:
load_worlds(0)
func _on_refresh_pressed() -> void:
load_worlds(_current_page)
func _on_prev_page_pressed() -> void:
if _current_page > 0:
load_worlds(_current_page - 1)
func _on_next_page_pressed() -> void:
var total_pages := int(ceil(float(_total_count) / float(_page_size)))
if (_current_page + 1) < total_pages:
load_worlds(_current_page + 1)
func _on_back_pressed() -> void:
back_requested.emit()
if get_tree():
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
+89
View File
@@ -0,0 +1,89 @@
extends SceneTree
## Headless smoke test for DiscoveryUI.
func _initialize() -> void:
_run()
func _run() -> void:
print("[SMOKE] Starting DiscoveryUI smoke test...")
await process_frame
var scene = load("res://scenes/discovery_ui.tscn")
if not scene:
printerr("[FAIL] Could not load res://scenes/discovery_ui.tscn")
quit(1)
return
var ui: DiscoveryUI = scene.instantiate()
root.add_child(ui)
await process_frame
# 1. Verify node references
assert(ui.search_edit != null, "search_edit is null")
assert(ui.category_option != null, "category_option is null")
assert(ui.sort_option != null, "sort_option is null")
assert(ui.filter_button != null, "filter_button is null")
assert(ui.worlds_container != null, "worlds_container is null")
assert(ui.prev_button != null, "prev_button is null")
assert(ui.next_button != null, "next_button is null")
assert(ui.page_label != null, "page_label is null")
print("[PASS] Node references verified")
# 2. Verify sort options
assert(ui.sort_option.item_count == 3, "sort_option should have 3 items")
assert(ui.sort_option.get_item_text(0) == "Newest", "sort 0 should be Newest")
assert(ui.sort_option.get_item_text(1) == "Most Played", "sort 1 should be Most Played")
assert(ui.sort_option.get_item_text(2) == "Trending", "sort 2 should be Trending")
print("[PASS] Sort options verified")
# 3. Verify category dropdown initial state
assert(ui.category_option.item_count >= 1, "category_option should have at least All Categories")
assert(ui.category_option.get_item_text(0) == "All Categories", "category 0 should be All Categories")
print("[PASS] Category dropdown verified")
# 4. Test rendering of WorldSummary cards
var mock_w := DiscoveryApiClient.WorldSummary.new()
mock_w.id = 101
mock_w.name = "Cyber Nexus"
mock_w.category = "Action"
mock_w.tags = ["cyberpunk", "pvp"]
mock_w.play_count = 256
mock_w.description = "A futuristic arena"
mock_w.owner_username = "neon_builder"
var mock_worlds: Array[DiscoveryApiClient.WorldSummary] = [mock_w]
ui._render_worlds(mock_worlds)
assert(ui.worlds_container.get_child_count() == 1, "Should render 1 card")
assert(ui.empty_label.visible == false, "empty_label should be hidden when items present")
print("[PASS] World card rendering verified")
# 5. Test empty state
var empty_list: Array[DiscoveryApiClient.WorldSummary] = []
ui._render_worlds(empty_list)
assert(ui.worlds_container.get_child_count() == 0, "Container should have 0 children")
assert(ui.empty_label.visible == true, "empty_label should be visible when empty")
print("[PASS] Empty state verified")
# 6. Test pagination logic
ui._total_count = 25
ui._page_size = 10
ui._current_page = 0
ui._update_pagination()
assert(ui.prev_button.disabled == true, "Page 0 prev_button should be disabled")
assert(ui.next_button.disabled == false, "Page 0 next_button should be enabled")
assert(ui.page_label.text.contains("Page 1 of 3"), "Page 1 of 3 mismatch: %s" % ui.page_label.text)
ui._current_page = 1
ui._update_pagination()
assert(ui.prev_button.disabled == false, "Page 1 prev_button should be enabled")
assert(ui.next_button.disabled == false, "Page 1 next_button should be enabled")
ui._current_page = 2
ui._update_pagination()
assert(ui.prev_button.disabled == false, "Page 2 prev_button should be enabled")
assert(ui.next_button.disabled == true, "Page 2 next_button should be disabled")
print("[PASS] Pagination logic verified")
ui.queue_free()
print("[SMOKE] DiscoveryUI smoke test PASSED")
quit(0)