From dcd589a3451573da9aa7cce5a873348a58156294 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Tue, 6 Oct 2026 22:11:31 +0000 Subject: [PATCH 01/14] bug: keep the aim crosshair small up close, and drop the text over smokes The aim reticle was sized at 4.5% of its distance but never below 9 units, so facing a wall a few units away it filled the screen and the point inside it could not be found. It now scales with distance all the way down (same ~2.5 degrees at any range), with its beam width and centre dot shrinking alongside; far-away reticles are unchanged. Also removes the floating world text over the landing spot and above the aim point, along with the label bookkeeping that only existed for it. --- .../src/Services/PracticeReplay.cs | 45 ++++- .../utility-sw/src/Services/PracticeReplay.cs | 176 ++++-------------- 2 files changed, 71 insertions(+), 150 deletions(-) diff --git a/apps/utility-css/src/Services/PracticeReplay.cs b/apps/utility-css/src/Services/PracticeReplay.cs index 0d5b412d..c4b783c7 100644 --- a/apps/utility-css/src/Services/PracticeReplay.cs +++ b/apps/utility-css/src/Services/PracticeReplay.cs @@ -468,12 +468,10 @@ private void ShowMarkers(LineupRecord lineup) color ); + // No text over the landing or the aim point: the rings and their + // colour already say it, and words hung in the air above the smoke + // only got in the way of seeing it. Ring(landing, 26f, color, 2f); - Label( - new Vec3(landing.x, landing.y, landing.z + 16f), - lineup.utility_type.ToUpperInvariant(), - color - ); // Where to look, placed along the recorded aim at the distance the // throw actually travelled, so it sits on the thing being aimed at @@ -494,8 +492,31 @@ private void ShowMarkers(LineupRecord lineup) eye.z + (float)(-Math.Sin(pitch)) * reach ); - Label(aim, "AIM", color); - Ring(aim, 10f, color, 1f); + // Sized by distance from the eye so it covers the same slice of + // the view at any range. A fixed 10-unit ring was fine far off but + // swallowed the screen on a short throw, with nothing in it to + // pinpoint; past ~220 units it is the same ring as before. + float away = new Vec3(aim.x - eye.x, aim.y - eye.y, aim.z - eye.z).Length(); + float radius = Math.Clamp(away * 0.045f, 0.25f, 10f); + float width = Math.Clamp(away * 0.0045f, 0.02f, 1f); + + Ring(aim, radius, color, width); + + // The exact point, so there is something precise to cover. + float dot = Math.Max(radius * 0.08f, 0.05f); + + AddMarkerBeam( + new Vec3(aim.x - dot, aim.y, aim.z + 2f), + new Vec3(aim.x + dot, aim.y, aim.z + 2f), + color, + width * 1.6f + ); + AddMarkerBeam( + new Vec3(aim.x, aim.y - dot, aim.z + 2f), + new Vec3(aim.x, aim.y + dot, aim.z + 2f), + color, + width * 1.6f + ); } } @@ -530,6 +551,16 @@ private void Ring(Vec3 center, float radius, Color color, float width) } } + private void AddMarkerBeam(Vec3 start, Vec3 end, Color color, float width) + { + CEnvBeam? beam = CreateBeam(start, end, color, width); + + if (beam != null) + { + _markerBeams.Add(beam); + } + } + private void Label(Vec3 at, string text, Color color) { try diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 10b5da4b..d7357847 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -152,6 +152,13 @@ private static TraceParams SkipMarkers() // anything heavier. private const float MarkerWidth = 0.6f; + // The aim reticle's half-size per unit of distance from the stance eye: + // roughly 2.5 degrees, so it covers the same slice of the view at any + // range. The minimums only stop a zero distance drawing nothing. + private const float AimSizePerUnit = 0.045f; + private const float AimMinSize = 0.25f; + private const float AimMinWidth = 0.02f; + // How far away the model's outline stays visible. Bounded: across the whole // map every spot glowing through every wall is noise, not guidance. private const int UtilityGlowRange = 1500; @@ -179,17 +186,15 @@ private static TraceParams SkipMarkers() // for must not blink out because somebody ran .next. private readonly List _spawnBeams = new(); private List? _spawnInto; - private readonly List _markerTexts = new(); private readonly List _markerProps = new(); - // The selection layer: the crosshair and labels for whichever lineup ONE + // The selection layer: the crosshairs for whichever lineup ONE // player has focused. Kept per player and transmit-blocked from everybody // else, because two people practising at once were otherwise wiping each // other's aim marker every time either of them moved. private class Selection { public readonly List Beams = new(); - public readonly List Texts = new(); // The crosshairs, kept per throw so they can be recoloured as the // player moves the mouse instead of being torn down and redrawn. @@ -1618,14 +1623,6 @@ private void ClearSelection(ulong steamId) } } - foreach (CPointWorldText text in selection.Texts) - { - if (text.IsValid) - { - text.Despawn(); - } - } - _selections.Remove(steamId); } @@ -1729,21 +1726,19 @@ private void ShowMarkers(LineupRecord lineup) Color color = ColorFor(lineup.utility_type); Vec3 landing = lineup.detonation_position; + // No text over the landing: the diamond and its colour already say + // where it goes and what it is, and a word hung in the air above the + // smoke only got in the way of seeing it. Diamond(landing, 30f, color, MarkerWidth); - Label( - new Vec3(landing.x, landing.y, landing.z + 16f), - PracticeLineupUtility.Tracked(lineup.utility_type), - color - ); - AimReticle(lineup, lineup.name); + AimReticle(lineup); } // Where to point. The aim ray is traced until it hits something, so the // reticle lands ON the surface being aimed at rather than hanging in the // air short of it -- for an arcing smoke the crosshair sits well above the // landing spot, so distance-to-landing was never the right answer. - private void AimReticle(LineupRecord lineup, string label) + private void AimReticle(LineupRecord lineup) { // From the eye of somebody standing on THIS LINEUP'S spot -- never from // wherever the player happens to be. The caller's stance is the live @@ -1827,7 +1822,13 @@ private void AimReticle(LineupRecord lineup, string label) // wall is ten units away or two thousand. // Tighter than a "look over there" marker: this is a point to cover // with the crosshair, so it subtends a few degrees and no more. - float size = Math.Clamp(away * 0.045f, 9f, 110f); + // + // The floor used to be 9 units, which held for anything past ~200 + // units but blew up up close: facing a wall 10 units off, a 9-unit + // reticle filled most of the screen and the aim point inside it could + // not be found. Proportional all the way down keeps the same ~2.5 + // degrees at any range; the floor only guards a degenerate distance. + float size = Math.Clamp(away * AimSizePerUnit, AimMinSize, 110f); // Deliberately not the utility's colour: this is the only marker that // is not a place the utility goes, and it has to separate from the @@ -1835,7 +1836,14 @@ private void AimReticle(LineupRecord lineup, string label) // Thin lines vanish at range, so the reticle's weight grows with // distance the same way its size does. The ground rings never need // this: you are always standing on them. - float weight = Math.Clamp(away * 0.0018f, MarkerWidth, 2.2f); + // The floor scales too, for the same reason as the size: a fixed + // 0.6-unit beam is a hair at range but a bar across the view from a + // wall a few units away. Unchanged from ~100 units out. + float weight = Math.Clamp( + away * 0.0018f, + Math.Max(Math.Min(MarkerWidth, away * 0.006f), AimMinWidth), + 2.2f + ); // Every throw off the spot is drawn at the same size and weight. Which // one you are on is said in COLOUR, not in scale: a smaller crosshair @@ -1895,13 +1903,6 @@ private void AimReticle(LineupRecord lineup, string label) _aimInto = null; } - // Named at the crosshair itself: several throws off one spot are only - // useful if you can tell which crosshair belongs to which. - // Amber, and never repainted: the label names the throw, the beams - // carry the miss signal, and splitting the jobs means the label's own - // colour networking never becomes a question. - Label(new Vec3(center.x, center.y, center.z + size + 8f), label, Amber); - _drawingInto?.Aims.Add(aim); } @@ -2007,7 +2008,9 @@ Vec3 Corner(float x, float y) => // The point itself: a dot small enough that covering it with the // crosshair means covering the exact spot the throw was aimed at. - float dot = Math.Max(size * 0.03f, 0.6f); + // Its floor shrinks with the reticle: a fixed 0.6 swamped a reticle + // drawn on a wall a few units away, hiding the point it marks. + float dot = Math.Max(size * 0.03f, Math.Min(size * 0.067f, 0.6f)); AddMarkerBeam(Corner(-dot, 0), Corner(dot, 0), color, width * 1.6f); AddMarkerBeam(Corner(0, -dot), Corner(0, dot), color, width * 1.6f); @@ -2114,7 +2117,6 @@ private Vec3 Grounded(Vec3 position) // face, where to point. The utility's own colour is reserved for the half // that is about the grenade: where it lands and what it is. Everything // being type-coloured is what made a busy map unreadable. - private static readonly Color Amber = new Color(249, 158, 47, 255); private static readonly Color AmberDim = new Color(203, 117, 11, 255); private const float StanceWidth = 1.6f; @@ -2132,18 +2134,9 @@ private Vec3 Grounded(Vec3 position) // being on the ground from a player's eye line. private const float StanceRingHeight = 26f; - // Which face of the text plane is the front. Zero put the back of it toward - // the reader and every label came out mirrored. - private const float LabelYaw = 180f; - // Enough to clear an uneven floor without being a noticeable drop. private const float TeleportClearance = 4f; - // Legible without being architecture. These labels sit on the spot they - // name, at arm's length, not across the map. - private const int LabelFontSize = 34; - private const float LabelUnitsPerPx = 0.06f; - // Stamped on every entity this plugin spawns. Entities outlive the plugin // instance that made them: a hot reload drops all our references while the // beams stay in the world, so the ONLY way a fresh instance can find its @@ -2152,6 +2145,8 @@ private Vec3 Grounded(Vec3 position) // The classes we spawn. Maps author their own env_beams and props, which is // exactly why the sweep matches on the tag as well as the class. + // point_worldtext is no longer spawned, but stays so a hot reload over an + // older build still clears the labels that build left behind. private static readonly string[] MarkerClasses = { "env_beam", @@ -2410,92 +2405,6 @@ private static Vec3 Normalize(Vec3 v) return length < 0.0001f ? v : new Vec3(v.x / length, v.y / length, v.z / length); } - private CPointWorldText? Label(Vec3 at, string text, Color color) - { - if (!Sane(at)) - { - return null; - } - - try - { - CPointWorldText label = - _core.EntitySystem.CreateEntityByDesignerName( - "point_worldtext" - ); - - if (!label.IsValid) - { - return null; - } - - label.MessageText = text; - label.Color = color; - label.FontName = "Arial Black"; - label.Fullbright = true; - label.Enabled = true; - label.JustifyHorizontal = PointWorldTextJustifyHorizontal_t - .POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER; - label.JustifyVertical = PointWorldTextJustifyVertical_t - .POINT_WORLD_TEXT_JUSTIFY_VERTICAL_CENTER; - - // Every label spins to face whoever is reading it, so nothing here - // computes where the reader is. What it does have to get right is - // which FACE the text is written on: reorient turns the entity to - // the viewer, and with a zero yaw that presented the back of the - // plane, so every name came out mirrored. The flip is a property of - // the entity, not of anybody looking at it. - // - // Reading it from directly underneath still foreshortens it to the - // point of illegibility -- reorient only turns around the up axis, - // so there is no yaw that fixes a label being read from below. - label.ReorientMode = PointWorldTextReorientMode_t - .POINT_WORLD_TEXT_REORIENT_AROUND_UP; - - // Small, because these sit ON the thing they name rather than - // across the map from it. 60px at 0.15 units/px was roughly two - // metres of lettering standing in a doorway. - label.FontSize = LabelFontSize; - label.WorldUnitsPerPx = LabelUnitsPerPx; - - var angle = new QAngle(0, LabelYaw, 0); - - label.Teleport( - new Vector(at.x, at.y, at.z), - angle, - new Vector(0, 0, 0) - ); - - label.DispatchSpawn(Tagged()); - - // Again after the spawn: DispatchSpawn re-derives the transform - // from the entity's own keyvalues, so an angle set only before it - // is the angle that gets thrown away. - label.Teleport( - new Vector(at.x, at.y, at.z), - angle, - new Vector(0, 0, 0) - ); - - if (_drawingInto != null) - { - _drawingInto.Texts.Add(label); - } - else - { - _markerTexts.Add(label); - } - - return label; - } - catch (Exception error) - { - _logger.LogError(error, "unable to place a lineup marker"); - - return null; - } - } - // For a map change only. The entities died with the map, so their handles // are stale -- and a stale handle can be recycled into a NEW entity, which // makes despawning it actively harmful. Drop the references instead. @@ -2505,7 +2414,6 @@ public void ForgetMarkers() _spawnBeams.Clear(); _aimHits.Clear(); _markerBeams.Clear(); - _markerTexts.Clear(); _markerProps.Clear(); _selections.Clear(); _drawingInto = null; @@ -2523,14 +2431,6 @@ private void ClearSharedMarkers() } } - foreach (CPointWorldText label in _markerTexts) - { - if (label.IsValid) - { - label.Despawn(); - } - } - foreach (CPhysicsProp prop in _markerProps) { if (prop.IsValid) @@ -2540,7 +2440,6 @@ private void ClearSharedMarkers() } _markerBeams.Clear(); - _markerTexts.Clear(); _markerProps.Clear(); } @@ -2561,14 +2460,6 @@ public void ClearMarkers() } } - foreach (CPointWorldText label in _markerTexts) - { - if (label.IsValid) - { - label.Despawn(); - } - } - foreach (CPhysicsProp prop in _markerProps) { if (prop.IsValid) @@ -2578,7 +2469,6 @@ public void ClearMarkers() } _markerBeams.Clear(); - _markerTexts.Clear(); _markerProps.Clear(); } From 49e89f7fcceed6d8a52757e0307d6543741887c9 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Tue, 6 Oct 2026 22:32:44 +0000 Subject: [PATCH 02/14] bug: keep the lineup name at the crosshair, drop the stand label Puts back the lineup name over the aim crosshair in the SwiftlyS2 plugin, since several throws off one spot need it to tell them apart. It now shrinks with the crosshair up close so it no longer covers the point it names; from ~200 units out it is unchanged. The CounterStrikeSharp port drops its STAND label on the ground and shows the lineup name at the aim ring instead, sized the same way. --- .../src/Services/PracticeReplay.cs | 27 +-- .../utility-sw/src/Services/PracticeReplay.cs | 154 +++++++++++++++++- 2 files changed, 165 insertions(+), 16 deletions(-) diff --git a/apps/utility-css/src/Services/PracticeReplay.cs b/apps/utility-css/src/Services/PracticeReplay.cs index c4b783c7..6e35af50 100644 --- a/apps/utility-css/src/Services/PracticeReplay.cs +++ b/apps/utility-css/src/Services/PracticeReplay.cs @@ -461,16 +461,11 @@ private void ShowMarkers(LineupRecord lineup) Vec3 stance = lineup.release.feet_position; Vec3 landing = lineup.detonation_position; - Ring(stance, 18f, color, 1.5f); - Label( - new Vec3(stance.x, stance.y, stance.z + 12f), - $"STAND\n{lineup.name}", - color - ); - - // No text over the landing or the aim point: the rings and their + // No text on the stance or over the landing: the rings and their // colour already say it, and words hung in the air above the smoke - // only got in the way of seeing it. + // only got in the way of seeing it. The name lives at the aim point, + // where it tells several throws off one spot apart. + Ring(stance, 18f, color, 1.5f); Ring(landing, 26f, color, 2f); // Where to look, placed along the recorded aim at the distance the @@ -502,6 +497,16 @@ private void ShowMarkers(LineupRecord lineup) Ring(aim, radius, color, width); + // Shrinks with the ring up close so the name never covers the + // point it is naming. Unchanged from ~220 units out. + float labelScale = Math.Min(1f, radius / 10f); + Label( + new Vec3(aim.x, aim.y, aim.z + radius + 6f * labelScale), + lineup.name, + color, + 0.15f * labelScale + ); + // The exact point, so there is something precise to cover. float dot = Math.Max(radius * 0.08f, 0.05f); @@ -561,7 +566,7 @@ private void AddMarkerBeam(Vec3 start, Vec3 end, Color color, float width) } } - private void Label(Vec3 at, string text, Color color) + private void Label(Vec3 at, string text, Color color, float unitsPerPx) { try { @@ -579,7 +584,7 @@ private void Label(Vec3 at, string text, Color color) label.FontSize = 60; label.FontName = "Arial Black"; label.Fullbright = true; - label.WorldUnitsPerPx = 0.15f; + label.WorldUnitsPerPx = unitsPerPx; label.Enabled = true; label.JustifyHorizontal = PointWorldTextJustifyHorizontal_t .POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER; diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index d7357847..eaef0a75 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -186,15 +186,17 @@ private static TraceParams SkipMarkers() // for must not blink out because somebody ran .next. private readonly List _spawnBeams = new(); private List? _spawnInto; + private readonly List _markerTexts = new(); private readonly List _markerProps = new(); - // The selection layer: the crosshairs for whichever lineup ONE + // The selection layer: the crosshair and labels for whichever lineup ONE // player has focused. Kept per player and transmit-blocked from everybody // else, because two people practising at once were otherwise wiping each // other's aim marker every time either of them moved. private class Selection { public readonly List Beams = new(); + public readonly List Texts = new(); // The crosshairs, kept per throw so they can be recoloured as the // player moves the mouse instead of being torn down and redrawn. @@ -1623,6 +1625,14 @@ private void ClearSelection(ulong steamId) } } + foreach (CPointWorldText text in selection.Texts) + { + if (text.IsValid) + { + text.Despawn(); + } + } + _selections.Remove(steamId); } @@ -1731,14 +1741,14 @@ private void ShowMarkers(LineupRecord lineup) // smoke only got in the way of seeing it. Diamond(landing, 30f, color, MarkerWidth); - AimReticle(lineup); + AimReticle(lineup, lineup.name); } // Where to point. The aim ray is traced until it hits something, so the // reticle lands ON the surface being aimed at rather than hanging in the // air short of it -- for an arcing smoke the crosshair sits well above the // landing spot, so distance-to-landing was never the right answer. - private void AimReticle(LineupRecord lineup) + private void AimReticle(LineupRecord lineup, string label) { // From the eye of somebody standing on THIS LINEUP'S spot -- never from // wherever the player happens to be. The caller's stance is the live @@ -1903,6 +1913,22 @@ private void AimReticle(LineupRecord lineup) _aimInto = null; } + // Named at the crosshair itself: several throws off one spot are only + // useful if you can tell which crosshair belongs to which. + // Amber, and never repainted: the label names the throw, the beams + // carry the miss signal, and splitting the jobs means the label's own + // colour networking never becomes a question. + // Shrinks with the reticle up close for the same reason the reticle + // does: full-size lettering on a wall a few units off covered the very + // point it was naming. Unchanged from ~200 units out. + float labelScale = Math.Min(1f, size / 9f); + Label( + new Vec3(center.x, center.y, center.z + size + 8f * labelScale), + label, + Amber, + LabelUnitsPerPx * labelScale + ); + _drawingInto?.Aims.Add(aim); } @@ -2117,6 +2143,7 @@ private Vec3 Grounded(Vec3 position) // face, where to point. The utility's own colour is reserved for the half // that is about the grenade: where it lands and what it is. Everything // being type-coloured is what made a busy map unreadable. + private static readonly Color Amber = new Color(249, 158, 47, 255); private static readonly Color AmberDim = new Color(203, 117, 11, 255); private const float StanceWidth = 1.6f; @@ -2134,9 +2161,18 @@ private Vec3 Grounded(Vec3 position) // being on the ground from a player's eye line. private const float StanceRingHeight = 26f; + // Which face of the text plane is the front. Zero put the back of it toward + // the reader and every label came out mirrored. + private const float LabelYaw = 180f; + // Enough to clear an uneven floor without being a noticeable drop. private const float TeleportClearance = 4f; + // Legible without being architecture. These labels sit on the spot they + // name, at arm's length, not across the map. + private const int LabelFontSize = 34; + private const float LabelUnitsPerPx = 0.06f; + // Stamped on every entity this plugin spawns. Entities outlive the plugin // instance that made them: a hot reload drops all our references while the // beams stay in the world, so the ONLY way a fresh instance can find its @@ -2145,8 +2181,6 @@ private Vec3 Grounded(Vec3 position) // The classes we spawn. Maps author their own env_beams and props, which is // exactly why the sweep matches on the tag as well as the class. - // point_worldtext is no longer spawned, but stays so a hot reload over an - // older build still clears the labels that build left behind. private static readonly string[] MarkerClasses = { "env_beam", @@ -2405,6 +2439,97 @@ private static Vec3 Normalize(Vec3 v) return length < 0.0001f ? v : new Vec3(v.x / length, v.y / length, v.z / length); } + private CPointWorldText? Label( + Vec3 at, + string text, + Color color, + float unitsPerPx = LabelUnitsPerPx + ) + { + if (!Sane(at)) + { + return null; + } + + try + { + CPointWorldText label = + _core.EntitySystem.CreateEntityByDesignerName( + "point_worldtext" + ); + + if (!label.IsValid) + { + return null; + } + + label.MessageText = text; + label.Color = color; + label.FontName = "Arial Black"; + label.Fullbright = true; + label.Enabled = true; + label.JustifyHorizontal = PointWorldTextJustifyHorizontal_t + .POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER; + label.JustifyVertical = PointWorldTextJustifyVertical_t + .POINT_WORLD_TEXT_JUSTIFY_VERTICAL_CENTER; + + // Every label spins to face whoever is reading it, so nothing here + // computes where the reader is. What it does have to get right is + // which FACE the text is written on: reorient turns the entity to + // the viewer, and with a zero yaw that presented the back of the + // plane, so every name came out mirrored. The flip is a property of + // the entity, not of anybody looking at it. + // + // Reading it from directly underneath still foreshortens it to the + // point of illegibility -- reorient only turns around the up axis, + // so there is no yaw that fixes a label being read from below. + label.ReorientMode = PointWorldTextReorientMode_t + .POINT_WORLD_TEXT_REORIENT_AROUND_UP; + + // Small, because these sit ON the thing they name rather than + // across the map from it. 60px at 0.15 units/px was roughly two + // metres of lettering standing in a doorway. + label.FontSize = LabelFontSize; + label.WorldUnitsPerPx = unitsPerPx; + + var angle = new QAngle(0, LabelYaw, 0); + + label.Teleport( + new Vector(at.x, at.y, at.z), + angle, + new Vector(0, 0, 0) + ); + + label.DispatchSpawn(Tagged()); + + // Again after the spawn: DispatchSpawn re-derives the transform + // from the entity's own keyvalues, so an angle set only before it + // is the angle that gets thrown away. + label.Teleport( + new Vector(at.x, at.y, at.z), + angle, + new Vector(0, 0, 0) + ); + + if (_drawingInto != null) + { + _drawingInto.Texts.Add(label); + } + else + { + _markerTexts.Add(label); + } + + return label; + } + catch (Exception error) + { + _logger.LogError(error, "unable to place a lineup marker"); + + return null; + } + } + // For a map change only. The entities died with the map, so their handles // are stale -- and a stale handle can be recycled into a NEW entity, which // makes despawning it actively harmful. Drop the references instead. @@ -2414,6 +2539,7 @@ public void ForgetMarkers() _spawnBeams.Clear(); _aimHits.Clear(); _markerBeams.Clear(); + _markerTexts.Clear(); _markerProps.Clear(); _selections.Clear(); _drawingInto = null; @@ -2431,6 +2557,14 @@ private void ClearSharedMarkers() } } + foreach (CPointWorldText label in _markerTexts) + { + if (label.IsValid) + { + label.Despawn(); + } + } + foreach (CPhysicsProp prop in _markerProps) { if (prop.IsValid) @@ -2440,6 +2574,7 @@ private void ClearSharedMarkers() } _markerBeams.Clear(); + _markerTexts.Clear(); _markerProps.Clear(); } @@ -2460,6 +2595,14 @@ public void ClearMarkers() } } + foreach (CPointWorldText label in _markerTexts) + { + if (label.IsValid) + { + label.Despawn(); + } + } + foreach (CPhysicsProp prop in _markerProps) { if (prop.IsValid) @@ -2469,6 +2612,7 @@ public void ClearMarkers() } _markerBeams.Clear(); + _markerTexts.Clear(); _markerProps.Clear(); } From adca0283900e309cec5a655db9baaffc5756582a Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Tue, 6 Oct 2026 22:38:23 +0000 Subject: [PATCH 03/14] feature: hide the rest of the library while practising one lineup Loading a lineup (.load, .next/.prev, the menu or map, or the website) now hides every other lineup's markers from that player only, using SwiftlyS2's per-player transmit blocks. Their loaded lineup keeps its stance, crosshair and landing. .all brings the whole map back; .clear, .reload, .save, a drill or an execute also leave it whole. --- apps/utility-sw/src/Commands/Practice.cs | 67 ++++++++++++++- .../utility-sw/src/Services/PracticeReplay.cs | 84 +++++++++++++++++-- .../utility-sw/src/Services/PracticeSystem.cs | 13 +++ apps/utility-sw/src/UtilityPracticePlugin.cs | 18 +++- 4 files changed, 172 insertions(+), 10 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index ec385bbe..7376fd5b 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -145,9 +145,11 @@ private void SaveThrow(ulong steamId, string name) PracticeState saved = _system.StateFor(steamId); saved.Loaded = thrown; + saved.Practising = null; saved.Results.Clear(); saved.Results.Add(thrown); saved.Index = 0; + _replay.ApplyLibraryVisibility(player); // Deliberately not the full .load: the player is already standing on // the spot they just threw from, and teleporting them onto it would @@ -398,6 +400,37 @@ public void OnCallouts(ICommandContext context) } } + // Back to the whole map after loading one lineup. The loaded lineup stays + // loaded -- .rethrow still goes back to it -- it just stops being the only + // thing drawn. + [Command("all", registerRaw: false, permission: "")] + public void OnAll(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + + if (state.Practising == null) + { + Reply(context, $" {ChatColors.Grey}already showing every lineup"); + return; + } + + state.Practising = null; + _replay.ApplyLibraryVisibility(player); + + // The selection was narrowed to the one throw; drawn again on the next + // spot check with everything off the spot the player is standing on. + _standingIn.Remove(player.SteamID); + + Reply(context, $" {ChatColors.Green}showing every lineup"); + } + [Command("clear", registerRaw: false, permission: "")] public void OnClear(ICommandContext context) { @@ -410,6 +443,7 @@ public void OnClear(ICommandContext context) PracticeState state = _system.StateFor(player.SteamID); state.Loaded = null; + state.Practising = null; state.Results.Clear(); state.Index = -1; state.Bloom = false; @@ -1024,6 +1058,13 @@ public void OnDelete(ICommandContext context) _library.Remove(player.SteamID, loaded); state.Results.RemoveAll(match => match.client_id == loaded.client_id); state.Loaded = null; + + if (state.Practising?.client_id == loaded.client_id) + { + state.Practising = null; + _replay.ApplyLibraryVisibility(player); + } + _replay.ClearGhosts(player.SteamID); Reply(context, $" {ChatColors.Green}deleted {ChatColors.Default}{loaded.name}"); @@ -1069,6 +1110,7 @@ public void OnReload(ICommandContext context) PracticeState reloading = _system.StateFor(steamId); reloading.Loaded = null; + reloading.Practising = null; reloading.Results.Clear(); reloading.Index = -1; @@ -1383,6 +1425,7 @@ public void OnRefresh(ICommandContext context) $" {ChatColors.Default}.next / .prev {ChatColors.Grey}walk the last search", $" {ChatColors.Default}.jump {ChatColors.Grey}stand where the loaded lineup lands", $" {ChatColors.Default}.rethrow {ChatColors.Grey}back to the loaded lineup", + $" {ChatColors.Default}.all {ChatColors.Grey}show every lineup again after loading one", $" {ChatColors.Default}.last / .back {ChatColors.Grey}back to a throw you made", $" {ChatColors.Default}.map / .here {ChatColors.Grey}pick off the minimap, or only what you can throw from here", $" {ChatColors.Default}.edit {ChatColors.Grey}rename the loaded lineup or change who sees it", @@ -1553,7 +1596,29 @@ private void Apply(IPlayer player, LineupRecord lineup) return; } - _system.StateFor(player.SteamID).Loaded = lineup; + PracticeState state = _system.StateFor(player.SteamID); + LineupRecord? was = state.Practising; + + state.Loaded = lineup; + + // Loading one lineup is asking to practise THAT one, so the rest of + // the map is hidden from this player until .all. A drill and an + // execute pick their own throws and already draw only those, and + // leave the map whole when they end. + state.Practising = + _drill.Current(player.SteamID) == null && !_playbook.Running ? lineup : null; + + // Now as well as on the redraw Load makes: a library that has not + // changed is not rebuilt, so nothing else would apply this. + _replay.ApplyLibraryVisibility(player); + + if (state.Practising != null && was?.client_id != lineup.client_id) + { + player.SendChat( + $" {ChatColors.Green}practising {ChatColors.Default}{DrillUtility.Name(lineup)} {ChatColors.Grey}-- other lineups hidden, {ChatColors.Default}.all{ChatColors.Grey} to show them" + .Colored() + ); + } // Standing the player on the lineup needs nothing but the flat fields, // so it happens now; the line itself may still be a round trip away. diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index eaef0a75..bde3693d 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -178,7 +178,8 @@ private static TraceParams SkipMarkers() // The library layer: every lineup's stance ring, landing ring, name and // grenade model. Shared on purpose -- everyone on the server should see - // where the lineups are. + // where the lineups are -- and only transmit-blocked from a player who is + // practising one lineup (see ApplyLibraryVisibility). private readonly List _markerBeams = new(); // Spawn rings live outside the library layer on purpose: ShowLibrary @@ -336,8 +337,12 @@ ILogger logger // channel that fades. public Func AnnouncesLoad { get; set; } = _ => true; + // Whether this player is practising one lineup they loaded, and so should + // see only that one. Wired by the plugin, which owns the state. + public Func LibraryHidden { get; set; } = _ => false; + // The whole library for a player, so loading one lineup still draws the - // rest. Supplied by the plugin, which owns the library. + // rest for everybody else. Supplied by the plugin, which owns the library. /// /// Narrows the library layer to a named set of lineups, IN ORDER. /// @@ -567,8 +572,13 @@ public void Load(IPlayer player, LineupRecord lineup) IReadOnlyList library = everything.Count > 0 ? everything : new[] { lineup }; - // Whatever else is throwable from this spot comes up with it. - List here = SpotAt(library, standing); + // Whatever else is throwable from this spot comes up with it -- + // unless the player is practising this one, where a sibling + // crosshair is exactly the "which one am I on" they loaded it to + // avoid. + List here = LibraryHidden(player.SteamID) + ? new List() + : SpotAt(library, standing); if (!here.Any(entry => entry.client_id == lineup.client_id)) { @@ -581,7 +591,11 @@ public void Load(IPlayer player, LineupRecord lineup) if (AnnouncesLoad(player.SteamID)) { - player.SendCenter(Describe(lineup)); + player.SendCenter( + LibraryHidden(player.SteamID) + ? $"{Describe(lineup)}\n.all shows every lineup" + : Describe(lineup) + ); } } @@ -1411,6 +1425,66 @@ public void ShowLibrary(IEnumerable lineups) } ShowSpotUtility(drawn); + + // New entities start out sent to everybody, so whoever was practising + // one lineup would get the whole map back on every rebuild. + ApplyLibraryVisibility(); + } + + /// + /// Hides the library layer from whoever is practising one lineup, and + /// shows it to everyone else. Per viewer, so one player narrowing their + /// view leaves the rest of the server seeing the whole map. Their own + /// selection is a separate set of entities and is never touched here. + /// + /// Not while an execute is running: that already narrows the library to + /// its own throws, and hiding those would hide the execute. + /// + public void ApplyLibraryVisibility(IPlayer? only = null) + { + var indexes = new List(); + + foreach (CEnvBeam beam in _markerBeams) + { + if (beam.IsValid) + { + indexes.Add((int)beam.Index); + } + } + + foreach (CPointWorldText label in _markerTexts) + { + if (label.IsValid) + { + indexes.Add((int)label.Index); + } + } + + foreach (CPhysicsProp prop in _markerProps) + { + if (prop.IsValid) + { + indexes.Add((int)prop.Index); + } + } + + IEnumerable players = + only != null ? new[] { only } : _core.PlayerManager.GetAllPlayers(); + + foreach (IPlayer player in players) + { + if (player == null || !player.IsValid || player.IsFakeClient) + { + continue; + } + + bool hide = LibraryRestriction == null && LibraryHidden(player.SteamID); + + foreach (int index in indexes) + { + player.ShouldBlockTransmitEntity(index, hide); + } + } } // What to bring, not which throw to make. A model belongs to the SPOT: two diff --git a/apps/utility-sw/src/Services/PracticeSystem.cs b/apps/utility-sw/src/Services/PracticeSystem.cs index 4f05c2c6..338659fb 100644 --- a/apps/utility-sw/src/Services/PracticeSystem.cs +++ b/apps/utility-sw/src/Services/PracticeSystem.cs @@ -13,6 +13,13 @@ public class PracticeState { public LineupRecord? Loaded { get; set; } + // The lineup this player explicitly loaded to practise -- .load, .next, a + // pick off the panel or the website. While set, every other lineup is + // hidden from them alone. Separate from Loaded because that one follows + // whichever ring they look at, and glancing at a spot must not be what + // empties the map. .all puts it back to null. + public LineupRecord? Practising { get; set; } + // The last query's matches, so .next and .prev walk them in place. public List Results { get; } = new List(); public int Index { get; set; } = -1; @@ -120,6 +127,12 @@ public bool IsSolo(ulong steamId) return !_states.TryGetValue(steamId, out PracticeState? state) || state.Solo; } + // Same shape as IsSolo: no state means nothing loaded, so nothing hidden. + public bool IsPractising(ulong steamId) + { + return _states.TryGetValue(steamId, out PracticeState? state) && state.Practising != null; + } + public void Forget(ulong steamId) { _states.Remove(steamId); diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 3bfacab9..9ff8e7b3 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -143,6 +143,7 @@ public override void Load(bool hotReload) _replay.IsSolo = _system.IsSolo; _replay.AnnouncesLoad = steamId => !UseHud(steamId); + _replay.LibraryHidden = _system.IsPractising; _replay.All = steamId => _library.For(steamId); // A solve rains live HE and molotovs on a map people are standing in. _system.SolveRunning = () => _solver.IsBusy; @@ -691,7 +692,12 @@ private void StandOnNearest(IPlayer player, CCSPlayerPawn pawn) Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); var at = new Vec3(origin.X, origin.Y, origin.Z); - IReadOnlyList library = _library.For(player.SteamID); + // Practising one lineup, the rest of the map is hidden from them, and + // use must not stand them on a ring they cannot see. + LineupRecord? practising = _system.StateFor(player.SteamID).Practising; + + IReadOnlyList library = + practising != null ? new[] { practising } : _library.For(player.SteamID); if (library.Count == 0) { @@ -797,10 +803,14 @@ private void SpotWatch() // chosen for them. LineupRecord? drilling = _drill.Current(player.SteamID); - if (drilling != null) + // Practising one lineup is the same ask: that throw and nothing + // else, wherever they walk. + LineupRecord? only = drilling ?? _system.StateFor(player.SteamID).Practising; + + if (only != null) { - show = new List { drilling }; - aimedAt = drilling; + show = new List { only }; + aimedAt = only; } string key = string.Join( From 7ba812211bfcabf8ab151bc4f1f5a298ba7e53b4 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Tue, 6 Oct 2026 23:57:25 +0000 Subject: [PATCH 04/14] bug: nade practice clear, rethrow, bots and flash feedback Brings over #179 onto the current plugin: - .clear holds until the player asks for something again, so the spot watcher no longer redraws the markers a quarter second later, and it also clears the smokes, fires and grenades in the world. - The ping key clears thrown utility, silently when there was none. - .rethrow throws the loaded lineup (or your last throw) again from where it was thrown without moving you, and says why when it cannot. - Bots killed or added mid-round are respawned and stood back on their spots. - Flash feedback for players as well as bots: how long it would have had you, and the thrower hears it for the team-mate they flashed. Merged with the practise-one-lineup focus: loading still clears the hold, and .clear still drops the focus. --- apps/utility-sw/src/Commands/Practice.cs | 112 +++++++++++++++--- apps/utility-sw/src/Events/PracticePlayer.cs | 56 +++++++-- .../utility-sw/src/Services/PracticeReplay.cs | 66 ++++++++++- .../utility-sw/src/Services/PracticeSystem.cs | 7 ++ apps/utility-sw/src/UtilityPracticePlugin.cs | 107 ++++++++++++++++- 5 files changed, 317 insertions(+), 31 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 7376fd5b..bf1444c1 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -144,6 +144,7 @@ private void SaveThrow(ulong steamId, string name) // becomes the loaded one and gets its markers straight away. PracticeState saved = _system.StateFor(steamId); + saved.Cleared = false; saved.Loaded = thrown; saved.Practising = null; saved.Results.Clear(); @@ -318,6 +319,11 @@ public void OnPrev(ICommandContext context) Step(context, -1); } + // sv_rethrow_last_grenade, which is what the word means everywhere else: + // the grenade goes again from where it was thrown and the player stays put, + // so they can stand off to the side and watch the line they cannot see + // while making it. .load, .last and .back are the commands that move you -- + // this one moving you as well is what left it with nothing of its own to do. [Command("rethrow", registerRaw: false, permission: "")] public void OnRethrow(ICommandContext context) { @@ -327,17 +333,66 @@ public void OnRethrow(ICommandContext context) { return; } - _logger.LogInformation("[nade-render] OnRethrow for {steam}", player.SteamID); - LineupRecord? loaded = _system.StateFor(player.SteamID).Loaded; + if (!_config.ReplayEnabled) + { + Reply(context, $" {ChatColors.Red}replay is disabled on this server"); + return; + } - if (loaded == null) + ulong steamId = player.SteamID; + + // The loaded lineup first, the player's own last throw second: after a + // .load the reference throw is the one worth seeing again, and with + // nothing loaded "rethrow" can only mean the grenade they just threw. + // Answering that one with "nothing loaded" is most of why this read as + // a command that did not work. + LineupRecord? lineup = _system.StateFor(steamId).Loaded ?? _recorder.LastThrow(steamId); + + if (lineup == null) + { + Reply( + context, + $" {ChatColors.Red}throw something first, or {ChatColors.Default}.load" + + $" {ChatColors.Red}a lineup" + ); + + return; + } + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + Reply(context, $" {ChatColors.Red}you have to be alive to rethrow"); + return; + } + + // A lineup fitted to a demo has a path but no seed, so there is nothing + // to hand the engine. Said out loud rather than logged: a command that + // silently does nothing is the bug being fixed here. + if (!lineup.IsExactlyReplayable()) { - Reply(context, $" {ChatColors.Red}nothing loaded"); + Reply( + context, + $" {ChatColors.Yellow}{DrillUtility.Name(lineup)} {ChatColors.Grey}has no recorded" + + $" throw to replay -- {ChatColors.Default}.load{ChatColors.Grey} it and" + + $" throw it yourself" + ); + return; } - Apply(player, loaded); + _logger.LogInformation("[nade-render] OnRethrow for {steam}", steamId); + + // Forced: np_ghost_projectile decides whether .load and .next throw as + // they go, but a player who typed the word has already asked. + _replay.ThrowGhostProjectile(player, lineup, force: true); + + Reply( + context, + $" {ChatColors.Green}rethrown {ChatColors.Default}{DrillUtility.Name(lineup)}" + ); } [Command("last", registerRaw: false, permission: "")] @@ -448,17 +503,36 @@ public void OnClear(ICommandContext context) state.Index = -1; state.Bloom = false; + // Held until the next .load, .next, walk-on or save. Sweeping alone + // left the spot watcher free to redraw the spot under their feet on + // its next pass -- a quarter of a second, or the first time they + // glanced at another ring -- which is why .clear read as broken. + state.Cleared = true; + _replay.ClearGhosts(player.SteamID); + // What everybody else's .clear means, and what a player standing in + // their own smoke is asking for. The ping key does this on its own -- + // this is the same call, for anybody who types it. + int thrown = _replay.ClearThrownUtility(); + // Swept rather than cleared: ClearMarkers can only despawn what this // instance still has a handle to, and anything a previous load left // behind is exactly what makes .clear look like it did nothing. - // _standingIn is deliberately NOT reset -- the spot the player is - // stood on stays cleared until they step off it and back on, instead - // of redrawing itself a second later. _replay.SweepMarkers(); - Reply(context, $" {ChatColors.Green}cleared"); + // Safe to drop now the watcher is held off: nothing can match against + // it, so the first redraw after they ask again is decided by where + // they are then rather than by where they were when they cleared. + ForgetSpot(player.SteamID); + + Reply( + context, + thrown > 0 + ? $" {ChatColors.Green}cleared {ChatColors.Grey}the preview and the utility" + + $" in the world" + : $" {ChatColors.Green}cleared" + ); } [Command("bloom", registerRaw: false, permission: "")] @@ -927,6 +1001,7 @@ public void OnCrosshair(ICommandContext context) // Redrawn rather than left until the player steps off the spot and back // on: a toggle that appears to do nothing gets pressed again. _replay.ClearMarkers(); + ForgetSpot(player.SteamID); Reply( context, @@ -1116,6 +1191,7 @@ public void OnReload(ICommandContext context) _replay.ClearGhosts(steamId); _replay.ClearMarkers(); + ForgetSpot(steamId); _library.Refresh( steamId, @@ -1413,7 +1489,8 @@ public void OnRefresh(ICommandContext context) $" {ChatColors.Green}utility practice {ChatColors.Grey}-- infinite utility, buy anywhere", $" {ChatColors.Default}.save {ChatColors.Grey}saves the throw you just made", $" {ChatColors.Default}.load {ChatColors.Grey}stands you on a saved lineup", - $" {ChatColors.Default}.rethrow {ChatColors.Grey}back to the loaded lineup", + $" {ChatColors.Default}.rethrow {ChatColors.Grey}throws it again from where it was thrown", + $" {ChatColors.Grey}your {ChatColors.Default}ping key {ChatColors.Grey}clears smokes and fires", $" {ChatColors.Default}.help {ChatColors.Grey}everything else", }; @@ -1424,7 +1501,7 @@ public void OnRefresh(ICommandContext context) $" {ChatColors.Default}.load {ChatColors.Grey}teleports you to a lineup", $" {ChatColors.Default}.next / .prev {ChatColors.Grey}walk the last search", $" {ChatColors.Default}.jump {ChatColors.Grey}stand where the loaded lineup lands", - $" {ChatColors.Default}.rethrow {ChatColors.Grey}back to the loaded lineup", + $" {ChatColors.Default}.rethrow {ChatColors.Grey}throws the loaded lineup again, without moving you", $" {ChatColors.Default}.all {ChatColors.Grey}show every lineup again after loading one", $" {ChatColors.Default}.last / .back {ChatColors.Grey}back to a throw you made", $" {ChatColors.Default}.map / .here {ChatColors.Grey}pick off the minimap, or only what you can throw from here", @@ -1443,7 +1520,9 @@ public void OnRefresh(ICommandContext context) $" {ChatColors.Default}.colors {ChatColors.Grey}a colour per throw, smoke and trail", $" {ChatColors.Default}.crosshair {ChatColors.Grey}hide the aim marker and throw it blind", $" {ChatColors.Default}.spawns / .spawn next {ChatColors.Grey}where rounds start from", - $" {ChatColors.Default}.noclip / .god / .timer / .solo / .clear", + $" {ChatColors.Default}.clear {ChatColors.Grey}or your {ChatColors.Default}ping key" + + $" {ChatColors.Grey}-- smokes, fires and the preview", + $" {ChatColors.Default}.noclip / .god / .timer / .solo", }; private void StartPlaybook(IPlayer player, ICommandContext context) @@ -1599,6 +1678,9 @@ private void Apply(IPlayer player, LineupRecord lineup) PracticeState state = _system.StateFor(player.SteamID); LineupRecord? was = state.Practising; + // Asking for a lineup is asking to see it: whatever .clear held off + // starts drawing again from here. + state.Cleared = false; state.Loaded = lineup; // Loading one lineup is asking to practise THAT one, so the rest of @@ -1641,12 +1723,6 @@ private void Apply(IPlayer player, LineupRecord lineup) return; } - IPlayer? still = _system.Find(steamId); - - if (still != null && still.IsValid) - { - } - DrawBloom(steamId, fetched); } ); diff --git a/apps/utility-sw/src/Events/PracticePlayer.cs b/apps/utility-sw/src/Events/PracticePlayer.cs index 78361d2f..254be062 100644 --- a/apps/utility-sw/src/Events/PracticePlayer.cs +++ b/apps/utility-sw/src/Events/PracticePlayer.cs @@ -46,6 +46,31 @@ public HookResult OnPlayerJoinTeam(EventPlayerTeam @event) return HookResult.Continue; } + // The ping key, because that is where every other practice server put it + // and because clearing is the one thing you want BETWEEN two attempts -- + // the moment chat is worst at. The smoke in the way is in front of you now, + // not after you have typed six characters into it. + [GameEventHandler(HookMode.Post)] + public HookResult OnPlayerPing(EventPlayerPing @event) + { + IPlayer? player = @event.UserIdPlayer; + + if (player == null || !player.IsValid || player.IsFakeClient) + { + return HookResult.Continue; + } + + int cleared = _replay.ClearThrownUtility(); + + // Silent when there was nothing to clear: a ping is also just a ping. + if (cleared > 0) + { + Tell(player.SteamID, $" {ChatColors.Green}cleared {ChatColors.Grey}the utility in the world"); + } + + return HookResult.Continue; + } + [GameEventHandler(HookMode.Post)] public HookResult OnPlayerBlind(EventPlayerBlind @event) { @@ -56,12 +81,25 @@ public HookResult OnPlayerBlind(EventPlayerBlind @event) IPlayer? blinded = @event.UserIdPlayer; IPlayer? thrower = @event.AttackerPlayer; - // Only a bot's blindness is worth reporting. A player who flashed - // themselves already knows, and one who flashed a team-mate is told by - // the team-mate. - if (blinded != null && blinded.IsValid && blinded.IsFakeClient && duration > 0f) + if (duration > 0f && blinded != null && blinded.IsValid) { - ReportFlash(thrower, duration); + if (blinded.IsFakeClient) + { + ReportFlash(thrower, duration, "a bot"); + } + else + { + // NoFlash below zeroes the blindness, so the screen never goes + // white and the number IS the feedback: "how long would that + // have had me" is the whole question a flash lineup asks, and + // the server was answering it for the bot only. + ReportFlash(blinded, duration, "you"); + + if (thrower != null && thrower.IsValid && thrower.SteamID != blinded.SteamID) + { + ReportFlash(thrower, duration, blinded.Controller.PlayerName); + } + } } if (!_config.NoFlash) @@ -139,16 +177,16 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event) private const int BotFullHealth = 100; - private void ReportFlash(IPlayer? thrower, float duration) + private void ReportFlash(IPlayer? told, float duration, string who) { - if (thrower == null || !thrower.IsValid || thrower.IsFakeClient) + if (told == null || !told.IsValid || told.IsFakeClient) { return; } Tell( - thrower.SteamID, - $" {ChatColors.Default}{duration:0.00}s {ChatColors.Grey}of flash on a bot" + told.SteamID, + $" {ChatColors.Default}{duration:0.00}s {ChatColors.Grey}of flash on {who}" ); } diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index bde3693d..a4775499 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -875,12 +875,16 @@ public void ClearBloomSmoke(ulong steamId) // Tier 2: a real grenade, launched from the physics seed the engine gave // us at record time rather than from the player's eye angles, so it lands // where the recorded one did instead of near it. - public void ThrowGhostProjectile(IPlayer player, LineupRecord lineup) + // force is .rethrow asking by name. The switch below is what a render pod + // sets to have every load throw itself; a player typing the command has + // already said which throw they want, so it is not the switch's to refuse. + public void ThrowGhostProjectile(IPlayer player, LineupRecord lineup, bool force = false) { _logger.LogInformation( - "[nade-render] ThrowGhostProjectile: emitGrenades={emit} ghostProjectile={ghost} exactlyReplayable={exact} hasSeed={seed} confidence={conf}", + "[nade-render] ThrowGhostProjectile: emitGrenades={emit} ghostProjectile={ghost} forced={force} exactlyReplayable={exact} hasSeed={seed} confidence={conf}", EmitGrenades, _config.GhostProjectile, + force, lineup.IsExactlyReplayable(), lineup.HasPhysicsSeed(), lineup.confidence @@ -892,7 +896,7 @@ public void ThrowGhostProjectile(IPlayer player, LineupRecord lineup) return; } - if (!_config.GhostProjectile) + if (!_config.GhostProjectile && !force) { _logger.LogInformation("[nade-render] skip: GhostProjectile off"); return; @@ -2271,6 +2275,62 @@ private static CEntityKeyValues Tagged() return keys; } + // Live utility, as opposed to the preview drawn over it. Smokes and fires + // are what stands between two attempts at the same lineup, and anything + // still in the air goes with them: a rethrow answered by the grenade you + // are trying to replace is not an answer. + private static readonly string[] ThrownUtilityClasses = + { + "smokegrenade_projectile", + "flashbang_projectile", + "hegrenade_projectile", + "molotov_projectile", + "decoy_projectile", + "inferno", + }; + + public int ClearThrownUtility() + { + int cleared = 0; + + foreach (string designer in ThrownUtilityClasses) + { + try + { + foreach ( + CBaseEntity entity in _core.EntitySystem.GetAllEntitiesByDesignerName( + designer + ) + ) + { + if (!entity.IsValid) + { + continue; + } + + // Taken out of the world by hand, so nothing will ever + // report its detonation: the bookkeeping goes with it or it + // sits in the table until the reaper times it out. + _ghostThrows.Remove(entity.Index); + + entity.Despawn(); + cleared += 1; + } + } + catch (Exception error) + { + _logger.LogWarning(error, "unable to clear {designer}", designer); + } + } + + // The bloom preview is a real smoke, so it went with the rest. Its + // handles have to go too: a dead one can be recycled into a new entity, + // and despawning THAT is worse than leaving the entry behind. + _bloomSmoke.Clear(); + + return cleared; + } + // Despawns every marker in the world, ours or a previous instance's, then // forgets the handles. Safe to call when there is nothing to find. public int SweepMarkers() diff --git a/apps/utility-sw/src/Services/PracticeSystem.cs b/apps/utility-sw/src/Services/PracticeSystem.cs index 338659fb..636cd0b2 100644 --- a/apps/utility-sw/src/Services/PracticeSystem.cs +++ b/apps/utility-sw/src/Services/PracticeSystem.cs @@ -64,6 +64,13 @@ public class PracticeState // has not asked for it should not be paying for it. public bool Bloom { get; set; } + // Set by .clear and held until the player asks for something again. The + // spot watcher redraws whatever is under their feet four times a second + // and re-adopts the lineup they are looking at, so without a flag saying + // "they asked for nothing" it put the markers back inside a quarter of a + // second -- which is .clear appearing to do nothing at all. + public bool Cleared { get; set; } + // Lineups this player has already been told are not exact. Said once per // lineup: a warning repeated on every .rethrow is a warning nobody reads. diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 9ff8e7b3..5b1797c4 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -527,6 +527,14 @@ private static float ToleranceFor(LineupRecord lineup) // light up everything throwable from it without redrawing every tick. private readonly Dictionary _standingIn = new(); + // What the watcher believes is drawn for a player. Anything that wipes the + // world without moving them has to say so here: left stale, the key matches + // on the next pass and suppresses the redraw that was supposed to follow. + private void ForgetSpot(ulong steamId) + { + _standingIn.Remove(steamId); + } + // IN_USE. Read every tick rather than on the 4Hz spot sweep because a tap // is shorter than a quarter of a second and a walk-up that does nothing is // worse than not offering it. @@ -607,6 +615,82 @@ private void PlaceBots() return; } + // A bot added to a live round never gets a spawn of its own: nothing + // here ends a round (mp_ignore_round_win_conditions), so the second + // .bot only ever added a dead name to the scoreboard. The same call + // brings back one a practice HE killed outright. + // + // Placed a beat later when anything was revived: a respawn has no pawn + // until the engine has run a tick, and the spots are handed out in + // order -- standing up only the bots that are already alive would give + // one of them the spot belonging to the bot still on its way back. + if (ReviveBots()) + { + Core.Scheduler.DelayBySeconds(BotPlaceDelaySeconds, StandBotsOnSpots); + + return; + } + + StandBotsOnSpots(); + } + + // A bot on no team cannot be spawned, and asking would put the engine in + // the position of choosing one -- which is how a bot ends up playing the + // round it was placed to stand still through. + private bool ReviveBots() + { + bool revived = false; + + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || !player.IsFakeClient || player.IsAlive) + { + continue; + } + + if (player.Controller.Team is not (Team.CT or Team.T)) + { + continue; + } + + player.Respawn(); + revived = true; + } + + return revived; + } + + // Nothing here brings a bot back on its own -- see PlaceBots -- so a dead + // one is a target that has quietly stopped being one. Checked rather than + // run blind: with every bot up this is one loop and no teleports. + private void KeepBotsStanding() + { + if (_bots.Count == 0) + { + return; + } + + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + // Only one that ReviveBots can bring back: a dead bot on no team + // would otherwise re-teleport every live bot on every tick. + if ( + player != null + && player.IsValid + && player.IsFakeClient + && !player.IsAlive + && player.Controller.Team is Team.CT or Team.T + ) + { + PlaceBots(); + + return; + } + } + } + + private void StandBotsOnSpots() + { int index = 0; foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) @@ -732,7 +816,11 @@ private void StandOnNearest(IPlayer player, CCSPlayerPawn pawn) if (_replay.StandOn(player, target)) { - _system.StateFor(player.SteamID).Loaded = target; + PracticeState state = _system.StateFor(player.SteamID); + + // Walking onto a spot on purpose is asking to see it again. + state.Cleared = false; + state.Loaded = target; } } @@ -774,6 +862,14 @@ private void SpotWatch() continue; } + // .clear asked for an empty world, and this is the loop that would + // otherwise hand it straight back: both the markers and the + // Loaded assignment below. + if (_system.StateFor(player.SteamID).Cleared) + { + continue; + } + Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); var at = new Vec3(origin.X, origin.Y, origin.Z); @@ -1505,6 +1601,7 @@ private void OnSecond() _session.RetryIfMissing(TimeSpan.FromSeconds(15)); EndWarmup(); RespawnTheDead(); + KeepBotsStanding(); KeepEveryoneStocked(); ReportOccupancy(); _system.Tick(); @@ -1941,6 +2038,14 @@ private void ShowLibraryFor(ulong steamId, IReadOnlyList library) return; } + // A refresh nobody asked for does not get to undo .clear: the drain + // runs on its own schedule, and the panel pushing an edit is not the + // player asking for their markers back. + if (_system.StateFor(steamId).Cleared) + { + return; + } + _replay.ShowLibrary(library); } From bb286b7ffc3ba2a52c74df5a8101c5c6a20b1bb0 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 7 Oct 2026 00:07:40 +0000 Subject: [PATCH 05/14] bug: draw every connected player's lineups in the shared marker layer, and send each player only theirs --- apps/utility-sw/src/Commands/Practice.cs | 5 + .../src/Services/PracticeLibrary.cs | 17 ++ .../utility-sw/src/Services/PracticeReplay.cs | 145 ++++++++++++++++-- .../utility-sw/src/Services/PracticeSystem.cs | 6 + apps/utility-sw/src/UtilityPracticePlugin.cs | 84 ++++++++-- 5 files changed, 229 insertions(+), 28 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index bf1444c1..b9979f17 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -508,6 +508,7 @@ public void OnClear(ICommandContext context) // its next pass -- a quarter of a second, or the first time they // glanced at another ring -- which is why .clear read as broken. state.Cleared = true; + _replay.ApplyLibraryVisibility(player); _replay.ClearGhosts(player.SteamID); @@ -1142,6 +1143,10 @@ public void OnDelete(ICommandContext context) _replay.ClearGhosts(player.SteamID); + // Gone from their library, so gone from the map -- not left standing + // until somebody else's refresh happens to rebuild the layer. + RedrawLibrary(); + Reply(context, $" {ChatColors.Green}deleted {ChatColors.Default}{loaded.name}"); if (loaded.id == null) diff --git a/apps/utility-sw/src/Services/PracticeLibrary.cs b/apps/utility-sw/src/Services/PracticeLibrary.cs index 8cc6ee3a..dd9a7159 100644 --- a/apps/utility-sw/src/Services/PracticeLibrary.cs +++ b/apps/utility-sw/src/Services/PracticeLibrary.cs @@ -49,6 +49,12 @@ ILogger logger public string Map => _map; + // Raised on the game thread whenever a fetch replaces a player's library, + // before anyone waiting on that fetch is answered. Every refresh lands + // here -- the drain's, .reload's, a load pushed from the website -- so the + // markers follow the library whichever of them asked. + public event Action? Replaced; + public void SetMap(string map) { if (_map == map) @@ -213,6 +219,17 @@ private void Landed( { _lineups[steamId] = lineups; count = lineups.Count; + + try + { + Replaced?.Invoke(steamId); + } + catch (Exception error) + { + // Same reason as the callbacks below: nothing thrown here may + // reach native code, or keep the waiting callers unanswered. + _logger.LogError(error, "a library replaced handler threw"); + } } foreach (Action waiting in fetch.Waiting) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index a4775499..d30bdf17 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -190,6 +190,12 @@ private static TraceParams SkipMarkers() private readonly List _markerTexts = new(); private readonly List _markerProps = new(); + // Which lineups each library entity stands for, by entity index. A needle + // and a diamond belong to one lineup; a floating grenade belongs to every + // lineup of its type thrown from that spot. This is what lets one shared + // set of entities show each player only the lineups the panel gave THEM. + private readonly Dictionary _markerLineups = new(); + // The selection layer: the crosshair and labels for whichever lineup ONE // player has focused. Kept per player and transmit-blocked from everybody // else, because two people practising at once were otherwise wiping each @@ -413,6 +419,15 @@ private static Color Faded(Color color, float visibility) public Func> All { get; set; } = _ => Array.Empty(); + // What the library layer draws: every connected player's library merged + // into one set, because the layer is one set of entities for the whole + // server. Drawing the CALLER's library there replaced everybody else's + // markers with theirs on every .load, and put their private lineups in + // front of the room. Who may actually see each lineup is decided per + // viewer afterwards, in ApplyLibraryVisibility. + public Func> Shared { get; set; } = + () => Array.Empty(); + // A capture client watching a lineup wants the throw and not the plugin's // drawing of it: beams in frame are our overlay filmed instead of the map. @@ -572,6 +587,10 @@ public void Load(IPlayer player, LineupRecord lineup) IReadOnlyList library = everything.Count > 0 ? everything : new[] { lineup }; + // The layer everyone shares, not this player's slice of it: their + // library decides only what their own selection offers below. + IReadOnlyList shared = Shared(); + // Whatever else is throwable from this spot comes up with it -- // unless the player is practising this one, where a sibling // crosshair is exactly the "which one am I on" they loaded it to @@ -585,7 +604,7 @@ public void Load(IPlayer player, LineupRecord lineup) here.Add(lineup); } - ShowLibrary(library); + ShowLibrary(shared.Count > 0 ? shared : library); ShowSelection(player, here, standing); }); @@ -1290,7 +1309,9 @@ public void ShowMarkersFor(IPlayer player, LineupRecord lineup) here.Add(lineup); } - ShowLibrary(library); + IReadOnlyList shared = Shared(); + + ShowLibrary(shared.Count > 0 ? shared : library); ShowSelection(player, here, standing); } @@ -1384,6 +1405,11 @@ public void ShowLibrary(IEnumerable lineups) if (signature == _librarySignature && _markerBeams.Count > 0) { + // Nothing to rebuild, but who may see what can still have moved: + // a lineup made public arrives in somebody else's library without + // changing a single marker. + ApplyLibraryVisibility(); + return; } @@ -1424,8 +1450,12 @@ public void ShowLibrary(IEnumerable lineups) // No name on the ground and no post: the chevron says which way, // the glowing model says what and where, and the name arrives in // centre text when the player points at the grenade. + int before = _markerBeams.Count; + Needle(feet, lineup.release.yaw, 13f, needle, MarkerWidth); Diamond(lineup.detonation_position, 22f, type, MarkerWidth); + + TagLineups(_markerBeams.Skip(before), new[] { lineup.client_id }); } ShowSpotUtility(drawn); @@ -1441,18 +1471,24 @@ public void ShowLibrary(IEnumerable lineups) /// view leaves the rest of the server seeing the whole map. Their own /// selection is a separate set of entities and is never touched here. /// + /// The layer is everybody's libraries at once, so each lineup is also + /// hidden from anyone the panel did not give it to: a private lineup is + /// seen by its owner and nobody else. A lineup in nobody's library -- one + /// loaded straight off the website, say -- has no owner to protect and + /// stays visible, as it always was. + /// /// Not while an execute is running: that already narrows the library to /// its own throws, and hiding those would hide the execute. /// public void ApplyLibraryVisibility(IPlayer? only = null) { - var indexes = new List(); + var entities = new List<(int index, string[]? lineups)>(); foreach (CEnvBeam beam in _markerBeams) { if (beam.IsValid) { - indexes.Add((int)beam.Index); + entities.Add(Tracked(beam.Index)); } } @@ -1460,7 +1496,7 @@ public void ApplyLibraryVisibility(IPlayer? only = null) { if (label.IsValid) { - indexes.Add((int)label.Index); + entities.Add(Tracked(label.Index)); } } @@ -1468,29 +1504,66 @@ public void ApplyLibraryVisibility(IPlayer? only = null) { if (prop.IsValid) { - indexes.Add((int)prop.Index); + entities.Add(Tracked(prop.Index)); + } + } + + var humans = new List(); + + foreach (IPlayer player in _core.PlayerManager.GetAllPlayers()) + { + if (player != null && player.IsValid && !player.IsFakeClient) + { + humans.Add(player); } } - IEnumerable players = - only != null ? new[] { only } : _core.PlayerManager.GetAllPlayers(); + // Read once per pass rather than once per entity: a busy map is over a + // thousand entities, and the library behind each player does not move + // while this runs. + var libraries = new Dictionary>(); + var owned = new HashSet(); - foreach (IPlayer player in players) + foreach (IPlayer human in humans) { - if (player == null || !player.IsValid || player.IsFakeClient) + var mine = new HashSet(All(human.SteamID).Select(lineup => lineup.client_id)); + + libraries[human.SteamID] = mine; + owned.UnionWith(mine); + } + + bool restricted = LibraryRestriction != null; + + foreach (IPlayer player in humans) + { + if (only != null && player.SteamID != only.SteamID) { continue; } - bool hide = LibraryRestriction == null && LibraryHidden(player.SteamID); + bool hideAll = !restricted && LibraryHidden(player.SteamID); + HashSet mine = libraries[player.SteamID]; - foreach (int index in indexes) + foreach ((int index, string[]? lineups) in entities) { + bool hide = + hideAll + || ( + !restricted + && lineups != null + && !lineups.Any(id => mine.Contains(id) || !owned.Contains(id)) + ); + player.ShouldBlockTransmitEntity(index, hide); } } } + private (int index, string[]? lineups) Tracked(uint index) + { + return ((int)index, _markerLineups.TryGetValue(index, out string[]? lineups) ? lineups : null); + } + // What to bring, not which throw to make. A model belongs to the SPOT: two // smokes thrown from one position want ONE smoke floating over it, or the // spot reads as six grenades rather than one place to stand. A spot holding @@ -1524,11 +1597,56 @@ private void ShowSpotUtility(IReadOnlyList lineups) // is the one that does not say which throw it is. Color? glow = StepGlowAt(lineups, spot.types[index], spot.x, spot.y, spot.z); + int before = _markerProps.Count; + UtilityModel( spot.types[index], new Vec3(spot.x + offset, spot.y, spot.z), glow ); + + TagLineups( + _markerProps.Skip(before), + LineupsAt(lineups, spot.types[index], spot.x, spot.y, spot.z) + ); + } + } + } + + // The same "is this lineup part of that spot" test StepGlowAt makes, so a + // grenade is shown to whoever can see any one of the throws it stands for. + private string[] LineupsAt( + IReadOnlyList lineups, + string utilityType, + float x, + float y, + float z + ) + { + return lineups + .Where(lineup => + { + if (lineup.utility_type != utilityType) + { + return false; + } + + Vec3 feet = Grounded(lineup.release.feet_position); + + return new Vec3(feet.x - x, feet.y - y, 0f).LengthXY() <= SpotRadius + && Math.Abs(feet.z - z) <= SpotHeight; + }) + .Select(lineup => lineup.client_id) + .ToArray(); + } + + private void TagLineups(IEnumerable entities, string[] lineups) + { + foreach (CEntityInstance entity in entities) + { + if (entity.IsValid) + { + _markerLineups[entity.Index] = lineups; } } } @@ -2675,6 +2793,7 @@ public void ForgetMarkers() _markerBeams.Clear(); _markerTexts.Clear(); _markerProps.Clear(); + _markerLineups.Clear(); _selections.Clear(); _drawingInto = null; } @@ -2710,6 +2829,7 @@ private void ClearSharedMarkers() _markerBeams.Clear(); _markerTexts.Clear(); _markerProps.Clear(); + _markerLineups.Clear(); } public void ClearMarkers() @@ -2748,6 +2868,7 @@ public void ClearMarkers() _markerBeams.Clear(); _markerTexts.Clear(); _markerProps.Clear(); + _markerLineups.Clear(); } // A NaN or an infinity reaching Teleport takes the whole server down inside diff --git a/apps/utility-sw/src/Services/PracticeSystem.cs b/apps/utility-sw/src/Services/PracticeSystem.cs index 636cd0b2..865d1de8 100644 --- a/apps/utility-sw/src/Services/PracticeSystem.cs +++ b/apps/utility-sw/src/Services/PracticeSystem.cs @@ -140,6 +140,12 @@ public bool IsPractising(ulong steamId) return _states.TryGetValue(steamId, out PracticeState? state) && state.Practising != null; } + // Same shape again: .clear holds until the player asks for something. + public bool IsCleared(ulong steamId) + { + return _states.TryGetValue(steamId, out PracticeState? state) && state.Cleared; + } + public void Forget(ulong steamId) { _states.Remove(steamId); diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 5b1797c4..62050ab7 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -143,8 +143,15 @@ public override void Load(bool hotReload) _replay.IsSolo = _system.IsSolo; _replay.AnnouncesLoad = steamId => !UseHud(steamId); - _replay.LibraryHidden = _system.IsPractising; + // .clear hides the library the same way practising one lineup does: + // from the player who asked, and nobody else. Hidden rather than + // swept, so no redraw -- a save, a refresh, a round restart -- can hand + // it back to them, and nobody else loses theirs. + _replay.LibraryHidden = steamId => + _system.IsPractising(steamId) || _system.IsCleared(steamId); _replay.All = steamId => _library.For(steamId); + _replay.Shared = SharedLibrary; + _library.Replaced += OnLibraryReplaced; // A solve rains live HE and molotovs on a map people are standing in. _system.SolveRunning = () => _solver.IsBusy; _session.Refreshed += OnSessionRefreshed; @@ -237,6 +244,11 @@ public override void Load(bool hotReload) _occupancyDirty = true; _prompt?.Cancel(@event.PlayerId); + // Their lineups leave the shared layer with them. A tick late, for + // the same reason the occupancy is: the leaving player is still on + // the roster while this runs. + Core.Scheduler.NextTick(() => RedrawLibrary()); + ForPlayer( @event.PlayerId, steamId => @@ -395,6 +407,7 @@ private void TearDown() _replay.SweepMarkers(); _session.Refreshed -= OnSessionRefreshed; + _library.Replaced -= OnLibraryReplaced; _recorder.Thrown -= _system.OnThrown; _recorder.Finalized -= _score.OnFinalized; _recorder.Ended -= _replay.TrailEnded; @@ -821,6 +834,7 @@ private void StandOnNearest(IPlayer player, CCSPlayerPawn pawn) // Walking onto a spot on purpose is asking to see it again. state.Cleared = false; state.Loaded = target; + _replay.ApplyLibraryVisibility(player); } } @@ -1945,6 +1959,17 @@ private void RefreshAndShow(ulong steamId, bool pushed) load.FetchingSince = now; + // A player who has only just arrived is sent every marker on the map + // until somebody says otherwise, and their library is still a round + // trip away. With nothing of their own yet they see only what nobody + // owns, rather than everybody's private lineups for a second or two. + IPlayer? player = _system.Find(steamId); + + if (player != null) + { + _replay.ApplyLibraryVisibility(player); + } + _library.Refresh(steamId, count => LibraryLanded(steamId, map, count, pushed)); } @@ -1986,8 +2011,6 @@ private void LibraryLanded(ulong steamId, string map, int count, bool pushed) { IReadOnlyList library = _library.For(steamId); - ShowLibraryFor(steamId, library); - // .next and .prev walk state.Results, and a refresh never filled it // -- so every lineup on the map was drawn and none of them could be // stepped through until the player ran a search. If they can SEE @@ -2025,28 +2048,57 @@ private void LibraryLanded(ulong steamId, string map, int count, bool pushed) // Markers are one shared set of entities for the whole server (see // PracticeReplay.ShowLibrary) while a library is filtered per player by the - // panel, so drawing one on somebody's behalf only holds while there is - // nobody else it could be shown to. With company it drew whoever's fetch - // landed last, which put one player's private lineups in front of everyone. - // Their own .load, .next and .menu still draw, because those were asked for. - private void ShowLibraryFor(ulong steamId, IReadOnlyList library) + // panel. Drawing whoever's fetch landed last put one player's private + // lineups in front of everyone, and refusing to draw with company left a + // full server with markers that never followed a save or an edit. So the + // layer draws everybody's at once and each player is sent only the + // lineups that are theirs to see -- see ApplyLibraryVisibility. + private void OnLibraryReplaced(ulong steamId) { - List connected = _system.ConnectedSteamIds(); + RedrawLibrary(); + } - if (connected.Count != 1 || connected[0] != steamId) + // Rebuilds only when what it would draw has changed, so this is cheap to + // call from anything that might have moved the library. .clear is not + // undone by it: that hides the layer from the player who asked, rather + // than taking it away from the room. + private void RedrawLibrary() + { + if (_tornDown) { return; } - // A refresh nobody asked for does not get to undo .clear: the drain - // runs on its own schedule, and the panel pushing an edit is not the - // player asking for their markers back. - if (_system.StateFor(steamId).Cleared) + _replay.ShowLibrary(SharedLibrary()); + } + + // Every connected player's library, one lineup per client id. Taken in + // turns rather than one library after another: the draw is capped, and a + // cap that ran out halfway through the first player's library would show + // the second player none of theirs. + private IReadOnlyList SharedLibrary() + { + List> libraries = _system + .ConnectedSteamIds() + .Select(steamId => _library.For(steamId)) + .ToList(); + + var seen = new HashSet(); + var shared = new List(); + int longest = libraries.Count == 0 ? 0 : libraries.Max(library => library.Count); + + for (int index = 0; index < longest; index++) { - return; + foreach (IReadOnlyList library in libraries) + { + if (index < library.Count && seen.Add(library[index].client_id)) + { + shared.Add(library[index]); + } + } } - _replay.ShowLibrary(library); + return shared; } private LibraryLoad LibraryLoadFor(ulong steamId) From e7cbfbfd57d10b863e11d376f812c446c29e5a6c Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 7 Oct 2026 00:08:13 +0000 Subject: [PATCH 06/14] bug: redraw markers and selections after a round restart, and drop stale handles instead of despawning them --- .../utility-sw/src/Services/PracticeReplay.cs | 14 ++++++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index d30bdf17..24ea48b5 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -2798,6 +2798,20 @@ public void ForgetMarkers() _drawingInto = null; } + // Every handle this instance holds, markers and ghosts and previews alike, + // dropped without touching the world. For the moments the world was + // emptied under us -- a round restart's map cleanup, a map change -- when + // each handle may already be recycled into an entity that is somebody + // else's, and despawning it would take THAT out instead. + public void ForgetEverything() + { + _ghosts.Clear(); + _trails.Clear(); + _ghostThrows.Clear(); + _bloomSmoke.Clear(); + ForgetMarkers(); + } + // The library layer only. The selection layer belongs to individual players // and outlives a library redraw. private void ClearSharedMarkers() diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 62050ab7..c9ad7a82 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -221,6 +221,11 @@ public override void Load(bool hotReload) { KeepRoundsMoving(); + // A new round is a map cleanup, and the cleanup takes every beam, + // label and grenade model with it. Nothing else redraws them: the + // library is only rebuilt when it changes, and it has not. + Core.Scheduler.NextTick(() => OnRoundRestarted()); + return HookResult.Continue; }); @@ -1898,6 +1903,10 @@ private void ForPlayer(int playerId, Action action) private void OnMapLoad(string mapName) { + // First: the resets below despawn whatever they still hold a handle + // to, and every one of those handles died with the old map. + _replay.ForgetEverything(); + _hud?.Reset(); _menus.Clear(); _recorder.Reset(); @@ -1925,6 +1934,29 @@ private void OnMapLoad(string mapName) RefreshEverything(); } + // Warmup ending, mp_restartgame and a round running out all come through + // here, a tick after round_start so the cleanup is certainly behind us. + // Swept by tag first, in case anything of ours did survive it, then every + // handle is dropped -- what is left of them may point at recycled + // entities -- and the shared layer and each player's selection are drawn + // again from scratch. + private void OnRoundRestarted() + { + if (_tornDown) + { + return; + } + + _replay.SweepMarkers(); + _replay.ForgetEverything(); + + // The spot watcher only redraws a selection when the player moves to a + // different spot. Forgotten, it redraws each one on its next pass. + _standingIn.Clear(); + + RedrawLibrary(); + } + // The panel is the only source of both the roster and the library, so a // refresh is one round trip followed by one per connected player. // From 85b895e1b7c9416cefee51cfb4c6fcce1307a6e1 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 7 Oct 2026 00:08:23 +0000 Subject: [PATCH 07/14] bug: keep .clear, .crosshair and .reload to the caller's own markers --- apps/utility-sw/src/Commands/Practice.cs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index b9979f17..fb99d40b 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -517,10 +517,12 @@ public void OnClear(ICommandContext context) // this is the same call, for anybody who types it. int thrown = _replay.ClearThrownUtility(); - // Swept rather than cleared: ClearMarkers can only despawn what this - // instance still has a handle to, and anything a previous load left - // behind is exactly what makes .clear look like it did nothing. - _replay.SweepMarkers(); + // Their own selection only. The library layer is everyone's, and the + // sweep that used to be here took it -- and every other player's + // crosshair -- off the map for the whole server; the hold above hides + // it from this player alone. A previous load's litter is already + // swept when this instance loads. + _replay.ClearSelectionFor(player.SteamID); // Safe to drop now the watcher is held off: nothing can match against // it, so the first redraw after they ask again is decided by where @@ -1000,8 +1002,10 @@ public void OnCrosshair(ICommandContext context) state.Crosshair = !state.Crosshair; // Redrawn rather than left until the player steps off the spot and back - // on: a toggle that appears to do nothing gets pressed again. - _replay.ClearMarkers(); + // on: a toggle that appears to do nothing gets pressed again. Only + // their selection -- the crosshair is theirs, and clearing every marker + // to redraw it blanked the map for everybody else. + _replay.ClearSelectionFor(player.SteamID); ForgetSpot(player.SteamID); Reply( @@ -1195,7 +1199,12 @@ public void OnReload(ICommandContext context) reloading.Index = -1; _replay.ClearGhosts(steamId); - _replay.ClearMarkers(); + + // Their selection, not the shared layer: other players' markers have + // nothing to do with this library, and the layer is redrawn from the + // new one the moment it lands (OnLibraryReplaced). + _replay.ClearSelectionFor(steamId); + _replay.ApplyLibraryVisibility(player); ForgetSpot(steamId); _library.Refresh( From 32b9a67e36fa97a88e833ae5fc233b6c61083ad4 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 7 Oct 2026 00:08:47 +0000 Subject: [PATCH 08/14] bug: keep a library fetch that left before a save or delete from undoing it --- .../src/Services/PracticeLibrary.cs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/apps/utility-sw/src/Services/PracticeLibrary.cs b/apps/utility-sw/src/Services/PracticeLibrary.cs index dd9a7159..e3f765db 100644 --- a/apps/utility-sw/src/Services/PracticeLibrary.cs +++ b/apps/utility-sw/src/Services/PracticeLibrary.cs @@ -36,6 +36,24 @@ private sealed class PendingFetch private readonly Dictionary _fetching = new(); + // What this server changed in a player's library itself -- a .save, a + // solve, a .delete -- and when, by client id. A fetch that went out before + // the change, or before the panel had it, answers with the library as it + // was; applied as-is it dropped the lineup somebody had just saved, or + // brought back the one they had just deleted. Kept is null for a removal. + private sealed class LocalChange + { + public DateTime At; + public LineupRecord? Kept; + } + + private readonly Dictionary> _local = new(); + + // How long a local change outranks the panel's answer once a fetch has + // started after it. A save is one round trip away from being on the panel; + // past this, a lineup missing from the answer is one the website removed. + private static readonly TimeSpan LocalHold = TimeSpan.FromMinutes(2); + public PracticeLibrary( ISwiftlyCore core, UtilityApiClient api, @@ -64,6 +82,7 @@ public void SetMap(string map) _map = map; _lineups.Clear(); + _local.Clear(); } public IReadOnlyList For(ulong steamId) @@ -88,6 +107,7 @@ public void Add(ulong steamId, LineupRecord lineup) lineups.RemoveAll(existing => existing.client_id == lineup.client_id); lineups.Add(lineup); + Touch(steamId, lineup.client_id, lineup); } public void Remove(ulong steamId, LineupRecord lineup) @@ -96,6 +116,92 @@ public void Remove(ulong steamId, LineupRecord lineup) { lineups.RemoveAll(existing => existing.client_id == lineup.client_id); } + + Touch(steamId, lineup.client_id, null); + } + + private void Touch(ulong steamId, string clientId, LineupRecord? kept) + { + if (!_local.TryGetValue(steamId, out Dictionary? changes)) + { + changes = new Dictionary(); + _local[steamId] = changes; + } + + changes[clientId] = new LocalChange { At = DateTime.UtcNow, Kept = kept }; + } + + // The panel's answer, with this server's own changes laid back over it + // while the answer cannot have seen them yet. A change is forgotten once + // an answer agrees with it, or once it is old enough that disagreeing + // means somebody else changed it since. + private List MergeLocal(ulong steamId, DateTime startedAt, List answer) + { + if (!_local.TryGetValue(steamId, out Dictionary? changes)) + { + return answer; + } + + DateTime now = DateTime.UtcNow; + var merged = new List(answer); + + foreach ((string clientId, LocalChange change) in changes.ToList()) + { + int index = merged.FindIndex(row => row.client_id == clientId); + + // Made after the request left: the answer cannot know about it. + bool newer = change.At >= startedAt; + bool holding = newer || now - change.At < LocalHold; + + if (change.Kept == null) + { + if (index < 0) + { + if (!newer) + { + changes.Remove(clientId); + } + } + else if (holding) + { + merged.RemoveAt(index); + } + else + { + changes.Remove(clientId); + } + + continue; + } + + if (index >= 0) + { + if (newer) + { + merged[index] = change.Kept; + } + else + { + changes.Remove(clientId); + } + + continue; + } + + // Not on the panel yet. One with no id never reached it at all -- + // it is waiting in the retry queue -- so it stays for as long as it + // stays in this server's hands. + if (holding || string.IsNullOrEmpty(change.Kept.id)) + { + merged.Add(change.Kept); + } + else + { + changes.Remove(clientId); + } + } + + return merged; } // A library row carries no flight path and no measured bloom, so neither @@ -217,6 +323,8 @@ private void Landed( // answer beats showing inferno lineups on mirage. if (lineups != null && map == _map) { + lineups = MergeLocal(steamId, fetch.StartedAt, lineups); + _lineups[steamId] = lineups; count = lineups.Count; From 1edfd8d5f7fe8c7f9d8040d9a449a866bc3bfb3f Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 7 Oct 2026 00:09:05 +0000 Subject: [PATCH 09/14] bug: drain the upload retry queue on a timer, and try it once more on unload --- apps/utility-sw/src/UtilityPracticePlugin.cs | 67 ++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index c9ad7a82..b193bf6b 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -376,6 +376,8 @@ public override void Unload() _logger.LogWarning(exception, "teardown failed"); } + FlushQueued(); + return; } @@ -396,6 +398,44 @@ public override void Unload() { _logger.LogWarning(exception, "teardown failed"); } + + FlushQueued(); + } + + // Saves and scored attempts the panel has not taken yet live only in this + // instance's memory, and a reload or a shutdown throws them away. One + // last try on the way out: waited for briefly, and only off the game + // thread -- a server shutting down is not held up for a panel that is + // down, it just loses what it could not send. + private static readonly TimeSpan FlushWait = TimeSpan.FromSeconds(3); + + private void FlushQueued() + { + try + { + int queued = _api?.QueuedCount ?? 0; + + if (queued == 0) + { + return; + } + + Task drain = Task.Run(() => _api!.Drain()); + + if (!Thread.CurrentThread.IsThreadPoolThread || drain.Wait(FlushWait)) + { + return; + } + + _logger.LogWarning( + "{queued} queued upload(s) still unsent at unload; they go with this instance", + _api!.QueuedCount + ); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "flushing the upload queue failed"); + } } // A tick is milliseconds away. Only a server that has stopped running them @@ -1631,6 +1671,33 @@ private void OnSecond() _callouts.Report(_session.Map); DrainLibraryLoads(); DrainPendingMapLoad(); + DrainQueued(); + } + + // The retry queue only used to move when something else got through -- a + // later save, a scored throw, a map change -- so a panel that came back + // while nobody was saving left everything queued until the next one. + private const int DrainEverySeconds = 30; + + private int _drainTicks; + + private void DrainQueued() + { + if (++_drainTicks < DrainEverySeconds) + { + return; + } + + _drainTicks = 0; + + if (_api.QueuedCount == 0) + { + return; + } + + // Off the game thread like every other call to the panel; Drain lets + // only one pass run at a time, so a slow one is never stacked on. + _ = Task.Run(() => _api.Drain()); } // Nobody stays dead on a practice server. Rejoining while dead, falling off From c8702feb4203e272c6c10e95ff15059118ab8254 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 7 Oct 2026 00:10:17 +0000 Subject: [PATCH 10/14] bug: stop callbacks that outlive a hot reload drawing or teleporting, and unhook round start on unload --- apps/utility-sw/src/Hud/PracticeEdit.cs | 7 +++ .../src/Services/PracticeLibrary.cs | 21 +++++++++ .../utility-sw/src/Services/PracticeReplay.cs | 27 ++++++++++-- apps/utility-sw/src/UtilityPracticePlugin.cs | 43 ++++++++++++++++--- 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/apps/utility-sw/src/Hud/PracticeEdit.cs b/apps/utility-sw/src/Hud/PracticeEdit.cs index 972777d7..b6858d16 100644 --- a/apps/utility-sw/src/Hud/PracticeEdit.cs +++ b/apps/utility-sw/src/Hud/PracticeEdit.cs @@ -215,6 +215,13 @@ private void SaveEdit(IPlayer player, EditDraft draft) Core.Scheduler.NextTick(() => { + // Answered after a reload tore this instance down: the panel it + // would redraw belongs to an instance that is gone. + if (_tornDown) + { + return; + } + IPlayer? still = _system.Find(steamId); if (still == null || !still.IsValid) diff --git a/apps/utility-sw/src/Services/PracticeLibrary.cs b/apps/utility-sw/src/Services/PracticeLibrary.cs index e3f765db..57e8f988 100644 --- a/apps/utility-sw/src/Services/PracticeLibrary.cs +++ b/apps/utility-sw/src/Services/PracticeLibrary.cs @@ -67,6 +67,17 @@ ILogger logger public string Map => _map; + // Set once this instance has been torn down. A fetch still in flight + // across a hot reload lands on a tick after the teardown, and everything + // waiting on it -- a pushed load, a redraw -- would teleport players and + // draw markers on behalf of an instance that no longer owns any of them. + private volatile bool _closed; + + public void Close() + { + _closed = true; + } + // Raised on the game thread whenever a fetch replaces a player's library, // before anyone waiting on that fetch is answered. Every refresh lands // here -- the drain's, .reload's, a load pushed from the website -- so the @@ -224,6 +235,11 @@ public void EnsureTrajectory(LineupRecord lineup, ulong steamId, Action { + if (_closed) + { + return; + } + if (artifact != null) { lineup.trajectory = artifact.path; @@ -307,6 +323,11 @@ private void Landed( List? lineups ) { + if (_closed) + { + return; + } + // An answer to a request that was already given up on. The callers it // was carrying moved to the one that replaced it, and its rows are the // older of the two. diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 24ea48b5..b3600e8d 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -320,6 +320,17 @@ private class GhostThrow private readonly Dictionary _ghostThrows = new(); + // Set by the plugin's teardown. Nothing is spawned past it: a callback + // that outlives a hot reload -- a library answer, a trajectory fetch, a + // delayed redraw -- would otherwise put entities in the world after the + // sweep, where no instance holds a handle to them. + private volatile bool _closed; + + public void Close() + { + _closed = true; + } + public PracticeReplay( ISwiftlyCore core, UtilityConfig config, @@ -821,7 +832,7 @@ public int ShowBloom(IPlayer player, LineupRecord lineup) // subject. Only Swiftly can emit one, so only Swiftly offers it. public bool ShowBloomSmoke(IPlayer player, LineupRecord lineup) { - if (!EmitGrenades) + if (!EmitGrenades || _closed) { return false; } @@ -899,6 +910,11 @@ public void ClearBloomSmoke(ulong steamId) // already said which throw they want, so it is not the switch's to refuse. public void ThrowGhostProjectile(IPlayer player, LineupRecord lineup, bool force = false) { + if (_closed) + { + return; + } + _logger.LogInformation( "[nade-render] ThrowGhostProjectile: emitGrenades={emit} ghostProjectile={ghost} forced={force} exactlyReplayable={exact} hasSeed={seed} confidence={conf}", EmitGrenades, @@ -2549,7 +2565,7 @@ private void Diamond(Vec3 at, float radius, Color color, float width) private void UtilityModel(string utilityType, Vec3 at, Color? glow = null) { - if (!DrawModels) + if (!DrawModels || _closed) { return; } @@ -2698,7 +2714,7 @@ private static Vec3 Normalize(Vec3 v) float unitsPerPx = LabelUnitsPerPx ) { - if (!Sane(at)) + if (_closed || !Sane(at)) { return null; } @@ -2895,6 +2911,11 @@ private static bool Sane(Vec3 point) private CEnvBeam? CreateBeam(Vec3 start, Vec3 end, Color color, float width) { + if (_closed) + { + return null; + } + if (!Sane(start) || !Sane(end)) { _logger.LogWarning( diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index b193bf6b..da6ad010 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -85,6 +85,11 @@ private sealed class LibraryLoad private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; private EventDelegates.OnCustomHudClicked? _hudClickHandler; + // Hooked by hand, so unhooked by hand: SwiftlyS2 only drops it once the + // core is disposed, a step after Unload, and a round that starts in + // between still reaches this instance's handler. + private Guid _roundStartHook; + public UtilityPracticePlugin(ISwiftlyCore core) : base(core) { } @@ -205,7 +210,7 @@ public override void Load(bool hotReload) CEntityInstance entity = @event.Entity; Core.Scheduler.NextTick(() => { - if (entity.IsValid) + if (!_tornDown && entity.IsValid) { _recorder.OnProjectileCreated(entity); TintSmoke(entity); @@ -217,8 +222,13 @@ public override void Load(bool hotReload) _mapLoadHandler = @event => OnMapLoad(@event.MapName); Core.Event.OnMapLoad += _mapLoadHandler; - Core.GameEvent.HookPre(_ => + _roundStartHook = Core.GameEvent.HookPre(_ => { + if (_tornDown) + { + return HookResult.Continue; + } + KeepRoundsMoving(); // A new round is a map cleanup, and the cleanup takes every beam, @@ -307,6 +317,12 @@ public override void Load(bool hotReload) _library.SetMap(current); _session.Map = current; ApplyPracticeCfg(); + + // Nobody reconnects for a hot reload, so no connect hook fires for + // the players already here. RefreshEverything fetches each of their + // libraries -- the drain picks up anyone it misses -- and the + // occupancy goes out on the next second rather than in a minute. + _occupancyDirty = true; RefreshEverything(); } @@ -318,7 +334,9 @@ public override void Load(bool hotReload) ); } - private bool _tornDown; + // Volatile: Unload runs on a thread pool thread during a hot reload, and + // continuations of the panel calls land on whichever thread they like. + private volatile bool _tornDown; // SwiftlyS2 never reschedules a repeating timer whose callback throws, // still runs a timer's first pass if it was cancelled before then, and @@ -446,6 +464,11 @@ private void TearDown() { _tornDown = true; + // Before the sweep, so nothing that lands after it -- a library answer, + // a trajectory, a queued redraw -- can put anything back. + _library.Close(); + _replay.Close(); + // Drawn entities are not the plugin's to leave behind: without this a // hot reload orphans every beam, label and model in the world, with no // instance left holding a reference to any of them. @@ -490,11 +513,17 @@ private void TearDown() if (_disconnectHandler != null) { Core.Event.OnClientDisconnected -= _disconnectHandler; + } - if (_precacheHandler != null) - { - Core.Event.OnPrecacheResource -= _precacheHandler; - } + if (_precacheHandler != null) + { + Core.Event.OnPrecacheResource -= _precacheHandler; + } + + if (_roundStartHook != Guid.Empty) + { + Core.GameEvent.Unhook(_roundStartHook); + _roundStartHook = Guid.Empty; } if (_authorizeHandler != null) From 97f9ad6091b36b84f700063975853c3bd9eeec7c Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 7 Oct 2026 00:10:24 +0000 Subject: [PATCH 11/14] bug: reload everyone's library after a changelevel onto the same map --- apps/utility-sw/src/UtilityPracticePlugin.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index da6ad010..261ca21c 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -2021,6 +2021,16 @@ private void OnMapLoad(string mapName) _library.SetMap(mapName); _session.Map = mapName; + + // Loaded-for is compared by map name, so a changelevel onto the map + // the server was already on left every player marked as done: the + // drain never asked again, and anyone not caught by the refresh below + // -- still reconnecting when it ran -- kept whatever they had before. + foreach (LibraryLoad load in _libraryLoads.Values) + { + load.LoadedFor = null; + } + _callouts.Reset(); _callouts.Report(mapName); From 09a8314f934c6788379f7ac3c232f259aba0e660 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 7 Oct 2026 00:10:29 +0000 Subject: [PATCH 12/14] bug: keep a player's place in .next/.prev when their library refreshes --- apps/utility-sw/src/UtilityPracticePlugin.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 261ca21c..5faca6b6 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -2162,9 +2162,18 @@ private void LibraryLanded(ulong steamId, string map, int count, bool pushed) if (pushed || state.Results.Count == 0) { + // Where they were in the walk, by lineup rather than position: + // the panel pushing an edit is not the player asking to start + // again from the top, and the new rows are new objects anyway. + string? at = + state.Index >= 0 && state.Index < state.Results.Count + ? state.Results[state.Index].client_id + : null; + state.Results.Clear(); state.Results.AddRange(library); - state.Index = -1; + state.Index = + at == null ? -1 : state.Results.FindIndex(match => match.client_id == at); if (!pushed) { From eba99ef21b63e8f131cef694d601bde61e8dc0b1 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 7 Oct 2026 00:11:00 +0000 Subject: [PATCH 13/14] bug: drop a lineup archived on the website from whoever has it loaded, and tell them --- .../src/Services/PracticeLibrary.cs | 16 ++++- apps/utility-sw/src/UtilityPracticePlugin.cs | 61 ++++++++++++++++++- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeLibrary.cs b/apps/utility-sw/src/Services/PracticeLibrary.cs index 57e8f988..5e3c070c 100644 --- a/apps/utility-sw/src/Services/PracticeLibrary.cs +++ b/apps/utility-sw/src/Services/PracticeLibrary.cs @@ -81,8 +81,10 @@ public void Close() // Raised on the game thread whenever a fetch replaces a player's library, // before anyone waiting on that fetch is answered. Every refresh lands // here -- the drain's, .reload's, a load pushed from the website -- so the - // markers follow the library whichever of them asked. - public event Action? Replaced; + // markers follow the library whichever of them asked. Carries the client + // ids the refresh took away: archived or deleted on the website since the + // last answer. + public event Action>? Replaced; public void SetMap(string map) { @@ -346,12 +348,20 @@ private void Landed( { lineups = MergeLocal(steamId, fetch.StartedAt, lineups); + var dropped = new HashSet(); + + if (_lineups.TryGetValue(steamId, out List? was)) + { + dropped.UnionWith(was.Select(lineup => lineup.client_id)); + dropped.ExceptWith(lineups.Select(lineup => lineup.client_id)); + } + _lineups[steamId] = lineups; count = lineups.Count; try { - Replaced?.Invoke(steamId); + Replaced?.Invoke(steamId, dropped); } catch (Exception error) { diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 5faca6b6..344ffe2c 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -2200,11 +2200,70 @@ private void LibraryLanded(ulong steamId, string map, int count, bool pushed) // full server with markers that never followed a save or an edit. So the // layer draws everybody's at once and each player is sent only the // lineups that are theirs to see -- see ApplyLibraryVisibility. - private void OnLibraryReplaced(ulong steamId) + private void OnLibraryReplaced(ulong steamId, IReadOnlyCollection dropped) { + if (dropped.Count > 0) + { + ForgetDropped(steamId, dropped); + } + RedrawLibrary(); } + // A lineup archived on the website while somebody here had it loaded. Left + // alone it stayed their Loaded lineup -- scored against, rethrown, and as + // the one being practised, the reason the rest of the map was hidden from + // them -- with no marker of its own left to explain any of it. + private void ForgetDropped(ulong steamId, IReadOnlyCollection dropped) + { + PracticeState state = _system.StateFor(steamId); + + bool loadedGone = state.Loaded != null && dropped.Contains(state.Loaded.client_id); + bool practisingGone = + state.Practising != null && dropped.Contains(state.Practising.client_id); + + if (!loadedGone && !practisingGone) + { + return; + } + + LineupRecord gone = (practisingGone ? state.Practising : state.Loaded)!; + + if (loadedGone) + { + state.Loaded = null; + } + + if (practisingGone) + { + state.Practising = null; + } + + string? at = + state.Index >= 0 && state.Index < state.Results.Count + ? state.Results[state.Index].client_id + : null; + + state.Results.RemoveAll(match => dropped.Contains(match.client_id)); + state.Index = at == null ? -1 : state.Results.FindIndex(match => match.client_id == at); + + _replay.ClearGhosts(steamId); + _replay.ClearSelectionFor(steamId); + ForgetSpot(steamId); + + IPlayer? player = _system.Find(steamId); + + if (player != null) + { + _replay.ApplyLibraryVisibility(player); + } + + Tell( + steamId, + $" {ChatColors.Yellow}{DrillUtility.Name(gone)} {ChatColors.Grey}was removed from your library" + ); + } + // Rebuilds only when what it would draw has changed, so this is cheap to // call from anything that might have moved the library. .clear is not // undone by it: that hides the layer from the player who asked, rather From 38640cf3c431787770f3fdd8c88b789aaa26c892 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 7 Oct 2026 00:12:09 +0000 Subject: [PATCH 14/14] bug: mirror the library fetch merge and the upload queue drain to the css plugin --- .../src/Services/PracticeLibrary.cs | 109 ++++++++++++++++++ apps/utility-css/src/UtilityPracticePlugin.cs | 34 ++++++ 2 files changed, 143 insertions(+) diff --git a/apps/utility-css/src/Services/PracticeLibrary.cs b/apps/utility-css/src/Services/PracticeLibrary.cs index 8712ec4e..fcb5aebd 100644 --- a/apps/utility-css/src/Services/PracticeLibrary.cs +++ b/apps/utility-css/src/Services/PracticeLibrary.cs @@ -16,6 +16,24 @@ public class PracticeLibrary private readonly Dictionary> _lineups = new(); private string _map = ""; + // What this server changed in a player's library itself -- a .save, a + // .delete -- and when, by client id. A fetch that went out before the + // change, or before the panel had it, answers with the library as it was; + // applied as-is it dropped the lineup somebody had just saved, or brought + // back the one they had just deleted. Kept is null for a removal. + private sealed class LocalChange + { + public DateTime At; + public LineupRecord? Kept; + } + + private readonly Dictionary> _local = new(); + + // How long a local change outranks the panel's answer once a fetch has + // started after it. A save is one round trip away from being on the panel; + // past this, a lineup missing from the answer is one the website removed. + private static readonly TimeSpan LocalHold = TimeSpan.FromMinutes(2); + public PracticeLibrary(UtilityApiClient api, ILogger logger) { _api = api; @@ -33,6 +51,7 @@ public void SetMap(string map) _map = map; _lineups.Clear(); + _local.Clear(); } public IReadOnlyList For(ulong steamId) @@ -57,6 +76,7 @@ public void Add(ulong steamId, LineupRecord lineup) lineups.RemoveAll(existing => existing.client_id == lineup.client_id); lineups.Add(lineup); + Touch(steamId, lineup.client_id, lineup); } public void Remove(ulong steamId, LineupRecord lineup) @@ -65,6 +85,92 @@ public void Remove(ulong steamId, LineupRecord lineup) { lineups.RemoveAll(existing => existing.client_id == lineup.client_id); } + + Touch(steamId, lineup.client_id, null); + } + + private void Touch(ulong steamId, string clientId, LineupRecord? kept) + { + if (!_local.TryGetValue(steamId, out Dictionary? changes)) + { + changes = new Dictionary(); + _local[steamId] = changes; + } + + changes[clientId] = new LocalChange { At = DateTime.UtcNow, Kept = kept }; + } + + // The panel's answer, with this server's own changes laid back over it + // while the answer cannot have seen them yet. A change is forgotten once + // an answer agrees with it, or once it is old enough that disagreeing + // means somebody else changed it since. + private List MergeLocal(ulong steamId, DateTime startedAt, List answer) + { + if (!_local.TryGetValue(steamId, out Dictionary? changes)) + { + return answer; + } + + DateTime now = DateTime.UtcNow; + var merged = new List(answer); + + foreach ((string clientId, LocalChange change) in changes.ToList()) + { + int index = merged.FindIndex(row => row.client_id == clientId); + + // Made after the request left: the answer cannot know about it. + bool newer = change.At >= startedAt; + bool holding = newer || now - change.At < LocalHold; + + if (change.Kept == null) + { + if (index < 0) + { + if (!newer) + { + changes.Remove(clientId); + } + } + else if (holding) + { + merged.RemoveAt(index); + } + else + { + changes.Remove(clientId); + } + + continue; + } + + if (index >= 0) + { + if (newer) + { + merged[index] = change.Kept; + } + else + { + changes.Remove(clientId); + } + + continue; + } + + // Not on the panel yet. One with no id never reached it at all -- + // it is waiting in the retry queue -- so it stays for as long as it + // stays in this server's hands. + if (holding || string.IsNullOrEmpty(change.Kept.id)) + { + merged.Add(change.Kept); + } + else + { + changes.Remove(clientId); + } + } + + return merged; } // A library row carries no flight path and no measured bloom, so neither @@ -103,6 +209,7 @@ public void EnsureTrajectory(LineupRecord lineup, ulong steamId, Action? done = null) { string map = _map; + DateTime startedAt = DateTime.UtcNow; _ = Task.Run(async () => { @@ -124,6 +231,8 @@ public void Refresh(ulong steamId, Action? done = null) return; } + lineups = MergeLocal(steamId, startedAt, lineups); + _lineups[steamId] = lineups; done?.Invoke(lineups.Count); }); diff --git a/apps/utility-css/src/UtilityPracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs index 068da6ef..02a88f9a 100644 --- a/apps/utility-css/src/UtilityPracticePlugin.cs +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -144,6 +144,13 @@ public override void Unload(bool hotReload) _playbook.Reset(); _drill.Reset(); _system.Reset(); + + // One last try at what the panel has not taken yet: it lives only in + // this instance's memory. Not waited for -- this is the game thread. + if (_api.QueuedCount > 0) + { + _ = Task.Run(() => _api.Drain()); + } } private void OnSecond() @@ -158,6 +165,33 @@ private void OnSecond() // A no-op once the map has answered; see MapCalloutsReporter.Report. _callouts.Report(_session.Map); DrainPendingMapLoad(); + DrainQueued(); + } + + // The retry queue only used to move when something else got through -- a + // later save, a scored throw, a map change -- so a panel that came back + // while nobody was saving left everything queued until the next one. + private const int DrainEverySeconds = 30; + + private int _drainTicks; + + private void DrainQueued() + { + if (++_drainTicks < DrainEverySeconds) + { + return; + } + + _drainTicks = 0; + + if (_api.QueuedCount == 0) + { + return; + } + + // Off the game thread like every other call to the panel; Drain lets + // only one pass run at a time, so a slow one is never stacked on. + _ = Task.Run(() => _api.Drain()); } // Nobody stays dead on a practice server. Rejoining while dead, falling off