Core/PrototypeGame.cs
using Sandbox.Network;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Prototype;

public sealed partial class PrototypeGame : Component, Component.INetworkListener
{
	public static PrototypeGame Instance { get; private set; }
	[Property] public bool ReviewChaserInGame { get; set; }
	[Property] public bool ReviewGunnerInGame { get; set; }
	[Property] public bool ReviewChargerInGame { get; set; }
	[Property] public bool StartLocalChaserTest { get; set; }
	[Sync( SyncFlags.FromHost )] public RunPhase Phase { get; private set; } = RunPhase.Lobby;
	[Sync( SyncFlags.FromHost )] public int Seed { get; private set; }
	[Sync( SyncFlags.FromHost )] public int RoomNumber { get; private set; }
	[Sync( SyncFlags.FromHost )] public int EnemiesAlive { get; private set; }
	[Sync( SyncFlags.FromHost )] public int Level { get; private set; } = 1;
	[Sync( SyncFlags.FromHost )] public int Score { get; private set; }
	[Sync( SyncFlags.FromHost )] public int RunKills { get; private set; }
	[Sync(SyncFlags.FromHost)] public float RunSeconds { get; private set; }
	[Sync(SyncFlags.FromHost)] public float FinalRunSeconds { get; private set; }
	public string Status { get; private set; } = "Ready";
	public int MaxPlayers => PartyLobbyView.Capacity;
	[Sync( SyncFlags.FromHost )] public NetDictionary<string, string> PartyRooms { get; set; } = new();
	[Sync( SyncFlags.FromHost )] public NetDictionary<string, string> PlayerParties { get; set; } = new();
	public bool IsOnline => Networking.IsActive && !string.IsNullOrEmpty(ServerPartyId);
	public bool IsHost => !Networking.IsActive || Networking.IsHost;
	public ProceduralDungeon Dungeon => _dungeon;
	private int _generatedSeed;
    [Sync(SyncFlags.FromHost)] private string _healedRoom { get; set; } = "";
	public bool WorldReady => Seed != 0 && _generatedSeed == Seed && _dungeon.Rooms.Count > 0;
	[Sync( SyncFlags.FromHost )] public string ActivePartyId { get; private set; } = "";
    [Sync( SyncFlags.FromHost )] public string RunMemberIds { get; private set; } = "";
    [Sync(SyncFlags.FromHost)] public string DepartedRunIds { get; private set; } = "";
    public bool IsRunMenuOpen { get; set; }
    public bool IsRunMember( Guid id ) => RunMemberIds.Split( ',' ).Contains( id.ToString() ) && !DepartedRunIds.Split(',').Contains(id.ToString());
    public bool IsRunPlayer( FpsPlayer player ) => player.IsValid() && (!Networking.IsActive || IsRunMember( player.ParticipantId ));
    public IEnumerable<FpsPlayer> RunPlayers => Scene.GetAll<FpsPlayer>().Where( IsRunPlayer );
    public bool LocalInRun => !Networking.IsActive || (Connection.Local is not null && IsRunMember( Connection.Local.Id ));
    public RunPhase LocalPhase => LocalInRun ? Phase : RunPhase.Lobby;
    public FpsPlayer LocalPlayer => Scene.GetAll<FpsPlayer>().FirstOrDefault( p => Networking.IsActive ? Connection.Local is not null && p.ParticipantId == Connection.Local.Id && p.GameObject.Network.OwnerId == Connection.Local.Id : !p.IsProxy );
	public IReadOnlyList<Connection> Players => Connection.All;
	public string LocalPartyId => LocalConnectionId is { Length: > 0 } id && PlayerParties.TryGetValue( id, out var party ) ? party : "";
	public IEnumerable<PartyLobbyView> AllPartyRooms => PartyRooms.Select( pair => PartyLobbyView.TryDecode( pair.Key, pair.Value, out var room, out _ ) ? room : default ).Where( room => !string.IsNullOrEmpty( room.Id ) );
	public IEnumerable<PartyLobbyView> WaitingPartyRooms => AllPartyRooms.Where( room => !room.Started && !PrivateLobbies.ContainsKey(room.Id) );
	private ProceduralDungeon _dungeon;
	private readonly List<EnemyBrain> _enemies = new();
	private readonly List<FpsPlayer> _players = new();
	[Sync] private GameObject _rewardChest { get; set; }
	[Sync(SyncFlags.FromHost)] private bool _pendingChest { get; set; }
	[Sync(SyncFlags.FromHost)] private Vector3 _bossDeathPosition { get; set; }
	private float _rewardRetryAt;
	[Sync] private GameObject _portal { get; set; }
	private readonly HashSet<GameObject> _portalEntrants = new();
	[Sync] public int PortalEntered { get; private set; }
	[Sync] public int PortalRequired { get; private set; }
	[Sync] private bool _buffChosen { get; set; }
	[Sync] private bool _chestOpened { get; set; }
	public bool RewardAvailable => Phase == RunPhase.Reward && _chestOpened && LocalInRun && LocalPlayer?.Vitality?.IsDead == false && LocalPlayer?.Buffs is { } buffs && buffs.OfferedSeed == Seed && buffs.ClaimedSeed != Seed;
    public bool AllRewardsChosen => RunPlayers.Where(p => p.Vitality?.IsDead == false).All(p => p.Buffs?.ClaimedSeed == Seed);
	public bool ChestReady => Phase == RunPhase.Reward && !_chestOpened && _rewardChest.IsValid();
	public bool PortalReady => Phase == RunPhase.Reward && _buffChosen && _portal.IsValid();
    public bool HasNearbyRunInteraction => LocalPlayer is { } runner && runner.Vitality?.IsDead == false
        && ((ChestReady && runner.WorldPosition.Distance(_rewardChest.WorldPosition) < 145f)
            || (PortalReady && runner.WorldPosition.Distance(_portal.WorldPosition) < 145f));
	public Vector3 RewardChestPosition => _rewardChest.IsValid() ? _rewardChest.WorldPosition : Vector3.Zero;
	public Vector3 PortalPosition => _portal.IsValid() ? _portal.WorldPosition : Vector3.Zero;

	protected override void OnAwake()
	{
		Instance = this;
		_dungeon = Components.GetOrCreate<ProceduralDungeon>();
	}

	protected override void OnStart()
	{
		GameProfile.Load();
        PrototypeAudio.PreloadWeapons();
        Components.Create<RunMusic>();
		var ambientObject = new GameObject( true, "Ruin Ambient Fill" );
		ambientObject.SetParent( GameObject, true );
		ambientObject.Components.Create<AmbientLight>().Color = new Color( .14f, .17f, .22f );
		var moonObject = new GameObject( true, "Moonlight" );
		moonObject.WorldRotation = Rotation.From( new Angles( 48f, -32f, -18f ) );
		moonObject.SetParent( GameObject, true );
		var moon = moonObject.Components.Create<DirectionalLight>();
		moon.LightColor = new Color( .34f, .48f, .72f ) * 2.1f;
		moon.SkyColor = new Color( .05f, .08f, .13f );
		moon.Shadows = true;
		moon.ContactShadows = true;
		moon.ShadowCascadeCount = 3;
		if ( !Networking.IsActive ) SpawnPlayer( null );
	}

    public bool AcceptConnection(Connection channel, ref string reason)
    {
        if (channel == Connection.Local) return true;
        if (Phase != RunPhase.Lobby) { reason = "This run has already started."; return false; }
        if (string.IsNullOrEmpty(ServerPartyId))
        {
            // Debug instances need the scene snapshot to load their own solo menu.
            // They are never added to the team or spawned as participants.
            if (Application.IsEditor) return true;
            reason = "This is a solo game."; return false;
        }
        if (!PartyLobbyView.HasConnectionSlot(Connection.All.Select(c => c.Id.ToString()), channel.Id.ToString())) { reason = "Lobby is full."; return false; }
        if (!PartyRooms.TryGetValue(ServerPartyId, out var encoded) || !PartyLobbyView.TryDecode(ServerPartyId, encoded, out var room, out var expected))
        { reason = "Lobby is not ready yet."; return false; }
        if (room.Locked && channel.GetUserData("dasher_join_key") != expected)
        { reason = "Incorrect lobby password."; return false; }
        return true;
    }

    public void OnConnected(Connection connection)
    {
        if (IsHost && Phase == RunPhase.Lobby) LobbyCountdown.Clear();
    }

    public void OnActive(Connection connection)
    {
        if (!IsHost || connection is null) return;
        Log.Info($"[Dasher lobby] Participant active: {connection.Id}; phase={Phase}; team={ServerPartyId}");
        if (Phase == RunPhase.Lobby) AddServerMember(connection);
        else if (IsRunMember(connection.Id)) SpawnPlayer(connection);
        else connection.Kick("This run has already started.");
    }
	public void OnDisconnected( Connection connection )
    {
        if (!IsHost || connection is null) return;
        if (Phase != RunPhase.Lobby && IsRunMember(connection.Id)) DepartRun(connection.Id);
        else LeavePartyInternal(connection.Id.ToString());
        foreach (var pawn in Scene.GetAll<FpsPlayer>().Where(p => p.ParticipantId == connection.Id).ToArray()) pawn.GameObject.Destroy();
    }
	public void OnBecameHost(Connection previousHost)
    {
        _enemies.Clear(); _enemies.AddRange(Scene.GetAll<EnemyBrain>());
        _publishedLobbyState = "";
        _dungeon.ApplyRemoteRoomState(RoomNumber, EnemiesAlive == 0);
        _portalEntrants.Clear(); PortalEntered = 0;
        if (previousHost is not null) OnDisconnected(previousHost);
        // Readiness must be confirmed again after a host changes in the waiting room.
        if (Phase == RunPhase.Lobby) { LobbyReady.Clear(); LobbyCountdown.Clear(); }
        Status = "Host migrated";
    }

	private void SpawnPlayer( Connection owner )
	{
		if ( !IsHost ) return;
		if ( Phase != RunPhase.Lobby && owner is not null && !IsRunMember( owner.Id ) ) return;
		if ( owner is not null && Scene.GetAll<FpsPlayer>().Any( p => p.GameObject.Network.OwnerId == owner.Id ) ) return;
		var index = Scene.GetAll<FpsPlayer>().Count();
		var go = new GameObject( true, owner is null ? "Local Runner" : owner.DisplayName );
		go.WorldPosition = (WorldReady ? _dungeon.CurrentCenter : Vector3.Zero) + new Vector3( index % 4 * 70f, 0, 8f );
		go.Tags.Add( "player" );
		go.Components.Create<HealthComponent>();
		go.Components.Create<WeaponController>();
		go.Components.Create<DasherBodyVisual>();
        go.Components.Create<RunBuffs>();
		var voice = go.Components.Create<PartyVoice>();
		voice.Mode = Voice.ActivateMode.Manual;
		voice.PushToTalkInput = "Voice";
		voice.WorldspacePlayback = false;
		voice.Loopback = false;
		voice.LipSync = false;
		go.Components.Create<PartyVoiceInput>();
		var player = go.Components.Create<FpsPlayer>(); player.PlayerName = owner?.DisplayName ?? "Solo Runner"; player.ParticipantId = owner?.Id ?? Guid.Empty;
		_players.Add( player );
		if ( owner is not null ) go.NetworkSpawn( owner );
	}

    [Sync(SyncFlags.FromHost)] public NetDictionary<string,bool> PrivateLobbies { get; set; } = new();
    [Sync(SyncFlags.FromHost)] public NetDictionary<string,bool> LobbyReady { get; set; } = new();
    [Sync(SyncFlags.FromHost)] public NetDictionary<string,float> LobbyCountdown { get; set; } = new();
    public bool ReadyFor(string id) => LobbyReady.TryGetValue(id, out var ready) && ready;
    public float CountdownFor(string id) => LobbyCountdown.TryGetValue(id, out var end) ? MathF.Max(0,end-Time.Now) : -1;
    public void ToggleLobbyReady()
    {
        if (SessionBusy) return;
        if (Networking.IsActive && Networking.IsHost && string.IsNullOrEmpty(ServerPartyId))
        {
            _ = ReadyInSoloPreview();
            return;
        }
        ToggleLobbyReadyHost();
    }

    [Rpc.Host]
    private void ToggleLobbyReadyHost() => ToggleLobbyReadyInternal(Rpc.Caller ?? Connection.Local);

    private void ToggleLobbyReadyInternal(Connection caller)
    {
        if (caller is null || Phase != RunPhase.Lobby || SessionBusy) return;
        var id=caller.Id.ToString();
        if (!PlayerParties.ContainsKey(id))
        {
            if (Networking.IsActive) return;
            var roomId=Guid.NewGuid().ToString("N");
            PartyRooms[roomId]=PartyLobbyView.Encode(new PartyLobbyView(roomId,caller.DisplayName+"'s Solo Run",id,false,false,new[]{id}), "");
            PlayerParties[id]=roomId; PrivateLobbies[roomId]=true;
        }
        LobbyReady[id]=!ReadyFor(id);
        LobbyCountdown.Remove(PlayerParties[id]);
        if (!Networking.IsActive && !ReadyFor(id)) LeavePartyInternal(id);
    }
    private void UpdateLobbyCountdowns()
    {
        foreach(var room in AllPartyRooms.Where(r=>!r.Started).ToArray())
        {
            if(room.Members.Count==0 || !room.Members.All(ReadyFor) || (Networking.IsActive && Connection.All.Any(c => !room.Members.Contains(c.Id.ToString())))) { LobbyCountdown.Remove(room.Id); continue; }
            if(!LobbyCountdown.TryGetValue(room.Id,out var end)) { LobbyCountdown[room.Id]=Time.Now+5f; continue; }
            if(Time.Now<end) continue;
            ActivePartyId=room.Id; RunMemberIds=string.Join(",",room.Members);
            PartyLobbyView.TryDecode(room.Id,PartyRooms[room.Id],out _,out var password);
            PartyRooms[room.Id]=PartyLobbyView.Encode(room with {Started=true},password);
            LobbyCountdown.Clear(); LobbyReady.Clear(); BeginRunInternal(); break;
        }
    }

	public void StartSolo() => StartSoloRun();

	[Rpc.Host]
	private void StartSoloRun()
	{
		if ( Phase != RunPhase.Lobby || Networking.IsActive || SessionBusy ) return;
		var caller = Rpc.Caller ?? Connection.Local;
		if ( caller is null || Phase != RunPhase.Lobby ) return;
		LeavePartyInternal( caller.Id.ToString() );
		var id = Guid.NewGuid().ToString( "N" );
		var owner = caller.Id.ToString();
		var room = new PartyLobbyView( id, $"{caller.DisplayName}'s Solo Run", owner, false, true, new[] { owner } );
		PartyRooms[id] = PartyLobbyView.Encode( room, "" ); PlayerParties[owner] = id;
		ActivePartyId = id; RunMemberIds = owner;
		BeginRunInternal();
	}

	public void HostLobby() => HostLobby( $"{Connection.Local?.DisplayName ?? "Runner"}'s Run", "" );

    public void HostLobby(string lobbyName, string password)
    {
        var name = string.IsNullOrWhiteSpace(lobbyName) ? "Dasher Co-op" : lobbyName.Trim();
        name = name.Replace("\u001f", " ");
        _ = CreateServerLobby(name[..Math.Min(name.Length, 32)], password ?? "");
    }

    public void JoinParty(string roomId, string password)
    {
        var lobby = ServerLobbies.FirstOrDefault(l => l.Id == roomId);
        if (!string.IsNullOrEmpty(lobby.ServerAddress)) _ = ConnectToServer(lobby, password);
    }

	public static string PasswordToken( string value )
	{
		unchecked
		{
			uint hash = 2166136261;
			foreach ( var character in value ?? "" ) { hash ^= character; hash *= 16777619; }
			return hash.ToString( "x8" );
		}
	}

    public async Task QuickJoin()
    {
        await RefreshServerLobbies();
        var room = ServerLobbies.FirstOrDefault(l => !l.Locked && !l.IsFull && !l.Started && l.Id != ServerPartyId);
        if (!string.IsNullOrEmpty(room.Id)) JoinParty(room.Id, ""); else Status = "No open lobby found";
    }

    public void LeaveLobby()
    {
        if (Phase != RunPhase.Lobby || SessionBusy) return;
        _ = ReturnToSolo();
    }

	private void LeavePartyInternal( string playerId )
	{
        LobbyReady.Remove(playerId);
		if ( !PlayerParties.TryGetValue( playerId, out var roomId ) ) return;
		PlayerParties.Remove( playerId );
        LobbyCountdown.Remove(roomId);
		if ( !PartyRooms.TryGetValue( roomId, out var encoded ) || !PartyLobbyView.TryDecode( roomId, encoded, out var room, out var password ) ) return;
		var members = room.Members.Where( id => id != playerId ).ToArray();
		if ( members.Length == 0 ) { PartyRooms.Remove( roomId ); PrivateLobbies.Remove(roomId); return; }
		var owner = room.OwnerId == playerId ? members[0] : room.OwnerId;
		PartyRooms[roomId] = PartyLobbyView.Encode( room with { OwnerId = owner, Members = members }, password );
	}

	private string LocalConnectionId => Connection.Local?.Id.ToString() ?? "";

	[Rpc.Host]
	public void BeginRun()
	{
		if ( Phase != RunPhase.Lobby ) return;
		var caller = Rpc.Caller ?? Connection.Local;
		if ( caller is null || !PlayerParties.TryGetValue( caller.Id.ToString(), out var roomId ) || !PartyRooms.TryGetValue( roomId, out var encoded ) || !PartyLobbyView.TryDecode( roomId, encoded, out var room, out var password ) || room.OwnerId != caller.Id.ToString() ) return;
        if (!room.Members.All(ReadyFor) || !LobbyCountdown.TryGetValue(roomId, out var deadline) || Time.Now < deadline) return;
		ActivePartyId = roomId;
		RunMemberIds = string.Join( ",", room.Members.Distinct().Take( MaxPlayers ) );
		PartyRooms[roomId] = PartyLobbyView.Encode( room with { Started = true }, password );
		BeginRunInternal();
	}

	[Sync(SyncFlags.FromHost)] public string CoinRunId { get; private set; } = "";
    [Sync(SyncFlags.FromHost)] public NetDictionary<string, int> RunCoinTotals { get; set; } = new();
    public int CoinsFor(Guid id) => RunCoinTotals.TryGetValue(id.ToString(), out var total) ? total : 0;
    public int LocalRunCoins => CoinsFor(Networking.IsActive ? Connection.Local?.Id ?? Guid.Empty : Guid.Empty);
    private void AwardTeamCoins(int amount)
    {
        foreach (var runner in RunPlayers.Where(p => p.Vitality?.IsDead == false))
        {
            var id = runner.ParticipantId;
            var total = CoinsFor(id) + amount;
            RunCoinTotals[id.ToString()] = total;
            runner.ReceiveCoins(CoinRunId, total);
        }
    }
    [Sync(SyncFlags.FromHost)] public string RunTeamNames { get; private set; } = "";
    [Sync(SyncFlags.FromHost)] public long RunRankingOwner { get; private set; }
	private void BeginRunInternal()
	{
		if ( Phase != RunPhase.Lobby ) return;
        RematchVotes.Clear();
        DepartedRunIds = "";
		if ( Networking.IsActive )
        {
            foreach ( var outsider in Scene.GetAll<FpsPlayer>().Where( p => !IsRunPlayer( p ) ).ToArray() ) outsider.GameObject.Destroy();
            foreach ( var connection in Connection.All.Where( c => IsRunMember( c.Id ) ) ) SpawnPlayer( connection );
        }
        foreach(var runner in RunPlayers) { runner.Buffs?.ResetHost(); runner.Weapon?.ResetHostMagazine(); }
        RunTeamNames = Networking.IsActive
            ? string.Join(" + ", Connection.All.Where(c => IsRunMember(c.Id)).OrderBy(c => c.Id).Select(c => c.DisplayName))
            : Connection.Local?.DisplayName ?? "Runner";
        CoinRunId = Guid.NewGuid().ToString(); RunCoinTotals.Clear();
        Level = 1; Score = 0; RunKills = 0; RunSeconds = 0f; FinalRunSeconds = 0f;
		StartLevel( true );
	}

	private void StartLevel( bool freshRun )
	{
		_portalEntrants.Clear(); PortalEntered = 0; PortalRequired = 0;
		foreach ( var enemy in Scene.GetAll<EnemyBrain>().ToArray() ) enemy.GameObject.Destroy();
		foreach ( var projectile in Scene.GetAll<Projectile>().ToArray() ) projectile.GameObject.Destroy();
		Seed = Game.Random.Int( 100000, 999999 );
		Phase = RunPhase.Generating; RoomNumber = 0;
		_dungeon.Generate( Seed, 7 );
		_generatedSeed = Seed;
		foreach ( var player in RunPlayers )
		{
			player.TeleportForLevel( new Vector3( Game.Random.Float( -100, 100 ), Game.Random.Float( -100, 100 ), 8f ), Seed, freshRun );
            // TeleportForLevel applies the owning player's purchased health.
            // Never overwrite it here with the unupgraded 100 HP baseline.
		}
		_buffChosen = false;
		Phase = RunPhase.Combat; SpawnWave(); Status = $"Level {Level} · Seed {Seed}";
	}

	protected override void OnUpdate()
	{
        UpdateServerSession();
        if (_returningToSolo || SessionBusy) return;
        GameProfile.UpdateAudio(Time.Delta);
        GameProfile.RetryPendingRuns();
		var localRunner = LocalPlayer;
        if ( !IsRunMenuOpen && LocalInRun && ChestReady && localRunner?.Vitality?.IsDead == false && _rewardChest.IsValid()
            && localRunner.WorldPosition.Distance( _rewardChest.WorldPosition ) < 145f && Input.Pressed( "Use" ) ) OpenRewardChest( Seed );
		if ( !IsRunMenuOpen && LocalInRun && PortalReady && localRunner?.Vitality?.IsDead == false
			&& localRunner.WorldPosition.Distance( _portal.WorldPosition ) < 145f && Input.Pressed( "Use" ) )
			ConfirmPortal( Seed );
		if ( !IsHost )
		{
			// Static geometry is local; synced enemies alone do not give clients a floor.
			if ( Seed != 0 && Phase != RunPhase.Lobby && _generatedSeed != Seed )
			{
				_dungeon.Generate( Seed, 7 );
				_generatedSeed = Seed;
			}
			if ( WorldReady ) _dungeon.ApplyRemoteRoomState( RoomNumber, EnemiesAlive == 0 );
			return;
		}
		if (Phase == RunPhase.Defeat) TryStartRematch();
		if (Phase == RunPhase.Lobby) UpdateLobbyCountdowns();
		if ( StartLocalChaserTest && !Networking.IsActive )
		{
			StartLocalChaserTest = false;
			ReviewChaserInGame = true;
			BeginRunInternal();
		}
		_dungeon.UpdateEntranceGate( RunPlayers.Where( p => p.Vitality?.IsDead == false ).Select( p => p.WorldPosition ) );
		if ( Phase is RunPhase.Combat or RunPhase.Reward ) RunSeconds += Time.Delta;
		// Remote Health sync does not invoke the local Died delegate. Evaluate the
		// party after replicated health arrives, not only on the host player's death.
		if ( Phase is RunPhase.Combat or RunPhase.Reward )
		{
			var runners = RunPlayers.ToArray();
			if ( runners.Length > 0 && runners.All( p => p.Vitality?.IsDead == true ) ) NotifyPlayerDeath( runners[0] );
		}
		if ( Phase == RunPhase.Reward )
		{
			// Also publish a chest created by the previous code in an ongoing run.
			if ( Networking.IsActive && _rewardChest.IsValid() && !_rewardChest.Network.Active ) _rewardChest.NetworkSpawn();
			if ( _pendingChest && Time.Now >= _rewardRetryAt ) SpawnBossReward( _bossDeathPosition );
			if ( PortalReady )
            {
                var living = RunPlayers.Where( p => p.Vitality?.IsDead == false ).ToArray();
                PortalRequired = living.Length;
                PortalEntered = living.Count( p => _portalEntrants.Contains( p.GameObject ) );
                if ( PortalRequired > 0 && PortalEntered == PortalRequired && AllRewardsChosen ) AdvanceLevel();
            }
			return;
		}
		if ( Phase != RunPhase.Combat ) return;
		if ( EnemiesAlive == 0 )
		{
            var healKey = $"{CoinRunId}:{Seed}:{RoomNumber}";
            if (_healedRoom != healKey) { _healedRoom=healKey; foreach(var p in RunPlayers) p.Buffs?.RoomCleared(Seed,RoomNumber); }
			var livingPlayers = RunPlayers
				.Where( player => player.Vitality?.IsDead == false )
				.Select( player => player.WorldPosition )
				.ToList();
			if ( _dungeon.TryAdvance( livingPlayers ) )
			{
				RoomNumber = _dungeon.ActiveRoom;
				SpawnWave();
			}
		}
	}

	[Rpc.Host]
	private void ConfirmPortal( int runSeed )
	{
		if ( !PortalReady || Seed != runSeed ) return;
		var caller = Rpc.Caller ?? Connection.Local;
		var runner = Networking.IsActive
			? RunPlayers.FirstOrDefault( p => caller is not null && p.GameObject.Network.OwnerId == caller.Id )
			: LocalPlayer;
		if ( runner?.Vitality?.IsDead != false || runner.WorldPosition.Distance( _portal.WorldPosition ) >= 145f ) return;
		_portalEntrants.Add( runner.GameObject );
		var living = RunPlayers.Where( p => p.Vitality?.IsDead == false ).ToArray();
		PortalRequired = living.Length;
		PortalEntered = living.Count( p => _portalEntrants.Contains( p.GameObject ) );
		if ( PortalRequired > 0 && PortalEntered == PortalRequired && AllRewardsChosen ) AdvanceLevel();
	}

	private void SpawnWave()
	{
		_enemies.Clear();
		var isBoss = RoomNumber == _dungeon.Rooms.Count - 1;
		var count = isBoss ? 1 : 4 + RoomNumber * 2 + (Level - 1) * 2;
		for ( var i = 0; i < count; i++ )
		{
			var role = isBoss ? EnemyRole.Boss : (EnemyRole)(i % 3);
			var spec = PrototypeCatalog.Enemy( role, RoomNumber );
            // Progression increases combat stats, never the visual model scale.
            var depthHealth = MathF.Pow(1.4f, Level - 1);
            var depthDamage = MathF.Pow(1.22f, Level - 1);
            spec = spec with { Health = spec.Health * (isBoss ? 2.2f : 1.4f) * depthHealth, Damage = spec.Damage * 1.5f * depthDamage, AttackCooldown = spec.AttackCooldown * .85f };
			var position = FindEnemySpawn( spec, i, count );
			// Keep gameplay/collision on an unscaled root. The visible body is a child;
			// otherwise the root scale also multiplies its collider and decorations.
			var go = new GameObject( true, role.ToString() );
			go.WorldPosition = position;
			if ( !RuinVisualFactory.CreateEnemyModel( go, role, spec.Color, 27f * spec.Scale + 2f ) )
			{
				PrototypeFactory.Disc( "Body", position, 28f * spec.Scale, 66f * spec.Scale, spec.Color, go );
				RuinVisualFactory.DecorateEnemy( go, role, spec.Color, spec.Scale );
			}
			go.Tags.Add( "enemy" );
			// The imported enemies are tall humanoids. A sphere only covered their
			// ankles/pelvis and made shots through the torso miss, so collision follows
			// the full visible silhouette with a vertical capsule.
			var collider = go.Components.Create<CapsuleCollider>();
			collider.Radius = 20f * spec.Scale;
			// Root is radius+2 above the floor. Keep the capsule's lower hemisphere
			// tangent to that floor instead of burying its centre below it.
			collider.Start = Vector3.Down * (7f * spec.Scale + 2f);
			collider.End = Vector3.Up * ((role == EnemyRole.Boss ? 120f : 78f) * spec.Scale);
			collider.IsTrigger = false;
			go.Components.Create<HealthComponent>();
			var brain = go.Components.Create<EnemyBrain>(); brain.Configure( spec );
			if ( role == EnemyRole.Chaser ) go.Components.Create<ChaserGameplayPreview>();
			if ( role == EnemyRole.Gunner ) go.Components.Create<GunnerGameplayPreview>();
			if ( role == EnemyRole.Charger ) go.Components.Create<ChargerGameplayPreview>();
            if ( role == EnemyRole.Boss ) go.Components.Create<BossGameplayPreview>();
			_enemies.Add( brain );
			if ( Networking.IsActive ) go.NetworkSpawn();
		}
		EnemiesAlive = count;
		if ( count > 0 ) _dungeon.CloseEntrance();
	}

	private Vector3 FindEnemySpawn( EnemySpec spec, int index, int count )
	{
		var radius = 30f * spec.Scale;
		var players = RunPlayers.Where( p => p.Vitality?.IsDead == false ).ToArray();
		for ( var attempt = 0; attempt < 64; attempt++ )
		{
			var angle = (index * 360f / Math.Max( 1, count )) + attempt * 137.5f;
			var distance = Game.Random.Float( 520f, 900f );
			var candidate = _dungeon.CurrentCenter + Rotation.FromYaw( angle ).Forward * distance;
			candidate = candidate.WithZ( radius + 2f );

			if ( players.Any( player => player.WorldPosition.WithZ( 0f ).Distance( candidate.WithZ( 0f ) ) < 450f ) )
				continue;
			if ( !_dungeon.IsClearForSpawn( candidate, radius ) ) continue;
			// Validate against the actual scene as well as generated bounds. This catches
			// gates and any authored collider that is not represented in the map data.
			var physicalCheck = Scene.Trace.Sphere( radius + 35f, candidate + Vector3.Up * 40f, candidate + Vector3.Up * 41f )
				.WithoutTags( "player", "enemy", "projectile" ).Run();
			if ( physicalCheck.Hit ) continue;
			if ( _enemies.Any( enemy => enemy.IsValid && enemy.WorldPosition.WithZ( 0f ).Distance( candidate.WithZ( 0f ) ) < radius + 70f ) )
				continue;

			var occupied = Scene.Trace.Sphere( radius, candidate, candidate + Vector3.Up * 1f )
				.WithoutTags( "projectile", "enemy" ).Run();
			if ( occupied.Hit ) continue;

			var floor = Scene.Trace.Ray( candidate, candidate + Vector3.Down * (radius + 24f) )
				.WithoutTags( "projectile", "enemy", "player" ).Run();
			if ( !floor.Hit ) continue;
			return candidate.WithZ( floor.HitPosition.z + radius + 2f );
		}

		// The generator keeps the centre clear, so this deterministic fallback is
		// still valid if all randomized perimeter candidates were rejected.
		var fallbackAngle = index * 360f / Math.Max( 1, count );
		return _dungeon.CurrentCenter + Rotation.FromYaw( fallbackAngle ).Forward * 620f + Vector3.Up * (radius + 2f);
	}

	public void NotifyEnemyDeath( EnemyBrain enemy )
	{
		if ( !IsHost || Phase != RunPhase.Combat ) return;
		var deathPosition = enemy.WorldPosition;
		var wasBoss = enemy.Spec.Role == EnemyRole.Boss;
		if ( wasBoss ) _bossDeathPosition = deathPosition;
		_enemies.Remove( enemy ); EnemiesAlive = Math.Max( 0, EnemiesAlive - 1 );
		RunKills++;
        AwardTeamCoins(wasBoss ? 80 * Level : 4 + Level);
		Score += wasBoss ? 1000 * Level : 100 + RoomNumber * 25 + (Level - 1) * 50;
		if ( EnemiesAlive != 0 ) return;
        foreach(var p in RunPlayers) p.Buffs?.RoomCleared(Seed,RoomNumber);

		if ( RoomNumber == 6 ) SpawnBossReward( _bossDeathPosition );
		else
		{
			_dungeon.OpenExit();
			Phase = RunPhase.RoomClear;
			Status = "Room clear — exit gate opened";
			Phase = RunPhase.Combat;
		}
	}

	private void SpawnBossReward( Vector3 position )
	{
		if ( !IsHost || RoomNumber != 6 || _dungeon.Rooms.Count <= 6 ) return;
		Phase = RunPhase.Reward;
		if ( !TryFindRewardPosition( position, 120f, 150f, false, out var safePosition ) )
		{
			_pendingChest = true; _rewardRetryAt = Time.Now + 1f;
			Status = "Move away from the reward area";
			return;
		}
		_pendingChest = false;
		_buffChosen = false;
		_chestOpened = false;
		_rewardChest?.Destroy(); _portal?.Destroy();
		_rewardChest = new GameObject( true, "Boss Reward Chest" );
		_rewardChest.WorldPosition = safePosition;
		var chestCollider = _rewardChest.Components.Create<BoxCollider>();
		chestCollider.Scale = new Vector3( 80f, 58f, 52f );
		chestCollider.Center = Vector3.Up * 26f;
		if ( !RuinVisualFactory.UseImportedModel( _rewardChest, "models/prototype/reward_chest.vmdl", new Color(1f,.62f,.12f) ) ) RuinVisualFactory.DecorateChest( _rewardChest );
		if ( Networking.IsActive ) _rewardChest.NetworkSpawn();
		Status = "Boss defeated — choose a reward from the chest";
	}

    private FpsPlayer RequestingRunner()
    {
        if ( !Networking.IsActive ) return LocalPlayer;
        var caller = Rpc.Caller ?? Connection.Local;
        return RunPlayers.FirstOrDefault( p => caller is not null && p.GameObject.Network.OwnerId == caller.Id );
    }

    [Rpc.Host]
    private void OpenRewardChest( int seed )
    {
        var runner = RequestingRunner();
        if ( Seed != seed || !ChestReady || runner?.Vitality?.IsDead != false || !_rewardChest.IsValid()
            || runner.WorldPosition.Distance( _rewardChest.WorldPosition ) >= 145f ) return;
        _chestOpened = true;
        foreach(var player in RunPlayers.Where(p => p.Vitality?.IsDead == false)) player.Buffs?.Roll(Seed);
        Status = "Each teammate chooses one reward for this run";
    }

    [Rpc.Host]
	public void ClaimBuff( string buff, int offeredSeed )
	{
		var runner = RequestingRunner();
		if (offeredSeed != Seed || runner?.Vitality?.IsDead != false || Phase != RunPhase.Reward || !_chestOpened || runner.Buffs is not { } choices || choices.OfferedSeed != Seed || choices.ClaimedSeed == Seed || !choices.Choices.Any(b => b.Id == buff)) return;
		var preferred = (_rewardChest.IsValid() ? _rewardChest.WorldPosition : _dungeon.CurrentCenter) + Vector3.Forward * 320f;
		var portalPosition = Vector3.Zero;
        if ( !_buffChosen && !TryFindRewardPosition( preferred, 160f, 300f, true, out portalPosition ) )
		{
			Status = "No clear portal position — move away and choose again";
			return;
		}
		choices.Ranks[buff] = choices.Rank(buff)+1;
        choices.ClaimedSeed = Seed;
        choices.Grant(buff,Seed);
        if (_buffChosen) return;
		_buffChosen = true;
		PrototypeAudio.Play( "chest_open", _rewardChest.IsValid() ? _rewardChest.WorldPosition : _dungeon.CurrentCenter );
		if ( _rewardChest.IsValid() ) _rewardChest.Components.Get<ModelRenderer>()?.Tint = new Color( .25f, .3f, .34f );
		_portal = new GameObject( true, "Next Level Portal" );
		_portal.WorldPosition = portalPosition;
		if ( !RuinVisualFactory.UseImportedModel( _portal, "models/prototype/level_portal.vmdl", new Color(.25f,.45f,1f) ) ) RuinVisualFactory.DecoratePortal( _portal );
		else
		{
			var glow = _portal.Components.Create<PointLight>();
			glow.LightColor = new Color(.12f,.42f,1f) * 3f;
			glow.Radius = 310f;
			glow.Shadows = false;
		}
		if ( Networking.IsActive ) _portal.NetworkSpawn();
		_portalEntrants.Clear(); PortalEntered = 0;
		PortalRequired = RunPlayers.Count( p => p.Vitality?.IsDead == false );
		PrototypeAudio.Play( "portal_open", portalPosition );
		Status = "Reward acquired — enter the portal and press E";
	}

    private bool TryFindRewardPosition( Vector3 preferred, float radius, float height, bool avoidChest, out Vector3 result )
    {
        result = default;
        const int rewardRoom = 6;
        if ( _dungeon.Rooms.Count <= rewardRoom ) return false;
        var rewardCenter = _dungeon.Rooms[rewardRoom].World( ProceduralDungeon.RoomSpacing );
        var candidates = new System.Collections.Generic.List<Vector3> { preferred.WithZ(0) };
        for ( var x = -1000; x <= 1000; x += 100 )
            for ( var y = -1000; y <= 1000; y += 100 )
                candidates.Add( rewardCenter + new Vector3( x, y, 0 ) );
        foreach ( var point in candidates.OrderBy( p => p.Distance( preferred ) ) )
        {
            if ( !_dungeon.IsClearForSpawn( point, radius + 45f, rewardRoom ) ) continue;
            if ( avoidChest && _rewardChest.IsValid() && point.Distance( _rewardChest.WorldPosition ) < radius + 170f ) continue;
            var floor = Scene.Trace.Ray( point + Vector3.Up * 30f, point + Vector3.Down * 50f ).WithoutTags( "enemy", "player", "projectile" ).Run();
            if ( !floor.Hit || floor.Normal.z < .9f ) continue;
            var basePoint = point.WithZ( floor.HitPosition.z + 1f );
            var occupied = Scene.Trace.Box( new Vector3( radius * 2, radius * 2, height ), basePoint + Vector3.Up * (height * .5f + 1), basePoint + Vector3.Up * (height * .5f + 2) )
                .WithoutTags( "enemy", "projectile" ).Run();
            if ( occupied.Hit ) continue;
            bool supported = true;
            foreach ( var offset in new[] { Vector3.Forward, Vector3.Backward, Vector3.Left, Vector3.Right } )
            {
                var edge = basePoint + offset * radius;
                var support = Scene.Trace.Ray( edge + Vector3.Up * 10, edge + Vector3.Down * 10 ).WithoutTags( "enemy", "player", "projectile" ).Run();
                if ( !support.Hit || System.MathF.Abs( support.HitPosition.z - floor.HitPosition.z ) > 3 ) { supported = false; break; }
            }
            if ( !supported ) continue;
            result = basePoint; return true;
        }
        result = default; return false;
    }

	private void AdvanceLevel()
	{
		_rewardChest?.Destroy(); _portal?.Destroy();
		Level++;
		StartLevel( false );
	}

	public void NotifyPlayerDeath( FpsPlayer player, bool abandoned = false )
	{
		if ( !IsHost || Phase is RunPhase.Lobby or RunPhase.Defeat ) return;
		var remaining = RunPlayers.ToArray();
        // Missing/transferring pawns are not proof that the team has died.
        var expected = Networking.IsActive ? Connection.All.Count(c => IsRunMember(c.Id)) : 1;
        if ( abandoned || (remaining.Length > 0 && remaining.Length == expected && remaining.All(p => p.Vitality?.IsDead == true)) )
		{
			FinalRunSeconds = RunSeconds;
			Phase = RunPhase.Defeat; Status = "Team eliminated";
			var teamSize = Math.Clamp( Networking.IsActive ? Math.Max( 1, RunMemberIds.Split( ',', StringSplitOptions.RemoveEmptyEntries ).Length ) : 1, 1, 4 );
			var category = new[] { "Solo", "Duo", "Trio", "Squad" }[teamSize - 1];
            RunRankingOwner = Networking.IsActive
                ? Connection.All.Where(c => RunMemberIds.Split(',').Contains(c.Id.ToString())).OrderByDescending(c => IsRunMember(c.Id)).ThenBy(c => c.Id).Select(c => (long)c.SteamId).FirstOrDefault()
                : (long)Sandbox.Game.SteamId;
            PublishRunResult(category, Score, RunKills, Math.Max(0, Level - 1), RunSeconds, RunRankingOwner, RunTeamNames, CoinRunId);
		}
	}

    [Rpc.Broadcast]
    private void PublishRunResult(string category, int score, int kills, int levels, float seconds, long rankingOwner, string teamNames, string runId)
    {
        if (Rpc.Caller is not null && !Rpc.Caller.IsHost) return;
        if (Networking.IsActive && (Connection.Local is null || !RunMemberIds.Split(',').Contains(Connection.Local.Id.ToString()))) return;
        GameProfile.AddRun(new RunRecord { RunId = runId, PlayerName = teamNames, Category = category, Score = score, Kills = kills, Levels = levels, Seconds = seconds, FinishedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }, (long)Sandbox.Game.SteamId == rankingOwner);
    }

    [Sync(SyncFlags.FromHost)] public NetDictionary<string, string> RematchVotes { get; set; } = new();
    private string[] RematchParticipants => Networking.IsActive
        ? Connection.All.Where(c => IsRunMember(c.Id)).Select(c => c.Id.ToString()).Distinct().ToArray()
        : new[] { Guid.Empty.ToString() };
    public int RematchRequired => RematchParticipants.Length;
    public int RematchReady => RematchParticipants.Count(id => RematchVotes.TryGetValue(id, out var run) && run == CoinRunId);
    public bool RematchConfirmed => RematchVotes.TryGetValue((Networking.IsActive ? Connection.Local?.Id ?? Guid.Empty : Guid.Empty).ToString(), out var run) && run == CoinRunId;
    public string RematchButtonText => $"{(RematchConfirmed ? "READY" : "One More Round")}  {RematchReady} / {RematchRequired}";

    public void OneMoreRound()
    {
        if (Networking.IsActive) RestartPartyRun(CoinRunId);
        else ConfirmRematch(CoinRunId, Guid.Empty);
    }

    [Rpc.Host]
    private void RestartPartyRun(string finishedRunId)
    {
        var caller = Rpc.Caller ?? Connection.Local;
        if (caller is null || !IsRunMember(caller.Id)) return;
        ConfirmRematch(finishedRunId, caller.Id);
    }

    private void ConfirmRematch(string finishedRunId, Guid participant)
    {
        if (!IsHost || Phase != RunPhase.Defeat || finishedRunId != CoinRunId) return;
        RematchVotes[participant.ToString()] = finishedRunId;
        TryStartRematch();
    }

    private void TryStartRematch()
    {
        if (!IsHost || Phase != RunPhase.Defeat) return;
        var participants = RematchParticipants;
        if (participants.Length == 0 || !participants.All(id => RematchVotes.TryGetValue(id, out var run) && run == CoinRunId)) return;
        if (Networking.IsActive) RunMemberIds = string.Join(",", participants);
        CleanupRun();
        LobbyReady.Clear(); LobbyCountdown.Clear();
        // Lobby is only the internal entry condition; start immediately in this tick.
        Phase = RunPhase.Lobby;
        BeginRunInternal();
    }

    public void LeaveRun() => AbandonRun(CoinRunId);

    [Rpc.Host]
    private void AbandonRun(string runId)
    {
        var caller = Rpc.Caller ?? Connection.Local;
        if (runId != CoinRunId || Phase == RunPhase.Lobby) return;
        if (Networking.IsActive && (caller is null || !IsRunMember(caller.Id))) return;
        var id = caller?.Id ?? Guid.Empty;
        SettleDeparture(id, runId, CoinsFor(Networking.IsActive ? id : Guid.Empty));
        DepartRun(id);
    }

    private void DepartRun(Guid id)
    {
        if (Networking.IsActive)
        {
            DepartedRunIds = string.Join(",", DepartedRunIds.Split(',', StringSplitOptions.RemoveEmptyEntries).Append(id.ToString()).Distinct());
            LeavePartyInternal(id.ToString());
            foreach (var player in Scene.GetAll<FpsPlayer>().Where(p => p.ParticipantId == id).ToArray())
                player.GameObject.Destroy();
        }
        if (!Networking.IsActive || !Connection.All.Any(c => IsRunMember(c.Id)))
        {
            NotifyPlayerDeath(null, true);
            CleanupRun();
            Phase = RunPhase.Lobby;
            ActivePartyId = "";
        }
        else NotifyPlayerDeath(null);
    }

    [Rpc.Broadcast]
    private void SettleDeparture(Guid playerId, string runId, int earned)
    {
        if (Rpc.Caller is not null && !Rpc.Caller.IsHost) return;
        if (Networking.IsActive && Connection.Local?.Id != playerId) return;
        GameProfile.BankCoins(runId, earned);
        IsRunMenuOpen = false;
        _ = ReturnToSolo();
    }

	public void Restart() => ReturnToLobby();

	[Rpc.Host]
	private void ReturnToLobby()
	{
		// Any teammate can return the defeated party; a delayed click must not
		// terminate a new run that has already started.
		if ( Phase != RunPhase.Defeat || (Networking.IsActive && (Rpc.Caller is null || !IsRunMember( Rpc.Caller.Id ))) ) return;
        ReturnPartyToWaiting();
	}

    private void ReturnPartyToWaiting()
    {
        RematchVotes.Clear();
        LobbyReady.Clear();
        LobbyCountdown.Clear();
		CleanupRun();
		foreach ( var pair in PartyRooms.ToArray() )
		{
			if ( pair.Key == ActivePartyId && PartyLobbyView.TryDecode( pair.Key, pair.Value, out var room, out var password ) && room.Started )
				PartyRooms[pair.Key] = PartyLobbyView.Encode( room with { Started = false }, password );
		}
		Phase = RunPhase.Lobby;
		ActivePartyId = ""; RunMemberIds = "";
        if (!Networking.IsActive) { PartyRooms.Clear(); PlayerParties.Clear(); PrivateLobbies.Clear(); LobbyReady.Clear(); LobbyCountdown.Clear(); }
		Status = "Ready";
	}
	public void ToggleWeapon() => LocalPlayer?.EquipAlternate();
	private void CleanupRun()
	{
		foreach ( var e in Scene.GetAll<EnemyBrain>().ToArray() ) e.GameObject.Destroy();
		foreach ( var projectile in Scene.GetAll<Projectile>().ToArray() ) projectile.GameObject.Destroy();
		_rewardChest?.Destroy(); _portal?.Destroy();
		_pendingChest = false; _chestOpened = false; _buffChosen = false;
		EnemiesAlive = 0;
	}
	private void RemoveOfflinePlayers() { foreach ( var p in Scene.GetAll<FpsPlayer>().Where( p => !p.GameObject.Network.Active ).ToArray() ) p.GameObject.Destroy(); }
	private static Color PlayerColor( int i ) => new[] { new Color( .2f, .85f, 1f ), new Color( .35f, 1f, .45f ), new Color( 1f, .7f, .2f ), new Color( .8f, .35f, 1f ) }[i % 4];
	protected override void OnDestroy() { if ( Instance == this ) Instance = null; }
}