World/HorizonGame.MapDoors.cs

Partial of HorizonGame that holds the map door state. Syncs which doors are open and which are locked from the host, handles open/close and staff lock RPCs, and persists locks through the Backend API or a local JSON file when offline.

NetworkingFile Access
namespace HorizonRP;

/// <summary>
/// L'état des portes de la map convertie (World/MapDoor.cs) : ouvert ou fermé, et verrouillé par le
/// staff, tenu par l'hôte et partagé avec tous. Ici, sur HorizonGame, avec les autres états partagés
/// du jeu. Les verrous sont gardés en base (hz_map_locks, par MapDoor.Key) et reposés au démarrage.
/// </summary>
public sealed partial class HorizonGame
{
	/// <summary>Les portes ouvertes : numéro → côté (1 ou -1). Une porte absente est fermée.</summary>
	[Sync( SyncFlags.FromHost )] public NetDictionary<int, int> MapDoors { get; set; } = new();

	/// <summary>Les portes verrouillées (numéros) : elles ne s'ouvrent plus.</summary>
	[Sync( SyncFlags.FromHost )] public NetList<int> LockedMapDoors { get; set; } = new();

	/// <summary>Hors ligne (éditeur), les verrous vont dans ce fichier des données.</summary>
	const string MapLocksFile = "verrous_portes.json";

	bool mapLocksLoaded;
	RealTimeSince sinceMapLocksTry = 60;

	/// <summary>Un joueur ouvre ou ferme une porte de la map (touche E) ; l'hôte vérifie qu'il est à côté.</summary>
	[Rpc.Host]
	public void RequestMapDoor( int index )
	{
		var player = RoleplayPlayer.All.FirstOrDefault( x => Rpc.Caller is not null && x.Network.OwnerId == Rpc.Caller.Id );
		var door = Scene.GetAllComponents<MapDoor>().FirstOrDefault( x => x.Index == index );
		if ( player is null || door is null || player.Downed || player.Restrained )
			return;
		if ( player.WorldPosition.Distance( door.WorldTransform.PointToWorld( door.PanelCenter ) ) > MapDoor.Reach + 120 )
			return;

		if ( MapDoors.ContainsKey( index ) )
			MapDoors.Remove( index );
		else if ( LockedMapDoors.Contains( index ) )
			// Fermée à clé : la poignée résiste, et tout le monde autour l'entend.
			PlayMapDoorDenied( index );
		else
			MapDoors[index] = door.SideAwayFrom( player.WorldPosition );
	}

	/// <summary>Chez tous : quelqu'un a essayé d'ouvrir une porte verrouillée. Elle tremble, la poignée résiste.</summary>
	[Rpc.Broadcast( NetFlags.HostOnly )]
	void PlayMapDoorDenied( int index )
	{
		if ( Scene.GetAllComponents<MapDoor>().FirstOrDefault( x => x.Index == index ) is not { } door )
			return;
		door.Rattle();
		Sounds.PlayFileAt( MapDoor.LockedSound, door.SoundPosition, 0.8f );
	}

	/// <summary>
	/// G maintenu sur une porte (MapDoor.UpdateLockHold) : la verrouiller ou la déverrouiller, pour
	/// de bon. Le staff seulement pour l'instant ; les clés des joueurs viendront. Une porte ouverte
	/// se ferme en se verrouillant.
	/// </summary>
	[Rpc.Host]
	public void RequestMapDoorLock( int index, bool locked )
	{
		var player = RoleplayPlayer.All.FirstOrDefault( x => Rpc.Caller is not null && x.Network.OwnerId == Rpc.Caller.Id );
		var door = Scene.GetAllComponents<MapDoor>().FirstOrDefault( x => x.Index == index );
		if ( player is null || door is null || !player.IsStaff || door.Key is null )
			return;
		if ( player.WorldPosition.Distance( door.SoundPosition ) > MapDoor.Reach + 120 )
			return;
		if ( locked == LockedMapDoors.Contains( index ) )
			return;

		if ( locked )
		{
			MapDoors.Remove( index );
			LockedMapDoors.Add( index );
		}
		else
			LockedMapDoors.Remove( index );
		player.Notify( locked ? "Porte verrouillée (elle le reste au redémarrage)." : "Porte déverrouillée.", "info" );
		_ = SaveMapLock( player, door.Key, locked );
	}

	async Task SaveMapLock( RoleplayPlayer staff, string key, bool locked )
	{
		var by = $"{staff.RpName} ({staff.SteamId})";
		if ( Backend.IsOnline )
		{
			var response = await Backend.SetMapLock( key, locked, by );
			if ( response?.Ok != true && staff.IsValid() )
				staff.Notify( "Verrou posé en jeu, mais non enregistré (API injoignable) : il sautera au redémarrage.", "erreur" );
			return;
		}
		var keys = FileSystem.Data.ReadJsonOrDefault<List<string>>( MapLocksFile ) ?? new();
		keys.Remove( key );
		if ( locked )
			keys.Add( key );
		FileSystem.Data.WriteJson( MapLocksFile, keys );
	}

	/// <summary>Hôte, chaque seconde : une fois les portes de la map construites, leurs verrous reposés.</summary>
	void TickMapLocks()
	{
		if ( mapLocksLoaded || sinceMapLocksTry < 30f )
			return;
		var doors = Scene.GetAllComponents<MapDoor>().Where( x => x.Key is not null ).ToList();
		if ( doors.Count == 0 )
			return;
		mapLocksLoaded = true;
		sinceMapLocksTry = 0;
		_ = LoadMapLocks( doors );
	}

	async Task LoadMapLocks( List<MapDoor> doors )
	{
		List<string> keys;
		if ( Backend.IsOnline )
		{
			keys = (await Backend.LoadMapLocks())?.Keys;
			if ( keys is null )
			{
				// Réessayé 30 s plus tard : sans cela, les portes resteraient ouvertes jusqu'au prochain
				// redémarrage.
				mapLocksLoaded = false;
				return;
			}
		}
		else
			keys = FileSystem.Data.ReadJsonOrDefault<List<string>>( MapLocksFile ) ?? new();

		var byKey = doors.GroupBy( x => x.Key ).ToDictionary( x => x.Key, x => x.First() );
		var found = 0;
		foreach ( var key in keys )
		{
			if ( !byKey.TryGetValue( key, out var door ) || LockedMapDoors.Contains( door.Index ) )
				continue;
			MapDoors.Remove( door.Index );
			LockedMapDoors.Add( door.Index );
			found++;
		}
		if ( keys.Count > 0 )
			Log.Info( $"[Horizon] {found} porte(s) de la map verrouillée(s) sur {keys.Count} en base" );
	}
}