City/CityManager.cs

Host-only city manager for a roleplay gamemode. Loads saved staff placements from the backend API or a local JSON file, spawns them as networked CityObject components, and handles place, move and remove operations with rollback and local persistence.

NetworkingFile Access
namespace HorizonRP;

/// <summary>
/// La ville posée par le staff : relue au démarrage, recréée en objets réseau,
/// et tenue à jour à chaque pose, déplacement ou suppression.
///
/// Tourne sur l'hôte seul. Les objets vivent en base (hz_placements, via l'API)
/// quand le serveur est relié au site, sinon dans un fichier local — c'est le
/// cas d'une partie lancée depuis l'éditeur.
/// </summary>
[Title( "Horizon RP – Ville" )]
[Icon( "apartment" )]
public sealed class CityManager : Component
{
	public static CityManager Current { get; private set; }

	const string LocalFile = "placements.json";

	readonly Dictionary<long, CityObject> objects = new();
	bool loaded;

	protected override void OnAwake() => Current = this;

	protected override void OnDestroy()
	{
		if ( Current == this )
			Current = null;
	}

	protected override void OnUpdate()
	{
		// On attend que le lobby existe : un objet créé avant ne serait pas
		// envoyé aux joueurs.
		if ( loaded || !Networking.IsHost || !Networking.IsActive || Scene.IsEditor )
			return;

		loaded = true;
		_ = LoadAll();
	}

	async Task LoadAll()
	{
		List<Backend.Placement> placements;

		if ( Backend.IsOnline )
		{
			var response = await Backend.LoadPlacements();
			if ( response?.Placements is null )
			{
				// Une ville vide écrasée par-dessus la vraie serait pire qu'une
				// ville absente : on réessaiera au prochain démarrage.
				Log.Warning( "[Horizon] Objets de la ville indisponibles (API injoignable)" );
				return;
			}
			placements = response.Placements;
		}
		else
		{
			placements = FileSystem.Data.ReadJsonOrDefault<List<Backend.Placement>>( LocalFile ) ?? new();
		}

		foreach ( var p in placements )
			Spawn( p );

		Log.Info( $"[Horizon] {placements.Count} objet(s) de la ville recréé(s)" );
	}

	void Spawn( Backend.Placement p )
	{
		var go = new GameObject( true, $"{CityKinds.Get( p.Kind ).Name} n°{p.Id}" );
		go.WorldPosition = new Vector3( p.X, p.Y, p.Z );
		go.WorldRotation = Rotation.From( p.Pitch, p.Yaw, p.Roll );
		go.WorldScale = p.Scale;

		var obj = go.Components.Create<CityObject>();
		obj.PlacementId = p.Id;
		obj.Kind = p.Kind;
		obj.ModelSource = p.Model;
		obj.Label = p.Label;
		obj.Data = p.Data;

		if ( p.Kind == CityKinds.Spawn )
			go.Components.Create<SpawnPoint>();

		go.NetworkSpawn();
		objects[p.Id] = obj;
	}

	static Backend.Placement ToPlacement( CityObject obj )
	{
		var angles = obj.WorldRotation.Angles();
		return new Backend.Placement
		{
			Id = obj.PlacementId,
			Kind = obj.Kind,
			Model = obj.ModelSource,
			Label = obj.Label,
			Data = obj.Data,
			X = obj.WorldPosition.x,
			Y = obj.WorldPosition.y,
			Z = obj.WorldPosition.z,
			Pitch = angles.pitch,
			Yaw = angles.yaw,
			Roll = angles.roll,
			Scale = obj.WorldScale.x,
		};
	}

	// Écritures, appelées par AdminActions après vérification du staff

	public async Task<string> Place( Backend.Placement p, string by )
	{
		if ( Backend.IsOnline )
		{
			var response = await Backend.SavePlacement( p, by );
			if ( response is null || !response.Ok )
				return response?.Error ?? "API injoignable : objet non enregistré.";
			p.Id = response.Id;
		}
		else
		{
			p.Id = objects.Count == 0 ? 1 : objects.Keys.Max() + 1;
		}

		Spawn( p );
		SaveLocal();
		return null;
	}

	public async Task<string> Move( long id, Vector3 position, Rotation rotation, float scale, string by )
	{
		if ( !objects.TryGetValue( id, out var obj ) || !obj.IsValid() )
			return "Objet introuvable.";

		var before = (obj.WorldPosition, obj.WorldRotation, obj.WorldScale);
		obj.WorldPosition = position;
		obj.WorldRotation = rotation;
		obj.WorldScale = scale;

		if ( Backend.IsOnline )
		{
			var response = await Backend.SavePlacement( ToPlacement( obj ), by );
			if ( response is null || !response.Ok )
			{
				// Pas enregistré : on remet l'objet où il était, sinon il
				// reviendrait tout seul à l'ancienne place au prochain démarrage.
				(obj.WorldPosition, obj.WorldRotation, obj.WorldScale) = before;
				return response?.Error ?? "API injoignable : déplacement annulé.";
			}
		}

		SaveLocal();
		return null;
	}

	public async Task<string> Remove( long id, string by )
	{
		if ( !objects.TryGetValue( id, out var obj ) )
			return "Objet introuvable.";

		if ( Backend.IsOnline )
		{
			var response = await Backend.DeletePlacement( id, by );
			if ( response is null || !response.Ok )
				return response?.Error ?? "API injoignable : objet conservé.";
		}

		objects.Remove( id );
		if ( obj.IsValid() )
			obj.GameObject.Destroy();

		SaveLocal();
		return null;
	}

	void SaveLocal()
	{
		if ( Backend.IsOnline )
			return;

		FileSystem.Data.WriteJson( LocalFile, objects.Values.Where( x => x.IsValid() ).Select( ToPlacement ).ToList() );
	}
}