Entities/AiInputSource.cs

AI input source for an impostor clone player. Drives a Player by producing held and edge input bits each fixed step, implements chase heuristics (walk, jump, wall-jump, fast-fall), detour waypoint pathfinding on a coarse grid, stuck-escape behavior for sticky surfaces, lifetime expiry, and a celebratory wander mode.

File AccessNetworking
namespace BlockParty;

/// <summary>
/// AI input + chase brain for Summoner impostors. Drives a
/// <see cref="Player"/> by supplying the eight gameplay input bits each fixed step, so the AI body
/// inherits ALL of the player's physics (gravity, wall-jump, crush/spike death, wind/magnet/stasis) for
/// free. The behaviour is a simple "chase the real player" heuristic: walk toward them horizontally, and
/// jump / wall-jump to climb toward them or over an obstacle in the way. A small coarse-grid search picks
/// a nearby detour waypoint when blocks or level geometry prevent a direct route. The brain PLANS in the impostor's
/// gravity frame (vertical offsets and "grounded" measured against its current floor — the ceiling inside
/// a reverse-gravity field) and publishes its Up/Down intents as physical keys through the same swap the
/// player's input accessors apply, so a clone navigates a flipped field as competently as level ground.
///
/// It also owns the clone's lifetime countdown (<see cref="Expired"/>). <see cref="Lifetime"/> is the
/// hostile-impostor default; callers can provide a shorter lifetime.
///
/// Deterministic / replay-safe: the decision is a function of the two bodies' positions, the impostor's
/// collision flags, the fixed step, and the AUTHORITATIVE Rng stream (a small "walk instead of jump"
/// chance so the chase reads less robotic) — no <c>Time.Delta</c> — so a replay reproduces every impostor
/// move exactly. Edges (the "just pressed" bits) are derived from the previous step's held bits, mirroring
/// how <see cref="InputState.Sample"/> latches them for the real player.
/// </summary>
public sealed class AiInputSource : IPlayerInput
{
	// --- tuning ---------------------------------------------------------------------------------
	public const float Lifetime = 8.0f;               // seconds the impostor lives before it dissipates

	const float MOVE_DEADZONE = 5.0f;                 // don't jitter left/right when roughly aligned
	const float STUCK_TANGENT_DEADZONE = 5.0f;        // stuck escape: chase along the face only when the
	                                                  // target clearly leads somewhere, else nearest edge
	const float JUMP_UP_MARGIN = 8.0f;                // target must be at least this far above to jump up at it
	const float CLIMB_HEIGHT_MARGIN = 26.0f;          // target this far above (more than a single jump) → wall-climb
	const float FAST_FALL_MARGIN = 24.0f;             // hold Down to drop faster when the target is well below
	const float JUMP_COOLDOWN = 0.30f;                // min gap between jump attempts (also lets Up release so a
	                                                  // fresh UpJust edge can fire — a held Up never re-triggers)
	const int UP_HOLD_TICKS = 7;                      // ~0.12s of held Up per jump (a brief float for extra height)
	const float JUMP_CHANCE = 0.7f;                   // chance to actually leap when the player is above & the way up is clear
	const float WALK_PAUSE = 0.4f;                    // seconds to walk before reconsidering a declined jump
	const int PATH_GRID_STEP = 2;                     // coarse enough to stay cheap, fine enough for the 8px-wide body
	const float PATH_REPLAN_TIME = 0.30f;             // moving blocks/targets only need occasional route refreshes
	const float PATH_MOVE_DEADZONE = 1.0f;
	const float PATH_WAYPOINT_REACH = 2.0f;
	// Strictly less than the smallest body axis, so two player centers separated by even a zero-width
	// solid cannot be mistaken for a reached goal. Surface-gravity hitbox rotation only swaps these axes.
	static readonly float PATH_GOAL_REACH = MathF.Min( Player.COLLISION_SIZE.x, Player.COLLISION_SIZE.y );

	// Searches complete synchronously while impostors tick sequentially, so every brain can share this workspace.
	const int PATH_GRID_WIDTH = Arena.WIDTH / PATH_GRID_STEP;
	const int PATH_GRID_HEIGHT = Arena.HEIGHT / PATH_GRID_STEP;
	const int PATH_NODE_COUNT = PATH_GRID_WIDTH * PATH_GRID_HEIGHT;
	static readonly bool[] _pathBlocked = new bool[PATH_NODE_COUNT];
	static readonly int[] _pathParent = new int[PATH_NODE_COUNT];
	static readonly int[] _pathQueue = new int[PATH_NODE_COUNT];

	// --- input snapshot (what the player reads this step) ---------------------------------------
	public bool Left { get; private set; }
	public bool Right { get; private set; }
	public bool Up { get; private set; }
	public bool Down { get; private set; }
	public bool LeftJust { get; private set; }
	public bool RightJust { get; private set; }
	public bool UpJust { get; private set; }
	public bool DownJust { get; private set; }

	// --- brain state ----------------------------------------------------------------------------
	float _life;
	bool _pLeft, _pRight, _pUp, _pDown;               // previous held bits (for edge derivation)
	int _upHold;                                      // remaining ticks to keep Up held for the current jump
	float _jumpCooldown;                              // remaining time before the next jump attempt is allowed
	Direction _climbWall = Direction.None;            // the wall we're currently wall-climbing (Left/Right), else None
	float _celebrateTimer;                            // game-over celebration: time until the next random heading
	int _celebrateDir;                                // game-over celebration: current wander heading (-1/0/1)
	float _pathReplanTimer;
	Vector2 _pathWaypoint;
	bool _hasPathWaypoint;
	Direction _pathClimbWall = Direction.None;
	int _stuckEscapeDir;                              // committed tangent heading while glued (0 = free)
	Direction _stuckEscapeFace = Direction.None;      // the face that heading was committed against

	public AiInputSource( float lifetime = Lifetime )
	{
		_life = Math.Max( 0f, lifetime );
	}

	/// <summary>True once the impostor has outlived its lifetime and should dissipate.</summary>
	public bool Expired => _life <= 0f;

	/// <summary>Advance the AI one fixed step: age the lifetime and choose this step's input to chase
	/// <paramref name="target"/> (the real player's position).</summary>
	public void Think( Player self, Vector2 target, float dt )
	{
		_life -= dt;

		// Glued to a sticky block: the chase heuristics below assume free movement and just press into
		// the face — escape along its tangent instead (see ThinkStuckEscape). Nav bookkeeping pauses
		// with us; the replan timer picks the route back up on release.
		if ( ThinkStuckEscape( self, target ) ) return;

		_pathReplanTimer -= dt;
		if ( _hasPathWaypoint && (_pathWaypoint - self.Pos).Length <= PATH_WAYPOINT_REACH )
		{
			_hasPathWaypoint = false;
			_pathClimbWall = Direction.None;
			_pathReplanTimer = 0f;
		}
		if ( _pathReplanTimer <= 0f )
		{
			_hasPathWaypoint = TryFindDetourWaypoint( self, target, out _pathWaypoint, out _pathClimbWall );
			_pathReplanTimer = PATH_REPLAN_TIME;
		}

		Vector2 chaseTarget = _hasPathWaypoint ? _pathWaypoint : target;
		float dx = chaseTarget.x - self.X;
		// Vertical offset in the impostor's GRAVITY FRAME (positive = away from its current floor): inside
		// a reverse-gravity field the impostor stands on the CEILING, so "above" — needs a jump to reach —
		// means world-BELOW. Planning in the frame keeps every vertical margin/branch here unchanged; the
		// publish step at the bottom maps the frame intents back to physical keys.
		float dy = self.GravityReversed ? -(chaseTarget.y - self.Y) : (chaseTarget.y - self.Y);

		bool grounded = self.OnFloor; // the surface gravity holds us against (the ceiling when reversed)

		// Base horizontal intent: toward the target (deadzone stops left/right jitter when roughly aligned).
		float moveDeadzone = _hasPathWaypoint ? PATH_MOVE_DEADZONE : MOVE_DEADZONE;
		int hx = 0;
		if ( dx < -moveDeadzone ) hx = -1;
		else if ( dx > moveDeadzone ) hx = 1;

		// Does the target sit more than a single jump above us? If so we can't just hop up to it — we climb.
		bool needHeight = dy > CLIMB_HEIGHT_MARGIN;

		// Drop the climb target the moment we no longer need height (landed, or the target dipped to/below
		// us). Leaving it set would keep the fast-fall below suppressed even when we should be diving down.
		if ( grounded || !needHeight ) _climbWall = Direction.None;

		// WALL-CLIMB: to reach a target higher than one jump, hug a wall and repeatedly wall-jump up it. Each
		// wall-jump throws us UP and AWAY from the wall, so we keep steering BACK toward that SAME wall (while
		// holding Up) to re-touch it and jump again — exactly "hold up and move back into the wall". Once we're
		// high enough (needHeight clears) we stop and head straight for the target.
		if ( needHeight )
		{
			if ( !grounded )
			{
				Direction touching = self.CollidingLeft ? Direction.Left
								   : self.CollidingRight ? Direction.Right
								   : Direction.None;
				if ( touching != Direction.None ) _climbWall = touching;   // lock onto the wall we reach
			}

			if ( _climbWall == Direction.Left ) hx = -1;               // press into / return to the climb wall
			else if ( _climbWall == Direction.Right ) hx = 1;
			else if ( _pathClimbWall == Direction.Left ) hx = -1;
			else if ( _pathClimbWall == Direction.Right ) hx = 1;
			else if ( hx == 0 && _hasPathWaypoint && dx != 0f ) hx = dx < 0f ? -1 : 1;
			else if ( hx == 0 )
			{
				// No wall yet & aligned under the target: head for the wall the TARGET is flush against —
				// a wall-hugging player may be partway up an interior pillar face, and "nearest arena side"
				// points AWAY from a centered pillar, fighting the toward-target rule at the deadzone edge
				// (hx flips sign every step: the impostor hops and spins in place instead of climbing).
				// Only a target that isn't against anything falls back to the nearest arena side.
				int wallSide = WallSideBeside( self, chaseTarget );
				hx = wallSide != 0 ? wallSide : (self.X < Arena.WIDTH / 2f ? -1 : 1);
			}
		}

		// Target BELOW us while we're against a wall (e.g. hugging the side of a moving block high
		// above the player): don't wall-jump up it — peel off the wall so we DROP down past it. Without this the
		// "blocked toward target" wall-jump below fires forever and we grind up into the block / ceiling.
		bool targetBelow = dy < -JUMP_UP_MARGIN;
		if ( targetBelow )
		{
			if ( grounded )
			{
				// A vertical-first descending route reaches its drop by walking along the second, horizontal
				// leg. Its waypoint can share our X at the ledge lip, so the ordinary waypoint deadzone gives
				// no input; preserve the planned horizontal leg until gravity can take over past the edge.
				if ( _pathClimbWall == Direction.Left ) hx = -1;
				else if ( _pathClimbWall == Direction.Right ) hx = 1;
			}
			else
			{
				if ( self.CollidingLeft ) { hx = 1; _upHold = 0; }         // steer off the wall, stop holding Up
				else if ( self.CollidingRight ) { hx = -1; _upHold = 0; }
			}
		}
		
		bool left = hx < 0;
		bool right = hx > 0;
		bool up = false, down = false;

		// "Blocked toward the target" = pressing into a wall/block on the side we're moving. That same press
		// is what arms the player's wall-jump (hugging the wall), so pressing Up on this tick both climbs a
		// wall and hops a ground obstacle. During a climb this fires every time we re-touch the climb wall.
		bool blockedTowardTarget = (left && self.CollidingLeft) || (right && self.CollidingRight);
		bool targetAbove = dy > JUMP_UP_MARGIN;

		_jumpCooldown -= dt;
		bool wantJump = false;
		if ( _jumpCooldown <= 0f )
		{
			if ( grounded && blockedTowardTarget )
				wantJump = true;                       // always hop over something blocking our path
			else if ( grounded && targetAbove )
			{
				// Planned climbs jump immediately once the wall blocks us via the branch above. During the
				// unobstructed approach, occasionally walk before reconsidering the jump.
				if ( Rng.Value() < JUMP_CHANCE )
					wantJump = true;
				else
					_jumpCooldown = WALK_PAUSE;         // walk a moment before reconsidering
			}
			else if ( !grounded && blockedTowardTarget && targetAbove )
				wantJump = true;                       // wall-jump off a wall ONLY to climb toward a target above us
		}

		if ( wantJump )
		{
			_upHold = UP_HOLD_TICKS;
			_jumpCooldown = JUMP_COOLDOWN;
		}

		if ( _upHold > 0 )
		{
			up = true;
			_upHold--;
		}

		// Fast-fall onto a target that's well below us while airborne — but never while wall-climbing (holding
		// Down would fight the upward climb).
		if ( !grounded && _climbWall == Direction.None && dy < -FAST_FALL_MARGIN )
			down = true;

		// The brain's up/down are GRAVITY-FRAME intents (up = jump / away from the floor, down = dive
		// toward it). Publish them as PHYSICAL key bits: inside a reverse field the player-side semantic
		// swap (Player.UpPressed reads physical Down there) would otherwise re-interpret them, so swapping
		// here makes the two swaps cancel and the frame intent lands on the jump/fast-fall semantics
		// exactly. Edges latch off the previous PHYSICAL bits, mirroring how InputState latches for the
		// real player (whose physical keys are likewise what the recorder sees).
		if ( self.GravityReversed ) (up, down) = (down, up);

		PublishInput( left, right, up, down );
	}

	/// <summary>Escape input while glued to a sticky block face. The chase heuristics assume free
	/// movement — glued to a SIDE face with a level target they press into the wall and issue no climb
	/// input at all, dangling until the 4s stick timeout tears the body off. Only tangent travel works
	/// in the goo (the jump/walljump is refused), so head along the face: on the first stuck tick pick
	/// a heading — toward the chase target when it has a meaningful offset on that axis (keeps the
	/// pursuit pressure, exits the near edge), else toward the NEAREST edge — then COMMIT to it. A
	/// solid across the heading (the floor a grounded sticky block rests on, an obstacle lip we're
	/// perched on) turns the crawl into a zero-net shove-and-fold — ApplyStuckMovement pushes the body
	/// back and folds the displacement into the tangent offset, a frozen dangle all over again — so a
	/// blocked heading flips to the other edge. The commitment is what makes the flip safe: re-deriving
	/// the preference each tick would ping-pong between a blocked near edge and the open far one
	/// forever. The side-face climb keys are PHYSICAL world directions (ApplyStuckMovement routes them
	/// through GravitySign), so unlike the chase's frame intents these publish WITHOUT the
	/// reverse-field swap; a top/bottom walk is plain Left/Right either way. Draws no Rng, so it serves
	/// both the authoritative chase and the cosmetic celebration without touching either stream.</summary>
	bool ThinkStuckEscape( Player self, Vector2? chase )
	{
		if ( !self.IsStuck )
		{
			_stuckEscapeDir = 0;
			_stuckEscapeFace = Direction.None;
			return false;
		}

		Direction face = self.StuckFace;
		bool verticalFace = face is Direction.Left or Direction.Right;

		// Fresh grab (or the glue handed us to a different face): choose the heading once.
		if ( _stuckEscapeDir == 0 || face != _stuckEscapeFace )
		{
			float tangentSelf = verticalFace ? self.Y : self.X;
			float tangentEdgeRef = verticalFace ? self.StuckBlockCenter.y : self.StuckBlockCenter.x;
			float toChase = chase.HasValue ? (verticalFace ? chase.Value.y : chase.Value.x) - tangentSelf : 0f;
			_stuckEscapeDir = MathF.Abs( toChase ) > STUCK_TANGENT_DEADZONE
				? Math.Sign( toChase )
				: (tangentSelf >= tangentEdgeRef ? 1 : -1);
			_stuckEscapeFace = face;
		}

		// Heading walled off → commit to the other edge. (Both edges walled off = genuinely wedged;
		// the flip then alternates to no effect and the 4s stick timeout is the way out.)
		bool blocked = verticalFace
			? (_stuckEscapeDir > 0 ? self.CollidingUp : self.CollidingDown)
			: (_stuckEscapeDir > 0 ? self.CollidingRight : self.CollidingLeft);
		if ( blocked ) _stuckEscapeDir = -_stuckEscapeDir;

		_upHold = 0;                 // a queued jump hold is useless in the goo
		_climbWall = Direction.None; // a release re-picks its climb wall from fresh contacts

		if ( verticalFace ) PublishInput( left: false, right: false, up: _stuckEscapeDir > 0, down: _stuckEscapeDir < 0 );
		else PublishInput( left: _stuckEscapeDir < 0, right: _stuckEscapeDir > 0, up: false, down: false );
		return true;
	}

	/// <summary>Publish this step's held bits, latching the "just pressed" edges off the previous
	/// step's bits (same model as InputState.Sample).</summary>
	void PublishInput( bool left, bool right, bool up, bool down )
	{
		LeftJust = left && !_pLeft;
		RightJust = right && !_pRight;
		UpJust = up && !_pUp;
		DownJust = down && !_pDown;

		Left = left; Right = right; Up = up; Down = down;
		_pLeft = left; _pRight = right; _pUp = up; _pDown = down;
	}

	/// <summary>Find a short obstacle-aware waypoint toward the target. A route with at most two straight
	/// legs is just the ordinary direct chase, so only a route that needs a third leg produces a waypoint.
	/// The waypoint ends the first two legs, giving the existing walk/jump/wall-jump heuristic a nearby
	/// corner to pursue without trying to replace player physics with grid movement.</summary>
	bool TryFindDetourWaypoint( Player self, Vector2 target, out Vector2 waypoint, out Direction climbWall )
	{
		waypoint = target;
		climbWall = Direction.None;
		if ( HasClearSameLevelRoute( self, target ) ) return false;

		float halfWidth = self.Width / 2f;
		float halfHeight = self.Height / 2f;
		int minX = (int)MathF.Ceiling( (Arena.WALL_SIZE + halfWidth) / PATH_GRID_STEP );
		int maxX = (int)MathF.Floor( (Arena.WIDTH - Arena.WALL_SIZE - halfWidth) / PATH_GRID_STEP );
		int minY = (int)MathF.Ceiling( (Arena.WALL_SIZE + halfHeight) / PATH_GRID_STEP );
		int maxY = (int)MathF.Floor( (Arena.HEIGHT - Arena.WALL_SIZE - halfHeight) / PATH_GRID_STEP );
		int startX = Math.Clamp( (int)MathF.Round( self.X / PATH_GRID_STEP ), minX, maxX );
		int startY = Math.Clamp( (int)MathF.Round( self.Y / PATH_GRID_STEP ), minY, maxY );
		int start = PathIndex( startX, startY );

		Array.Clear( _pathBlocked );
		foreach ( Entity2D obstacle in self.Stage.GetPlayerSolidObstacles() )
			MarkPathBlocked( obstacle.GetRect(), halfWidth, halfHeight, minX, maxX, minY, maxY );
		foreach ( Block block in self.Stage.GetBlocks() )
			if ( !block.IsDead && !block.PhasingIn )
				MarkPathBlocked( block.GetRect(), halfWidth, halfHeight, minX, maxX, minY, maxY );

		// Moving solids can overlap the rounded start cell by less than one grid step. Player collision
		// resolution will separate the body, so allow the search to escape from that cell.
		_pathBlocked[start] = false;
		int targetX = Math.Clamp( (int)MathF.Round( target.x / PATH_GRID_STEP ), minX, maxX );
		bool horizontalRouteBlocked = IsHorizontalPathBlocked( startX, startY, targetX );
		Array.Fill( _pathParent, -1 );
		_pathParent[start] = start;
		int head = 0, tail = 0;
		_pathQueue[tail++] = start;
		int goal = -1;

		Vector2 targetDelta = target - self.Pos;
		Direction horizontal = targetDelta.x >= 0f ? Direction.Right : Direction.Left;
		Direction vertical = targetDelta.y >= 0f ? Direction.Up : Direction.Down;
		bool horizontalFirst = MathF.Abs( targetDelta.x ) >= MathF.Abs( targetDelta.y );
		Direction first = horizontalFirst ? horizontal : vertical;
		Direction second = horizontalFirst ? vertical : horizontal;

		while ( head < tail )
		{
			int node = _pathQueue[head++];
			int nodeX = node % PATH_GRID_WIDTH;
			int nodeY = node / PATH_GRID_WIDTH;
			if ( PathNodeReachesTarget( nodeX, nodeY, target ) )
			{
				goal = node;
				break;
			}

			for ( int i = 0; i < 4; i++ )
			{
				Direction candidate = i switch
				{
					0 => first,
					1 => second,
					2 => Globals.GetOppositeDirection( second ),
					_ => Globals.GetOppositeDirection( first ),
				};
				Vector2 step = Globals.GetVectorForDirection( candidate );
				int nextX = nodeX + (int)step.x;
				int nextY = nodeY + (int)step.y;
				if ( nextX < minX || nextX > maxX || nextY < minY || nextY > maxY ) continue;

				int next = PathIndex( nextX, nextY );
				if ( _pathBlocked[next] || _pathParent[next] != -1 ) continue;
				_pathParent[next] = node;
				_pathQueue[tail++] = next;
			}
		}

		if ( goal == -1 || goal == start ) return false;

		int pathLength = 0;
		for ( int node = goal; node != start; node = _pathParent[node] )
			_pathQueue[pathLength++] = node;

		Direction runDirection = Direction.None;
		Direction firstRunDirection = Direction.None;
		Direction secondRunDirection = Direction.None;
		Direction thirdRunDirection = Direction.None;
		int runCount = 0;
		int previous = start;
		int firstRunEnd = start;
		int secondRunEnd = start;
		for ( int i = pathLength - 1; i >= 0; i-- )
		{
			int node = _pathQueue[i];
			Direction direction = DirectionBetweenPathNodes( previous, node );
			if ( direction != runDirection )
			{
				runDirection = direction;
				runCount++;
				if ( runCount == 1 ) firstRunDirection = direction;
				else if ( runCount == 2 ) secondRunDirection = direction;
				else if ( runCount == 3 ) thirdRunDirection = direction;
			}
			if ( runCount <= 1 ) firstRunEnd = node;
			if ( runCount <= 2 ) secondRunEnd = node;
			previous = node;
		}

		// A platformer cannot execute a blocked Up/Down → Left/Right route by directly chasing the
		// target: it must keep climbing until its whole body reaches the first corner above/below the
		// obstacle. Keep the route's horizontal leg as an explicit wall-press direction: deriving it from
		// waypoint X made the target move with each replan and created an attractor beside the obstacle.
		if ( runCount == 2 && horizontalRouteBlocked
			&& firstRunDirection is Direction.Up or Direction.Down
			&& secondRunDirection is Direction.Left or Direction.Right )
		{
			waypoint = PathPosition( firstRunEnd );
			waypoint.y += Globals.GetVectorForDirection( firstRunDirection ).y * JUMP_UP_MARGIN;
			climbWall = secondRunDirection;
			return true;
		}

		if ( runCount <= 2 ) return false;

		waypoint = PathPosition( secondRunEnd );
		Direction edgeTravel = Direction.None;
		Direction edgeVertical = Direction.None;
		if ( firstRunDirection is Direction.Left or Direction.Right
			&& secondRunDirection is Direction.Up or Direction.Down )
		{
			edgeTravel = firstRunDirection;
			edgeVertical = secondRunDirection;
		}
		else if ( secondRunDirection is Direction.Left or Direction.Right
			&& thirdRunDirection is Direction.Up or Direction.Down )
		{
			edgeTravel = secondRunDirection;
			edgeVertical = thirdRunDirection;
		}

		if ( edgeTravel != Direction.None )
		{
			// At a drop, move fully past the supporting corner. At a climb, press slightly back into the
			// wall so the existing wall-hug and repeated wall-jump behavior takes over.
			bool climbing = self.GravityReversed
				? edgeVertical == Direction.Down
				: edgeVertical == Direction.Up;
			float horizontalSign = Globals.GetVectorForDirection( edgeTravel ).x;
			float horizontalOffset = climbing ? -PATH_GRID_STEP : PATH_GRID_STEP;
			waypoint.x += horizontalSign * horizontalOffset;
			if ( climbing )
			{
				waypoint.y += Globals.GetVectorForDirection( edgeVertical ).y * JUMP_UP_MARGIN;
				climbWall = edgeTravel;
			}
		}
		return true;
	}

	/// <summary>Which side of <paramref name="point"/> has a solid face flush against a player body
	/// standing there: -1 = left, 1 = right, 0 = neither. Steers a climb toward the wall a wall-hugging
	/// target is holding onto — which may be an interior obstacle (or block) face, not an arena side.
	/// Fences are excluded (players pass through them); glass is included (solid to players).</summary>
	static int WallSideBeside( Player self, Vector2 point )
	{
		float halfWidth = Player.COLLISION_SIZE.x / 2f;
		float halfHeight = Player.COLLISION_SIZE.y / 2f;
		const float PROBE = 2f;
		// End-trim the probe verticals like the player's own side-wall flag probes: hairline embedding
		// in the support UNDERFOOT (glue float round-trip, a rising block catching the feet) or a corner
		// graze would otherwise read the target's floor as a hugged side wall and steer the climb the
		// wrong way. A genuinely hugged wall overlaps most of the body — and the climb itself keys off
		// the trimmed CollidingLeft/Right flags, so an untrimmed match here could chase a wall the
		// wall-jump can never arm on.
		float trim = Player.COLLISION_SIZE.y * Player.WALL_PROBE_END_TRIM_FRAC;
		RectF leftProbe = new RectF( point.x - halfWidth - PROBE, point.y - halfHeight + trim,
			point.x - halfWidth, point.y + halfHeight - trim );
		RectF rightProbe = new RectF( point.x + halfWidth, point.y - halfHeight + trim,
			point.x + halfWidth + PROBE, point.y + halfHeight - trim );

		if ( leftProbe.Left <= Arena.WALL_SIZE ) return -1;
		if ( rightProbe.Right >= Arena.WIDTH - Arena.WALL_SIZE ) return 1;

		foreach ( Entity2D obstacle in self.Stage.GetPlayerSolidObstacles() )
		{
			if ( leftProbe.Intersects( obstacle.GetRect() ) ) return -1;
			if ( rightProbe.Intersects( obstacle.GetRect() ) ) return 1;
		}
		foreach ( Block block in self.Stage.GetBlocks() )
		{
			if ( block.IsDead || block.PhasingIn ) continue;
			if ( leftProbe.Intersects( block.GetRect() ) ) return -1;
			if ( rightProbe.Intersects( block.GetRect() ) ) return 1;
		}
		return 0;
	}

	bool HasClearSameLevelRoute( Player self, Vector2 target )
	{
		if ( !(self.CollidingDown || self.CollidingUp)
			|| MathF.Abs( target.y - self.Y ) >= PATH_GRID_STEP ) return false;

		// A standing center can round into the support's inflated grid row even though the body only
		// touches its edge. Use the continuous swept body here so that support does not invent a climb.
		float halfWidth = self.Width / 2f;
		float halfHeight = self.Height / 2f;
		RectF route = new RectF(
			MathF.Min( self.X, target.x ) - halfWidth,
			self.Y - halfHeight,
			MathF.Max( self.X, target.x ) + halfWidth,
			self.Y + halfHeight );

		foreach ( Entity2D obstacle in self.Stage.GetPlayerSolidObstacles() )
			if ( route.Intersects( obstacle.GetRect() ) ) return false;
		foreach ( Block block in self.Stage.GetBlocks() )
			if ( !block.IsDead && !block.PhasingIn && route.Intersects( block.GetRect() ) ) return false;
		return true;
	}

	static bool IsHorizontalPathBlocked( int startX, int y, int targetX )
	{
		int step = Math.Sign( targetX - startX );
		for ( int x = startX + step; step != 0 && x != targetX + step; x += step )
			if ( _pathBlocked[PathIndex( x, y )] ) return true;
		return false;
	}

	static void MarkPathBlocked( RectF solid, float halfWidth, float halfHeight,
		int minX, int maxX, int minY, int maxY )
	{
		int left = Math.Max( minX, (int)MathF.Floor( (solid.Left - halfWidth) / PATH_GRID_STEP ) + 1 );
		int right = Math.Min( maxX, (int)MathF.Ceiling( (solid.Right + halfWidth) / PATH_GRID_STEP ) - 1 );
		int bottom = Math.Max( minY, (int)MathF.Floor( (solid.Bottom - halfHeight) / PATH_GRID_STEP ) + 1 );
		int top = Math.Min( maxY, (int)MathF.Ceiling( (solid.Top + halfHeight) / PATH_GRID_STEP ) - 1 );
		for ( int y = bottom; y <= top; y++ )
			for ( int x = left; x <= right; x++ )
				_pathBlocked[PathIndex( x, y )] = true;
	}

	static bool PathNodeReachesTarget( int x, int y, Vector2 target )
	{
		Vector2 position = new Vector2( x * PATH_GRID_STEP, y * PATH_GRID_STEP );
		return MathF.Abs( position.x - target.x ) < PATH_GOAL_REACH
			&& MathF.Abs( position.y - target.y ) < PATH_GOAL_REACH;
	}

	static int PathIndex( int x, int y ) => y * PATH_GRID_WIDTH + x;
	static Vector2 PathPosition( int node )
		=> new Vector2( node % PATH_GRID_WIDTH * PATH_GRID_STEP, node / PATH_GRID_WIDTH * PATH_GRID_STEP );

	static Direction DirectionBetweenPathNodes( int from, int to )
	{
		int fromX = from % PATH_GRID_WIDTH, fromY = from / PATH_GRID_WIDTH;
		int toX = to % PATH_GRID_WIDTH, toY = to / PATH_GRID_WIDTH;
		if ( toX < fromX ) return Direction.Left;
		if ( toX > fromX ) return Direction.Right;
		if ( toY < fromY ) return Direction.Down;
		if ( toY > fromY ) return Direction.Up;
		return Direction.None;
	}

	/// <summary>Game-over CELEBRATION input (used when the PLAYER died): the impostor gleefully hops around
	/// at random instead of chasing. The run is already decided, so this is cosmetic — it draws from the
	/// cosmetic Rng stream and never ages the lifetime.</summary>
	public void Celebrate( Player self, float dt )
	{
		// Even the victory lap respects the goo: a celebrating body that hopped onto a sticky block
		// walks itself off the nearest edge (no chase target), then gets back to partying.
		if ( ThinkStuckEscape( self, null ) ) return;

		bool grounded = self.OnFloor; // hop off whatever gravity holds us to (the ceiling in a reverse field)

		// Wander: pick a fresh random horizontal heading every so often.
		_celebrateTimer -= dt;
		if ( _celebrateTimer <= 0f )
		{
			int r = Rng.CosmeticInt( 0, 3 );          // 0 = left, 1 = right, 2 = stand still
			_celebrateDir = r == 0 ? -1 : (r == 1 ? 1 : 0);
			_celebrateTimer = Rng.CosmeticFloat( 0.3f, 0.9f );
		}

		// Peel off a wall so we don't grind against it.
		if ( self.CollidingLeft ) _celebrateDir = 1;
		else if ( self.CollidingRight ) _celebrateDir = -1;

		bool left = _celebrateDir < 0;
		bool right = _celebrateDir > 0;
		bool up = false, down = false;

		// Excited hopping: jump most times we're grounded and off cooldown.
		_jumpCooldown -= dt;
		if ( grounded && _jumpCooldown <= 0f && Rng.CosmeticValue() < 0.85f )
		{
			_upHold = UP_HOLD_TICKS;
			_jumpCooldown = Rng.CosmeticFloat( 0.15f, 0.5f );
		}
		if ( _upHold > 0 ) { up = true; _upHold--; }

		// Frame → physical mapping, same as Think (a celebratory hop off a reverse-field ceiling needs
		// the physical Down key there).
		if ( self.GravityReversed ) (up, down) = (down, up);

		PublishInput( left, right, up, down );
	}
}