SkaterMap.cs

Map instance for the skate game that manages grind path reconstruction and map changes. It captures grind_path and grind_path_node entities on map load, attempts several strategies to rebuild missing path node link data (parentname grouping, hammerUniqueId ordering, and geometric/proximity reconstruction), and exposes a map change RPC/console command.

NetworkingFile Access
using Skateboard.Entities;
using System;

namespace Skateboard;

public sealed class SkaterMap : MapInstance
{
	public static SkaterMap Current { get; set; }

	/// <summary>
	/// If a recompiled map lost its grind_path "pathNodesJSON" (so the paths come through
	/// empty), rebuild grind rails by connecting the loose grind_path_node entities.
	/// </summary>
	[Property] public bool GrindAutoConnect { get; set; } = true;
	/// <summary>How many nearest nodes each node links to. 2 reconstructs simple rails; raise to fill gaps.</summary>
	[Property] public int GrindAutoConnectNeighbors { get; set; } = 2;
	/// <summary>Maximum link length - just a safety cap, since only the nearest neighbours are linked.</summary>
	[Property] public float GrindAutoConnectDistance { get; set; } = 600f;
	[Property] public bool GrindAutoConnectLineOfSight { get; set; } = false;

	private bool _grindPathsChecked;

	// Captured during map load so we can recover grind paths from the Hammer hierarchy
	// (node.parentname -> path.targetname) when the pathNodesJSON keyvalue was lost.
	private readonly List<(GrindPathEntity path, string targetName, int uid)> _grindPathInfo = new();
	private readonly List<(GameObject node, string parentName, int uid)> _grindNodeInfo = new();

	// Raw keyvalue dumps for diagnosing what linkage data survived the recompile.
	private readonly List<string> _grindNodeDumps = new();
	private readonly List<string> _grindPathDumps = new();

	protected override void OnAwake()
	{
		Current = this;
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();

		// Wait until the map has fully loaded so every grind_path_node exists.
		if ( !_grindPathsChecked && IsLoaded && Game.IsPlaying )
		{
			_grindPathsChecked = true;
			TryReconstructGrindPaths();
		}
	}

	[ConCmd( "map", ConVarFlags.Admin )]
	private static void Command_ChangeMap( string mapName )
	{
		Current?.ChangeMap( mapName );
	}

	[Rpc.Broadcast]
	public void ChangeMap( string mapName )
	{
		if ( !Networking.IsHost )
			return;
		MapName = mapName;

		foreach ( var pawn in Scene.GetAllComponents<Skateboard.Player.SkatePawn>() )
		{
			pawn.Respawn();
		}
	}

	protected override void OnCreateObject( GameObject go, MapLoader.ObjectEntry kv )
	{
		if ( !Game.IsPlaying )
			return;

		if ( kv.TypeName == "grind_path" )
		{
			var json = kv.GetValue<string>( "pathNodesJSON" );
			var path = go.AddComponent<GrindPathEntity>();
			path.SetPathNodesJson( json );
			int.TryParse( kv.GetValue<string>( "hammerUniqueId" ), out var pathUid );
			_grindPathInfo.Add( (path, kv.TargetName, pathUid) );

			if ( _grindPathDumps.Count < 6 )
				_grindPathDumps.Add( DumpEntity( kv ) );
		}

		if ( kv.TypeName == "grind_path_node" )
		{
			go.AddComponent<GrindPathNodeEntity>();
			int.TryParse( kv.GetValue<string>( "hammerUniqueId" ), out var nodeUid );
			_grindNodeInfo.Add( (go, kv.ParentName, nodeUid) );

			if ( _grindNodeDumps.Count < 10 )
				_grindNodeDumps.Add( DumpEntity( kv ) );
		}
	}

	// Probe the keyvalues that path/node entities commonly use to chain together, so we can
	// see what linkage (if any) survived the recompile rather than guessing from geometry.
	private static string DumpEntity( MapLoader.ObjectEntry kv )
	{
		string[] keys =
		{
			"targetname", "parentname", "target", "nextnode", "next", "nextkey",
			"node", "node01", "next_node", "prevnode", "path", "pathid", "id",
			"index", "order", "hammerUniqueId", "pathNodesJSON"
		};

		var found = new List<string>();
		foreach ( var key in keys )
		{
			var v = kv.GetValue<string>( key );
			if ( !string.IsNullOrWhiteSpace( v ) )
				found.Add( $"{key}={v}" );
		}

		return $"pos={kv.Position} | {(found.Count > 0 ? string.Join( ", ", found ) : "(no recognised keys)")}";
	}

	private void TryReconstructGrindPaths()
	{
		if ( !GrindAutoConnect )
			return;

		// If any grind path already has usable nodes, the map's data is intact - leave it.
		foreach ( var path in Scene.GetAllComponents<GrindPathEntity>() )
		{
			path.RefreshPathNodes();
			if ( path.PathNodes is { Count: >= 2 } )
				return;
		}

		// Best case: recover the exact original grouping from the Hammer hierarchy
		// (each grind_path_node's parentname points at its grind_path's targetname).
		int recovered = ReconstructFromParentNames();
		if ( recovered > 0 )
		{
			Log.Info( $"[Skate] Recovered {recovered} grind path(s) from Hammer parent links." );
			return;
		}

		// Next best: hammerUniqueId is sequential and each path is immediately followed by its
		// own nodes, so sorting by id recovers the exact grouping and order.
		recovered = ReconstructFromHammerIds();
		if ( recovered > 0 )
		{
			Log.Info( $"[Skate] Recovered {recovered} grind path(s) from sequential hammer ids." );
			return;
		}

		Log.Info( "[Skate] No node linkage found - falling back to proximity reconstruction." );

		Log.Info( $"[Skate][diag] grind_path entities: {_grindPathInfo.Count}, grind_path_node entities: {_grindNodeInfo.Count}" );
		Log.Info( "[Skate][diag] --- sample grind_path keyvalues ---" );
		foreach ( var dump in _grindPathDumps )
			Log.Info( $"[Skate][diag] path: {dump}" );
		Log.Info( "[Skate][diag] --- sample grind_path_node keyvalues ---" );
		foreach ( var dump in _grindNodeDumps )
			Log.Info( $"[Skate][diag] node: {dump}" );

		var nodes = Scene.GetAllComponents<GrindPathNodeEntity>()
			.Select( n => n.GameObject )
			.Where( go => go.IsValid() )
			.ToList();

		if ( nodes.Count < 2 )
			return;

		var chains = BuildGrindChains( nodes );
		if ( chains.Count == 0 )
			return;

		var container = new GameObject( GameObject, true, "ReconstructedGrindPaths" );
		foreach ( var chain in chains )
		{
			var pathObject = new GameObject( container, true, "GrindPath" );
			pathObject.Components.GetOrCreate<GrindPathEntity>().PathNodes = chain;
		}

		Log.Info( $"[Skate] Grind path data was missing - reconstructed {chains.Count} rail(s) from {nodes.Count} loose nodes." );
	}

	/// <summary>
	/// Recover grind paths from the Hammer parent hierarchy: group each grind_path_node under
	/// the grind_path whose targetname matches its parentname, then order the group into a rail.
	/// Returns how many paths were rebuilt this way (0 if the nodes weren't parented).
	/// </summary>
	private int ReconstructFromParentNames()
	{
		if ( _grindNodeInfo.Count == 0 || _grindPathInfo.Count == 0 )
			return 0;

		var byParent = new Dictionary<string, List<GameObject>>( StringComparer.OrdinalIgnoreCase );
		foreach ( var (node, parentName, _) in _grindNodeInfo )
		{
			if ( !node.IsValid() || string.IsNullOrWhiteSpace( parentName ) )
				continue;

			if ( !byParent.TryGetValue( parentName, out var list ) )
			{
				list = new List<GameObject>();
				byParent[parentName] = list;
			}

			list.Add( node );
		}

		if ( byParent.Count == 0 )
			return 0;

		int recovered = 0;
		foreach ( var (path, targetName, _) in _grindPathInfo )
		{
			if ( !path.IsValid() || string.IsNullOrWhiteSpace( targetName ) )
				continue;

			if ( !byParent.TryGetValue( targetName, out var group ) || group.Count < 2 )
				continue;

			path.PathNodes = OrderNodes( group );
			recovered++;
		}

		return recovered;
	}

	/// <summary>
	/// Recover paths from sequential hammerUniqueId: entities are created path-then-its-nodes,
	/// so sorting by id and assigning each node to the most recent path rebuilds the exact rails
	/// in their original order - no geometry guessing.
	/// </summary>
	private int ReconstructFromHammerIds()
	{
		var items = new List<(int uid, GrindPathEntity path, GameObject node)>();

		foreach ( var (path, _, uid) in _grindPathInfo )
		{
			if ( uid > 0 && path.IsValid() )
				items.Add( (uid, path, null) );
		}

		foreach ( var (node, _, uid) in _grindNodeInfo )
		{
			if ( uid > 0 && node.IsValid() )
				items.Add( (uid, null, node) );
		}

		if ( items.Count == 0 )
			return 0;

		items.Sort( ( a, b ) => a.uid.CompareTo( b.uid ) );

		int recovered = 0;
		GrindPathEntity current = null;
		List<GameObject> currentNodes = null;

		void Commit()
		{
			if ( current.IsValid() && currentNodes is { Count: >= 2 } )
			{
				current.PathNodes = currentNodes;
				recovered++;
			}
		}

		foreach ( var (_, path, node) in items )
		{
			if ( path is not null )
			{
				Commit();
				current = path;
				currentNodes = new List<GameObject>();
			}
			else if ( currentNodes is not null && node.IsValid() )
			{
				currentNodes.Add( node );
			}
		}

		Commit();
		return recovered;
	}

	/// <summary>
	/// Order a single rail's nodes into a polyline: start at one extreme end (the farthest-apart
	/// pair) and walk nearest-neighbour. Unambiguous because the group is already one rail.
	/// </summary>
	private static List<GameObject> OrderNodes( List<GameObject> group )
	{
		int n = group.Count;
		var pos = group.Select( g => g.WorldPosition ).ToArray();

		// Farthest-apart pair => the two rail ends.
		int endA = 0, endB = 1;
		float best = -1f;
		for ( int i = 0; i < n; i++ )
		{
			for ( int j = i + 1; j < n; j++ )
			{
				var d = Vector3.DistanceBetween( pos[i], pos[j] );
				if ( d > best )
				{
					best = d;
					endA = i;
					endB = j;
				}
			}
		}

		var used = new bool[n];
		var ordered = new List<GameObject>( n );

		int current = endA;
		used[current] = true;
		ordered.Add( group[current] );

		for ( int step = 1; step < n; step++ )
		{
			int next = -1;
			float nd = float.MaxValue;
			for ( int j = 0; j < n; j++ )
			{
				if ( used[j] )
					continue;

				var d = Vector3.DistanceBetween( pos[current], pos[j] );
				if ( d < nd )
				{
					nd = d;
					next = j;
				}
			}

			if ( next == -1 )
				break;

			used[next] = true;
			ordered.Add( group[next] );
			current = next;
		}

		return ordered;
	}

	/// <summary>
	/// Reconstruct rails from loose nodes using directional linking: each node connects to its
	/// nearest neighbour and its nearest roughly-opposite neighbour (the collinear continuation),
	/// which follows rails straight and avoids perpendicular cross-links. Every link is then
	/// covered by a trail so no node is left disconnected. Distance is only a cap.
	/// </summary>
	private List<List<GameObject>> BuildGrindChains( List<GameObject> nodes )
	{
		int count = nodes.Count;
		var positions = nodes.Select( n => n.WorldPosition ).ToArray();
		int k = Math.Max( 1, GrindAutoConnectNeighbors );

		// Build an undirected adjacency graph from each node's K nearest in-range neighbours.
		var adjacency = new List<int>[count];
		for ( int i = 0; i < count; i++ )
			adjacency[i] = new List<int>();

		void AddEdge( int a, int b )
		{
			if ( !adjacency[a].Contains( b ) ) adjacency[a].Add( b );
			if ( !adjacency[b].Contains( a ) ) adjacency[b].Add( a );
		}

		for ( int i = 0; i < count; i++ )
		{
			var found = new List<(int idx, float dist)>();
			for ( int j = 0; j < count; j++ )
			{
				if ( i == j )
					continue;

				var dist = Vector3.DistanceBetween( positions[i], positions[j] );
				if ( dist > GrindAutoConnectDistance )
					continue;

				if ( GrindAutoConnectLineOfSight && IsGrindLinkBlocked( positions[i], positions[j] ) )
					continue;

				found.Add( (j, dist) );
			}

			if ( found.Count == 0 )
				continue;

			found.Sort( ( a, b ) => a.dist.CompareTo( b.dist ) );

			// Directional linking. Link 1 is the nearest neighbour. Link 2 is the nearest
			// *roughly opposite* neighbour - the collinear continuation down the rail - which
			// avoids 90-degree jumps onto parallel rails. Extra links (k > 2) pick up junction
			// branches that head off in a distinct direction.
			var linkDirs = new List<Vector3>();

			int nearest = found[0].idx;
			AddEdge( i, nearest );
			var dirNearest = (positions[nearest] - positions[i]).Normal;
			linkDirs.Add( dirNearest );

			for ( int t = 1; t < found.Count; t++ )
			{
				var dir = (positions[found[t].idx] - positions[i]).Normal;
				if ( Vector3.Dot( dirNearest, dir ) < -0.5f )
				{
					AddEdge( i, found[t].idx );
					linkDirs.Add( dir );
					break;
				}
			}

			for ( int t = 1; t < found.Count && linkDirs.Count < k; t++ )
			{
				var dir = (positions[found[t].idx] - positions[i]).Normal;

				bool distinct = true;
				foreach ( var existing in linkDirs )
				{
					if ( Vector3.Dot( dir, existing ) > 0.5f )
					{
						distinct = false;
						break;
					}
				}

				if ( distinct )
				{
					AddEdge( i, found[t].idx );
					linkDirs.Add( dir );
				}
			}
		}

		// Cover every edge with a trail. A node may appear in several trails (at junctions),
		// so unlike a one-pass chain walk this never drops a rail.
		var used = new HashSet<long>();
		long EdgeKey( int a, int b ) => a < b ? ((long)a << 32) | (uint)b : ((long)b << 32) | (uint)a;

		var trails = new List<List<GameObject>>();

		for ( int start = 0; start < count; start++ )
		{
			foreach ( var first in adjacency[start] )
			{
				if ( used.Contains( EdgeKey( start, first ) ) )
					continue;

				var trail = new List<int> { start, first };
				used.Add( EdgeKey( start, first ) );

				Extend( trail, forward: true );   // walk on from 'first'
				Extend( trail, forward: false );  // walk back from 'start'

				trails.Add( trail.Select( idx => nodes[idx] ).ToList() );
			}
		}

		return trails;

		void Extend( List<int> trail, bool forward )
		{
			int cur = forward ? trail[^1] : trail[0];
			int prev = forward ? trail[^2] : trail[1];

			while ( true )
			{
				int next = -1;
				foreach ( var cand in adjacency[cur] )
				{
					if ( cand == prev )
						continue;
					if ( used.Contains( EdgeKey( cur, cand ) ) )
						continue;
					next = cand;
					break;
				}

				if ( next == -1 )
					break;

				used.Add( EdgeKey( cur, next ) );
				if ( forward ) trail.Add( next );
				else trail.Insert( 0, next );

				prev = cur;
				cur = next;
			}
		}
	}

	private bool IsGrindLinkBlocked( Vector3 a, Vector3 b )
	{
		return Scene.Trace.Ray( a + Vector3.Up * 4f, b + Vector3.Up * 4f )
			.WithAnyTags( "solid", "playerclip", "passbullets", "unskateable" )
			.Run()
			.Hit;
	}
}