RestaurantArena.cs
using Sandbox;
using System;
using System.Threading.Tasks;
namespace PaintballBaddies;

/// <summary>Editable gameplay dressing over the original workshop restaurant.
/// Map and library assets mount by package identity on every machine.</summary>
public sealed class RestaurantArena : Component
{
    public const string MapPackage="ethanthedoodler.los_hermanos";
    public const string Plate="models/game_room/plate.vmdl";
    public const string Shelf="models/unity stuff/1/shelf/shelf_b.vmdl";
    public const string Fridge="models/newcliffy/fridge.vmdl";
    public const string Table="models/props/trim_sheets/table/coffeetable_a.vmdl";
    public const string Mug="models/props/mugs/mug_01.vmdl";
    public const string CappuccinoCup="teacup/tea_cup.vmdl";
    public const string Microwave="models/props/microwave/microwave.vmdl";
    public const string Booth="models/newcliffy/booth.vmdl";
    public const string Stove="models/electric_stove_static.vmdl";
    public const string Sauce="models/saucebottle/saucebottle.vmdl";
    public const string Chair="models/props/trim_sheets/chairs/wooden_chair_a.vmdl";
    public const string Till="models/props/cash_register/cash_register.vmdl";
    public const string Toaster="models/props/toaster/toaster.vmdl";
    public const string BreakfastTray="models/restaurant/breakfast/steel_tray.vmdl";
    public const string Breakfast="models/restaurant/breakfast/breakfast.vmdl";
    public const string Stool="models/furniture/tavern_stool/tavern_stool.vmdl";
    public const string CommercialCooker="models/rust_props/kitchen_furniture/kitchen_cooker.vmdl";
    public const string KitchenPrep="models/rust_props/kitchen_furniture/kitchen_table.vmdl";
    public const string KitchenSink="models/rust_props/kitchen_furniture/kitchen_sink.vmdl";
    public const string Extractor="models/rust_props/kitchen_furniture/kitchen_extractor_hood.vmdl";
    public const string CoffeeMachine="models/restaurant/breakfast/coffee_machine.vmdl";
    public const string Griddle="models/restaurant/breakfast/griddle.vmdl";
    [Property] public bool EnableServiceExpansion {get;set;}
    public bool Ready {get;private set;}
    public bool SourcePrepared=>prepared;
    public string Error {get;private set;}="";
    private bool dressing,prepared;
    private int stockedRound=int.MinValue;
    private float gibSweep;
    private MapInstance map;
    private GameObject decor;
    private GameObject localMapRoot;
    private readonly List<(string Kind,string Model,Vector3 Point,Rotation Rotation,float Scale)> stock=new();
    private readonly List<(Model Model,Transform Pose)> chairs=new();
    private readonly List<(Model Model,Transform Pose)> tables=new();
    public const int TableShots=12;
    protected override void OnStart()
    {
        if(EnableServiceExpansion && !Components.Get<RestaurantServiceExpansion>().IsValid())Components.Create<RestaurantServiceExpansion>();
        if(!Components.Get<KitchenDoorways>().IsValid())Components.Create<KitchenDoorways>();
        if(!Components.Get<RestaurantSiteProbe>().IsValid())Components.Create<RestaurantSiteProbe>();
        if(!Components.Get<PoseSnapshots>().IsValid())Components.Create<PoseSnapshots>();
        map=Scene.GetAllComponents<MapInstance>().FirstOrDefault();
        // Late guests restore the host snapshot, which intentionally excludes
        // the Never-networked source map. Load that source locally before the
        // service replacement; the engine skips host-owned dynamic props on
        // clients, while our breakable stock still arrives from the host.
        if(!map.IsValid())
        {
            localMapRoot=new GameObject(false,"Local restaurant workshop source"){NetworkMode=NetworkMode.Never};
            map=localMapRoot.Components.Create<MapInstance>();
            map.MapName=MapPackage;map.UseMapFromLaunch=false;
            localMapRoot.Enabled=true;
        }
        if(map.IsValid()){map.OnMapLoaded+=PrepareMap;if(map.IsLoaded)PrepareMap();}
        _=Build();
    }
    private void PrepareMap()
    {
        if(prepared || !map.IsValid() || !map.IsLoaded)return;prepared=true;
        // Old map physics furniture otherwise falls independently on each peer.
        // Keep authored furniture stable, and use separate networked breakables.
        // One-off inventory of the map's furniture, to find the dining tables.
        Log.Info("RESTAURANT_PROPS "+string.Join(" ; ",map.GameObject.Components.GetAll<Prop>(FindMode.EverythingInSelfAndDescendants)
            .GroupBy(p=>p.Model?.Name ?? "?").Select(g=>$"{g.Key} x{g.Count()} h{(g.First().Model?.Bounds.Size.z ?? 0)*g.First().WorldScale.z:0} w{(g.First().Model is {} m ? MathF.Max(m.Bounds.Size.x,m.Bounds.Size.y) : 0)*g.First().WorldScale.x:0}")));
        Log.Info("RESTAURANT_MODELS "+string.Join(" ; ",map.GameObject.Components.GetAll<ModelRenderer>(FindMode.EverythingInSelfAndDescendants)
            .Where(r=>r.Model?.Name?.Contains("table",StringComparison.OrdinalIgnoreCase)==true)
            .GroupBy(r=>r.Model.Name).Select(g=>$"{g.Key} x{g.Count()} prop={g.First().Components.Get<Prop>().IsValid()}")));
        foreach(var prop in map.GameObject.Components.GetAll<Prop>(FindMode.EverythingInSelfAndDescendants).ToArray())
        {
            // The workshop restaurant contains over a hundred loose takeaway
            // boxes. Remove this noisy physics clutter before rebuilding routes.
            if(prop.Model?.Name=="megascans/debris/trash/burger_box_rmegu.vmdl"){prop.GameObject.Destroy();continue;}
            if(prop.Model?.Name=="models/rust_props/wooden_furniture/chair_a.vmdl" && prop.WorldPosition.z>50){prop.GameObject.Destroy();continue;}
            if(prop.Model?.Name?.Contains("chair",StringComparison.OrdinalIgnoreCase)==true)
            {
                chairs.Add((prop.Model,prop.WorldTransform));prop.GameObject.Destroy();continue;
            }
            // Dining tables become slow breakables: respawned each round and
            // taking a dozen shots, so cover wears down instead of vanishing.
            if(IsDiningTable(prop))
            {
                tables.Add((prop.Model,prop.WorldTransform));prop.GameObject.Destroy();continue;
            }
            prop.IsStatic=true;
        }
        foreach(var door in map.GameObject.Children.Where(g=>g.Name.StartsWith("ent_door")).ToArray())door.Enabled=false;
        Log.Info($"RESTAURANT_TABLES count={tables.Count} models="+string.Join(",",tables.Select(t=>t.Model.Name).Distinct()));
        Scene.NavMesh.SetDirty();
    }
    private static bool IsDiningTable(Prop prop)
    {
        var name=prop.Model?.Name ?? "";
        if(!name.Contains("table",StringComparison.OrdinalIgnoreCase) || name.Contains("vegetable",StringComparison.OrdinalIgnoreCase)
            || name.Contains("tablet",StringComparison.OrdinalIgnoreCase) || name.Contains("coffeetable",StringComparison.OrdinalIgnoreCase))return false;
        var size=prop.Model.Bounds.Size*prop.WorldScale.x;
        return size.z>18 && size.z<70 && MathF.Max(size.x,size.y)>22;
    }
    private async Task Build()
    {
        if(dressing)return;dressing=true;
        try
        {
            foreach(var ident in new[]{"caro.shelf_b","tones.fridge_industrial","facepunch.coffeetablea","facepunch.mug_01","ambi.plate","tones.booth_restaurant","baik.saucebottle","facepunch.wooden_chair_a","facepunch.cash_register","facepunch.toaster","baik.tavernstool","rust.kitchencooker","rust.kitchentable","rust.kitchensink","rust.kitchenextractorhood","titanovsky.tea_cup"})
                await Cloud.Load<Model>(ident);
            if(!IsValid || Scene!=Game.ActiveScene)return;
            if(new[]{Shelf,Fridge,Table,Mug,Plate,Booth,Sauce,Chair,Till,Toaster,BreakfastTray,Breakfast,Stool,CommercialCooker,KitchenPrep,KitchenSink,Extractor,CoffeeMachine,Griddle,CappuccinoCup}.Any(p=>Model.Load(p).IsError))
                throw new InvalidOperationException("A restaurant library model could not load");
            decor=new GameObject(true,"Restaurant library dressing"){NetworkMode=NetworkMode.Never};
            // Thin render-only finishes retain the workshop floor/wall collision,
            // doorway widths and navigation. Four static meshes, no per-frame work.
            foreach(var name in new[]{"dining_floor","kitchen_floor","calm_ceiling","kitchen_back_wall","diner_walls_lower","diner_walls_upper"})
            {
                var finish=new GameObject(decor,true,"Restaurant finish "+name){NetworkMode=NetworkMode.Never};
                bool service=Components.Get<RestaurantServiceExpansion>()?.GeometryReady==true && (name=="kitchen_back_wall" || name.StartsWith("diner_walls_"));
                finish.Components.Create<ModelRenderer>().Model=Model.Load($"models/restaurant/{(service ? "service" : "finish")}/{name}.vmdl");
            }
            Rack(new(-417,1120,32),0);
            Rack(new(700,1860,32),180);
            Rack(new(700,1000,32),180);
            Fixture("Commercial upright refrigerator north",Fridge,new(689,1740,32),180,1,true);
            Fixture("Commercial upright refrigerator south",Fridge,new(689,850,32),180,1,true);
            CounterStock();
            foreach(float y in new[]{1200f,1510f})Fixture("Restaurant upholstered booth",Booth,new(-401,y,32),0,.92f,false);
            foreach(float y in new[]{1350f,1400f})
            {
                Fixture("Commercial kitchen range",CommercialCooker,new(709,y,32),180,1,false);
                Fixture("Range extraction hood",Extractor,new(709,y,112),180,1.28f,false);
            }
            Fixture("Stainless preparation table",KitchenPrep,new(709,1490,32),180,1,false);
            Fixture("Stainless plating table",KitchenPrep,new(709,1660,32),180,1,false);
            Fixture("Central kitchen prep island",KitchenPrep,new(595,1240,32),180,1,false);
            Fixture("Commercial wash sink",KitchenSink,new(709,1580,32),180,1,false);
            Fixture("Separate kitchen rinse sink",KitchenSink,new(709,930,32),180,1,false);
            Fixture("Commercial flat top griddle",Griddle,new(709,1400,70.2f),0,1,false);
            // Broad, restrained work lights illuminate the equipment fronts.
            // Local static dressing: no per-frame work or extra shadow maps.
            foreach(float y in new[]{940f,1380f,1620f,1810f})
            {
                var lamp=new GameObject(decor,true,"Kitchen work lighting"){NetworkMode=NetworkMode.Never,WorldPosition=new Vector3(640,y,132)};
                var light=lamp.Components.Create<PointLight>();light.LightColor=new Color(.88f,.94f,1)*.75f;light.Radius=205;light.Shadows=false;
            }
            // Condiment bottles now ride on the breakable dining tables (see stocking).
            Fixture("The Missing Link diner sign","models/restaurant/identity/diner_sign.vmdl",new(-230,1898,110),-90,1,false);
            Fixture("House specials menu","models/restaurant/identity/menu_board.vmdl",new(438,1100,136),180,1,false);
            Fixture("Kitchen specials menu","models/restaurant/identity/menu_board.vmdl",new(438,1510,136),180,1,false);
            // Sideboards sit against the north wall, clear of the counter aisle.
            // Crockery is parented to each table so it cannot float after a break.
            foreach(var pos in new[]{new Vector3(5,1902,32),new Vector3(125,1902,32)})
                stock.Add(("wood",Table,pos,Rotation.FromYaw(90),1.65f));
            Scene.NavMesh.SetDirty();Ready=true;
        }
        catch(Exception ex){Error="Restaurant dressing did not load. Restart to retry.";Log.Warning($"RESTAURANT_ASSETS {ex.Message}");}
    }
    private GameObject Fixture(string name,string path,Vector3 ground,float yaw,float scale,bool cover)
    {
        var model=Model.Load(path);var r=Rotation.FromYaw(yaw);
        var go=new GameObject(decor,true,name){NetworkMode=NetworkMode.Never,WorldPosition=ground-r*(model.Bounds.Center.WithZ(model.Bounds.Mins.z)*scale),WorldRotation=r,WorldScale=new Vector3(scale)};
        go.Components.Create<ModelRenderer>().Model=model;
        var collider=go.Components.Create<ModelCollider>();collider.Model=model;collider.Static=true;
        if(cover){var c=go.Components.Create<CoverSurface>();c.ModelHeight=model.Bounds.Maxs.z;c.StandingOnly=true;}
        return go;
    }
    private void Rack(Vector3 ground,float yaw)
    {
        const float scale=1.1f;var r=Rotation.FromYaw(yaw);
        Fixture("Crockery rack",Shelf,ground,yaw,scale,false);
        // Upright plates are large enough to read and aim at across the room.
        // Their ceramic fronts face out of the shelves, not into the wall.
        foreach(float height in new[]{38f,72f})
        foreach(float offset in new[]{-17f,0,17})
            stock.Add(("plate",Plate,ground+r*new Vector3(5,offset,height+6),r*Rotation.FromPitch(78),.82f));
        foreach(float offset in new[]{-16f,0,16})
            stock.Add(("mug",Mug,ground+r*new Vector3(3,offset,102.5f),r,1.5f));
    }
    private void CounterStock()
    {
        // All countertop dressing is round stock, with its own weapon collider
        // and multi-hit damage. Stacks share one parent so no upper dishes float.
        stock.Add(("counter_metal",Till,new(236,1740,63.2f),Rotation.FromYaw(0),2.25f));
        stock.Add(("counter_metal",Toaster,new(238,1604,63.2f),Rotation.FromYaw(90),1));
        stock.Add(("counter_food",BreakfastTray,new(442,1640,82.3f),Rotation.FromYaw(180),1));
        stock.Add(("counter_metal",CoffeeMachine,new(392,1270,63.2f),Rotation.Identity,1.3f));
        foreach(var point in new[]{new Vector3(420,1259,63.2f),new Vector3(420,1279,63.2f),new Vector3(236,1566,63.2f)})
            stock.Add(("counter_cup_stack",Plate,point,Rotation.Identity,.46f));
        foreach(var point in new[]{new Vector3(706,1467,68.05f),new Vector3(706,1511,68.05f),new Vector3(706,1640,68.05f),new Vector3(595,1230,68.05f)})
            stock.Add(("counter_plate_stack",Plate,point,Rotation.Identity,.78f));
        foreach(float y in new[]{1290f,1430f,1570f,1700f})
        {
            stock.Add(("counter_plate",Plate,new(199,y,63.2f),Rotation.Identity,.82f));
            stock.Add(("counter_mug",Mug,new(224,y+20,63.2f),Rotation.FromYaw(y%80-40),1.2f));
            stock.Add(("counter_glass",Sauce,new(251,y-5,63.2f),Rotation.FromYaw(180),.72f));
        }
        foreach(float y in new[]{1637f,1652f})
            stock.Add(("counter_mug",Mug,new(239,y,63.2f),Rotation.FromYaw(110),1.2f));
        // Full-width, 22-inch seats suit this 31-inch service counter. Gaps
        // preserve the room's cover access and the kitchen-door approach.
        foreach(float y in new[]{1280f,1360f,1440f,1520f,1700f,1780f})
            stock.Add(("stool",Stool,new(148,y,32.2f),Rotation.FromYaw(180),1.10f));
    }
    protected override void OnUpdate()
    {
        if(!prepared && map?.IsLoaded==true)PrepareMap();
        gibSweep-=Time.Delta;
        if(gibSweep<=0)
        {
            gibSweep=.4f;
            // Native remote gib creation does not raise the source Prop callback.
            // Apply the same short debris lifetime on both peers.
            foreach(var gib in Scene.GetAllComponents<Gib>())
            {
                string name=gib.Components.Get<ModelRenderer>()?.Model?.Name ?? "";
                if((name.Contains("chair",StringComparison.OrdinalIgnoreCase) || name.StartsWith("models/props/mugs/mug_01_gib") || name.StartsWith("models/props/trim_sheets/table/coffeetable")
                    || name.StartsWith("models/props/cash_register/") || name.StartsWith("models/props/toaster/") || name.StartsWith("models/items/plain_set/") || name.StartsWith("models/saucebottle/")) && !gib.Components.Get<RestaurantGibLifetime>().IsValid())
                {gib.GameObject.Tags.Add("paintball_debris");gib.Components.Create<RestaurantGibLifetime>();}
            }
        }
        var s=MultiplayerSession.Find(Scene);
        if(!Ready || !prepared || s is null || !MultiplayerSession.Authority || stockedRound==s.RoundId)return;
        stockedRound=s.RoundId;
        foreach(var old in Scene.GetAllComponents<RestaurantBreakable>().ToArray())old.GameObject.Destroy();
        foreach(var item in stock)
        {
            var model=Model.Load(item.Model);var offset=item.Kind=="plate" ? model.Bounds.Center : model.Bounds.Center.WithZ(model.Bounds.Mins.z);
            var go=new GameObject(false,"Restaurant "+item.Kind){WorldPosition=item.Point-item.Rotation*(offset*item.Scale),WorldRotation=item.Rotation,WorldScale=new Vector3(item.Scale)};
            var prop=go.Components.Create<Prop>();prop.Model=model;prop.IsStatic=true;
            var b=go.Components.Create<RestaurantBreakable>();b.Kind=item.Kind;b.Round=s.RoundId;
            DressStack(go,item.Kind,model,item.Scale);
            if(item.Kind=="wood")
            {
                foreach(float dishOffset in new[]{-23f,0,23})
                {
                    var dishes=new GameObject(go,true,"Sideboard crockery")
                    {WorldPosition=item.Point+new Vector3(dishOffset,0,model.Bounds.Size.z*item.Scale+.15f),WorldRotation=Rotation.Identity,WorldScale=new Vector3(.78f)};
                    var dishProp=dishes.Components.Create<Prop>();dishProp.Model=Model.Load(Plate);dishProp.IsStatic=true;
                    var dishBreak=dishes.Components.Create<RestaurantBreakable>();dishBreak.Kind="counter_plate_stack";dishBreak.Round=s.RoundId;
                    DressStack(dishes,dishBreak.Kind,dishProp.Model,.78f);
                }
            }
            if(item.Kind=="counter_food")
            {
                // The whole plated serving shares one damage state. Food collision
                // resolves to this parent, and no topping survives a broken tray.
                var food=new GameObject(go,true,"Hash browns and fried eggs")
                    {LocalPosition=model.Bounds.Center.WithZ(model.Bounds.Mins.z+.45f),LocalScale=new Vector3(1/item.Scale)};
                var foodModel=Model.Load(Breakfast);food.Components.Create<ModelRenderer>().Model=foodModel;
                var collider=food.Components.Create<ModelCollider>();collider.Model=foodModel;collider.Static=true;
            }
            go.Enabled=true;
            if(MultiplayerSession.Online){go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();}
        }
        foreach(var table in tables)
        {
            var go=new GameObject(false,"Restaurant breakable table"){WorldPosition=table.Pose.Position,WorldRotation=table.Pose.Rotation,WorldScale=table.Pose.Scale};
            var prop=go.Components.Create<Prop>();prop.Model=table.Model;prop.IsStatic=true;
            var b=go.Components.Create<RestaurantBreakable>();b.Kind="table";b.Round=s.RoundId;
            // A condiment bottle on top that goes down with the table.
            var sauce=Model.Load(Sauce);
            if(!sauce.IsError)
            {
                var top=table.Model.Bounds;var bottle=new GameObject(go,true,"Table condiment bottle")
                {LocalPosition=new Vector3(top.Center.x,top.Center.y,top.Maxs.z)-sauce.Bounds.Center.WithZ(sauce.Bounds.Mins.z)*(.65f/MathF.Max(.01f,table.Pose.Scale.x)),
                 LocalRotation=Rotation.FromYaw(15),LocalScale=new Vector3(.65f/MathF.Max(.01f,table.Pose.Scale.x))};
                bottle.Components.Create<ModelRenderer>().Model=sauce;
            }
            go.Enabled=true;
            if(MultiplayerSession.Online){go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();}
        }
        foreach(var chair in chairs)
        {
            // Matching library chairs replace the map's mismatched old seating.
            // BreakCue reuses existing wooden fragment meshes on every peer.
            var go=new GameObject(false,"Restaurant breakable chair"){WorldPosition=chair.Pose.Position,WorldRotation=chair.Pose.Rotation};
            var prop=go.Components.Create<Prop>();prop.Model=Model.Load(Chair);prop.IsStatic=true;
            var b=go.Components.Create<RestaurantBreakable>();b.Kind="chair";b.Round=s.RoundId;go.Enabled=true;
            if(MultiplayerSession.Online){go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();}
        }
    }
    private static void DressStack(GameObject parent,string kind,Model plate,float scale)
    {
        if(kind=="counter_plate_stack")
        {
            for(int layer=1;layer<6;layer++)
            {
                var dish=new GameObject(parent,true,"Stacked ceramic plate"){LocalPosition=Vector3.Up*(layer*1.15f),LocalRotation=Rotation.FromYaw(layer*17)};
                dish.Components.Create<ModelRenderer>().Model=plate;
                var collider=dish.Components.Create<ModelCollider>();collider.Model=plate;collider.Static=true;
            }
        }
        if(kind=="counter_cup_stack")
        {
            var cup=Model.Load(CappuccinoCup);
            for(int layer=0;layer<3;layer++)
            {
                var piece=new GameObject(parent,true,"Stacked cappuccino cup")
                {LocalPosition=Vector3.Up*(.6f+layer*1.65f)/scale,LocalRotation=Rotation.FromYaw(layer*34),LocalScale=new Vector3(1.4f,1.4f,.85f)/scale};
                var render=piece.Components.Create<ModelRenderer>();render.Model=cup;render.MaterialOverride=Material.Load("models/restaurant/breakfast/porcelain.vmat");
                var collider=piece.Components.Create<ModelCollider>();collider.Model=cup;collider.Static=true;
            }
        }
    }
    protected override void OnDestroy()
    {
        if(map.IsValid())map.OnMapLoaded-=PrepareMap;
        if(decor.IsValid())decor.Destroy();
        if(localMapRoot.IsValid())localMapRoot.Destroy();
    }
}

/// <summary>Native Prop owns collision, health, gibs and replicated destruction.
/// This bridge validates host weapon damage and supplies the missing plate FX.</summary>
public sealed class RestaurantBreakable : Component
{
    [Sync(SyncFlags.FromHost)] public string Kind {get;set;}="plate";
    [Sync(SyncFlags.FromHost)] public int Round {get;set;}
    public static int Broken {get;private set;}
    public static int NativeGibs {get;private set;}
    private bool broken;
    public bool BrokenState=>broken;
    // Tables (dining and coffee) break after a dozen hits, not one blast.
    private bool Slow=>Kind is "table" or "wood";
    public bool CounterItem=>Kind.StartsWith("counter_",StringComparison.Ordinal);
    public int ShotsRequired=>Slow ? RestaurantArena.TableShots : Kind=="counter_food" ? 1 : CounterItem || Kind is "chair" or "stool" ? 4 : 0;
    [Sync(SyncFlags.FromHost)] public int ShotsTaken {get;private set;}
    protected override void OnStart()
    {
        var prop=Components.Get<Prop>();
        if(!prop.IsValid())return;
        if(MultiplayerSession.Authority)prop.Health=ShotsRequired>0 ? 1000 : Kind=="chair" ? 24 : 8;
        prop.OnGibsCreated+=gibs=>
        {
            NativeGibs+=gibs.Count;
            foreach(var gib in gibs)if(gib.IsValid()){gib.GameObject.Tags.Add("paintball_debris");gib.Components.Create<RestaurantGibLifetime>();}
        };
    }
    public static RestaurantBreakable Find(GameObject go)
    {
        while(go.IsValid()){var b=go.Components.Get<RestaurantBreakable>();if(b.IsValid())return b;go=go.Parent;}return null;
    }
    public void Hit(float damage,Vector3 point,PaintballCombatant attacker)
    {
        var s=MultiplayerSession.Find(Scene);var prop=Components.Get<Prop>();
        if(!MultiplayerSession.Authority || broken || !prop.IsValid() || s?.IsOverdrive!=true || !s.InRound || Round!=s.RoundId || !attacker.IsValid() || !attacker.AcceptHits || !float.IsFinite(damage) || damage<=0)return;
        if(ShotsRequired>0)
        {
            ShotsTaken++;
            if(ShotsTaken<ShotsRequired)
            {
                if(CounterItem)CounterWearCue(point,Kind,Round);
                else WearCue(GameObject,point,ShotsTaken,Round);
                return;
            }
            damage=prop.Health+1;
        }
        if(damage>=prop.Health)
        {
            broken=true;Broken++;BreakCue(point,Kind,Round,WorldPosition,WorldRotation,(point-attacker.WorldPosition).WithZ(0).Normal);
            if(Slow || Kind is "chair" or "stool")Scene.NavMesh.SetDirty();
        }
        prop.OnDamage(new DamageInfo{Damage=damage,Attacker=attacker.GameObject,Origin=attacker.WorldPosition+Vector3.Up*55,Position=point});
        // Some library appliances have no Prop break data. Keep disappearance
        // authoritative while BreakCue supplies bounded material-specific debris.
        if(broken && (CounterItem || Kind is "chair" or "stool") && GameObject.IsValid())GameObject.Destroy();
    }
    protected override void OnUpdate()
    {
        if(!CounterItem && Kind is not ("chair" or "stool"))return;
        // Synced hit count also restores wear for a late joiner; RPC-only tint
        // would incorrectly show a pristine prop on that client.
        foreach(var render in Components.GetAll<ModelRenderer>(FindMode.EverythingInSelfAndDescendants))
            render.Tint=Color.Lerp(Color.White,new Color(.55f,.51f,.46f),ShotsTaken/(float)ShotsRequired);
    }
    [Rpc.Broadcast(NetFlags.HostOnly)] private static async void CounterWearCue(Vector3 point,string kind,int round)
    {
        var scene=Game.ActiveScene;await GameTask.MainThread();
        if(!scene.IsValid() || scene!=Game.ActiveScene || MultiplayerSession.Find(scene) is not {IsOverdrive:true} s || s.RoundId!=round)return;
        CounterDebris.Spawn(scene,point,kind,round,false);
    }
    // Each hit on a table: a crack, a few splinters, and the wood darkens and
    // scuffs so the damage (and how close it is to going) reads at a glance.
    [Rpc.Broadcast(NetFlags.HostOnly)] private static async void WearCue(GameObject table,Vector3 point,int shots,int round)
    {
        var scene=Game.ActiveScene;await GameTask.MainThread();
        if(!scene.IsValid() || scene!=Game.ActiveScene || MultiplayerSession.Find(scene) is not {IsOverdrive:true} s || s.RoundId!=round)return;
        FeelAudio.Play("wood_crack",3,point,.55f+.3f*shots/RestaurantArena.TableShots,900,.1f);
        RestaurantChairDebris.Splinters(scene,point,2,round);
        if(!table.IsValid())return;
        float wear=(float)shots/RestaurantArena.TableShots;
        foreach(var r in table.Components.GetAll<ModelRenderer>(FindMode.EverythingInSelf))r.Tint=Color.Lerp(Color.White,new Color(.45f,.36f,.3f),wear);
    }
    [Rpc.Broadcast(NetFlags.HostOnly)] private static async void BreakCue(Vector3 point,string kind,int round,Vector3 origin,Rotation rotation,Vector3 direction)
    {
        var scene=Game.ActiveScene;await GameTask.MainThread();
        if(!scene.IsValid() || scene!=Game.ActiveScene || MultiplayerSession.Find(scene) is not {IsOverdrive:true} s || s.RoundId!=round)return;
        if(kind=="plate")SausageCeramicBurst.Spawn(point);
        if(kind is "chair" or "stool")RestaurantChairDebris.Spawn(scene,origin,rotation,round);
        if(kind is "table" or "wood")
        {
            RestaurantChairDebris.Spawn(scene,origin+Vector3.Up*10,rotation,round,1.6f);
            FeelAudio.Play("wood_crack",3,point,1,1100,.08f);
        }
        if(kind is "plate" or "mug")SausageKitchenAudio.Play(true,point);
        if(kind.StartsWith("counter_",StringComparison.Ordinal))CounterDebris.Spawn(scene,point,kind,round,true);
        if(kind=="counter_food")CounterTrayFlight.Spawn(scene,origin,rotation,direction,round);
        // Native table and mug models supply their own matching gib shapes.
    }
}
public sealed class RestaurantGibLifetime : Component
{
    private float age;
    protected override void OnUpdate(){age+=Time.Delta;if(age>4)GameObject.Destroy();}
}