From 9e681576a3a6555938ac84fcf77c84c46a5a2dab Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 2 Nov 2026 20:24:05 -0500 Subject: [PATCH 1/2] feature: bots standing at a save stay with the lineup, and drop stands them back up --- .github/workflows/hud-workshop.yaml | 2 +- apps/utility-sw/hud/README.md | 4 +- .../panorama/layout/custom_game/nade4_bar.xml | 130 ++++++++++++++ apps/utility-sw/src/Commands/Practice.cs | 6 + apps/utility-sw/src/Hud/PracticeActions.cs | 20 ++- apps/utility-sw/src/Hud/PracticeBar.cs | 1 + apps/utility-sw/src/Hud/PracticeKeys.cs | 17 +- apps/utility-sw/src/Hud/PracticeLineupBots.cs | 164 ++++++++++++++++++ .../src/Services/UtilityApiClient.cs | 32 +++- apps/utility-sw/src/UtilityPractice.csproj | 1 + apps/utility-sw/src/UtilityPracticePlugin.cs | 13 +- apps/utility-sw/test/FiveStack.Tests.csproj | 1 + apps/utility-sw/test/LineupBotsTests.cs | 113 ++++++++++++ apps/utility-sw/test/UtilityWireTests.cs | 57 ++++++ .../FiveStack.Entities/Practice/LineupBot.cs | 9 + .../Practice/LineupRecord.cs | 3 + .../Practice/UtilityIngestPayload.cs | 4 + .../Practice/UtilityLibraryRow.cs | 3 + shared/dotnet/FiveStack.Utilities/HudSlots.cs | 9 +- .../dotnet/FiveStack.Utilities/LineupBots.cs | 52 ++++++ 20 files changed, 620 insertions(+), 21 deletions(-) create mode 100644 apps/utility-sw/hud/panorama/layout/custom_game/nade4_bar.xml create mode 100644 apps/utility-sw/src/Hud/PracticeLineupBots.cs create mode 100644 apps/utility-sw/test/LineupBotsTests.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/LineupBot.cs create mode 100644 shared/dotnet/FiveStack.Utilities/LineupBots.cs diff --git a/.github/workflows/hud-workshop.yaml b/.github/workflows/hud-workshop.yaml index b8cf9b85..38114363 100644 --- a/.github/workflows/hud-workshop.yaml +++ b/.github/workflows/hud-workshop.yaml @@ -189,7 +189,7 @@ jobs: nade2_hud.vxml_c nade2_pick.vxml_c \ nade2_run.vxml_c nade2_edit.vxml_c nade2_bar.vxml_c \ nade2_wheel.vxml_c nadehud2.vcss_c nadewheel2.vcss_c \ - nade3_edit.vxml_c nade3_bar.vxml_c nadebar3.vcss_c + nade3_edit.vxml_c nade3_bar.vxml_c nade4_bar.vxml_c nadebar3.vcss_c do case "$TREE" in *"$expected"*) ;; diff --git a/apps/utility-sw/hud/README.md b/apps/utility-sw/hud/README.md index 9675eb08..5f655f1f 100644 --- a/apps/utility-sw/hud/README.md +++ b/apps/utility-sw/hud/README.md @@ -6,7 +6,7 @@ it through `CCSCustomHudLayout`. | Layout | Captures input | What it is | | --- | --- | --- | -| `nade3_bar` | no | Everything on screen while you play: a compass with a grenade icon per spot, coloured by type, what the keys do right now, and what the last key did. On the right, what utility is out and for how long, and the plan or stack being worked through. In the middle, for a few seconds, what a flash did. | +| `nade4_bar` | no | Everything on screen while you play: a compass with a grenade icon per spot, coloured by type, what the keys do right now, and what the last key did. On the right, what utility is out and for how long, and the plan or stack being worked through. In the middle, for a few seconds, what a flash did. | | `nade2_hud` | no | The throw card, bottom right: where it lands, a drawn mouse and keycaps for how to throw it, the run-up, live aim error, and how far along the run of landed throws that counts as mastered. | | `nade2_wheel` | yes | TAB. Eight wedges around a hub, an outer ring of two more over each, and wedges that open a wheel of their own. Beside it, what the keys do. Generated with `nadewheel2.css` by `wheel.py`: edit that and run it, never the two files. | | `nade2_pick` | yes | Choosing a lineup: the map with a marker per landing spot beside the list, under one set of filter chips. | @@ -21,7 +21,7 @@ what changed on the compass once `nadehud2.css` was published. `nade_hud`, `nade servers still on a plugin that asks for them; nothing in this repo drives them any more. Neither does it drive `nade2_edit`, superseded by `nade3_edit` because its buttons used classes `nadehud2.css` never styled, nor `nade2_bar`, superseded by -`nade3_bar` because every compass pip was the same grey. `HudSlots.cs` has the rule for when a name has to change. +`nade3_bar` because every compass pip was the same grey, and `nade4_bar` for the legend row that spawns a lineup's bots. `HudSlots.cs` has the rule for when a name has to change. Passive layouts must never capture input — a panel that takes the cursor while somebody is aiming takes the shot with it. diff --git a/apps/utility-sw/hud/panorama/layout/custom_game/nade4_bar.xml b/apps/utility-sw/hud/panorama/layout/custom_game/nade4_bar.xml new file mode 100644 index 00000000..03c66937 --- /dev/null +++ b/apps/utility-sw/hud/panorama/layout/custom_game/nade4_bar.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 2a42aca1..22d8ec9a 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -137,6 +137,7 @@ private void SaveThrow(ulong steamId, string name) thrown.side = player.Controller.Team == Team.CT ? "CT" : "TERRORIST"; thrown.visibility = nameof(eLineupVisibility.Private); thrown.plugin_version = ModuleVersion; + thrown.bots = LineupBots.Snapshot(_bots, MaxBots); _library.Add(steamId, thrown); @@ -157,6 +158,11 @@ private void SaveThrow(ulong steamId, string name) Tell(steamId, $" {ChatColors.Green}saved {ChatColors.Default}{name}"); + if (thrown.bots.Count > 0) + { + Toast(player, $"Saved · {BotCount(thrown.bots.Count)} kept with it"); + } + // A smoke saved straight after it went off is still spreading, and the // panel keeps the first cloud a lineup gets. The save waits the few // seconds for it rather than going without -- see SmokeSettled. diff --git a/apps/utility-sw/src/Hud/PracticeActions.cs b/apps/utility-sw/src/Hud/PracticeActions.cs index d71732c9..9ea7f0bd 100644 --- a/apps/utility-sw/src/Hud/PracticeActions.cs +++ b/apps/utility-sw/src/Hud/PracticeActions.cs @@ -177,7 +177,17 @@ private WheelPage WheelFor(IPlayer player, string page) "Place A Bot", _bots.Count == 0 ? "where I stand" : $"{_bots.Count} of {MaxBots}", Disabled: BotSlotsLeft() == 0 - ), + ) + { + Quick = new WheelItem?[] + { + new WheelItem( + "keepbots", + _bots.Count == 0 ? "Clear Lineup Bots" : "Keep On Lineup", + Disabled: KeepTarget(player) == null + ), + }, + }, new WheelItem( "nobots", "Remove Bots", @@ -906,6 +916,14 @@ private void Do(IPlayer player, string action) ClearBots(); Toast(player, "Bots removed"); + break; + case "lineupbots": + SpawnLineupBots(player); + + break; + case "keepbots": + KeepBotsOnLineup(player); + break; case "spawns": ToggleSpawns(player); diff --git a/apps/utility-sw/src/Hud/PracticeBar.cs b/apps/utility-sw/src/Hud/PracticeBar.cs index a03bb658..00b08778 100644 --- a/apps/utility-sw/src/Hud/PracticeBar.cs +++ b/apps/utility-sw/src/Hud/PracticeBar.cs @@ -402,6 +402,7 @@ bool cleared UseKey(steamId, at, cleared), new NadeBarTemplate.Key(reload, zoomed || following), new NadeBarTemplate.Key("Add a bot here"), + BotKey(player, pawn, at), new NadeBarTemplate.Key(noclip ? "Noclip · on" : "Noclip", noclip), AimRingKey(steamId), new NadeBarTemplate.Key("Clear smoke and fire · twice for bots"), diff --git a/apps/utility-sw/src/Hud/PracticeKeys.cs b/apps/utility-sw/src/Hud/PracticeKeys.cs index 73abf9be..68ab5de4 100644 --- a/apps/utility-sw/src/Hud/PracticeKeys.cs +++ b/apps/utility-sw/src/Hud/PracticeKeys.cs @@ -22,6 +22,10 @@ namespace UtilityPractice; // behind it. That one needs no rule either -- there is nothing to throw again // until the first has landed. // +// Drop is taken outright: a server where nothing runs out has no use for it, so +// it stands up the bots saved on a lineup instead, on whatever key it is bound +// to. +// // Crouch and walk keep their jobs and gain one each on a double tap, which is // a thing nobody does to either by accident: a bot where you stand, and // noclip. @@ -40,6 +44,8 @@ public partial class UtilityPracticePlugin // What the default bind for delete sends. private const string SellBackCommand = "sellbackall"; + private const string DropCommand = "drop"; + // Two presses of one key inside a third of a second are one gesture. private const int DoubleTapTicks = 21; @@ -87,7 +93,7 @@ private HookResult OnClientCommand(int playerId, string commandLine) string command = commandLine.Trim().Split(' ')[0]; - if (command != SellBackCommand) + if (command != SellBackCommand && command != DropCommand) { return HookResult.Continue; } @@ -103,7 +109,14 @@ private HookResult OnClientCommand(int playerId, string commandLine) try { - ClearPressed(player); + if (command == SellBackCommand) + { + ClearPressed(player); + } + else if (!CursorOpen(player)) + { + Do(player, "lineupbots"); + } } catch (Exception error) { diff --git a/apps/utility-sw/src/Hud/PracticeLineupBots.cs b/apps/utility-sw/src/Hud/PracticeLineupBots.cs new file mode 100644 index 00000000..3058c6be --- /dev/null +++ b/apps/utility-sw/src/Hud/PracticeLineupBots.cs @@ -0,0 +1,164 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.SchemaDefinitions; + +namespace UtilityPractice; + +public partial class UtilityPracticePlugin +{ + private static string BotCount(int bots) + { + return bots == 1 ? "1 bot" : $"{bots} bots"; + } + + private LineupBots.Choice BotChoice(IPlayer player, CCSPlayerPawn pawn, Vec3 at) + { + IReadOnlyList shown = Shown(player.SteamID); + + if (!shown.Any(lineup => lineup.bots.Count > 0)) + { + return default; + } + + (LineupRecord? focused, _, _) = Focused(player, pawn); + + return LineupBots.Pick(focused, PracticeReplay.SpotAt(shown, at)); + } + + private NadeBarTemplate.Key BotKey(IPlayer player, CCSPlayerPawn pawn, Vec3 at) + { + LineupBots.Choice choice = BotChoice(player, pawn, at); + + if (choice.Waiting > 1) + { + return new NadeBarTemplate.Key($"Point at one · {choice.Waiting} here have bots"); + } + + if (choice.Lineup == null) + { + return new NadeBarTemplate.Key("Spawn a lineup's bots", Dim: true); + } + + int missing = LineupBots.Missing(choice.Lineup.bots, _bots).Count; + + return missing == 0 + ? new NadeBarTemplate.Key("Its bots are up", On: true) + : new NadeBarTemplate.Key($"Spawn {BotCount(missing)} · {LineupName(choice.Lineup)}"); + } + + private static string LineupName(LineupRecord lineup) + { + return PracticeLineupUtility.TitleCase(DrillUtility.Name(lineup)); + } + + private void SpawnLineupBots(IPlayer player) + { + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return; + } + + Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); + LineupBots.Choice choice = BotChoice(player, pawn, new Vec3(origin.X, origin.Y, origin.Z)); + + if (choice.Lineup == null) + { + Toast(player, choice.Waiting > 1 ? "Point at the lineup you want" : "No lineup with bots here"); + + return; + } + + List missing = LineupBots.Missing(choice.Lineup.bots, _bots); + + if (missing.Count == 0) + { + Toast(player, "Those bots are already up"); + + return; + } + + int room = BotSlotsLeft(); + + if (room == 0) + { + Toast(player, _bots.Count >= MaxBots ? $"{MaxBots} bots at most" : "The server has no room for another bot"); + + return; + } + + List spots = missing + .Take(room) + .Select(bot => new ThrowSnapshot { feet_position = new Vec3(bot.x, bot.y, bot.z), yaw = bot.yaw }) + .ToList(); + + AskForBots(player, pawn, spots); + + Toast( + player, + spots.Count == missing.Count + ? $"{BotCount(spots.Count)} · {LineupName(choice.Lineup)}" + : $"{spots.Count} of {BotCount(missing.Count)} · no room for the rest" + ); + } + + private LineupRecord? KeepTarget(IPlayer player) + { + LineupRecord? lineup = DrillTarget(player); + + return + lineup != null + && !string.IsNullOrEmpty(lineup.id) + && lineup.author_steam_id == player.SteamID.ToString() + ? lineup + : null; + } + + private void KeepBotsOnLineup(IPlayer player) + { + LineupRecord? lineup = KeepTarget(player); + + if (lineup == null) + { + Toast(player, "Point at a lineup of yours"); + + return; + } + + ulong steamId = player.SteamID; + string id = lineup.id!; + List bots = LineupBots.Snapshot(_bots, MaxBots); + + _ = Task.Run(async () => + { + UtilityApiClient.EditResult result = await _api.UpdateBots(id, steamId, bots); + + Core.Scheduler.NextTick(() => + { + bool saved = result.Outcome == UtilityApiClient.eEditOutcome.Saved; + + if (saved) + { + lineup.bots = bots; + } + + IPlayer? asker = _system.Find(steamId); + + if (asker == null || !asker.IsValid) + { + return; + } + + Toast( + asker, + !saved ? "The panel did not save that" + : bots.Count == 0 ? $"Bots cleared from {LineupName(lineup)}" + : $"{BotCount(bots.Count)} saved to {LineupName(lineup)}" + ); + }); + }); + } +} diff --git a/apps/utility-sw/src/Services/UtilityApiClient.cs b/apps/utility-sw/src/Services/UtilityApiClient.cs index 2eeb3e8f..eda33940 100644 --- a/apps/utility-sw/src/Services/UtilityApiClient.cs +++ b/apps/utility-sw/src/Services/UtilityApiClient.cs @@ -524,14 +524,32 @@ public async Task Update( string visibility ) { - var payload = new Dictionary - { - ["steam_id"] = steamId.ToString(), - ["name"] = name, - ["description"] = description, - ["visibility"] = visibility, - }; + return await Patch( + lineupId, + new Dictionary + { + ["steam_id"] = steamId.ToString(), + ["name"] = name, + ["description"] = description, + ["visibility"] = visibility, + } + ); + } + public async Task UpdateBots( + string lineupId, + ulong steamId, + IReadOnlyList bots + ) + { + return await Patch( + lineupId, + new Dictionary { ["steam_id"] = steamId.ToString(), ["bots"] = bots } + ); + } + + private async Task Patch(string lineupId, Dictionary payload) + { string body; try diff --git a/apps/utility-sw/src/UtilityPractice.csproj b/apps/utility-sw/src/UtilityPractice.csproj index 624ffad5..87275465 100644 --- a/apps/utility-sw/src/UtilityPractice.csproj +++ b/apps/utility-sw/src/UtilityPractice.csproj @@ -70,6 +70,7 @@ + diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 4f8ba34b..6e304b4f 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -670,7 +670,14 @@ public bool AddBot(IPlayer player) yaw = angles.Y, }; - _bots.Add(spot); + AskForBots(player, pawn, new[] { spot }); + + return true; + } + + private void AskForBots(IPlayer player, CCSPlayerPawn pawn, IReadOnlyList spots) + { + _bots.AddRange(spots); _botsAskedBy = player.SteamID; // The other side, which is what utility is thrown at. @@ -678,7 +685,7 @@ public bool AddBot(IPlayer player) Core.Engine.ExecuteCommand("sv_cheats 1;" + string.Join(";", BotsCfg)); MakeRoomOn(side == "ct" ? Team.CT : Team.T); - Core.Engine.ExecuteCommand($"bot_add_{side}"); + Core.Engine.ExecuteCommand(string.Join(";", spots.Select(_ => $"bot_add_{side}"))); _logger.LogInformation( "asked for a bot on {side}: {placed} placed, {joined} in game", @@ -691,8 +698,6 @@ public bool AddBot(IPlayer player) // wherever the map puts it. Placing it is a second step, taken on the // first tick there is a bot to place. _botsDueBy = _aimTick + BotHurryTicks; - - return true; } // Five is a site's worth of defenders; past that a team is full before the diff --git a/apps/utility-sw/test/FiveStack.Tests.csproj b/apps/utility-sw/test/FiveStack.Tests.csproj index dcebc6ff..b4df0b22 100644 --- a/apps/utility-sw/test/FiveStack.Tests.csproj +++ b/apps/utility-sw/test/FiveStack.Tests.csproj @@ -56,6 +56,7 @@ + diff --git a/apps/utility-sw/test/LineupBotsTests.cs b/apps/utility-sw/test/LineupBotsTests.cs new file mode 100644 index 00000000..1ff00b19 --- /dev/null +++ b/apps/utility-sw/test/LineupBotsTests.cs @@ -0,0 +1,113 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +namespace FiveStack.Tests; + +public class LineupBotsTests +{ + private static LineupRecord Lineup(int bots = 0) + { + var lineup = new LineupRecord(); + + for (int index = 0; index < bots; index++) + { + lineup.bots.Add(new LineupBot { x = index * 100f }); + } + + return lineup; + } + + private static ThrowSnapshot Standing(float x, float y = 0f, float z = 0f, float yaw = 0f) + { + return new ThrowSnapshot { feet_position = new Vec3(x, y, z), yaw = yaw }; + } + + [Fact] + public void PointingAtALineupWithBotsNamesIt() + { + LineupRecord pointedAt = Lineup(2); + + LineupBots.Choice choice = LineupBots.Pick(pointedAt, new[] { Lineup(3), pointedAt }); + + Assert.Same(pointedAt, choice.Lineup); + } + + [Fact] + public void TheOnlyLineupOnTheSpotNeedsNoPointing() + { + LineupRecord only = Lineup(3); + + Assert.Same(only, LineupBots.Pick(null, new[] { only }).Lineup); + } + + [Fact] + public void TheOnlyLineupWithBotsOnTheSpotNeedsNoPointingEither() + { + LineupRecord only = Lineup(3); + LineupRecord loaded = Lineup(); + + Assert.Same(only, LineupBots.Pick(loaded, new[] { loaded, only, Lineup() }).Lineup); + } + + [Fact] + public void TwoWithBotsOnTheSpotWaitForThePlayerToPoint() + { + LineupBots.Choice choice = LineupBots.Pick(null, new[] { Lineup(1), Lineup(2), Lineup() }); + + Assert.Null(choice.Lineup); + Assert.Equal(2, choice.Waiting); + } + + [Fact] + public void NoBotsAnywhereIsNothingToPick() + { + LineupBots.Choice choice = LineupBots.Pick(Lineup(), new[] { Lineup() }); + + Assert.Null(choice.Lineup); + Assert.Equal(0, choice.Waiting); + } + + [Fact] + public void OnlyBotsNotAlreadyStandingAreAskedFor() + { + var saved = new List + { + new() { x = 0f }, + new() { x = 500f }, + }; + + List missing = LineupBots.Missing(saved, new[] { Standing(10f) }); + + Assert.Equal(500f, Assert.Single(missing).x); + } + + [Fact] + public void BotsThatBelongToSomethingElseStayOutOfIt() + { + var saved = new List + { + new() { x = 0f }, + }; + + Assert.Single(LineupBots.Missing(saved, new[] { Standing(900f), Standing(0f, 0f, 500f) })); + } + + [Fact] + public void ASnapshotKeepsWhereTheyStandAndWhichWayTheyFace() + { + LineupBot bot = Assert.Single( + LineupBots.Snapshot(new[] { Standing(1f, 2f, 3f, 90f) }, max: 5) + ); + + Assert.Equal((1f, 2f, 3f, 90f), (bot.x, bot.y, bot.z, bot.yaw)); + } + + [Fact] + public void ASnapshotStopsAtWhatAServerCanStandUp() + { + ThrowSnapshot[] standing = Enumerable.Range(0, 7).Select(index => Standing(index)).ToArray(); + + Assert.Equal(5, LineupBots.Snapshot(standing, max: 5).Count); + } +} diff --git a/apps/utility-sw/test/UtilityWireTests.cs b/apps/utility-sw/test/UtilityWireTests.cs index 43ce06db..1189fdd5 100644 --- a/apps/utility-sw/test/UtilityWireTests.cs +++ b/apps/utility-sw/test/UtilityWireTests.cs @@ -203,6 +203,63 @@ public void TheApproachIsSentAsFlatObjectsInTheApisSpelling() ); } + [Fact] + public void BotsAreSentInTheApisSpelling() + { + LineupRecord lineup = Lineup(); + lineup.bots = new List + { + new() { x = 1032.5f, y = 1879f, z = 549f, yaw = 90f }, + new() { x = -20f, y = 40.25f, z = -5f, yaw = -179.5f }, + }; + + string json = JsonSerializer.Serialize( + UtilityIngestPayload.From(lineup), + PracticeJson.Options + ); + + Assert.Contains( + "\"bots\":[{\"x\":1032.5,\"y\":1879,\"z\":549,\"yaw\":90}," + + "{\"x\":-20,\"y\":40.25,\"z\":-5,\"yaw\":-179.5}]", + json + ); + } + + [Fact] + public void AThrowWithNoBotsSendsNone() + { + string json = JsonSerializer.Serialize( + UtilityIngestPayload.From(Lineup()), + PracticeJson.Options + ); + + Assert.DoesNotContain("\"bots\"", json); + } + + [Fact] + public void BotsComeBackOutOfTheLibrary() + { + UtilityLibraryRow? row = JsonSerializer.Deserialize( + "{\"id\":\"panel-id\",\"bots\":[{\"x\":1,\"y\":2,\"z\":3,\"yaw\":4}]}", + PracticeJson.Options + ); + + LineupBot bot = Assert.Single(row!.ToLineup().bots); + + Assert.Equal((1f, 2f, 3f, 4f), (bot.x, bot.y, bot.z, bot.yaw)); + } + + [Fact] + public void ALibraryRowWithNoBotsHasAnEmptyList() + { + UtilityLibraryRow? row = JsonSerializer.Deserialize( + "{\"id\":\"panel-id\"}", + PracticeJson.Options + ); + + Assert.Empty(row!.ToLineup().bots); + } + [Fact] public void AThrowWithNoRunUpSendsNoApproach() { diff --git a/shared/dotnet/FiveStack.Entities/Practice/LineupBot.cs b/shared/dotnet/FiveStack.Entities/Practice/LineupBot.cs new file mode 100644 index 00000000..a38d3550 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/LineupBot.cs @@ -0,0 +1,9 @@ +namespace FiveStack.Entities.Practice; + +public class LineupBot +{ + public float x { get; set; } + public float y { get; set; } + public float z { get; set; } + public float yaw { get; set; } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs b/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs index e06bb13d..8769f6b7 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs @@ -56,6 +56,9 @@ public class LineupRecord // for a throw made standing still. public List approach { get; set; } = new List(); + // The bots that stand for this throw, as the player left them. + public List bots { get; set; } = new List(); + // The measured bloom, when the panel has one. Arrives with the trajectory // artifact rather than with the library row, and is absent for everything // that is not a smoke. diff --git a/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs index 81a98a2f..05d7dc1f 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs @@ -52,6 +52,8 @@ public class UtilityIngestPayload public List? approach { get; set; } + public List? bots { get; set; } + // Only on a smoke whose cloud finished growing; a save waits for it. public SmokeVoxelPayload? smoke_voxels { get; set; } @@ -110,6 +112,8 @@ public static UtilityIngestPayload From(LineupRecord lineup) ? null : lineup.approach.Select(UtilityApproachPoint.From).ToList(), + bots = lineup.bots.Count == 0 ? null : lineup.bots, + smoke_voxels = lineup.smoke_voxels, }; } diff --git a/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs index 1a73ad93..eca05b56 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs @@ -52,6 +52,8 @@ public class UtilityLibraryRow public List? approach { get; set; } + public List? bots { get; set; } + // Where the player the library was asked for stands on this lineup, in // the panel's own totals. Null for one they have never thrown at, and // from a panel too old to send it. @@ -93,6 +95,7 @@ public LineupRecord ToLineup() detonation_position = new Vec3(land_x ?? 0f, land_y ?? 0f, land_z ?? 0f), flight_time = (flight_time_ms ?? 0) / 1000f, approach = UtilityApproachPoint.ToSamples(approach), + bots = bots ?? new List(), }; // A seed is one thing, not six numbers, so it is taken whole or not at diff --git a/shared/dotnet/FiveStack.Utilities/HudSlots.cs b/shared/dotnet/FiveStack.Utilities/HudSlots.cs index 97d1909c..99e86e69 100644 --- a/shared/dotnet/FiveStack.Utilities/HudSlots.cs +++ b/shared/dotnet/FiveStack.Utilities/HudSlots.cs @@ -70,7 +70,7 @@ public static class HudSlots public const string NadePick = "nade2_pick"; public const string NadeRun = "nade2_run"; public const string NadeEdit = "nade3_edit"; - public const string NadeBar = "nade3_bar"; + public const string NadeBar = "nade4_bar"; public const string NadeWheel = "nade2_wheel"; // Class toggled on a layout's root to show it for one player. The layouts @@ -127,9 +127,9 @@ public static string ThrowKeyBinding(string cap) public const int CompassColumns = 256; public const float CompassHalfSpanDegrees = 60f; - // TAB, E, R, then the three double taps and the clear key -- one row each, - // in that order. - public const int LegendKeys = 7; + // TAB, E, R, crouch twice, drop, then the other two double taps and the + // clear key -- one row each, in that order. + public const int LegendKeys = 8; // The command behind each keycap, in the form CS2's csgo_key formatter // reads: it draws whatever key the player has bound to it. @@ -139,6 +139,7 @@ public static string ThrowKeyBinding(string cap) "%use%", "%reload%", "%duck%", + "%drop%", "%sprint%", "%lookatweapon%", "%sellbackall%", diff --git a/shared/dotnet/FiveStack.Utilities/LineupBots.cs b/shared/dotnet/FiveStack.Utilities/LineupBots.cs new file mode 100644 index 00000000..8113ebda --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/LineupBots.cs @@ -0,0 +1,52 @@ +using FiveStack.Entities.Practice; + +namespace FiveStack.Utilities; + +public static class LineupBots +{ + // Closer than this to a saved spot and a standing bot is already that spot's. + public const float SameSpotUnits = 32f; + + // Waiting counts the lineups a press could mean when it cannot pick one. + public readonly record struct Choice(LineupRecord? Lineup, int Waiting); + + public static Choice Pick(LineupRecord? focused, IReadOnlyList here) + { + if (focused != null && focused.bots.Count > 0) + { + return new Choice(focused, 0); + } + + List withBots = here.Where(lineup => lineup.bots.Count > 0).ToList(); + + return withBots.Count == 1 ? new Choice(withBots[0], 0) : new Choice(null, withBots.Count); + } + + public static List Missing( + IReadOnlyList saved, + IReadOnlyList standing + ) + { + return saved + .Where(bot => + !standing.Any(spot => + (new Vec3(bot.x, bot.y, bot.z) - spot.feet_position).Length() <= SameSpotUnits + ) + ) + .ToList(); + } + + public static List Snapshot(IReadOnlyList standing, int max) + { + return standing + .Take(max) + .Select(spot => new LineupBot + { + x = spot.feet_position.x, + y = spot.feet_position.y, + z = spot.feet_position.z, + yaw = spot.yaw, + }) + .ToList(); + } +} From c6539315f79f3e349ce979e9bc30448e58b342f0 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 2 Nov 2026 20:42:20 -0500 Subject: [PATCH 2/2] bug: bots stay on the map they were placed on, and the bot key only answers to the lineup you point at --- apps/utility-sw/src/Hud/PracticeActions.cs | 4 +- apps/utility-sw/src/Hud/PracticeLineupBots.cs | 43 ++++++++++++++----- apps/utility-sw/src/UtilityPracticePlugin.cs | 9 +++- apps/utility-sw/test/LineupBotsTests.cs | 9 ++++ .../dotnet/FiveStack.Utilities/LineupBots.cs | 5 +++ 5 files changed, 56 insertions(+), 14 deletions(-) diff --git a/apps/utility-sw/src/Hud/PracticeActions.cs b/apps/utility-sw/src/Hud/PracticeActions.cs index 9ea7f0bd..52d511a8 100644 --- a/apps/utility-sw/src/Hud/PracticeActions.cs +++ b/apps/utility-sw/src/Hud/PracticeActions.cs @@ -183,8 +183,8 @@ private WheelPage WheelFor(IPlayer player, string page) { new WheelItem( "keepbots", - _bots.Count == 0 ? "Clear Lineup Bots" : "Keep On Lineup", - Disabled: KeepTarget(player) == null + "Keep On Lineup", + Disabled: _bots.Count == 0 || KeepTarget(player) == null ), }, }, diff --git a/apps/utility-sw/src/Hud/PracticeLineupBots.cs b/apps/utility-sw/src/Hud/PracticeLineupBots.cs index 3058c6be..68c06319 100644 --- a/apps/utility-sw/src/Hud/PracticeLineupBots.cs +++ b/apps/utility-sw/src/Hud/PracticeLineupBots.cs @@ -13,18 +13,23 @@ private static string BotCount(int bots) return bots == 1 ? "1 bot" : $"{bots} bots"; } + // Only lineups with bots are looked through, and nothing is loaded: a drill + // or a loaded throw holds the panels still, and must not stop the player + // pointing at the lineup they mean here. private LineupBots.Choice BotChoice(IPlayer player, CCSPlayerPawn pawn, Vec3 at) { - IReadOnlyList shown = Shown(player.SteamID); + List withBots = Shown(player.SteamID) + .Where(lineup => lineup.bots.Count > 0) + .ToList(); - if (!shown.Any(lineup => lineup.bots.Count > 0)) + if (withBots.Count == 0) { return default; } - (LineupRecord? focused, _, _) = Focused(player, pawn); + List here = PracticeReplay.SpotAt(withBots, at); - return LineupBots.Pick(focused, PracticeReplay.SpotAt(shown, at)); + return LineupBots.Pick(LookingAt(pawn, at, withBots, here, null), here); } private NadeBarTemplate.Key BotKey(IPlayer player, CCSPlayerPawn pawn, Vec3 at) @@ -45,7 +50,9 @@ private NadeBarTemplate.Key BotKey(IPlayer player, CCSPlayerPawn pawn, Vec3 at) return missing == 0 ? new NadeBarTemplate.Key("Its bots are up", On: true) - : new NadeBarTemplate.Key($"Spawn {BotCount(missing)} · {LineupName(choice.Lineup)}"); + : new NadeBarTemplate.Key( + $"Spawn {BotCount(missing)} · {Clip(LineupName(choice.Lineup), LegendNameLength)}" + ); } private static string LineupName(LineupRecord lineup) @@ -105,13 +112,22 @@ private void SpawnLineupBots(IPlayer player) ); } + // What the player is on or pointing at: a lineup loaded and walked away + // from is not one they are looking at to change. private LineupRecord? KeepTarget(IPlayer player) { - LineupRecord? lineup = DrillTarget(player); + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return null; + } + + (LineupRecord? lineup, _, _) = Focused(player, pawn); return lineup != null - && !string.IsNullOrEmpty(lineup.id) + && PracticeLineupUtility.IsPanelId(lineup.id) && lineup.author_steam_id == player.SteamID.ToString() ? lineup : null; @@ -128,6 +144,13 @@ private void KeepBotsOnLineup(IPlayer player) return; } + if (_bots.Count == 0) + { + Toast(player, "Place a bot first"); + + return; + } + ulong steamId = player.SteamID; string id = lineup.id!; List bots = LineupBots.Snapshot(_bots, MaxBots); @@ -154,9 +177,9 @@ private void KeepBotsOnLineup(IPlayer player) Toast( asker, - !saved ? "The panel did not save that" - : bots.Count == 0 ? $"Bots cleared from {LineupName(lineup)}" - : $"{BotCount(bots.Count)} saved to {LineupName(lineup)}" + saved + ? $"{BotCount(bots.Count)} saved to {Clip(LineupName(lineup), LegendNameLength)}" + : "The panel did not save that" ); }); }); diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 6e304b4f..36267d40 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -638,8 +638,8 @@ private void ForgetSpot(ulong steamId) private readonly HashSet _useHeld = new(); // Bots that have been placed, and where they were asked to stand. Kept so - // a map change or a cfg re-run can put them back rather than leaving the - // player with a bot standing in a spawn. + // a reload of the map or a cfg re-run can put them back rather than leaving + // the player with a bot standing in a spawn. private readonly List _bots = new(); /// @@ -2242,6 +2242,11 @@ private void OnMapLoad(string mapName) // into a NEW entity, which makes despawning it later actively harmful. _replay.SweepMarkers(); + if (!LineupBots.SpotsSurviveLoad(_library.Map, mapName)) + { + _bots.Clear(); + } + _library.SetMap(mapName); _session.Map = mapName; _callouts.MapLoaded(); diff --git a/apps/utility-sw/test/LineupBotsTests.cs b/apps/utility-sw/test/LineupBotsTests.cs index 1ff00b19..d9d6d74b 100644 --- a/apps/utility-sw/test/LineupBotsTests.cs +++ b/apps/utility-sw/test/LineupBotsTests.cs @@ -93,6 +93,15 @@ public void BotsThatBelongToSomethingElseStayOutOfIt() Assert.Single(LineupBots.Missing(saved, new[] { Standing(900f), Standing(0f, 0f, 500f) })); } + [Theory] + [InlineData("de_mirage", "de_mirage", true)] + [InlineData("de_mirage", "de_dust2", false)] + [InlineData("", "de_dust2", false)] + public void SpotsOnlyMeanSomethingOnTheMapTheyWerePlacedOn(string from, string to, bool kept) + { + Assert.Equal(kept, LineupBots.SpotsSurviveLoad(from, to)); + } + [Fact] public void ASnapshotKeepsWhereTheyStandAndWhichWayTheyFace() { diff --git a/shared/dotnet/FiveStack.Utilities/LineupBots.cs b/shared/dotnet/FiveStack.Utilities/LineupBots.cs index 8113ebda..12eadc8a 100644 --- a/shared/dotnet/FiveStack.Utilities/LineupBots.cs +++ b/shared/dotnet/FiveStack.Utilities/LineupBots.cs @@ -22,6 +22,11 @@ public static Choice Pick(LineupRecord? focused, IReadOnlyList her return withBots.Count == 1 ? new Choice(withBots[0], 0) : new Choice(null, withBots.Count); } + public static bool SpotsSurviveLoad(string fromMap, string toMap) + { + return fromMap == toMap; + } + public static List Missing( IReadOnlyList saved, IReadOnlyList standing