diff --git a/coordination/claims/M02-GMP-SANDBOX-CAPABILITIES-001.md b/coordination/claims/M02-GMP-SANDBOX-CAPABILITIES-001.md new file mode 100644 index 0000000..e7b821e --- /dev/null +++ b/coordination/claims/M02-GMP-SANDBOX-CAPABILITIES-001.md @@ -0,0 +1,82 @@ +# M02-GMP-SANDBOX-CAPABILITIES-001 — Debug movement capability gate + + + +## Ownership + +- Target: M02 +- Program: GMP/FND +- Owner/Agent ID: sindo-main-codex +- Branch: `work/sindo-main-codex/m02-sandbox-capabilities` +- Lease expires UTC: 2026-07-16 +- Integrator: M02 milestone integrator + +## Outcome + +Introduce an immutable local-player movement capability contract and require it +at the movement boundary so debug sprint and free flight execute only in the +explicit `RenderSandbox` profile and are rejected by `Blizzlike335`. + +## Non-goals + +- Claim that current movement values or `Blizzlike335` locomotion are 1:1. +- Build a global application/profile framework before the application shell exists. +- Remove debug Input Map actions or change their current sandbox bindings. +- Implement jump, fall, swim, collision, server authority or reconciliation. +- Mark M02 complete or edit its checklist/Evidence. + +## Paths + +- Exclusive: `src/gameplay/movement/player_movement_capabilities.gd`, + `src/tools/verify_player_movement_capabilities.gd`, this claim +- Shared/hotspots: `src/gameplay/movement/local_player_movement_controller.gd`, + `src/scenes/player/third_person_wow_controller.gd`, movement/input verifiers, + player regression scene, `docs/modules/local-player-movement.md`, + `docs/modules/player-input.md` +- Generated/ignored: local `.godot`, native DLL, generated resources and renderer corpus + +## Contracts and data + +- `PlayerMovementCapabilities` owns immutable profile ID plus explicit debug + sprint/flight booleans and safe named factories. +- `LocalPlayerMovementController` requires the capability value; missing input + falls back to `Blizzlike335`, never sandbox. +- Player scene maps its exported profile selection once at the composition boundary. +- Input actions and `MoveIntent` remain unchanged; rejected debug requests have no effect. + +## Dependencies + +- Requires: merged character presentation package on master `8c71e46`. +- Blocks: final M02 integration audit and milestone closeout. +- External state: none; verification is asset-free. + +## Verification + +- Pure profile factory/default and immutability regression. +- Sprint accepted in RenderSandbox and ignored in Blizzlike335. +- Flight toggle/vertical basis accepted in RenderSandbox and rejected in Blizzlike335. +- Real regression scene verifies both profile compositions. +- Existing input/movement/terrain/camera/presentation and renderer gates remain required. +- Fidelity evidence: negative proof that debug capabilities cannot affect the + compatibility profile; no positive movement parity claim. + +## Documentation deliverables + +- Inline public API and profile semantics on the capability/controller boundary. +- Updated movement and input specs: API, inputs/outputs, data flow, lifecycle, + failure/recovery, capability matrix and source map. + +## Simplicity and naming + +- Important name: `PlayerMovementCapabilities`. +- Simplest approach: one immutable value injected into the existing controller. +- Rejected complexity: autoload singleton, generic feature-flag service and global profile registry. +- Unavoidable complexity: one profile-to-capability mapping at scene composition. +- Measured optimization evidence: not applicable. + +## Status + +- State: active +- Done: claim and isolated worktree from merged presentation master +- Next: implement capability contract, gate controller/player and add regressions +- Blocked by: diff --git a/docs/modules/local-player-movement.md b/docs/modules/local-player-movement.md index 0fa6526..68bbd5e 100644 --- a/docs/modules/local-player-movement.md +++ b/docs/modules/local-player-movement.md @@ -5,10 +5,10 @@ | Field | Value | |---|---| | Status | Implemented | -| Target/work package | M02 / M02-GMP-MOVEMENT-001, camera basis and character-presentation consumer updates | +| Target/work package | M02 / M02-GMP-MOVEMENT-001, M02-GMP-SANDBOX-CAPABILITIES-001 and presentation consumers | | Owners | Gameplay local movement state; scene composition owns the instance | -| Last verified | `97bb53f`, 2026-07-14 | -| Profiles/capabilities | Current render sandbox; production/debug profile gate pending | +| Last verified | `working tree`, 2026-07-14 | +| Profiles/capabilities | `RenderSandbox` debug movement; `Blizzlike335` rejects debug sprint/flight | ## Purpose @@ -32,6 +32,8 @@ flowchart LR Input[MoveIntent] --> Movement[LocalPlayerMovementController] Basis[Explicit local movement Basis] --> Movement Delta[Physics delta seconds] --> Movement + Profile[Scene profile ID] --> Capabilities[PlayerMovementCapabilities] + Capabilities --> Movement Movement --> Displacement[Godot-world displacement] Movement --> Velocity[Read-only velocity state] Scene[ThirdPersonWowController] --> Basis @@ -57,10 +59,14 @@ Forbidden dependencies: | Symbol | Kind | Purpose | Thread/lifetime | Errors | |---|---|---|---|---| -| `LocalPlayerMovementController.new(...)` | Constructor | Sets explicit speeds, acceleration and sandbox sprint multiplier | Created by composition root; retained for player scene lifetime | Caller owns configuration validation | +| `PlayerMovementCapabilities.render_sandbox()` | Factory | Creates the only debug sprint/free-flight capability set | Immutable caller value | None | +| `PlayerMovementCapabilities.blizzlike_335()` | Factory | Creates the compatibility-safe set with debug movement disabled | Immutable caller value | Name is intent, not a parity claim | +| `PlayerMovementCapabilities.for_profile_id(...)` | Factory | Maps composition configuration once; unknown IDs fail closed | Immutable caller value | Unknown maps to `Blizzlike335` | +| `LocalPlayerMovementController.new(...)` | Constructor | Sets explicit speeds, acceleration, sprint multiplier and movement capabilities | Created by composition root; retained for player scene lifetime | Null capability defaults to `Blizzlike335` | | `godot_world_velocity_units_per_second` | Read-only state | Current Godot-world velocity after the last update | Stable until next mutation on owning thread | None | | `is_flight_enabled` | Read-only state | Current sandbox free-flight mode | Stable until toggle | None | -| `toggle_sandbox_flight()` | Command | Toggles flight and clears velocity | Owning physics/input thread | Returns new flight state | +| `movement_profile_id` | Read-only state | Effective capability profile ID | Controller lifetime | None | +| `toggle_sandbox_flight()` | Command | Toggles flight only when explicitly permitted and clears velocity | Owning physics/input thread | Rejected request returns `false` with state unchanged | | `advance(move_intent, godot_world_movement_basis, delta_seconds)` | Stateful update | Accelerates toward requested velocity and returns displacement for the tick | Owning physics thread; one call per tick | Negative delta is treated as zero | Constructor units: @@ -79,6 +85,7 @@ Constructor units: | Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | |---|---|---|---|---|---| | Input | `MoveIntent` | `PlayerInputSource` | Movement controller | Immutable caller-held value | One physics tick | +| Input | `PlayerMovementCapabilities` | Application/player scene composition | Movement controller | Immutable caller-held value | Controller lifetime | | Input | Godot-world `Basis` | Player or `ThirdPersonCameraRig` adapter | Movement controller | Value copy | One physics tick | | Input | Delta seconds | Godot physics loop | Movement controller | Scalar value | One physics tick | | Input | Flight toggle command | Remappable sandbox action via scene adapter | Movement controller | Synchronous command | Input event dispatch | @@ -100,6 +107,9 @@ flowchart LR Basis[Player or camera-pivot Basis] --> Directions[Forward/right directions] Directions --> Target Config[Speeds and sprint multiplier] --> Target + Profile[RenderSandbox or Blizzlike335] --> Capability[Immutable debug capabilities] + Capability -->|allow or reject sprint| Target + Capability -->|allow or reject flight toggle| Current Previous[Owned velocity] --> Integrate[move_toward by acceleration × delta] Target --> Integrate Integrate --> Current[Updated velocity] @@ -112,7 +122,8 @@ flowchart LR ```mermaid stateDiagram-v2 [*] --> GroundedSandbox - GroundedSandbox --> FlyingSandbox: toggle / clear velocity + GroundedSandbox --> FlyingSandbox: toggle [debug flight allowed] / clear velocity + GroundedSandbox --> GroundedSandbox: toggle [debug flight rejected] FlyingSandbox --> GroundedSandbox: toggle / clear velocity GroundedSandbox --> GroundedSandbox: advance FlyingSandbox --> FlyingSandbox: advance @@ -128,8 +139,11 @@ sequenceDiagram participant Input as PlayerInputSource participant Scene as ThirdPersonWowController participant Move as LocalPlayerMovementController + participant Cap as PlayerMovementCapabilities participant Present as CharacterAnimationPresenter adapter Input-->>Scene: MoveIntent + Scene->>Cap: map configured profile once + Scene->>Move: construct with immutable capabilities Scene->>Move: advance(intent, selected basis, delta) Move-->>Scene: displacement Scene->>Scene: apply global position and ground snap @@ -140,6 +154,8 @@ sequenceDiagram ## Ownership, threading and resources - The player scene creates and exclusively owns one movement controller. +- The composition root creates one immutable capability value; the controller + retains it and exposes only its effective profile ID. - Velocity and flight state have the same lifetime as that controller. - All mutations currently occur on the main input/physics thread. - Read-only value returns are copies; consumers cannot mutate owned state. @@ -153,6 +169,8 @@ sequenceDiagram | Negative physics delta | `advance` guard | Returns zero displacement; velocity unchanged | Unit regression | Resume with valid non-negative delta | | Missing scene basis owner | Scene composition defect | Controller cannot be called correctly | Scene/parser error or boundary test | Restore explicit scene dependency | | Flight toggle during motion | Command path | Velocity clears before next step | State-transition regression | Continue from zero velocity | +| Flight/sprint request in `Blizzlike335` | Capability check | Request has no effect | Profile regression | Select explicit `RenderSandbox` only for tooling/debug scenes | +| Unknown/null profile capability | Factory/constructor safe default | Uses `Blizzlike335`; debug movement disabled | Capability regression | Correct composition profile ID | | Missing terrain data | `TerrainGroundSample.is_available == false` | Scene skips existing ground snap | Stable terrain failure code | Replace/recover query backend | There is no asynchronous cancellation, retry or rollback. Recreating the @@ -169,6 +187,9 @@ controller resets velocity and flight state deterministically. | Flight vertical speed | `7.0` units/s | Sandbox debug | Fixed for controller lifetime | Vertical free-flight target | | Sprint multiplier | `6.0` | Sandbox debug | Fixed for controller lifetime | Multiplies planar and vertical targets | | Camera-relative basis | Selected only while flight is enabled | Sandbox debug | Per tick | Preserves existing pitched flight direction | +| Debug sprint permission | Enabled | `RenderSandbox` only | Immutable | Allows sprint request multiplier | +| Debug free-flight permission | Enabled | `RenderSandbox` only | Immutable | Allows toggle and vertical movement | +| Compatibility-safe debug permissions | Disabled | `Blizzlike335` | Immutable | Ignores sprint/toggle requests | ## Persistence, cache and migration @@ -188,6 +209,8 @@ separate versioned movement snapshot contract. - Unit/contract: `src/tools/verify_local_player_movement.gd` covers forward, backward, strafe, rotated basis, acceleration, deceleration, sprint, flight, pitched camera basis, state reset and invalid delta. +- Capability: `verify_player_movement_capabilities.gd` covers three profile-ID + cases, safe defaults, sprint/flight allow/reject behavior and two real scene compositions. - Boundary: verifier rejects Node/Input/Camera/ADT dependencies and movement integration/state remaining in the player scene. - Scene integration: `verify_player_input.gd` drives the asset-free player @@ -202,7 +225,8 @@ separate versioned movement snapshot contract. - Terrain/collision policy can consume displacement without changing input or velocity ownership. - A future server-aware predictor may replace this controller behind the scene composition boundary. - A typed movement snapshot may expose deterministic replay state when M08/M09 require it. -- Sandbox debug capability gating can reject sprint/flight before commands reach this controller. +- A future application profile can map to this narrow capability value without + coupling the movement controller to the application shell. ## Capability status @@ -210,7 +234,8 @@ separate versioned movement snapshot contract. |---|---|---|---| | Scene-free local velocity state | Implemented | Pure controller and source boundary regression | Integrate target checklist after review | | Existing planar acceleration/speeds | Implemented | Exact numeric unit cases and real scene regression | Compare with build 12340 before fidelity claim | -| Existing camera-relative free flight | Implemented | Pitched-basis and toggle/reset cases | Restrict to sandbox profile | +| Existing camera-relative free flight | Implemented in `RenderSandbox` | Pitched-basis, profile and toggle/reset cases | Original-client flight semantics remain out of scope | +| Typed sprint/free-flight exclusion | Implemented | Pure and real-scene profile regressions | Application shell must select profile explicitly later | | Terrain height query | Implemented | Typed `TerrainQuery` and injected player regression | Ground-snap policy remains scene-owned | | Terrain collision movement policy | Planned | Height-only query does not model collision | Add slopes/holes/collision later | | Jump/fall/swim | Planned | M02/M09 roadmap | Requires terrain/liquid and server contracts | @@ -223,7 +248,10 @@ separate versioned movement snapshot contract. properties does not reconfigure an existing controller. - Ground snap remains in the player scene, but its ADT data access is now behind a replaceable `TerrainQuery`. -- Sprint and free flight still lack a typed sandbox capability gate. +- Runtime renderer scenes intentionally default to `RenderSandbox`; a future + game-client composition must explicitly select `Blizzlike335`. +- `Blizzlike335` currently means only debug movement exclusion and must not be + interpreted as evidence that remaining movement semantics are 1:1. - The module uses Godot math value types; a future engine-independent gameplay predictor may require scalar/domain movement contracts. @@ -232,11 +260,13 @@ separate versioned movement snapshot contract. | Path | Responsibility | |---|---| | `src/gameplay/movement/local_player_movement_controller.gd` | Owned velocity/flight state and deterministic integration | +| `src/gameplay/movement/player_movement_capabilities.gd` | Immutable profile-to-debug-capability contract and safe defaults | | `src/gameplay/terrain/terrain_query.gd` | Replaceable ground-height input boundary | | `src/scenes/player/third_person_camera_rig.gd` | Camera-relative sandbox flight basis producer | | `src/scenes/character/character_animation_presenter.gd` | Locomotion presentation consumer behind the scene adapter | | `src/scenes/player/third_person_wow_controller.gd` | Composition, basis selection, displacement/terrain and presentation-state adapters | | `src/tools/verify_local_player_movement.gd` | Pure numeric, state-transition and dependency regression | +| `src/tools/verify_player_movement_capabilities.gd` | Profile factory, allow/reject and real-scene regression | | `src/tests/scenes/player_input_regression.tscn` | Asset-free integrated player fixture | | `src/tools/verify_player_input.gd` | Input-to-movement scene regression | diff --git a/docs/modules/player-input.md b/docs/modules/player-input.md index d5a886b..d4da61a 100644 --- a/docs/modules/player-input.md +++ b/docs/modules/player-input.md @@ -5,10 +5,10 @@ | Field | Value | |---|---| | Status | Partial | -| Target/work package | M02 / M02-GMP-INPUT-001, M02-GMP-MOVEMENT-001 and M02-RND-CAMERA-001 consumer updates | +| Target/work package | M02 / input, movement, camera and sandbox-capability consumer updates | | Owners | Gameplay input boundary; `sindo-main-codex` for current package | -| Last verified | `8c1cf9b`, 2026-07-14 | -| Profiles/capabilities | Current render-sandbox defaults; `Blizzlike335` binding semantics not yet verified | +| Last verified | `working tree`, 2026-07-14 | +| Profiles/capabilities | Debug requests sampled in all profiles; execution allowed only by `RenderSandbox` movement capabilities | ## Purpose @@ -21,8 +21,8 @@ independently testable local movement controller. - Define authoritative server movement, prediction or reconciliation. - Own character transform, terrain queries, camera state or animation state. - Claim that the current W/S/A/D sandbox mapping exactly matches build 12340. -- Make sprint or free flight available to the production compatibility profile; - their profile gate remains a later M02 package. +- Suppress debug action sampling by physical device or UI context; execution is + rejected at the typed movement capability boundary. ## Context and boundaries @@ -32,6 +32,7 @@ flowchart LR InputMap --> Source[PlayerInputSource] Source --> Intent[MoveIntent] Intent --> Movement[LocalPlayerMovementController] + Profile[PlayerMovementCapabilities] --> Movement Movement --> Controller[ThirdPersonWowController adapter] Controller --> Camera[ThirdPersonCameraRig] ``` @@ -65,6 +66,7 @@ Forbidden dependencies: | Input | Movement action strengths | Godot `InputMap`/`Input` | `PlayerInputSource` | Engine-owned snapshot | Sampled on main physics tick | | Input | Mouse action events | Godot `_unhandled_input` dispatch | `ThirdPersonCameraRig` through player dispatch | Engine-owned event | Current input dispatch only | | Output | `MoveIntent` | `PlayerInputSource` | `LocalPlayerMovementController` through scene composition | Immutable caller-held value | One physics tick | +| Output | Debug sprint request/vertical axes | `PlayerInputSource` | Capability-gated movement controller | Immutable intent fields | One physics tick | | Output | Camera action names | `PlayerInputActions` | `ThirdPersonCameraRig` | Static constants | Process lifetime | Side effects: @@ -84,6 +86,7 @@ flowchart LR Map -->|camera events| Camera[ThirdPersonCameraRig] Source -->|clamp cancel normalize| Intent[MoveIntent] Intent --> Consumer[LocalPlayerMovementController] + Profile[PlayerMovementCapabilities] --> Consumer Consumer --> Adapter[ThirdPersonWowController] ``` @@ -92,7 +95,9 @@ flowchart LR The adapter is stateless. The scene composition root creates one `PlayerInputSource` and `LocalPlayerMovementController` in `_ready`; each physics tick produces a new immutable intent. Flight enabled/disabled state is owned by -the movement controller and is not hidden in the input adapter. +the movement controller and is not hidden in the input adapter. Debug requests +remain data in the intent; the immutable movement capability value decides +whether they execute. ## Main sequence @@ -127,6 +132,7 @@ sequenceDiagram | Conflicting opposite actions | Pure composition | Strengths cancel deterministically | Covered by contract test | Release/remap one action | | Strength outside range | Pure composition | Value clamps to `[0, 1]` before axis calculation | Covered by contract test | Input adapter may be corrected independently | | Controller disabled/unloaded | Scene lifecycle | Sampling stops with physics processing | Existing scene lifecycle | Re-enable or recreate scene | +| Debug request in `Blizzlike335` | Movement capability check | Request is sampled but has no movement effect | Capability regression | Use `RenderSandbox` only when debug movement is intended | There is no asynchronous operation to cancel and no persisted state to roll back. @@ -138,8 +144,8 @@ There is no asynchronous operation to cancel and no persisted state to roll back | Strafe left/right | Physical A/D | Current render sandbox | Yes | Produces signed right axis | | Camera rotate | Right mouse button | Current render sandbox | Yes | Captures/releases mouse for orbit | | Camera zoom | Mouse wheel | Current render sandbox | Yes | Adjusts current camera distance | -| Debug sprint | Shift | Sandbox debug only by intent; profile gate pending | Yes | Requests existing speed multiplier | -| Debug flight up/down/toggle | E/Q/Space | Sandbox debug only by intent; profile gate pending | Yes | Requests existing free-flight controls | +| Debug sprint | Shift | Executable only in `RenderSandbox` | Yes | Requests multiplier; `Blizzlike335` ignores it | +| Debug flight up/down/toggle | E/Q/Space | Executable only in `RenderSandbox` | Yes | Requests free flight; `Blizzlike335` remains grounded | ## Persistence, cache and migration @@ -162,6 +168,8 @@ provide a settings migration once user settings are persisted. the verifier, which drives forward, camera-zoom and immediate sandbox-flight actions through the real scene controller and also rejects direct physical-key polling. +- Profile integration: `verify_player_movement_capabilities.gd` drives sprint + and flight actions through real `RenderSandbox` and `Blizzlike335` scene instances. - Fidelity evidence: current sandbox defaults retain W/S/A/D, E/Q, Shift, Space, right mouse and wheel bindings. This is observable-regression coverage only. - Performance budgets: no I/O, jobs or scene lookup per sample; one immutable @@ -185,14 +193,14 @@ provide a settings migration once user settings are persisted. | Remappable current sandbox controls | Implemented | `verify_player_input.gd` action/default checks | Add persisted user binding settings later | | `PlayerInputSource → MoveIntent` seam | Implemented | Pure composition and controller boundary checks | Local movement consumer is now extracted | | Exact 3.3.5a default semantics | Unknown | No original-client binding fixture in this package | Capture build-12340 defaults and mouse-turn/strafe policy | -| Production profile exclusion of sprint/flight | Partial | Debug actions are named explicitly | Add typed profile/capability gate in M02 | +| Compatibility-profile exclusion of sprint/flight | Implemented | Typed capability and two real-scene regressions | Application shell selection remains future work | | Jump/fall/swim/fly gameplay semantics | Planned | M02 target and gameplay roadmap | Implement after terrain/movement state extraction | ## Known gaps and risks - The defaults preserve the pre-M02 sandbox behavior, not verified original-client semantics. -- Sprint and free flight are still executable by the sandbox controller until a - typed build-profile gate is introduced. +- Input Map still exposes debug bindings process-wide; the movement consumer now + rejects their effects outside `RenderSandbox`. - Camera state is extracted; active collision remains an explicit planned policy. - There is no persisted user keybinding format or UI yet. @@ -205,10 +213,12 @@ provide a settings migration once user settings are persisted. | `src/gameplay/input/player_input_actions.gd` | Stable project action names | | `src/gameplay/input/player_input_source.gd` | Engine Input Map adapter and pure composition | | `src/gameplay/movement/local_player_movement_controller.gd` | Scene-free movement consumer | +| `src/gameplay/movement/player_movement_capabilities.gd` | Execution gate for sampled debug requests | | `src/scenes/player/third_person_camera_rig.gd` | Camera-action consumer and orbit/zoom state | | `src/scenes/player/third_person_wow_controller.gd` | Composition and input-event dispatcher | | `src/tests/scenes/player_input_regression.tscn` | Asset-free controller movement/camera regression fixture | | `src/tools/verify_player_input.gd` | Headless contract/integration regression | +| `src/tools/verify_player_movement_capabilities.gd` | Sandbox versus compatibility-profile integration regression | ## Related decisions and references diff --git a/src/gameplay/movement/local_player_movement_controller.gd b/src/gameplay/movement/local_player_movement_controller.gd index 730ceef..7468394 100644 --- a/src/gameplay/movement/local_player_movement_controller.gd +++ b/src/gameplay/movement/local_player_movement_controller.gd @@ -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 diff --git a/src/gameplay/movement/player_movement_capabilities.gd b/src/gameplay/movement/player_movement_capabilities.gd new file mode 100644 index 0000000..a0b54aa --- /dev/null +++ b/src/gameplay/movement/player_movement_capabilities.gd @@ -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) diff --git a/src/gameplay/movement/player_movement_capabilities.gd.uid b/src/gameplay/movement/player_movement_capabilities.gd.uid new file mode 100644 index 0000000..c1f663d --- /dev/null +++ b/src/gameplay/movement/player_movement_capabilities.gd.uid @@ -0,0 +1 @@ +uid://cdnulu22drbdw diff --git a/src/scenes/player/third_person_wow_controller.gd b/src/scenes/player/third_person_wow_controller.gd index dcf33eb..3f6219e 100644 --- a/src/scenes/player/third_person_wow_controller.gd +++ b/src/scenes/player/third_person_wow_controller.gd @@ -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 diff --git a/src/tools/verify_local_player_movement.gd b/src/tools/verify_local_player_movement.gd index 4d9ba3f..9bf64f2 100644 --- a/src/tools/verify_local_player_movement.gd +++ b/src/tools/verify_local_player_movement.gd @@ -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( diff --git a/src/tools/verify_player_movement_capabilities.gd b/src/tools/verify_player_movement_capabilities.gd new file mode 100644 index 0000000..1c85ba8 --- /dev/null +++ b/src/tools/verify_player_movement_capabilities.gd @@ -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)