gmp(M02): gate debug movement capabilities

Work-Package: M02-GMP-SANDBOX-CAPABILITIES-001

Agent: sindo-main-codex

Tests: capability/input/movement/terrain/camera/presentation; coordinate/streaming/docs/coordination; renderer dry-run 7/7

Fidelity: proves debug sprint/free-flight exclusion in Blizzlike335; no positive movement parity claim
This commit is contained in:
2026-07-14 23:55:27 +04:00
parent 8c71e46872
commit 742c415885
9 changed files with 382 additions and 27 deletions
@@ -13,25 +13,32 @@ var is_flight_enabled: bool:
get:
return _is_flight_enabled
var movement_profile_id: StringName:
get:
return _movement_capabilities.profile_id
var _run_speed_units_per_second: float
var _backward_speed_units_per_second: float
var _strafe_speed_units_per_second: float
var _flight_vertical_speed_units_per_second: float
var _acceleration_units_per_second_squared: float
var _sandbox_sprint_multiplier: float
var _movement_capabilities: PlayerMovementCapabilities
var _godot_world_velocity_units_per_second := Vector3.ZERO
var _is_flight_enabled := false
## Creates a movement state with explicit Godot-unit speeds and acceleration.
## Creates a movement state with explicit Godot-unit speeds, acceleration and capabilities.
## The current compatibility renderer treats one Godot unit as one WoW yard.
## A null capability input fails closed to Blizzlike335 debug permissions.
func _init(
run_speed_units_per_second: float,
backward_speed_units_per_second: float,
strafe_speed_units_per_second: float,
flight_vertical_speed_units_per_second: float,
acceleration_units_per_second_squared: float,
sandbox_sprint_multiplier: float
sandbox_sprint_multiplier: float,
movement_capabilities: PlayerMovementCapabilities = null
) -> void:
_run_speed_units_per_second = run_speed_units_per_second
_backward_speed_units_per_second = backward_speed_units_per_second
@@ -39,11 +46,18 @@ func _init(
_flight_vertical_speed_units_per_second = flight_vertical_speed_units_per_second
_acceleration_units_per_second_squared = acceleration_units_per_second_squared
_sandbox_sprint_multiplier = sandbox_sprint_multiplier
_movement_capabilities = (
movement_capabilities
if movement_capabilities != null
else PlayerMovementCapabilities.blizzlike_335()
)
## Toggles sandbox free flight and clears velocity exactly like the pre-M02 controller.
## Returns the new flight state.
## Toggles sandbox free flight and clears velocity when the profile permits it.
## Returns the resulting state; rejected compatibility-profile requests return false.
func toggle_sandbox_flight() -> bool:
if not _movement_capabilities.allows_debug_free_flight:
return false
_is_flight_enabled = not _is_flight_enabled
_godot_world_velocity_units_per_second = Vector3.ZERO
return _is_flight_enabled
@@ -83,7 +97,11 @@ func _calculate_target_velocity(move_intent: MoveIntent, godot_world_movement_ba
if move_intent.forward_axis > 0.0
else _backward_speed_units_per_second
)
var speed_multiplier := _sandbox_sprint_multiplier if move_intent.is_sprint_requested else 1.0
var speed_multiplier := (
_sandbox_sprint_multiplier
if move_intent.is_sprint_requested and _movement_capabilities.allows_debug_sprint
else 1.0
)
var target_velocity := (
forward_direction * move_intent.forward_axis * forward_speed
+ right_direction * move_intent.strafe_axis * _strafe_speed_units_per_second
@@ -0,0 +1,53 @@
class_name PlayerMovementCapabilities
extends RefCounted
## Immutable local-player movement capability selection.
## Unknown or missing profile identifiers collapse to the safe Blizzlike335
## capability set; only RenderSandbox enables debug sprint and free flight.
const BLIZZLIKE_335_PROFILE_ID: StringName = &"Blizzlike335"
const RENDER_SANDBOX_PROFILE_ID: StringName = &"RenderSandbox"
var profile_id: StringName:
get:
return _profile_id
var allows_debug_sprint: bool:
get:
return _allows_debug_sprint
var allows_debug_free_flight: bool:
get:
return _allows_debug_free_flight
var _profile_id: StringName
var _allows_debug_sprint: bool
var _allows_debug_free_flight: bool
func _init(requested_profile_id: StringName = BLIZZLIKE_335_PROFILE_ID) -> void:
if requested_profile_id == RENDER_SANDBOX_PROFILE_ID:
_profile_id = RENDER_SANDBOX_PROFILE_ID
_allows_debug_sprint = true
_allows_debug_free_flight = true
return
_profile_id = BLIZZLIKE_335_PROFILE_ID
_allows_debug_sprint = false
_allows_debug_free_flight = false
## Returns the debug-enabled capability set used by current renderer scenes.
static func render_sandbox() -> PlayerMovementCapabilities:
return PlayerMovementCapabilities.new(RENDER_SANDBOX_PROFILE_ID)
## Returns the compatibility-safe capability set with debug movement disabled.
## This name identifies the intended compatibility profile, not proven 1:1 movement.
static func blizzlike_335() -> PlayerMovementCapabilities:
return PlayerMovementCapabilities.new(BLIZZLIKE_335_PROFILE_ID)
## Maps scene/application profile configuration to a known capability set.
## Unknown identifiers fail closed to Blizzlike335.
static func for_profile_id(requested_profile_id: StringName) -> PlayerMovementCapabilities:
return PlayerMovementCapabilities.new(requested_profile_id)
@@ -0,0 +1 @@
uid://cdnulu22drbdw
@@ -7,6 +7,7 @@ const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/ad
const PLAYER_INPUT_SOURCE_SCRIPT := preload("res://src/gameplay/input/player_input_source.gd")
const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd")
const LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT := preload("res://src/gameplay/movement/local_player_movement_controller.gd")
const PLAYER_MOVEMENT_CAPABILITIES_SCRIPT := preload("res://src/gameplay/movement/player_movement_capabilities.gd")
const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrain_query.gd")
@export var extracted_dir: String = "res://data/extracted"
@@ -21,6 +22,9 @@ const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrai
@export var sprint_multiplier: float = 6.0
@export var flight_vertical_speed: float = 7.0
@export var acceleration: float = 28.0
## Local composition profile. RenderSandbox preserves debug sprint/free flight;
## Blizzlike335 rejects them but does not yet claim complete movement parity.
@export_enum("RenderSandbox", "Blizzlike335") var movement_profile_id: String = "RenderSandbox"
@export var ground_offset: float = 0.05
@export var ground_snap_speed: float = 24.0
@@ -55,7 +59,8 @@ func _ready() -> void:
strafe_speed,
flight_vertical_speed,
acceleration,
sprint_multiplier
sprint_multiplier,
PLAYER_MOVEMENT_CAPABILITIES_SCRIPT.for_profile_id(StringName(movement_profile_id))
)
_camera_rig = get_node_or_null(camera_pivot_path) as ThirdPersonCameraRig
_appearance_presenter = get_node_or_null(visual_path) as CharacterAppearancePresenter
+9 -1
View File
@@ -102,7 +102,15 @@ func _verify_scene_boundary(failures: Array[String]) -> void:
func _new_controller() -> LocalPlayerMovementController:
return LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0)
return LocalPlayerMovementController.new(
7.0,
4.5,
4.5,
7.0,
28.0,
6.0,
PlayerMovementCapabilities.render_sandbox()
)
func _intent(
@@ -0,0 +1,148 @@
extends SceneTree
## Headless M02 profile/capability gate regression for debug player movement.
const MOVEMENT_CAPABILITIES_PATH := "res://src/gameplay/movement/player_movement_capabilities.gd"
const MOVEMENT_CONTROLLER_PATH := "res://src/gameplay/movement/local_player_movement_controller.gd"
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
func _initialize() -> void:
_run_verification.call_deferred()
func _run_verification() -> void:
var failures: Array[String] = []
_verify_capability_profiles(failures)
_verify_movement_gate(failures)
_verify_real_scene_profiles(failures)
_verify_source_boundary(failures)
if not failures.is_empty():
for failure in failures:
push_error("PLAYER_MOVEMENT_CAPABILITIES: %s" % failure)
quit(1)
return
print("PLAYER_MOVEMENT_CAPABILITIES PASS profiles=3 sprint=2 flight=2 scenes=2 boundary=1")
quit(0)
func _verify_capability_profiles(failures: Array[String]) -> void:
var render_sandbox := PlayerMovementCapabilities.render_sandbox()
_expect_true(render_sandbox.profile_id == PlayerMovementCapabilities.RENDER_SANDBOX_PROFILE_ID, "sandbox profile identity", failures)
_expect_true(render_sandbox.allows_debug_sprint, "sandbox sprint capability", failures)
_expect_true(render_sandbox.allows_debug_free_flight, "sandbox flight capability", failures)
var blizzlike := PlayerMovementCapabilities.blizzlike_335()
_expect_true(blizzlike.profile_id == PlayerMovementCapabilities.BLIZZLIKE_335_PROFILE_ID, "blizzlike profile identity", failures)
_expect_true(not blizzlike.allows_debug_sprint, "blizzlike rejects sprint", failures)
_expect_true(not blizzlike.allows_debug_free_flight, "blizzlike rejects flight", failures)
var unknown_profile := PlayerMovementCapabilities.for_profile_id(&"UnknownProfile")
_expect_true(unknown_profile.profile_id == PlayerMovementCapabilities.BLIZZLIKE_335_PROFILE_ID, "unknown profile fails closed", failures)
_expect_true(not unknown_profile.allows_debug_sprint, "unknown profile rejects sprint", failures)
_expect_true(not unknown_profile.allows_debug_free_flight, "unknown profile rejects flight", failures)
func _verify_movement_gate(failures: Array[String]) -> void:
var sprint_intent := MoveIntent.new(1.0, 0.0, 0.0, true)
var sandbox_controller := _new_controller(PlayerMovementCapabilities.render_sandbox())
sandbox_controller.advance(sprint_intent, Basis.IDENTITY, 2.0)
_expect_vector_near(sandbox_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -42.0), "sandbox sprint applied", failures)
_expect_true(sandbox_controller.toggle_sandbox_flight(), "sandbox flight enabled", failures)
var blizzlike_controller := _new_controller(PlayerMovementCapabilities.blizzlike_335())
blizzlike_controller.advance(sprint_intent, Basis.IDENTITY, 2.0)
_expect_vector_near(blizzlike_controller.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -7.0), "blizzlike sprint ignored", failures)
_expect_true(not blizzlike_controller.toggle_sandbox_flight(), "blizzlike flight rejected", failures)
_expect_true(not blizzlike_controller.is_flight_enabled, "blizzlike remains grounded", failures)
var safe_default_controller := LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0)
_expect_true(safe_default_controller.movement_profile_id == PlayerMovementCapabilities.BLIZZLIKE_335_PROFILE_ID, "missing capability defaults safe", failures)
func _verify_real_scene_profiles(failures: Array[String]) -> void:
var sandbox_player := _instantiate_player("RenderSandbox", failures)
if sandbox_player != null:
_press_sprint_forward_for_two_seconds(sandbox_player)
var sandbox_movement: LocalPlayerMovementController = sandbox_player.get("_local_movement_controller")
_expect_vector_near(sandbox_movement.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -42.0), "sandbox scene sprint", failures)
sandbox_player.call("_unhandled_input", _action_event(PlayerInputActions.DEBUG_TOGGLE_FLIGHT))
_expect_true(sandbox_movement.is_flight_enabled, "sandbox scene flight", failures)
sandbox_player.free()
var blizzlike_player := _instantiate_player("Blizzlike335", failures)
if blizzlike_player != null:
_press_sprint_forward_for_two_seconds(blizzlike_player)
var blizzlike_movement: LocalPlayerMovementController = blizzlike_player.get("_local_movement_controller")
_expect_vector_near(blizzlike_movement.godot_world_velocity_units_per_second, Vector3(0.0, 0.0, -7.0), "blizzlike scene sprint rejected", failures)
blizzlike_player.call("_unhandled_input", _action_event(PlayerInputActions.DEBUG_TOGGLE_FLIGHT))
_expect_true(not blizzlike_movement.is_flight_enabled, "blizzlike scene flight rejected", failures)
blizzlike_player.free()
func _instantiate_player(profile_id: String, failures: Array[String]) -> CharacterBody3D:
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
if packed_scene == null:
failures.append("regression scene loads")
return null
var player := packed_scene.instantiate() as CharacterBody3D
if player == null:
failures.append("regression player instantiates")
return null
player.set("movement_profile_id", profile_id)
root.add_child(player)
player.set_physics_process(false)
return player
func _press_sprint_forward_for_two_seconds(player: CharacterBody3D) -> void:
Input.action_press(PlayerInputActions.MOVE_FORWARD)
Input.action_press(PlayerInputActions.DEBUG_SPRINT)
player.call("_physics_process", 2.0)
Input.action_release(PlayerInputActions.DEBUG_SPRINT)
Input.action_release(PlayerInputActions.MOVE_FORWARD)
func _action_event(action_name: StringName) -> InputEventAction:
var input_event := InputEventAction.new()
input_event.action = action_name
input_event.pressed = true
return input_event
func _verify_source_boundary(failures: Array[String]) -> void:
var capability_source := _read_text(MOVEMENT_CAPABILITIES_PATH, failures)
for forbidden_text in ["extends Node", "extends Resource", "Input.", "CharacterBody3D"]:
_expect_true(not capability_source.contains(forbidden_text), "capability omits %s" % forbidden_text, failures)
_expect_true(not capability_source.contains("\n\tset:"), "capability exposes no property setters", failures)
var movement_source := _read_text(MOVEMENT_CONTROLLER_PATH, failures)
_expect_true(movement_source.contains("allows_debug_sprint"), "movement gates sprint capability", failures)
_expect_true(movement_source.contains("allows_debug_free_flight"), "movement gates flight capability", failures)
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
_expect_true(player_source.contains("for_profile_id(StringName(movement_profile_id))"), "scene maps profile once", failures)
_expect_true(not player_source.contains('movement_profile_id == "'), "scene omits profile branches", failures)
func _new_controller(capabilities: PlayerMovementCapabilities) -> LocalPlayerMovementController:
return LocalPlayerMovementController.new(7.0, 4.5, 4.5, 7.0, 28.0, 6.0, capabilities)
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_vector_near(actual_value: Vector3, expected_value: Vector3, label: String, failures: Array[String]) -> void:
if not actual_value.is_equal_approx(expected_value):
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)