From 00f9d98f4be9def94ffefa97a0ce93c8e7cd5eff Mon Sep 17 00:00:00 2001 From: adtpdn Date: Fri, 26 Jun 2026 09:40:17 +0800 Subject: [PATCH] refactor: enhance test framework with automated resource tracking and scripted error capture capabilities --- .clinerules/workflows/Do Task.md | 160 --- addons/godot_ai/handlers/editor_handler.gd | 48 +- addons/godot_ai/handlers/input_handler.gd | 2 + addons/godot_ai/handlers/script_handler.gd | 136 +- addons/godot_ai/plugin.cfg | 2 +- addons/godot_ai/plugin.gd | 12 +- addons/godot_ai/runtime/game_helper.gd | 10 +- addons/godot_ai/runtime/logger_loader.gd | 1 + .../runtime/loggers/validation_logger.gd | 43 + addons/godot_ai/testing/loggers/.gdignore | 1 + .../testing/loggers/script_error_capture.gd | 51 + .../testing/script_error_capture_loader.gd | 26 + .../script_error_capture_loader.gd.uid | 1 + addons/godot_ai/testing/test_runner.gd | 82 +- addons/godot_ai/testing/test_suite.gd | 53 + addons/godot_ai/utils/diagnostics_capture.gd | 66 + .../godot_ai/utils/diagnostics_capture.gd.uid | 1 + addons/godot_ai/utils/update_manager.gd | 271 +++- format_report_final.js | 158 -- format_report_updated.js | 154 -- scenes/arena/gauntlet.tscn | 11 + scenes/lobby.tscn | 10 +- scenes/main.gd | 18 +- scenes/ui/admin_panel.tscn | 302 ++-- scenes/ui/gacha_panel.tscn | 222 ++- scratch_lobby.txt | Bin 176158 -> 0 bytes scripts/game_mode.gd | 6 +- scripts/managers/camera_context_manager.gd | 27 +- scripts/managers/gauntlet_manager.gd | 1279 +++++++++++++---- scripts/managers/lobby_manager.gd | 56 +- scripts/managers/mail_manager.gd | 16 +- scripts/managers/player_movement_manager.gd | 36 +- scripts/mode_config.gd | 6 +- scripts/ui/admin_panel.gd | 346 ++++- server/nakama/lua/admin.lua | 125 +- server/nakama/lua/leaderboard.lua | 2 +- server/nakama/lua/utils.lua | 30 + test_smack.py | 55 - tests/helpers/gridmap_mock.gd | 24 + tests/helpers/gridmap_mock.gd.uid | 1 + tests/helpers/main_mock.gd | 9 + tests/helpers/main_mock.gd.uid | 1 + tests/test_gauntlet_bubble.gd | 163 +++ tests/test_gauntlet_bubble.gd.uid | 1 + tests/test_gauntlet_cannon_timer.gd | 41 - tests/test_gauntlet_cannon_timer.gd.uid | 1 - tests/test_gauntlet_cleanser.gd | 115 ++ tests/test_gauntlet_cleanser.gd.uid | 1 + tests/test_gauntlet_growth_tick.gd | 159 ++ tests/test_gauntlet_growth_tick.gd.uid | 1 + tests/test_gauntlet_movement_buffer.gd | 119 ++ tests/test_gauntlet_movement_buffer.gd.uid | 1 + tests/test_gauntlet_registration.gd | 20 +- tests/test_gauntlet_scoring.gd | 185 +++ tests/test_gauntlet_scoring.gd.uid | 1 + tests/test_gauntlet_sticky_system.gd | 208 +++ tests/test_gauntlet_sticky_system.gd.uid | 1 + tests/test_gauntlet_tile_spawning.gd | 6 +- 58 files changed, 3594 insertions(+), 1289 deletions(-) delete mode 100644 .clinerules/workflows/Do Task.md create mode 100644 addons/godot_ai/runtime/loggers/validation_logger.gd create mode 100644 addons/godot_ai/testing/loggers/.gdignore create mode 100644 addons/godot_ai/testing/loggers/script_error_capture.gd create mode 100644 addons/godot_ai/testing/script_error_capture_loader.gd create mode 100644 addons/godot_ai/testing/script_error_capture_loader.gd.uid create mode 100644 addons/godot_ai/utils/diagnostics_capture.gd create mode 100644 addons/godot_ai/utils/diagnostics_capture.gd.uid delete mode 100644 format_report_final.js delete mode 100644 format_report_updated.js delete mode 100644 scratch_lobby.txt delete mode 100644 test_smack.py create mode 100644 tests/helpers/gridmap_mock.gd create mode 100644 tests/helpers/gridmap_mock.gd.uid create mode 100644 tests/helpers/main_mock.gd create mode 100644 tests/helpers/main_mock.gd.uid create mode 100644 tests/test_gauntlet_bubble.gd create mode 100644 tests/test_gauntlet_bubble.gd.uid delete mode 100644 tests/test_gauntlet_cannon_timer.gd delete mode 100644 tests/test_gauntlet_cannon_timer.gd.uid create mode 100644 tests/test_gauntlet_cleanser.gd create mode 100644 tests/test_gauntlet_cleanser.gd.uid create mode 100644 tests/test_gauntlet_growth_tick.gd create mode 100644 tests/test_gauntlet_growth_tick.gd.uid create mode 100644 tests/test_gauntlet_movement_buffer.gd create mode 100644 tests/test_gauntlet_movement_buffer.gd.uid create mode 100644 tests/test_gauntlet_scoring.gd create mode 100644 tests/test_gauntlet_scoring.gd.uid create mode 100644 tests/test_gauntlet_sticky_system.gd create mode 100644 tests/test_gauntlet_sticky_system.gd.uid diff --git a/.clinerules/workflows/Do Task.md b/.clinerules/workflows/Do Task.md deleted file mode 100644 index 16a6212..0000000 --- a/.clinerules/workflows/Do Task.md +++ /dev/null @@ -1,160 +0,0 @@ -# SKILLS.md — AI Agent Workflow Guide for Tekton Dash - -This document tells AI agents how to work on Tekton Dash tasks end-to-end. - ---- - -## 1. Task Source: Notion MCP - -All tasks live on the **"TektonDash - Armageddon PR Tasks"** Notion board. - -https://www.notion.so/danchiego-game/36433be43b29800c8422ed5bdd65671b?v=36633be43b29803891cd000c6f6e5c5f - -### Finding Tasks - -Should always start with this to find tasks, find the highest priority task that is not done, second is In Progress, then To Do. - -example, query for "Gauntlet" -``` -Use: mcp_notion-mcp-server_API-post-search - query: "[Gauntlet]" or task name - filter: {"property": "object", "value": "page"} -``` - -### Reading a Task - -Each task page has these properties: - -| Property | Type | Purpose | -|---|---|---| -| **Name** | title | Task title, e.g. `[Gauntlet] #1 Game Mode Registration` | -| **Status** | select | `To Do` → `In Progress` → `Done` | -| **Priority** | select | `P0` (critical) / `P1` / `P2` / `P3` | -| **Effort** | select | `S - Small` / `M - Medium` / `L - Large` / `XL - Epic` | -| **Sprint** | select | `Alpha` / `Beta` / `Release` | -| **ProjectType** | select | `CORE` / `CLIENT` / `SERVER` / `INFRA` | -| **Description** | rich_text | Full task description — **read this to understand what to do** | -| **Acceptance** | checkbox | Check when task is verified complete | -| **DueDate** | date | Optional deadline | -| **UnitTest** | date | Optional test completion date | - -### Task Lifecycle - -``` -To Do → In Progress → Done -``` - -1. **Pick up task**: Set `Status` → `In Progress` -2. **Do the work**: Read `Description`, implement the changes -3. **Write unit tests**: Follow pattern in `tests/` directory -4. **Mark complete**: Set `Status` → `Done`, check `Acceptance` ✅ -5. **Update changelog**: Add entry to `CHANGELOG_DRAFT.md` (consumer language) -6. **Bump version**: Update `project.godot` + `export_presets.cfg` - -``` -Use: mcp_notion-mcp-server_API-patch-page - page_id: "" - properties: {"Status": {"select": {"name": "Done"}}, "Acceptance": {"checkbox": true}} -``` - ---- - -## 2. Code Structure - -| Path | Purpose | -|---|---| -| `scripts/game_mode.gd` | GameMode enum + helpers (add new modes here) | -| `scripts/managers/` | All game mode managers (lobby, stop_n_go, portal, gauntlet) | -| `scenes/main.gd` | Central orchestrator — init, setup, game start routing | -| `tests/` | GUT unit tests — one file per task/feature | - -### Adding a New Game Mode - -1. Add enum to `scripts/game_mode.gd` → update `from_string()`, `mode_to_string()`, `get_all_modes()`, `is_restricted()` -2. Add mode name to `LobbyManager.available_game_modes` in `lobby_manager.gd` -3. Add arena name to `_update_available_areas()` in `lobby_manager.gd` -4. Add manager var + init branch in `main.gd` `_init_managers()` -5. Add setup branch in `_setup_host_game()` and `_setup_client_game()` -6. Add start branch in `_start_game()` -7. Add background in `_apply_arena_background()` - ---- - -## 3. Unit Testing - -### Pattern - -All tests extend `GutTest` and live in `tests/`. Naming: `test_.gd` - -```gdscript -extends GutTest - -func before_all(): - gut.p("=== Feature Tests [Task ID] ===") - -func test_something(): - assert_eq(actual, expected, "Description") - -func after_all(): - gut.p("=== Feature Tests Complete ===") -``` - -### Running Tests - -```cmd -run_tests.cmd # all tests -run_tests.cmd test_gauntlet_registration # specific test -``` - -Reports saved to `test_reports/` with timestamps. - ---- - -## 4. Version Bumping - -**Before bumping, check git for existing uncommitted version changes:** - -```cmd -git diff --cached -- project.godot CHANGELOG_DRAFT.md -git diff -- project.godot CHANGELOG_DRAFT.md -``` - -### If version changes already exist (staged or unstaged): -→ **APPEND** your changelog bullet to the existing version block in `CHANGELOG_DRAFT.md` -→ **DO NOT** bump `project.godot` or `export_presets.cfg` — you're joining an in-progress batch - -### If NO version changes exist (clean state): -→ **BUMP** version (increment patch: `2.3.5` → `2.3.6`) -→ **UPDATE** all locations below - -Version appears in **4 locations** — all must match: - -| File | Field | -|---|---| -| `CHANGELOG_DRAFT.md` | `## [X.Y.Z] — YYYY-MM-DD` header | -| `project.godot` | `config/version="X.Y.Z"` | -| `export_presets.cfg` | `application/file_version` and `application/product_version` (per preset) | -| `export_presets.cfg` | `export_path` filenames containing version | -| `export_presets.cfg` | `version/name` (Android preset) | - -### Changelog Style - -Entries are **consumer-facing** (readable by players). No internal jargon. - -```markdown -## [2.3.6] — 2026-05-22 -- Added new game mode: Candy Cannon Survival -``` - -**Bad:** "Added GAUNTLET = 3 to GameMode.Mode enum" -**Good:** "Added new game mode: Candy Cannon Survival" - ---- - -## 5. Key Conventions - -- **Caveman Mode**: Be terse. No filler. Execute first, talk second. -- **Read before edit**: Always check whole files before modifying `.gd`, `.tscn`, `.tres`, `.res` files. -- **Notion status flow**: `To Do` → `In Progress` → `Done` (never skip steps). -- **Test everything**: Every completed task gets a `test_.gd` in `tests/`. -- **GUT framework**: Tests use the Godot Unit Test (GUT) addon at `addons/gut/`. diff --git a/addons/godot_ai/handlers/editor_handler.gd b/addons/godot_ai/handlers/editor_handler.gd index 5da340c..c7acd3e 100644 --- a/addons/godot_ai/handlers/editor_handler.gd +++ b/addons/godot_ai/handlers/editor_handler.gd @@ -70,6 +70,8 @@ func get_logs(params: Dictionary) -> Dictionary: var offset: int = maxi(0, int(params.get("offset", 0))) var source: String = str(params.get("source", "plugin")) var include_details: bool = bool(params.get("include_details", false)) + var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null + var since_cursor: int = maxi(0, int(params.get("since_cursor", 0))) if not source in VALID_LOG_SOURCES: return ErrorCodes.make( ErrorCodes.VALUE_OUT_OF_RANGE, @@ -82,7 +84,7 @@ func get_logs(params: Dictionary) -> Dictionary: "game": return _get_game_logs(count, offset, include_details) "editor": - return _get_editor_logs(count, offset, include_details) + return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor) "all": return _get_all_logs(count, offset, include_details) return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable") @@ -134,15 +136,18 @@ func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionar } -func _get_editor_logs(count: int, offset: int, include_details: bool) -> Dictionary: +func _get_editor_logs(count: int, offset: int, include_details: bool, has_since_cursor: bool = false, since_cursor: int = 0) -> Dictionary: ## Editor-process script errors (parse errors, @tool runtime errors, ## EditorPlugin errors, push_error/push_warning). Captured by ## editor_logger.gd via OS.add_logger and gated on Godot 4.5+; on older ## engines the buffer can be null. Godot also sends GDScript reload ## warnings/errors straight to the Debugger dock's Errors tab; those do ## not flow through OS.add_logger, so merge the visible tree rows here. + if has_since_cursor: + return _get_editor_logs_since(count, since_cursor, include_details) var all_entries := _collect_editor_log_entries() var page := _entries_for_response(_slice_entries(all_entries, offset, count), include_details) + var appended_total := _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0 return { "data": { "source": "editor", @@ -151,6 +156,45 @@ func _get_editor_logs(count: int, offset: int, include_details: bool) -> Diction "returned_count": page.size(), "offset": offset, "dropped_count": _editor_log_buffer.dropped_count() if _editor_log_buffer != null else 0, + "next_cursor": appended_total, + "appended_total": appended_total, + } + } + + +func _get_editor_logs_since(count: int, since_cursor: int, include_details: bool) -> Dictionary: + ## Cursor reads are defined over the monotonic editor logger ring only. + ## Visible Debugger Errors-tab rows are live UI state, not ring entries, + ## so regular offset reads still merge them while since_cursor polling + ## reports only Logger-backed entries. + var captured := { + "cursor": since_cursor, + "oldest_cursor": 0, + "next_cursor": 0, + "appended_total": 0, + "truncated": false, + "has_more": false, + "entries": [], + } + var dropped := 0 + if _editor_log_buffer != null: + captured = _editor_log_buffer.get_since(since_cursor, count) + dropped = _editor_log_buffer.dropped_count() + var page := _entries_for_response(captured.get("entries", []), include_details) + return { + "data": { + "source": "editor", + "lines": page, + "total_count": int(captured.get("appended_total", 0)), + "returned_count": page.size(), + "offset": 0, + "dropped_count": dropped, + "cursor": int(captured.get("cursor", since_cursor)), + "oldest_cursor": int(captured.get("oldest_cursor", 0)), + "next_cursor": int(captured.get("next_cursor", 0)), + "appended_total": int(captured.get("appended_total", 0)), + "truncated": bool(captured.get("truncated", false)), + "has_more": bool(captured.get("has_more", false)), } } diff --git a/addons/godot_ai/handlers/input_handler.gd b/addons/godot_ai/handlers/input_handler.gd index 196f0db..f6a7735 100644 --- a/addons/godot_ai/handlers/input_handler.gd +++ b/addons/godot_ai/handlers/input_handler.gd @@ -175,6 +175,7 @@ func _create_event(event_type: String, params: Dictionary): ev.alt_pressed = params.get("alt", false) ev.shift_pressed = params.get("shift", false) ev.meta_pressed = params.get("meta", false) + ev.device = -1 return ev "mouse_button": if not params.has("button"): @@ -186,6 +187,7 @@ func _create_event(event_type: String, params: Dictionary): "mouse_button button must be > 0 (got %d). Use 1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down." % button) var ev := InputEventMouseButton.new() ev.button_index = button + ev.device = -1 return ev "joy_button": if not params.has("button"): diff --git a/addons/godot_ai/handlers/script_handler.gd b/addons/godot_ai/handlers/script_handler.gd index 1a4aaec..2e83e56 100644 --- a/addons/godot_ai/handlers/script_handler.gd +++ b/addons/godot_ai/handlers/script_handler.gd @@ -2,6 +2,8 @@ extends RefCounted const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") +const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_capture.gd") +const LoggerLoader := preload("res://addons/godot_ai/runtime/logger_loader.gd") ## Handles script creation, reading, attaching, detaching, and symbol inspection. @@ -50,6 +52,17 @@ func create_script(params: Dictionary) -> Dictionary: file.store_string(content) file.close() + var data := { + "path": path, + "size": content.length(), + "committed": true, + "import_settled": existed_before, + "import_settle": "already_known" if existed_before else "not_waited", + "undoable": false, + "reason": "File system operations cannot be undone via editor undo", + } + _attach_gdscript_diagnostics(data, path, content) + # Register just this file with the editor instead of a full recursive # scan(). A scan() per write stacks `update_scripts_classes` / # `update_script_paths_documentation` WorkerThreadPool tasks under concurrent @@ -61,15 +74,6 @@ func create_script(params: Dictionary) -> Dictionary: if efs != null: efs.update_file(path) - var data := { - "path": path, - "size": content.length(), - "committed": true, - "import_settled": existed_before, - "import_settle": "already_known" if existed_before else "not_waited", - "undoable": false, - "reason": "File system operations cannot be undone via editor undo", - } # `.gd.uid` is the sidecar Godot generates on scan; list both so the caller # can rm the full set in one go. McpResourceIO.attach_cleanup_hint(data, existed_before, [path, path + ".uid"]) @@ -165,6 +169,99 @@ func read_script(params: Dictionary) -> Dictionary: } +func _attach_gdscript_diagnostics(data: Dictionary, path: String, content: String) -> void: + var validation := _validate_gdscript_source(content) + var diagnostics: Array = [] + var diagnostics_detail := "none" + var diagnostics_status := "checked" + + if not validation.get("ok", true): + var capture := _capture_gdscript_load_diagnostics(path) + diagnostics = capture.get("diagnostics", []) + diagnostics_detail = capture.get("diagnostics_detail", "none") + diagnostics_status = capture.get("diagnostics_status", "checked") + if not validation.get("ok", true) and diagnostics.is_empty(): + diagnostics.append(_fallback_gdscript_diagnostic(path, validation.get("error_code", FAILED), content)) + diagnostics_detail = "fallback" + data["diagnostics"] = diagnostics + data["diagnostics_detail"] = diagnostics_detail + data["diagnostics_scope"] = "this_file" + data["diagnostics_status"] = diagnostics_status + + +static func _validate_gdscript_source(content: String) -> Dictionary: + var script := GDScript.new() + script.source_code = content + ## Keep validation off the live cached resource: assigning resource_path to + ## this ephemeral Script can collide with loaded instances. reload() still + ## performs normal GDScript analysis, including static initializer work, so + ## this check is intentionally scoped to `.gd` writes where the editor would + ## compile the file on scan anyway. + var err := script.reload() + return { + "ok": err == OK, + "error_code": err, + } + + +static func _capture_gdscript_load_diagnostics(path: String) -> Dictionary: + if not (ClassDB.class_exists("Logger") and OS.has_method("add_logger") and OS.has_method("remove_logger")): + return _empty_diagnostics_capture() + var logger_script := LoggerLoader.build(LoggerLoader.VALIDATION_LOGGER_PATH) + if logger_script == null: + return _empty_diagnostics_capture() + var buffer := McpEditorLogBuffer.new() + var logger = logger_script.new(buffer) + var capture := DiagnosticsCapture.capture_this_file(buffer, path, func() -> Dictionary: + OS.call("add_logger", logger) + # ResourceLoader.load() reports parse failure instead of throwing, and + # a failed GDScript parse does not execute user code; remove immediately + # after the synchronous load to keep the private capture window tiny. + ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) + OS.call("remove_logger", logger) + return {} + ) + return capture + + +static func _empty_diagnostics_capture() -> Dictionary: + return { + "diagnostics": [], + "diagnostics_detail": "none", + "diagnostics_scope": "this_file", + "diagnostics_status": "checked", + } + + +static func _fallback_gdscript_diagnostic(path: String, error_code: int, content: String) -> Dictionary: + var line := _fallback_gdscript_error_line(content) + return { + "source": "editor", + "level": "error", + "text": "GDScript reload failed with error code %d." % error_code, + "path": path, + "line": line, + "function": "GDScript::reload", + "details": { + "code": "gdscript_reload_failed", + "error_code": error_code, + "fallback_line": true, + "source": { + "path": path, + "line": line, + }, + }, + } + + +static func _fallback_gdscript_error_line(content: String) -> int: + var lines := content.split("\n") + for i in range(lines.size() - 1, -1, -1): + if not str(lines[i]).strip_edges().is_empty(): + return i + 1 + return 1 + + func patch_script(params: Dictionary) -> Dictionary: var path: String = params.get("path", "") var old_text: String = params.get("old_text", "") @@ -214,21 +311,22 @@ func patch_script(params: Dictionary) -> Dictionary: write.store_string(new_content) write.close() + var data := { + "path": path, + "replacements": replacements, + "size": new_content.length(), + "old_size": content.length(), + "undoable": false, + "reason": "File system operations cannot be undone via editor undo", + } + _attach_gdscript_diagnostics(data, path, new_content) + # Single-file register, not a full scan() — see create_script (dsarno/godot#6). var efs := EditorInterface.get_resource_filesystem() if efs != null: efs.update_file(path) - return { - "data": { - "path": path, - "replacements": replacements, - "size": new_content.length(), - "old_size": content.length(), - "undoable": false, - "reason": "File system operations cannot be undone via editor undo", - } - } + return {"data": data} func attach_script(params: Dictionary) -> Dictionary: diff --git a/addons/godot_ai/plugin.cfg b/addons/godot_ai/plugin.cfg index 5b36d7b..17609e1 100644 --- a/addons/godot_ai/plugin.cfg +++ b/addons/godot_ai/plugin.cfg @@ -3,5 +3,5 @@ name="Godot AI" description="MCP server and AI tools for Godot" author="Godot AI" -version="2.7.3" +version="2.7.6" script="plugin.gd" diff --git a/addons/godot_ai/plugin.gd b/addons/godot_ai/plugin.gd index ea91ac0..f3d0e02 100644 --- a/addons/godot_ai/plugin.gd +++ b/addons/godot_ai/plugin.gd @@ -1092,7 +1092,17 @@ func _evaluate_strong_port_occupant_proof(port: int, live: Dictionary = {}) -> D var record_version := str(record.get("version", "")) if record_pid > 1 and record_pid != OS.get_process_id(): - if listener_pids.has(record_pid) and _pid_alive_for_proof(record_pid): + ## Brand-verify the recorded PID before trusting it as a kill target. + ## A recorded PID can outlive the server it named and be recycled by + ## the kernel for an unrelated process that happens to bind the same + ## port — without the cmdline brand gate (the same one the + ## `pidfile_listener` branch enforces) that process could be killed. + ## See #525. + if ( + listener_pids.has(record_pid) + and _pid_alive_for_proof(record_pid) + and _pid_cmdline_is_godot_ai_for_proof(record_pid) + ): return {"proof": "managed_record", "pids": [record_pid]} var legacy_targets := _legacy_pidfile_kill_targets(port, listener_pids) diff --git a/addons/godot_ai/runtime/game_helper.gd b/addons/godot_ai/runtime/game_helper.gd index cfcefb6..f5ed507 100644 --- a/addons/godot_ai/runtime/game_helper.gd +++ b/addons/godot_ai/runtime/game_helper.gd @@ -430,7 +430,15 @@ func _current_scene_root() -> Node: return null var scene_root := tree.current_scene if scene_root == null and Engine.is_editor_hint(): - scene_root = EditorInterface.get_edited_scene_root() + # Look the editor singleton up by name rather than referencing the bare + # `EditorInterface` identifier: that identifier is compiled out of export + # templates, so the GDScript parser rejects it ("Identifier + # "EditorInterface" not declared in the current scope") in an exported + # build even though `Engine.is_editor_hint()` would never run it there. + # That parse failure stops this autoload from loading in every export. + var editor := Engine.get_singleton(&"EditorInterface") + if editor: + scene_root = editor.get_edited_scene_root() return scene_root diff --git a/addons/godot_ai/runtime/logger_loader.gd b/addons/godot_ai/runtime/logger_loader.gd index 974a4de..f0c1d57 100644 --- a/addons/godot_ai/runtime/logger_loader.gd +++ b/addons/godot_ai/runtime/logger_loader.gd @@ -25,6 +25,7 @@ extends RefCounted const EDITOR_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/editor_logger.gd" const GAME_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/game_logger.gd" +const VALIDATION_LOGGER_PATH := "res://addons/godot_ai/runtime/loggers/validation_logger.gd" ## Compile a `.gdignore`'d logger script from its on-disk source. Returns the diff --git a/addons/godot_ai/runtime/loggers/validation_logger.gd b/addons/godot_ai/runtime/loggers/validation_logger.gd new file mode 100644 index 0000000..672b11f --- /dev/null +++ b/addons/godot_ai/runtime/loggers/validation_logger.gd @@ -0,0 +1,43 @@ +@tool +extends Logger + +## Short-lived Logger used only for per-write validation loads. +## +## Unlike editor_logger.gd this deliberately has no addon feedback-loop filter: +## the caller attaches it around one ResourceLoader.load() call, reads its +## private buffer, and immediately removes it. The shared editor logger should +## still drop these validation-load errors so logs_read(source="editor") stays +## clean. + +const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd") + +var _buffer + + +func _init(buffer = null) -> void: + _buffer = buffer + + +func _log_error( + function: String, + file: String, + line: int, + code: String, + rationale: String, + _editor_notify: bool, + error_type: int, + script_backtraces: Array, +) -> void: + if _buffer == null: + return + var resolved := _LogBacktrace.resolve_error( + function, + file, + line, + code, + rationale, + error_type, + script_backtraces, + ) + var details: Dictionary = resolved.get("details", {}) + _buffer.append(resolved.level, resolved.message, resolved.path, resolved.line, resolved.function, details) diff --git a/addons/godot_ai/testing/loggers/.gdignore b/addons/godot_ai/testing/loggers/.gdignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/addons/godot_ai/testing/loggers/.gdignore @@ -0,0 +1 @@ + diff --git a/addons/godot_ai/testing/loggers/script_error_capture.gd b/addons/godot_ai/testing/loggers/script_error_capture.gd new file mode 100644 index 0000000..9e66c16 --- /dev/null +++ b/addons/godot_ai/testing/loggers/script_error_capture.gd @@ -0,0 +1,51 @@ +@tool +extends Logger + +## Captures GDScript runtime errors emitted while a test is running. +## +## Deliberately no class_name: this script is compiled dynamically by +## script_error_capture_loader.gd only when Logger exists. It lives in a +## `.gdignore`d folder so older Godot editor scans never parse `extends Logger`. +## +## Only ERROR_TYPE_SCRIPT is captured. push_error(), push_warning(), and +## engine-internal ERR_FAIL_* checks are often valid negative-path assertions and +## should not abort the test. + +var _mutex := Mutex.new() +var _capturing := false +var _errors := PackedStringArray() + + +func begin_capture() -> void: + _mutex.lock() + _capturing = true + _errors.clear() + _mutex.unlock() + + +func end_capture() -> PackedStringArray: + _mutex.lock() + var captured := _errors.duplicate() + _capturing = false + _errors.clear() + _mutex.unlock() + return captured + + +func _log_error( + function: String, + file: String, + line: int, + code: String, + rationale: String, + _editor_notify: bool, + error_type: int, + _script_backtraces: Array, +) -> void: + if error_type != ERROR_TYPE_SCRIPT: + return + _mutex.lock() + if _capturing: + var text := rationale if not rationale.is_empty() else code + _errors.append("%s (%s:%d in %s)" % [text, file, line, function]) + _mutex.unlock() diff --git a/addons/godot_ai/testing/script_error_capture_loader.gd b/addons/godot_ai/testing/script_error_capture_loader.gd new file mode 100644 index 0000000..fc0767a --- /dev/null +++ b/addons/godot_ai/testing/script_error_capture_loader.gd @@ -0,0 +1,26 @@ +@tool +extends RefCounted + +## Builds the Logger-based test script-error capture lazily. +## +## Logger exists only on newer Godot versions. The capture implementation lives +## under a `.gdignore`d folder and is compiled from source only after the runner +## verifies the Logger API is present, so older editor scans do not parse an +## `extends Logger` file and emit red startup errors. + +const SCRIPT_ERROR_CAPTURE_PATH := "res://addons/godot_ai/testing/loggers/script_error_capture.gd" + + +static func build() -> Object: + if not ClassDB.class_exists("Logger") or not OS.has_method("add_logger"): + return null + if not FileAccess.file_exists(SCRIPT_ERROR_CAPTURE_PATH): + return null + var source := FileAccess.get_file_as_string(SCRIPT_ERROR_CAPTURE_PATH) + if source.is_empty(): + return null + var script := GDScript.new() + script.source_code = source + if script.reload() != OK: + return null + return script.new() diff --git a/addons/godot_ai/testing/script_error_capture_loader.gd.uid b/addons/godot_ai/testing/script_error_capture_loader.gd.uid new file mode 100644 index 0000000..d6089db --- /dev/null +++ b/addons/godot_ai/testing/script_error_capture_loader.gd.uid @@ -0,0 +1 @@ +uid://pmtc07go8ty4 diff --git a/addons/godot_ai/testing/test_runner.gd b/addons/godot_ai/testing/test_runner.gd index 394c3ae..d03ea30 100644 --- a/addons/godot_ai/testing/test_runner.gd +++ b/addons/godot_ai/testing/test_runner.gd @@ -5,11 +5,25 @@ extends RefCounted ## Lightweight test runner for MCP plugin tests. Discovers test_* methods ## on McpTestSuite instances, runs them, and collects structured results. +const ScriptErrorCaptureLoader := preload("res://addons/godot_ai/testing/script_error_capture_loader.gd") + var _results: Array[Dictionary] = [] var _last_run_ms: int = 0 +var _script_error_capture: Object = null +var _capture_registered := false + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE and _capture_registered and _script_error_capture != null: + OS.call("remove_logger", _script_error_capture) + _capture_registered = false func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filter: String = "") -> void: + var owns_capture := not _capture_registered + if owns_capture: + _register_capture() + var name := suite.suite_name() var methods := _get_test_methods(suite) var exclusions := _parse_exclusions(exclude_test_filter) @@ -29,9 +43,12 @@ func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filte continue suite._reset() + _begin_script_error_capture() suite.setup() suite.call(method_name) suite.teardown() + var script_errors := suite._unexpected_script_errors(_end_script_error_capture()) + suite._free_tracked() ## Issue #19 defence: free any `_McpTest*` nodes the test created, even ## nested ones. If the scene gets auto-saved mid-test while one of these @@ -39,10 +56,23 @@ func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filte ## with a "missing dependency" error. Runs after every test, not just at ## suite boundaries, so a test that fails mid-flow can't leave a trap ## for the next test or for scene autosave. - var scene_root_for_cleanup := EditorInterface.get_edited_scene_root() + var scene_root_for_cleanup := _edited_scene_root() if scene_root_for_cleanup != null and scene_root_for_cleanup.is_inside_tree(): _free_mcp_test_nodes_recursive(scene_root_for_cleanup) + if not script_errors.is_empty(): + var abort_message := "Aborted by SCRIPT ERROR: %s" % "; ".join(script_errors) + if suite._failed: + abort_message += " (after assertion failure: %s)" % suite._message + _results.append({ + "suite": name, + "test": method_name, + "passed": false, + "message": abort_message, + "assertion_count": suite._assertion_count, + }) + continue + if suite._skipped: _results.append({ "suite": name, @@ -70,6 +100,9 @@ func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filte "assertion_count": suite._assertion_count, }) + if owns_capture: + _unregister_capture() + func run_suites(suites: Array, suite_filter: String = "", test_filter: String = "", ctx: Dictionary = {}, verbose: bool = false, exclude_test_filter: String = "") -> Dictionary: _results.clear() @@ -83,12 +116,17 @@ func run_suites(suites: Array, suite_filter: String = "", test_filter: String = var _prev_console_echo := McpLogBuffer.console_echo McpLogBuffer.console_echo = false + ## If a prior run was interrupted after registering the logger but before + ## normal teardown, remove that stale registration before starting fresh. + _unregister_capture() + _register_capture() + for suite: McpTestSuite in suites: if not suite_filter.is_empty() and suite.suite_name() != suite_filter: continue ## Snapshot scene children before the suite so we can clean up leaks. - var scene_root := EditorInterface.get_edited_scene_root() + var scene_root := _edited_scene_root() var before_children: Array[Node] = [] if scene_root != null: before_children = _get_children_snapshot(scene_root) @@ -119,6 +157,7 @@ func run_suites(suites: Array, suite_filter: String = "", test_filter: String = else: run_suite(suite, test_filter, exclude_test_filter) suite.suite_teardown() + suite._free_tracked() ## Remove any nodes the suite left behind (failed undo, missing cleanup). if scene_root != null and scene_root.is_inside_tree(): @@ -126,9 +165,48 @@ func run_suites(suites: Array, suite_filter: String = "", test_filter: String = _last_run_ms = Time.get_ticks_msec() - start McpLogBuffer.console_echo = _prev_console_echo + _unregister_capture() return get_results(verbose) +func _register_capture() -> void: + if _capture_registered: + return + if _script_error_capture == null: + _script_error_capture = ScriptErrorCaptureLoader.build() + if _script_error_capture == null: + return + OS.call("add_logger", _script_error_capture) + _capture_registered = true + + +func _unregister_capture() -> void: + if not _capture_registered: + return + if _script_error_capture == null: + _capture_registered = false + return + OS.call("remove_logger", _script_error_capture) + _capture_registered = false + + +func _begin_script_error_capture() -> void: + if _script_error_capture != null and _capture_registered: + _script_error_capture.call("begin_capture") + + +func _end_script_error_capture() -> PackedStringArray: + if _script_error_capture == null or not _capture_registered: + return PackedStringArray() + return _script_error_capture.call("end_capture") as PackedStringArray + + +static func _edited_scene_root() -> Node: + if not Engine.is_editor_hint(): + return null + return EditorInterface.get_edited_scene_root() + + func get_results(verbose: bool = false) -> Dictionary: var passed := 0 var failed := 0 diff --git a/addons/godot_ai/testing/test_suite.gd b/addons/godot_ai/testing/test_suite.gd index fe46f6d..c1ea2bc 100644 --- a/addons/godot_ai/testing/test_suite.gd +++ b/addons/godot_ai/testing/test_suite.gd @@ -31,6 +31,34 @@ func suite_teardown() -> void: pass +# ----- tracked allocations (freed by the runner after each test) ----- + +var _tracked_objects: Array[Object] = [] + + +## Register a manually-managed Object (plain Object or out-of-tree Node) so +## the runner frees it after the current test. RefCounted instances are +## accepted but ignored because they manage their own lifetime. +func track(obj: Object) -> Object: + if obj != null and not obj is RefCounted: + _tracked_objects.append(obj) + return obj + + +## Free everything registered via track(). Called by the runner after each +## test's teardown() and again after suite_teardown(). +func _free_tracked() -> void: + for obj in _tracked_objects: + if not is_instance_valid(obj) or (obj is Node and obj.is_queued_for_deletion()): + continue + if obj is Node: + var parent := (obj as Node).get_parent() + if parent != null: + parent.remove_child(obj) + obj.free() + _tracked_objects.clear() + + # ----- assertion state (managed by McpTestRunner) ----- var _failed: bool = false @@ -38,6 +66,7 @@ var _message: String = "" var _assertion_count: int = 0 var _skipped: bool = false var _skip_reason: String = "" +var _expected_script_error_substrings: Array[String] = [] # ----- suite-level state (managed by McpTestRunner) ----- @@ -53,6 +82,7 @@ func _reset() -> void: _assertion_count = 0 _skipped = false _skip_reason = "" + _expected_script_error_substrings.clear() func _reset_suite_state() -> void: @@ -121,6 +151,29 @@ func skip_on_godot_lt(min_version: String, reason: String = "") -> bool: return false +## Allow one captured SCRIPT ERROR whose text contains `substring`. +## Use only for negative-path tests that intentionally compile or execute +## invalid GDScript and assert on the resulting diagnostics. +func expect_script_error_containing(substring: String) -> void: + _expected_script_error_substrings.append(substring) + + +func _unexpected_script_errors(captured: PackedStringArray) -> PackedStringArray: + var unexpected := PackedStringArray() + var remaining := _expected_script_error_substrings.duplicate() + for error in captured: + var matched_index := -1 + for i in range(remaining.size()): + if error.find(remaining[i]) != -1: + matched_index = i + break + if matched_index == -1: + unexpected.append(error) + else: + remaining.remove_at(matched_index) + return unexpected + + ## Trigger an undo against whichever history (scene or global) holds the most ## recent action. `EditorUndoRedoManager` in Godot 4.x doesn't expose `.undo()` ## directly — you resolve the history's underlying UndoRedo and call it there. diff --git a/addons/godot_ai/utils/diagnostics_capture.gd b/addons/godot_ai/utils/diagnostics_capture.gd new file mode 100644 index 0000000..22441ad --- /dev/null +++ b/addons/godot_ai/utils/diagnostics_capture.gd @@ -0,0 +1,66 @@ +@tool +class_name McpDiagnosticsCapture +extends RefCounted + +## Small helper for scoped validation-log capture windows. Callers snapshot a +## private log cursor, perform a deliberate validation action, then only report +## new diagnostics whose original source location is the target file. + + +static func capture_this_file(log_buffer: McpEditorLogBuffer, target_path: String, action: Callable) -> Dictionary: + var cursor := 0 + if log_buffer != null: + cursor = log_buffer.appended_total() + + var action_result = action.call() + var diagnostics: Array[Dictionary] = [] + var truncated := false + + if log_buffer != null: + var captured: Dictionary = log_buffer.get_since(cursor) + truncated = captured.get("truncated", false) + diagnostics = _diagnostics_for_target(captured.get("entries", []), target_path) + + return { + "action": action_result if action_result is Dictionary else {}, + "diagnostics": diagnostics, + "diagnostics_detail": "log_capture" if not diagnostics.is_empty() else "none", + "diagnostics_scope": "this_file", + "diagnostics_status": "partial" if truncated else "checked", + } + + +static func _diagnostics_for_target(entries: Array, target_path: String) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + for raw_entry in entries: + if not raw_entry is Dictionary: + continue + var entry: Dictionary = raw_entry + if not _entry_matches_target(entry, target_path): + continue + out.append(_normalize_entry(entry, target_path)) + return out + + +static func _entry_matches_target(entry: Dictionary, target_path: String) -> bool: + var source := _source_location(entry) + return str(source.get("path", "")) == target_path + + +static func _normalize_entry(entry: Dictionary, target_path: String) -> Dictionary: + var normalized := entry.duplicate(true) + var source := _source_location(entry) + normalized["path"] = str(source.get("path", target_path)) + normalized["line"] = int(source.get("line", normalized.get("line", 0))) + normalized["function"] = str(source.get("function", normalized.get("function", ""))) + if normalized.has("details") and normalized.details is Dictionary: + normalized["details"] = normalized.details.duplicate(true) + return normalized + + +static func _source_location(entry: Dictionary) -> Dictionary: + if entry.get("details") is Dictionary: + var details: Dictionary = entry.details + if details.get("source") is Dictionary: + return details.source + return {} diff --git a/addons/godot_ai/utils/diagnostics_capture.gd.uid b/addons/godot_ai/utils/diagnostics_capture.gd.uid new file mode 100644 index 0000000..95973c9 --- /dev/null +++ b/addons/godot_ai/utils/diagnostics_capture.gd.uid @@ -0,0 +1 @@ +uid://b3npxxpuobbc2 diff --git a/addons/godot_ai/utils/update_manager.gd b/addons/godot_ai/utils/update_manager.gd index 79ba21e..3abae44 100644 --- a/addons/godot_ai/utils/update_manager.gd +++ b/addons/godot_ai/utils/update_manager.gd @@ -34,6 +34,21 @@ const UPDATE_TEMP_DIR := "user://godot_ai_update/" const UPDATE_TEMP_ZIP := "user://godot_ai_update/update.zip" const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +## Hosts the self-update download is allowed to come from. The download URL +## is taken verbatim from the GitHub Releases API's `browser_download_url`, +## so before fetching we pin it to https on a GitHub-owned host — a tampered +## or unexpected API response can't then point the in-editor updater at an +## arbitrary origin. (HTTPRequest follows the github.com -> githubusercontent +## redirect internally; this validates the entry point. Release-side checksum +## / provenance verification of the downloaded bytes remains tracked in #523.) +const _TRUSTED_DOWNLOAD_HOSTS := [ + "github.com", + "www.github.com", + "api.github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +] + ## Emitted after `check_for_updates()` resolves a newer remote version. ## Payload mirrors the Dictionary returned by `parse_releases_response`: ## {has_update, version, forced, label_text, download_url} @@ -53,7 +68,12 @@ var _dock var _http_request: HTTPRequest var _download_request: HTTPRequest +var _verify_request: HTTPRequest var _latest_download_url: String = "" +## URL of the `godot-ai-plugin.zip.sha256` sidecar asset, when the release +## ships one. Used to verify the downloaded archive's integrity before extract +## (#523). Empty for older releases published without a checksum sidecar. +var _latest_checksum_url: String = "" ## Set for the duration of `_install_zip` — extract-overwrite of plugin ## scripts on disk would crash any worker mid-`GDScriptFunction::call` @@ -101,6 +121,7 @@ func cancel_check() -> void: ## flips so a fresh check paints over a clean banner. func clear_pending_download() -> void: _latest_download_url = "" + _latest_checksum_url = "" ## True when the running Godot can self-update in place. Godot < 4.4 takes @@ -166,6 +187,22 @@ func start_install() -> void: OS.shell_open(RELEASES_PAGE) return + ## Pin the resolved asset URL to https on a GitHub host before fetching. + ## Fall back to the release page (a user-driven browser download) rather + ## than pulling an executable plugin payload from an unexpected origin. + ## See #523. + if not _is_trusted_download_url(_latest_download_url): + push_error( + "MCP | refusing self-update download from untrusted URL: %s" + % _latest_download_url + ) + OS.shell_open(RELEASES_PAGE) + install_state_changed.emit({ + "button_text": "Update via download page", + "button_disabled": false, + }) + return + install_state_changed.emit({ "button_text": "Downloading...", "button_disabled": true, @@ -212,6 +249,7 @@ func is_install_in_flight() -> bool: ## forced: bool ## mode_override() == "user" (banner-only hint) ## label_text: String ## "Update available: vX.Y.Z" + " (forced)" ## download_url: String ## matching `godot-ai-plugin.zip` asset URL +## checksum_url: String ## `godot-ai-plugin.zip.sha256` asset URL ("" if absent) ## ## Static so tests drive it without instancing the manager. static func parse_releases_response( @@ -223,6 +261,7 @@ static func parse_releases_response( "forced": false, "label_text": "", "download_url": "", + "checksum_url": "", } if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: return out @@ -239,12 +278,15 @@ static func parse_releases_response( return out var url := "" + var checksum_url := "" var assets: Array = json.get("assets", []) for asset in assets: var asset_dict: Dictionary = asset - if String(asset_dict.get("name", "")) == "godot-ai-plugin.zip": + var asset_name := String(asset_dict.get("name", "")) + if asset_name == "godot-ai-plugin.zip": url = String(asset_dict.get("browser_download_url", "")) - break + elif asset_name == "godot-ai-plugin.zip.sha256": + checksum_url = String(asset_dict.get("browser_download_url", "")) var forced := ClientConfigurator.mode_override() == "user" var label_text := "Update available: v%s" % remote_version @@ -258,9 +300,35 @@ static func parse_releases_response( out["forced"] = forced out["label_text"] = label_text out["download_url"] = url + out["checksum_url"] = checksum_url return out +## True only for an `https://` URL whose host is one of +## `_TRUSTED_DOWNLOAD_HOSTS`. Parses the authority by hand (GDScript has no +## URL parser): strips userinfo via the LAST `@` so a spoof like +## `https://github.com@evil.com/...` resolves to `evil.com` (rejected), and +## strips any `:port`. Static so the guard is unit-testable without +## instancing the manager. +static func _is_trusted_download_url(url: String) -> bool: + const SCHEME := "https://" + if not url.begins_with(SCHEME): + return false + var rest := url.substr(SCHEME.length()) + var authority := rest + var slash := rest.find("/") + if slash >= 0: + authority = rest.substr(0, slash) + ## Host is everything after the LAST '@' (userinfo precedes it). + var at := authority.rfind("@") + if at >= 0: + authority = authority.substr(at + 1) + var colon := authority.find(":") + if colon >= 0: + authority = authority.substr(0, colon) + return authority.to_lower() in _TRUSTED_DOWNLOAD_HOSTS + + static func _is_newer(remote: String, local: String) -> bool: var r := remote.split(".") var l := local.split(".") @@ -286,6 +354,7 @@ func _on_update_check_completed( if not bool(parsed.get("has_update", false)): return _latest_download_url = String(parsed.get("download_url", "")) + _latest_checksum_url = String(parsed.get("checksum_url", "")) update_check_completed.emit(parsed) ## On engines that can't self-update (Godot < 4.4, #475), surface the ## full manual-update guidance AND relabel the button up-front — before @@ -315,9 +384,117 @@ func _on_download_completed( }) return + # Deferred so the HTTPRequest callback returns before the next step starts. + _verify_then_install.call_deferred() + + +# ---- Integrity verification (#523) ------------------------------------- + +## Gate the extract on a SHA-256 match against the release's checksum sidecar. +## TLS + host pinning already constrain where the bytes came from; this +## verifies the bytes themselves so a tampered asset (or a compromised CDN +## object) can't be installed over live plugin code. Releases published +## without a `.sha256` sidecar (older versions) install without this check — +## verify-if-present rather than hard-fail, so existing releases stay +## updatable; the host pin still applies to the download itself. +func _verify_then_install() -> void: + if _latest_checksum_url.is_empty(): + print("MCP | no checksum published for this release; skipping integrity verification") + install_state_changed.emit({"button_text": "Installing..."}) + _install_zip() + return + + ## A present-but-untrusted checksum URL is a tamper signal, not a + ## backward-compat case — refuse rather than silently skip. + if not _is_trusted_download_url(_latest_checksum_url): + _fail_verification("checksum URL is not a trusted GitHub host") + return + + install_state_changed.emit({"button_text": "Verifying..."}) + if _verify_request != null: + _verify_request.queue_free() + _verify_request = HTTPRequest.new() + _verify_request.max_redirects = 10 + _verify_request.request_completed.connect(_on_checksum_completed) + add_child(_verify_request) + var err := _verify_request.request(_latest_checksum_url) + if err != OK: + _verify_request.queue_free() + _verify_request = null + _fail_verification("could not request checksum (error %d)" % err) + + +func _on_checksum_completed( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray +) -> void: + if _verify_request != null: + _verify_request.queue_free() + _verify_request = null + + if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: + _fail_verification("checksum download failed (result=%d code=%d)" % [result, response_code]) + return + + var expected := _parse_sha256_digest(body.get_string_from_utf8()) + if expected.is_empty(): + _fail_verification("malformed checksum file") + return + + var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP) + var actual := FileAccess.get_sha256(zip_path).to_lower() + if actual.is_empty(): + _fail_verification("could not hash the downloaded archive") + return + if actual != expected: + _fail_verification( + "checksum mismatch (expected %s…, got %s…)" + % [expected.substr(0, 12), actual.substr(0, 12)] + ) + return + + print("MCP | self-update checksum verified (sha256 %s)" % actual) install_state_changed.emit({"button_text": "Installing..."}) - # Deferred so the HTTPRequest callback returns before the extract starts. - _install_zip.call_deferred() + _install_zip() + + +## Surface an integrity-check failure and drop the staged zip so the bad +## bytes can never reach the extract path. Keeps the button enabled for retry. +func _fail_verification(reason: String) -> void: + push_error( + "MCP | self-update integrity check failed: %s. The download was not installed." + % reason + ) + print("MCP | self-update aborted (integrity): %s" % reason) + DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)) + install_state_changed.emit({ + "button_text": "Verification failed — retry", + "button_disabled": false, + }) + + +## Extract the hex digest from a `sha256sum`-style file (" ") or a +## bare digest line. Returns lowercase 64-char hex, or "" if the content isn't +## a valid SHA-256 digest. Static so it's unit-testable. See #523. +static func _parse_sha256_digest(text: String) -> String: + var trimmed := text.strip_edges() + if trimmed.is_empty(): + return "" + ## First whitespace-delimited token; `sha256sum` separates digest and + ## filename with two spaces, so allow_empty=false collapses the run. + var tokens := trimmed.split(" ", false) + if tokens.is_empty(): + return "" + var digest := String(tokens[0]).strip_edges().to_lower() + if digest.length() != 64: + return "" + for i in digest.length(): + var c := digest[i] + if not ((c >= "0" and c <= "9") or (c >= "a" and c <= "f")): + return "" + return digest # ---- Install orchestration --------------------------------------------- @@ -382,16 +559,43 @@ func _install_zip_inline(version: Dictionary) -> void: for file_path in files: if not file_path.begins_with("addons/godot_ai/"): continue + ## Skip zip dir entries; parent dirs are created from each validated + ## file's base dir below — the same shape the runner uses. Creating a + ## dir from an unvalidated entry would itself be a traversal hole. if file_path.ends_with("/"): - DirAccess.make_dir_recursive_absolute(install_base.path_join(file_path)) - else: - var dir := file_path.get_base_dir() - DirAccess.make_dir_recursive_absolute(install_base.path_join(dir)) - var content := reader.read_file(file_path) - var f := FileAccess.open(install_base.path_join(file_path), FileAccess.WRITE) - if f != null: - f.store_buffer(content) - f.close() + continue + ## Reject path-traversal / absolute / backslash entries BEFORE any + ## path_join + write. The modern runner enforces this via + ## `update_reload_runner.gd::_is_safe_zip_addon_file`; the pre-4.4 + ## inline path used to gate only on the `addons/godot_ai/` prefix, so + ## `addons/godot_ai/../../evil.gd` escaped the addon dir. This guard + ## closes that gap so the weaker path runs the same checks. See #522. + if not _is_safe_zip_addon_file(file_path): + _abort_inline_install(reader, "unsafe zip path: %s" % file_path) + return + var dir := file_path.get_base_dir() + DirAccess.make_dir_recursive_absolute(install_base.path_join(dir)) + var content := reader.read_file(file_path) + var target := install_base.path_join(file_path) + var f := FileAccess.open(target, FileAccess.WRITE) + ## Unlike the runner (tmp+rename+per-file backup+rollback), this pre-4.4 + ## path writes directly over live files and can't roll back. It used to + ## skip a null open and ignore store_buffer errors silently, leaving a + ## partially-overwritten addons tree while still telling the user to + ## restart onto it. Check both error surfaces and abort loudly instead. + ## See #524. + if f == null: + _abort_inline_install( + reader, + "could not open %s for write (error %d)" % [target, FileAccess.get_open_error()], + ) + return + f.store_buffer(content) + var write_error := f.get_error() + f.close() + if write_error != OK: + _abort_inline_install(reader, "write error %d for %s" % [write_error, target]) + return reader.close() @@ -428,6 +632,47 @@ func _install_zip_inline(version: Dictionary) -> void: }) +## Abort the inline (pre-4.4) extract on a path-safety or write failure. +## Closes the ZIP reader, drops the in-flight gate so dock spawn paths +## un-block, and surfaces the failure loudly: this path has no rollback, so +## the addons tree may be partially overwritten and the user must reinstall +## from the download page rather than relaunch onto a half-written plugin. +## See #522 / #524. +func _abort_inline_install(reader: ZIPReader, reason: String) -> void: + reader.close() + _install_in_flight = false + push_error( + "MCP | self-update extract failed: %s. addons/godot_ai/ may be" + % reason + + " partially updated — reinstall the plugin from the download page" + + " before relaunching." + ) + print("MCP | self-update extract aborted: %s" % reason) + install_state_changed.emit({ + "button_text": "Extract failed — reinstall", + "button_disabled": false, + }) + + +## Mirror of `update_reload_runner.gd::_is_safe_zip_addon_file`. Rejects any +## entry that could escape `addons/godot_ai/` — absolute paths, backslashes, +## and `.`/`..`/empty path segments — before it reaches a `path_join` + write +## on the inline (pre-4.4) extract path, which has no rollback. Static so the +## guard is unit-testable without instancing the manager. See #522. +static func _is_safe_zip_addon_file(file_path: String) -> bool: + if file_path.is_absolute_path() or file_path.contains("\\"): + return false + if not file_path.begins_with("addons/godot_ai/"): + return false + var rel_path := file_path.trim_prefix("addons/godot_ai/") + if rel_path.is_empty() or rel_path.ends_with("/"): + return false + for segment in rel_path.split("/", true): + if segment.is_empty() or segment == "." or segment == "..": + return false + return true + + func _on_filesystem_scanned_for_update() -> void: install_state_changed.emit({"button_text": "Reloading..."}) _reload_after_update.call_deferred() diff --git a/format_report_final.js b/format_report_final.js deleted file mode 100644 index e3848df..0000000 --- a/format_report_final.js +++ /dev/null @@ -1,158 +0,0 @@ -/* Build fancy Adaptive Card report with icons and status */ -function getTitle(item) { - if (item.json.Name && item.json.Name.title && Array.isArray(item.json.Name.title) && item.json.Name.title.length > 0) { - return item.json.Name.title[0].plain_text; - } - if (item.json.title && Array.isArray(item.json.title) && item.json.title.length > 0) { - return item.json.title[0].plain_text; - } - return "Untitled"; -} - -const items = $input.all(); -const taskBlocks = []; - -if (items.length === 0) { - taskBlocks.push({ - type: 'TextBlock', - text: 'No tasks were completed today. 💤', - color: 'warning', - weight: 'Bolder' - }); -} else { - items.forEach((item, index) => { - const projectType = item.json.ProjectType || ''; - const priority = item.json.Priority || ''; - const taskName = getTitle(item); - const description = item.json.Description || 'No description provided.'; - const status = item.json.Status || ''; - - const prefix = [projectType, priority].filter(Boolean).join('-'); - const title = prefix ? `[${prefix}]: ${taskName}` : taskName; - - // Determine status icon and color - let statusIcon = 'https://cdn-icons-png.flaticon.com/512/190/190411.png'; - let statusColor = 'Good'; - let statusText = 'DONE'; - - if (status === 'In Progress') { - statusIcon = 'https://cdn-icons-png.flaticon.com/512/992/992697.png'; - statusColor = 'Accent'; - statusText = 'IN PROGRESS'; - } else if (status === 'Pending' || status === '') { - statusIcon = 'https://cdn-icons-png.flaticon.com/512/1828/1828645.png'; - statusColor = 'Default'; - statusText = 'PENDING'; - } - - taskBlocks.push({ - type: 'ColumnSet', - spacing: 'Medium', - separator: true, - columns: [ - { - type: 'Column', - width: 'auto', - verticalContentAlignment: 'Center', - items: [ - { - type: 'Image', - url: statusIcon, - width: '24px', - altText: statusText - } - ] - }, - { - type: 'Column', - width: 'stretch', - items: [ - { - type: 'TextBlock', - text: title, - size: 'Medium', - weight: 'Bolder', - color: statusColor, - wrap: true - }, - { - type: 'TextBlock', - text: description, - isSubtle: true, - size: 'Small', - wrap: true, - spacing: 'None' - } - ] - } - ] - }); - }); -} - -return [{ - json: { - type: 'message', - attachments: [{ - contentType: 'application/vnd.microsoft.card.adaptive', - content: { - $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', - type: 'AdaptiveCard', - version: '1.4', - body: [ - { - type: 'ColumnSet', - style: 'emphasis', - columns: [ - { - type: 'Column', - width: 'auto', - items: [ - { - type: 'Image', - url: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/godot.png', - width: '40px', - altText: 'Godot Logo' - } - ] - }, - { - type: 'Column', - width: 'stretch', - verticalContentAlignment: 'Center', - items: [ - { - type: 'TextBlock', - text: 'ADT Report : Tekton Dash', - weight: 'Bolder', - size: 'Large', - color: 'Accent' - }, - { - type: 'TextBlock', - text: new Date().toLocaleDateString(), - isSubtle: true, - size: 'Small', - spacing: 'None' - } - ] - } - ] - }, - ...taskBlocks, - { - type: 'ActionSet', - actions: [ - { - type: 'Action.OpenUrl', - title: 'Open PR Game Dev', - url: 'https://www.notion.so/danchiego-game/36433be43b29800c8422ed5bdd65671b?v=36433be43b2980de8635000c0a910a0d', - style: 'positive' - } - ] - } - ] - } - }] - } -}]; diff --git a/format_report_updated.js b/format_report_updated.js deleted file mode 100644 index ce2dcdb..0000000 --- a/format_report_updated.js +++ /dev/null @@ -1,154 +0,0 @@ -function getTitle(item) { - if (item.json.Name && item.json.Name.title && Array.isArray(item.json.Name.title) && item.json.Name.title.length > 0) { - return item.json.Name.title[0].plain_text; - } - if (item.json.title && Array.isArray(item.json.title) && item.json.title.length > 0) { - return item.json.title[0].plain_text; - } - return "Untitled"; -} - -const items = $input.all(); -const taskBlocks = []; - -if (items.length === 0) { - taskBlocks.push({ - type: "TextBlock", - text: "No new tasks were completed today.", - color: "warning", - weight: "Bolder" - }); -} else { - items.forEach(item => { - const projectType = item.json.ProjectType || ""; - const priority = item.json.Priority || ""; - const taskName = getTitle(item); - const description = item.json.Description || "No description provided."; - const status = item.json.Status || ""; - - let statusBadge = "PENDING"; - let badgeColor = "Default"; - - if (status === "In Progress") { - statusBadge = "IN PROGRESS"; - badgeColor = "Accent"; - } else if (status === "Done") { - statusBadge = "DONE"; - badgeColor = "Good"; - } - - const prefix = [projectType, priority].filter(Boolean).join("-"); - const title = prefix ? `[${prefix}]: ${taskName}` : taskName; - - taskBlocks.push({ - type: "ColumnSet", - spacing: "Medium", - separator: true, - columns: [ - { - type: "Column", - width: "auto", - verticalContentAlignment: "Center", - items: [ - { - type: "TextBlock", - text: statusBadge, - size: "Small", - weight: "Bolder", - color: badgeColor, - wrap: false - } - ] - }, - { - type: "Column", - width: "stretch", - items: [ - { - type: "TextBlock", - text: title, - size: "Medium", - weight: "Bolder", - color: badgeColor, - wrap: true - }, - { - type: "TextBlock", - text: description, - isSubtle: true, - size: "Small", - wrap: true, - spacing: "None" - } - ] - } - ] - }); - }); -} - -return [{ - json: { - type: "message", - attachments: [{ - contentType: "application/vnd.microsoft.card.adaptive", - content: { - $schema: "http://adaptivecards.io/schemas/adaptive-card.json", - type: "AdaptiveCard", - version: "1.4", - body: [ - { - type: "ColumnSet", - columns: [ - { - type: "Column", - width: "auto", - items: [ - { - type: "Image", - url: "https://godotengine.org/assets/press/godot-logo.svg", - size: "Small", - altText: "Godot Logo" - } - ] - }, - { - type: "Column", - width: "stretch", - verticalContentAlignment: "Center", - items: [ - { - type: "TextBlock", - text: "ADT Report : Tekton Dash", - weight: "Bolder", - size: "Large", - color: "Accent" - } - ] - } - ] - }, - { - type: "TextBlock", - text: new Date().toLocaleDateString(), - isSubtle: true, - size: "Small", - spacing: "None" - }, - ...taskBlocks, - { - type: "ActionSet", - actions: [ - { - type: "Action.OpenUrl", - title: "Open PR Game Dev", - url: "https://www.notion.so/danchiego-game/36433be43b29800c8422ed5bdd65671b?v=36433be43b2980de8635000c0a910a0d", - style: "positive" - } - ] - } - ] - } - }] - } -}]; diff --git a/scenes/arena/gauntlet.tscn b/scenes/arena/gauntlet.tscn index cfdd4b0..d5cb7ce 100644 --- a/scenes/arena/gauntlet.tscn +++ b/scenes/arena/gauntlet.tscn @@ -17,6 +17,13 @@ albedo_color = Color(0.8, 0.2, 0.5, 1) material = SubResource("StandardMaterial3D_zone") size = Vector3(3, 1, 3) +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_86fyc"] +albedo_color = Color(0.4973, 0.6, 0.12599999, 1) + +[sub_resource type="PlaneMesh" id="PlaneMesh_ugtui"] +material = SubResource("StandardMaterial3D_86fyc") +size = Vector2(50, 50) + [node name="Gauntlet" type="Node3D" unique_id=1063002869] [node name="PlaceholderFloor" type="MeshInstance3D" parent="." unique_id=932640085] @@ -30,3 +37,7 @@ visible = false mesh = SubResource("BoxMesh_cannon") [node name="Gauntlet terrain" parent="." unique_id=193457353 instance=ExtResource("1_86fyc")] + +[node name="MeshInstance3D" type="MeshInstance3D" parent="." unique_id=1749367969] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9.192932, 0, 9.441385) +mesh = SubResource("PlaneMesh_ugtui") diff --git a/scenes/lobby.tscn b/scenes/lobby.tscn index 4f483d9..8a637b6 100644 --- a/scenes/lobby.tscn +++ b/scenes/lobby.tscn @@ -4,12 +4,12 @@ [ext_resource type="Theme" uid="uid://da337sh5qxi0s" path="res://assets/themes/ui_theme.tres" id="2_theme"] [ext_resource type="Texture2D" uid="uid://jqvv6s55mlsk" path="res://assets/graphics/gui/BG.png" id="3_iulku"] [ext_resource type="Texture2D" uid="uid://2d1ks5pmblc7" path="res://assets/graphics/main_menu/bg_back.png" id="3_q60fs"] -[ext_resource type="PackedScene" uid="uid://ejeamn0pyey4" path="res://assets/characters/Bob.glb" id="4_bob"] -[ext_resource type="PackedScene" uid="uid://d4cul3w3wem5w" path="res://assets/characters/Gatot.glb" id="4_gatot"] -[ext_resource type="PackedScene" uid="uid://1vk0mjnwkngi" path="res://assets/characters/Masbro.glb" id="4_masbro"] +[ext_resource type="PackedScene" uid="uid://5qdk1umx2rjf" path="res://assets/characters/Bob.glb" id="4_bob"] +[ext_resource type="PackedScene" uid="uid://bfujakntxa0v6" path="res://assets/characters/Gatot.glb" id="4_gatot"] +[ext_resource type="PackedScene" uid="uid://cfjx66gthp1c5" path="res://assets/characters/Masbro.glb" id="4_masbro"] [ext_resource type="Texture2D" uid="uid://dvp0as6yyudco" path="res://assets/graphics/main_menu/bg_illust.png" id="4_nqcc7"] -[ext_resource type="PackedScene" uid="uid://bmln7v6v5kvxg" path="res://assets/characters/Oldpop.glb" id="4_oldpop"] -[ext_resource type="AnimationLibrary" uid="uid://c3pyopnwibckj" path="res://assets/characters/animations/animation-pack.res" id="5_animlib"] +[ext_resource type="PackedScene" uid="uid://cxvbrdybeglt5" path="res://assets/characters/Oldpop.glb" id="4_oldpop"] +[ext_resource type="AnimationLibrary" path="res://assets/characters/animations/animation-pack.res" id="5_animlib"] [ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="5_pc087"] [ext_resource type="Texture2D" uid="uid://brhn1dhp1gm13" path="res://assets/graphics/character_selection/sc_characters/sc_copper.png" id="10_dyhay"] [ext_resource type="Texture2D" uid="uid://c8xwpkvvwa7a4" path="res://assets/graphics/gui/mainmenu/chat.png" id="12_dfnwm"] diff --git a/scenes/main.gd b/scenes/main.gd index ffac8e4..2d91e09 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -252,8 +252,8 @@ func _init_managers(): add_child(portal_mode_manager) portal_mode_manager.initialize(self , $EnhancedGridMap) - # Gauntlet manager for Candy Cannon Survival mode - if LobbyManager.game_mode == "Candy Cannon Survival": + # Gauntlet manager for Candy Pump Survival mode + if LobbyManager.game_mode == "Candy Pump Survival": gauntlet_manager = load("res://scripts/managers/gauntlet_manager.gd").new() gauntlet_manager.name = "GauntletManager" add_child(gauntlet_manager) @@ -621,7 +621,7 @@ func _setup_host_game(): stop_n_go_manager._setup_arena() elif LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager: portal_mode_manager.setup_arena_locally() - elif LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager: + elif LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager: gauntlet_manager._setup_arena() else: # Randomize grid first to ensure Floor 0 is walkable for pre-calculation @@ -729,8 +729,8 @@ func _setup_client_game(): if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager: portal_mode_manager.setup_arena_locally() - # Initialize arena locally for Candy Cannon Survival - if LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager: + # Initialize arena locally for Candy Pump Survival + if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager: gauntlet_manager._apply_arena_setup() # Ensure local player setup (UI, controls) is verified @@ -829,12 +829,12 @@ func _start_game(): stop_n_go_manager.spawn_initial_powerups() # Ensure power-ups exist before 1,2,3 Go # Gauntlet: Spawn mission tiles across 20x20 arena BEFORE countdown - if LobbyManager.game_mode == "Candy Cannon Survival" and gauntlet_manager: + if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager: gauntlet_manager.setup_mission_tiles() # Spawn Static Tektons and random tiles BEFORE countdown (Free Mode Only) # Exclude for Stop n Go and Tekton Doors - if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Cannon Survival": + if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Pump Survival": spawn_static_tektons() # Tekton Doors: Randomize connections BEFORE countdown so colors show @@ -873,7 +873,7 @@ func _start_game(): if goals_cycle_manager: var match_duration = LobbyManager.get_match_duration() goals_cycle_manager.start_match(float(match_duration)) - elif LobbyManager.game_mode == "Candy Cannon Survival": + elif LobbyManager.game_mode == "Candy Pump Survival": if gauntlet_manager: gauntlet_manager.start_game_mode() @@ -1860,7 +1860,7 @@ func randomize_item_at_position(grid_position: Vector2i): if is_ground: var get_mode_specific_tile = func(): - if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Cannon Survival": + if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Pump Survival": # 60% Chance for Common (7-10), 40% for PowerUp if randf() <= 0.6: return [7, 8, 9, 10].pick_random() diff --git a/scenes/ui/admin_panel.tscn b/scenes/ui/admin_panel.tscn index 70af1da..1492ba1 100644 --- a/scenes/ui/admin_panel.tscn +++ b/scenes/ui/admin_panel.tscn @@ -1,17 +1,18 @@ -[gd_scene load_steps=2 format=3 uid="uid://biio8efqysivs"] +[gd_scene format=3 uid="uid://biio8efqysivs"] [ext_resource type="Script" uid="uid://ic8fg0o0p0i4" path="res://scripts/ui/admin_panel.gd" id="1"] [ext_resource type="PackedScene" uid="uid://dp12345678" path="res://scenes/ui/date_picker.tscn" id="2_dp"] -[node name="AdminPanel" type="Panel"] +[node name="AdminPanel" type="Panel" unique_id=1215317796] anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1") +metadata/_edit_vertical_guides_ = [72.0] -[node name="BG" type="ColorRect" parent="."] +[node name="BG" type="ColorRect" parent="." unique_id=1804706969] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 @@ -20,7 +21,7 @@ grow_horizontal = 2 grow_vertical = 2 color = Color(0.1, 0.1, 0.12, 1) -[node name="Margin" type="MarginContainer" parent="."] +[node name="Margin" type="MarginContainer" parent="." unique_id=455016900] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 @@ -32,52 +33,53 @@ theme_override_constants/margin_top = 16 theme_override_constants/margin_right = 24 theme_override_constants/margin_bottom = 16 -[node name="VBox" type="VBoxContainer" parent="Margin"] +[node name="VBox" type="VBoxContainer" parent="Margin" unique_id=2140901986] layout_mode = 2 theme_override_constants/separation = 8 -[node name="Header" type="HBoxContainer" parent="Margin/VBox"] +[node name="Header" type="HBoxContainer" parent="Margin/VBox" unique_id=2066510654] layout_mode = 2 theme_override_constants/separation = 12 -[node name="Title" type="Label" parent="Margin/VBox/Header"] +[node name="Title" type="Label" parent="Margin/VBox/Header" unique_id=1489422413] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 text = "SERVER ADMIN PANEL" -[node name="CountLabel" type="Label" parent="Margin/VBox/Header"] +[node name="CountLabel" type="Label" parent="Margin/VBox/Header" unique_id=73628023] unique_name_in_owner = true layout_mode = 2 text = "0 items" -[node name="RefreshBtn" type="Button" parent="Margin/VBox/Header"] +[node name="RefreshBtn" type="Button" parent="Margin/VBox/Header" unique_id=1328255917] unique_name_in_owner = true custom_minimum_size = Vector2(90, 32) layout_mode = 2 text = "Refresh" -[node name="CloseBtn" type="Button" parent="Margin/VBox/Header"] +[node name="CloseBtn" type="Button" parent="Margin/VBox/Header" unique_id=1357556455] unique_name_in_owner = true custom_minimum_size = Vector2(32, 32) layout_mode = 2 text = "X" -[node name="Sep" type="HSeparator" parent="Margin/VBox"] +[node name="Sep" type="HSeparator" parent="Margin/VBox" unique_id=1215687357] layout_mode = 2 -[node name="Tabs" type="TabContainer" parent="Margin/VBox"] +[node name="Tabs" type="TabContainer" parent="Margin/VBox" unique_id=786391934] unique_name_in_owner = true layout_mode = 2 size_flags_vertical = 3 -current_tab = 0 +current_tab = 7 -[node name="Users" type="VBoxContainer" parent="Margin/VBox/Tabs"] +[node name="Users" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=888669929] +visible = false layout_mode = 2 theme_override_constants/separation = 8 metadata/_tab_index = 0 -[node name="UserTree" type="Tree" parent="Margin/VBox/Tabs/Users"] +[node name="UserTree" type="Tree" parent="Margin/VBox/Tabs/Users" unique_id=1451007875] unique_name_in_owner = true layout_mode = 2 size_flags_vertical = 3 @@ -87,63 +89,62 @@ allow_reselect = true hide_root = true select_mode = 1 -[node name="UserActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Users"] +[node name="UserActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Users" unique_id=327018008] layout_mode = 2 theme_override_constants/separation = 8 -[node name="SelectAllBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"] +[node name="SelectAllBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=572327166] unique_name_in_owner = true custom_minimum_size = Vector2(100, 36) layout_mode = 2 text = "Select All" -[node name="DeselectBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"] +[node name="DeselectBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=1569831836] unique_name_in_owner = true custom_minimum_size = Vector2(100, 36) layout_mode = 2 text = "Deselect All" -[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Users/UserActionBar"] +[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=708964742] layout_mode = 2 size_flags_horizontal = 3 -[node name="SelectedLabel" type="Label" parent="Margin/VBox/Tabs/Users/UserActionBar"] +[node name="SelectedLabel" type="Label" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=1896049587] unique_name_in_owner = true layout_mode = 2 text = "0 selected" -[node name="HistoryBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"] +[node name="HistoryBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=2045123211] unique_name_in_owner = true custom_minimum_size = Vector2(80, 36) layout_mode = 2 text = "HISTORY" - -[node name="BanBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"] +[node name="BanBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=1870479743] unique_name_in_owner = true custom_minimum_size = Vector2(80, 36) layout_mode = 2 text = "BAN" -[node name="UnbanBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"] +[node name="UnbanBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=222463017] unique_name_in_owner = true custom_minimum_size = Vector2(80, 36) layout_mode = 2 text = "UNBAN" -[node name="DeleteBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"] +[node name="DeleteBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar" unique_id=313663234] unique_name_in_owner = true custom_minimum_size = Vector2(80, 36) layout_mode = 2 text = "DELETE" -[node name="Leaderboards" type="VBoxContainer" parent="Margin/VBox/Tabs"] +[node name="Leaderboards" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=102020095] visible = false layout_mode = 2 theme_override_constants/separation = 8 metadata/_tab_index = 1 -[node name="LeaderboardTree" type="Tree" parent="Margin/VBox/Tabs/Leaderboards"] +[node name="LeaderboardTree" type="Tree" parent="Margin/VBox/Tabs/Leaderboards" unique_id=1966740510] unique_name_in_owner = true layout_mode = 2 size_flags_vertical = 3 @@ -152,48 +153,48 @@ column_titles_visible = true allow_reselect = true hide_root = true -[node name="LBActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Leaderboards"] +[node name="LBActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Leaderboards" unique_id=72512512] layout_mode = 2 theme_override_constants/separation = 8 alignment = 2 -[node name="SyncLeaderboardBtn" type="Button" parent="Margin/VBox/Tabs/Leaderboards/LBActionBar"] +[node name="SyncLeaderboardBtn" type="Button" parent="Margin/VBox/Tabs/Leaderboards/LBActionBar" unique_id=1234854116] unique_name_in_owner = true custom_minimum_size = Vector2(160, 36) layout_mode = 2 text = "Sync with Storage" -[node name="ResetLBBtn" type="Button" parent="Margin/VBox/Tabs/Leaderboards/LBActionBar"] +[node name="ResetLBBtn" type="Button" parent="Margin/VBox/Tabs/Leaderboards/LBActionBar" unique_id=895290771] unique_name_in_owner = true visible = false custom_minimum_size = Vector2(120, 36) layout_mode = 2 text = "Reset All Scores" -[node name="Daily Rewards" type="VBoxContainer" parent="Margin/VBox/Tabs"] +[node name="Daily Rewards" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=1077500751] visible = false layout_mode = 2 theme_override_constants/separation = 8 metadata/_tab_index = 2 -[node name="MonthHBox" type="HBoxContainer" parent="Margin/VBox/Tabs/Daily Rewards"] +[node name="MonthHBox" type="HBoxContainer" parent="Margin/VBox/Tabs/Daily Rewards" unique_id=1428330077] layout_mode = 2 theme_override_constants/separation = 12 -[node name="Label" type="Label" parent="Margin/VBox/Tabs/Daily Rewards/MonthHBox"] +[node name="Label" type="Label" parent="Margin/VBox/Tabs/Daily Rewards/MonthHBox" unique_id=545398145] layout_mode = 2 text = "Target Month:" -[node name="MonthOptionBtn" type="OptionButton" parent="Margin/VBox/Tabs/Daily Rewards/MonthHBox"] +[node name="MonthOptionBtn" type="OptionButton" parent="Margin/VBox/Tabs/Daily Rewards/MonthHBox" unique_id=837393990] unique_name_in_owner = true custom_minimum_size = Vector2(150, 0) layout_mode = 2 -[node name="DaysScroll" type="ScrollContainer" parent="Margin/VBox/Tabs/Daily Rewards"] +[node name="DaysScroll" type="ScrollContainer" parent="Margin/VBox/Tabs/Daily Rewards" unique_id=550036125] layout_mode = 2 size_flags_vertical = 3 -[node name="DaysGrid" type="GridContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll"] +[node name="DaysGrid" type="GridContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll" unique_id=737222522] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 @@ -202,38 +203,37 @@ theme_override_constants/h_separation = 10 theme_override_constants/v_separation = 10 columns = 6 -[node name="DayConfigTemplate" type="PanelContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid"] +[node name="DayConfigTemplate" type="PanelContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid" unique_id=931392759] unique_name_in_owner = true visible = false layout_mode = 2 size_flags_horizontal = 3 -[node name="ColorRect" type="ColorRect" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate"] +[node name="ColorRect" type="ColorRect" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate" unique_id=1136593241] layout_mode = 2 color = Color(0.12, 0.12, 0.12, 1) -[node name="Border" type="ReferenceRect" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate"] +[node name="Border" type="ReferenceRect" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate" unique_id=533194914] layout_mode = 2 border_color = Color(0.25, 0.25, 0.25, 1) -border_width = 1.0 editor_only = false -[node name="Margin" type="MarginContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate"] +[node name="Margin" type="MarginContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate" unique_id=1878869939] layout_mode = 2 theme_override_constants/margin_left = 4 theme_override_constants/margin_top = 4 theme_override_constants/margin_right = 4 theme_override_constants/margin_bottom = 4 -[node name="VBox" type="VBoxContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin"] +[node name="VBox" type="VBoxContainer" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin" unique_id=809832816] layout_mode = 2 -[node name="DayLabel" type="Label" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin/VBox"] +[node name="DayLabel" type="Label" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin/VBox" unique_id=572039382] layout_mode = 2 text = "Day 1" horizontal_alignment = 1 -[node name="TypeOptionBtn" type="OptionButton" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin/VBox"] +[node name="TypeOptionBtn" type="OptionButton" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin/VBox" unique_id=1462823824] layout_mode = 2 alignment = 1 item_count = 5 @@ -248,123 +248,122 @@ popup/item_3/id = 3 popup/item_4/text = "frag_rare" popup/item_4/id = 4 -[node name="AmountSpinBox" type="SpinBox" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin/VBox"] +[node name="AmountSpinBox" type="SpinBox" parent="Margin/VBox/Tabs/Daily Rewards/DaysScroll/DaysGrid/DayConfigTemplate/Margin/VBox" unique_id=1768492868] layout_mode = 2 max_value = 10000.0 alignment = 1 -[node name="DRActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Daily Rewards"] +[node name="DRActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Daily Rewards" unique_id=1558168965] layout_mode = 2 theme_override_constants/separation = 8 alignment = 2 -[node name="LoadDRConfigBtn" type="Button" parent="Margin/VBox/Tabs/Daily Rewards/DRActionBar"] +[node name="LoadDRConfigBtn" type="Button" parent="Margin/VBox/Tabs/Daily Rewards/DRActionBar" unique_id=2139474519] unique_name_in_owner = true custom_minimum_size = Vector2(160, 36) layout_mode = 2 text = "Reload Config" -[node name="SaveDRConfigBtn" type="Button" parent="Margin/VBox/Tabs/Daily Rewards/DRActionBar"] +[node name="SaveDRConfigBtn" type="Button" parent="Margin/VBox/Tabs/Daily Rewards/DRActionBar" unique_id=1692637953] unique_name_in_owner = true custom_minimum_size = Vector2(160, 36) layout_mode = 2 text = "Save Config" -[node name="Announcements" type="VBoxContainer" parent="Margin/VBox/Tabs"] +[node name="Announcements" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=905445194] visible = false layout_mode = 2 theme_override_constants/separation = 12 metadata/_tab_index = 3 -[node name="TargetHBox" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements"] +[node name="TargetHBox" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements" unique_id=1418326252] layout_mode = 2 theme_override_constants/separation = 8 -[node name="Label" type="Label" parent="Margin/VBox/Tabs/Announcements/TargetHBox"] +[node name="Label" type="Label" parent="Margin/VBox/Tabs/Announcements/TargetHBox" unique_id=763223589] layout_mode = 2 text = "Target:" -[node name="TargetUserEdit" type="LineEdit" parent="Margin/VBox/Tabs/Announcements/TargetHBox"] +[node name="TargetUserEdit" type="LineEdit" parent="Margin/VBox/Tabs/Announcements/TargetHBox" unique_id=97068317] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 placeholder_text = "Username or User ID (empty = ALL)" -[node name="FindUserBtn" type="Button" parent="Margin/VBox/Tabs/Announcements/TargetHBox"] +[node name="FindUserBtn" type="Button" parent="Margin/VBox/Tabs/Announcements/TargetHBox" unique_id=967759165] unique_name_in_owner = true custom_minimum_size = Vector2(80, 0) layout_mode = 2 text = "Find" -[node name="ResolvedIdLabel" type="Label" parent="Margin/VBox/Tabs/Announcements/TargetHBox"] +[node name="ResolvedIdLabel" type="Label" parent="Margin/VBox/Tabs/Announcements/TargetHBox" unique_id=1229281906] unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.4, 0.9, 0.4, 1) -text = "" -[node name="TitleEdit" type="LineEdit" parent="Margin/VBox/Tabs/Announcements"] +[node name="TitleEdit" type="LineEdit" parent="Margin/VBox/Tabs/Announcements" unique_id=1221923610] unique_name_in_owner = true layout_mode = 2 placeholder_text = "Message Title" -[node name="ContentEdit" type="TextEdit" parent="Margin/VBox/Tabs/Announcements"] +[node name="ContentEdit" type="TextEdit" parent="Margin/VBox/Tabs/Announcements" unique_id=535034535] unique_name_in_owner = true custom_minimum_size = Vector2(0, 150) layout_mode = 2 placeholder_text = "Message Content" -[node name="ScheduleHBox" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements"] +[node name="ScheduleHBox" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements" unique_id=1506301658] layout_mode = 2 theme_override_constants/separation = 12 -[node name="LabelStart" type="Label" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox"] +[node name="LabelStart" type="Label" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox" unique_id=1634148723] layout_mode = 2 text = "Start:" -[node name="StartDatePicker" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox" instance=ExtResource("2_dp")] +[node name="StartDatePicker" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox" unique_id=1650084476 instance=ExtResource("2_dp")] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 -[node name="LabelEnd" type="Label" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox"] +[node name="LabelEnd" type="Label" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox" unique_id=2022036343] layout_mode = 2 text = "End:" -[node name="EndDatePicker" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox" instance=ExtResource("2_dp")] +[node name="EndDatePicker" parent="Margin/VBox/Tabs/Announcements/ScheduleHBox" unique_id=250724993 instance=ExtResource("2_dp")] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 -[node name="RewardsHeader" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements"] +[node name="RewardsHeader" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements" unique_id=1717375008] layout_mode = 2 -[node name="Label" type="Label" parent="Margin/VBox/Tabs/Announcements/RewardsHeader"] +[node name="Label" type="Label" parent="Margin/VBox/Tabs/Announcements/RewardsHeader" unique_id=1897885246] layout_mode = 2 size_flags_horizontal = 3 text = "Attached Rewards" -[node name="AddRewardBtn" type="Button" parent="Margin/VBox/Tabs/Announcements/RewardsHeader"] +[node name="AddRewardBtn" type="Button" parent="Margin/VBox/Tabs/Announcements/RewardsHeader" unique_id=222205115] unique_name_in_owner = true custom_minimum_size = Vector2(100, 30) layout_mode = 2 text = "+ ADD" -[node name="RewardsList" type="VBoxContainer" parent="Margin/VBox/Tabs/Announcements"] +[node name="RewardsList" type="VBoxContainer" parent="Margin/VBox/Tabs/Announcements" unique_id=1911576777] unique_name_in_owner = true layout_mode = 2 theme_override_constants/separation = 8 -[node name="RewardRowTemplate" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements"] +[node name="RewardRowTemplate" type="HBoxContainer" parent="Margin/VBox/Tabs/Announcements" unique_id=549284803] unique_name_in_owner = true visible = false layout_mode = 2 theme_override_constants/separation = 8 -[node name="TypeOption" type="OptionButton" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate"] +[node name="TypeOption" type="OptionButton" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate" unique_id=544162634] custom_minimum_size = Vector2(100, 0) layout_mode = 2 -item_count = 4 selected = 0 +item_count = 4 popup/item_0/text = "star" popup/item_0/id = 0 popup/item_1/text = "gold" @@ -374,34 +373,34 @@ popup/item_2/id = 2 popup/item_3/text = "skin" popup/item_3/id = 3 -[node name="IdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate"] +[node name="IdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate" unique_id=1011603098] layout_mode = 2 size_flags_horizontal = 3 placeholder_text = "Item/Skin ID (Leave empty for Star/Gold)" -[node name="AmountSpin" type="SpinBox" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate"] +[node name="AmountSpin" type="SpinBox" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate" unique_id=1485263050] layout_mode = 2 max_value = 100000.0 value = 1.0 -[node name="RemoveBtn" type="Button" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate"] +[node name="RemoveBtn" type="Button" parent="Margin/VBox/Tabs/Announcements/RewardRowTemplate" unique_id=370893501] layout_mode = 2 -text = "X" theme_override_colors/font_color = Color(1, 0.3, 0.3, 1) +text = "X" -[node name="SendMailBtn" type="Button" parent="Margin/VBox/Tabs/Announcements"] +[node name="SendMailBtn" type="Button" parent="Margin/VBox/Tabs/Announcements" unique_id=84552601] unique_name_in_owner = true custom_minimum_size = Vector2(0, 40) layout_mode = 2 text = "SEND ANNOUNCEMENT" -[node name="Mail Manager" type="VBoxContainer" parent="Margin/VBox/Tabs"] +[node name="Mail Manager" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=1187054862] visible = false layout_mode = 2 theme_override_constants/separation = 8 metadata/_tab_index = 4 -[node name="MailTree" type="Tree" parent="Margin/VBox/Tabs/Mail Manager"] +[node name="MailTree" type="Tree" parent="Margin/VBox/Tabs/Mail Manager" unique_id=1947023585] unique_name_in_owner = true layout_mode = 2 size_flags_vertical = 3 @@ -411,248 +410,243 @@ allow_reselect = true hide_root = true select_mode = 1 -[node name="MailActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Mail Manager"] +[node name="MailActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Mail Manager" unique_id=2069104938] layout_mode = 2 theme_override_constants/separation = 8 -[node name="RefreshMailBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar"] +[node name="RefreshMailBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar" unique_id=888045352] unique_name_in_owner = true custom_minimum_size = Vector2(100, 36) layout_mode = 2 text = "Refresh" -[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar"] +[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar" unique_id=1229119376] layout_mode = 2 size_flags_horizontal = 3 -[node name="EditMailBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar"] +[node name="EditMailBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar" unique_id=2119864672] unique_name_in_owner = true custom_minimum_size = Vector2(100, 36) layout_mode = 2 text = "Edit" -[node name="EndMailBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar"] +[node name="EndMailBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar" unique_id=740259358] unique_name_in_owner = true custom_minimum_size = Vector2(120, 36) layout_mode = 2 text = "End Now" -[node name="DeleteMailServerBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar"] +[node name="DeleteMailServerBtn" type="Button" parent="Margin/VBox/Tabs/Mail Manager/MailActionBar" unique_id=480113439] unique_name_in_owner = true custom_minimum_size = Vector2(120, 36) layout_mode = 2 text = "Delete" -[node name="StatusLabel" type="Label" parent="Margin/VBox"] -unique_name_in_owner = true -layout_mode = 2 -horizontal_alignment = 1 - -[node name="Shop" type="VBoxContainer" parent="Margin/VBox/Tabs"] +[node name="Shop" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=1663746802] visible = false layout_mode = 2 theme_override_constants/separation = 16 metadata/_tab_index = 5 -[node name="HeaderLbl" type="Label" parent="Margin/VBox/Tabs/Shop"] +[node name="HeaderLbl" type="Label" parent="Margin/VBox/Tabs/Shop" unique_id=629314273] layout_mode = 2 -text = "Featured Banner Slots (Event / Special)" theme_override_font_sizes/font_size = 15 +text = "Featured Banner Slots (Event / Special)" -[node name="InfoLbl" type="Label" parent="Margin/VBox/Tabs/Shop"] +[node name="InfoLbl" type="Label" parent="Margin/VBox/Tabs/Shop" unique_id=1265660269] layout_mode = 2 -autowrap_mode = 3 text = "Each slot shows a cosmetic item as a special event banner in the Shop sidebar. Leave Item ID blank to hide the slot." +autowrap_mode = 3 -[node name="SlotsVBox" type="VBoxContainer" parent="Margin/VBox/Tabs/Shop"] +[node name="SlotsVBox" type="VBoxContainer" parent="Margin/VBox/Tabs/Shop" unique_id=1496005796] unique_name_in_owner = true layout_mode = 2 theme_override_constants/separation = 12 -[node name="Slot1" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox"] +[node name="Slot1" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox" unique_id=2145460867] layout_mode = 2 theme_override_constants/separation = 10 -[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1"] +[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1" unique_id=1907194640] custom_minimum_size = Vector2(60, 0) layout_mode = 2 text = "Slot 1:" vertical_alignment = 1 -[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1"] +[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1" unique_id=1455276821] layout_mode = 2 size_flags_horizontal = 3 placeholder_text = "Item ID (e.g. oldpop-blue-hat)" -[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1"] +[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1" unique_id=806604635] custom_minimum_size = Vector2(160, 0) layout_mode = 2 placeholder_text = "Event label (e.g. LIMITED!)" -[node name="Slot2" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox"] +[node name="Slot2" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox" unique_id=1493005856] layout_mode = 2 theme_override_constants/separation = 10 -[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2"] +[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2" unique_id=573101771] custom_minimum_size = Vector2(60, 0) layout_mode = 2 text = "Slot 2:" vertical_alignment = 1 -[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2"] +[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2" unique_id=1748503110] layout_mode = 2 size_flags_horizontal = 3 placeholder_text = "Item ID (e.g. oldpop-red-hat)" -[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2"] +[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2" unique_id=1504360312] custom_minimum_size = Vector2(160, 0) layout_mode = 2 placeholder_text = "Event label" -[node name="Slot3" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox"] +[node name="Slot3" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox" unique_id=868105669] layout_mode = 2 theme_override_constants/separation = 10 -[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3"] +[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3" unique_id=564565213] custom_minimum_size = Vector2(60, 0) layout_mode = 2 text = "Slot 3:" vertical_alignment = 1 -[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3"] +[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3" unique_id=956708716] layout_mode = 2 size_flags_horizontal = 3 placeholder_text = "Item ID (e.g. oldpop-yellow-hat)" -[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3"] +[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3" unique_id=715444993] custom_minimum_size = Vector2(160, 0) layout_mode = 2 placeholder_text = "Event label" -[node name="ShopActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop"] +[node name="ShopActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop" unique_id=2071973475] layout_mode = 2 theme_override_constants/separation = 8 alignment = 2 -[node name="LoadBannersBtn" type="Button" parent="Margin/VBox/Tabs/Shop/ShopActionBar"] +[node name="LoadBannersBtn" type="Button" parent="Margin/VBox/Tabs/Shop/ShopActionBar" unique_id=429010019] unique_name_in_owner = true custom_minimum_size = Vector2(140, 36) layout_mode = 2 text = "Load Current" -[node name="SaveBannersBtn" type="Button" parent="Margin/VBox/Tabs/Shop/ShopActionBar"] +[node name="SaveBannersBtn" type="Button" parent="Margin/VBox/Tabs/Shop/ShopActionBar" unique_id=583339120] unique_name_in_owner = true custom_minimum_size = Vector2(160, 36) layout_mode = 2 -[node name="Lobby Chat" type="VBoxContainer" parent="Margin/VBox/Tabs"] +[node name="Lobby Chat" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=707175209] +visible = false layout_mode = 2 theme_override_constants/separation = 10 +metadata/_tab_index = 6 -[node name="PrefixRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat"] +[node name="PrefixRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat" unique_id=1979823156] layout_mode = 2 theme_override_constants/separation = 8 -[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/PrefixRow"] -layout_mode = 2 +[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/PrefixRow" unique_id=1610505794] custom_minimum_size = Vector2(220, 0) +layout_mode = 2 text = "System Prefix:" -[node name="PrefixEdit" type="LineEdit" parent="Margin/VBox/Tabs/Lobby Chat/PrefixRow"] +[node name="PrefixEdit" type="LineEdit" parent="Margin/VBox/Tabs/Lobby Chat/PrefixRow" unique_id=1229354911] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 placeholder_text = "[SERVER]" -[node name="MaxMsgRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat"] +[node name="MaxMsgRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat" unique_id=1333148469] layout_mode = 2 theme_override_constants/separation = 8 -[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/MaxMsgRow"] -layout_mode = 2 +[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/MaxMsgRow" unique_id=1345725816] custom_minimum_size = Vector2(220, 0) +layout_mode = 2 text = "Max messages loaded:" -[node name="MaxMsgSpin" type="SpinBox" parent="Margin/VBox/Tabs/Lobby Chat/MaxMsgRow"] +[node name="MaxMsgSpin" type="SpinBox" parent="Margin/VBox/Tabs/Lobby Chat/MaxMsgRow" unique_id=1271445635] unique_name_in_owner = true -layout_mode = 2 custom_minimum_size = Vector2(120, 0) +layout_mode = 2 min_value = 10.0 max_value = 200.0 step = 10.0 value = 50.0 -[node name="MaxAgeRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat"] +[node name="MaxAgeRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat" unique_id=1691464635] layout_mode = 2 theme_override_constants/separation = 8 -[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/MaxAgeRow"] -layout_mode = 2 +[node name="Label" type="Label" parent="Margin/VBox/Tabs/Lobby Chat/MaxAgeRow" unique_id=2146595304] custom_minimum_size = Vector2(220, 0) +layout_mode = 2 text = "Delete messages older than (days):" -[node name="MaxAgeSpin" type="SpinBox" parent="Margin/VBox/Tabs/Lobby Chat/MaxAgeRow"] +[node name="MaxAgeSpin" type="SpinBox" parent="Margin/VBox/Tabs/Lobby Chat/MaxAgeRow" unique_id=1658809503] unique_name_in_owner = true -layout_mode = 2 custom_minimum_size = Vector2(120, 0) -min_value = 0.0 -max_value = 365.0 -step = 1.0 -value = 0.0 +layout_mode = 2 tooltip_text = "0 = don't auto-delete, use manual purge only" +max_value = 365.0 -[node name="ChatActions" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat"] +[node name="ChatActions" type="HBoxContainer" parent="Margin/VBox/Tabs/Lobby Chat" unique_id=1452586888] layout_mode = 2 theme_override_constants/separation = 8 -[node name="WipeChatBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions"] +[node name="WipeChatBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions" unique_id=500711479] unique_name_in_owner = true custom_minimum_size = Vector2(140, 36) layout_mode = 2 text = "Wipe Chat" -[node name="PurgeOldBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions"] +[node name="PurgeOldBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions" unique_id=190131015] unique_name_in_owner = true custom_minimum_size = Vector2(140, 36) layout_mode = 2 text = "Purge Old" -[node name="SaveConfigBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions"] +[node name="SaveConfigBtn" type="Button" parent="Margin/VBox/Tabs/Lobby Chat/ChatActions" unique_id=157935610] unique_name_in_owner = true custom_minimum_size = Vector2(140, 36) layout_mode = 2 text = "Save Config" -[node name="ChatStatusLabel" type="Label" parent="Margin/VBox/Tabs/Lobby Chat"] +[node name="ChatStatusLabel" type="Label" parent="Margin/VBox/Tabs/Lobby Chat" unique_id=1210803106] unique_name_in_owner = true layout_mode = 2 -text = "" -[node name="Chat Storage" type="VBoxContainer" parent="Margin/VBox/Tabs"] +[node name="Chat Storage" type="VBoxContainer" parent="Margin/VBox/Tabs" unique_id=181772524] +layout_mode = 2 +theme_override_constants/separation = 8 +metadata/_tab_index = 7 + +[node name="ChannelIdRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Chat Storage" unique_id=242863643] layout_mode = 2 theme_override_constants/separation = 8 -[node name="ChannelIdRow" type="HBoxContainer" parent="Margin/VBox/Tabs/Chat Storage"] -layout_mode = 2 -theme_override_constants/separation = 8 - -[node name="Label" type="Label" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow"] +[node name="Label" type="Label" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow" unique_id=1799844455] layout_mode = 2 text = "Channel ID:" -[node name="ChannelIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow"] +[node name="ChannelIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow" unique_id=984574932] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 -placeholder_text = "Enter channel ID..." +text = "social_global" +placeholder_text = "social_global" -[node name="LoadMessagesBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow"] +[node name="LoadMessagesBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChannelIdRow" unique_id=1775485497] unique_name_in_owner = true custom_minimum_size = Vector2(100, 0) layout_mode = 2 text = "Load" -[node name="ChatTree" type="Tree" parent="Margin/VBox/Tabs/Chat Storage"] +[node name="ChatTree" type="Tree" parent="Margin/VBox/Tabs/Chat Storage" unique_id=412639164] unique_name_in_owner = true layout_mode = 2 size_flags_vertical = 3 @@ -662,32 +656,37 @@ allow_reselect = true hide_root = true select_mode = 1 -[node name="ChatStorageActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Chat Storage"] +[node name="ChatStorageActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Chat Storage" unique_id=738888311] layout_mode = 2 theme_override_constants/separation = 8 -[node name="RefreshChatBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar"] +[node name="RefreshChatBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar" unique_id=1962559221] unique_name_in_owner = true custom_minimum_size = Vector2(100, 36) layout_mode = 2 -text = "Refresh" +text = "Load More" -[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar"] +[node name="Spacer" type="Control" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar" unique_id=1699988791] layout_mode = 2 size_flags_horizontal = 3 -[node name="DeleteSelectedBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar"] +[node name="DeleteSelectedBtn" type="Button" parent="Margin/VBox/Tabs/Chat Storage/ChatStorageActionBar" unique_id=232174518] unique_name_in_owner = true custom_minimum_size = Vector2(120, 36) layout_mode = 2 text = "Delete Selected" -[node name="HistoryDialog" type="AcceptDialog" parent="."] +[node name="StatusLabel" type="Label" parent="Margin/VBox" unique_id=895343638] +unique_name_in_owner = true +layout_mode = 2 +horizontal_alignment = 1 + +[node name="HistoryDialog" type="AcceptDialog" parent="." unique_id=1324499735] unique_name_in_owner = true title = "User History" size = Vector2i(700, 500) -[node name="ScrollContainer" type="ScrollContainer" parent="HistoryDialog"] +[node name="ScrollContainer" type="ScrollContainer" parent="HistoryDialog" unique_id=1236659461] anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 @@ -698,10 +697,11 @@ offset_bottom = -49.0 grow_horizontal = 2 grow_vertical = 2 -[node name="HistoryText" type="RichTextLabel" parent="HistoryDialog/ScrollContainer"] +[node name="HistoryText" type="RichTextLabel" parent="HistoryDialog/ScrollContainer" unique_id=1203037388] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 size_flags_vertical = 3 focus_mode = 2 +bbcode_enabled = true selection_enabled = true diff --git a/scenes/ui/gacha_panel.tscn b/scenes/ui/gacha_panel.tscn index cf8bfe8..d2500ba 100644 --- a/scenes/ui/gacha_panel.tscn +++ b/scenes/ui/gacha_panel.tscn @@ -1,27 +1,11 @@ - [gd_scene format=3 uid="uid://gacha_panel_001"] +[gd_scene format=3 uid="uid://bjs7jhl7a85rh"] -[ext_resource type="Script" path="res://scripts/ui/gacha_panel.gd" id="1"] +[ext_resource type="Script" uid="uid://clkxaudy5hxfj" path="res://scripts/ui/gacha_panel.gd" id="1"] [ext_resource type="Theme" uid="uid://cxab3xxy00" path="res://assets/themes/GUI_Tekton.tres" id="2"] [ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="3_font"] [ext_resource type="Texture2D" uid="uid://jqvv6s55mlsk" path="res://assets/graphics/gui/BG.png" id="4_bg"] -[ext_resource type="Texture2D" uid="uid://b5pp08fke7ptd" path="res://assets/graphics/gui/lobby/gold.png" id="5_gold"] -[ext_resource type="Texture2D" uid="uid://d0ouvm3x8h42c" path="res://assets/graphics/gui/lobby/star.png" id="6_star"] -[ext_resource type="Texture2D" uid="uid://b6is65v4h87u8" path="res://assets/graphics/gui/lobby/star.png" id="tex_star"] -[ext_resource type="Texture2D" uid="uid://be5i65v4h87u7" path="res://assets/graphics/gui/lobby/gold.png" id="tex_gold"] - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_OuterPanel"] -content_margin_left = 12.0 -content_margin_top = 12.0 -content_margin_right = 12.0 -content_margin_bottom = 12.0 -bg_color = Color(0.14117648, 0.16862746, 0.19215687, 1) -corner_radius_top_left = 12 -corner_radius_top_right = 12 -corner_radius_bottom_right = 12 -corner_radius_bottom_left = 12 -shadow_color = Color(0, 0, 0, 0.3529412) -shadow_size = 4 -shadow_offset = Vector2(-2, 2) +[ext_resource type="Texture2D" uid="uid://b5pp08fke7ptd" path="res://assets/graphics/gui/lobby/gold.png" id="tex_gold"] +[ext_resource type="Texture2D" uid="uid://d0ouvm3x8h42c" path="res://assets/graphics/gui/lobby/star.png" id="tex_star"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_InnerDark"] bg_color = Color(0.1, 0.1, 0.1, 1) @@ -30,14 +14,6 @@ corner_radius_top_right = 6 corner_radius_bottom_right = 6 corner_radius_bottom_left = 6 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gd4oi"] -bg_color = Color(0, 0, 0, 0.48235294) -border_color = Color(0.92941177, 0.91764706, 0.8862745, 1) -corner_radius_top_left = 3 -corner_radius_top_right = 3 -corner_radius_bottom_right = 3 -corner_radius_bottom_left = 3 - [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_tab_inactive"] content_margin_left = 16.0 content_margin_top = 14.0 @@ -60,28 +36,29 @@ corner_radius_top_right = 8 corner_radius_bottom_right = 8 corner_radius_bottom_left = 8 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_banner_tab_inactive"] -bg_color = Color(0.33, 0.62, 0.78, 1) -corner_radius_top_left = 8 -corner_radius_top_right = 8 -corner_radius_bottom_right = 8 -corner_radius_bottom_left = 8 +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_OuterPanel"] +content_margin_left = 12.0 +content_margin_top = 12.0 +content_margin_right = 12.0 +content_margin_bottom = 12.0 +bg_color = Color(0.14117648, 0.16862746, 0.19215687, 1) +corner_radius_top_left = 12 +corner_radius_top_right = 12 +corner_radius_bottom_right = 12 +corner_radius_bottom_left = 12 +shadow_color = Color(0, 0, 0, 0.3529412) +shadow_size = 4 +shadow_offset = Vector2(-2, 2) -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_banner_tab_active"] -bg_color = Color(0.1, 0.19, 0.27, 1) -corner_radius_top_left = 8 -corner_radius_top_right = 8 -corner_radius_bottom_right = 8 -corner_radius_bottom_left = 8 +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gd4oi"] +bg_color = Color(0, 0, 0, 0.48235294) +border_color = Color(0.92941177, 0.91764706, 0.8862745, 1) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_BtnDark"] -bg_color = Color(0.15, 0.15, 0.15, 1) -corner_radius_top_left = 8 -corner_radius_top_right = 8 -corner_radius_bottom_right = 8 -corner_radius_bottom_left = 8 - -[node name="GachaPanel" type="Control"] +[node name="GachaPanel" type="Control" unique_id=1349292500] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 @@ -91,7 +68,7 @@ grow_vertical = 2 theme = ExtResource("2") script = ExtResource("1") -[node name="Background" type="ColorRect" parent="."] +[node name="Background" type="ColorRect" parent="." unique_id=1532983361] visible = false layout_mode = 1 anchors_preset = 15 @@ -101,7 +78,7 @@ grow_horizontal = 2 grow_vertical = 2 color = Color(0.0627451, 0.0745098, 0.101961, 1) -[node name="BackgroundTexture" type="TextureRect" parent="."] +[node name="BackgroundTexture" type="TextureRect" parent="." unique_id=1663610089] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 @@ -111,7 +88,7 @@ grow_vertical = 2 texture = ExtResource("4_bg") expand_mode = 2 -[node name="MainMargin" type="MarginContainer" parent="."] +[node name="MainMargin" type="MarginContainer" parent="." unique_id=139511059] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 @@ -123,50 +100,50 @@ theme_override_constants/margin_top = 20 theme_override_constants/margin_right = 28 theme_override_constants/margin_bottom = 20 -[node name="MainVBox" type="VBoxContainer" parent="MainMargin"] +[node name="MainVBox" type="VBoxContainer" parent="MainMargin" unique_id=691618538] layout_mode = 2 theme_override_constants/separation = 14 -[node name="TopBar" type="HBoxContainer" parent="MainMargin/MainVBox"] +[node name="TopBar" type="HBoxContainer" parent="MainMargin/MainVBox" unique_id=1625524615] layout_mode = 2 theme_override_constants/separation = 10 -[node name="BackBtn" type="Button" parent="MainMargin/MainVBox/TopBar"] +[node name="BackBtn" type="Button" parent="MainMargin/MainVBox/TopBar" unique_id=1391183972] unique_name_in_owner = true custom_minimum_size = Vector2(44, 44) layout_mode = 2 text = "←" -[node name="TitleLabel" type="Label" parent="MainMargin/MainVBox/TopBar"] +[node name="TitleLabel" type="Label" parent="MainMargin/MainVBox/TopBar" unique_id=1690585510] layout_mode = 2 -theme_override_colors/font_color = Color(1.0, 0.85, 0.25, 1) +theme_override_colors/font_color = Color(1, 0.85, 0.25, 1) theme_override_fonts/font = ExtResource("3_font") theme_override_font_sizes/font_size = 26 text = "✨ Gacha" -[node name="CurrencyRow" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar"] +[node name="CurrencyRow" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar" unique_id=460847703] layout_mode = 2 size_flags_horizontal = 3 theme_override_constants/separation = 8 alignment = 2 -[node name="StarPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow"] +[node name="StarPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow" unique_id=1836425268] custom_minimum_size = Vector2(100, 0) layout_mode = 2 theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark") -[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel"] +[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel" unique_id=1930556005] layout_mode = 2 theme_override_constants/margin_left = 6 theme_override_constants/margin_top = 4 theme_override_constants/margin_right = 6 theme_override_constants/margin_bottom = 4 -[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin"] +[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin" unique_id=187167010] layout_mode = 2 theme_override_constants/separation = 4 -[node name="Icon" type="TextureRect" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin/HBox"] +[node name="Icon" type="TextureRect" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin/HBox" unique_id=1225334011] custom_minimum_size = Vector2(20, 20) layout_mode = 2 size_flags_vertical = 4 @@ -174,7 +151,7 @@ texture = ExtResource("tex_star") expand_mode = 1 stretch_mode = 5 -[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin/HBox"] +[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin/HBox" unique_id=522439690] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 @@ -183,23 +160,23 @@ theme_override_font_sizes/font_size = 14 text = "0" horizontal_alignment = 2 -[node name="GoldPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow"] +[node name="GoldPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow" unique_id=35144569] custom_minimum_size = Vector2(100, 0) layout_mode = 2 theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark") -[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel"] +[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel" unique_id=1920706991] layout_mode = 2 theme_override_constants/margin_left = 6 theme_override_constants/margin_top = 4 theme_override_constants/margin_right = 6 theme_override_constants/margin_bottom = 4 -[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin"] +[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin" unique_id=1860851243] layout_mode = 2 theme_override_constants/separation = 4 -[node name="Icon" type="TextureRect" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin/HBox"] +[node name="Icon" type="TextureRect" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin/HBox" unique_id=1342503990] custom_minimum_size = Vector2(20, 20) layout_mode = 2 size_flags_vertical = 4 @@ -207,7 +184,7 @@ texture = ExtResource("tex_gold") expand_mode = 1 stretch_mode = 5 -[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin/HBox"] +[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin/HBox" unique_id=1096459165] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 @@ -216,86 +193,86 @@ theme_override_font_sizes/font_size = 14 text = "0" horizontal_alignment = 2 -[node name="CraftBtn" type="Button" parent="MainMargin/MainVBox/TopBar"] +[node name="CraftBtn" type="Button" parent="MainMargin/MainVBox/TopBar" unique_id=922059235] unique_name_in_owner = true custom_minimum_size = Vector2(130, 40) layout_mode = 2 -theme_override_colors/font_color = Color(0.4, 1.0, 0.7, 1) +theme_override_colors/font_color = Color(0.4, 1, 0.7, 1) theme_override_fonts/font = ExtResource("3_font") theme_override_font_sizes/font_size = 14 theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive") -theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive") theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active") +theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive") text = "🧩 Fragment Craft" -[node name="BannerTabs" type="HBoxContainer" parent="MainMargin/MainVBox"] +[node name="BannerTabs" type="HBoxContainer" parent="MainMargin/MainVBox" unique_id=36917097] layout_mode = 2 theme_override_constants/separation = 12 alignment = 1 -[node name="StarTabBtn" type="Button" parent="MainMargin/MainVBox/BannerTabs"] +[node name="StarTabBtn" type="Button" parent="MainMargin/MainVBox/BannerTabs" unique_id=267357394] unique_name_in_owner = true custom_minimum_size = Vector2(130, 38) layout_mode = 2 theme_override_fonts/font = ExtResource("3_font") theme_override_font_sizes/font_size = 14 theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive") -theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive") theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active") +theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive") text = "✦ Star Banner" -[node name="GoldTabBtn" type="Button" parent="MainMargin/MainVBox/BannerTabs"] +[node name="GoldTabBtn" type="Button" parent="MainMargin/MainVBox/BannerTabs" unique_id=1043151664] unique_name_in_owner = true custom_minimum_size = Vector2(130, 38) layout_mode = 2 theme_override_fonts/font = ExtResource("3_font") theme_override_font_sizes/font_size = 14 theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive") -theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive") theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active") +theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive") text = "▤ Gold Banner" -[node name="ContentHBox" type="HBoxContainer" parent="MainMargin/MainVBox"] +[node name="ContentHBox" type="HBoxContainer" parent="MainMargin/MainVBox" unique_id=1498567570] layout_mode = 2 size_flags_vertical = 3 theme_override_constants/separation = 20 -[node name="LeftPanel" type="PanelContainer" parent="MainMargin/MainVBox/ContentHBox"] +[node name="LeftPanel" type="PanelContainer" parent="MainMargin/MainVBox/ContentHBox" unique_id=1542327161] custom_minimum_size = Vector2(320, 0) layout_mode = 2 theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel") -[node name="LeftMargin" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel"] +[node name="LeftMargin" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel" unique_id=443861949] layout_mode = 2 theme_override_constants/margin_left = 18 theme_override_constants/margin_top = 18 theme_override_constants/margin_right = 18 theme_override_constants/margin_bottom = 18 -[node name="LeftVBox" type="VBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin"] +[node name="LeftVBox" type="VBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin" unique_id=1376947326] layout_mode = 2 theme_override_constants/separation = 14 -[node name="BannerLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"] +[node name="BannerLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=128145824] unique_name_in_owner = true layout_mode = 2 -theme_override_colors/font_color = Color(1.0, 0.85, 0.25, 1) +theme_override_colors/font_color = Color(1, 0.85, 0.25, 1) theme_override_fonts/font = ExtResource("3_font") theme_override_font_sizes/font_size = 22 text = "Star Banner" horizontal_alignment = 1 -[node name="BalanceRow" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"] +[node name="BalanceRow" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=908965342] layout_mode = 2 theme_override_constants/separation = 6 alignment = 1 -[node name="GoldPanel" type="Panel" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow"] +[node name="GoldPanel" type="Panel" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow" unique_id=1862665063] custom_minimum_size = Vector2(100, 30) layout_mode = 2 theme_override_styles/panel = SubResource("StyleBoxFlat_gd4oi") -[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel"] +[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel" unique_id=375107954] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 @@ -307,17 +284,16 @@ theme_override_constants/margin_top = 3 theme_override_constants/margin_right = 6 theme_override_constants/margin_bottom = 3 -[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer"] +[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer" unique_id=21246450] layout_mode = 2 -[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer/HBoxContainer"] +[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer/HBoxContainer" unique_id=65278254] layout_mode = 2 size_flags_horizontal = 0 size_flags_vertical = 4 -texture = ExtResource("5_gold") +texture = ExtResource("tex_gold") -[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer/HBoxContainer"] -unique_name_in_owner = true +[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer/HBoxContainer" unique_id=1159494172] layout_mode = 2 size_flags_horizontal = 3 theme_override_fonts/font = ExtResource("3_font") @@ -325,12 +301,12 @@ theme_override_font_sizes/font_size = 18 text = "0" horizontal_alignment = 2 -[node name="StarPanel" type="Panel" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow"] +[node name="StarPanel" type="Panel" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow" unique_id=193442837] custom_minimum_size = Vector2(100, 30) layout_mode = 2 theme_override_styles/panel = SubResource("StyleBoxFlat_gd4oi") -[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel"] +[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel" unique_id=506401407] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 @@ -342,17 +318,16 @@ theme_override_constants/margin_top = 3 theme_override_constants/margin_right = 6 theme_override_constants/margin_bottom = 3 -[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer"] +[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer" unique_id=51102528] layout_mode = 2 -[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer/HBoxContainer"] +[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer/HBoxContainer" unique_id=1476183321] layout_mode = 2 size_flags_horizontal = 0 size_flags_vertical = 4 -texture = ExtResource("6_star") +texture = ExtResource("tex_star") -[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer/HBoxContainer"] -unique_name_in_owner = true +[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer/HBoxContainer" unique_id=1357103504] layout_mode = 2 size_flags_horizontal = 3 theme_override_fonts/font = ExtResource("3_font") @@ -360,7 +335,7 @@ theme_override_font_sizes/font_size = 18 text = "0" horizontal_alignment = 2 -[node name="BalanceLbl" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow"] +[node name="BalanceLbl" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow" unique_id=1997926367] visible = false layout_mode = 2 theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1) @@ -368,7 +343,7 @@ theme_override_font_sizes/font_size = 13 text = "Balance:" horizontal_alignment = 1 -[node name="BalanceLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow"] +[node name="BalanceLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow" unique_id=1743519745] unique_name_in_owner = true visible = false layout_mode = 2 @@ -378,7 +353,7 @@ theme_override_font_sizes/font_size = 18 text = "✦ 0" horizontal_alignment = 2 -[node name="PityLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"] +[node name="PityLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=379092911] unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.6, 0.6, 0.9, 1) @@ -386,14 +361,14 @@ theme_override_font_sizes/font_size = 13 text = "Pity: 0 / 90" horizontal_alignment = 1 -[node name="HSep1" type="HSeparator" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"] +[node name="HSep1" type="HSeparator" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=2009144054] layout_mode = 2 -[node name="Pull1Row" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"] +[node name="Pull1Row" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=1253450919] layout_mode = 2 theme_override_constants/separation = 8 -[node name="Pull1Btn" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull1Row"] +[node name="Pull1Btn" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull1Row" unique_id=1313297675] unique_name_in_owner = true custom_minimum_size = Vector2(0, 44) layout_mode = 2 @@ -401,11 +376,11 @@ size_flags_horizontal = 3 theme_override_fonts/font = ExtResource("3_font") theme_override_font_sizes/font_size = 16 theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive") -theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive") theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active") +theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive") text = "1× Pull" -[node name="Cost1Label" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull1Row"] +[node name="Cost1Label" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull1Row" unique_id=151347195] unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.9, 0.75, 0.2, 1) @@ -413,11 +388,11 @@ theme_override_fonts/font = ExtResource("3_font") theme_override_font_sizes/font_size = 14 text = "✦ 160" -[node name="Pull10Row" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"] +[node name="Pull10Row" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=1677571152] layout_mode = 2 theme_override_constants/separation = 8 -[node name="Pull10Btn" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull10Row"] +[node name="Pull10Btn" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull10Row" unique_id=1681522273] unique_name_in_owner = true custom_minimum_size = Vector2(0, 44) layout_mode = 2 @@ -425,11 +400,11 @@ size_flags_horizontal = 3 theme_override_fonts/font = ExtResource("3_font") theme_override_font_sizes/font_size = 16 theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive") -theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive") theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active") +theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive") text = "10× Pull" -[node name="Cost10Label" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull10Row"] +[node name="Cost10Label" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull10Row" unique_id=191013010] unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.9, 0.75, 0.2, 1) @@ -437,41 +412,37 @@ theme_override_fonts/font = ExtResource("3_font") theme_override_font_sizes/font_size = 14 text = "✦ 1440" -[node name="StatusLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"] +[node name="StatusLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox" unique_id=447296799] unique_name_in_owner = true layout_mode = 2 -theme_override_colors/font_color = Color(1.0, 1.0, 1.0, 1) +theme_override_colors/font_color = Color(1, 1, 1, 1) theme_override_font_sizes/font_size = 13 horizontal_alignment = 1 -text = "" -[node name="RightPanel" type="PanelContainer" parent="MainMargin/MainVBox/ContentHBox"] +[node name="RightPanel" type="PanelContainer" parent="MainMargin/MainVBox/ContentHBox" unique_id=208396625] layout_mode = 2 size_flags_horizontal = 3 theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel") -[node name="RightMargin" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/RightPanel"] +[node name="RightMargin" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/RightPanel" unique_id=988364101] layout_mode = 2 theme_override_constants/margin_left = 16 theme_override_constants/margin_top = 16 theme_override_constants/margin_right = 16 theme_override_constants/margin_bottom = 16 -[node name="RatesLabel" type="RichTextLabel" parent="MainMargin/MainVBox/ContentHBox/RightPanel/RightMargin"] +[node name="RatesLabel" type="RichTextLabel" parent="MainMargin/MainVBox/ContentHBox/RightPanel/RightMargin" unique_id=436846225] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 size_flags_vertical = 3 theme_override_colors/default_color = Color(0.75, 0.78, 0.82, 1) -theme_override_font_sizes/font_size = 13 -text = "" fit_content = true -[node name="ResultPanel" type="PanelContainer" parent="."] +[node name="ResultPanel" type="PanelContainer" parent="." unique_id=1303462967] unique_name_in_owner = true visible = false layout_mode = 1 -theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel") anchors_preset = 8 anchor_left = 0.5 anchor_top = 0.5 @@ -483,12 +454,13 @@ offset_right = 450.0 offset_bottom = 260.0 grow_horizontal = 2 grow_vertical = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel") -[node name="ResultVBox" type="VBoxContainer" parent="ResultPanel"] +[node name="ResultVBox" type="VBoxContainer" parent="ResultPanel" unique_id=893629253] layout_mode = 2 theme_override_constants/separation = 10 -[node name="ResultTitle" type="Label" parent="ResultPanel/ResultVBox"] +[node name="ResultTitle" type="Label" parent="ResultPanel/ResultVBox" unique_id=1336286642] layout_mode = 2 theme_override_colors/font_color = Color(1, 0.9, 0.3, 1) theme_override_fonts/font = ExtResource("3_font") @@ -496,12 +468,12 @@ theme_override_font_sizes/font_size = 20 text = "Pull Results" horizontal_alignment = 1 -[node name="ScrollContainer" type="ScrollContainer" parent="ResultPanel/ResultVBox"] +[node name="ScrollContainer" type="ScrollContainer" parent="ResultPanel/ResultVBox" unique_id=411371159] +custom_minimum_size = Vector2(0, 160) layout_mode = 2 size_flags_vertical = 3 -custom_minimum_size = Vector2(0, 160) -[node name="ResultGrid" type="GridContainer" parent="ResultPanel/ResultVBox/ScrollContainer"] +[node name="ResultGrid" type="GridContainer" parent="ResultPanel/ResultVBox/ScrollContainer" unique_id=1917152748] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 @@ -509,12 +481,12 @@ theme_override_constants/h_separation = 8 theme_override_constants/v_separation = 8 columns = 5 -[node name="CloseResultBtn" type="Button" parent="ResultPanel/ResultVBox"] +[node name="CloseResultBtn" type="Button" parent="ResultPanel/ResultVBox" unique_id=1215289477] unique_name_in_owner = true custom_minimum_size = Vector2(0, 38) layout_mode = 2 theme_override_fonts/font = ExtResource("3_font") theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive") -theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive") theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active") +theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive") text = "Close" diff --git a/scratch_lobby.txt b/scratch_lobby.txt deleted file mode 100644 index fc24dc3fdcd928bdf51e0009f6d4429e1a9989c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 176158 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 H&9C}DR>P?3 diff --git a/scripts/game_mode.gd b/scripts/game_mode.gd index 4cc987f..7525039 100644 --- a/scripts/game_mode.gd +++ b/scripts/game_mode.gd @@ -16,7 +16,7 @@ static func from_string(mode: String) -> Mode: return Mode.STOP_N_GO "Tekton Doors": return Mode.TEKTON_DOORS - "Candy Cannon Survival": + "Candy Pump Survival": return Mode.GAUNTLET _: return Mode.FREEMODE @@ -30,7 +30,7 @@ static func mode_to_string(mode: Mode) -> String: Mode.TEKTON_DOORS: return "Tekton Doors" Mode.GAUNTLET: - return "Candy Cannon Survival" + return "Candy Pump Survival" _: return "Freemode" @@ -38,4 +38,4 @@ static func is_restricted(mode: Mode) -> bool: return mode == Mode.STOP_N_GO or mode == Mode.TEKTON_DOORS or mode == Mode.GAUNTLET static func get_all_modes() -> Array[String]: - return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Cannon Survival"] + return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival"] diff --git a/scripts/managers/camera_context_manager.gd b/scripts/managers/camera_context_manager.gd index df2e5c0..fd0f12e 100644 --- a/scripts/managers/camera_context_manager.gd +++ b/scripts/managers/camera_context_manager.gd @@ -12,11 +12,12 @@ var player: Node3D @export var z_offset: float = 12.0 @export var default_y: float = 19.636 +var bounds_gauntlet = { "min_x": 0.0, "max_x": 20.0, "min_z": 10.0, "max_z": 32.0 } + # Bounds Definitions { min_x, max_x, min_z, max_z } var bounds_freemode = { "min_x": 3.0, "max_x": 11.0, "min_z": 13.0, "max_z": 22.5 } var bounds_stop_n_go = { "min_x": 3.0, "max_x": 19.5, "min_z": 13.0, "max_z": 19.5 } var bounds_doors = { "min_x": 7.0, "max_x": 7.0, "min_z": 25.8, "max_z": 25.8 } # Static overlook -var bounds_gauntlet = { "min_x": 0.0, "max_x": 20.0, "min_z": 0.0, "max_z": 20.0 } # 20x20 arena func initialize(p_camera: Camera3D, _p_shake_manager: Node): camera = p_camera @@ -29,38 +30,38 @@ func set_player(p_player: Node3D): func _physics_process(delta): if not player or not camera or not is_instance_valid(player): return - + var target_pos = _calculate_target_position() - + # Smoothly interpolate to target camera.position = camera.position.lerp(target_pos, smooth_speed * delta) func _calculate_target_position() -> Vector3: var player_pos = player.global_position - + var mode = LobbyManager.get_game_mode() + # Initial target based on player position + offsets var target_x = player_pos.x var target_y = default_y var target_z = player_pos.z + z_offset - + # Apply Mode-Specific Clamping - var mode = LobbyManager.get_game_mode() var bounds = bounds_freemode # Default - - if mode == GameMode.Mode.STOP_N_GO: + + if mode == GameMode.Mode.GAUNTLET: + bounds = bounds_gauntlet + elif mode == GameMode.Mode.STOP_N_GO: bounds = bounds_stop_n_go elif mode == GameMode.Mode.TEKTON_DOORS: bounds = bounds_doors target_y = 32.3 # Doors uses a higher overlook - elif mode == GameMode.Mode.GAUNTLET: - bounds = bounds_gauntlet - + # Clamp X and Z target_x = clamp(target_x, bounds.min_x, bounds.max_x) target_z = clamp(target_z, bounds.min_z, bounds.max_z) - + # Special case for Setup C in Freemode (Lower Y at bottom edges) if mode == GameMode.Mode.FREEMODE and target_z > 21.0: target_y = 19.22636 - + return Vector3(target_x, target_y, target_z) diff --git a/scripts/managers/gauntlet_manager.gd b/scripts/managers/gauntlet_manager.gd index 4727185..22b16f4 100644 --- a/scripts/managers/gauntlet_manager.gd +++ b/scripts/managers/gauntlet_manager.gd @@ -1,11 +1,11 @@ extends Node class_name GauntletManager -# GauntletManager - Handles Candy Cannon Survival (Gauntlet) game mode +# GauntletManager - Handles Candy Pump Survival (Gauntlet) game mode # Pattern: StopNGoManager + PortalModeManager signal phase_changed(phase_index: int, phase_name: String) -signal cannon_fired(targets: Array) +signal growth_tick(cells: Array) signal player_trapped(player_id: int) signal cleanser_granted(player_id: int) @@ -24,6 +24,20 @@ const TILE_OBSTACLE: int = 4 const TILE_STICKY: int = 17 # New candy-pink overlay (Layer 2) const TILE_TELEGRAPH: int = 18 # Warning glow (Layer 2, temporary) +# Cell states (v2 ground-growth model). Logical state of each playable cell. +enum CellState { + SAFE, # Can be entered, crossed, collected + TELEGRAPHED, # Warned as future sticky, still passable (1s) + STICKY, # Covered in sticky candy, blocks + traps + BUBBLE_GROWING, # Candy bubble growing, not yet exploded + BLOCKED, # NPC zone or permanent obstacle + CLEANSED, # Recently cleaned by Cleanser (temp protection) +} + +# Cells temporarily protected after a Cleanser pass (Vector2i -> time remaining). +var cleansed_cells: Dictionary = {} +const CLEANSED_PROTECTION_TIME: float = 5.0 + # Phase timing thresholds (seconds elapsed) const PHASE_1_START: float = 0.0 # Open Arena const PHASE_2_START: float = 60.0 # Route Pressure @@ -39,23 +53,61 @@ var elapsed_time: float = 0.0 var is_active: bool = false # ============================================================================= -# Cannon State +# Growth State (v2 ground-growth model — replaces cannon volley) # ============================================================================= -var cannon_timer: float = 0.0 -var cannon_interval: float = 5.0 # seconds between volleys -var volley_size: int = 5 +var growth_timer: float = 0.0 +var growth_interval: float = 3.0 # seconds between growth ticks +var telegraph_duration: float = 1.0 # seconds telegraphed cells stay passable var sticky_cells: Dictionary = {} # Vector2i → true -var last_targeted_player_id: int = -1 +var telegraphed_cells: Dictionary = {} # Vector2i → time remaining (still passable) +var _last_tick_cells: Array = [] # cells selected last tick (for repetition penalty) -# Phase-specific cannon parameters -var phase_configs: Array = [ - # Phase 0 (Open Arena): slow, small volleys - {"interval": 5.0, "volley": 5, "telegraph_time": 1.2}, - # Phase 1 (Route Pressure): faster, bigger volleys - {"interval": 4.0, "volley": 8, "telegraph_time": 1.0}, - # Phase 2 (Survival Endgame): rapid fire, huge volleys - {"interval": 3.0, "volley": 12, "telegraph_time": 0.8}, +# Camping detection (#073): time each player has spent in their current 4x4 +# region. player_id -> {"region": Vector2i, "time": float}. +var _camp_tracking: Dictionary = {} +const CAMP_REGION_SIZE: int = 4 + +# Movement buffers (#083): hidden, decaying penalties on SAFE cells that form +# critical movement corridors. Detected dynamically each growth tick; never +# shown to players. pos(Vector2i) -> {"penalty": float, "adjacent": bool}. +# The penalty discourages the growth algorithm from sealing off a corridor too +# early, then fades over time / phases so the arena still closes in by the end. +var movement_buffers: Dictionary = {} +var _buffer_decay_timer: float = 0.0 +const BUFFER_DECAY_INTERVAL: float = 5.0 # seconds between decay steps +const BUFFER_DECAY_FACTOR: float = 0.75 # each step keeps 75% (−25%) +const BUFFER_PHASE_DECAY: float = 0.5 # phase change halves all penalties +const BUFFER_MIN_PENALTY: float = 4.0 # prune below this magnitude +# Base "inside a buffer corridor" penalty per phase (adjacent = half). +const BUFFER_BASE_PENALTY: Array = [40.0, 20.0, 10.0] +# A SAFE cell is a corridor if removing it drops a player's reachable region +# below this many cells (i.e. it is a genuine chokepoint, not open floor). +const BUFFER_CORRIDOR_THRESHOLD: int = 12 + +# Candy bubbles (#082): occasional anti-camping hazards that grow from 1x1 and +# explode into a 3x3 sticky area. Separate from normal ground growth. +# active_bubbles entries: {"center": Vector2i, "timer": float, "cells": Array}. +var active_bubbles: Array = [] +var bubble_cells: Dictionary = {} # Vector2i -> true (BUBBLE_GROWING state) +var recent_bubble_positions: Array = [] # centers of recent bubbles (anti-stacking) +var bubbles_this_phase: int = 0 # spawned in the current phase +var bubbles_total: int = 0 # spawned this round +const MAX_BUBBLES_PER_PHASE: Array = [0, 2, 3] # phase 1 / 2 / 3 +const BUBBLE_GROW_DURATION: float = 2.75 # seconds from spawn to explosion (2.5–3) +const BUBBLE_EXPLOSION_RADIUS: int = 1 # 1 => 3x3 area +const BUBBLE_RECENT_MEMORY: int = 4 # how many recent centers to remember +const BUBBLE_RECENT_RADIUS: int = 3 # anti-stacking exclusion distance + +# Phase-specific growth parameters (cells-per-tick range per phase). +# Layer weights: [outer, middle, inner] priority for the current pressure layer. +var phase_growth_config: Array = [ + # Phase 0 (Outer Pressure): 4-6 cells/tick, push from the outside in + {"cells_min": 4, "cells_max": 6, "layer_weights": {"outer": 60, "middle": 15, "inner": -40}}, + # Phase 1 (Middle Pressure): 6-8 cells/tick + {"cells_min": 6, "cells_max": 8, "layer_weights": {"outer": 20, "middle": 60, "inner": 5}}, + # Phase 2 (Inner Survival): 8-10 cells/tick + {"cells_min": 8, "cells_max": 10, "layer_weights": {"outer": 10, "middle": 35, "inner": 60}}, ] # ============================================================================= @@ -108,7 +160,10 @@ const CLEANSER_ACTIVATION_DELAY: float = 0.3 # Trapped Players # ============================================================================= -var trapped_players: Dictionary = {} # player_id → true +var trapped_players: Dictionary = {} # player_id → true (legacy; sticky now slows) + +# Sticky entry slows the player instead of trapping them (per-player, fair in MP). +const STICKY_SLOW_DURATION: float = 2.0 # ============================================================================= # Slow-Mo Effect @@ -126,8 +181,10 @@ var slowmo_overlay: ColorRect = null var main_scene: Node = null var gridmap: Node = null -var candy_cannon_scene: PackedScene = preload("res://scenes/candy_cannon.tscn") -var cannon_instance: Node3D = null +# Static Candy Pump NPC model at the arena center (the v2 "pump" that injects +# candy into the ground). Purely visual now — projectile logic was removed. +var candy_pump_scene: PackedScene = preload("res://scenes/candy_cannon.tscn") +var pump_instance: Node3D = null # HUD var hud_layer: CanvasLayer @@ -177,11 +234,24 @@ func _process(delta: float) -> void: # Server only logic if multiplayer.is_server(): - # Cannon timer - cannon_timer -= delta - if cannon_timer <= 0.0: - _fire_volley() - cannon_timer = cannon_interval + # Track camping behaviour for candidate scoring (#073) + _update_camp_tracking(delta) + + # Growth tick timer + growth_timer -= delta + if growth_timer <= 0.0: + _process_growth_tick() + growth_timer = growth_interval + + # Decay cleansed-cell protection windows + if not cleansed_cells.is_empty(): + _tick_cleansed_cells(delta) + + # Decay hidden movement buffers over time (#083) + _decay_movement_buffers(delta) + + # Advance candy-bubble grow timers; explode when ready (#082) + _update_bubbles(delta) # Smack mechanic update (ALL PEERS) var all_players = get_tree().get_nodes_in_group("Players") @@ -255,11 +325,18 @@ func _check_phase_transition() -> void: func _start_phase(phase: Phase) -> void: current_phase = phase - var config = phase_configs[int(phase)] - cannon_interval = config["interval"] - volley_size = config["volley"] - cannon_timer = cannon_interval - + # Growth config is read per-tick from phase_growth_config[current_phase]; + # resetting the timer keeps tick cadence aligned to the phase boundary. + growth_timer = growth_interval + + # Phase change relaxes movement buffers by 50% — the arena is allowed to + # close in more aggressively as pressure escalates (#083). + if not movement_buffers.is_empty(): + _scale_all_buffers(BUFFER_PHASE_DECAY) + + # Reset the per-phase candy-bubble budget (#082). + bubbles_this_phase = 0 + var phase_name = _phase_to_string(phase) print("[Gauntlet] Phase changed to: ", phase_name) @@ -271,11 +348,11 @@ func _start_phase(phase: Phase) -> void: func _phase_to_string(phase: Phase) -> String: match phase: Phase.OPEN_ARENA: - return "Open Arena" + return "Outer Pressure" Phase.ROUTE_PRESSURE: - return "Route Pressure" + return "Middle Pressure" Phase.SURVIVAL_ENDGAME: - return "Survival!" + return "Inner Survival" _: return "Unknown" @@ -284,9 +361,6 @@ func sync_phase(phase_index: int, phase_name: String) -> void: if not is_active: activate_client_side() current_phase = phase_index as Phase - var config = phase_configs[phase_index] - cannon_interval = config["interval"] - volley_size = config["volley"] _update_hud_phase(phase_name) # ============================================================================= @@ -337,7 +411,7 @@ func _apply_arena_setup() -> void: for z in range(ARENA_ROWS): var pos = Vector2i(x, z) - # Center 3x3 block: NPC obstacle (Candy Cannon) + # Center 3x3 block: NPC obstacle (Candy Pump) if _is_npc_zone(pos): gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE) gridmap.set_cell_item(Vector3i(x, 1, z), -1) @@ -357,13 +431,13 @@ func _apply_arena_setup() -> void: gridmap.update_grid_data() gridmap.initialize_astar() - if not cannon_instance and main_scene: - cannon_instance = candy_cannon_scene.instantiate() - cannon_instance.name = "CandyCannon" + if not pump_instance and main_scene: + pump_instance = candy_pump_scene.instantiate() + pump_instance.name = "CandyPump" var cx = NPC_CENTER.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0 var cz = NPC_CENTER.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 - cannon_instance.position = Vector3(cx, 0, cz) - main_scene.add_child(cannon_instance) + pump_instance.position = Vector3(cx, 0, cz) + main_scene.add_child(pump_instance) print("[Gauntlet] Arena setup complete. Boundary walls at perimeter. Center NPC at (%d,%d)" % [ NPC_CENTER.x, NPC_CENTER.y @@ -433,7 +507,7 @@ func _spawn_mission_tiles() -> void: for z in range(ARENA_ROWS): var pos = Vector2i(x, z) - # Skip NPC cannon zone (center 3x3) + # Skip NPC pump zone (center 3x3) if _is_npc_zone(pos): continue @@ -463,234 +537,400 @@ func _spawn_mission_tiles() -> void: print("[Gauntlet] Spawned %d mission tiles across %dx%d arena" % [tiles_spawned, ARENA_COLUMNS, ARENA_ROWS]) # ============================================================================= -# Cannon Logic (Server Only) +# Growth Logic (Server Only) — v2 ground-growth, replaces cannon volley # ============================================================================= -func _fire_volley() -> void: - """Select target cells, highlight, telegraph, then apply sticky after delay.""" +func _process_growth_tick() -> void: + """One growth tick: score SAFE cells, weight-select, path-check, telegraph.""" if not multiplayer.is_server(): return - - var targets = _select_targets() - if targets.is_empty(): - return - - var config = phase_configs[int(current_phase)] - var telegraph_time = config["telegraph_time"] - var highlight_time: float = 0.8 # Floor highlight duration before telegraph - - # Highlight phase — show pulsing floor warning BEFORE telegraph - if _can_rpc(): - rpc("sync_telegraph_highlight", targets) - await get_tree().create_timer(highlight_time).timeout - - # Telegraph phase — show warning overlay - if _can_rpc(): - rpc("sync_telegraph", targets) - - # Shoot projectiles visually with 0.1s offset between shots - if cannon_instance and cannon_instance.has_method("spawn_projectile_rpc") and cannon_instance.can_rpc(): - var cs = gridmap.cell_size - for i in range(targets.size()): - var target = targets[i] - var target_pos = Vector3(target.x * cs.x + cs.x / 2.0, 0, target.y * cs.z + cs.z / 2.0) - # Stagger shots: 0.1s offset per projectile - await get_tree().create_timer(i * 0.1).timeout - cannon_instance.rpc("spawn_projectile_rpc", target_pos, telegraph_time) - - # Wait remaining telegraph duration, then apply impact - var remaining_time = telegraph_time - (targets.size() - 1) * 0.1 - if remaining_time > 0: - await get_tree().create_timer(remaining_time).timeout - - if _can_rpc(): - rpc("sync_impact", targets) - - emit_signal("cannon_fired", targets) -func _select_targets() -> Array: - """Pick target cells for this volley based on current phase weights.""" - var targets: Array = [] - var all_players = get_tree().get_nodes_in_group("Players") - - # Collect all valid walkable positions (excluding NPC zone and existing sticky) - var valid_positions: Array = [] + var count := _cells_this_tick() + # Detect hidden movement-buffer corridors before scoring so the candidate + # scores reflect them this tick (#083; satisfies #067's buffer-check item). + _detect_movement_buffers() + var candidates := _generate_candidates() + if candidates.is_empty(): + return + + var selected := _select_cells_weighted(candidates, count) + selected = _apply_path_safety(selected) + if selected.is_empty(): + return + + _last_tick_cells = selected.duplicate() + + # Telegraph now (passable for telegraph_duration), then convert to sticky. + for pos in selected: + telegraphed_cells[pos] = telegraph_duration + if _can_rpc(): + rpc("sync_growth_telegraph", selected) + else: + sync_growth_telegraph(selected) + + await get_tree().create_timer(telegraph_duration).timeout + + for pos in selected: + telegraphed_cells.erase(pos) + if _can_rpc(): + rpc("sync_growth_apply", selected) + else: + sync_growth_apply(selected) + + emit_signal("growth_tick", selected) + + # Possibly start a candy bubble this tick (anti-camping hazard, #082). + _try_spawn_bubble() + +func _cells_this_tick() -> int: + """Random cell count within this phase's configured range.""" + var cfg = phase_growth_config[int(current_phase)] + var lo: int = cfg["cells_min"] + var hi: int = cfg["cells_max"] + if hi <= lo: + return lo + return lo + randi() % (hi - lo + 1) + +func _generate_candidates() -> Array: + """Build a list of {pos, score} for every SAFE, growable cell.""" + var candidates: Array = [] + var player_cells := _active_player_cells() # gathered once per tick for x in range(ARENA_COLUMNS): for z in range(ARENA_ROWS): - var pos = Vector2i(x, z) - if _is_npc_zone(pos): + var pos := Vector2i(x, z) + # Only SAFE cells are growable; skip blocked, sticky, telegraphed, + # and cleansed (temporary regrowth protection from #068). + if cell_state(pos) != CellState.SAFE: continue - if sticky_cells.has(pos): - continue - valid_positions.append(pos) - - if valid_positions.is_empty(): - return targets - - # Simple targeting: mix of random + player-adjacent - var remaining = volley_size - - # 40% of volley near players - var player_targets = int(remaining * 0.4) - for i in range(player_targets): - if all_players.is_empty(): - break - # Pick a random player - var player = all_players[randi() % all_players.size()] - var player_pos = player.current_position if player.get("current_position") else Vector2i(10, 10) - - # Pick a cell near them (within 3 tiles) - var nearby = _get_nearby_valid_cells(player_pos, 3, valid_positions) - if not nearby.is_empty(): - var target = nearby[randi() % nearby.size()] - if target not in targets: - targets.append(target) - remaining -= 1 - - # Remaining: random scatter - valid_positions.shuffle() - for pos in valid_positions: - if remaining <= 0: - break - if pos not in targets: - targets.append(pos) - remaining -= 1 - - return targets + candidates.append({"pos": pos, "score": _calculate_candidate_score(pos, player_cells)}) + return candidates -func _get_nearby_valid_cells(center: Vector2i, radius: int, valid: Array) -> Array: - var result: Array = [] - for pos in valid: - if abs(pos.x - center.x) <= radius and abs(pos.y - center.y) <= radius: - result.append(pos) - return result +func _calculate_candidate_score(pos: Vector2i, player_cells: Array = []) -> float: + """Full v2 candidate score (#073). Higher score = higher pick chance. + + CandidateScore = + LayerPriority + StickyNeighbor + InwardPressure + PlayerPressure + + ClusterGrowth + CampingPressure + RandomNoise + + MovementBuffer + PathSafety + Repetition + """ + var score := 0.0 + score += _score_layer_priority(pos) + score += _score_sticky_neighbor(pos) + score += _score_inward_pressure(pos) + score += _score_player_pressure(pos, player_cells) + score += _score_cluster_growth(pos) + score += _score_camping_pressure(pos) + score += randf_range(-20.0, 20.0) # RandomNoise — keep growth imperfect + score += _score_movement_buffer(pos) + score += _score_path_safety(pos) + score += _score_repetition(pos) + return score + +# --- score components (#073) ------------------------------------------------- + +func _score_layer_priority(pos: Vector2i) -> float: + """Steer growth to the current phase's pressure ring.""" + var weights: Dictionary = phase_growth_config[int(current_phase)]["layer_weights"] + return float(weights[_layer_of(pos)]) + +func _score_sticky_neighbor(pos: Vector2i) -> float: + """Prefer growing adjacent to existing sticky: +8 each, capped +64.""" + return min(_sticky_neighbor_count(pos) * 8.0, 64.0) + +func _score_inward_pressure(pos: Vector2i) -> float: + """Push candy inward more strongly as the round progresses. Scales with how + close the cell is to the center within the per-phase range.""" + var d := _chebyshev(pos, NPC_CENTER) + var max_d := float(maxi(ARENA_COLUMNS, ARENA_ROWS) / 2) # ~10 + var closeness := clampf(1.0 - float(d) / max_d, 0.0, 1.0) + match int(current_phase): + 0: return lerpf(0.0, 10.0, closeness) + 1: return lerpf(5.0, 20.0, closeness) + _: return lerpf(10.0, 30.0, closeness) + +func _score_player_pressure(pos: Vector2i, player_cells: Array) -> float: + """Pressure players without directly targeting them. + - 2-4 cells away: +20 + - directly under a player: -50 (before final 30s), +10 (final 30s).""" + if player_cells.is_empty(): + return 0.0 + var best := 0.0 + var final_window := float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW + for pcell in player_cells: + var d := _chebyshev(pos, pcell) + var s := 0.0 + if d == 0: + s = 10.0 if final_window else -50.0 + elif d >= 2 and d <= 4: + s = 20.0 + if abs(s) > abs(best): + best = s + return best + +func _score_cluster_growth(pos: Vector2i) -> float: + """Reward expanding/connecting sticky clusters. Distinct sticky neighbours + spanning more than one direction implies a bridge between clusters.""" + var neighbours := _sticky_neighbor_count(pos) + if neighbours == 0: + return 0.0 + if neighbours >= 3: + return 25.0 # connects clusters + return 15.0 # expands a cluster + +func _score_camping_pressure(pos: Vector2i) -> float: + """Target areas where a player has lingered. + >5s: +20, >8s: +40, >10s: +60.""" + var t := _camp_time_for_region(_region_of(pos)) + if t > 10.0: + return 60.0 + elif t > 8.0: + return 40.0 + elif t > 5.0: + return 20.0 + return 0.0 + +func _score_movement_buffer(pos: Vector2i) -> float: + """Respect hidden safe zones. Two complementary parts (#083): + 1. Dynamically-detected buffer corridors (decaying) — `_buffer_penalty_at`. + 2. A light proximity floor around players so the immediate ring stays open. + Both lift entirely in the final window so the arena can close out.""" + var final_window := float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW + if final_window: + return 0.0 + + # 1. Detected corridor buffers (strongest signal). + var buffer := _buffer_penalty_at(pos) + if buffer < 0.0: + return buffer + + # 2. Proximity floor (kept from #073) — discourage sealing the ring next to a + # player even when no corridor was detected there. + var player_cells := _active_player_cells() + var min_d := INF + for pcell in player_cells: + min_d = min(min_d, float(_chebyshev(pos, pcell))) + if min_d == INF: + return 0.0 + match int(current_phase): + 0: + if min_d <= 1: return -40.0 + elif min_d <= 2: return -20.0 + 1: + if min_d <= 1: return -20.0 + elif min_d <= 2: return -10.0 + _: + if min_d <= 1: return -10.0 + return 0.0 + +func _score_path_safety(pos: Vector2i) -> float: + """Soft penalty that discourages selections which would strand a player. + The hard guarantee is enforced separately by _apply_path_safety().""" + if float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW: + return 0.0 + var extra := {pos: true} + for pcell in _active_player_cells(): + if not _player_has_safe_region(pcell, extra): + return -100.0 # would fully trap a player + return 0.0 + +func _score_repetition(pos: Vector2i) -> float: + """Avoid spammy growth on last tick's footprint.""" + for last in _last_tick_cells: + if _chebyshev(pos, last) <= 1: + return -30.0 + return 0.0 + +func _select_cells_weighted(candidates: Array, count: int) -> Array: + """Weighted-random selection: higher score = higher pick chance. + + Scores are shifted positive so the lowest-scoring cell still has a small + non-zero weight, preserving organic unpredictability. + """ + var pool: Array = candidates.duplicate() + var picked: Array = [] + + # Find the minimum score to offset all weights into the positive range. + var min_score := INF + for c in pool: + min_score = min(min_score, c["score"]) + var offset := 1.0 - min_score # ensures every weight >= 1.0 + + var n: int = min(count, pool.size()) + for _i in range(n): + var total := 0.0 + for c in pool: + total += c["score"] + offset + if total <= 0.0: + break + var roll := randf() * total + var acc := 0.0 + var chosen_idx := 0 + for j in range(pool.size()): + acc += pool[j]["score"] + offset + if roll <= acc: + chosen_idx = j + break + picked.append(pool[chosen_idx]["pos"]) + pool.remove_at(chosen_idx) + + return picked + +# --- scoring helpers --------------------------------------------------------- + +func _layer_of(pos: Vector2i) -> String: + """Classify a cell into outer / middle / inner rings by Chebyshev distance + from the arena center (matches the NPC pump at the middle).""" + var d := _chebyshev(pos, NPC_CENTER) + if d >= 7: + return "outer" + elif d >= 4: + return "middle" + return "inner" + +func _sticky_neighbor_count(pos: Vector2i) -> int: + """Count of the 8 surrounding cells that are already sticky.""" + var c := 0 + for dx in range(-1, 2): + for dz in range(-1, 2): + if dx == 0 and dz == 0: + continue + if sticky_cells.has(pos + Vector2i(dx, dz)): + c += 1 + return c + +func _chebyshev(a: Vector2i, b: Vector2i) -> int: + return max(abs(a.x - b.x), abs(a.y - b.y)) + +# --- camping tracking -------------------------------------------------------- + +func _region_of(pos: Vector2i) -> Vector2i: + """Coarse 4x4 region key a cell belongs to (for camping detection).""" + return Vector2i(pos.x / CAMP_REGION_SIZE, pos.y / CAMP_REGION_SIZE) + +func _update_camp_tracking(delta: float) -> void: + """Accumulate time each player spends in their current 4x4 region. + Resets the timer when a player moves to a new region. Server-side.""" + var seen := {} + for player in get_tree().get_nodes_in_group("Players"): + var pid = player.get("peer_id") if "peer_id" in player else -1 + if pid == -1 or not ("current_position" in player) or player.current_position == null: + continue + seen[pid] = true + var region := _region_of(player.current_position) + var rec = _camp_tracking.get(pid) + if rec == null or rec["region"] != region: + _camp_tracking[pid] = {"region": region, "time": 0.0} + else: + rec["time"] += delta + # Drop tracking for players that left the match. + for pid in _camp_tracking.keys(): + if not seen.has(pid): + _camp_tracking.erase(pid) + +func _camp_time_for_region(region: Vector2i) -> float: + """Longest camp time any player has accrued in the given region.""" + var best := 0.0 + for pid in _camp_tracking: + var rec = _camp_tracking[pid] + if rec["region"] == region: + best = max(best, rec["time"]) + return best # ============================================================================= -# Telegraph & Impact (RPCs) - # ============================================================================= +# Growth Telegraph & Apply (RPCs) — v2 +# ============================================================================= @rpc("authority", "call_local", "reliable") -func sync_telegraph_highlight(targets: Array) -> void: - """Show pulsing floor highlight on target cells BEFORE the telegraph drop.""" +func sync_growth_telegraph(cells: Array) -> void: + """Warn that the given cells will become sticky. Cells stay passable until + sync_growth_apply fires (telegraph_duration later).""" if not gridmap: return - - # Create programmatic highlight overlays (pulsing circles on floor) - for target in targets: - var pos = target as Vector2i - var cs = gridmap.cell_size - var world_pos = Vector3(pos.x * cs.x + cs.x / 2.0, 0.15, pos.y * cs.z + cs.z / 2.0) - - # Create a flat pulsing indicator mesh - var mesh_inst = MeshInstance3D.new() - var box = BoxMesh.new() - box.size = Vector3(cs.x * 0.8, 0.02, cs.z * 0.8) - mesh_inst.mesh = box - mesh_inst.position = world_pos - - var mat = StandardMaterial3D.new() - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.albedo_color = Color(1.0, 0.3, 0.5, 0.4) # Pink warning glow - mat.emission_enabled = true - mat.emission = Color(1.0, 0.3, 0.5) - mat.emission_energy_multiplier = 2.0 - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mesh_inst.material_override = mat - - # Add to scene tree under main - var main = get_node_or_null("/root/Main") - if main: - main.add_child(mesh_inst) - # Pulse animation - var tween = create_tween().set_loops() - tween.tween_method(func(a): mat.albedo_color.a = a, 0.4, 0.1, 0.2) - tween.tween_method(func(a): mat.albedo_color.a = a, 0.1, 0.4, 0.2) - # Auto-remove after highlight duration - var remove_timer = get_tree().create_timer(0.8) - remove_timer.timeout.connect(func(): - if is_instance_valid(mesh_inst): - mesh_inst.queue_free() - ) - - # Play warning sound - if SfxManager: - SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile") -@rpc("authority", "call_local", "reliable") -func sync_telegraph(targets: Array) -> void: - """Show warning overlay on target cells with multi-stage animation.""" - if not gridmap: return - - # Place telegraph tiles - for target in targets: - var pos = target as Vector2i + for cell in cells: + var pos = cell as Vector2i + # Telegraph overlay tile on Layer 2 (still passable). gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH) - - # Animate telegraph with Tween (build-up phase) - _animate_telegraph(targets) + _spawn_telegraph_highlight(pos) -func _animate_telegraph(targets: Array) -> void: - """Tween animation for telegraph: fade in, flash, then transition to sticky.""" - var config = phase_configs[int(current_phase)] - var telegraph_time = config["telegraph_time"] - var build_up_time = telegraph_time * 0.8 # 80% for build-up - var flash_time = telegraph_time * 0.2 # 20% for flash - - # Create tween for visual feedback - var tween = create_tween() - tween.set_parallel(true) - - # Phase 1: Fade in (alpha 0 -> 1) during build-up - # Note: GridMap tiles don't support alpha directly, so we use modulation - # We'll animate the gridmap overlay opacity conceptually - for target in targets: - var pos = target as Vector2i - # Tween the cell brightness by swapping between telegraph variants - tween.tween_callback(_flash_telegraph.bind(targets, 0)).set_delay(0.0) - tween.tween_callback(_flash_telegraph.bind(targets, 1)).set_delay(0.4) - tween.tween_callback(_flash_telegraph.bind(targets, 0)).set_delay(0.8) - - # Audio: rising pitch during build-up + # Audio: warning pulse if SfxManager: SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile") - - await get_tree().create_timer(1.0).timeout -func _flash_telegraph(targets: Array, brightness: int) -> void: - """Flicker telegraph tiles between normal and bright.""" - if not gridmap: return - # Toggle visual feedback - in full implementation would modify material/overlay - # For now, this provides the timing structure for the animation - pass +func _spawn_telegraph_highlight(pos: Vector2i) -> void: + """Two-stage amber warning under a telegraphed cell (#069): + • Build-up (0–0.8s): amber glow ramps alpha 0→1. + • Flash (0.8–1.0s): flickers to bright amber just before impact. + Auto-removed at the end of the telegraph window. Amber here is deliberately + distinct from the pink/magenta sticky overlay so the two never read alike.""" + var cs = gridmap.cell_size + var world_pos = Vector3(pos.x * cs.x + cs.x / 2.0, 0.15, pos.y * cs.z + cs.z / 2.0) + + var mesh_inst = MeshInstance3D.new() + var box = BoxMesh.new() + box.size = Vector3(cs.x * 0.8, 0.02, cs.z * 0.8) + mesh_inst.mesh = box + mesh_inst.position = world_pos + + var amber := Color(1.0, 0.65, 0.1) # syrup amber — clearly not sticky pink + var mat = StandardMaterial3D.new() + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.albedo_color = Color(amber.r, amber.g, amber.b, 0.0) + mat.emission_enabled = true + mat.emission = amber + mat.emission_energy_multiplier = 1.5 + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mesh_inst.material_override = mat + + var main = get_node_or_null("/root/Main") + if not main: + return + main.add_child(mesh_inst) + + # Split the telegraph window 80% build-up / 20% flash. + var build := telegraph_duration * 0.8 + var flash := telegraph_duration * 0.2 + + var tween = create_tween() + # Build-up: fade in to a steady amber. + tween.tween_method(func(a): mat.albedo_color.a = a, 0.0, 0.55, build) + # Flash: quick bright flicker (alpha + emission energy) right before impact. + tween.tween_method(func(e): mat.emission_energy_multiplier = e, 1.5, 4.0, flash * 0.5) + tween.parallel().tween_method(func(a): mat.albedo_color.a = a, 0.55, 0.9, flash * 0.5) + tween.tween_method(func(e): mat.emission_energy_multiplier = e, 4.0, 2.5, flash * 0.5) + + var remove_timer = get_tree().create_timer(telegraph_duration) + remove_timer.timeout.connect(func(): + if is_instance_valid(mesh_inst): + mesh_inst.queue_free() + ) @rpc("authority", "call_local", "reliable") -func sync_impact(targets: Array) -> void: - """Apply sticky cells at target positions.""" +func sync_growth_apply(cells: Array) -> void: + """Convert telegraphed cells to permanent sticky candy.""" if not gridmap: return - for target in targets: - var pos = target as Vector2i - # Replace telegraph with sticky on Layer 2 + for cell in cells: + var pos = cell as Vector2i gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY) sticky_cells[pos] = true - + # Screen shake for impact if main_scene and main_scene.get("screen_shake_manager"): main_scene.screen_shake_manager.shake(0.15, 0.4) - - # Audio: impact splat sound + + # Audio: sticky splat if SfxManager: SfxManager.rpc("play_rpc", "tile_scatter") if _can_rpc() else SfxManager.play("tile_scatter") - - # Spawn candy splash particles at impact locations - _spawn_impact_particles(targets) - - # Check if any player is now trapped + + _spawn_impact_particles(cells) + + # Re-evaluate trapped players after the new sticky cells land. _check_all_players_trapped() func _spawn_impact_particles(targets: Array) -> void: """Spawn candy splash particles at impact locations.""" if not main_scene: return - + for target in targets: var pos = target as Vector2i var world_pos = Vector3( @@ -698,7 +938,7 @@ func _spawn_impact_particles(targets: Array) -> void: 0.5, # Slightly above floor pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 ) - + # Create a simple particle effect (GPUParticles3D) var particles = GPUParticles3D.new() particles.emitting = true @@ -706,7 +946,7 @@ func _spawn_impact_particles(targets: Array) -> void: particles.amount = 8 particles.lifetime = 0.5 particles.explosiveness = 1.0 - + # Candy pink color var material = ParticleProcessMaterial.new() material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE @@ -718,12 +958,12 @@ func _spawn_impact_particles(targets: Array) -> void: material.gravity = Vector3(0, -9.8, 0) material.scale_min = 0.1 material.scale_max = 0.3 - + particles.process_material = material particles.position = world_pos - + main_scene.add_child(particles) - + # Auto-remove after particles finish await get_tree().create_timer(1.0).timeout if particles and is_instance_valid(particles): @@ -736,33 +976,564 @@ func _spawn_impact_particles(targets: Array) -> void: func is_sticky_cell(pos: Vector2i) -> bool: return sticky_cells.has(pos) +func is_cleansed_cell(pos: Vector2i) -> bool: + return cleansed_cells.has(pos) + +func cell_state(pos: Vector2i) -> CellState: + """Logical state of a playable cell (v2 ground-growth model).""" + if _is_npc_zone(pos) or _is_boundary(pos): + return CellState.BLOCKED + if sticky_cells.has(pos): + return CellState.STICKY + if cleansed_cells.has(pos): + return CellState.CLEANSED + if telegraphed_cells.has(pos): + return CellState.TELEGRAPHED + if bubble_cells.has(pos): + return CellState.BUBBLE_GROWING + return CellState.SAFE + +func mark_cleansed(pos: Vector2i) -> void: + """Flag a cell as recently cleansed, granting temporary regrowth protection.""" + cleansed_cells[pos] = CLEANSED_PROTECTION_TIME + +func _tick_cleansed_cells(delta: float) -> void: + """Count down cleansed-cell protection; expire when it runs out.""" + var expired: Array[Vector2i] = [] + for pos in cleansed_cells: + cleansed_cells[pos] -= delta + if cleansed_cells[pos] <= 0.0: + expired.append(pos) + for pos in expired: + cleansed_cells.erase(pos) + +func _is_boundary(pos: Vector2i) -> bool: + return pos.x == 0 or pos.x == ARENA_COLUMNS - 1 or pos.y == 0 or pos.y == ARENA_ROWS - 1 + +# ============================================================================= +# Coverage tracking (v2 target: 70-75%, down from v1's 80%) +# ============================================================================= + +const COVERAGE_TARGET_MIN: float = 0.70 +const COVERAGE_TARGET_MAX: float = 0.75 + +func playable_cell_count() -> int: + """Number of cells that can ever become sticky (interior, minus NPC zone).""" + var count := 0 + for x in range(ARENA_COLUMNS): + for z in range(ARENA_ROWS): + var pos := Vector2i(x, z) + if _is_boundary(pos) or _is_npc_zone(pos): + continue + count += 1 + return count + +func coverage_ratio() -> float: + """Fraction of playable cells currently sticky (0.0-1.0).""" + var playable := playable_cell_count() + if playable <= 0: + return 0.0 + return float(sticky_cells.size()) / float(playable) + +func is_coverage_reached() -> bool: + """True once sticky coverage hits the v2 minimum target.""" + return coverage_ratio() >= COVERAGE_TARGET_MIN + +# ============================================================================= +# Path safety (v2): never trap a player before the final window +# ============================================================================= + +const SAFE_REGION_MIN_CELLS: int = 6 # each player must keep this many reachable safe cells +const FORCED_TRAP_WINDOW: float = 30.0 # final seconds where trapping is allowed + +func _is_cell_passable(pos: Vector2i, extra_sticky: Dictionary = {}) -> bool: + """Can a player stand on / move through this cell, given a hypothetical sticky set?""" + if _is_boundary(pos) or _is_npc_zone(pos): + return false + if sticky_cells.has(pos) or extra_sticky.has(pos): + return false + return true + +func _reachable_safe_cells(start: Vector2i, extra_sticky: Dictionary, limit: int) -> int: + """Flood-fill from start over passable cells; stop early once `limit` reached.""" + if not _is_cell_passable(start, extra_sticky): + return 0 + var visited := {start: true} + var queue: Array[Vector2i] = [start] + var count := 0 + const NEIGHBORS := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)] + while not queue.is_empty(): + var cur: Vector2i = queue.pop_front() + count += 1 + if count >= limit: + return count + for d in NEIGHBORS: + var nxt: Vector2i = cur + d + if visited.has(nxt): + continue + if _is_cell_passable(nxt, extra_sticky): + visited[nxt] = true + queue.push_back(nxt) + return count + +func _player_has_safe_region(start: Vector2i, extra_sticky: Dictionary) -> bool: + """Player at `start` still has at least SAFE_REGION_MIN_CELLS reachable cells.""" + return _reachable_safe_cells(start, extra_sticky, SAFE_REGION_MIN_CELLS) >= SAFE_REGION_MIN_CELLS + +func _apply_path_safety(candidates: Array) -> Array: + """Filter a candidate sticky-cell list so no active player is trapped. + + During the final FORCED_TRAP_WINDOW seconds, trapping is allowed and the + candidate list is returned unchanged. + """ + var time_left := float(gauntlet_round_duration() - elapsed_time) + if time_left <= FORCED_TRAP_WINDOW: + return candidates + + var player_cells := _active_player_cells() + if player_cells.is_empty(): + return candidates + + var accepted: Array = [] + var pending := {} + for c in candidates: + pending[c] = true + for c in candidates: + # Tentatively accept c, then verify every player keeps a safe region. + var trial := pending.duplicate() + # `pending` holds all not-yet-rejected candidates; treat accepted ones as sticky. + var trial_sticky := {} + for a in accepted: + trial_sticky[a] = true + trial_sticky[c] = true + var safe_for_all := true + for pcell in player_cells: + if not _player_has_safe_region(pcell, trial_sticky): + safe_for_all = false + break + if safe_for_all: + accepted.append(c) + else: + pending.erase(c) + return accepted + +# ============================================================================= +# Movement buffers (#083): hidden, decaying safe corridors +# ============================================================================= + +func _detect_movement_buffers() -> void: + """Find SAFE cells that are critical movement corridors for active players and + register/refresh a hidden penalty on them. A corridor is a passable cell near + a player whose removal would shrink that player's reachable region below + BUFFER_CORRIDOR_THRESHOLD (a genuine chokepoint, not open floor). + + Campers don't get fresh buffers near them — staying put forfeits protection. + Runs server-side once per growth tick, before scoring.""" + var player_cells := _active_player_cells() + if player_cells.is_empty(): + return + + var base: float = BUFFER_BASE_PENALTY[int(current_phase)] + const NEIGHBORS := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)] + + for pcell in player_cells: + # Camping override: a player lingering in one region loses buffer help. + if _camp_time_for_region(_region_of(pcell)) > 5.0: + continue + # Examine the passable cells immediately around the player. + for d in NEIGHBORS: + var cell: Vector2i = pcell + d + if not _is_cell_passable(cell): + continue + # Is this a chokepoint? Removing it must noticeably cut reachability. + var without := _reachable_safe_cells(pcell, {cell: true}, BUFFER_CORRIDOR_THRESHOLD) + if without < BUFFER_CORRIDOR_THRESHOLD: + _register_buffer(cell, base) + +func _register_buffer(pos: Vector2i, penalty: float) -> void: + """Add or refresh a buffer cell at full penalty for the current phase.""" + if movement_buffers.has(pos): + # Refresh to the stronger of the existing or the new base penalty. + movement_buffers[pos]["penalty"] = max(movement_buffers[pos]["penalty"], penalty) + else: + movement_buffers[pos] = {"penalty": penalty} + +func _decay_movement_buffers(delta: float) -> void: + """Reduce buffer penalties by 25% every BUFFER_DECAY_INTERVAL seconds, then + prune any that have faded below BUFFER_MIN_PENALTY. Server-side each tick.""" + if movement_buffers.is_empty(): + return + _buffer_decay_timer += delta + if _buffer_decay_timer < BUFFER_DECAY_INTERVAL: + return + _buffer_decay_timer = 0.0 + _scale_all_buffers(BUFFER_DECAY_FACTOR) + +func _scale_all_buffers(factor: float) -> void: + """Multiply every buffer penalty by `factor`, pruning faded entries.""" + for pos in movement_buffers.keys(): + var p: float = movement_buffers[pos]["penalty"] * factor + if p < BUFFER_MIN_PENALTY: + movement_buffers.erase(pos) + else: + movement_buffers[pos]["penalty"] = p + +func _buffer_penalty_at(pos: Vector2i) -> float: + """Penalty for landing growth on a buffer cell (inside = full, adjacent = half). + Lifts entirely in the final window so the arena can close out.""" + if movement_buffers.is_empty(): + return 0.0 + if float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW: + return 0.0 + if movement_buffers.has(pos): + return -movement_buffers[pos]["penalty"] + # Adjacent to a buffer cell → half penalty. + const NEIGHBORS := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)] + for d in NEIGHBORS: + if movement_buffers.has(pos + d): + return -movement_buffers[pos + d]["penalty"] * 0.5 + return 0.0 + +func _active_player_cells() -> Array[Vector2i]: + """Current grid cells of non-trapped players.""" + var cells: Array[Vector2i] = [] + for player in get_tree().get_nodes_in_group("Players"): + var pid = player.get("peer_id") if "peer_id" in player else -1 + if trapped_players.has(pid): + continue + if "current_position" in player and player.current_position != null: + cells.append(player.current_position) + return cells + +# ============================================================================= +# Candy bubbles (#082): anti-camping hazards (1x1 grow → 3x3 explosion) +# ============================================================================= + +func _bubble_budget_for_phase() -> int: + """How many bubbles this phase is allowed to spawn in total.""" + return MAX_BUBBLES_PER_PHASE[int(current_phase)] + +func _generate_bubble_candidates() -> Array: + """Score every SAFE cell as a potential bubble center. Returns {pos, score}.""" + var candidates: Array = [] + var player_cells := _active_player_cells() + for x in range(ARENA_COLUMNS): + for z in range(ARENA_ROWS): + var pos := Vector2i(x, z) + if cell_state(pos) != CellState.SAFE: + continue + candidates.append({"pos": pos, "score": _calculate_bubble_score(pos, player_cells)}) + return candidates + +func _calculate_bubble_score(pos: Vector2i, player_cells: Array = []) -> float: + """Bubble-specific scoring (#082). Higher = better bubble target. + + BubbleScore = Camping + UntouchedArea + PlayerCluster + RandomNoise + + DirectHitPenalty + RecentBubblePenalty + UnfairTrapPenalty + """ + var score := 0.0 + score += _bubble_score_camping(pos) + score += _bubble_score_untouched_area(pos) + score += _bubble_score_player_cluster(pos, player_cells) + score += randf_range(-20.0, 20.0) + score += _bubble_score_direct_hit(pos, player_cells) + score += _bubble_score_recent(pos) + score += _bubble_score_unfair_trap(pos) + return score + +func _bubble_score_camping(pos: Vector2i) -> float: + """Reward targeting campers. +40 >5s, +60 >8s, +80 >10s-with-cleanser.""" + var t := _camp_time_for_region(_region_of(pos)) + if t > 10.0: + # Stronger only if a nearby player actually holds a cleanser. + if _any_cleanser_holder_near(pos): + return 80.0 + return 60.0 + elif t > 8.0: + return 60.0 + elif t > 5.0: + return 40.0 + return 0.0 + +func _bubble_score_untouched_area(pos: Vector2i) -> float: + """+30 when the cell sits in a large untouched (sticky-free) region.""" + var open := _reachable_safe_cells(pos, {}, 30) + return 30.0 if open >= 24 else 0.0 + +func _bubble_score_player_cluster(pos: Vector2i, player_cells: Array) -> float: + """+20 when 2+ players are nearby (within 4 cells).""" + var near := 0 + for pcell in player_cells: + if _chebyshev(pos, pcell) <= 4: + near += 1 + return 20.0 if near >= 2 else 0.0 + +func _bubble_score_direct_hit(pos: Vector2i, player_cells: Array) -> float: + """-60 if a bubble would erupt directly under a player (unfair, unreadable).""" + for pcell in player_cells: + if pos == pcell: + return -60.0 + return 0.0 + +func _bubble_score_recent(pos: Vector2i) -> float: + """-50 if a recent bubble erupted in/near this region (anti-stacking).""" + for c in recent_bubble_positions: + if _chebyshev(pos, c) <= BUBBLE_RECENT_RADIUS: + return -50.0 + return 0.0 + +func _bubble_score_unfair_trap(pos: Vector2i) -> float: + """-100 if the 3x3 explosion would strand a player (before the final window).""" + if float(gauntlet_round_duration() - elapsed_time) <= FORCED_TRAP_WINDOW: + return 0.0 + var blast := {} + for cell in _bubble_blast_cells(pos): + blast[cell] = true + for pcell in _active_player_cells(): + if blast.has(pcell): + continue # direct-hit handled separately + if not _player_has_safe_region(pcell, blast): + return -100.0 + return 0.0 + +func _bubble_blast_cells(center: Vector2i) -> Array: + """The 3x3 (radius 1) sticky cells a bubble at `center` would create, + clipped to passable/playable cells.""" + var cells: Array = [] + for dx in range(-BUBBLE_EXPLOSION_RADIUS, BUBBLE_EXPLOSION_RADIUS + 1): + for dz in range(-BUBBLE_EXPLOSION_RADIUS, BUBBLE_EXPLOSION_RADIUS + 1): + var c := center + Vector2i(dx, dz) + if _is_boundary(c) or _is_npc_zone(c): + continue + cells.append(c) + return cells + +func _any_cleanser_holder_near(pos: Vector2i) -> bool: + """True if a player holding a Cleanser charge is within the camping region.""" + for player in get_tree().get_nodes_in_group("Players"): + var pid = player.get("peer_id") if "peer_id" in player else -1 + if pid == -1: + continue + if player_cleansers.get(pid, 0) <= 0: + continue + if "current_position" in player and player.current_position != null: + if _region_of(player.current_position) == _region_of(pos): + return true + return false + +# --- bubble lifecycle (server-authoritative) --------------------------------- + +func _try_spawn_bubble() -> void: + """Maybe spawn one candy bubble this growth tick, if the phase still has + budget. Server-side; called from _process_growth_tick after normal growth.""" + if not multiplayer.is_server(): + return + if bubbles_this_phase >= _bubble_budget_for_phase(): + return + # Probabilistic so bubbles don't all fire on the first ticks of a phase. + # ~1 in 4 eligible ticks; the per-phase cap still bounds the total. + if randf() > 0.25: + return + + var candidates := _generate_bubble_candidates() + if candidates.is_empty(): + return + var picked := _select_cells_weighted(candidates, 1) + if picked.is_empty(): + return + var center: Vector2i = picked[0] + + # Reject low-quality targets (e.g. recent/unfair) — only spawn if the chosen + # cell scores non-negative, so penalties can veto a bad bubble. + var best_score := -INF + for c in candidates: + if c["pos"] == center: + best_score = c["score"] + break + if best_score < 0.0: + return + + _spawn_bubble(center) + +func _spawn_bubble(center: Vector2i) -> void: + """Begin a bubble at `center`: mark the 3x3 footprint BUBBLE_GROWING and start + its grow timer. Broadcasts the warning to clients.""" + bubbles_this_phase += 1 + bubbles_total += 1 + + var cells := _bubble_blast_cells(center) + for c in cells: + bubble_cells[c] = true + + active_bubbles.append({"center": center, "timer": BUBBLE_GROW_DURATION, "cells": cells}) + + # Anti-stacking memory. + recent_bubble_positions.append(center) + while recent_bubble_positions.size() > BUBBLE_RECENT_MEMORY: + recent_bubble_positions.pop_front() + + if _can_rpc(): + rpc("sync_bubble_spawn", center, cells) + else: + sync_bubble_spawn(center, cells) + +func _update_bubbles(delta: float) -> void: + """Advance grow timers; explode bubbles whose timer elapses. Server-side.""" + if active_bubbles.is_empty(): + return + var exploded: Array = [] + for b in active_bubbles: + b["timer"] -= delta + if b["timer"] <= 0.0: + exploded.append(b) + for b in exploded: + active_bubbles.erase(b) + _explode_bubble(b["center"], b["cells"]) + +func _explode_bubble(center: Vector2i, cells: Array) -> void: + """Convert a bubble's 3x3 footprint to sticky, slow players caught inside, + and broadcast the explosion.""" + for c in cells: + bubble_cells.erase(c) + sticky_cells[c] = true + + if _can_rpc(): + rpc("sync_bubble_explode", center, cells) + else: + sync_bubble_explode(center, cells) + + # Slow any player standing in the blast (consistent with sticky entry, #068). + var blast := {} + for c in cells: + blast[c] = true + for player in get_tree().get_nodes_in_group("Players"): + if "current_position" in player and player.current_position != null: + if blast.has(player.current_position): + var pid = player.get("peer_id") if "peer_id" in player else -1 + if pid != -1 and is_cleanser_active(pid): + continue + apply_sticky_slow(player) + + # Bot paths through the new sticky are now invalid. + if gridmap and gridmap.has_method("initialize_astar"): + gridmap.initialize_astar() + +@rpc("authority", "call_local", "reliable") +func sync_bubble_spawn(center: Vector2i, cells: Array) -> void: + """Show the growing bubble + 3x3 warning area on all clients.""" + if not gridmap: + return + # Telegraph-style warning overlay on the footprint (still passable). + for c in cells: + var pos = c as Vector2i + gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH) + _spawn_bubble_visual(center) + if SfxManager: + SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile") + +@rpc("authority", "call_local", "reliable") +func sync_bubble_explode(center: Vector2i, cells: Array) -> void: + """Apply the 3x3 sticky overlay + explosion VFX on all clients.""" + if not gridmap: + return + for c in cells: + var pos = c as Vector2i + gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_STICKY) + # Medium shake — bubbles hit harder than a normal growth tick. + if main_scene and main_scene.get("screen_shake_manager"): + main_scene.screen_shake_manager.shake(0.3, 0.6) + if SfxManager: + SfxManager.rpc("play_rpc", "tile_scatter") if _can_rpc() else SfxManager.play("tile_scatter") + _spawn_impact_particles(cells) + +func _spawn_bubble_visual(center: Vector2i) -> void: + """A pulsing candy bubble sphere that grows over the bubble's lifetime.""" + if not gridmap: + return + var cs = gridmap.cell_size + var world_pos = Vector3(center.x * cs.x + cs.x / 2.0, 0.4, center.y * cs.z + cs.z / 2.0) + + var mesh_inst = MeshInstance3D.new() + var sphere = SphereMesh.new() + sphere.radius = 0.25 + sphere.height = 0.5 + mesh_inst.mesh = sphere + mesh_inst.position = world_pos + + var mat = StandardMaterial3D.new() + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.albedo_color = Color(1.0, 0.2, 0.6, 0.7) # candy pink + mat.emission_enabled = true + mat.emission = Color(1.0, 0.2, 0.6) + mat.emission_energy_multiplier = 1.5 + mesh_inst.material_override = mat + + var main = get_node_or_null("/root/Main") + if not main: + return + main.add_child(mesh_inst) + + # Grow + pulse over the grow duration, then remove (explosion VFX takes over). + var tween = create_tween() + tween.tween_property(mesh_inst, "scale", Vector3(3.0, 3.0, 3.0), BUBBLE_GROW_DURATION) \ + .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN) + tween.parallel().tween_method(func(e): mat.emission_energy_multiplier = e, 1.5, 4.0, BUBBLE_GROW_DURATION) + var remove_timer = get_tree().create_timer(BUBBLE_GROW_DURATION + 0.05) + remove_timer.timeout.connect(func(): + if is_instance_valid(mesh_inst): + mesh_inst.queue_free() + ) + +func gauntlet_round_duration() -> int: + """Round length in seconds (from lobby settings, with a sane fallback).""" + if LobbyManager and "gauntlet_round_duration" in LobbyManager: + return LobbyManager.gauntlet_round_duration + return 180 + func _check_all_players_trapped() -> void: + """After growth lands, slow any player standing on a fresh sticky cell.""" if not multiplayer.is_server(): return var all_players = get_tree().get_nodes_in_group("Players") for player in all_players: var pos = player.current_position if player.get("current_position") else Vector2i(-1, -1) - if is_sticky_cell(pos) and not trapped_players.has(player.get("peer_id") if "peer_id" in player else -1): - _trap_player(player) + if is_sticky_cell(pos): + var pid = player.get("peer_id") if "peer_id" in player else -1 + if pid != -1 and is_cleanser_active(pid): + continue # cleansing players are immune to the slow + apply_sticky_slow(player) + +func apply_sticky_slow(player: Node) -> void: + """Sticky candy slows a single player to a crawl (no global time_scale, no + hard freeze). The player can still struggle free at reduced speed.""" + if not player or not player.has_method("apply_slow_effect"): + return + if _can_rpc(): + player.rpc("apply_slow_effect", STICKY_SLOW_DURATION) + else: + player.apply_slow_effect(STICKY_SLOW_DURATION) func _trap_player(player: Node) -> void: + """Legacy hard-trap. No longer used for sticky entry (sticky now slows). + Kept for potential future hazards.""" var pid = player.get("peer_id") if "peer_id" in player else -1 if pid == -1: return trapped_players[pid] = true print("[Gauntlet] Player %d TRAPPED at %s" % [pid, str(player.current_position)]) emit_signal("player_trapped", pid) - + # Apply visual feedback and notify if player.has_method("apply_stagger"): if _can_rpc(): player.rpc("apply_stagger", 999.0) # Basically infinite until cleansed else: player.apply_stagger(999.0) - + NotificationManager.send_message(player, "Stuck in Candy!", NotificationManager.MessageType.WARNING) func clear_sticky_cell(pos: Vector2i) -> void: """Used by Cleanser power-up to remove a sticky cell.""" sticky_cells.erase(pos) + mark_cleansed(pos) # temporary regrowth protection (v2) if gridmap: gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1) @@ -816,13 +1587,7 @@ func _try_use_cleanser() -> void: elif multiplayer.is_server(): if _can_rpc(): rpc("sync_cleanser_count", local_pid, 0) - - # Trigger slow-mo for dramatic effect - if multiplayer.is_server(): - trigger_slowmo() - else: - rpc("rpc_trigger_slowmo") - + NotificationManager.send_message(local_player, "Cleanser Active! (5 cells)", NotificationManager.MessageType.POWERUP) func deactivate_cleanser(player_id: int) -> void: @@ -844,6 +1609,16 @@ func use_cleanser_cell(player_id: int) -> bool: return false return true +func notify_movement_stopped(player_id: int, pos: Vector2i) -> void: + """Cleanser also ends when the player comes to rest on a safe (non-sticky) + cell — they're clear of the candy, so immunity is no longer needed (#072). + Called from PlayerMovementManager when a move chain settles. Gauntlet-only; + a no-op when the player has no active cleanser.""" + if not cleanser_active.has(player_id): + return + if not is_sticky_cell(pos): + deactivate_cleanser(player_id) + @rpc("any_peer", "call_local", "reliable") func rpc_activate_cleanser(pid: int) -> void: """RPC for clients to activate cleanser on server.""" @@ -979,10 +1754,10 @@ func _update_hud_phase(phase_name: String) -> void: if phase_label: var icon = "🍬" match phase_name: - "Route Pressure": + "Middle Pressure": icon = "⚠️" phase_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2)) # Warning gold - "Survival!": + "Inner Survival": icon = "💀" phase_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3)) # Danger red _: @@ -1091,7 +1866,7 @@ func _respawn_mission_tiles() -> void: # Shuffle and place tiles empty_cells.shuffle() - var tiles_to_place = min(empty_cells.size(), 20) # Limit respawn count + var tiles_to_place = min(empty_cells.size(), 6) # Light refill — avoid flooding the board while players collect for i in range(tiles_to_place): var pos = empty_cells[i] diff --git a/scripts/managers/lobby_manager.gd b/scripts/managers/lobby_manager.gd index acf83f9..4701ddc 100644 --- a/scripts/managers/lobby_manager.gd +++ b/scripts/managers/lobby_manager.gd @@ -33,8 +33,8 @@ signal doors_required_goals_changed(goals: int) # Gauntlet settings signals signal gauntlet_round_duration_changed(duration: int) -signal gauntlet_cannon_interval_changed(interval: int) -signal gauntlet_volley_size_changed(size: int) +signal gauntlet_growth_interval_changed(interval: float) +signal gauntlet_cells_per_tick_changed(cells: Dictionary) # Room data structure var current_room: Dictionary = {} @@ -81,8 +81,12 @@ var doors_required_goals: int = 8 # Gauntlet settings var gauntlet_round_duration: int = 180 -var gauntlet_cannon_interval: int = 5 -var gauntlet_volley_size: int = 5 +var gauntlet_growth_interval: float = 3.0 # seconds between growth ticks +var gauntlet_cells_per_tick: Dictionary = { + "phase1": [4, 6], + "phase2": [6, 8], + "phase3": [8, 10], +} # Rematch tracking var rematch_votes: Array = [] # [player_id, ...] @@ -90,7 +94,7 @@ var rematch_votes: Array = [] # [player_id, ...] # Character and area selection var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"] var available_areas: Array[String] = [] -var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Cannon Survival"] +var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Pump Survival"] var selected_area: String = "Freemode Arena" # Host-controlled var game_mode: String = "Freemode" # Host-controlled var local_character_index: int = 0 # Local player's character index @@ -145,7 +149,7 @@ func _update_available_areas(mode: String) -> void: available_areas = ["Freemode Arena", "Classic", "Colloseum"] "Stop n Go": available_areas = ["Stop N Go Arena"] - "Candy Cannon Survival": + "Candy Pump Survival": available_areas = ["Gauntlet Arena"] _: available_areas = ["Classic"] @@ -562,23 +566,23 @@ func sync_gauntlet_round_duration(duration: int) -> void: gauntlet_round_duration = duration emit_signal("gauntlet_round_duration_changed", duration) -func set_gauntlet_cannon_interval(interval: int) -> void: - gauntlet_cannon_interval = interval - if is_host: rpc("sync_gauntlet_cannon_interval", interval) +func set_gauntlet_growth_interval(interval: float) -> void: + gauntlet_growth_interval = interval + if is_host: rpc("sync_gauntlet_growth_interval", interval) @rpc("authority", "call_local", "reliable") -func sync_gauntlet_cannon_interval(interval: int) -> void: - gauntlet_cannon_interval = interval - emit_signal("gauntlet_cannon_interval_changed", interval) +func sync_gauntlet_growth_interval(interval: float) -> void: + gauntlet_growth_interval = interval + emit_signal("gauntlet_growth_interval_changed", interval) -func set_gauntlet_volley_size(size: int) -> void: - gauntlet_volley_size = size - if is_host: rpc("sync_gauntlet_volley_size", size) +func set_gauntlet_cells_per_tick(cells: Dictionary) -> void: + gauntlet_cells_per_tick = cells + if is_host: rpc("sync_gauntlet_cells_per_tick", cells) @rpc("authority", "call_local", "reliable") -func sync_gauntlet_volley_size(size: int) -> void: - gauntlet_volley_size = size - emit_signal("gauntlet_volley_size_changed", size) +func sync_gauntlet_cells_per_tick(cells: Dictionary) -> void: + gauntlet_cells_per_tick = cells + emit_signal("gauntlet_cells_per_tick_changed", cells) # ============================================================================= # Character Selection @@ -738,8 +742,8 @@ func set_game_mode(mode: String) -> void: set_area("Stop n Go Area") elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas: set_area("Tekton Doors Area") - elif mode == "Gauntlet" and "Candy Pump Arena" in available_areas: - set_area("Candy Pump Arena") + elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas: + set_area("Gauntlet Arena") @rpc("authority", "call_local", "reliable") func sync_game_mode(mode: String) -> void: @@ -754,8 +758,8 @@ func sync_game_mode(mode: String) -> void: selected_area = "Stop n Go Area" elif mode == "Tekton Doors" and "Tekton Doors Area" in available_areas: selected_area = "Tekton Doors Area" - elif mode == "Gauntlet" and "Candy Pump Arena" in available_areas: - selected_area = "Candy Pump Arena" + elif mode == "Candy Pump Survival" and "Gauntlet Arena" in available_areas: + selected_area = "Gauntlet Arena" elif selected_area not in available_areas: selected_area = available_areas[0] @@ -786,8 +790,8 @@ func start_game(force: bool = false) -> void: rpc("sync_doors_required_goals", doors_required_goals) # Sync gauntlet settings rpc("sync_gauntlet_round_duration", gauntlet_round_duration) - rpc("sync_gauntlet_cannon_interval", gauntlet_cannon_interval) - rpc("sync_gauntlet_volley_size", gauntlet_volley_size) + rpc("sync_gauntlet_growth_interval", gauntlet_growth_interval) + rpc("sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick) # Sync game mode rpc("sync_game_mode", game_mode) @@ -864,8 +868,8 @@ func request_room_info(requester_id: int, requester_name: String, requester_char rpc_id(requester_id, "sync_doors_refresh_time", doors_refresh_time) rpc_id(requester_id, "sync_doors_required_goals", doors_required_goals) rpc_id(requester_id, "sync_gauntlet_round_duration", gauntlet_round_duration) - rpc_id(requester_id, "sync_gauntlet_cannon_interval", gauntlet_cannon_interval) - rpc_id(requester_id, "sync_gauntlet_volley_size", gauntlet_volley_size) + rpc_id(requester_id, "sync_gauntlet_growth_interval", gauntlet_growth_interval) + rpc_id(requester_id, "sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick) rpc_id(requester_id, "sync_game_mode", game_mode) rpc_id(requester_id, "sync_area", selected_area) diff --git a/scripts/managers/mail_manager.gd b/scripts/managers/mail_manager.gd index 0fdaeab..6ba1996 100644 --- a/scripts/managers/mail_manager.gd +++ b/scripts/managers/mail_manager.gd @@ -29,10 +29,20 @@ func fetch_mails() -> void: var payload = result.get("data", {}) if payload and payload is Dictionary: - mails = payload.get("mails", []) + var raw_mails = payload.get("mails", []) + if typeof(raw_mails) == TYPE_ARRAY: + mails = raw_mails + elif typeof(raw_mails) == TYPE_DICTIONARY: + mails = raw_mails.values() + else: + mails = [] var state = payload.get("state", {}) - claimed_ids = state.get("claimed_ids", []) - read_ids = state.get("read_ids", []) + if typeof(state) != TYPE_DICTIONARY: + state = {} + var raw_claimed_ids = state.get("claimed_ids", []) + var raw_read_ids = state.get("read_ids", []) + claimed_ids = raw_claimed_ids if typeof(raw_claimed_ids) == TYPE_ARRAY else [] + read_ids = raw_read_ids if typeof(raw_read_ids) == TYPE_ARRAY else [] # Sort by date descending mails.sort_custom(func(a, b): diff --git a/scripts/managers/player_movement_manager.gd b/scripts/managers/player_movement_manager.gd index 1d1e7aa..ba32599 100644 --- a/scripts/managers/player_movement_manager.gd +++ b/scripts/managers/player_movement_manager.gd @@ -135,13 +135,8 @@ func simple_move_to(grid_position: Vector2i) -> bool: var main_gauntlet = player.get_tree().root.get_node_or_null("Main") if main_gauntlet and main_gauntlet.get("gauntlet_manager"): gm = main_gauntlet.gauntlet_manager - - # Check if currently trapped - if gm and gm.is_active: - var pid = player.get("peer_id") if "peer_id" in player else -1 - if pid != -1 and gm.trapped_players.has(pid): - print("[Move] Failed: Player is trapped in a sticky cell") - return false + + # Sticky no longer hard-traps — players are slowed instead and can move freely. # Check for Tekton interaction (Charged Strike Mode) # If moving into a Tekton's space while Charged, trigger knock @@ -154,19 +149,19 @@ func simple_move_to(grid_position: Vector2i) -> bool: player.knock_tekton() return false # Don't move into the tile, just knock - # If moving into a sticky cell, trigger trap (unless cleanser active) + # If moving into a sticky cell: slow the player (unless cleanser active, + # which clears the cell instead). Sticky no longer hard-traps. if gm and gm.is_active and gm.is_sticky_cell(grid_position): var pid = player.get("peer_id") if "peer_id" in player else -1 if pid != -1 and gm.is_cleanser_active(pid): - # Cleanser immunity: clear sticky cell, use one cell, don't trap + # Cleanser immunity: clear sticky cell, use one cell, don't slow gm.clear_sticky_cell(grid_position) gm.use_cleanser_cell(pid) print("[Move] Cleanser cleared sticky cell at %s (%d cells left)" % [grid_position, gm.cleanser_cells_left.get(pid, 0)]) else: - print("[Move] Player stepping into sticky cell at %s" % grid_position) - movement_queue.clear() + print("[Move] Player stepping into sticky cell at %s — slowed" % grid_position) if player.is_multiplayer_authority() or multiplayer.is_server(): - gm._trap_player(player) + gm.apply_sticky_slow(player) rotate_towards_target(grid_position) @@ -348,9 +343,9 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool: gm_sticky.use_cleanser_cell(push_pid) print("[Move] Cleanser cleared push-into-sticky at %s" % pushed_to_pos) else: - print("[Move] Player pushed into sticky cell at %s" % pushed_to_pos) + print("[Move] Player pushed into sticky cell at %s — slowed" % pushed_to_pos) if multiplayer.is_server() or other_player.is_multiplayer_authority(): - gm_sticky._trap_player(other_player) + gm_sticky.apply_sticky_slow(other_player) # 2. Apply freeze/stun effect var stun_duration = 1.0 if (gm_push and gm_push.is_active) else 1.5 @@ -397,7 +392,7 @@ func set_speed_multiplier(multiplier: float): func _on_movement_finished(): if not movement_queue.is_empty(): var next_target = movement_queue.pop_front() - # Use a small delay or call_deferred to avoid recursion issues, + # Use a small delay or call_deferred to avoid recursion issues, # but keep it snappy by executing immediately if possible. if not simple_move_to(next_target): # If next move failed, clear queue and signal finished @@ -406,6 +401,17 @@ func _on_movement_finished(): emit_signal("movement_finished") else: current_move_direction = Vector2i.ZERO + # Gauntlet (#072): a Cleanser ends early once the player rests on a safe + # cell. Gated on gm.is_active so other game modes are never affected. + var gm = null + var main_node = player.get_tree().root.get_node_or_null("Main") + if main_node and main_node.get("gauntlet_manager"): + gm = main_node.gauntlet_manager + if gm and gm.is_active and player.get("current_position") != null: + var mpid = player.get("peer_id") if "peer_id" in player else -1 + if mpid != -1 and gm.is_cleanser_active(mpid): + if multiplayer.is_server() or player.is_multiplayer_authority(): + gm.notify_movement_stopped(mpid, player.current_position) emit_signal("movement_finished") func move_to_clicked_position(grid_position: Vector2i) -> bool: diff --git a/scripts/mode_config.gd b/scripts/mode_config.gd index 46c5eb7..5a12da3 100644 --- a/scripts/mode_config.gd +++ b/scripts/mode_config.gd @@ -24,10 +24,10 @@ const SCHEMA = { "doors_refresh_time": {"type": TYPE_INT, "default": 25, "min": 15, "max": 40}, "doors_required_goals": {"type": TYPE_INT, "default": 8, "min": 5, "max": 12} }, - "Candy Cannon Survival": { + "Candy Pump Survival": { "match_duration": {"type": TYPE_INT, "default": 180, "min": 60, "max": 600}, - "gauntlet_cannon_interval": {"type": TYPE_FLOAT, "default": 5.0, "min": 2.0, "max": 10.0}, - "gauntlet_volley_size": {"type": TYPE_INT, "default": 5, "min": 3, "max": 15} + "gauntlet_growth_interval": {"type": TYPE_FLOAT, "default": 3.0, "min": 1.0, "max": 10.0}, + "gauntlet_cells_per_tick": {"type": TYPE_DICTIONARY, "default": {"phase1": [4, 6], "phase2": [6, 8], "phase3": [8, 10]}} } } diff --git a/scripts/ui/admin_panel.gd b/scripts/ui/admin_panel.gd index ba669b8..0d34f92 100644 --- a/scripts/ui/admin_panel.gd +++ b/scripts/ui/admin_panel.gd @@ -197,14 +197,15 @@ func _setup_columns() -> void: _mail_root = mail_tree.create_item() # Chat Storage - chat_tree.set_column_title(0, "Sender") - chat_tree.set_column_title(1, "Content") - chat_tree.set_column_title(2, "Date") - chat_tree.set_column_title(3, "ID") - chat_tree.set_column_custom_minimum_width(0, 100) - chat_tree.set_column_expand(1, true) - chat_tree.set_column_custom_minimum_width(2, 120) - chat_tree.set_column_custom_minimum_width(3, 100) + chat_tree.set_column_title(0, "Select") + chat_tree.set_column_title(1, "Sender") + chat_tree.set_column_title(2, "Content") + chat_tree.set_column_title(3, "Date / ID") + chat_tree.set_column_custom_minimum_width(0, 70) + chat_tree.set_column_expand(0, false) + chat_tree.set_column_custom_minimum_width(1, 100) + chat_tree.set_column_expand(2, true) + chat_tree.set_column_custom_minimum_width(3, 180) _chat_tree_root = chat_tree.create_item() func _connect_signals() -> void: @@ -256,8 +257,8 @@ func _connect_signals() -> void: # Chat Storage actions load_messages_btn.pressed.connect(_on_load_chat_messages) - refresh_chat_btn.pressed.connect(_on_load_chat_messages) - delete_selected_btn.pressed.connect(_on_delete_chat_message) + refresh_chat_btn.pressed.connect(_on_load_more_chat_messages) + delete_selected_btn.pressed.connect(_on_delete_selected_chat_messages) # ============================================================================= # Core Panel Logic @@ -281,6 +282,8 @@ func _on_tab_changed(tab_index: int) -> void: await _load_leaderboard() elif tab_index == 2: await _load_daily_rewards_config() + elif tab_index == 3: + _update_announcement_count() elif tab_index == 4: await _load_mail() elif tab_index == 5: @@ -371,12 +374,15 @@ func _on_user_tree_button_clicked(item: TreeItem, _col: int, _id: int, _mouse: i func _show_edit_user_dialog(user: Dictionary) -> void: var uid: String = user.get("user_id", "") var uname: String = user.get("username", "") + var display_name: String = user.get("display_name", uname) var role: String = user.get("role", "player") var banned: bool = user.get("banned", false) + var detail := await _rpc("admin_get_user_detail", {"user_id": uid}) + var detail_user: Dictionary = detail.get("user", {}) if not detail.has("error") else {} var dialog := AcceptDialog.new() dialog.title = "Edit User: " + uname - dialog.min_size = Vector2i(380, 260) + dialog.min_size = Vector2i(460, 420) var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 8) @@ -385,6 +391,25 @@ func _show_edit_user_dialog(user: Dictionary) -> void: id_lbl.add_theme_color_override("font_color", CLR_DIM) vbox.add_child(id_lbl) + var email_lbl := Label.new() + var email = detail_user.get("email", "") + var verified = detail_user.get("email_verified", false) + email_lbl.text = "Email: %s (%s)" % [email if not str(email).is_empty() else "none", "verified" if verified else "unverified"] + email_lbl.add_theme_color_override("font_color", CLR_DIM) + vbox.add_child(email_lbl) + + var name_grid := GridContainer.new() + name_grid.columns = 2 + name_grid.add_theme_constant_override("h_separation", 8) + name_grid.add_theme_constant_override("v_separation", 8) + var username_lbl := Label.new(); username_lbl.text = "Username:"; name_grid.add_child(username_lbl) + var username_input := LineEdit.new(); username_input.text = detail_user.get("username", uname); name_grid.add_child(username_input) + var display_lbl := Label.new(); display_lbl.text = "Display Name:"; name_grid.add_child(display_lbl) + var display_input := LineEdit.new(); display_input.text = detail_user.get("display_name", display_name); name_grid.add_child(display_input) + var password_lbl := Label.new(); password_lbl.text = "New Password:"; name_grid.add_child(password_lbl) + var password_input := LineEdit.new(); password_input.placeholder_text = "Leave empty to keep"; password_input.secret = true; name_grid.add_child(password_input) + vbox.add_child(name_grid) + var role_hbox := HBoxContainer.new() var role_lbl := Label.new() role_lbl.text = "Role: " @@ -419,18 +444,31 @@ func _show_edit_user_dialog(user: Dictionary) -> void: save_btn.pressed.connect(func(): var new_role: String = roles[role_option.selected] - await _save_user_edit(uid, uname, new_role, ban_check.button_pressed, reason_input.text) + await _save_user_edit(uid, username_input.text.strip_edges(), display_input.text.strip_edges(), password_input.text, new_role, ban_check.button_pressed, reason_input.text) dialog.queue_free() ) -func _save_user_edit(uid: String, uname: String, new_role: String, new_banned: bool, reason: String) -> void: +func _save_user_edit(uid: String, uname: String, display_name: String, new_password: String, new_role: String, new_banned: bool, reason: String) -> void: _set_status("Saving...") + var identity_res := await _rpc("admin_update_user_identity", { + "user_id": uid, + "username": uname, + "display_name": display_name + }) + if identity_res.has("error"): + _set_status("Identity save failed: " + str(identity_res.error), CLR_STATUS_ERR) + return + if not new_password.strip_edges().is_empty(): + var password_res := await _rpc("admin_set_user_password", {"user_id": uid, "password": new_password}) + if password_res.has("error"): + _set_status("Password save failed: " + str(password_res.error), CLR_STATUS_ERR) + return await _rpc("admin_set_user_role", {"user_id": uid, "role": new_role}) if new_banned: await _rpc("admin_ban_player", {"user_id": uid, "reason": reason, "duration_hours": 0}) else: await _rpc("admin_unban_player", {"user_id": uid}) - _set_status("Saved: " + uname, CLR_STATUS_OK) + _set_status("Saved: " + display_name, CLR_STATUS_OK) await _load_users() # ============================================================================= @@ -529,19 +567,47 @@ func _on_history_pressed() -> void: return var uid = selected_data[0].get("user_id", "") - _set_status("Fetching history for user...", CLR_STATUS_OK) + _set_status("Fetching user details...", CLR_STATUS_OK) + var detail_res = await _rpc("admin_get_user_detail", {"user_id": uid}) var res = await _rpc("admin_get_user_history", {"user_id": uid}) - if res.has("error"): - _set_status("Failed to get history: " + str(res.error), CLR_STATUS_ERR) + if detail_res.has("error") and res.has("error"): + _set_status("Failed to get user details: " + str(detail_res.error), CLR_STATUS_ERR) return - _set_status("History loaded.", CLR_STATUS_OK) + _set_status("User details loaded.", CLR_STATUS_OK) - var h = res.get("history", {}) - var text = "[b]=== USER HISTORY ===[/b]\n" + var h = res.get("history", {}) if not res.has("error") else {} + var details = detail_res if not detail_res.has("error") else {} + var detail_user: Dictionary = details.get("user", {}) + var text = "[b]=== USER DETAIL ===[/b]\n" text += "User ID: " + uid + "\n\n" + text += "[b]-- Account --[/b]\n" + text += "Username: %s\n" % detail_user.get("username", "") + text += "Display Name: %s\n" % detail_user.get("display_name", "") + text += "Email: %s (%s)\n" % [detail_user.get("email", "none"), "verified" if detail_user.get("email_verified", false) else "unverified"] + text += "Created: %s\n" % str(detail_user.get("create_time", "")) + text += "Wallet: %s\n" % str(detail_user.get("wallet", {})) + text += "Subscription: %s\n\n" % str(details.get("subscription", {})) + text += "[b]-- Friends --[/b]\n" + var friends = details.get("friends", []) + if friends.is_empty(): + text += "No friends found.\n" + else: + for f in friends: + text += "- %s (%s) state=%s\n" % [f.get("username", ""), f.get("user_id", ""), str(f.get("state", ""))] + text += "\n" + + text += "[b]-- Purchase History / Receipts --[/b]\n" + var purchases = details.get("purchases", []) + if purchases.is_empty(): + text += "No purchases found.\n" + else: + for p in purchases: + text += "- %s: %s\n" % [p.get("key", ""), str(p.get("value", {}))] + text += "\n" + # Logins text += "[b]-- Recent Logins --[/b]\n" var logins = h.get("logins", []) @@ -575,6 +641,17 @@ func _on_history_pressed() -> void: else: for m in matches: text += "- " + str(m) + "\n" + + text += "\n[b]-- Storage Objects --[/b]\n" + var storage = details.get("storage", {}) + if storage.is_empty(): + text += "No storage objects found.\n" + else: + for collection in storage.keys(): + var objects = storage[collection] + text += "\n[b]%s[/b] (%d)\n" % [collection, objects.size()] + for obj in objects: + text += "- %s: %s\n" % [obj.get("key", ""), str(obj.get("value", {}))] history_text.text = text history_dialog.popup_centered() @@ -614,7 +691,15 @@ func _load_leaderboard() -> void: return var raw_lb = res.get("leaderboard", []) - lb_data = raw_lb if typeof(raw_lb) == TYPE_ARRAY else [] + if typeof(raw_lb) == TYPE_ARRAY: + lb_data = raw_lb + elif typeof(raw_lb) == TYPE_DICTIONARY: + lb_data = raw_lb.values() + else: + lb_data = [] + + if lb_data.is_empty(): + lb_data = await _fetch_native_leaderboard_for_admin() count_label.text = "%d records" % lb_data.size() lb_data.sort_custom(func(a, b): return a.get("high_score", 0) > b.get("high_score", 0)) @@ -631,6 +716,37 @@ func _load_leaderboard() -> void: rank += 1 _set_status("") +func _fetch_native_leaderboard_for_admin() -> Array: + var result = await NakamaManager.client.list_leaderboard_records_async( + NakamaManager.session, + "global_high_score", + [], + null, + 100 + ) + if result.is_exception(): + push_warning("[AdminPanel] Native leaderboard load failed: " + result.get_exception().message) + return [] + + var data: Array = [] + for record in result.records: + var meta: Dictionary = {} + if record.metadata and not record.metadata.is_empty(): + var parsed = JSON.parse_string(record.metadata) + if parsed is Dictionary: + meta = parsed + data.append({ + "user_id": record.owner_id, + "username": record.username, + "display_name": record.username if (record.username and not record.username.is_empty()) else "Unknown", + "avatar_url": meta.get("avatar_url", ""), + "loadout_character": meta.get("loadout_character", "Copper"), + "high_score": int(record.score), + "games_played": int(meta.get("games_played", 0)), + "games_won": int(meta.get("games_won", 0)) + }) + return data + func _on_lb_tree_button_clicked(item: TreeItem, _col: int, _id: int, _mouse: int) -> void: _show_edit_score_dialog(item.get_metadata(0)) @@ -729,6 +845,7 @@ func _load_daily_rewards_config() -> void: month_option_btn.select(0) _build_dr_grid() + _update_daily_reward_count() _set_status("Config Loaded", CLR_STATUS_OK) func _on_dr_month_selected(index: int) -> void: @@ -737,6 +854,7 @@ func _on_dr_month_selected(index: int) -> void: _current_dr_month = month_option_btn.get_item_metadata(index) _build_dr_grid() + _update_daily_reward_count() func _save_current_grid_to_dict() -> void: if _current_dr_month.is_empty(): return @@ -784,6 +902,11 @@ func _build_dr_grid() -> void: spin.value = int(rdata) opt.select(0) +func _update_daily_reward_count() -> void: + var rewards = _daily_reward_config_data.get(_current_dr_month, []) + var count: int = rewards.size() if typeof(rewards) == TYPE_ARRAY else 0 + count_label.text = "%d reward days" % count + func _save_daily_rewards_config() -> void: _save_current_grid_to_dict() _set_status("Saving config...") @@ -802,7 +925,15 @@ func _on_add_reward_pressed() -> void: row.visible = true rewards_list.add_child(row) var remove_btn = row.get_node("RemoveBtn") as Button - remove_btn.pressed.connect(func(): row.queue_free()) + remove_btn.pressed.connect(func(): row.queue_free(); _update_announcement_count()) + _update_announcement_count() + +func _update_announcement_count() -> void: + var count := 0 + for child in rewards_list.get_children(): + if child.visible: + count += 1 + count_label.text = "%d rewards attached" % count func _on_find_user() -> void: var input = target_user_edit.text.strip_edges() @@ -927,6 +1058,7 @@ func _on_send_mail() -> void: end_date_edit.clear_date() for child in rewards_list.get_children(): if child.visible: child.queue_free() + _update_announcement_count() # ============================================================================= # TAB 5: MAIL MANAGER @@ -1245,6 +1377,7 @@ func _save_featured_banners() -> void: # ============================================================================= func _load_chat_config() -> void: chat_status_label.text = "Loading config..." + count_label.text = "chat config" var res := await _rpc("admin_get_chat_config", {}) if res.has("error"): chat_status_label.text = "Failed: " + str(res.error) @@ -1344,18 +1477,19 @@ func _on_save_chat_config() -> void: func _on_load_chat_messages() -> void: var channel_id := chat_channel_id_edit.text.strip_edges() if channel_id.is_empty(): - _set_status("Enter a Channel ID first.", CLR_STATUS_ERR) - return + # Default to the global lobby room rather than erroring out. + channel_id = "social_global" + chat_channel_id_edit.text = channel_id - # Auto-resolve "social_global" to the actual Nakama Channel ID if the admin is in the lobby + # Best-effort: resolve "social_global" to the real hashed Nakama Channel ID so + # the admin sees it in the UI. If resolution fails (not in lobby / socket + # down), fall through with the room name — the server resolves it + # authoritatively via nk.channel_id_build. if channel_id == "social_global": - var lobby = get_tree().get_first_node_in_group("Lobby") - if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"): - channel_id = lobby.chat._chat_channel.id - chat_channel_id_edit.text = channel_id # Update UI so admin sees the real ID - else: - _set_status("Cannot resolve social_global. Join chat first.", CLR_STATUS_ERR) - return + var resolved := await _resolve_global_chat_channel_id() + if not resolved.is_empty(): + channel_id = resolved + chat_channel_id_edit.text = channel_id # show the admin the real ID _chat_channel_id = channel_id _chat_cursor = "" @@ -1364,6 +1498,35 @@ func _on_load_chat_messages() -> void: await _fetch_chat_messages_batch() +func _resolve_global_chat_channel_id() -> String: + # Nakama Room channel IDs are deterministically hashed from the type and room name. + # For type=2 (Room) and name="social_global", the ID format is always: + # "2." + uri_encoded_room_name + "." # no domain needed for rooms. + # But Nakama's format often just uses "2.RoomName." - let's ensure we try the exact determinism if socket fails. + + var lobby = get_tree().get_first_node_in_group("Lobby") + if lobby and lobby.get("chat") and lobby.chat.get("_chat_channel"): + return lobby.chat._chat_channel.id + + var socket = NakamaManager.socket + if socket and socket.is_connected_to_host(): + var result = await socket.join_chat_async("social_global", NakamaSocket.ChannelType.Room, true, false) + if not result.is_exception(): + return result.id + + # Fallback if no socket or join failed: construct the exact ID the Web UI expects. + # Type 2 (Room), Name "social_global" + return "2." + "social_global".uri_encode() + "." + +func _on_load_more_chat_messages() -> void: + if _chat_channel_id.is_empty(): + await _on_load_chat_messages() + return + if _chat_cursor.is_empty(): + _set_status("No more messages to load.", CLR_STATUS_OK) + return + await _fetch_chat_messages_batch() + func _fetch_chat_messages_batch() -> void: _set_status("Loading messages...") var payload := { @@ -1380,19 +1543,42 @@ func _fetch_chat_messages_batch() -> void: var msgs = res.get("messages", []) var next_cursor = res.get("next_cursor", "") + if (typeof(msgs) == TYPE_DICTIONARY and msgs.is_empty()) or (typeof(msgs) == TYPE_ARRAY and msgs.is_empty()): + var fallback = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, _chat_channel_id, 50, false, _chat_cursor) + if not fallback.is_exception(): + msgs = fallback.messages if fallback.messages else [] + next_cursor = fallback.next_cursor + else: + _set_status("Failed: " + fallback.get_exception().message, CLR_STATUS_ERR) + return + if typeof(msgs) == TYPE_DICTIONARY: + msgs = msgs.values() + elif typeof(msgs) != TYPE_ARRAY: + msgs = [] + var added_count := 0 - for msg in msgs: + for raw_msg in msgs: + var msg := _normalize_chat_storage_message(raw_msg) + if msg.is_empty(): + continue _chat_messages_data.append(msg) var item := _chat_tree_root.create_child() - item.set_text(0, msg.get("username", msg.get("sender_id", "?").substr(0, 8))) - item.set_text(1, msg.get("content", "")) - item.set_text(2, msg.get("create_time", "").substr(0, 19).replace("T", " ")) + item.set_cell_mode(0, TreeItem.CELL_MODE_CHECK) + item.set_editable(0, true) + item.set_text(1, msg.get("username", msg.get("sender_id", "?").substr(0, 8))) + item.set_text(2, _format_chat_storage_content(msg.get("content", ""))) + item.set_text(3, msg.get("create_time", "").substr(0, 19).replace("T", " ")) var mid = msg.get("message_id", "") - item.set_text(3, mid) item.set_tooltip_text(3, mid) item.set_metadata(0, msg) + added_count += 1 count_label.text = "%d messages loaded" % _chat_messages_data.size() + chat_tree.queue_redraw() + + if added_count == 0: + _set_status("No stored messages returned for channel: " + _chat_channel_id, CLR_STATUS_ERR) + return if not next_cursor.is_empty(): _chat_cursor = next_cursor @@ -1401,37 +1587,75 @@ func _fetch_chat_messages_batch() -> void: _chat_cursor = "" _set_status("All messages loaded.", CLR_STATUS_OK) -func _on_delete_chat_message() -> void: - var item = chat_tree.get_selected() - if not item: - _set_status("Select a message to delete.", CLR_STATUS_ERR) - return +func _format_chat_storage_content(content) -> String: + if typeof(content) == TYPE_DICTIONARY: + return str(content.get("msg", content)) - var msg = item.get_metadata(0) - if not msg: - return + var text := str(content) + var parsed = JSON.parse_string(text) + if typeof(parsed) == TYPE_DICTIONARY: + return str(parsed.get("msg", text)) + return text - var msg_id = msg.get("message_id", "") - if msg_id.is_empty(): +func _normalize_chat_storage_message(raw_msg) -> Dictionary: + if typeof(raw_msg) == TYPE_DICTIONARY: + return raw_msg + if typeof(raw_msg) != TYPE_OBJECT: + return {} + return { + "message_id": raw_msg.message_id, + "sender_id": raw_msg.sender_id, + "username": raw_msg.username, + "content": raw_msg.content, + "create_time": raw_msg.create_time, + "update_time": raw_msg.update_time, + "channel_id": raw_msg.channel_id + } + +func _on_delete_selected_chat_messages() -> void: + var items := _get_checked_chat_items() + if items.is_empty(): + var selected = chat_tree.get_selected() + if selected: + items.append(selected) + if items.is_empty(): + _set_status("Select one or more messages to delete.", CLR_STATUS_ERR) return var confirm := ConfirmationDialog.new() - confirm.title = "Delete Message?" - confirm.dialog_text = "Permanently delete message from " + msg.get("username", "?") + "?" + confirm.title = "Delete %d Message(s)?" % items.size() + confirm.dialog_text = "Permanently delete selected chat messages?" add_child(confirm) confirm.popup_centered() confirm.confirmed.connect(func(): - _set_status("Deleting message...") - var res = await _rpc("admin_delete_channel_message", { - "channel_id": _chat_channel_id, - "message_id": msg_id - }) - if res.get("success", false): - _set_status("Message deleted!", CLR_STATUS_OK) - chat_tree.get_root().remove_child(item) - item.free() - count_label.text = "%d messages loaded" % _chat_messages_data.size() - else: - _set_status("Failed: " + str(res.get("error", "")), CLR_STATUS_ERR) + _set_status("Deleting %d message(s)..." % items.size()) + var deleted := 0 + for item in items: + var msg = item.get_metadata(0) + if typeof(msg) != TYPE_DICTIONARY: + continue + var msg_id = msg.get("message_id", "") + if msg_id.is_empty(): + continue + var res = await _rpc("admin_delete_channel_message", { + "channel_id": _chat_channel_id, + "message_id": msg_id + }) + if res.get("success", false): + deleted += 1 + _chat_messages_data.erase(msg) + _chat_tree_root.remove_child(item) + item.free() + count_label.text = "%d messages loaded" % _chat_messages_data.size() + _set_status("Deleted %d message(s)" % deleted, CLR_STATUS_OK if deleted > 0 else CLR_STATUS_ERR) confirm.queue_free() ) + +func _get_checked_chat_items() -> Array: + var items: Array = [] + var child = _chat_tree_root.get_first_child() if _chat_tree_root else null + while child: + if child.is_checked(0): + items.append(child) + child = child.get_next() + return items diff --git a/server/nakama/lua/admin.lua b/server/nakama/lua/admin.lua index 5055466..f90cca1 100644 --- a/server/nakama/lua/admin.lua +++ b/server/nakama/lua/admin.lua @@ -227,6 +227,7 @@ function admin.rpc_admin_clear_global_chat(context, payload) if channelId == "" then error("channel_id is required. Pass the channel ID from the client.") end + channelId = utils.resolve_channel_id(channelId) local deleted = 0 local cursor = "" @@ -319,6 +320,121 @@ function admin.rpc_admin_delete_users(context, payload) return nk.json_encode({ success = true, deleted = deleted, failed = failed }) end +function admin.rpc_admin_get_user_detail(context, payload) + utils.require_admin(context) + local request = nk.json_decode(payload or "{}") + local userId = request.user_id or "" + if userId == "" then error("user_id is required") end + + local account = nk.account_get_id(userId) + local metadata = {} + if account.user.metadata then + local s, m = pcall(nk.json_decode, account.user.metadata) + if s and m then metadata = m end + end + + local friends = {} + local okFriends, friendResult = pcall(nk.friends_list, userId, nil, 100, "") + if okFriends and friendResult and friendResult.friends then + for _, f in pairs(friendResult.friends) do + table.insert(friends, { + user_id = f.user.id, + username = f.user.username, + display_name = f.user.display_name, + state = f.state + }) + end + end + + local purchaseHistory = {} + local okReceipts, receiptResult = pcall(nk.storage_list, userId, "receipts", 100, "") + if okReceipts and receiptResult and receiptResult.objects then + for _, obj in pairs(receiptResult.objects) do + table.insert(purchaseHistory, { key = obj.key, value = obj.value, update_time = obj.update_time }) + end + end + + local collections = request.collections or {"profiles", "stats", "inventory", "receipts", "history", "matches", "inbox"} + local storage = {} + for _, collection in ipairs(collections) do + local okStorage, storageResult = pcall(nk.storage_list, userId, collection, 100, "") + storage[collection] = {} + if okStorage and storageResult and storageResult.objects then + for _, obj in pairs(storageResult.objects) do + table.insert(storage[collection], { key = obj.key, value = obj.value, version = obj.version, update_time = obj.update_time }) + end + end + end + + local walletLedger = {} + local okLedger, ledgerResult = pcall(nk.wallet_ledger_list, userId, 50) + if okLedger and ledgerResult then walletLedger = ledgerResult.items or {} end + + return nk.json_encode({ + user = { + user_id = account.user.id, + username = account.user.username or "", + display_name = account.user.display_name or "", + avatar_url = account.user.avatar_url or "", + lang_tag = account.user.lang_tag or "", + location = account.user.location or "", + timezone = account.user.timezone or "", + create_time = account.user.create_time or "", + update_time = account.user.update_time or "", + metadata = metadata, + wallet = account.wallet or {}, + email = account.email or "", + email_verified = account.email_verified or false + }, + friends = friends, + purchases = purchaseHistory, + wallet_ledger = walletLedger, + storage = storage, + subscription = metadata.subscription or metadata.subscriptions or {} + }) +end + +function admin.rpc_admin_update_user_identity(context, payload) + utils.require_admin(context) + local request = nk.json_decode(payload or "{}") + local userId = request.user_id or "" + if userId == "" then error("user_id is required") end + + local account = nk.account_get_id(userId) + local metadata = {} + if account.user.metadata then + local s, m = pcall(nk.json_decode, account.user.metadata) + if s and m then metadata = m end + end + if request.metadata and type(request.metadata) == "table" then + for k, v in pairs(request.metadata) do metadata[k] = v end + end + + nk.account_update_id( + userId, + request.username or account.user.username, + request.display_name or account.user.display_name, + request.timezone or account.user.timezone, + request.location or account.user.location, + request.lang_tag or account.user.lang_tag, + request.avatar_url or account.user.avatar_url, + nk.json_encode(metadata) + ) + return nk.json_encode({ success = true }) +end + +function admin.rpc_admin_set_user_password(context, payload) + utils.require_admin(context) + local request = nk.json_decode(payload or "{}") + local userId = request.user_id or "" + local password = request.password or "" + if userId == "" or password == "" then error("user_id and password are required") end + local account = nk.account_get_id(userId) + if not account.email or account.email == "" then error("User has no email credential") end + nk.account_update_id(userId, nil, nil, nil, nil, nil, nil, nil, account.email, password) + return nk.json_encode({ success = true }) +end + function admin.rpc_admin_get_player_list(context, payload) local request = nk.json_decode(payload) utils.require_admin_or_host(context, request.match_id) @@ -386,6 +502,7 @@ function admin.rpc_admin_purge_old_messages(context, payload) if channelId == "" then error("channel_id is required") end + channelId = utils.resolve_channel_id(channelId) if maxAgeDays <= 0 then error("max_age_days must be > 0") end @@ -441,6 +558,7 @@ function admin.rpc_admin_list_channel_messages(context, payload) error("channel_id is required") end + channelId = utils.resolve_channel_id(channelId) local status, result = pcall(nk.channel_messages_list, channelId, limit, forward, cursor) if not status then error("Failed to list messages: " .. tostring(result)) @@ -448,7 +566,7 @@ function admin.rpc_admin_list_channel_messages(context, payload) local msgs = {} if result and result.messages then - for _, msg in ipairs(result.messages) do + for _, msg in pairs(result.messages) do table.insert(msgs, { message_id = msg.message_id, sender_id = msg.sender_id, @@ -463,6 +581,7 @@ function admin.rpc_admin_list_channel_messages(context, payload) return nk.json_encode({ messages = msgs, + channel_id = channelId, next_cursor = result.next_cursor or "", cache_cursor = result.cache_cursor or "" }) @@ -478,6 +597,7 @@ function admin.rpc_admin_delete_channel_message(context, payload) error("channel_id and message_id are required") end + channelId = utils.resolve_channel_id(channelId) local status, err = pcall(nk.channel_message_remove, channelId, messageId) if not status then error("Failed to delete message: " .. tostring(err)) @@ -500,6 +620,9 @@ nk.register_rpc(admin.rpc_admin_topup_gold, "admin_topup_gold") nk.register_rpc(admin.rpc_admin_clear_global_chat, "admin_clear_global_chat") nk.register_rpc(admin.rpc_admin_list_users, "admin_list_users") nk.register_rpc(admin.rpc_admin_delete_users, "admin_delete_users") +nk.register_rpc(admin.rpc_admin_get_user_detail, "admin_get_user_detail") +nk.register_rpc(admin.rpc_admin_update_user_identity, "admin_update_user_identity") +nk.register_rpc(admin.rpc_admin_set_user_password, "admin_set_user_password") nk.register_rpc(admin.rpc_admin_get_chat_config, "admin_get_chat_config") nk.register_rpc(admin.rpc_admin_set_chat_config, "admin_set_chat_config") nk.register_rpc(admin.rpc_admin_purge_old_messages, "admin_purge_old_messages") diff --git a/server/nakama/lua/leaderboard.lua b/server/nakama/lua/leaderboard.lua index 8fb5d6e..1258ba8 100644 --- a/server/nakama/lua/leaderboard.lua +++ b/server/nakama/lua/leaderboard.lua @@ -14,7 +14,7 @@ function leaderboard.rpc_get_leaderboard_stats(context, payload) local leaderboardData = {} local ownerRecords = records_or_err.records or {} - for _, record in ipairs(ownerRecords) do + for _, record in pairs(ownerRecords) do local metadata = {} if record.metadata then local s, m = pcall(nk.json_decode, record.metadata) diff --git a/server/nakama/lua/utils.lua b/server/nakama/lua/utils.lua index 810157f..a16b4b2 100644 --- a/server/nakama/lua/utils.lua +++ b/server/nakama/lua/utils.lua @@ -50,4 +50,34 @@ function utils.require_admin_or_host(context, match_id) end end +-- Channel type constants for nk.channel_id_build (Nakama Lua runtime). +-- NOTE: these differ from the Godot client's NakamaSocket.ChannelType enum. +utils.CHANNEL_TYPE_ROOM = 1 +utils.CHANNEL_TYPE_DIRECT = 2 +utils.CHANNEL_TYPE_GROUP = 3 + +-- Resolve a chat channel identifier for admin chat RPCs. +-- +-- The client may send either an already-hashed Nakama channel ID, or a friendly +-- room name (e.g. "social_global"). A raw room name is NOT a valid channel ID for +-- nk.channel_messages_list, so we build the canonical hashed ID via the +-- authoritative nk.channel_id_build API (system user as sender → global room). +-- Returns the resolved channel ID, or the original value if it already looks +-- hashed / can't be built. +function utils.resolve_channel_id(channel_id) + if not channel_id or channel_id == "" then + return "" + end + -- A hashed Nakama channel ID contains a '.' separator; a plain room name does + -- not. Only treat dot-free values as room names needing resolution. + if string.find(channel_id, "%.") then + return channel_id + end + local status, built = pcall(nk.channel_id_build, "", channel_id, utils.CHANNEL_TYPE_ROOM) + if status and built and built ~= "" then + return built + end + return channel_id +end + return utils diff --git a/test_smack.py b/test_smack.py deleted file mode 100644 index aadae9a..0000000 --- a/test_smack.py +++ /dev/null @@ -1,55 +0,0 @@ -import re - -with open("scripts/managers/gauntlet_manager.gd", "r") as f: - content = f.read() - -new_process = """func _process(delta: float) -> void: - if not is_active: - return - - elapsed_time += delta - - # Phase escalation - _check_phase_transition() - - # Cannon timer (server only) - if multiplayer.is_server(): - cannon_timer -= delta - if cannon_timer <= 0.0: - _fire_volley() - cannon_timer = cannon_interval - - # Smack mechanic update - var all_players = get_tree().get_nodes_in_group("Players") - for player in all_players: - var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int() - - if not smack_cooldowns.has(pid) and not smack_charged.has(pid): - smack_cooldowns[pid] = SMACK_COOLDOWN - smack_charged[pid] = 0.0 - - if smack_cooldowns[pid] > 0: - smack_cooldowns[pid] -= delta - if smack_cooldowns[pid] <= 0: - smack_cooldowns[pid] = 0.0 - smack_charged[pid] = SMACK_CHARGE_WINDOW - if player.has_method("sync_modulate"): - if _can_rpc(): - player.rpc("sync_modulate", Color.PINK) - else: - player.sync_modulate(Color.PINK) - elif smack_charged[pid] > 0: - smack_charged[pid] -= delta - if smack_charged[pid] <= 0: - smack_charged[pid] = 0.0 - smack_cooldowns[pid] = SMACK_COOLDOWN - if player.has_method("sync_modulate"): - if _can_rpc(): - player.rpc("sync_modulate", Color.WHITE) - else: - player.sync_modulate(Color.WHITE)""" - -content = re.sub(r"func _process\(delta: float\) -> void:.*?(?=\n# =+)", new_process, content, flags=re.DOTALL) - -with open("scripts/managers/gauntlet_manager.gd", "w") as f: - f.write(content) diff --git a/tests/helpers/gridmap_mock.gd b/tests/helpers/gridmap_mock.gd new file mode 100644 index 0000000..63b0f08 --- /dev/null +++ b/tests/helpers/gridmap_mock.gd @@ -0,0 +1,24 @@ +extends Node + +# Minimal EnhancedGridMap stand-in for Gauntlet headless tests. Records +# set_cell_item calls so lifecycle tests can run the local sync path without a +# real GridMap. Only the surface the manager touches is implemented. + +var cell_size := Vector3(1, 1, 1) +var cells: Dictionary = {} # Vector3i -> item id +var astar_inits := 0 + +func set_cell_item(pos: Vector3i, item: int, _orientation: int = 0) -> void: + if item == -1: + cells.erase(pos) + else: + cells[pos] = item + +func get_cell_item(pos: Vector3i) -> int: + return cells.get(pos, -1) + +func initialize_astar() -> void: + astar_inits += 1 + +func update_grid_data() -> void: + pass diff --git a/tests/helpers/gridmap_mock.gd.uid b/tests/helpers/gridmap_mock.gd.uid new file mode 100644 index 0000000..5b4b56a --- /dev/null +++ b/tests/helpers/gridmap_mock.gd.uid @@ -0,0 +1 @@ +uid://b7ihsm80fbyb5 diff --git a/tests/helpers/main_mock.gd b/tests/helpers/main_mock.gd new file mode 100644 index 0000000..1835d5e --- /dev/null +++ b/tests/helpers/main_mock.gd @@ -0,0 +1,9 @@ +extends Node + +# Minimal "Main" stand-in for Gauntlet headless tests. Provides the RPC methods +# the GauntletManager calls on its main_scene so calls resolve without the full +# game scene. Methods are no-ops that just need to exist + be rpc-tagged. + +@rpc("any_peer", "call_local", "reliable") +func sync_grid_item(_x: int, _y: int, _z: int, _item: int) -> void: + pass diff --git a/tests/helpers/main_mock.gd.uid b/tests/helpers/main_mock.gd.uid new file mode 100644 index 0000000..a5d770d --- /dev/null +++ b/tests/helpers/main_mock.gd.uid @@ -0,0 +1 @@ +uid://ca04jq87bj3ap diff --git a/tests/test_gauntlet_bubble.gd b/tests/test_gauntlet_bubble.gd new file mode 100644 index 0000000..8cac01f --- /dev/null +++ b/tests/test_gauntlet_bubble.gd @@ -0,0 +1,163 @@ +extends GutTest + +# ============================================================================= +# Test: Gauntlet Candy Bubble System (v2) [Gauntlet #082] +# Covers bubble-specific scoring components, phase budgets, anti-stacking, +# the 3x3 blast footprint, and the grow→explode lifecycle. +# Runs headless (no multiplayer peer): elapsed_time = 0 so the final-30s window +# is inactive unless a test sets it directly. +# ============================================================================= + +const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") +const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd") +var manager +var main_mock: Node +var gridmap_mock: Node + +func before_each(): + main_mock = Node.new() + add_child(main_mock) + gridmap_mock = GridMapMock.new() + gridmap_mock.name = "EnhancedGridMap" + main_mock.add_child(gridmap_mock) + manager = GauntletManager.new() + main_mock.add_child(manager) + manager.initialize(main_mock, gridmap_mock) + manager.current_phase = 0 + +func after_each(): + if main_mock: + main_mock.queue_free() + +# Run a callable with the multiplayer peer detached so manager code takes the +# local (non-rpc) sync path — deterministic for headless lifecycle tests. +func _without_peer(fn: Callable) -> void: + var saved = multiplayer.multiplayer_peer + multiplayer.multiplayer_peer = null + fn.call() + multiplayer.multiplayer_peer = saved + +# ============================================================================= +# Phase budget +# ============================================================================= + +func test_bubble_budget_per_phase(): + manager.current_phase = 0 + assert_eq(manager._bubble_budget_for_phase(), 0, "Phase 1 → 0 bubbles") + manager.current_phase = 1 + assert_eq(manager._bubble_budget_for_phase(), 2, "Phase 2 → 2 bubbles") + manager.current_phase = 2 + assert_eq(manager._bubble_budget_for_phase(), 3, "Phase 3 → 3 bubbles") + +func test_phase_change_resets_counter(): + manager.bubbles_this_phase = 2 + manager._start_phase(manager.Phase.SURVIVAL_ENDGAME) + assert_eq(manager.bubbles_this_phase, 0, "Per-phase bubble count resets on phase change") + +# ============================================================================= +# Blast footprint (3x3, clipped) +# ============================================================================= + +func test_blast_is_3x3_in_open_area(): + var cells = manager._bubble_blast_cells(Vector2i(14, 14)) + assert_eq(cells.size(), 9, "Open-area bubble blast is 3x3 = 9 cells") + +func test_blast_clips_npc_zone(): + # Center adjacent to the NPC zone (9,9) clips blocked cells out. + var cells = manager._bubble_blast_cells(Vector2i(7, 9)) + assert_true(cells.size() < 9, "Blast near NPC zone is clipped below 9") + for c in cells: + assert_false(manager._is_npc_zone(c), "No blast cell lands in NPC zone") + +# ============================================================================= +# Scoring components +# ============================================================================= + +func test_bubble_camping_thresholds(): + var region: Vector2i = manager._region_of(Vector2i(8, 8)) + manager._camp_tracking[1] = {"region": region, "time": 6.0} + assert_eq(manager._bubble_score_camping(Vector2i(8, 8)), 40.0, ">5s = +40") + manager._camp_tracking[1]["time"] = 9.0 + assert_eq(manager._bubble_score_camping(Vector2i(8, 8)), 60.0, ">8s = +60") + +func test_bubble_player_cluster(): + var players = [Vector2i(5, 5), Vector2i(6, 6)] + assert_eq(manager._bubble_score_player_cluster(Vector2i(5, 6), players), 20.0, "2 nearby players = +20") + assert_eq(manager._bubble_score_player_cluster(Vector2i(15, 15), players), 0.0, "No nearby players = 0") + +func test_bubble_direct_hit_penalty(): + var players = [Vector2i(5, 5)] + assert_eq(manager._bubble_score_direct_hit(Vector2i(5, 5), players), -60.0, "Directly under player = -60") + assert_eq(manager._bubble_score_direct_hit(Vector2i(8, 8), players), 0.0, "Not under player = 0") + +func test_bubble_recent_penalty(): + manager.recent_bubble_positions = [Vector2i(14, 14)] + assert_eq(manager._bubble_score_recent(Vector2i(11, 11)), -50.0, "Near recent bubble = -50") + assert_eq(manager._bubble_score_recent(Vector2i(2, 2)), 0.0, "Far from recent bubble = 0") + +func test_bubble_untouched_area(): + # Open arena around (10,10) → large reachable region → +30. + assert_eq(manager._bubble_score_untouched_area(Vector2i(14, 14)), 30.0, "Large untouched area = +30") + +func test_bubble_full_score_is_finite(): + var s = manager._calculate_bubble_score(Vector2i(8, 8), []) + assert_true(is_finite(s), "Full bubble score is finite") + +# ============================================================================= +# Spawn lifecycle +# ============================================================================= + +func test_spawn_bubble_marks_growing_cells(): + _without_peer(func(): + manager._spawn_bubble(Vector2i(14, 14)) + ) + assert_eq(manager.bubbles_this_phase, 1, "Phase counter increments") + assert_eq(manager.bubbles_total, 1, "Round counter increments") + assert_eq(manager.active_bubbles.size(), 1, "One active bubble") + assert_true(manager.bubble_cells.has(Vector2i(14, 14)), "Center marked BUBBLE_GROWING") + assert_eq(manager.cell_state(Vector2i(14, 14)), manager.CellState.BUBBLE_GROWING, "cell_state reports BUBBLE_GROWING") + +func test_spawn_bubble_records_recent_position(): + _without_peer(func(): + manager._spawn_bubble(Vector2i(14, 14)) + ) + assert_true(manager.recent_bubble_positions.has(Vector2i(14, 14)), "Center remembered for anti-stacking") + +func test_recent_positions_capped(): + _without_peer(func(): + for i in range(manager.BUBBLE_RECENT_MEMORY + 3): + manager._spawn_bubble(Vector2i(2 + i, 15)) + ) + assert_eq(manager.recent_bubble_positions.size(), manager.BUBBLE_RECENT_MEMORY, "Recent memory capped") + +# ============================================================================= +# Explosion +# ============================================================================= + +func test_update_bubbles_explodes_after_grow_duration(): + _without_peer(func(): + manager._spawn_bubble(Vector2i(14, 14)) + manager._update_bubbles(manager.BUBBLE_GROW_DURATION + 0.1) + ) + assert_eq(manager.active_bubbles.size(), 0, "Bubble removed after exploding") + assert_true(manager.sticky_cells.has(Vector2i(14, 14)), "Center became sticky") + assert_false(manager.bubble_cells.has(Vector2i(14, 14)), "BUBBLE_GROWING cleared on explode") + +func test_update_bubbles_waits_for_timer(): + _without_peer(func(): + manager._spawn_bubble(Vector2i(14, 14)) + manager._update_bubbles(manager.BUBBLE_GROW_DURATION * 0.5) + ) + assert_eq(manager.active_bubbles.size(), 1, "Bubble still growing before timer elapses") + assert_false(manager.sticky_cells.has(Vector2i(14, 14)), "No sticky yet mid-grow") + +func test_explode_creates_3x3_sticky(): + _without_peer(func(): + manager._explode_bubble(Vector2i(14, 14), manager._bubble_blast_cells(Vector2i(14, 14))) + ) + var sticky_in_blast := 0 + for dx in range(-1, 2): + for dz in range(-1, 2): + if manager.sticky_cells.has(Vector2i(14 + dx, 14 + dz)): + sticky_in_blast += 1 + assert_eq(sticky_in_blast, 9, "Explosion creates a full 3x3 sticky area") diff --git a/tests/test_gauntlet_bubble.gd.uid b/tests/test_gauntlet_bubble.gd.uid new file mode 100644 index 0000000..4efbefa --- /dev/null +++ b/tests/test_gauntlet_bubble.gd.uid @@ -0,0 +1 @@ +uid://bkte51v8tyoii diff --git a/tests/test_gauntlet_cannon_timer.gd b/tests/test_gauntlet_cannon_timer.gd deleted file mode 100644 index 4862841..0000000 --- a/tests/test_gauntlet_cannon_timer.gd +++ /dev/null @@ -1,41 +0,0 @@ -extends GutTest - -const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") -var gauntlet_manager: Node -var main_mock: Node -var gridmap_mock: Node - -func before_all(): - gut.p("=== Feature Tests [Gauntlet #4 Cannon Timer] ===") - -func before_each(): - main_mock = Node.new() - add_child(main_mock) - gridmap_mock = Node.new() - gridmap_mock.name = "EnhancedGridMap" - main_mock.add_child(gridmap_mock) - - gauntlet_manager = GauntletManager.new() - main_mock.add_child(gauntlet_manager) - gauntlet_manager.initialize(main_mock, gridmap_mock) - -func test_cannon_timer_initialization(): - assert_eq(gauntlet_manager.cannon_timer, 0.0, "Timer should start at 0.0 before phase starts") - - # Manually start phase to setup interval - gauntlet_manager.current_phase = 0 # GauntletManager.Phase.OPEN_ARENA - var config = gauntlet_manager.phase_configs[0] - gauntlet_manager.cannon_interval = config["interval"] - gauntlet_manager.cannon_timer = gauntlet_manager.cannon_interval - - assert_eq(gauntlet_manager.cannon_timer, 5.0, "Timer should initialize to Phase 1 interval (5.0)") - -func test_volley_size_configuration(): - assert_eq(gauntlet_manager.phase_configs[0]["volley"], 5, "Phase 1 volley size should be 5") - -func after_each(): - if main_mock: - main_mock.queue_free() - -func after_all(): - gut.p("=== Feature Tests Complete ===") diff --git a/tests/test_gauntlet_cannon_timer.gd.uid b/tests/test_gauntlet_cannon_timer.gd.uid deleted file mode 100644 index e26a0b1..0000000 --- a/tests/test_gauntlet_cannon_timer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ct0psnc84v1sy diff --git a/tests/test_gauntlet_cleanser.gd b/tests/test_gauntlet_cleanser.gd new file mode 100644 index 0000000..1a5d108 --- /dev/null +++ b/tests/test_gauntlet_cleanser.gd @@ -0,0 +1,115 @@ +extends GutTest + +# ============================================================================= +# Test: Gauntlet Cleanser Power-Up (v2) [Gauntlet #072] +# Covers grant cadence (every 2 missions, max 1), 5-cell immunity lifecycle, +# sticky clearing, stun-blocked activation, and the safe-stop early termination. +# Runs headless; uses a GridMap mock so clear_sticky_cell can run locally. +# ============================================================================= + +const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") +const GridMapMock = preload("res://tests/helpers/gridmap_mock.gd") +const MainMock = preload("res://tests/helpers/main_mock.gd") +var manager +var main_mock: Node +var gridmap_mock: Node + +func before_each(): + main_mock = MainMock.new() + add_child(main_mock) + gridmap_mock = GridMapMock.new() + gridmap_mock.name = "EnhancedGridMap" + main_mock.add_child(gridmap_mock) + manager = GauntletManager.new() + main_mock.add_child(manager) + manager.initialize(main_mock, gridmap_mock) + manager.current_phase = 0 + +func after_each(): + if main_mock: + main_mock.queue_free() + +func _without_peer(fn: Callable) -> void: + var saved = multiplayer.multiplayer_peer + multiplayer.multiplayer_peer = null + fn.call() + multiplayer.multiplayer_peer = saved + +# ============================================================================= +# Grant cadence: every 2 missions, inventory max 1 +# ============================================================================= + +func test_no_cleanser_after_one_mission(): + manager._on_goal_count_updated(7, 1) + assert_eq(manager.player_cleansers.get(7, 0), 0, "No cleanser after 1 mission") + +func test_cleanser_granted_after_two_missions(): + manager._on_goal_count_updated(7, 1) + manager._on_goal_count_updated(7, 2) + assert_eq(manager.player_cleansers.get(7, 0), 1, "Cleanser granted after 2 missions") + +func test_cleanser_inventory_capped_at_one(): + # Four missions would be two grants, but inventory caps at 1 (not consumed). + for i in range(4): + manager._on_goal_count_updated(7, i + 1) + assert_eq(manager.player_cleansers.get(7, 0), 1, "Inventory never exceeds 1") + +# ============================================================================= +# Activation / immunity lifecycle +# ============================================================================= + +func test_use_cleanser_cell_decrements_until_exhausted(): + manager.cleanser_active[3] = true + manager.cleanser_cells_left[3] = manager.CLEANSER_MAX_CELLS + # First 4 uses keep it active... + for i in range(manager.CLEANSER_MAX_CELLS - 1): + assert_true(manager.use_cleanser_cell(3), "Still active on use %d" % (i + 1)) + # ...the 5th use exhausts it. + assert_false(manager.use_cleanser_cell(3), "Exhausted after 5th cell") + assert_false(manager.is_cleanser_active(3), "Deactivated after 5th cell") + +func test_use_cleanser_cell_when_inactive_returns_false(): + assert_false(manager.use_cleanser_cell(99), "Inactive cleanser use returns false") + +func test_deactivate_clears_state(): + manager.cleanser_active[5] = true + manager.cleanser_cells_left[5] = 3 + manager.deactivate_cleanser(5) + assert_false(manager.is_cleanser_active(5), "Deactivated") + assert_false(manager.cleanser_cells_left.has(5), "Cells-left cleared") + +# ============================================================================= +# Sticky clearing +# ============================================================================= + +func test_clear_sticky_cell_removes_and_protects(): + manager.sticky_cells[Vector2i(4, 4)] = true + _without_peer(func(): + manager.clear_sticky_cell(Vector2i(4, 4)) + ) + assert_false(manager.is_sticky_cell(Vector2i(4, 4)), "Sticky removed") + assert_true(manager.is_cleansed_cell(Vector2i(4, 4)), "Cleared cell gets regrowth protection") + # Layer-2 overlay cleared (mock records -1 = erased). + assert_eq(gridmap_mock.get_cell_item(Vector3i(4, 2, 4)), -1, "Layer-2 sticky overlay cleared") + +# ============================================================================= +# Safe-stop early termination (#072 acceptance: ends when stopping on safe cell) +# ============================================================================= + +func test_stop_on_safe_cell_ends_cleanser(): + manager.cleanser_active[8] = true + manager.cleanser_cells_left[8] = 3 + manager.notify_movement_stopped(8, Vector2i(5, 5)) # safe cell + assert_false(manager.is_cleanser_active(8), "Cleanser ends on safe-cell stop") + +func test_stop_on_sticky_cell_keeps_cleanser(): + manager.sticky_cells[Vector2i(6, 6)] = true + manager.cleanser_active[8] = true + manager.cleanser_cells_left[8] = 3 + manager.notify_movement_stopped(8, Vector2i(6, 6)) # still on sticky + assert_true(manager.is_cleanser_active(8), "Cleanser persists while still on sticky") + +func test_notify_stop_noop_without_cleanser(): + # Should not crash or change anything when the player has no cleanser. + manager.notify_movement_stopped(123, Vector2i(5, 5)) + assert_false(manager.is_cleanser_active(123), "No cleanser → no-op") diff --git a/tests/test_gauntlet_cleanser.gd.uid b/tests/test_gauntlet_cleanser.gd.uid new file mode 100644 index 0000000..104ecc2 --- /dev/null +++ b/tests/test_gauntlet_cleanser.gd.uid @@ -0,0 +1 @@ +uid://b1bay8n1h65u3 diff --git a/tests/test_gauntlet_growth_tick.gd b/tests/test_gauntlet_growth_tick.gd new file mode 100644 index 0000000..d410ff1 --- /dev/null +++ b/tests/test_gauntlet_growth_tick.gd @@ -0,0 +1,159 @@ +extends GutTest + +# ============================================================================= +# Test: Gauntlet Growth Tick System (v2) [Gauntlet #067] +# Replaces the old cannon-timer test. Covers growth timing, phase configs, +# candidate generation, cells-per-tick ranges, weighted selection, and +# cleansed-cell exclusion. +# ============================================================================= + +const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") +var gauntlet_manager: Node +var main_mock: Node +var gridmap_mock: Node + +func before_all(): + gut.p("=== Feature Tests [Gauntlet #067 Growth Tick] ===") + +func before_each(): + main_mock = Node.new() + add_child(main_mock) + gridmap_mock = Node.new() + gridmap_mock.name = "EnhancedGridMap" + main_mock.add_child(gridmap_mock) + + gauntlet_manager = GauntletManager.new() + main_mock.add_child(gauntlet_manager) + gauntlet_manager.initialize(main_mock, gridmap_mock) + +func after_each(): + if main_mock: + main_mock.queue_free() + +func after_all(): + gut.p("=== Feature Tests Complete ===") + +# ============================================================================= +# Growth Timing +# ============================================================================= + +func test_growth_timer_starts_zero(): + assert_eq(gauntlet_manager.growth_timer, 0.0, "Growth timer starts at 0.0") + +func test_growth_interval_default(): + assert_eq(gauntlet_manager.growth_interval, 3.0, "Growth interval defaults to 3.0s") + +func test_telegraph_duration_default(): + assert_eq(gauntlet_manager.telegraph_duration, 1.0, "Telegraph duration defaults to 1.0s") + +# ============================================================================= +# Phase Growth Config +# ============================================================================= + +func test_phase_growth_config_has_three_phases(): + assert_eq(gauntlet_manager.phase_growth_config.size(), 3, "Three phase growth configs") + +func test_phase1_cells_range(): + var cfg = gauntlet_manager.phase_growth_config[0] + assert_eq(cfg["cells_min"], 4, "Phase 1 min 4 cells/tick") + assert_eq(cfg["cells_max"], 6, "Phase 1 max 6 cells/tick") + +func test_phase2_cells_range(): + var cfg = gauntlet_manager.phase_growth_config[1] + assert_eq(cfg["cells_min"], 6, "Phase 2 min 6 cells/tick") + assert_eq(cfg["cells_max"], 8, "Phase 2 max 8 cells/tick") + +func test_phase3_cells_range(): + var cfg = gauntlet_manager.phase_growth_config[2] + assert_eq(cfg["cells_min"], 8, "Phase 3 min 8 cells/tick") + assert_eq(cfg["cells_max"], 10, "Phase 3 max 10 cells/tick") + +func test_cells_this_tick_in_phase_range(): + for phase in range(3): + gauntlet_manager.current_phase = phase + var cfg = gauntlet_manager.phase_growth_config[phase] + # Sample several times since the count is randomized. + for _i in range(20): + var n = gauntlet_manager._cells_this_tick() + assert_true(n >= cfg["cells_min"] and n <= cfg["cells_max"], + "Phase %d cells/tick %d within [%d,%d]" % [phase, n, cfg["cells_min"], cfg["cells_max"]]) + +# ============================================================================= +# Candidate Generation +# ============================================================================= + +func test_candidates_are_all_safe_cells(): + gauntlet_manager.current_phase = 0 + var candidates = gauntlet_manager._generate_candidates() + # Fresh arena: every playable cell is SAFE. + assert_eq(candidates.size(), gauntlet_manager.playable_cell_count(), + "All playable cells are candidates on a fresh arena") + +func test_candidates_exclude_sticky(): + gauntlet_manager.sticky_cells[Vector2i(3, 3)] = true + var candidates = gauntlet_manager._generate_candidates() + var found := false + for c in candidates: + if c["pos"] == Vector2i(3, 3): + found = true + assert_false(found, "Sticky cells are excluded from candidates") + +func test_candidates_exclude_cleansed(): + gauntlet_manager.mark_cleansed(Vector2i(4, 4)) + var candidates = gauntlet_manager._generate_candidates() + var found := false + for c in candidates: + if c["pos"] == Vector2i(4, 4): + found = true + assert_false(found, "Cleansed cells are excluded from candidates (regrowth protection)") + +func test_candidates_exclude_npc_and_boundary(): + var candidates = gauntlet_manager._generate_candidates() + for c in candidates: + var p = c["pos"] + assert_false(gauntlet_manager._is_npc_zone(p), "No NPC-zone candidates") + assert_false(gauntlet_manager._is_boundary(p), "No boundary candidates") + +func test_candidates_have_scores(): + var candidates = gauntlet_manager._generate_candidates() + assert_true(candidates.size() > 0, "Has candidates") + assert_true(candidates[0].has("score"), "Candidate carries a score") + +# ============================================================================= +# Weighted Selection +# ============================================================================= + +func test_select_count_respected(): + var candidates = gauntlet_manager._generate_candidates() + var picked = gauntlet_manager._select_cells_weighted(candidates, 5) + assert_eq(picked.size(), 5, "Selects exactly the requested count") + +func test_select_no_duplicates(): + var candidates = gauntlet_manager._generate_candidates() + var picked = gauntlet_manager._select_cells_weighted(candidates, 10) + var seen := {} + for p in picked: + assert_false(seen.has(p), "No duplicate selections") + seen[p] = true + +func test_select_capped_at_pool_size(): + var small = [{"pos": Vector2i(2, 2), "score": 1.0}, {"pos": Vector2i(2, 3), "score": 1.0}] + var picked = gauntlet_manager._select_cells_weighted(small, 10) + assert_eq(picked.size(), 2, "Cannot select more than pool size") + +# ============================================================================= +# Scoring Helpers +# ============================================================================= + +func test_layer_classification(): + assert_eq(gauntlet_manager._layer_of(Vector2i(9, 9)), "inner", "Center is inner") + assert_eq(gauntlet_manager._layer_of(Vector2i(1, 1)), "outer", "Corner is outer") + +func test_sticky_neighbor_count(): + gauntlet_manager.sticky_cells[Vector2i(5, 5)] = true + gauntlet_manager.sticky_cells[Vector2i(5, 6)] = true + assert_eq(gauntlet_manager._sticky_neighbor_count(Vector2i(6, 5)), 2, + "Counts 8-directional sticky neighbors") + +func test_chebyshev(): + assert_eq(gauntlet_manager._chebyshev(Vector2i(0, 0), Vector2i(3, 1)), 3, "Chebyshev distance") diff --git a/tests/test_gauntlet_growth_tick.gd.uid b/tests/test_gauntlet_growth_tick.gd.uid new file mode 100644 index 0000000..17dd1c1 --- /dev/null +++ b/tests/test_gauntlet_growth_tick.gd.uid @@ -0,0 +1 @@ +uid://btbxtdhagjdba diff --git a/tests/test_gauntlet_movement_buffer.gd b/tests/test_gauntlet_movement_buffer.gd new file mode 100644 index 0000000..14e29fe --- /dev/null +++ b/tests/test_gauntlet_movement_buffer.gd @@ -0,0 +1,119 @@ +extends GutTest + +# ============================================================================= +# Test: Gauntlet Movement Buffer System (v2) [Gauntlet #083] +# Hidden, decaying safe-corridor penalties layered onto candidate scoring. +# Runs headless; elapsed_time = 0 so the final-30s window is inactive unless a +# test sets elapsed_time directly. +# ============================================================================= + +const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") +var manager +var main_mock: Node +var gridmap_mock: Node + +func before_each(): + main_mock = Node.new() + add_child(main_mock) + gridmap_mock = Node.new() + gridmap_mock.name = "EnhancedGridMap" + main_mock.add_child(gridmap_mock) + manager = GauntletManager.new() + main_mock.add_child(manager) + manager.initialize(main_mock, gridmap_mock) + manager.current_phase = 0 + +func after_each(): + if main_mock: + main_mock.queue_free() + +# ============================================================================= +# Registration +# ============================================================================= + +func test_register_buffer_sets_phase_base_penalty(): + manager._register_buffer(Vector2i(5, 5), 40.0) + assert_true(manager.movement_buffers.has(Vector2i(5, 5)), "Buffer registered") + assert_almost_eq(manager.movement_buffers[Vector2i(5, 5)]["penalty"], 40.0, 0.001, "Full penalty stored") + +func test_register_buffer_keeps_strongest(): + manager._register_buffer(Vector2i(5, 5), 20.0) + manager._register_buffer(Vector2i(5, 5), 40.0) + assert_almost_eq(manager.movement_buffers[Vector2i(5, 5)]["penalty"], 40.0, 0.001, "Keeps the stronger penalty") + manager._register_buffer(Vector2i(5, 5), 10.0) + assert_almost_eq(manager.movement_buffers[Vector2i(5, 5)]["penalty"], 40.0, 0.001, "Weaker refresh does not lower it") + +# ============================================================================= +# Penalty lookup (inside / adjacent / none / final-window) +# ============================================================================= + +func test_buffer_penalty_inside_is_full_negative(): + manager._register_buffer(Vector2i(6, 6), 40.0) + assert_almost_eq(manager._buffer_penalty_at(Vector2i(6, 6)), -40.0, 0.001, "Inside buffer = full negative") + +func test_buffer_penalty_adjacent_is_half(): + manager._register_buffer(Vector2i(6, 6), 40.0) + assert_almost_eq(manager._buffer_penalty_at(Vector2i(7, 6)), -20.0, 0.001, "Adjacent buffer = half penalty") + +func test_buffer_penalty_far_is_zero(): + manager._register_buffer(Vector2i(6, 6), 40.0) + assert_eq(manager._buffer_penalty_at(Vector2i(15, 15)), 0.0, "Far from buffer = 0") + +func test_buffer_penalty_lifts_in_final_window(): + manager._register_buffer(Vector2i(6, 6), 40.0) + manager.elapsed_time = manager.gauntlet_round_duration() - 5.0 # within final 30s + assert_eq(manager._buffer_penalty_at(Vector2i(6, 6)), 0.0, "Final window lifts buffers") + +func test_buffer_penalty_empty_is_zero(): + assert_eq(manager._buffer_penalty_at(Vector2i(6, 6)), 0.0, "No buffers = 0") + +# ============================================================================= +# Time decay (−25% every 5s) +# ============================================================================= + +func test_decay_reduces_penalty_after_interval(): + manager._register_buffer(Vector2i(5, 5), 40.0) + manager._decay_movement_buffers(manager.BUFFER_DECAY_INTERVAL) # one full step + assert_almost_eq(manager.movement_buffers[Vector2i(5, 5)]["penalty"], 30.0, 0.001, "−25% after one interval") + +func test_decay_waits_for_full_interval(): + manager._register_buffer(Vector2i(5, 5), 40.0) + manager._decay_movement_buffers(manager.BUFFER_DECAY_INTERVAL * 0.5) # not yet + assert_almost_eq(manager.movement_buffers[Vector2i(5, 5)]["penalty"], 40.0, 0.001, "No decay before interval elapses") + +func test_decay_prunes_faded_buffers(): + manager._register_buffer(Vector2i(5, 5), manager.BUFFER_MIN_PENALTY + 0.5) + manager._decay_movement_buffers(manager.BUFFER_DECAY_INTERVAL) + assert_false(manager.movement_buffers.has(Vector2i(5, 5)), "Faded buffer pruned below BUFFER_MIN_PENALTY") + +# ============================================================================= +# Phase-change decay (−50%) +# ============================================================================= + +func test_phase_change_halves_buffers(): + manager._register_buffer(Vector2i(5, 5), 40.0) + manager._start_phase(manager.Phase.ROUTE_PRESSURE) + assert_almost_eq(manager.movement_buffers[Vector2i(5, 5)]["penalty"], 20.0, 0.001, "Phase change halves penalty") + +# ============================================================================= +# Scoring integration +# ============================================================================= + +func test_score_movement_buffer_uses_detected_corridor(): + # With no players, the proximity floor is inert; a registered buffer still bites. + manager._register_buffer(Vector2i(5, 5), 40.0) + assert_almost_eq(manager._score_movement_buffer(Vector2i(5, 5)), -40.0, 0.001, "Score reflects buffer penalty") + +func test_score_movement_buffer_zero_without_buffers_or_players(): + assert_eq(manager._score_movement_buffer(Vector2i(5, 5)), 0.0, "No buffers, no players = 0") + +# ============================================================================= +# Scale helper +# ============================================================================= + +func test_scale_all_buffers_prunes_and_scales(): + manager._register_buffer(Vector2i(1, 1), 40.0) + manager._register_buffer(Vector2i(2, 2), manager.BUFFER_MIN_PENALTY + 0.1) + manager._scale_all_buffers(0.5) + assert_almost_eq(manager.movement_buffers[Vector2i(1, 1)]["penalty"], 20.0, 0.001, "Scaled by 0.5") + assert_false(manager.movement_buffers.has(Vector2i(2, 2)), "Below-min entry pruned") diff --git a/tests/test_gauntlet_movement_buffer.gd.uid b/tests/test_gauntlet_movement_buffer.gd.uid new file mode 100644 index 0000000..82c76ba --- /dev/null +++ b/tests/test_gauntlet_movement_buffer.gd.uid @@ -0,0 +1 @@ +uid://4cttae74ja3t diff --git a/tests/test_gauntlet_registration.gd b/tests/test_gauntlet_registration.gd index a43dda4..01fa10d 100644 --- a/tests/test_gauntlet_registration.gd +++ b/tests/test_gauntlet_registration.gd @@ -29,15 +29,15 @@ func test_all_modes_in_enum(): # String Conversion Tests # ============================================================================= -# Test 3: from_string recognizes "Candy Cannon Survival" +# Test 3: from_string recognizes "Candy Pump Survival" func test_from_string_candy_cannon(): - var result = GameMode.from_string("Candy Cannon Survival") - assert_eq(result, GameMode.Mode.GAUNTLET, "from_string should parse 'Candy Cannon Survival' as GAUNTLET") + var result = GameMode.from_string("Candy Pump Survival") + assert_eq(result, GameMode.Mode.GAUNTLET, "from_string should parse 'Candy Pump Survival' as GAUNTLET") -# Test 4: mode_to_string returns "Candy Cannon Survival" for GAUNTLET +# Test 4: mode_to_string returns "Candy Pump Survival" for GAUNTLET func test_mode_to_string_gauntlet(): var result = GameMode.mode_to_string(GameMode.Mode.GAUNTLET) - assert_eq(result, "Candy Cannon Survival", "mode_to_string should return 'Candy Cannon Survival'") + assert_eq(result, "Candy Pump Survival", "mode_to_string should return 'Candy Pump Survival'") # Test 5: Round-trip conversion is lossless func test_round_trip_conversion(): @@ -61,10 +61,10 @@ func test_unknown_string_defaults_freemode(): # get_all_modes Tests # ============================================================================= -# Test 8: get_all_modes includes "Candy Cannon Survival" +# Test 8: get_all_modes includes "Candy Pump Survival" func test_get_all_modes_includes_gauntlet(): var modes = GameMode.get_all_modes() - assert_has(modes, "Candy Cannon Survival", "get_all_modes should include 'Candy Cannon Survival'") + assert_has(modes, "Candy Pump Survival", "get_all_modes should include 'Candy Pump Survival'") # Test 9: get_all_modes returns exactly 4 entries func test_get_all_modes_count(): @@ -77,7 +77,7 @@ func test_get_all_modes_order(): assert_eq(modes[0], "Freemode", "First mode should be Freemode") assert_eq(modes[1], "Stop n Go", "Second mode should be Stop n Go") assert_eq(modes[2], "Tekton Doors", "Third mode should be Tekton Doors") - assert_eq(modes[3], "Candy Cannon Survival", "Fourth mode should be Candy Cannon Survival") + assert_eq(modes[3], "Candy Pump Survival", "Fourth mode should be Candy Pump Survival") # ============================================================================= # is_restricted Tests @@ -103,10 +103,10 @@ func test_all_restricted_modes(): # LobbyManager Integration Tests # ============================================================================= -# Test 14: Lobby available_game_modes includes "Candy Cannon Survival" +# Test 14: Lobby available_game_modes includes "Candy Pump Survival" func test_lobby_modes_includes_gauntlet(): var modes = LobbyManager.available_game_modes - assert_has(modes, "Candy Cannon Survival", "LobbyManager.available_game_modes should include 'Candy Cannon Survival'") + assert_has(modes, "Candy Pump Survival", "LobbyManager.available_game_modes should include 'Candy Pump Survival'") # Test 15: gauntlet_manager.gd script file exists func test_gauntlet_manager_script_exists(): diff --git a/tests/test_gauntlet_scoring.gd b/tests/test_gauntlet_scoring.gd new file mode 100644 index 0000000..97f5de0 --- /dev/null +++ b/tests/test_gauntlet_scoring.gd @@ -0,0 +1,185 @@ +extends GutTest + +# ============================================================================= +# Test: Gauntlet Candidate Scoring System (v2) [Gauntlet #073] +# Covers each score component, camping accumulation, and full-formula +# composition. Runs headless (no multiplayer peer), so elapsed_time = 0 and +# the final-30s window is inactive unless a test sets elapsed_time directly. +# ============================================================================= + +const GauntletManager = preload("res://scripts/managers/gauntlet_manager.gd") +var manager +var main_mock: Node +var gridmap_mock: Node + +func before_each(): + main_mock = Node.new() + add_child(main_mock) + gridmap_mock = Node.new() + gridmap_mock.name = "EnhancedGridMap" + main_mock.add_child(gridmap_mock) + manager = GauntletManager.new() + main_mock.add_child(manager) + manager.initialize(main_mock, gridmap_mock) + manager.current_phase = 0 + +func after_each(): + if main_mock: + main_mock.queue_free() + +# ============================================================================= +# LayerPriority +# ============================================================================= + +func test_layer_priority_matches_phase_weights(): + manager.current_phase = 0 + # Outer ring cell (corner-ish) gets the phase-0 outer weight (+60). + assert_eq(manager._score_layer_priority(Vector2i(2, 2)), 60.0, "Phase 0 outer = +60") + # Inner cell near center gets phase-0 inner weight (-40). + assert_eq(manager._score_layer_priority(Vector2i(9, 8)), -40.0, "Phase 0 inner = -40") + +func test_layer_priority_phase3_inner(): + manager.current_phase = 2 + assert_eq(manager._score_layer_priority(Vector2i(9, 8)), 60.0, "Phase 2 inner = +60") + +# ============================================================================= +# StickyNeighbor +# ============================================================================= + +func test_sticky_neighbor_score_scales(): + assert_eq(manager._score_sticky_neighbor(Vector2i(5, 5)), 0.0, "No neighbors = 0") + manager.sticky_cells[Vector2i(5, 6)] = true + assert_eq(manager._score_sticky_neighbor(Vector2i(5, 5)), 8.0, "One neighbor = +8") + +func test_sticky_neighbor_score_capped(): + # Surround (5,5) on all 8 sides → 8 * 8 = 64, capped at 64. + for dx in range(-1, 2): + for dz in range(-1, 2): + if dx == 0 and dz == 0: + continue + manager.sticky_cells[Vector2i(5 + dx, 5 + dz)] = true + assert_eq(manager._score_sticky_neighbor(Vector2i(5, 5)), 64.0, "Capped at +64") + +# ============================================================================= +# InwardPressure +# ============================================================================= + +func test_inward_pressure_higher_near_center(): + manager.current_phase = 2 + var near = manager._score_inward_pressure(Vector2i(8, 8)) # close to center + var far = manager._score_inward_pressure(Vector2i(1, 1)) # far corner + assert_true(near > far, "Inward pressure stronger near center") + +func test_inward_pressure_phase_scaling(): + # Same cell, later phase => higher inward pressure ceiling. + var pos := Vector2i(8, 8) + manager.current_phase = 0 + var p0 = manager._score_inward_pressure(pos) + manager.current_phase = 2 + var p2 = manager._score_inward_pressure(pos) + assert_true(p2 > p0, "Later phase pushes inward harder") + +# ============================================================================= +# PlayerPressure +# ============================================================================= + +func test_player_pressure_ring(): + # 3 cells from a player → +20. + var players = [Vector2i(5, 5)] + assert_eq(manager._score_player_pressure(Vector2i(8, 5), players), 20.0, "2-4 cells away = +20") + +func test_player_pressure_under_player_penalized(): + var players = [Vector2i(5, 5)] + # elapsed_time 0, round 180 → not final window → directly under = -50. + assert_eq(manager._score_player_pressure(Vector2i(5, 5), players), -50.0, "Under player (early) = -50") + +func test_player_pressure_under_player_final_window(): + manager.elapsed_time = manager.gauntlet_round_duration() - 5.0 # within final 30s + var players = [Vector2i(5, 5)] + assert_eq(manager._score_player_pressure(Vector2i(5, 5), players), 10.0, "Under player (final) = +10") + +func test_player_pressure_no_players(): + assert_eq(manager._score_player_pressure(Vector2i(5, 5), []), 0.0, "No players = 0") + +# ============================================================================= +# ClusterGrowth +# ============================================================================= + +func test_cluster_growth_none(): + assert_eq(manager._score_cluster_growth(Vector2i(5, 5)), 0.0, "No sticky neighbors = 0") + +func test_cluster_growth_expand(): + manager.sticky_cells[Vector2i(5, 6)] = true + assert_eq(manager._score_cluster_growth(Vector2i(5, 5)), 15.0, "Expanding cluster = +15") + +func test_cluster_growth_connect(): + manager.sticky_cells[Vector2i(4, 5)] = true + manager.sticky_cells[Vector2i(6, 5)] = true + manager.sticky_cells[Vector2i(5, 6)] = true + assert_eq(manager._score_cluster_growth(Vector2i(5, 5)), 25.0, "Connecting clusters = +25") + +# ============================================================================= +# CampingPressure +# ============================================================================= + +func test_camp_region_grouping(): + assert_eq(manager._region_of(Vector2i(0, 0)), Vector2i(0, 0), "Cells 0-3 → region 0") + assert_eq(manager._region_of(Vector2i(5, 7)), Vector2i(1, 1), "Cells 4-7 → region 1") + +func test_camping_pressure_thresholds(): + var region: Vector2i = manager._region_of(Vector2i(8, 8)) + manager._camp_tracking[1] = {"region": region, "time": 6.0} + assert_eq(manager._score_camping_pressure(Vector2i(8, 8)), 20.0, ">5s = +20") + manager._camp_tracking[1]["time"] = 9.0 + assert_eq(manager._score_camping_pressure(Vector2i(8, 8)), 40.0, ">8s = +40") + manager._camp_tracking[1]["time"] = 11.0 + assert_eq(manager._score_camping_pressure(Vector2i(8, 8)), 60.0, ">10s = +60") + +func test_camping_pressure_none(): + assert_eq(manager._score_camping_pressure(Vector2i(8, 8)), 0.0, "No camping = 0") + +# ============================================================================= +# Repetition +# ============================================================================= + +func test_repetition_penalty(): + manager._last_tick_cells = [Vector2i(5, 5)] + assert_eq(manager._score_repetition(Vector2i(5, 6)), -30.0, "Adjacent to last tick = -30") + assert_eq(manager._score_repetition(Vector2i(15, 15)), 0.0, "Far from last tick = 0") + +# ============================================================================= +# PathSafety (soft penalty) +# ============================================================================= + +func test_path_safety_no_players_no_penalty(): + assert_eq(manager._score_path_safety(Vector2i(5, 5)), 0.0, "No players = no penalty") + +# ============================================================================= +# Camp tracking accumulation +# ============================================================================= + +func test_camp_time_for_region_picks_max(): + var region := Vector2i(1, 1) + manager._camp_tracking[1] = {"region": region, "time": 3.0} + manager._camp_tracking[2] = {"region": region, "time": 7.0} + assert_almost_eq(manager._camp_time_for_region(region), 7.0, 0.001, "Longest camp time wins") + +# ============================================================================= +# Full formula composition +# ============================================================================= + +func test_full_score_runs_and_is_finite(): + var s = manager._calculate_candidate_score(Vector2i(5, 5), []) + assert_true(is_finite(s), "Full score is a finite number") + +func test_full_score_rewards_sticky_cluster(): + # A cell hugging an existing cluster should generally beat an isolated one. + # Average several samples to wash out RandomNoise (±20). + manager.sticky_cells[Vector2i(5, 6)] = true + manager.sticky_cells[Vector2i(6, 5)] = true + var clustered := 0.0 + var isolated := 0.0 + for _i in range(40): + clustered += manager._calculate_candidate_score(Vector2i(5, 5), []) + isolated += manager._calculate_candidate_score(Vector2i(15, 15), []) + assert_true(clustered > isolated, "Clustered cell scores higher on average") diff --git a/tests/test_gauntlet_scoring.gd.uid b/tests/test_gauntlet_scoring.gd.uid new file mode 100644 index 0000000..0cef456 --- /dev/null +++ b/tests/test_gauntlet_scoring.gd.uid @@ -0,0 +1 @@ +uid://tugcu571care diff --git a/tests/test_gauntlet_sticky_system.gd b/tests/test_gauntlet_sticky_system.gd new file mode 100644 index 0000000..ebbf8f4 --- /dev/null +++ b/tests/test_gauntlet_sticky_system.gd @@ -0,0 +1,208 @@ +extends GutTest + +# ============================================================================= +# Test: Gauntlet Sticky Cell System (v2) [Gauntlet #068] +# Covers cell states, CLEANSED protection, coverage helpers, and the +# path-safety reachability (BFS) check. +# ============================================================================= + +var GauntletManagerScript = load("res://scripts/managers/gauntlet_manager.gd") +var manager: GauntletManager + +func before_each(): + manager = GauntletManagerScript.new() + add_child(manager) + +func after_each(): + manager.queue_free() + +# ============================================================================= +# CellState enum +# ============================================================================= + +func test_cellstate_enum_has_six_states(): + assert_eq(manager.CellState.size(), 6, "CellState should have 6 states") + +func test_cellstate_values(): + assert_eq(manager.CellState.SAFE, 0, "SAFE should be 0") + assert_eq(manager.CellState.TELEGRAPHED, 1, "TELEGRAPHED should be 1") + assert_eq(manager.CellState.STICKY, 2, "STICKY should be 2") + assert_eq(manager.CellState.BUBBLE_GROWING, 3, "BUBBLE_GROWING should be 3") + assert_eq(manager.CellState.BLOCKED, 4, "BLOCKED should be 4") + assert_eq(manager.CellState.CLEANSED, 5, "CLEANSED should be 5") + +# ============================================================================= +# cell_state() classification +# ============================================================================= + +func test_interior_cell_is_safe(): + assert_eq(manager.cell_state(Vector2i(3, 3)), manager.CellState.SAFE, "Open interior cell is SAFE") + +func test_npc_zone_is_blocked(): + assert_eq(manager.cell_state(Vector2i(9, 9)), manager.CellState.BLOCKED, "NPC zone is BLOCKED") + +func test_boundary_is_blocked(): + assert_eq(manager.cell_state(Vector2i(0, 5)), manager.CellState.BLOCKED, "Boundary is BLOCKED") + assert_eq(manager.cell_state(Vector2i(19, 5)), manager.CellState.BLOCKED, "Far boundary is BLOCKED") + +func test_sticky_cell_state(): + manager.sticky_cells[Vector2i(4, 4)] = true + assert_eq(manager.cell_state(Vector2i(4, 4)), manager.CellState.STICKY, "Sticky cell is STICKY") + +func test_telegraphed_cell_state(): + manager.telegraphed_cells[Vector2i(5, 5)] = 1.0 + assert_eq(manager.cell_state(Vector2i(5, 5)), manager.CellState.TELEGRAPHED, "Telegraphed cell is TELEGRAPHED") + +func test_cleansed_cell_state(): + manager.mark_cleansed(Vector2i(6, 6)) + assert_eq(manager.cell_state(Vector2i(6, 6)), manager.CellState.CLEANSED, "Cleansed cell is CLEANSED") + +func test_sticky_takes_priority_over_cleansed(): + # A cell that is both should report STICKY (active hazard wins). + manager.sticky_cells[Vector2i(7, 7)] = true + manager.cleansed_cells[Vector2i(7, 7)] = 5.0 + assert_eq(manager.cell_state(Vector2i(7, 7)), manager.CellState.STICKY, "Sticky wins over cleansed") + +# ============================================================================= +# CLEANSED protection lifecycle +# ============================================================================= + +func test_mark_cleansed_sets_protection_time(): + manager.mark_cleansed(Vector2i(3, 4)) + assert_true(manager.is_cleansed_cell(Vector2i(3, 4)), "Cell should be cleansed") + assert_almost_eq(manager.cleansed_cells[Vector2i(3, 4)], manager.CLEANSED_PROTECTION_TIME, 0.001, "Protection time set") + +func test_clear_sticky_marks_cleansed(): + manager.sticky_cells[Vector2i(4, 5)] = true + manager.clear_sticky_cell(Vector2i(4, 5)) + assert_false(manager.is_sticky_cell(Vector2i(4, 5)), "Sticky removed") + assert_true(manager.is_cleansed_cell(Vector2i(4, 5)), "Cleared cell becomes cleansed") + +func test_tick_cleansed_decays_and_expires(): + manager.mark_cleansed(Vector2i(5, 6)) + manager._tick_cleansed_cells(manager.CLEANSED_PROTECTION_TIME + 0.1) + assert_false(manager.is_cleansed_cell(Vector2i(5, 6)), "Protection expires after full duration") + +func test_tick_cleansed_partial_decay_keeps_cell(): + manager.mark_cleansed(Vector2i(5, 7)) + manager._tick_cleansed_cells(1.0) + assert_true(manager.is_cleansed_cell(Vector2i(5, 7)), "Cell still protected after partial tick") + +# ============================================================================= +# Coverage helpers (v2 target 70-75%) +# ============================================================================= + +func test_coverage_targets(): + assert_almost_eq(manager.COVERAGE_TARGET_MIN, 0.70, 0.001, "Min coverage 70%") + assert_almost_eq(manager.COVERAGE_TARGET_MAX, 0.75, 0.001, "Max coverage 75%") + +func test_playable_cell_count(): + # 20x20 = 400, minus 76 boundary cells, minus 9 NPC zone = 315 + assert_eq(manager.playable_cell_count(), 315, "Playable cells = 315") + +func test_coverage_ratio_zero_when_empty(): + assert_almost_eq(manager.coverage_ratio(), 0.0, 0.001, "No sticky cells = 0 coverage") + +func test_coverage_ratio_scales(): + var playable := manager.playable_cell_count() + # Fill ~half the playable cells with arbitrary distinct keys. + var half := int(playable / 2.0) + for i in range(half): + manager.sticky_cells[Vector2i(1000 + i, 0)] = true + assert_almost_eq(manager.coverage_ratio(), float(half) / float(playable), 0.001, "Coverage tracks ratio") + +func test_coverage_reached_threshold(): + var playable := manager.playable_cell_count() + var needed := int(ceil(playable * manager.COVERAGE_TARGET_MIN)) + for i in range(needed): + manager.sticky_cells[Vector2i(2000 + i, 0)] = true + assert_true(manager.is_coverage_reached(), "Coverage reached at >=70%") + +func test_coverage_not_reached_below_threshold(): + manager.sticky_cells[Vector2i(2, 2)] = true + assert_false(manager.is_coverage_reached(), "One sticky cell is below target") + +# ============================================================================= +# Path safety: passability + reachability (BFS) +# ============================================================================= + +func test_passable_interior(): + assert_true(manager._is_cell_passable(Vector2i(3, 3)), "Open interior is passable") + +func test_not_passable_boundary_or_npc(): + assert_false(manager._is_cell_passable(Vector2i(0, 0)), "Boundary not passable") + assert_false(manager._is_cell_passable(Vector2i(9, 9)), "NPC zone not passable") + +func test_not_passable_sticky(): + manager.sticky_cells[Vector2i(3, 3)] = true + assert_false(manager._is_cell_passable(Vector2i(3, 3)), "Sticky cell not passable") + +func test_extra_sticky_blocks_passability(): + var extra := {Vector2i(4, 4): true} + assert_false(manager._is_cell_passable(Vector2i(4, 4), extra), "Hypothetical sticky blocks") + assert_true(manager._is_cell_passable(Vector2i(5, 5), extra), "Other cells still passable") + +func test_open_arena_has_large_safe_region(): + # From an open interior cell, flood fill should easily exceed the minimum. + var n := manager._reachable_safe_cells(Vector2i(3, 3), {}, 50) + assert_true(n >= 50, "Open arena reaches the search cap") + +func test_player_has_safe_region_when_open(): + assert_true(manager._player_has_safe_region(Vector2i(3, 3), {}), "Open cell has safe region") + +func test_fully_boxed_player_has_no_safe_region(): + # Box in the cell at (3,3) on all 4 sides with hypothetical sticky. + var extra := { + Vector2i(2, 3): true, Vector2i(4, 3): true, + Vector2i(3, 2): true, Vector2i(3, 4): true, + } + assert_false(manager._player_has_safe_region(Vector2i(3, 3), extra), "Boxed-in player has no safe region") + +func test_reachable_zero_when_start_blocked(): + manager.sticky_cells[Vector2i(3, 3)] = true + assert_eq(manager._reachable_safe_cells(Vector2i(3, 3), {}, 10), 0, "Blocked start reaches nothing") + +# ============================================================================= +# Sticky entry → per-player slow (v2: no hard trap, no global time_scale) +# ============================================================================= + +# Minimal stand-in for a Player that records apply_slow_effect calls. +class SlowSpyPlayer: + extends Node + var slow_calls: Array = [] + func apply_slow_effect(duration: float = 3.0) -> void: + slow_calls.append(duration) + +func test_apply_sticky_slow_calls_player_slow(): + var spy := SlowSpyPlayer.new() + add_child(spy) + # Force the local-call branch (no networked rpc) for a deterministic unit test. + var saved_peer = multiplayer.multiplayer_peer + multiplayer.multiplayer_peer = null + manager.apply_sticky_slow(spy) + multiplayer.multiplayer_peer = saved_peer + assert_eq(spy.slow_calls.size(), 1, "Sticky slow invokes apply_slow_effect once") + assert_almost_eq(spy.slow_calls[0], manager.STICKY_SLOW_DURATION, 0.001, "Slows for STICKY_SLOW_DURATION") + spy.queue_free() + +func test_apply_sticky_slow_does_not_trap(): + var spy := SlowSpyPlayer.new() + spy.set("peer_id", 42) + add_child(spy) + var saved_peer = multiplayer.multiplayer_peer + multiplayer.multiplayer_peer = null + manager.apply_sticky_slow(spy) + multiplayer.multiplayer_peer = saved_peer + assert_false(manager.trapped_players.has(42), "Sticky slow never adds to trapped_players") + spy.queue_free() + +func test_apply_sticky_slow_safe_without_method(): + # A node lacking apply_slow_effect must not crash the call. + var plain := Node.new() + add_child(plain) + manager.apply_sticky_slow(plain) # should no-op + assert_true(true, "apply_sticky_slow tolerates players without the method") + plain.queue_free() + +func test_sticky_slow_duration_is_positive(): + assert_true(manager.STICKY_SLOW_DURATION > 0.0, "Sticky slow duration is a positive number") diff --git a/tests/test_gauntlet_sticky_system.gd.uid b/tests/test_gauntlet_sticky_system.gd.uid new file mode 100644 index 0000000..f83d17f --- /dev/null +++ b/tests/test_gauntlet_sticky_system.gd.uid @@ -0,0 +1 @@ +uid://csco4t66gq5et diff --git a/tests/test_gauntlet_tile_spawning.gd b/tests/test_gauntlet_tile_spawning.gd index f8c8548..201ef0a 100644 --- a/tests/test_gauntlet_tile_spawning.gd +++ b/tests/test_gauntlet_tile_spawning.gd @@ -126,13 +126,13 @@ func test_initial_phase_is_open_arena(): assert_eq(manager.current_phase, GauntletManager.Phase.OPEN_ARENA, "Should start in Open Arena") func test_phase_to_string_open_arena(): - assert_eq(manager._phase_to_string(GauntletManager.Phase.OPEN_ARENA), "Open Arena") + assert_eq(manager._phase_to_string(GauntletManager.Phase.OPEN_ARENA), "Outer Pressure") func test_phase_to_string_route_pressure(): - assert_eq(manager._phase_to_string(GauntletManager.Phase.ROUTE_PRESSURE), "Route Pressure") + assert_eq(manager._phase_to_string(GauntletManager.Phase.ROUTE_PRESSURE), "Middle Pressure") func test_phase_to_string_survival(): - assert_eq(manager._phase_to_string(GauntletManager.Phase.SURVIVAL_ENDGAME), "Survival!") + assert_eq(manager._phase_to_string(GauntletManager.Phase.SURVIVAL_ENDGAME), "Inner Survival") # ============================================================================= # Match Timer Integration