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..52d511a8 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",
+ "Keep On Lineup",
+ Disabled: _bots.Count == 0 || 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..68c06319
--- /dev/null
+++ b/apps/utility-sw/src/Hud/PracticeLineupBots.cs
@@ -0,0 +1,187 @@
+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";
+ }
+
+ // 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)
+ {
+ List withBots = Shown(player.SteamID)
+ .Where(lineup => lineup.bots.Count > 0)
+ .ToList();
+
+ if (withBots.Count == 0)
+ {
+ return default;
+ }
+
+ List here = PracticeReplay.SpotAt(withBots, at);
+
+ return LineupBots.Pick(LookingAt(pawn, at, withBots, here, null), here);
+ }
+
+ 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)} · {Clip(LineupName(choice.Lineup), LegendNameLength)}"
+ );
+ }
+
+ 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"
+ );
+ }
+
+ // 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)
+ {
+ CCSPlayerPawn? pawn = player.PlayerPawn;
+
+ if (pawn == null || !pawn.IsValid)
+ {
+ return null;
+ }
+
+ (LineupRecord? lineup, _, _) = Focused(player, pawn);
+
+ return
+ lineup != null
+ && PracticeLineupUtility.IsPanelId(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;
+ }
+
+ 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);
+
+ _ = 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
+ ? $"{BotCount(bots.Count)} saved to {Clip(LineupName(lineup), LegendNameLength)}"
+ : "The panel did not save that"
+ );
+ });
+ });
+ }
+}
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..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();
///
@@ -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
@@ -2237,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/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..d9d6d74b
--- /dev/null
+++ b/apps/utility-sw/test/LineupBotsTests.cs
@@ -0,0 +1,122 @@
+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) }));
+ }
+
+ [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()
+ {
+ 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..12eadc8a
--- /dev/null
+++ b/shared/dotnet/FiveStack.Utilities/LineupBots.cs
@@ -0,0 +1,57 @@
+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 bool SpotsSurviveLoad(string fromMap, string toMap)
+ {
+ return fromMap == toMap;
+ }
+
+ 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();
+ }
+}