using Unity.Netcode; using Unity.Netcode.Components; using UnityEngine; using UnityEngine.InputSystem; using ProjectI.Combat; using ProjectI.Settings; namespace ProjectI.Player { [RequireComponent(typeof(CharacterController), typeof(NetworkObject), typeof(NetworkTransform))] public sealed partial class PrototypeFirstPersonPlayer : NetworkBehaviour { [SerializeField] private Camera playerCamera; [SerializeField] private AudioListener audioListener; [SerializeField] private Renderer bodyRenderer; [SerializeField] private Collider bodyCollider; [SerializeField] private float mouseSensitivity = 0.1f; private CharacterController motor; private NetworkTransform networkTransform; private PlayerNetworkTransform transformObserver; private readonly MovementWarpState warpState = new MovementWarpState(); private readonly WarpTravelBudget warpTravel = new WarpTravelBudget(); private readonly PendingWarpJumpQueue pendingWarpJump = new PendingWarpJumpQueue(); private bool hasCorrectionIssueTick; private int correctionIssueTick; private uint correctionLifetime, correctionSequence, ownerReceivedSequence, ownerMessageLifetime; private ulong correctionRun, correctionStage; private string correctionScene; private bool hasRemotePose, hasEligiblePose, hasQueuedCorrection, ownerPublicationArmed, ownerPublicationSent, ownerRearmPublication; private int remotePoseTick, eligiblePoseTick; private Vector3 remotePose, eligiblePose; private Correction queuedCorrection, ownerPublication; private struct Correction { public uint Sequence, Lifetime; public ulong Run, Stage; public string Scene; public int IssueTick; public bool HasIssueTick; public Vector3 Position; public Quaternion Rotation; public bool Grounded, ResetJump; public double Expires; } private float pitch; private float verticalSpeed; private Vector3 lastAcceptedPosition; private Vector3 lastSupportedPosition; private Quaternion lastAcceptedRotation; private NetworkTickSystem movementTicks; private readonly MovementValidationClock validationClock = new MovementValidationClock(); private readonly ReceivedMovementBudget receivedMovementBudget = new ReceivedMovementBudget(); private float recoilPitch; private float recoilRecovery = 8; private Vector3 spawnPosition; private PrototypePlayerCombat combat; private PrototypePlayerVitals vitals; private PrototypePlayerPassiveEffects passives; private readonly JumpAllowanceState localJumps = new JumpAllowanceState(0); private readonly JumpAllowanceState serverJumps = new JumpAllowanceState(0); private uint jumpSequence, lastServerJumpSequence; private float lastServerGroundedAt = -1000; private double lastServerGroundContactTime = double.NegativeInfinity; private uint minimumServerContactJumpSequence; private double nextFootstep; private Vector3 lastFootstepPosition; private static uint nextJumpLifetime; private readonly NetworkVariable jumpLifetime = new NetworkVariable(); private ExtraJumpPresentation extraJumpPresentation; private Vector3 movementSamplePosition; private double movementSampleTime; private bool acceptedSprint, lastSentSprint; private double lastSprintSent, lastSprintReceived; private bool headlessDashProbe; public Camera PlayerCamera => playerCamera; public Vector3 ServerAcceptedPosition => lastAcceptedPosition; public Vector3 ServerAcceptedForward => lastAcceptedRotation * Vector3.forward; public Vector3 SpawnPosition => spawnPosition; public Vector3 InputMoveDirection => MovementRules.ResolveDodgeDirection(ReadMoveInput(), transform.forward); public bool GroundedForJump => ProbeGrounded(); public float VerticalVelocity => verticalSpeed; public uint PositionCorrectionCount { get; private set; } public string LastJumpRejection { get; private set; } = ""; public uint ExtraJumpCueCount { get; private set; } public uint ServerAcceptedJumpCount { get; private set; } public bool HeadlessDashProbeEnabled => headlessDashProbe; #if UNITY_EDITOR || DEVELOPMENT_BUILD private bool jumpOnNextCorrection; public bool ArmJumpOnNextCorrection() { if (!IsOwner || !IsSpawned || vitals == null || !vitals.IsAlive) return false; jumpOnNextCorrection = true; return true; } public MovementWarpPhase DiagnosticWarpPhase => warpState.Phase; public uint DiagnosticCorrectionSequence => IsServer ? correctionSequence : ownerReceivedSequence; public int DiagnosticPublishedTick => warpState.HasPublication ? warpState.PublishedTick : 0; public int DiagnosticEligibleTick => warpState.HasEligiblePose ? warpState.EligibleTick : 0; public bool DiagnosticPendingWarpJump => pendingWarpJump.HasPending; public uint DiagnosticQueuedWarpJumpCount { get; private set; } internal void TraceWarp(string kind, string decision, int tick = 0, bool teleport = false, Vector3 pose = default, bool resultKnown = false, bool result = false, uint sequence = 0, uint lifetime = 0, ulong run = 0, ulong stage = 0, string scene = null, ulong sender = 0, bool freshKnown = false, bool fresh = false, bool orderKnown = false, bool order = false, bool spatialKnown = false, bool spatial = false, bool travelKnown = false, bool travel = false) { if (!WarpDiagnosticTrace.IsEnabledFor(this)) return; var ticks = NetworkManager != null ? NetworkManager.NetworkTickSystem : null; WarpDiagnosticTrace.Record(this, new WarpDiagnosticTraceBuffer.Event { Kind = kind, Decision = decision, Frame = Time.frameCount, Realtime = Time.unscaledTimeAsDouble, LocalTick = ticks != null ? ticks.LocalTime.Tick : 0, ServerTick = ticks != null ? ticks.ServerTime.Tick : 0, NativeTick = tick, Teleport = teleport, Pose = pose, Owner = OwnerClientId, NetworkObjectId = NetworkObjectId, Lifetime = combat != null ? combat.SpawnLifetime : correctionLifetime, Sequence = IsServer ? correctionSequence : ownerReceivedSequence, Run = correctionRun, Stage = correctionStage, Scene = correctionScene, ClaimedSequence = sequence, ClaimedLifetime = lifetime, ClaimedRun = run, ClaimedStage = stage, ClaimedScene = scene, Sender = sender, Spawned = IsSpawned, Server = IsServer, LocalOwner = IsOwner, Queued = hasQueuedCorrection, Armed = ownerPublicationArmed, Sent = ownerPublicationSent, Rearm = ownerRearmPublication, HasIssueTick = IsOwner ? ownerPublication.HasIssueTick : hasCorrectionIssueTick, IssueTick = IsOwner ? ownerPublication.IssueTick : correctionIssueTick, QueuedSequence = queuedCorrection.Sequence, QueuedLifetime = queuedCorrection.Lifetime, QueuedRun = queuedCorrection.Run, QueuedStage = queuedCorrection.Stage, QueuedScene = queuedCorrection.Scene, QueuedExpires = queuedCorrection.Expires, PublicationSequence = ownerPublication.Sequence, PublicationLifetime = ownerPublication.Lifetime, PublicationRun = ownerPublication.Run, PublicationStage = ownerPublication.Stage, PublicationScene = ownerPublication.Scene, QueuedIssueTick = queuedCorrection.IssueTick, QueuedHasIssueTick = queuedCorrection.HasIssueTick, WarpPhase = (int)warpState.Phase, PublishedTick = warpState.HasPublication ? warpState.PublishedTick : 0, EligibleTick = warpState.HasEligiblePose ? warpState.EligibleTick : 0, Expires = ownerPublication.Expires, ResultKnown = resultKnown, Result = result, FreshKnown = freshKnown, Fresh = fresh, OrderKnown = orderKnown, Order = order, SpatialKnown = spatialKnown, Spatial = spatial, TravelKnown = travelKnown, Travel = travel }); } #endif public bool SpatiallySettled { get { if (!IsSpawned) return false; if (!IsServer) return true; RefreshCorrectionIdentity(); warpState.Tick(Time.unscaledTimeAsDouble); return warpState.SpatiallySettled; } } private int TrustedTickRate => NetworkManager != null && NetworkManager.NetworkConfig.TickRate <= 1000 ? (int)NetworkManager.NetworkConfig.TickRate : 0; private int TrustedServerTick => NetworkManager != null && NetworkManager.NetworkTickSystem != null ? NetworkManager.NetworkTickSystem.ServerTime.Tick : 0; private ulong CurrentRun => ProjectI.Runs.PrototypeRunDirector.Instance != null && ProjectI.Runs.PrototypeRunDirector.Instance.IsSpawned ? ProjectI.Runs.PrototypeRunDirector.Instance.RunId : 0; private ulong CurrentStage => ProjectI.Runs.PrototypeRunDirector.Instance != null && ProjectI.Runs.PrototypeRunDirector.Instance.IsSpawned ? ProjectI.Runs.PrototypeRunDirector.Instance.StageId : 0; private string CurrentScene => UnityEngine.SceneManagement.SceneManager.GetActiveScene().name; private Vector3 ValidationPose => !IsOwner && hasRemotePose ? remotePose : transform.position; private float AuthorizedMovementSpeed => vitals != null && vitals.IsDowned ? 0 : combat != null ? combat.ServerMaximumMoveSpeed : MovementRules.SprintSpeed; private void ResetReceivedMovementBudget(Vector3 anchor, int sourceTick = 0, bool sourceKnown = false) => receivedMovementBudget.Reset(anchor, Time.unscaledTimeAsDouble, AuthorizedMovementSpeed, vitals != null && vitals.IsDowned, sourceTick, sourceKnown); private bool TryPreviewMovement(Vector3 pose, float elapsed, float speed, out ReceivedMovementBudget.Preview preview, out double fundedSeconds) { preview = default; fundedSeconds = elapsed; if (IsOwner || !hasRemotePose) return MovementRules.IsPlausiblePosition(lastAcceptedPosition, pose, elapsed, speed, ProjectI.Runs.StageSpatialContext.Envelope); bool valid = receivedMovementBudget.TryPreview(remotePoseTick, pose, Time.unscaledTimeAsDouble, TrustedServerTick, TrustedTickRate, speed, vitals != null && vitals.IsDowned, ProjectI.Runs.StageSpatialContext.Envelope, out preview); if (valid) fundedSeconds = preview.FundedSeconds; return valid; } public bool TryGetServerActionPosition(out Vector3 position) => ValidateServerActionPosition(out position, true); private bool ValidateServerActionPosition(out Vector3 position, bool correctInvalid) { position = lastAcceptedPosition; if (!IsServer || !IsSpawned || !SpatiallySettled) return false; float now = Time.unscaledTime; // During remote teleport propagation, retain the authoritative destination. if (!validationClock.CanValidate(now)) return true; float maximumSpeed = combat != null ? combat.ServerMaximumMoveSpeed : MovementRules.SprintSpeed; if (vitals != null && vitals.IsDowned) maximumSpeed = 0; float elapsed = validationClock.Elapsed(now); Vector3 pose = ValidationPose; if (!ProjectI.Runs.StageSpatialContext.AllowsMovement || !TryPreviewMovement(pose, elapsed, maximumSpeed, out _, out double fundedSeconds) || combat != null && !combat.ValidateSlipstreamStepServer(lastAcceptedPosition, pose, fundedSeconds)) { if (correctInvalid) CheckMovement(); if (!SpatiallySettled) return false; return false; } // Do not advance the periodic anchor/clock: firing must not grant extra travel budget. position = pose; return true; } private void Awake() { #if UNITY_EDITOR || DEVELOPMENT_BUILD ownerArrivalInputEnabled = OwnerArrivalInputLease.AllowsArguments(Debug.isDebugBuild, System.Environment.GetCommandLineArgs()); headlessDashProbe = MovementRules.AllowsHeadlessDashProbe(Debug.isDebugBuild, Application.isBatchMode, !string.IsNullOrEmpty(ProjectI.Sessions.PrototypeSessionController.CommandLineValue("--probe-report")) && !string.IsNullOrEmpty(ProjectI.Sessions.PrototypeSessionController.CommandLineValue("--probe-command")), System.Array.IndexOf(System.Environment.GetCommandLineArgs(), "--probe-headless-dash") >= 0); #endif motor = GetComponent(); networkTransform = GetComponent(); transformObserver = GetComponent(); combat = GetComponent(); vitals = GetComponent(); passives = GetComponent(); playerCamera.enabled = false; audioListener.enabled = false; motor.enabled = false; } public override void OnNetworkSpawn() { ResetCorrectionState(); if (IsServer && nextJumpLifetime < uint.MaxValue) jumpLifetime.Value = ++nextJumpLifetime; extraJumpPresentation = GetComponent(); if (extraJumpPresentation == null) extraJumpPresentation = gameObject.AddComponent(); extraJumpPresentation.Bind(NetworkObjectId, jumpLifetime.Value); jumpLifetime.OnValueChanged += OnJumpLifetimeChanged; playerCamera.enabled = IsOwner; audioListener.enabled = IsOwner; bodyRenderer.enabled = !IsOwner; bodyCollider.enabled = !IsOwner; motor.enabled = IsOwner; if (IsOwner) playerCamera.fieldOfView = LocalPlayerSettings.HipFieldOfView; lastAcceptedPosition = transform.position; spawnPosition = transform.position; lastSupportedPosition = spawnPosition; lastFootstepPosition = spawnPosition; nextFootstep = Time.unscaledTimeAsDouble + .6; lastAcceptedRotation = transform.rotation; validationClock.Accept(Time.unscaledTime); ResetReceivedMovementBudget(lastAcceptedPosition); ResetMovementSample(); if (IsServer && ProbeGrounded()) lastServerGroundedAt = Time.unscaledTime; StopMovementValidation(); if (IsServer) { movementTicks = NetworkManager.NetworkTickSystem; movementTicks.Tick += OnMovementTick; } } private void Update() { if (!IsSpawned) return; RefreshCorrectionIdentity(); if (IsServer) warpState.Tick(Time.unscaledTimeAsDouble); if (IsServer && warpState.SpatiallySettled) ProcessPendingWarpJump(); if (IsOwner) ProcessQueuedCorrection(); if (IsOwner) MoveOwner(); } private void OnMovementTick() { if (IsSpawned && IsServer) { if (!IsOwner) receivedMovementBudget.FundHost(Time.unscaledTimeAsDouble, AuthorizedMovementSpeed, vitals != null && vitals.IsDowned); RefreshWarpSpeedCeiling(); CheckMovement(); if (!SpatiallySettled) return; double sampleNow = NetworkManager.ServerTime.Time; double sampleElapsed = sampleNow - movementSampleTime; Vector3 sampleDelta = lastAcceptedPosition - movementSamplePosition; sampleDelta.y = 0; if (sampleElapsed > 0 && sampleElapsed < .5 && validationClock.CanValidate(Time.unscaledTime)) ProjectI.Runs.PrototypeRunDirector.Instance?.Effects.SampleMovement(OwnerClientId, sampleDelta.magnitude / (float)sampleElapsed, acceptedSprint && sampleNow - lastSprintReceived < .6 && combat != null && combat.CanAct, sampleNow); movementSamplePosition = lastAcceptedPosition; movementSampleTime = sampleNow; serverJumps.ConfigureExtraJumps(passives != null ? passives.ExtraJumpCount : 0); bool grounded = ProbeGrounded(); if (grounded) lastServerGroundedAt = Time.unscaledTime; // Rendered remote poses can deliver an old landing after the owner has relaunched. serverJumps.ObserveGrounded(grounded, false); if (grounded && combat != null && combat.CanAct && Time.unscaledTimeAsDouble >= nextFootstep && (transform.position - lastFootstepPosition).sqrMagnitude > 2) { float soundRadius = (transform.position - lastFootstepPosition).magnitude > combat.MovementSpeed * .72f ? 8 : 4; nextFootstep = Time.unscaledTimeAsDouble + .6; lastFootstepPosition = transform.position; } } } private Vector2 ReadMoveInput() { var keyboard = Keyboard.current; if (keyboard == null) return Vector2.zero; return MovementRules.NormalizeInput(new Vector2((keyboard.dKey.isPressed ? 1 : 0) - (keyboard.aKey.isPressed ? 1 : 0), (keyboard.wKey.isPressed ? 1 : 0) - (keyboard.sKey.isPressed ? 1 : 0))); } private bool debugMovementOverride, debugMovementSprint; private Vector2 debugMovementInput; public bool SetDebugMovementInput(Vector2 input, bool sprint) { if (!ProjectI.Debugging.DebugArenaEntry.InArena || !IsSpawned || !IsOwner || !IsServer || ProjectI.Debugging.DebugScenarioController.Instance == null || !ProjectI.Debugging.DebugScenarioController.Instance.IsAutomated || !MovementRules.IsFinite(input.x) || !MovementRules.IsFinite(input.y)) return false; debugMovementInput = MovementRules.NormalizeInput(input); debugMovementSprint = sprint; debugMovementOverride = true; return true; } public void ClearDebugMovementInput() { debugMovementInput = Vector2.zero; debugMovementSprint = debugMovementOverride = false; } private bool ProbeGrounded() => ProbeGrounded(transform.position); private bool ProbeGrounded(Vector3 position) => Physics.SphereCast(position + Vector3.up * .18f, .14f, Vector3.down, out var hit, .25f, ~LayerMask.GetMask("Players"), QueryTriggerInteraction.Ignore) && hit.normal.y > .55f; private void ObserveOwnerGrounded() { uint previousContact = localJumps.GroundContactSequence; localJumps.ObserveGrounded(motor.isGrounded || ProbeGrounded()); if (localJumps.GroundContactSequence != previousContact) GroundContactRpc(localJumps.GroundContactSequence, jumpSequence, transform.position, NetworkManager.ServerTime.Time, ProjectI.Runs.PrototypeRunDirector.Instance?.RunId??0, ProjectI.Runs.PrototypeRunDirector.Instance?.StageId??0); } private bool IsPlausibleReportedPosition(Vector3 position, double contactTime) { if (!SpatiallySettled) return false; if (!MovementRules.TryGetReportedPositionHorizon(NetworkManager.ServerTime.Time, contactTime, validationClock.Elapsed(Time.unscaledTime), out float horizon)) return false; float maximumSpeed = combat != null ? combat.ServerMaximumMoveSpeed : MovementRules.SprintSpeed; return ProjectI.Runs.StageSpatialContext.AllowsMovement && MovementRules.IsPlausiblePosition(lastAcceptedPosition, position, horizon, maximumSpeed, ProjectI.Runs.StageSpatialContext.Envelope); } [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] private void GroundContactRpc(uint contactSequence, uint sourceJumpSequence, Vector3 position, double contactTime, ulong runId, ulong stageId, RpcParams rpc = default) { if (runId!=(ProjectI.Runs.PrototypeRunDirector.Instance?.RunId??0)||stageId!=(ProjectI.Runs.PrototypeRunDirector.Instance?.StageId??0) ||rpc.Receive.SenderClientId != OwnerClientId || !vitals.IsAlive || sourceJumpSequence < minimumServerContactJumpSequence || contactTime < lastServerGroundContactTime || !IsPlausibleReportedPosition(position, contactTime) || !ProbeGrounded(position)) return; if (serverJumps.ObserveValidatedGroundContact(contactSequence)) { lastServerGroundContactTime = contactTime; lastServerGroundedAt = Time.unscaledTime; } } public bool RequestJump() { if (!IsOwner || !IsSpawned || vitals == null || !vitals.IsAlive || jumpSequence == uint.MaxValue) return false; localJumps.ConfigureExtraJumps(passives != null ? passives.ExtraJumpCount : 0); ObserveOwnerGrounded(); if (!localJumps.TryJump()) return false; verticalSpeed = 7; JumpRequestRpc(++jumpSequence, localJumps.LastJumpWasExtra, localJumps.GroundContactSequence, transform.position, NetworkManager.ServerTime.Time,ProjectI.Runs.PrototypeRunDirector.Instance?.RunId??0,ProjectI.Runs.PrototypeRunDirector.Instance?.StageId??0); return true; } [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] private void JumpRequestRpc(uint sequence, bool extra, uint contactSequence, Vector3 launchPosition, double contactTime,ulong runId,ulong stageId, RpcParams rpc = default) { if(runId!=(ProjectI.Runs.PrototypeRunDirector.Instance?.RunId??0)||stageId!=(ProjectI.Runs.PrototypeRunDirector.Instance?.StageId??0) ||rpc.Receive.SenderClientId != OwnerClientId || sequence == 0 || sequence <= lastServerJumpSequence) return; lastServerJumpSequence = sequence; RefreshCorrectionIdentity(); if (!SpatiallySettled) { // Preserve the owner's first launch while transform proof is in flight. This // input never supersedes its correction or extends that correction's deadline. if (vitals != null && vitals.IsAlive && Time.unscaledTimeAsDouble <= warpState.RecoveryUntil && MovementRules.TryGetReportedPositionHorizon(NetworkManager.ServerTime.Time, contactTime, 0, out _)) { bool queued = pendingWarpJump.Enqueue(sequence, correctionSequence, correctionLifetime, CurrentRun, CurrentStage, CurrentScene, contactTime, warpState.RecoveryUntil, extra, contactSequence, launchPosition); #if UNITY_EDITOR || DEVELOPMENT_BUILD if (queued && DiagnosticQueuedWarpJumpCount < uint.MaxValue) DiagnosticQueuedWarpJumpCount++; #endif } return; } ProcessJumpRequest(sequence, extra, contactSequence, launchPosition, contactTime, false); } private void ProcessJumpRequest(uint sequence, bool extra, uint contactSequence, Vector3 launchPosition, double contactTime, bool preserveCorrection) { serverJumps.ConfigureExtraJumps(passives != null ? passives.ExtraJumpCount : 0); bool grounded = ProbeGrounded(); if (grounded) lastServerGroundedAt = Time.unscaledTime; serverJumps.ObserveGrounded(grounded, false); bool validPosition = IsPlausibleReportedPosition(launchPosition, contactTime); bool validContact = validPosition && contactTime >= lastServerGroundContactTime && ProbeGrounded(launchPosition); // Use the validated actual launch point for ledge departures while the rendered pose lags. if (validPosition) serverJumps.ObserveGrounded(ProbeGrounded(launchPosition), false); LastJumpRejection = !vitals.IsAlive ? "Downed" : !validPosition || !ValidateServerActionPosition(out _, !preserveCorrection) ? "Position" : !serverJumps.TryRequestedJump(extra, Time.unscaledTime - lastServerGroundedAt <= .25f, contactSequence, validContact) ? (extra ? "ExtraAllowance" : "GroundLaunch") : ""; if (LastJumpRejection.Length == 0 && !extra) lastServerGroundContactTime = contactTime; if (LastJumpRejection.Length == 0 && ServerAcceptedJumpCount < uint.MaxValue) ServerAcceptedJumpCount++; if (ExtraJumpEventGate.ShouldPublish(LastJumpRejection.Length == 0, extra)) PublishAcceptedExtraJump(sequence, launchPosition); if (LastJumpRejection.Length > 0) { #if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.Log("Jump validation rejected owner=" + OwnerClientId + " reason=" + LastJumpRejection + " jumpSequence=" + sequence + " contactSequence=" + contactSequence + " extra=" + extra + " contactAge=" + (NetworkManager.ServerTime.Time - contactTime).ToString("F4") + " validPosition=" + validPosition + " validContact=" + validContact + " renderedGrounded=" + grounded + " launchGrounded=" + (validPosition && ProbeGrounded(launchPosition)) + " launch=" + launchPosition.ToString("F4") + " rendered=" + transform.position.ToString("F4") + " accepted=" + lastAcceptedPosition.ToString("F4") + " contactTime=" + contactTime.ToString("F4") + " priorContactTime=" + lastServerGroundContactTime.ToString("F4")); #endif if (!preserveCorrection) IssueCorrection(lastAcceptedPosition, lastAcceptedRotation, PrepareGroundedCorrection(lastAcceptedPosition), true); } } private void ResetMovementSample() { movementSamplePosition = lastAcceptedPosition; movementSampleTime = IsSpawned ? NetworkManager.ServerTime.Time : 0; acceptedSprint = false; } private void OnJumpLifetimeChanged(uint previous, uint value) => extraJumpPresentation?.Bind(NetworkObjectId, value); private void PublishAcceptedExtraJump(uint sequence, Vector3 position) { if (!IsServer || !IsSpawned || jumpLifetime.Value == 0) return; ProjectI.Runs.PrototypeRunDirector.Instance?.Effects.AcceptedExtraJump(OwnerClientId, NetworkManager.ServerTime.Time); AcceptedExtraJumpRpc(new ExtraJumpVisualEvent { PlayerId = NetworkObjectId, Lifetime = jumpLifetime.Value, RunId = ProjectI.Runs.PrototypeRunDirector.Instance?.RunId ?? 0, StageId = ProjectI.Runs.PrototypeRunDirector.Instance?.StageId ?? 0, Sequence = sequence, Position = position, ServerTime = NetworkManager.ServerTime.Time }); } [Rpc(SendTo.Everyone, InvokePermission = RpcInvokePermission.Server)] private void AcceptedExtraJumpRpc(ExtraJumpVisualEvent value) { if (!IsSpawned || value.PlayerId != NetworkObjectId || value.Lifetime != jumpLifetime.Value) return; if (value.RunId != (ProjectI.Runs.PrototypeRunDirector.Instance?.RunId ?? 0) || value.StageId != (ProjectI.Runs.PrototypeRunDirector.Instance?.StageId ?? 0)) return; if (extraJumpPresentation != null && extraJumpPresentation.Play(value, NetworkManager.ServerTime.Time)) ExtraJumpCueCount++; } [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] private void SprintStateRpc(bool sprinting, RpcParams rpc = default) { if (rpc.Receive.SenderClientId == OwnerClientId) { acceptedSprint = sprinting; lastSprintReceived = NetworkManager.ServerTime.Time; } } private bool PrepareGroundedCorrection(Vector3 position) { if (!ProbeGrounded(position)) return false; serverJumps.RecoverGroundedCorrection(); minimumServerContactJumpSequence = lastServerJumpSequence; lastServerGroundedAt = Time.unscaledTime; return true; } public void ResetJumpStateServer() { if (!IsServer || !IsSpawned) return; serverJumps.Reset(); lastServerGroundedAt = -1000; lastServerGroundContactTime = double.NegativeInfinity; ResetJumpOwnerRpc(); } [Rpc(SendTo.Owner, InvokePermission = RpcInvokePermission.Server)] private void ResetJumpOwnerRpc() { localJumps.Reset(); ClearDebugMovementInput(); #if UNITY_EDITOR || DEVELOPMENT_BUILD ClearOwnerArrivalInput("JumpReset"); #endif } private void StopMovementValidation() { if (movementTicks == null) return; movementTicks.Tick -= OnMovementTick; movementTicks = null; } private void MoveOwner() { var keyboard = Keyboard.current; var mouse = Mouse.current; bool controlsActive = Application.isFocused && Cursor.lockState == CursorLockMode.Locked && !LocalPlayerSettings.IsInputBlocked && !ProjectI.Runs.PrototypeRunDirector.BlockMovement; bool debugControls = debugMovementOverride && ProjectI.Debugging.DebugArenaEntry.InArena && !LocalPlayerSettings.IsInputBlocked && !ProjectI.Runs.PrototypeRunDirector.BlockMovement; bool canMove = (controlsActive || debugControls) && (vitals == null || vitals.IsAlive); bool leaseControls = false, leaseSprint = false; Vector2 leaseInput = Vector2.zero; #if UNITY_EDITOR || DEVELOPMENT_BUILD leaseControls = ReadOwnerArrivalInput(controlsActive, out leaseInput, out leaseSprint); #endif Vector2 input = Vector2.zero; if (canMove && (controlsActive || debugControls) && keyboard != null) { input.x = (keyboard.dKey.isPressed ? 1 : 0) - (keyboard.aKey.isPressed ? 1 : 0); input.y = (keyboard.wKey.isPressed ? 1 : 0) - (keyboard.sKey.isPressed ? 1 : 0); } input = MovementRules.NormalizeInput(input); if (canMove && debugControls) input = debugMovementInput; if (leaseControls) { input = leaseInput; canMove = true; } #if UNITY_EDITOR || DEVELOPMENT_BUILD if (leaseControls) { Vector2 look = ownerArrivalInput.LookDegreesPerSecond * Mathf.Min(Time.deltaTime, .05f); transform.Rotate(0f, look.x, 0f); pitch = Mathf.Clamp(pitch - look.y, -85f, 85f); } #endif if (controlsActive && mouse != null) { Vector2 look = mouse.delta.ReadValue() * mouseSensitivity * LocalPlayerSettings.SensitivityMultiplier * (playerCamera.fieldOfView / LocalPlayerSettings.HipFieldOfView); if (MovementRules.IsFinite(look.x) && MovementRules.IsFinite(look.y)) { transform.Rotate(0f, look.x, 0f); pitch = Mathf.Clamp(pitch - look.y, -85f, 85f); } } recoilPitch = Mathf.MoveTowards(recoilPitch, 0, recoilRecovery * Time.unscaledDeltaTime); playerCamera.transform.localRotation = Quaternion.Euler(Mathf.Clamp(pitch - recoilPitch, -85, 85), 0f, 0f); if (motor.isGrounded && verticalSpeed < 0f) verticalSpeed = -2f; localJumps.ConfigureExtraJumps(passives != null ? passives.ExtraJumpCount : 0); ObserveOwnerGrounded(); if (canMove && (controlsActive || debugControls) && keyboard != null && keyboard.spaceKey.wasPressedThisFrame) RequestJump(); float seconds = Mathf.Min(Time.deltaTime, 0.05f); verticalSpeed = Mathf.Max(verticalSpeed - 20f * seconds, -16f); float baseSpeed = combat != null ? combat.MovementSpeed : MovementRules.WalkSpeed; bool sprinting = canMove && input.sqrMagnitude > 0 && (leaseControls ? leaseSprint : debugControls ? debugMovementSprint : controlsActive && keyboard != null && keyboard.leftShiftKey.isPressed); if (sprinting != lastSentSprint || NetworkManager.ServerTime.Time - lastSprintSent > .25) { SprintStateRpc(sprinting); lastSentSprint = sprinting; lastSprintSent = NetworkManager.ServerTime.Time; } float speed = sprinting ? baseSpeed * 1.6f : baseSpeed; Vector3 movement = (transform.right * input.x + transform.forward * input.y) * speed; // Explicit development probes can drive an accepted Dash through this same motor // without a graphical focus/cursor. Ordinary controls and all modal/alive guards stay intact. bool diagnosticDash = headlessDashProbe && !LocalPlayerSettings.IsInputBlocked && !ProjectI.Runs.PrototypeRunDirector.BlockMovement && (vitals == null || vitals.IsAlive); if ((canMove || diagnosticDash) && combat != null && combat.DashVelocity.sqrMagnitude > 0) movement = combat.DashVelocity; movement.y = verticalSpeed; #if UNITY_EDITOR || DEVELOPMENT_BUILD Vector3 beforeMotor = leaseControls ? transform.position : default; #endif motor.Move(movement * seconds); #if UNITY_EDITOR || DEVELOPMENT_BUILD if (leaseControls) RecordOwnerArrivalApplication(input, sprinting, combat != null && combat.DashVelocity.sqrMagnitude > 0, transform.position - beforeMotor); #endif } private void CheckMovement() { if (!SpatiallySettled) return; float now = Time.unscaledTime; if (!validationClock.CanValidate(now)) return; float maximumSpeed = combat != null ? combat.ServerMaximumMoveSpeed : MovementRules.SprintSpeed; if (vitals != null && vitals.IsDowned) maximumSpeed = 0; float elapsed = validationClock.Elapsed(now); Vector3 pose = ValidationPose; ReceivedMovementBudget.Preview movementPreview = default; double fundedSeconds = elapsed; bool plausible = ProjectI.Runs.StageSpatialContext.AllowsMovement && TryPreviewMovement(pose, elapsed, maximumSpeed, out movementPreview, out fundedSeconds); if (!plausible || combat != null && !combat.ValidateSlipstreamStepServer(lastAcceptedPosition, pose, fundedSeconds)) { if(!ProjectI.Runs.StageSpatialContext.Envelope.Contains(pose) &&ProjectI.Runs.StageSpatialContext.TrySupportedDestination(lastSupportedPosition,out var recovery,transform)) {ServerTeleport(recovery);return;} bool groundedCorrection = PrepareGroundedCorrection(lastAcceptedPosition); IssueCorrection(lastAcceptedPosition, lastAcceptedRotation, groundedCorrection, false); validationClock.Accept(now); #if UNITY_EDITOR || DEVELOPMENT_BUILD if (ownerArrivalInputEnabled) Debug.Log("Foundation movement correction for client " + OwnerClientId + " | owner-input diagnostic previous=" + lastAcceptedPosition.ToString("R") + " reported=" + pose.ToString("R") + " elapsed=" + elapsed.ToString("R") + " maximumSpeed=" + maximumSpeed.ToString("R") + " stageAllows=" + ProjectI.Runs.StageSpatialContext.AllowsMovement + " inside=" + ProjectI.Runs.StageSpatialContext.Envelope.Contains(pose) + " plausible=" + plausible + " fundedSeconds=" + fundedSeconds.ToString("R") + " frame=" + Time.frameCount + " realtime=" + Time.unscaledTimeAsDouble.ToString("R") + " receivedTick=" + remotePoseTick + " serverTick=" + TrustedServerTick); else #endif Debug.Log("Foundation movement correction for client " + OwnerClientId); } // Rechecking a retained remote pose still enforces safety, but cannot erase the interval // needed by its next received sample or repeat accepted-movement side effects. else if (IsOwner || hasRemotePose && movementPreview.Fresh && validationClock.CanAcceptReceivedTick(remotePoseTick)) { if (!IsOwner && !receivedMovementBudget.Commit(movementPreview)) return; combat?.NotifyAcceptedMovementServer(lastAcceptedPosition, pose); lastAcceptedPosition = pose; if(ProbeGrounded(pose))lastSupportedPosition=lastAcceptedPosition; lastAcceptedRotation = transform.rotation; if (IsOwner) validationClock.Accept(now); else validationClock.AcceptReceivedTick(now, remotePoseTick); } } public uint MovementContinuityVersion { get; private set; } public void ReanchorPendingDownedServer() { if (!IsServer || !IsSpawned || vitals == null || !vitals.IsDowned || SpatiallySettled) return; double now = Time.unscaledTimeAsDouble; warpState.RevokeRecovery(now); warpTravel.Clear(); pendingWarpJump.Clear(); hasEligiblePose = false; // Downing cancels live travel. Require a new stationary owner publication so // the earlier alive/dash proof cannot relocate the downed actor. if (!IssueCorrection(lastAcceptedPosition, lastAcceptedRotation, ProbeGrounded(lastAcceptedPosition), true)) warpState.RevokeRecovery(Time.unscaledTimeAsDouble); } public void ServerTeleport(Vector3 position,bool stageSpawn=false) { if (!IsServer || !IsSpawned || !MovementRules.IsFinite(position.x) || !MovementRules.IsFinite(position.y) || !MovementRules.IsFinite(position.z)) return; if (!ProjectI.Runs.StageSpatialContext.TrySupportedDestination(position, out position,transform)) return; if (!IssueCorrection(position, lastAcceptedRotation, ProbeGrounded(position), false)) return; lastAcceptedPosition = position; lastSupportedPosition=position;if(stageSpawn)spawnPosition=position; MovementContinuityVersion++; validationClock.BeginWarp(Time.unscaledTime, .5f); ResetMovementSample(); extraJumpPresentation?.Clear(); ResetJumpStateServer(); } public void AddRecoil(float amount, float recovery) { if (!IsOwner) return; recoilPitch = Mathf.Min(12, recoilPitch + amount); recoilRecovery = recovery; } public void SetAim(float fieldOfView) { if (IsOwner) playerCamera.fieldOfView = Mathf.MoveTowards(playerCamera.fieldOfView, fieldOfView, Time.unscaledDeltaTime * 180); } [Rpc(SendTo.Owner, InvokePermission = RpcInvokePermission.Server)] private void CorrectOwnerRpc(uint sequence, uint lifetime, ulong run, ulong stage, string scene, bool hasIssueTick, int issueTick, Vector3 position, Quaternion rotation, bool groundedCorrection, bool resetJump) { #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("CorrectionReceived", "Entry", issueTick, pose: position, sequence: sequence, lifetime: lifetime, run: run, stage: stage, scene: scene); #endif if (!IsOwner || !IsSpawned || scene == null || scene.Length > 128 || !MovementRules.IsFinite(position.x) || !MovementRules.IsFinite(position.y) || !MovementRules.IsFinite(position.z)) { #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("CorrectionReceived", "EntryGuardRejected"); #endif return; } RefreshCorrectionIdentity(); if (lifetime == 0 || (combat.SpawnLifetime != 0 && lifetime < combat.SpawnLifetime)) { #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("CorrectionReceived", "LifetimeRejected"); #endif return; } if (ownerMessageLifetime != lifetime) { if (ownerMessageLifetime != 0 && lifetime < ownerMessageLifetime) { #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("CorrectionReceived", "MessageLifetimeRejected"); #endif return; } ownerMessageLifetime = lifetime; ownerReceivedSequence = 0; ownerPublicationArmed = false; } if (sequence == 0 || sequence <= ownerReceivedSequence) { #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("CorrectionReceived", "SequenceRejected"); #endif return; } ownerReceivedSequence = sequence; #if UNITY_EDITOR || DEVELOPMENT_BUILD ClearOwnerArrivalInput("Correction"); #endif queuedCorrection = new Correction { Sequence = sequence, Lifetime = lifetime, Run = run, Stage = stage, Scene = scene, IssueTick = issueTick, HasIssueTick = hasIssueTick, Position = position, Rotation = rotation, Grounded = groundedCorrection, ResetJump = resetJump, Expires = Time.unscaledTimeAsDouble + MovementWarpState.PendingSeconds + MovementWarpState.LateRecoverySeconds }; hasQueuedCorrection = true; #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("CorrectionQueued", "Accepted", issueTick, pose: position, sequence: sequence, lifetime: lifetime, run: run, stage: stage, scene: scene); #endif ProcessQueuedCorrection(); } private void ResetCorrectionState() { validationClock.ClearReceivedTick(); receivedMovementBudget.Clear(); correctionLifetime = combat != null ? combat.SpawnLifetime : 0; warpState.Clear(correctionLifetime); warpTravel.Clear(); pendingWarpJump.Clear(); hasCorrectionIssueTick = false; #if UNITY_EDITOR || DEVELOPMENT_BUILD jumpOnNextCorrection = false; #endif correctionSequence = ownerReceivedSequence = ownerMessageLifetime = 0; correctionRun = CurrentRun; correctionStage = CurrentStage; correctionScene = CurrentScene; hasRemotePose = hasEligiblePose = hasQueuedCorrection = ownerPublicationArmed = ownerPublicationSent = ownerRearmPublication = false; } private void RefreshCorrectionIdentity() { uint life = combat != null ? combat.SpawnLifetime : 0; if (life != 0 && life != correctionLifetime) { validationClock.ClearReceivedTick(); bool preserve = hasQueuedCorrection && queuedCorrection.Lifetime == life; var queued = queuedCorrection; correctionLifetime = life; warpState.Clear(life); correctionSequence = 0; warpTravel.Clear(); pendingWarpJump.Clear(); hasCorrectionIssueTick = false; hasRemotePose = hasEligiblePose = ownerPublicationArmed = ownerPublicationSent = ownerRearmPublication = false; hasQueuedCorrection = preserve; queuedCorrection = queued; if (!preserve) { ownerReceivedSequence = 0; ownerMessageLifetime = life; } ResetReceivedMovementBudget(lastAcceptedPosition); } if (IsServer && (correctionRun != CurrentRun || correctionStage != CurrentStage || correctionScene != CurrentScene)) { validationClock.ClearReceivedTick(); warpState.Clear(correctionLifetime); warpTravel.Clear(); pendingWarpJump.Clear(); hasCorrectionIssueTick = false; hasRemotePose = hasEligiblePose = false; correctionRun = CurrentRun; correctionStage = CurrentStage; correctionScene = CurrentScene; ResetReceivedMovementBudget(lastAcceptedPosition); } } private bool IssueCorrection(Vector3 position, Quaternion rotation, bool grounded, bool resetJump) { if (!IsServer || !IsSpawned || NetworkManager == null || !NetworkManager.IsListening) return false; RefreshCorrectionIdentity(); if (correctionLifetime == 0 || correctionSequence == uint.MaxValue || TrustedTickRate == 0) return false; double now = Time.unscaledTimeAsDouble; int tick = 0; uint sequence = correctionSequence + 1; // Ordering belongs to the received owner stream, not the host's ahead-of-owner clock. warpState.Clear(correctionLifetime); bool hasFence = transformObserver != null && transformObserver.HasReceivedState; if (hasFence) { tick = transformObserver.LastReceivedTick; hasFence = warpState.RecordObservedChecked(tick, false, false, TrustedServerTick, TrustedTickRate, now); } if (!warpState.Begin(sequence, correctionLifetime, CurrentRun, CurrentStage, position, now, MovementWarpState.PendingSeconds)) return false; validationClock.ClearReceivedTick(); receivedMovementBudget.Clear(); hasCorrectionIssueTick = hasFence; correctionIssueTick = tick; pendingWarpJump.Clear(); float normalSpeed = combat != null ? combat.ServerNormalMaximumMoveSpeed : MovementRules.SprintSpeed; uint dashSequence = 0; float dashSpeed = 0, dashSeconds = 0, dashTravel = 0; if (vitals != null && vitals.IsDowned) normalSpeed = 0; else if (combat != null) combat.TryGetDashWarpBudgetServer(out dashSequence, out dashSpeed, out dashSeconds, out dashTravel, out _); warpTravel.Begin(position, TrustedTickRate, normalSpeed, dashSpeed, dashSeconds, dashTravel, dashSequence, now, warpState.RecoveryUntil); correctionSequence = sequence; hasEligiblePose = false; lastAcceptedPosition = position; CorrectOwnerRpc(sequence, correctionLifetime, CurrentRun, CurrentStage, CurrentScene, hasFence, tick, position, rotation, grounded, resetJump); if (IsOwner) { if (Vector3.Distance(transform.position, position) > .2f) return false; warpState.CompleteTrustedLocalChecked(sequence, correctionLifetime, CurrentRun, CurrentStage, true, true, now); ownerPublicationArmed = false; validationClock.Accept(Time.unscaledTime); ResetReceivedMovementBudget(lastAcceptedPosition); } return true; } private void ProcessQueuedCorrection() { if (!IsOwner || !IsSpawned) return; if (hasQueuedCorrection) { var queued = queuedCorrection; if (Time.unscaledTimeAsDouble > queued.Expires) { hasQueuedCorrection = false; #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("CorrectionQueue", "Expired"); #endif } else if (combat.SpawnLifetime == queued.Lifetime && CurrentRun == queued.Run && CurrentStage == queued.Stage && CurrentScene == queued.Scene) { hasQueuedCorrection = false; ownerPublication = queued; ownerPublicationArmed = true; ownerPublicationSent = ownerRearmPublication = false; #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("CorrectionApply", "BeforeSetState", pose: queued.Position); #endif if (queued.ResetJump) localJumps.Reset(); ApplyCorrection(queued.Position, queued.Rotation, queued.Grounded); #if UNITY_EDITOR || DEVELOPMENT_BUILD if (jumpOnNextCorrection) { jumpOnNextCorrection = false; RequestJump(); } #endif } #if UNITY_EDITOR || DEVELOPMENT_BUILD else TraceWarp("CorrectionQueue", "ScopeWaiting"); #endif } #if UNITY_EDITOR || DEVELOPMENT_BUILD if (ownerPublicationArmed && !ownerPublicationSent && ownerRearmPublication) TraceWarp("CorrectionRearm", "GateEvaluation"); #endif if (ownerPublicationArmed && !ownerPublicationSent && ownerRearmPublication && (!ownerPublication.HasIssueTick || MovementWarpState.TickIsNewer(TrustedServerTick, ownerPublication.IssueTick))) { ownerRearmPublication = false; #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("CorrectionRearm", "SetState", pose: transform.position); #endif // Re-arm only the same authorized publication when its first send shared the issue tick. networkTransform.SetState(transform.position, transform.rotation, Vector3.one, false); } } internal void ObservePublishedTransform(int tick, bool teleport, Vector3 fullPose) { if (!IsSpawned || !IsOwner || !teleport || !ownerPublicationArmed || ownerPublicationSent) { #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("OwnerPublication", "GuardRejected", tick, teleport, fullPose); #endif return; } var stamp = ownerPublication; string publicationRefusal = combat.SpawnLifetime != stamp.Lifetime ? "LifetimeRejected" : CurrentRun != stamp.Run ? "RunRejected" : CurrentStage != stamp.Stage ? "StageRejected" : CurrentScene != stamp.Scene ? "SceneRejected" : Time.unscaledTimeAsDouble > stamp.Expires ? "Expired" : null; if (publicationRefusal != null) { ownerPublicationArmed = false; #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("OwnerPublication", publicationRefusal, tick, teleport, fullPose); #endif return; } if (stamp.HasIssueTick && !MovementWarpState.TickIsNewer(tick, stamp.IssueTick)) { ownerRearmPublication = true; #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("OwnerPublication", "IssueFenceRejected", tick, teleport, fullPose, orderKnown: true, order: false); #endif return; } ownerPublicationSent = true; #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("OwnerPublication", "RpcSubmitted", tick, teleport, fullPose, orderKnown: stamp.HasIssueTick, order: true); #endif WarpPublishedRpc(stamp.Sequence, stamp.Lifetime, stamp.Run, stamp.Stage, stamp.Scene, tick); } [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)] private void WarpPublishedRpc(uint sequence, uint lifetime, ulong run, ulong stage, string scene, int tick, RpcParams rpc = default) { #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("PublicationRpc", "Entry", tick, sequence: sequence, lifetime: lifetime, run: run, stage: stage, scene: scene, sender: rpc.Receive.SenderClientId); #endif if (!MovementWarpState.PublicationSenderAllowed(IsSpawned, IsServer, OwnerClientId, rpc.Receive.SenderClientId)) { #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("PublicationRpc", "SenderRejected", tick); #endif return; } RefreshCorrectionIdentity(); string rpcRefusal = scene != CurrentScene ? "SceneRejected" : run != CurrentRun ? "RunRejected" : stage != CurrentStage ? "StageRejected" : lifetime != correctionLifetime ? "LifetimeRejected" : sequence != correctionSequence ? "SequenceRejected" : null; if (rpcRefusal != null) { #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("PublicationRpc", rpcRefusal, tick); #endif return; } bool settled = warpState.SpatiallySettled; bool recorded = warpState.RecordPublishedChecked(sequence, lifetime, run, stage, tick, TrustedServerTick, TrustedTickRate, Time.unscaledTimeAsDouble); #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("PublicationRpc", "CheckedResult", tick, resultKnown: true, result: recorded); #endif if (!settled && warpState.SpatiallySettled) CommitReceivedWarp(); } internal bool ObserveReceivedTransform(int tick, bool teleport, Vector3 fullPose) { if (!IsServer || !IsSpawned) return false; bool fresh = MovementWarpState.TickWithinWindow(tick, TrustedServerTick, TrustedTickRate); if (!fresh) { #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("ReceivedObservation", "FreshnessRejected", tick, teleport, fullPose, freshKnown: true, fresh: false); #endif return false; } RefreshCorrectionIdentity(); double now = Time.unscaledTimeAsDouble; warpState.Tick(now); RefreshWarpSpeedCeiling(); bool settled = warpState.SpatiallySettled; bool legal = false; bool afterIssue = false, spatial = false; if (!settled) { afterIssue = !hasCorrectionIssueTick || MovementWarpState.TickIsNewer(tick, correctionIssueTick); spatial = afterIssue && ProjectI.Runs.StageSpatialContext.AllowsMovement; legal = spatial && warpTravel.Observe(tick, teleport, fullPose, now, ProjectI.Runs.StageSpatialContext.Envelope); } bool recorded = warpState.RecordObservedChecked(tick, teleport, legal, TrustedServerTick, TrustedTickRate, now); #if UNITY_EDITOR || DEVELOPMENT_BUILD TraceWarp("ReceivedObservation", "CheckedResult", tick, teleport, fullPose, resultKnown: true, result: recorded, freshKnown: true, fresh: true, orderKnown: !settled, order: afterIssue, spatialKnown: !settled && afterIssue, spatial: spatial, travelKnown: !settled && spatial, travel: legal); #endif if (!settled && legal && (warpState.Phase == MovementWarpPhase.Pending || warpState.Phase == MovementWarpPhase.Committed || now <= warpState.RecoveryUntil) && (!hasEligiblePose || MovementWarpState.TickIsNewer(tick, eligiblePoseTick))) { eligiblePose = warpTravel.Pose; eligiblePoseTick = warpTravel.Tick; hasEligiblePose = true; } if (settled && hasRemotePose && tick == remotePoseTick) return fullPose.Equals(remotePose); if (settled && hasRemotePose && !MovementWarpState.TickIsNewer(tick, remotePoseTick)) return false; if (settled) { remotePose = fullPose; remotePoseTick = tick; hasRemotePose = true; } if (!settled && warpState.SpatiallySettled) CommitReceivedWarp(); return true; } private void CommitReceivedWarp() { if (!hasEligiblePose) return; remotePose = eligiblePose; remotePoseTick = eligiblePoseTick; hasRemotePose = true; lastAcceptedPosition = eligiblePose; if (ProbeGrounded(eligiblePose)) lastSupportedPosition = eligiblePose; float dashTravel = warpTravel.ConsumeDashTravel(); if (warpTravel.DashSequence != 0 && dashTravel > 0) combat?.NotifyAcceptedWarpDashTravelServer(warpTravel.DashSequence, dashTravel); validationClock.ClearReceivedTick(); validationClock.AcceptReceivedTick(Time.unscaledTime, eligiblePoseTick); ResetMovementSample(); ResetReceivedMovementBudget(eligiblePose, eligiblePoseTick, true); ProcessPendingWarpJump(); } private void RefreshWarpSpeedCeiling() { if (!IsServer || warpState.SpatiallySettled) return; float speed = vitals != null && vitals.IsDowned ? 0 : combat != null ? combat.ServerNormalMaximumMoveSpeed : MovementRules.SprintSpeed; // Buffered owner ticks do not identify when replicated boosts were applied. // Preserve only host-authorized speed during this finite proof episode; the // budget raises future credit without changing prior travel or the deadline. warpTravel.RaiseNormalSpeedLimit(speed, Time.unscaledTimeAsDouble); } private void ProcessPendingWarpJump() { if (pendingWarpJump.TryTake(correctionSequence, correctionLifetime, CurrentRun, CurrentStage, CurrentScene, Time.unscaledTimeAsDouble, NetworkManager.ServerTime.Time, out var jump) && vitals != null && vitals.IsAlive) ProcessJumpRequest(jump.Sequence, jump.Extra, jump.ContactSequence, jump.Position, jump.ContactTime, true); } private void ApplyCorrection(Vector3 position, Quaternion rotation, bool groundedCorrection = false) { #if UNITY_EDITOR || DEVELOPMENT_BUILD ClearOwnerArrivalInput("Correction"); #endif if (IsOwner) PositionCorrectionCount++; motor.enabled = false; transform.SetPositionAndRotation(position, rotation); networkTransform.SetState(position, rotation, Vector3.one, false); verticalSpeed = 0f; if (groundedCorrection) localJumps.RecoverGroundedCorrection(); motor.enabled = IsOwner; ResetMovementSample(); extraJumpPresentation?.Clear(); } public override void OnNetworkDespawn() { #if UNITY_EDITOR || DEVELOPMENT_BUILD ClearOwnerArrivalInput("Despawned"); #endif ResetCorrectionState(); ClearDebugMovementInput(); jumpLifetime.OnValueChanged -= OnJumpLifetimeChanged; extraJumpPresentation?.Clear(); StopMovementValidation(); playerCamera.enabled = false; audioListener.enabled = false; motor.enabled = false; } public override void OnLostOwnership() { validationClock.ClearReceivedTick(); receivedMovementBudget.Clear(); hasRemotePose = hasEligiblePose = false; if (IsSpawned && NetworkManager != null && NetworkManager.IsListening) ResetReceivedMovementBudget(lastAcceptedPosition); #if UNITY_EDITOR || DEVELOPMENT_BUILD ClearOwnerArrivalInput("OwnershipLost"); #endif base.OnLostOwnership(); } public override void OnGainedOwnership() { validationClock.ClearReceivedTick(); receivedMovementBudget.Clear(); hasRemotePose = hasEligiblePose = false; if (IsSpawned && NetworkManager != null && NetworkManager.IsListening) ResetReceivedMovementBudget(lastAcceptedPosition); #if UNITY_EDITOR || DEVELOPMENT_BUILD ClearOwnerArrivalInput("OwnershipChanged"); #endif base.OnGainedOwnership(); } public override void OnDestroy() { StopMovementValidation(); base.OnDestroy(); } } }