KitchenDoorways.cs
using Sandbox;
using System;
namespace PaintballBaddies;

/// <summary>Last Orders: finds the real openings in the wall between the dining
/// room and the kitchen, frames each one so it reads as a door from the dining
/// room, and hands their positions to sausage routing. Runs on every peer; the
/// scan only reads collision, so all peers find the same openings.</summary>
public sealed class KitchenDoorways : Component
{
    // Wall between dining room (west) and kitchen (east), from the layout probe.
    const float WallX=460,ScanFrom=380,ScanTo=545,Floor=33;
    private readonly List<Vector3> doors=new();
    private readonly List<(float Y0,float Y1,float Top)> spans=new();
    public readonly List<(Vector3 Spawn,Vector3 Exit)> Routes=new();
    public bool HasDockRoute=>Routes.Any(r=>r.Spawn.x>800);
    public bool HasBathroomRoute=>Routes.Any(r=>r.Exit.y<1000);
    private float wait,stableNav,dockRetry;private bool done;
    private GameObject dressing;
    public void Refresh()
    {
        done=false;wait=0;stableNav=0;dockRetry=0;Routes.Clear();
    }
    protected override void OnUpdate()
    {
        if(ArenaSelection.Current(Scene)!=ArenaSelection.Restaurant)return;
        if(done)
        {
            // Late map collision and kitchen links can update navigation after
            // the first scan. Retain working routes and retry the dock once
            // navigation settles, without recreating links or their dressing.
            if(HasDockRoute)return;
            if(Scene.NavMesh.IsGenerating){dockRetry=0;return;}
            dockRetry+=RealTime.Delta;
            if(dockRetry>=1){dockRetry=0;TryConnectDockRoute();}
            return;
        }
        // The offline menu pauses Scene.TimeScale while it waits for these
        // routes. Loading must use real time or Start can never become ready.
        float setupDelta=RealTime.Delta;
        wait+=setupDelta;
        var expansion=Scene.GetAllComponents<RestaurantServiceExpansion>().FirstOrDefault();
        if(wait<3 || Scene.NavMesh.IsGenerating || Scene.GetAllComponents<MapInstance>().Any(m=>m.Active && !m.IsLoaded)
            || (expansion.IsValid() && !expansion.Ready && string.IsNullOrEmpty(expansion.Error))){stableNav=0;return;}
        stableNav+=setupDelta;if(stableNav<.7f)return;
        Scan();Dress();Connect();
        done=Routes.Count>0;
        // A cloud/map rebuild may briefly leave no navigation points. Retry
        // after settling instead of making that incomplete scan permanent.
        if(!done){wait=0;stableNav=0;}
    }
    private bool Open(float y,float z)
        =>!Scene.Trace.Ray(new Vector3(ScanFrom,y,z),new Vector3(ScanTo,y,z)).WithoutTags("paintball_actor","paintball_debris","overdrive_enemy").Run().Hit;
    private void Scan()
    {
        doors.Clear();spans.Clear();Routes.Clear();
        float start=float.NaN,last=0;
        for(float y=700;y<=1960;y+=6)
        {
            bool open=Open(y,Floor+40) && Open(y,Floor+78);
            if(open){if(float.IsNaN(start))start=y;last=y;}
            if((!open || y+6>1960) && !float.IsNaN(start))
            {
                if(last-start>=30)
                {
                    float mid=(start+last)/2,top=Floor+78;
                    while(top<Floor+200 && Open(mid,top+8))top+=8;
                    spans.Add((start,last,top));
                    var inside=Scene.NavMesh.GetClosestPoint(new Vector3(WallX-60,mid,Floor),80) ?? new Vector3(WallX-60,mid,Floor);
                    doors.Add(inside);
                }
                start=float.NaN;
            }
        }
        Log.Info("KITCHEN_DOORWAYS count="+spans.Count+" spans="+string.Join(";",spans.Select(s=>$"y{s.Y0:0}-{s.Y1:0} top{s.Top:0}")));

    }
    private void Connect()
    {
        foreach(var span in spans)
        {
            float y=(span.Y0+span.Y1)*.5f;
            var a=new Vector3(520,y,Floor+3);var b=new Vector3(400,y,Floor+3);
            // Only bridge a real, full-height clear doorway, never a solid wall.
            if(Scene.Trace.Capsule(new Capsule(Vector3.Up*17,Vector3.Up*57,17),a,b)
                .WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run().Hit)continue;
            var start=Scene.NavMesh.GetClosestPoint(a,35);var end=Scene.NavMesh.GetClosestPoint(b,35);
            if(!start.HasValue || !end.HasValue)continue;
            // Keep an explicit native connection across every verified opening.
            // A path may be complete during startup, then become disconnected
            // when the exit-only clip or later prop/nav rebuild finishes.
            var go=new GameObject(dressing,false,"Prep kitchen doorway navigation"){NetworkMode=NetworkMode.Never};
            var link=go.Components.Create<NavMeshLink>();link.WorldStartPosition=start.Value;link.WorldEndPosition=end.Value;
            link.ConnectionRadius=30;link.IsBiDirectional=true;go.Enabled=true;
            var spawn=Scene.NavMesh.GetClosestPoint(new Vector3(y<1000 ? 550 : 570,y,Floor+3),40);
            if(spawn.HasValue)Routes.Add((spawn.Value,end.Value));
        }
        TryConnectDockRoute();
        Log.Info($"KITCHEN_ENTRY_ROUTES {Routes.Count}");
    }
    public static string EntryName((Vector3 Spawn,Vector3 Exit) route)
        =>route.Spawn.x>800 ? "Loading dock" : route.Exit.y<1000 ? "Restroom" : route.Exit.y>1600 ? "North kitchen" : "South kitchen";
    public static IEnumerable<Vector3> ConcealedEntryCandidates((Vector3 Spawn,Vector3 Exit) route)
    {
        yield return route.Spawn;
        // Stay in the assigned room. The old +/-175 offsets could select the
        // neighbouring doorway's approach and silently feed the same entrance.
        if(route.Exit.y<1000)
        {
            foreach(var offset in new[]{new Vector3(35,40,0),new Vector3(55,-35,0),new Vector3(85,65,0)})yield return route.Spawn+offset;
        }
        else if(route.Spawn.x>800)
        {
            foreach(var offset in new[]{Vector3.Left*100,Vector3.Right*100,Vector3.Left*175,Vector3.Right*175})yield return route.Spawn+offset;
        }
        else
        {
            foreach(var offset in new[]{new Vector3(-35,105,0),new Vector3(-35,-105,0),new Vector3(0,140,0),new Vector3(0,-140,0),
                new Vector3(45,85,0),new Vector3(45,-85,0),new Vector3(105,125,0),new Vector3(105,-125,0)})yield return route.Spawn+offset;
            // The north prep wall has a broad service window. Start behind the
            // solid return, then walk up to the assigned north doorway.
            if(route.Exit.y>1600)foreach(var offset in new[]{new Vector3(-35,-210,0),new Vector3(0,-235,0),new Vector3(45,-250,0)})yield return route.Spawn+offset;
        }
    }
    public static bool UsesAssignedOpening(Sandbox.Navigation.NavMeshPath path,(Vector3 Spawn,Vector3 Exit) route)
    {
        if(path.Status.ToString()!="Complete")return false;
        float wall=route.Spawn.x>800 ? 752 : WallX;
        float center=route.Spawn.x>800 ? 1143 : route.Exit.y;
        for(int i=1;i<path.Points.Count;i++)
        {
            var a=path.Points[i-1].Position;var b=path.Points[i].Position;
            if(a.x>=wall && b.x<wall)
            {
                float y=a.y+(b.y-a.y)*((wall-a.x)/(b.x-a.x));
                return MathF.Abs(y-center)<65;
            }
        }
        return false;
    }
    private void TryConnectDockRoute()
    {
        if(HasDockRoute)return;
        var expansion=Scene.GetAllComponents<RestaurantServiceExpansion>().FirstOrDefault();
        if(expansion?.GeometryReady==true)
        {
            var dock=new Vector3(940,1295,34);var kitchen=new Vector3(650,1143,34);
            var start=Scene.NavMesh.GetClosestPoint(dock,30);var end=Scene.NavMesh.GetClosestPoint(kitchen,30);
            var from=new Vector3(700,1143,43);var to=new Vector3(810,1143,43);
            bool clear=!Scene.Trace.Capsule(new Capsule(Vector3.Up*17,Vector3.Up*57,17),from,to)
                .WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run().Hit;
            if(clear && start.HasValue && end.HasValue && Scene.NavMesh.CalculatePath(new Sandbox.Navigation.CalculatePathRequest{Start=start.Value,Target=end.Value}).Status.ToString()=="Complete")
            {
                Routes.Add((start.Value,end.Value));
                Log.Info("KITCHEN_DOCK_ROUTE ready");
            }
        }
    }
    // A painted frame, a warm lamp over each opening and a light spill, so
    // both entrances read as doors from anywhere in the dining room.
    private void Dress()
    {
        if(dressing.IsValid())dressing.Destroy();
        dressing=new GameObject(true,"Kitchen doorway dressing"){NetworkMode=NetworkMode.Never};
        if(!Components.Get<RestaurantBathroomEntrance>().IsValid())Components.Create<RestaurantBathroomEntrance>();
        var box=Model.Load("models/dev/box.vmdl");var ball=Model.Load("models/dev/sphere.vmdl");
        if(box.IsError)return;
        var frame=new Color(.62f,.1f,.07f);
        foreach(var s in spans)
        {
            if(s.Y1<1000)continue; // The outward-opening restroom door has its own signage.
            float x=WallX-10,height=MathF.Min(s.Top,Floor+110)-Floor;
            bool framed=Scene.GetAllComponents<RestaurantServiceExpansion>().FirstOrDefault()?.GeometryReady==true && s.Y0>1600;
            if(!framed){
            Piece(box,new Vector3(x,s.Y0-3,Floor+height/2),new Vector3(5,5,height),frame);
            Piece(box,new Vector3(x,s.Y1+3,Floor+height/2),new Vector3(5,5,height),frame);
            Piece(box,new Vector3(x,(s.Y0+s.Y1)/2,Floor+height+3),new Vector3(5,s.Y1-s.Y0+11,6),frame);
            }
            // Hazard stripe on the floor across the threshold.
            Piece(box,new Vector3(x+4,(s.Y0+s.Y1)/2,Floor+.3f),new Vector3(10,s.Y1-s.Y0,.6f),new Color(1,.72f,.08f));
            var lampAt=new Vector3(x-4,(s.Y0+s.Y1)/2,Floor+height+14);
            // Light only: a bare sphere read as a floating ball.
            var lightGo=new GameObject(dressing,true,"Kitchen door lamp light"){NetworkMode=NetworkMode.Never,WorldPosition=lampAt+Vector3.Backward*14};
            var light=lightGo.Components.Create<PointLight>();light.Radius=260;light.LightColor=new Color(1,.6f,.3f)*2.2f;light.Shadows=false;
        }
    }
    private GameObject Piece(Model model,Vector3 center,Vector3 size,Color tint)
    {
        var go=new GameObject(dressing,true,"Kitchen door frame piece"){NetworkMode=NetworkMode.Never};
        var b=model.Bounds;
        go.WorldScale=new Vector3(size.x/MathF.Max(.01f,b.Size.x),size.y/MathF.Max(.01f,b.Size.y),size.z/MathF.Max(.01f,b.Size.z));
        var sc=go.WorldScale;go.WorldPosition=center-new Vector3(b.Center.x*sc.x,b.Center.y*sc.y,b.Center.z*sc.z);
        var r=go.Components.Create<ModelRenderer>();r.Model=model;r.Tint=tint;
        return go;
    }
    protected override void OnDestroy(){if(dressing.IsValid())dressing.Destroy();doors.Clear();spans.Clear();Routes.Clear();}
}