refactor: enhance test framework with automated resource tracking and scripted error capture capabilities

This commit is contained in:
2026-06-26 09:40:17 +08:00
parent 948a99cf90
commit 00f9d98f4b
58 changed files with 3594 additions and 1289 deletions
-160
View File
@@ -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: "<task_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_<feature>.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_<feature>.gd` in `tests/`.
- **GUT framework**: Tests use the Godot Unit Test (GUT) addon at `addons/gut/`.
+46 -2
View File
@@ -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)),
}
}
@@ -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"):
+115 -17
View File
@@ -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,13 +311,7 @@ func patch_script(params: Dictionary) -> Dictionary:
write.store_string(new_content)
write.close()
# 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": {
var data := {
"path": path,
"replacements": replacements,
"size": new_content.length(),
@@ -228,7 +319,14 @@ func patch_script(params: Dictionary) -> Dictionary:
"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": data}
func attach_script(params: Dictionary) -> Dictionary:
+1 -1
View File
@@ -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"
+11 -1
View File
@@ -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)
+9 -1
View File
@@ -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
+1
View File
@@ -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
@@ -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)
@@ -0,0 +1 @@
@@ -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()
@@ -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()
@@ -0,0 +1 @@
uid://pmtc07go8ty4
+80 -2
View File
@@ -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
+53
View File
@@ -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.
@@ -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 {}
@@ -0,0 +1 @@
uid://b3npxxpuobbc2
+253 -8
View File
@@ -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..."})
# Deferred so the HTTPRequest callback returns before the extract starts.
_install_zip.call_deferred()
_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..."})
_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 ("<hex> <name>") 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:
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 f := FileAccess.open(install_base.path_join(file_path), FileAccess.WRITE)
if f != null:
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()
-158
View File
@@ -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'
}
]
}
]
}
}]
}
}];
-154
View File
@@ -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"
}
]
}
]
}
}]
}
}];
+11
View File
@@ -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")
+5 -5
View File
@@ -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"]
+9 -9
View File
@@ -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()
+151 -151
View File
@@ -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
+97 -125
View File
@@ -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"
BIN
View File
Binary file not shown.
+3 -3
View File
@@ -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"]
+6 -5
View File
@@ -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
@@ -37,6 +38,7 @@ func _physics_process(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
@@ -44,16 +46,15 @@ func _calculate_target_position() -> Vector3:
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)
+982 -207
View File
@@ -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.53)
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,10 +325,17 @@ 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,212 +537,379 @@ 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():
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 config = phase_configs[int(current_phase)]
var telegraph_time = config["telegraph_time"]
var highlight_time: float = 0.8 # Floor highlight duration before telegraph
var selected := _select_cells_weighted(candidates, count)
selected = _apply_path_safety(selected)
if selected.is_empty():
return
# Highlight phase — show pulsing floor warning BEFORE telegraph
_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_telegraph_highlight", targets)
await get_tree().create_timer(highlight_time).timeout
rpc("sync_growth_telegraph", selected)
else:
sync_growth_telegraph(selected)
# Telegraph phase — show warning overlay
await get_tree().create_timer(telegraph_duration).timeout
for pos in selected:
telegraphed_cells.erase(pos)
if _can_rpc():
rpc("sync_telegraph", targets)
rpc("sync_growth_apply", selected)
else:
sync_growth_apply(selected)
# 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)
emit_signal("growth_tick", selected)
# 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
# Possibly start a candy bubble this tick (anti-camping hazard, #082).
_try_spawn_bubble()
if _can_rpc():
rpc("sync_impact", targets)
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)
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 = []
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):
candidates.append({"pos": pos, "score": _calculate_candidate_score(pos, player_cells)})
return candidates
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
valid_positions.append(pos)
if sticky_cells.has(pos + Vector2i(dx, dz)):
c += 1
return c
if valid_positions.is_empty():
return targets
func _chebyshev(a: Vector2i, b: Vector2i) -> int:
return max(abs(a.x - b.x), abs(a.y - b.y))
# Simple targeting: mix of random + player-adjacent
var remaining = volley_size
# --- camping tracking --------------------------------------------------------
# 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)
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)
# 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
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)
# 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
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 _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
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)
_spawn_telegraph_highlight(pos)
# Audio: warning pulse
if SfxManager:
SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile")
func _spawn_telegraph_highlight(pos: Vector2i) -> void:
"""Two-stage amber warning under a telegraphed cell (#069):
Build-up (00.8s): amber glow ramps alpha 01.
Flash (0.81.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)
# 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 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(1.0, 0.3, 0.5, 0.4) # Pink warning glow
mat.albedo_color = Color(amber.r, amber.g, amber.b, 0.0)
mat.emission_enabled = true
mat.emission = Color(1.0, 0.3, 0.5)
mat.emission_energy_multiplier = 2.0
mat.emission = amber
mat.emission_energy_multiplier = 1.5
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:
if not main:
return
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)
# 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()
)
# 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."""
func sync_growth_apply(cells: Array) -> void:
"""Convert telegraphed cells to permanent sticky candy."""
if not gridmap: return
# Place telegraph tiles
for target in targets:
var pos = target as Vector2i
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH)
# Animate telegraph with Tween (build-up phase)
_animate_telegraph(targets)
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
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
@rpc("authority", "call_local", "reliable")
func sync_impact(targets: Array) -> void:
"""Apply sticky cells at target positions."""
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
@@ -676,14 +917,13 @@ func sync_impact(targets: Array) -> void:
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)
_spawn_impact_particles(cells)
# Check if any player is now trapped
# Re-evaluate trapped players after the new sticky cells land.
_check_all_players_trapped()
func _spawn_impact_particles(targets: Array) -> void:
@@ -736,15 +976,545 @@ 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
@@ -763,6 +1533,7 @@ func _trap_player(player: Node) -> void:
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)
@@ -817,12 +1588,6 @@ func _try_use_cleanser() -> void:
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]
+30 -26
View File
@@ -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)
+13 -3
View File
@@ -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):
+19 -13
View File
@@ -136,12 +136,7 @@ func simple_move_to(grid_position: Vector2i) -> bool:
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
@@ -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:
+3 -3
View File
@@ -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]}}
}
}
+278 -54
View File
@@ -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,18 +567,46 @@ 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"
@@ -576,6 +642,17 @@ func _on_history_pressed() -> void:
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...")
_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):
_set_status("Message deleted!", CLR_STATUS_OK)
chat_tree.get_root().remove_child(item)
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()
else:
_set_status("Failed: " + str(res.get("error", "")), CLR_STATUS_ERR)
_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
+124 -1
View File
@@ -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")
+1 -1
View File
@@ -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)
+30
View File
@@ -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
-55
View File
@@ -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)
+24
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://b7ihsm80fbyb5
+9
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://ca04jq87bj3ap
+163
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://bkte51v8tyoii
-41
View File
@@ -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 ===")
-1
View File
@@ -1 +0,0 @@
uid://ct0psnc84v1sy
+115
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://b1bay8n1h65u3
+159
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://btbxtdhagjdba
+119
View File
@@ -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")
@@ -0,0 +1 @@
uid://4cttae74ja3t
+10 -10
View File
@@ -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():
+185
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://tugcu571care
+208
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://csco4t66gq5et
+3 -3
View File
@@ -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