diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e722da4b..6059cb57 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,12 +46,16 @@ jobs: - 'Directory.Build.props' - '.github/workflows/ci.yaml' - '.github/workflows/plugin-build.yaml' + # each game-server image builds both its match plugin and the + # practice plugin, so either source tree rebuilds it css: - *shared - 'apps/counterstrikesharp/**' + - 'apps/utility-css/**' sw: - *shared - 'apps/swiftly/**' + - 'apps/utility-sw/**' validator: - 'apps/gamedata-validator/**' - 'shared/gamedata/**' @@ -71,6 +75,7 @@ jobs: version_tag_regex: '^(?:css-)?v0\.0\.(\d+)$' channel: ${{ needs.changes.outputs.channel }} run_tests: true + extra_test_dir: apps/utility-css secrets: inherit swiftly: @@ -86,6 +91,7 @@ jobs: version_floor: 41 channel: ${{ needs.changes.outputs.channel }} run_tests: true + extra_test_dir: apps/utility-sw secrets: inherit gamedata-validator: diff --git a/.github/workflows/plugin-build.yaml b/.github/workflows/plugin-build.yaml index a279f6f3..e8727e70 100644 --- a/.github/workflows/plugin-build.yaml +++ b/.github/workflows/plugin-build.yaml @@ -29,6 +29,11 @@ on: required: false default: false type: boolean + extra_test_dir: + description: "second app whose suite ships in this image (the practice plugin)" + required: false + default: "" + type: string jobs: build: @@ -47,7 +52,14 @@ jobs: - name: Test if: inputs.run_tests - run: dotnet test ${{ inputs.app_dir }}/test/FiveStack.Tests.csproj -c Release + env: + APP_DIR: ${{ inputs.app_dir }} + EXTRA_TEST_DIR: ${{ inputs.extra_test_dir }} + run: | + dotnet test "$APP_DIR/test/FiveStack.Tests.csproj" -c Release + if [ -n "$EXTRA_TEST_DIR" ]; then + dotnet test "$EXTRA_TEST_DIR/test/FiveStack.Tests.csproj" -c Release + fi - name: Resolve version and tags id: resolve diff --git a/apps/counterstrikesharp/Dockerfile b/apps/counterstrikesharp/Dockerfile index d3e10355..11d53274 100644 --- a/apps/counterstrikesharp/Dockerfile +++ b/apps/counterstrikesharp/Dockerfile @@ -1,4 +1,4 @@ -FROM registry.gitlab.steamos.cloud/steamrt/sniper/platform:latest-container-runtime-depot AS build +FROM registry.gitlab.steamos.cloud/steamrt/sniper/platform:latest-container-runtime-depot AS dotnet-sdk # Install .NET SDK 10.0 RUN apt-get update && \ @@ -14,6 +14,8 @@ RUN apt-get update && \ ENV PATH="/usr/share/dotnet:${PATH}" ENV DOTNET_ROOT="/usr/share/dotnet" +FROM dotnet-sdk AS build + WORKDIR /mod COPY Directory.Build.props ./ @@ -35,6 +37,30 @@ RUN rm /mod/release/CounterStrikeSharp.API.dll COPY apps/counterstrikesharp/src/lang /mod/release/lang +# The practice plugin is the other half of this image: a server runs either it +# or the match plugin, never both, so it is built here and symlinked into place +# only when INSTALL_UTILITY_PRACTICE_PLUGIN is set. +FROM dotnet-sdk AS utility-build + +WORKDIR /mod + +COPY Directory.Build.props ./ +COPY apps/utility-css/src/UtilityPractice.csproj apps/utility-css/src/ + +RUN dotnet restore apps/utility-css/src/UtilityPractice.csproj + +COPY shared shared +COPY apps/utility-css apps/utility-css + +ARG RELEASE_VERSION +ENV RELEASE_VERSION=${RELEASE_VERSION} + +RUN sed -i "s/__RELEASE_VERSION__/${RELEASE_VERSION}/" apps/utility-css/src/UtilityPracticePlugin.cs + +RUN dotnet build -c Release apps/utility-css/src/UtilityPractice.csproj -o release + +RUN rm -f /mod/release/CounterStrikeSharp.API.dll + # New stage for creating the zip file FROM debian:bookworm-slim AS zip-creator @@ -60,6 +86,7 @@ ENV AUTOLOAD_PLUGINS=true ENV PLUGINS_DIR="/opt/custom-plugins" ENV INSTALL_5STACK_PLUGIN=true +ENV INSTALL_UTILITY_PRACTICE_PLUGIN=false ENV GAME_ID="730" ENV GAME_PARAMS="" @@ -125,6 +152,7 @@ COPY apps/counterstrikesharp/cfg /opt/server-cfg COPY shared/scripts /opt/scripts COPY apps/counterstrikesharp/scripts /opt/scripts COPY --from=build /mod/release /opt/mod +COPY --from=utility-build /mod/release /opt/utility-practice RUN mv /opt/metamod/addons /opt/addons && \ cp -R /opt/counterstrikesharp/addons/metamod /opt/addons && \ diff --git a/apps/counterstrikesharp/Dockerfile.dev b/apps/counterstrikesharp/Dockerfile.dev index 6fb983c4..404cb73d 100644 --- a/apps/counterstrikesharp/Dockerfile.dev +++ b/apps/counterstrikesharp/Dockerfile.dev @@ -11,6 +11,7 @@ ENV AUTOLOAD_PLUGINS=true ENV PLUGINS_DIR="/opt/custom-plugins" ENV INSTALL_5STACK_PLUGIN=true +ENV INSTALL_UTILITY_PRACTICE_PLUGIN=false ENV GAME_ID="730" ENV GAME_PARAMS="" diff --git a/apps/counterstrikesharp/scripts/setup.sh b/apps/counterstrikesharp/scripts/setup.sh index 6804ccb4..c79e381c 100755 --- a/apps/counterstrikesharp/scripts/setup.sh +++ b/apps/counterstrikesharp/scripts/setup.sh @@ -66,7 +66,10 @@ fi ln -s "/opt/custom-plugins/addons/counterstrikesharp/configs" "${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/configs" -if [ "$SERVER_TYPE" != "Ranked" ]; then +# Public-server treatment: every installed custom plugin gets symlinked in, +# which is what "load on every match" is meant to gate. A practice server is +# 5stack-managed like a ranked one -- it runs its own plugin and nothing else. +if [ "$SERVER_TYPE" != "Ranked" ] && [ "$SERVER_TYPE" != "Practice" ]; then if [ ! -d "/opt/custom-plugins" ]; then mkdir -p "/opt/custom-plugins" fi @@ -129,6 +132,18 @@ if $INSTALL_5STACK_PLUGIN = true ; then fi fi +# A practice server runs this instead of the match plugin, never alongside it. +if $INSTALL_UTILITY_PRACTICE_PLUGIN = true ; then + echo "---Install Utility Practice---" + if [ "${DEV_SWAPPED}" == "1" ]; then + # css and sw dev builds share the dev volume; each uses its own subfolder + mkdir -p "/opt/dev/utility-css" + ln -s "/opt/dev/utility-css" "${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/plugins/UtilityPractice" + else + ln -s "/opt/utility-practice" "${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/plugins/UtilityPractice" + fi +fi + if [ ! -e "$INSTANCE_SERVER_DIR/game/csgo/addons/counterstrikesharp/configs/core.json" ]; then cp "/opt/server-cfg/core.json" "$INSTANCE_SERVER_DIR/game/csgo/addons/counterstrikesharp/configs" fi diff --git a/apps/swiftly/Dockerfile b/apps/swiftly/Dockerfile index f6571e56..dcd8a74a 100644 --- a/apps/swiftly/Dockerfile +++ b/apps/swiftly/Dockerfile @@ -1,4 +1,4 @@ -FROM registry.gitlab.steamos.cloud/steamrt/sniper/platform:latest-container-runtime-depot AS build +FROM registry.gitlab.steamos.cloud/steamrt/sniper/platform:latest-container-runtime-depot AS dotnet-sdk RUN apt-get update && \ apt-get install -y --no-install-recommends \ @@ -13,6 +13,8 @@ RUN apt-get update && \ ENV PATH="/usr/share/dotnet:${PATH}" ENV DOTNET_ROOT="/usr/share/dotnet" +FROM dotnet-sdk AS build + WORKDIR /mod COPY Directory.Build.props ./ @@ -30,6 +32,28 @@ RUN sed -i "s/__RELEASE_VERSION__/${RELEASE_VERSION}/" apps/swiftly/src/FiveStac RUN dotnet publish -c Release apps/swiftly/src/FiveStack.csproj -o /mod/release +# The practice plugin is the other half of this image: a server runs either it +# or the match plugin, never both, so it is built here and symlinked into place +# only when INSTALL_UTILITY_PRACTICE_PLUGIN is set. +FROM dotnet-sdk AS utility-build + +WORKDIR /mod + +COPY Directory.Build.props ./ +COPY apps/utility-sw/src/UtilityPractice.csproj apps/utility-sw/src/ + +RUN dotnet restore apps/utility-sw/src/UtilityPractice.csproj + +COPY shared shared +COPY apps/utility-sw apps/utility-sw + +ARG RELEASE_VERSION +ENV RELEASE_VERSION=${RELEASE_VERSION} + +RUN sed -i "s/__RELEASE_VERSION__/${RELEASE_VERSION}/" apps/utility-sw/src/UtilityPracticePlugin.cs + +RUN dotnet publish -c Release apps/utility-sw/src/UtilityPractice.csproj -o /mod/release + FROM debian:bookworm-slim AS zip-creator WORKDIR /zip-content @@ -54,6 +78,7 @@ ENV AUTOLOAD_PLUGINS=true ENV PLUGINS_DIR="/opt/custom-plugins" ENV INSTALL_5STACK_PLUGIN=true +ENV INSTALL_UTILITY_PRACTICE_PLUGIN=false ENV GAME_ID="730" ENV GAME_PARAMS="" @@ -132,6 +157,7 @@ COPY apps/swiftly/cfg /opt/server-cfg COPY shared/scripts /opt/scripts COPY apps/swiftly/scripts /opt/scripts COPY --from=build /mod/release /opt/mod +COPY --from=utility-build /mod/release /opt/utility-practice RUN cp -R /opt/swiftlys2/swiftlys2-linux-${SWIFTLYS2_VERSION}-with-runtimes/addons /opt/addons && \ rm -rf /opt/swiftlys2 diff --git a/apps/swiftly/scripts/setup.sh b/apps/swiftly/scripts/setup.sh index 9100399a..e8aa451b 100755 --- a/apps/swiftly/scripts/setup.sh +++ b/apps/swiftly/scripts/setup.sh @@ -89,7 +89,10 @@ if [ "$ENABLE_CSS_COMPAT" = "true" ]; then ln -s "/opt/custom-plugins/addons/counterstrikesharp/configs" "${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/configs" fi -if [ "$SERVER_TYPE" != "Ranked" ]; then +# Public-server treatment: every installed custom plugin gets symlinked in, +# which is what "load on every match" is meant to gate. A practice server is +# 5stack-managed like a ranked one -- it runs its own plugin and nothing else. +if [ "$SERVER_TYPE" != "Ranked" ] && [ "$SERVER_TYPE" != "Practice" ]; then if [ ! -d "/opt/custom-plugins" ]; then mkdir -p "/opt/custom-plugins" fi @@ -178,6 +181,31 @@ if $INSTALL_5STACK_PLUGIN = true ; then fi fi +# A practice server runs this instead of the match plugin, never alongside it. +if $INSTALL_UTILITY_PRACTICE_PLUGIN = true ; then + echo "---Install Utility Practice---" + UTILITY_PRACTICE_PLUGIN_DIR="${INSTANCE_SERVER_DIR}/game/csgo/addons/swiftlys2/plugins/UtilityPractice" + if [ "${DEV_SWAPPED}" == "1" ]; then + # AutoHotReload's recursive FileSystemWatcher does not follow symlinked plugin + # dirs, so hot reload needs the dev volume mounted straight onto this path + # (see the dev deployment). Without the mount, fall back to a symlink. + mkdir -p "$UTILITY_PRACTICE_PLUGIN_DIR" + if mountpoint -q "$UTILITY_PRACTICE_PLUGIN_DIR"; then + echo "---Utility Practice dev: plugin dir is a mounted volume (hot reload enabled)---" + else + # css and sw dev builds share the dev volume; each uses its own subfolder + rmdir "$UTILITY_PRACTICE_PLUGIN_DIR" 2>/dev/null + mkdir -p "/opt/dev/utility-sw" + ln -s "/opt/dev/utility-sw" "$UTILITY_PRACTICE_PLUGIN_DIR" + echo "---Utility Practice dev: symlinked /opt/dev/utility-sw (hot reload OFF; use 'sw plugins reload UtilityPractice')---" + fi + elif [ ! -e "$UTILITY_PRACTICE_PLUGIN_DIR" ]; then + ln -s "/opt/utility-practice" "$UTILITY_PRACTICE_PLUGIN_DIR" + else + echo "---Utility Practice: plugin dir already present, skipping /opt/utility-practice symlink---" + fi +fi + if [ ! -e "$INSTANCE_SERVER_DIR/game/csgo/addons/swiftlys2/configs/core.jsonc" ]; then cp "/opt/server-cfg/core.jsonc" "$INSTANCE_SERVER_DIR/game/csgo/addons/swiftlys2/configs" fi diff --git a/apps/utility-css/scripts/dev.sh b/apps/utility-css/scripts/dev.sh new file mode 100755 index 00000000..8aa38c55 --- /dev/null +++ b/apps/utility-css/scripts/dev.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +apt update +apt-get install inotify-tools -y + +# Variable to store the PID of dotnet watch process +dotnet_watch_pid="" + +# Function to kill the dotnet watch build process +kill_dotnet_watch() { + if [ -n "$dotnet_watch_pid" ]; then + kill "$dotnet_watch_pid" + exit + fi +} +dotnet build apps/utility-css/src + +dotnet watch build --project apps/utility-css/src & +dotnet_watch_pid=$! + +# Set up trap to kill dotnet watch process on script exit +trap kill_dotnet_watch EXIT + +directory_to_watch="/opt/5stack/apps/utility-css/src/bin/Debug/net10.0" + +# Matches the path apps/counterstrikesharp/scripts/setup.sh symlinks in. +DEV_DIR="/opt/dev/utility-css" +mkdir -p "$DEV_DIR" + +while true; do + rm -f "$directory_to_watch/CounterStrikeSharp.API.dll" + inotifywait -r -e modify,create,delete,move "$directory_to_watch" + cp -r "$directory_to_watch"/* "$DEV_DIR" +done diff --git a/apps/utility-css/src/Commands/Practice.cs b/apps/utility-css/src/Commands/Practice.cs new file mode 100644 index 00000000..89f958d5 --- /dev/null +++ b/apps/utility-css/src/Commands/Practice.cs @@ -0,0 +1,918 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Commands; +using CounterStrikeSharp.API.Modules.Utils; +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; + +namespace UtilityPractice; + +// The css_ prefix is what turns a console command into a chat command, so +// every player-facing verb here carries it and is spoken as ".save", ".load" +// and so on. Replies are always to the caller: a practice server is several +// people working on unrelated things in the same map. +public partial class UtilityPracticePlugin +{ + [ConsoleCommand("css_save", "Saves your last throw as a named lineup")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnSave(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + string name = command.ArgString.Trim().Trim('"'); + + if (string.IsNullOrEmpty(name)) + { + command.ReplyToCommand($" {ChatColors.Red}usage: .save "); + return; + } + + LineupRecord? thrown = _recorder.LastThrow(player.SteamID); + + if (thrown == null) + { + command.ReplyToCommand($" {ChatColors.Red}throw something first"); + return; + } + + if (_library.For(player.SteamID).Count >= _config.MaxSaved) + { + command.ReplyToCommand( + $" {ChatColors.Red}you already have {_config.MaxSaved} saved lineups on this map" + ); + return; + } + + thrown.name = name; + thrown.map = _library.Map; + thrown.side = player.Team == CsTeam.CounterTerrorist ? "CT" : "TERRORIST"; + thrown.visibility = nameof(eLineupVisibility.Private); + thrown.plugin_version = ModuleVersion; + + _library.Add(player.SteamID, thrown); + + command.ReplyToCommand($" {ChatColors.Green}saved {ChatColors.Default}{name}"); + + ulong steamId = player.SteamID; + + _ = Task.Run(async () => + { + string? id = await _api.Ingest(thrown); + + Server.NextFrame(() => + { + if (id != null) + { + thrown.id = id; + return; + } + + Tell(steamId, $" {ChatColors.Red}{name} could not reach the panel; it will retry"); + }); + }); + } + + [ConsoleCommand("css_load", "Teleports you to a saved lineup")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnLoad(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + string query = command.ArgString.Trim().Trim('"'); + PracticeState state = _system.StateFor(player.SteamID); + Vec3? near = PracticeSystem.Where(player)?.feet_position; + + LineupRecord? lineup = _library.Resolve(player.SteamID, query, near); + + if (lineup == null) + { + command.ReplyToCommand($" {ChatColors.Red}no lineup matches \"{query}\""); + return; + } + + state.Results.Clear(); + state.Results.AddRange( + PracticeLineupUtility.Filter(_library.For(player.SteamID), query, near) + ); + state.Index = state.Results.FindIndex(match => match.client_id == lineup.client_id); + + Apply(player, lineup); + } + + [ConsoleCommand("css_list", "Lists your saved lineups on this map")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnList(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + IReadOnlyList lineups = _library.For(player.SteamID); + + if (lineups.Count == 0) + { + command.ReplyToCommand($" {ChatColors.Grey}no saved lineups on {_library.Map}"); + return; + } + + command.ReplyToCommand($" {ChatColors.Green}{lineups.Count} lineups on {_library.Map}"); + + foreach (LineupRecord lineup in lineups) + { + command.ReplyToCommand( + $" {ChatColors.Default}{lineup.name} {ChatColors.Grey}({lineup.utility_type}, {lineup.technique})" + ); + } + } + + [ConsoleCommand("css_next", "Loads the next lineup in your last search")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnNext(CCSPlayerController? player, CommandInfo command) + { + Step(player, command, 1); + } + + [ConsoleCommand("css_prev", "Loads the previous lineup in your last search")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnPrev(CCSPlayerController? player, CommandInfo command) + { + Step(player, command, -1); + } + + [ConsoleCommand("css_rethrow", "Puts you back on the lineup you last loaded")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnRethrow(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + LineupRecord? loaded = _system.StateFor(player.SteamID).Loaded; + + if (loaded == null) + { + command.ReplyToCommand($" {ChatColors.Red}nothing loaded"); + return; + } + + Apply(player, loaded); + } + + [ConsoleCommand("css_last", "Puts you back on your last throw")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnLast(CCSPlayerController? player, CommandInfo command) + { + Back(player, command, 0); + } + + [ConsoleCommand("css_back", "Puts you back on the throw n before your last")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnBack(CCSPlayerController? player, CommandInfo command) + { + if (!int.TryParse(command.ArgString.Trim(), out int back) || back < 0) + { + command?.ReplyToCommand($" {ChatColors.Red}usage: .back "); + return; + } + + Back(player, command, back); + } + + [ConsoleCommand("css_clear", "Clears your lineup preview")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnClear(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + state.Loaded = null; + state.Results.Clear(); + state.Index = -1; + state.Bloom = false; + + _replay.ClearGhosts(player.SteamID); + + command.ReplyToCommand($" {ChatColors.Green}cleared"); + } + + [ConsoleCommand("css_bloom", "Outlines where the loaded lineup would bloom")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnBloom(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + LineupRecord? loaded = state.Loaded; + + if (loaded == null) + { + command.ReplyToCommand($" {ChatColors.Red}load a lineup first"); + return; + } + + if (!_config.GhostPreview) + { + command.ReplyToCommand($" {ChatColors.Red}previews are disabled on this server"); + return; + } + + state.Bloom = !state.Bloom; + + if (!state.Bloom) + { + _replay.ClearBloom(player.SteamID); + command.ReplyToCommand($" {ChatColors.Green}bloom off"); + return; + } + + command.ReplyToCommand($" {ChatColors.Grey}outlining {loaded.name}..."); + + ulong steamId = player.SteamID; + + // The same fetch .load already made, and free once it has landed. + _library.EnsureTrajectory(loaded, steamId, fetched => DrawBloom(steamId, fetched)); + } + + [ConsoleCommand("css_playbook", "Runs the loaded execute: .playbook / .playbook stop")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnPlaybook(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + string argument = command.ArgString.Trim().Trim('"'); + + if (argument.Equals("stop", StringComparison.OrdinalIgnoreCase)) + { + if (!_playbook.Stop()) + { + command.ReplyToCommand($" {ChatColors.Red}nothing is running"); + return; + } + + Server.PrintToChatAll($" {ChatColors.Green}{player.PlayerName} stopped the execute"); + return; + } + + StartPlaybook(player, command); + } + + [ConsoleCommand("css_run", "Runs the loaded execute")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnRun(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + StartPlaybook(player, command); + } + + [ConsoleCommand("css_drill", "Drills your lineups: .drill [count] [worst], .drill stop")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnDrill(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + DrillRequest request = DrillUtility.Parse(command.ArgString); + + if (!request.Valid) + { + command.ReplyToCommand( + $" {ChatColors.Red}usage: .drill [count] [worst|random] / .drill stop" + ); + return; + } + + if (request.Stop) + { + if (!_drill.Stop(player.SteamID)) + { + command.ReplyToCommand($" {ChatColors.Red}you are not drilling"); + } + return; + } + + switch (_drill.Start(player.SteamID, request.Order, request.Count)) + { + case eDrillStart.AlreadyRunning: + command.ReplyToCommand($" {ChatColors.Red}already drilling; .drill stop first"); + return; + case eDrillStart.ReplayDisabled: + command.ReplyToCommand($" {ChatColors.Red}replay is disabled on this server"); + return; + case eDrillStart.NotConnected: + command.ReplyToCommand( + $" {ChatColors.Red}this server has no panel, so a throw cannot be scored" + ); + return; + case eDrillStart.NothingToDrill: + command.ReplyToCommand( + $" {ChatColors.Red}nothing on {_library.Map} to drill; save some lineups or .reload" + ); + return; + } + } + + [ConsoleCommand("css_skip", "Skips the lineup your drill is on")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnSkip(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + if (!_drill.Skip(player.SteamID)) + { + command.ReplyToCommand($" {ChatColors.Red}you are not drilling"); + } + } + + [ConsoleCommand("css_pos", "Saves and restores positions: .pos save , .pos ")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnPos(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + string[] args = command + .ArgString.Trim() + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (args.Length == 0) + { + if (state.Positions.Count == 0) + { + command.ReplyToCommand($" {ChatColors.Grey}no saved positions"); + return; + } + + command.ReplyToCommand( + $" {ChatColors.Green}positions: {ChatColors.Default}{string.Join(", ", state.Positions.Keys)}" + ); + return; + } + + if (args[0].Equals("save", StringComparison.OrdinalIgnoreCase)) + { + if (args.Length < 2) + { + command.ReplyToCommand($" {ChatColors.Red}usage: .pos save "); + return; + } + + if (!_system.SavePosition(player, args[1])) + { + command.ReplyToCommand($" {ChatColors.Red}unable to save that position"); + return; + } + + command.ReplyToCommand( + $" {ChatColors.Green}saved position {ChatColors.Default}{args[1]}" + ); + return; + } + + if (!state.Positions.TryGetValue(args[0], out ThrowSnapshot? position)) + { + command.ReplyToCommand($" {ChatColors.Red}no position named {args[0]}"); + return; + } + + PracticeSystem.TeleportTo(player, position); + } + + [ConsoleCommand("css_spawn", "Teleports you to a spawn point")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnSpawn(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + List spawns = PracticeSystem.SpawnPoints(); + + if (spawns.Count == 0) + { + command.ReplyToCommand($" {ChatColors.Red}this map has no spawn points"); + return; + } + + if (!int.TryParse(command.ArgString.Trim(), out int index)) + { + command.ReplyToCommand($" {ChatColors.Red}usage: .spawn <1-{spawns.Count}>"); + return; + } + + index = Math.Clamp(index, 1, spawns.Count); + + PracticeSystem.TeleportTo(player, spawns[index - 1]); + command.ReplyToCommand($" {ChatColors.Green}spawn {index}/{spawns.Count}"); + } + + [ConsoleCommand("css_noclip", "Toggles noclip")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnNoclip(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + state.Noclip = !state.Noclip; + + command.ReplyToCommand($" {ChatColors.Green}noclip {Toggle(state.Noclip)}"); + } + + [ConsoleCommand("css_god", "Toggles god mode")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnGod(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + state.God = !state.God; + + command.ReplyToCommand($" {ChatColors.Green}god {Toggle(state.God)}"); + } + + [ConsoleCommand("css_timer", "Starts and stops a stopwatch")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnTimer(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + + if (state.TimerStartedAt == null) + { + state.TimerStartedAt = DateTime.UtcNow; + command.ReplyToCommand($" {ChatColors.Green}timer started"); + return; + } + + double elapsed = (DateTime.UtcNow - state.TimerStartedAt.Value).TotalSeconds; + state.TimerStartedAt = null; + + command.ReplyToCommand($" {ChatColors.Green}timer stopped at {elapsed:0.00}s"); + } + + [ConsoleCommand("css_solo", "Toggles whether your previews are yours alone")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnSolo(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + state.Solo = !state.Solo; + + command.ReplyToCommand( + state.Solo + ? $" {ChatColors.Green}solo on {ChatColors.Grey}(you only see your own previews)" + : $" {ChatColors.Green}solo off {ChatColors.Grey}(you see everyone's previews)" + ); + } + + // Sibling of .solo: that one decides whose previews you see, this one + // decides whether you see any at all. It takes an explicit on/off as well + // as toggling, because the caller that needs it most is a capture client + // that cannot read back what state it is in. + [ConsoleCommand("css_ghosts", "Turns your own preview lines on or off")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnGhosts(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + + if (!PracticeSignalUtility.TryParseToggle(command.ArgString, state.Ghosts, out bool wanted)) + { + command.ReplyToCommand($" {ChatColors.Red}usage: .ghosts [on|off]"); + return; + } + + state.Ghosts = wanted; + + if (!wanted) + { + _replay.ClearGhosts(player.SteamID); + } + + command.ReplyToCommand($" {ChatColors.Green}ghost previews {Toggle(state.Ghosts)}"); + } + + [ConsoleCommand("css_delete", "Deletes the lineup you have loaded")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnDelete(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + LineupRecord? loaded = state.Loaded; + + if (loaded == null) + { + command.ReplyToCommand($" {ChatColors.Red}load a lineup first"); + return; + } + + _library.Remove(player.SteamID, loaded); + state.Results.RemoveAll(match => match.client_id == loaded.client_id); + state.Loaded = null; + _replay.ClearGhosts(player.SteamID); + + command.ReplyToCommand($" {ChatColors.Green}deleted {ChatColors.Default}{loaded.name}"); + + if (loaded.id == null) + { + return; + } + + string id = loaded.id; + ulong steamId = player.SteamID; + + _ = Task.Run(async () => + { + bool deleted = await _api.Delete(id); + + if (deleted) + { + return; + } + + Server.NextFrame(() => + Tell(steamId, $" {ChatColors.Red}{loaded.name} is still on the panel; try .reload") + ); + }); + } + + [ConsoleCommand("css_reload", "Re-fetches your lineups from the panel")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnReload(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + ulong steamId = player.SteamID; + + command.ReplyToCommand($" {ChatColors.Grey}reloading..."); + + _library.Refresh( + steamId, + count => + { + Tell( + steamId, + count < 0 + ? $" {ChatColors.Red}the panel did not answer" + : $" {ChatColors.Green}{count} lineups on {_library.Map}" + ); + } + ); + } + + [ConsoleCommand("css_help", "Lists the practice commands")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnPracticeHelp(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + foreach (string line in HelpLines) + { + command.ReplyToCommand(line); + } + } + + // Server-only, like utility_practice_refresh below: the panel sends this + // over RCON when somebody presses "load me in" on the website, so the + // command has to name the player rather than being spoken by them. + [ConsoleCommand("utility_practice_load", "Stands a named player on a lineup, by id")] + [CommandHelper( + minArgs: 2, + usage: " ", + whoCanExecute: CommandUsage.SERVER_ONLY + )] + public void OnRemoteLoad(CCSPlayerController? _, CommandInfo command) + { + if (!ulong.TryParse(command.GetArg(1), out ulong steamId)) + { + command.ReplyToCommand("usage: utility_practice_load "); + return; + } + + string lineupId = command.GetArg(2).Trim().Trim('"'); + + if (string.IsNullOrEmpty(lineupId)) + { + command.ReplyToCommand("usage: utility_practice_load "); + return; + } + + RemoteLoad(steamId, lineupId, refreshed: false); + } + + private void RemoteLoad(ulong steamId, string lineupId, bool refreshed) + { + CCSPlayerController? player = Utilities.GetPlayerFromSteamId(steamId); + + if (player == null || !player.IsValid) + { + return; + } + + LineupRecord? lineup = PracticeLineupUtility.ById(_library.For(steamId), lineupId); + + if (lineup != null) + { + Apply(player, lineup); + return; + } + + // Not in the cached library. That is the normal case rather than an + // error: the panel sends lineups this player has never loaded here -- + // a scratch throw off the meta browser, or one saved on another + // device -- and the cache is only refreshed on demand. One refresh, + // then give up; retrying past that would hammer the panel every time + // somebody sends a lineup that really is gone. + if (refreshed) + { + Tell(steamId, $" {ChatColors.Red}that lineup is not available on this server"); + return; + } + + _library.Refresh(steamId, _ => RemoteLoad(steamId, lineupId, refreshed: true)); + } + + // Server-only and deliberately unprefixed, like the match plugin's + // get_match: the panel calls it when the roster or the library changes. + [ConsoleCommand("utility_practice_refresh", "Re-reads the practice session from the panel")] + [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)] + public void OnRefresh(CCSPlayerController? player, CommandInfo command) + { + RefreshEverything(); + } + + private const float WelcomeDelaySeconds = 2f; + + // Deliberately short. The full list reads as spam on every join; these are + // the four that get somebody throwing, and .help is where the rest lives. + private static readonly string[] WelcomeLines = new[] + { + $" {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}.help {ChatColors.Grey}everything else", + }; + + private static readonly string[] HelpLines = new[] + { + $" {ChatColors.Green}utility practice", + $" {ChatColors.Default}.save {ChatColors.Grey}saves your last throw", + $" {ChatColors.Default}.load {ChatColors.Grey}teleports you to a lineup", + $" {ChatColors.Default}.next / .prev {ChatColors.Grey}walk the last search", + $" {ChatColors.Default}.rethrow {ChatColors.Grey}back to the loaded lineup", + $" {ChatColors.Default}.last / .back {ChatColors.Grey}back to a throw you made", + $" {ChatColors.Default}.list / .reload / .delete {ChatColors.Grey}manage your library", + $" {ChatColors.Default}.pos save / .pos {ChatColors.Grey}saved positions", + $" {ChatColors.Default}.spawn {ChatColors.Grey}teleports to a spawn point", + $" {ChatColors.Default}.bloom {ChatColors.Grey}outlines where the loaded smoke lands", + $" {ChatColors.Default}.solve {ChatColors.Grey}SwiftlyS2 builds only", + $" {ChatColors.Default}.drill [count] [worst] / .skip {ChatColors.Grey}drills your book and scores it", + $" {ChatColors.Default}.playbook / .run / .playbook stop {ChatColors.Grey}the loaded execute", + $" {ChatColors.Default}.ghosts [on|off] {ChatColors.Grey}draws the preview line, or does not", + $" {ChatColors.Default}.noclip / .god / .timer / .solo / .clear", + }; + + private void StartPlaybook(CCSPlayerController player, CommandInfo command) + { + switch (_playbook.Start(_library.Map)) + { + case ePlaybookStart.NoPlaybook: + command.ReplyToCommand($" {ChatColors.Red}no execute is loaded on this session"); + return; + case ePlaybookStart.NoSteps: + command.ReplyToCommand($" {ChatColors.Red}that execute has no steps"); + return; + case ePlaybookStart.WrongMap: + command.ReplyToCommand($" {ChatColors.Red}that execute is for another map"); + return; + case ePlaybookStart.AlreadyRunning: + command.ReplyToCommand($" {ChatColors.Red}already running; .playbook stop first"); + return; + } + + IReadOnlyList steps = _playbook.Steps; + + Server.PrintToChatAll( + $" {ChatColors.Green}{player.PlayerName} started {ChatColors.Default}{_playbook.Loaded?.name} {ChatColors.Grey}({steps.Count} steps)" + ); + + for (int index = 0; index < steps.Count; index++) + { + UtilityPlaybookStep step = steps[index]; + string who = PlaybookUtility.IsAssigned(step) ? step.assigned_steam_id! : "anyone"; + + command.ReplyToCommand( + $" {ChatColors.Grey}{index + 1}. {step.offset_ms / 1000f:0.0}s {ChatColors.Default}{step.lineup?.name} {ChatColors.Grey}{who}" + ); + } + } + + // A mined lineup's stance and aim are fitted to the flight the demo + // recorded, which puts them a degree or two out. That is close enough to + // practise toward and not close enough to trust, so the player is told + // rather than left reading it as a precise alignment. + private void WarnIfInexact(CCSPlayerController player, LineupRecord lineup) + { + if (!lineup.IsKnownInexact()) + { + return; + } + + if (!_system.StateFor(player.SteamID).WarnedInexact.Add(lineup.client_id)) + { + return; + } + + player.PrintToChat( + $" {ChatColors.Yellow}{lineup.name} is {lineup.confidence}, not measured {ChatColors.Grey}- the aim is inferred to a degree or two, so walk it in" + ); + } + + // The measurement rides along with the flight path, so the outline cannot + // be drawn until that fetch has landed. + private void DrawBloom(ulong steamId, LineupRecord fetched) + { + PracticeState state = _system.StateFor(steamId); + + if (!state.Bloom || state.Loaded != fetched) + { + return; + } + + CCSPlayerController? player = Utilities.GetPlayerFromSteamId(steamId); + + if (player == null || !player.IsValid) + { + return; + } + + int beams = _replay.ShowBloom(player, fetched); + + Tell( + steamId, + beams == 0 + ? $" {ChatColors.Grey}no measured bloom for {fetched.name}" + : $" {ChatColors.Green}bloom on {ChatColors.Grey}({beams} lines)" + ); + } + + private void Step(CCSPlayerController? player, CommandInfo command, int direction) + { + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + + if (state.Results.Count == 0) + { + command.ReplyToCommand($" {ChatColors.Red}load something first"); + return; + } + + state.Index = + ((state.Index + direction) % state.Results.Count + state.Results.Count) + % state.Results.Count; + + Apply(player, state.Results[state.Index]); + } + + private void Back(CCSPlayerController? player, CommandInfo command, int back) + { + if (player == null || !player.IsValid) + { + return; + } + + LineupRecord? thrown = _recorder.LastThrow(player.SteamID, back); + + if (thrown == null) + { + command.ReplyToCommand($" {ChatColors.Red}no throw that far back"); + return; + } + + Apply(player, thrown); + } + + private void Apply(CCSPlayerController player, LineupRecord lineup) + { + if (!_config.ReplayEnabled) + { + player.PrintToChat($" {ChatColors.Red}replay is disabled on this server"); + return; + } + + _system.StateFor(player.SteamID).Loaded = lineup; + + // 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. + _replay.Load(player, lineup); + _replay.ShowGhost(player, lineup); + WarnIfInexact(player, lineup); + + ulong steamId = player.SteamID; + + _library.EnsureTrajectory( + lineup, + steamId, + fetched => + { + // The player may have loaded something else while the path was + // in flight; drawing it now would replace what they are looking + // at with the previous lineup. + if (_system.StateFor(steamId).Loaded != fetched) + { + return; + } + + CCSPlayerController? still = Utilities.GetPlayerFromSteamId(steamId); + + if (still != null && still.IsValid) + { + _replay.ShowGhost(still, fetched); + } + + DrawBloom(steamId, fetched); + } + ); + } + + private static string Toggle(bool on) + { + return on ? "on" : "off"; + } + + private static void Tell(ulong steamId, string message) + { + CCSPlayerController? player = Utilities.GetPlayerFromSteamId(steamId); + + if (player == null || !player.IsValid) + { + return; + } + + player.PrintToChat(message); + } +} diff --git a/apps/utility-css/src/Commands/Solver.cs b/apps/utility-css/src/Commands/Solver.cs new file mode 100644 index 00000000..69016f06 --- /dev/null +++ b/apps/utility-css/src/Commands/Solver.cs @@ -0,0 +1,54 @@ +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Commands; +using CounterStrikeSharp.API.Modules.Utils; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; + +namespace UtilityPractice; + +// The solver is a Swiftly-only feature, and these exist so it fails as an +// answer rather than as silence. +// +// It works by firing candidate grenades and reading where they actually land, +// which needs a way to put a projectile into the world from a chosen position +// and velocity. Swiftly exposes that; CounterStrikeSharp does not. The gap is +// not something this plugin can paper over: the alternative is reimplementing +// CS2's grenade physics against the collision mesh, which is the thing the +// whole design exists to avoid. +public partial class UtilityPracticePlugin +{ + private const string Unsupported = + "the solver needs a grenade emit API, which CounterStrikeSharp does not expose; run the SwiftlyS2 build of this plugin to use it"; + + [ConsoleCommand("css_solve", "Solves a throw onto the spot you are looking at")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnSolve(CCSPlayerController? player, CommandInfo command) + { + command.ReplyToCommand($" {ChatColors.Red}{Unsupported}"); + } + + [ConsoleCommand("utility_solver_solve", "Solves a throw onto a point (SwiftlyS2 only)")] + [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)] + public void OnSolverSolve(CCSPlayerController? player, CommandInfo command) + { + command.ReplyToCommand($" {ChatColors.Red}{Unsupported}"); + } + + // Answers with the same report shape Swiftly would, so whatever is driving + // this over RCON reads a refusal it understands rather than a missing + // command. + [ConsoleCommand("utility_solver_calibrate", "Checks the solver's premise (SwiftlyS2 only)")] + [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)] + public void OnSolverCalibrate(CCSPlayerController? player, CommandInfo command) + { + CalibrationReport report = PracticeCalibrationUtility.Unsupported( + _library.Map, + Unsupported + ); + + command.ReplyToCommand( + $" {ChatColors.Red}{report.map}: {report.status} {ChatColors.Grey}{report.message}" + ); + } +} diff --git a/apps/utility-css/src/Events/PracticeConnect.cs b/apps/utility-css/src/Events/PracticeConnect.cs new file mode 100644 index 00000000..87b810b9 --- /dev/null +++ b/apps/utility-css/src/Events/PracticeConnect.cs @@ -0,0 +1,132 @@ +using System.Runtime.InteropServices; +using System.Text; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Memory; +using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions; +using FiveStack.Enums; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace UtilityPractice; + +// A practice server is not public. It never loads the match plugin, so the +// door is here: the same ConnectClient hook the match plugin uses, deciding +// against the practice session's roster instead of a match lineup. +public partial class UtilityPracticePlugin +{ + private static int PasswordBufferLength = 86; + public static nint PasswordBuffer { get; set; } = nint.Zero; + public static Dictionary PendingPlayers = new(); + + // near "CNetworkGameServerBase::ConnectClient( name=\'%s\', remote=\'%s\' )\n" + private static string ConnectClientSignature = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ? "55 48 89 E5 41 57 49 89 D7 41 56 41 89 CE 41 55 41 54 49 89 F4 53 48 89 FB 48 81 EC ? ? ? ?" + : "48 89 5C 24 18 44 89 4C 24 20 55 41 54 41 55 41 56 41 57 48 8D 6C 24 F1 48 81 EC ? ? ? ? 81 64 24 54 FF FF 0F FF"; + + /// + /// + /// virtual CServerSideClientBase* CNetworkGameServerBase::ConnectClient( + /// const char* name, + /// ns_address* address, + /// void* netInfo, + /// C2S_CONNECT_Message* connectMsg, + /// const char* password, + /// const byte* authTicket, + /// int authTicketLength, + /// bool isLowViolence); + /// + /// + public static MemoryFunctionWithReturn< + nint, + nint, + nint, + nint, + nint, + nint, + nint, + int, + bool, + nint + > ConnectClientFunc = new(ConnectClientSignature, Addresses.EnginePath); + + private HookResult ConnectClientHook(DynamicHook hook) + { + var authTicket = hook.GetParamArray(6, 7); + var token = hook.GetParam(5); + var steamId = MemoryMarshal.Read(authTicket[..8]); + + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + _session.Current, + steamId, + token + ); + + if (decision.pending_role != null) + { + PendingPlayers[steamId] = decision.pending_role; + } + + // Never the token itself -- it is the server password. Everything else + // about the decision, because a connect that fails silently is the + // hardest thing here to diagnose from the outside. + _logger.LogInformation( + "connect {steamId}: {action} (token: {hasToken}, roster: {roster}, password ready: {ready})", + steamId, + decision.action, + token != null, + _session.Current?.allowed_steam_ids.Count ?? -1, + PasswordBuffer != nint.Zero + ); + + switch (decision.action) + { + case ePracticeConnect.Authorized: + if (PasswordBuffer != nint.Zero) + { + hook.SetParam(5, PasswordBuffer); + } + break; + case ePracticeConnect.Reject: + hook.SetParam(6, 0); + hook.SetParam(7, 0); + break; + } + + return HookResult.Continue; + } + + public static void SetPasswordBuffer(string password) + { + if (PasswordBuffer == nint.Zero) + { + PasswordBuffer = Marshal.StringToCoTaskMemUTF8(new string('\0', PasswordBufferLength)); + } + + StrCpy(PasswordBuffer, password); + } + + private static unsafe void StrCpy(nint dst, string src) + { + Span buffer = stackalloc byte[PasswordBufferLength]; + + int length = Encoding.UTF8.GetBytes(src, buffer[..(buffer.Length - 1)]); + buffer[length] = (byte)'\0'; + + var dstBuffer = new Span((byte*)dst, PasswordBufferLength); + buffer.CopyTo(dstBuffer); + } +} + +public static class DynamicHookExtensions +{ + public static unsafe Span GetParamArray( + this DynamicHook hook, + int paramIndex, + int lengthParamIndex + ) + { + var value = hook.GetParam(paramIndex); + var length = hook.GetParam(lengthParamIndex); + return new Span((void*)value, length); + } +} diff --git a/apps/utility-css/src/Events/PracticeGrenade.cs b/apps/utility-css/src/Events/PracticeGrenade.cs new file mode 100644 index 00000000..968e3a83 --- /dev/null +++ b/apps/utility-css/src/Events/PracticeGrenade.cs @@ -0,0 +1,71 @@ +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using FiveStack.Entities.Practice; + +namespace UtilityPractice; + +// Detonation is the other half of a recording: the projectile stops existing +// and we finally know where the lineup lands. +// +// Every event here except the molotov carries the projectile's entity index, so +// the throw it belongs to is a dictionary lookup. EventMolotovDetonate carries +// only Userid, which is why it takes the thrower-based path. +public partial class UtilityPracticePlugin +{ + [GameEventHandler] + public HookResult OnSmokeDetonate(EventSmokegrenadeDetonate @event, GameEventInfo info) + { + _recorder.OnDetonated( + (uint)@event.Entityid, + new Vec3(@event.X, @event.Y, @event.Z) + ); + return HookResult.Continue; + } + + [GameEventHandler] + public HookResult OnFlashDetonate(EventFlashbangDetonate @event, GameEventInfo info) + { + _recorder.OnDetonated( + (uint)@event.Entityid, + new Vec3(@event.X, @event.Y, @event.Z) + ); + return HookResult.Continue; + } + + [GameEventHandler] + public HookResult OnHeDetonate(EventHegrenadeDetonate @event, GameEventInfo info) + { + _recorder.OnDetonated( + (uint)@event.Entityid, + new Vec3(@event.X, @event.Y, @event.Z) + ); + return HookResult.Continue; + } + + [GameEventHandler] + public HookResult OnDecoyStarted(EventDecoyStarted @event, GameEventInfo info) + { + _recorder.OnDetonated( + (uint)@event.Entityid, + new Vec3(@event.X, @event.Y, @event.Z) + ); + return HookResult.Continue; + } + + // No Entityid on this one -- the thrower is the only handle we get. + [GameEventHandler] + public HookResult OnMolotovDetonate(EventMolotovDetonate @event, GameEventInfo info) + { + CCSPlayerController? thrower = @event.Userid; + if (thrower == null || !thrower.IsValid) + { + return HookResult.Continue; + } + + _recorder.OnMolotovDetonated( + thrower.SteamID, + new Vec3(@event.X, @event.Y, @event.Z) + ); + return HookResult.Continue; + } +} diff --git a/apps/utility-css/src/Events/PracticePlayer.cs b/apps/utility-css/src/Events/PracticePlayer.cs new file mode 100644 index 00000000..cd05acf9 --- /dev/null +++ b/apps/utility-css/src/Events/PracticePlayer.cs @@ -0,0 +1,135 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Entities; + +namespace UtilityPractice; + +public partial class UtilityPracticePlugin +{ + // Practising smokes through your own flash is nobody's idea of practice. + [GameEventHandler] + public HookResult OnPlayerBlind(EventPlayerBlind @event, GameEventInfo info) + { + if (!_config.NoFlash) + { + return HookResult.Continue; + } + + CCSPlayerPawn? pawn = @event.Userid?.PlayerPawn.Value; + + if (pawn == null || !pawn.IsValid) + { + return HookResult.Continue; + } + + pawn.FlashDuration = 0f; + + return HookResult.Continue; + } + + private void OnClientAuthorized(int slot, SteamID steamId) + { + _library.Refresh(steamId.SteamId64); + } + + // Joining a team is the moment somebody is actually in the server and able + // to read chat -- connect is too early, and a practice server whose + // commands nobody knows about is a practice server nobody can use. + [GameEventHandler] + public HookResult OnPlayerJoinTeam(EventPlayerTeam @event, GameEventInfo info) + { + CCSPlayerController? player = @event.Userid; + + if (player == null || !player.IsValid || player.IsBot) + { + return HookResult.Continue; + } + + ulong steamId = player.SteamID; + + // Once per connection, not once per team change: switching sides to + // line something up should not re-print the menu every time. + if (!_welcomed.Add(steamId)) + { + return HookResult.Continue; + } + + AddTimer( + WelcomeDelaySeconds, + () => + { + foreach (string line in WelcomeLines) + { + Tell(steamId, line); + } + } + ); + + return HookResult.Continue; + } + + private void OnClientDisconnect(int slot) + { + CCSPlayerController? player = Utilities.GetPlayerFromSlot(slot); + + if (player == null || !player.IsValid) + { + return; + } + + _welcomed.Remove(player.SteamID); + + // A run ends with the player who was in it: the map is still standing, + // but nobody is left to be teleported or told anything. + _drill.Forget(player.SteamID); + _system.Forget(player.SteamID); + } + + // A preview belongs to whoever asked for it. Solo is the default, so the + // usual case is that nobody else is sent the beams at all. + private void OnCheckTransmit(CCheckTransmitInfoList infoList) + { + // This runs every frame, so the common case -- nobody previewing + // anything -- gets out before allocating. + if (!_replay.HasGhosts) + { + return; + } + + IReadOnlyList<(uint index, ulong owner)> ghosts = _replay.GhostEntities(); + + foreach ((CCheckTransmitInfo info, CCSPlayerController? viewer) in infoList) + { + if (viewer == null || !viewer.IsValid) + { + continue; + } + + bool viewerIsSolo = _system.IsSolo(viewer.SteamID); + bool viewerWantsGhosts = _system.WantsGhosts(viewer.SteamID); + + foreach ((uint index, ulong owner) in ghosts) + { + // Somebody else's preview may already have been drawn before + // this viewer turned theirs off, so the transmit filter answers + // for their own as well. + if (!viewerWantsGhosts) + { + info.TransmitEntities.Remove(index); + continue; + } + + if (owner == viewer.SteamID) + { + continue; + } + + if (viewerIsSolo || _system.IsSolo(owner)) + { + info.TransmitEntities.Remove(index); + } + } + } + } +} diff --git a/apps/utility-css/src/Services/PracticeDrill.cs b/apps/utility-css/src/Services/PracticeDrill.cs new file mode 100644 index 00000000..bdaf44de --- /dev/null +++ b/apps/utility-css/src/Services/PracticeDrill.cs @@ -0,0 +1,235 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; + +namespace UtilityPractice; + +// Turns the library into practice: pick a lineup, stand the player on it, wait +// for the throw to be scored, move on, and say what the run came to. +// +// It owns no timer of its own -- Second is the plugin's shared one second job, +// which is only the watchdog for a throw the panel never answered. Nothing here +// touches a player either: standing somebody on a lineup is what .load already +// does, so it goes back out through the plugin exactly as the playbook's steps +// do. Everything is keyed by steam id, because several people drill in one +// server and none of them are told about each other's runs. +public class PracticeDrill +{ + private readonly UtilityConfig _config; + private readonly PracticeLibrary _library; + + private readonly Dictionary _runs = + new Dictionary(); + + private readonly DrillProgressBook _progress = new DrillProgressBook(); + private readonly Random _random = new Random(); + + public PracticeDrill(UtilityConfig config, PracticeLibrary library) + { + _config = config; + _library = library; + } + + // Wired by the plugin rather than injected, the same way the playbook's + // are. Load answers false when the lineup could not be stood on, which is + // the only thing the runner cannot work out for itself. + public Func? Load { get; set; } + public Action? Tell { get; set; } + public Action? Note { get; set; } + public Action? Center { get; set; } + + public eDrillStart Start(ulong steamId, eDrillOrder order, int count) + { + if (_runs.ContainsKey(steamId)) + { + return eDrillStart.AlreadyRunning; + } + + if (!_config.ReplayEnabled) + { + return eDrillStart.ReplayDisabled; + } + + // Every attempt is scored by the panel, so a server that has none has + // no drill to offer -- only teleports. + if (!_config.IsConnected()) + { + return eDrillStart.NotConnected; + } + + List queue = DrillUtility.Queue( + _library.For(steamId), + count, + order, + _progress.Lookup(steamId), + _random + ); + + if (queue.Count == 0) + { + return eDrillStart.NothingToDrill; + } + + var run = new PracticeDrillRun(queue); + _runs[steamId] = run; + + Tell?.Invoke( + steamId, + $"drill started - {queue.Count} throws, {Ordering(order)} (.skip to pass, .drill stop to end)" + ); + + Advance(steamId, run); + + return eDrillStart.Started; + } + + public bool Stop(ulong steamId) + { + if (!_runs.TryGetValue(steamId, out PracticeDrillRun? run)) + { + return false; + } + + run.End(eDrillEnd.Stopped); + Finish(steamId, run); + + return true; + } + + public bool Skip(ulong steamId) + { + if (!_runs.TryGetValue(steamId, out PracticeDrillRun? run) || !run.Skip()) + { + return false; + } + + Advance(steamId, run); + + return true; + } + + // The recorder's release edge. A grenade the plugin emitted never gets + // here: the recorder drops a projectile it threw itself before it raises + // anything, so a preview or a solve cannot become somebody's attempt. + public void OnThrown(ulong steamId, string utilityType) + { + if (_runs.TryGetValue(steamId, out PracticeDrillRun? run)) + { + run.Thrown(utilityType, DateTime.UtcNow); + } + } + + // The panel's answer, or the fact that there was not one. + public void OnScored(ulong steamId, string lineupId, UtilityPracticeResult? result) + { + // Recorded whether or not this player is drilling: a worst-first run + // reads what the panel has already said about a lineup, and a throw + // made after .load says as much about it as one made in a run. + _progress.Record(steamId, lineupId, result); + + if (!_runs.TryGetValue(steamId, out PracticeDrillRun? run) || !run.Score(lineupId, result)) + { + return; + } + + if (result == null) + { + Note?.Invoke(steamId, "that throw was not scored, so it does not count"); + } + + Advance(steamId, run); + } + + // The shared slow job. A throw whose answer never came is the one way a + // drill can stop advancing without anybody being told, so it is the one + // thing this watches for. + public void Second() + { + if (_runs.Count == 0) + { + return; + } + + DateTime now = DateTime.UtcNow; + + // Finishing a run removes it, so the sweep walks a copy of the keys. + foreach (ulong steamId in _runs.Keys.ToList()) + { + if (!_runs.TryGetValue(steamId, out PracticeDrillRun? run) || !run.Expired(now)) + { + continue; + } + + Note?.Invoke(steamId, "nothing came back for that throw; the panel may be down"); + + Advance(steamId, run); + } + } + + // A player who has left cannot be told anything, so their run ends where it + // stands rather than printing a summary into an empty seat. + public void Forget(ulong steamId) + { + _runs.Remove(steamId); + _progress.Forget(steamId); + } + + // A map change replaces the library every queue was built from. + public void Reset() + { + _runs.Clear(); + _progress.Clear(); + } + + private void Advance(ulong steamId, PracticeDrillRun run) + { + while (true) + { + LineupRecord? next = run.Next(); + + if (next == null) + { + Finish(steamId, run); + return; + } + + if (Load?.Invoke(steamId, next) == true) + { + run.Loaded(); + + Note?.Invoke( + steamId, + $"{run.Position}/{run.Length} {DrillUtility.Name(next)} - {Tally(run)}" + ); + + return; + } + + Note?.Invoke(steamId, $"{DrillUtility.Name(next)} could not be loaded; skipping it"); + + run.CannotLoad(); + } + } + + private void Finish(ulong steamId, PracticeDrillRun run) + { + _runs.Remove(steamId); + + foreach (string line in run.Summary()) + { + Tell?.Invoke(steamId, line); + } + + Center?.Invoke(steamId, $"drill\n{run.Hits}/{run.Attempts}"); + } + + private static string Tally(PracticeDrillRun run) + { + return $"{run.Hits} hit, {run.Misses} miss, streak {run.Streak}"; + } + + private static string Ordering(eDrillOrder order) + { + return order == eDrillOrder.Worst ? "worst first" : "shuffled"; + } +} diff --git a/apps/utility-css/src/Services/PracticeLibrary.cs b/apps/utility-css/src/Services/PracticeLibrary.cs new file mode 100644 index 00000000..8712ec4e --- /dev/null +++ b/apps/utility-css/src/Services/PracticeLibrary.cs @@ -0,0 +1,132 @@ +using CounterStrikeSharp.API; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace UtilityPractice; + +// The saved lineups for the map this server is on, one list per player. The +// panel already filters by map and by who is allowed to see what, so nothing +// here re-decides visibility. +public class PracticeLibrary +{ + private readonly UtilityApiClient _api; + private readonly ILogger _logger; + + private readonly Dictionary> _lineups = new(); + private string _map = ""; + + public PracticeLibrary(UtilityApiClient api, ILogger logger) + { + _api = api; + _logger = logger; + } + + public string Map => _map; + + public void SetMap(string map) + { + if (_map == map) + { + return; + } + + _map = map; + _lineups.Clear(); + } + + public IReadOnlyList For(ulong steamId) + { + return _lineups.TryGetValue(steamId, out List? lineups) + ? lineups + : new List(); + } + + public LineupRecord? Resolve(ulong steamId, string query, Vec3? near = null) + { + return PracticeLineupUtility.Resolve(For(steamId), query, near); + } + + public void Add(ulong steamId, LineupRecord lineup) + { + if (!_lineups.TryGetValue(steamId, out List? lineups)) + { + lineups = new List(); + _lineups[steamId] = lineups; + } + + lineups.RemoveAll(existing => existing.client_id == lineup.client_id); + lineups.Add(lineup); + } + + public void Remove(ulong steamId, LineupRecord lineup) + { + if (_lineups.TryGetValue(steamId, out List? lineups)) + { + lineups.RemoveAll(existing => existing.client_id == lineup.client_id); + } + } + + // A library row carries no flight path and no measured bloom, so neither + // can be drawn until they have been fetched. Everything else about a lineup + // -- where to stand, where to look, what to hold -- is already in hand, + // which is why .load teleports first and only then waits on this. + public void EnsureTrajectory(LineupRecord lineup, ulong steamId, Action ready) + { + if (lineup.trajectory.Count > 0 || string.IsNullOrEmpty(lineup.id)) + { + ready(lineup); + return; + } + + string id = lineup.id; + + _ = Task.Run(async () => + { + UtilityTrajectoryArtifact? artifact = await _api.Trajectory(id, steamId); + + Server.NextFrame(() => + { + if (artifact != null) + { + lineup.trajectory = artifact.path; + lineup.smoke_volume = artifact.smoke_volume; + } + + ready(lineup); + }); + }); + } + + // Fetches off the game thread and applies on it, so a slow panel cannot + // stall a tick and the dictionary is only ever touched from one thread. + public void Refresh(ulong steamId, Action? done = null) + { + string map = _map; + + _ = Task.Run(async () => + { + List? lineups = await _api.Library(map, steamId); + + Server.NextFrame(() => + { + if (lineups == null) + { + done?.Invoke(-1); + return; + } + + // The map can change while the request is in flight; dropping + // the answer beats showing inferno lineups on mirage. + if (map != _map) + { + done?.Invoke(-1); + return; + } + + _lineups[steamId] = lineups; + done?.Invoke(lineups.Count); + }); + }); + } +} diff --git a/apps/utility-css/src/Services/PracticePlaybook.cs b/apps/utility-css/src/Services/PracticePlaybook.cs new file mode 100644 index 00000000..d32a979f --- /dev/null +++ b/apps/utility-css/src/Services/PracticePlaybook.cs @@ -0,0 +1,254 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; + +namespace UtilityPractice; + +public enum ePlaybookStart +{ + Started, + NoPlaybook, + NoSteps, + WrongMap, + AlreadyRunning, +} + +// Runs the execute the panel loaded onto this session: a countdown, then each +// step at its own offset. +// +// It owns no timer of its own. Tick is the plugin's shared fast job and Second +// is the shared slow one, because ten people practising must not mean ten +// clocks. Everything that touches a player goes back out through the plugin, so +// a step puts somebody on a lineup by exactly the same path .load does. +public class PracticePlaybook +{ + public const int CountdownSeconds = 5; + + private enum Phase + { + Idle, + Countdown, + Running, + } + + private readonly PracticeSession _session; + private readonly PracticeSystem _system; + + private Phase _phase = Phase.Idle; + private string _name = ""; + private List _steps = new List(); + + // When t=0 is, which is the end of the countdown rather than the moment + // .playbook was typed. + private DateTime _startsAt = DateTime.MinValue; + + // The last elapsed time already fired. Negative so a step at offset zero is + // still in the first window. + private int _elapsedMs = -1; + private int _announced = -1; + + // A step names a lineup, and the same lineup can appear in several steps + // and several runs. Keeping one record per id means one trajectory fetch + // per id, and the identity check .load relies on keeps working. + private readonly Dictionary _lineups = + new Dictionary(); + + public PracticePlaybook(PracticeSession session, PracticeSystem system) + { + _session = session; + _system = system; + } + + // Wired by the plugin rather than injected: standing a player on a lineup is + // what .load already does, and a second implementation of it would be a + // second answer to the same question. + public Action? Load { get; set; } + public Action? Chat { get; set; } + public Action? Tell { get; set; } + public Action? Center { get; set; } + + public bool Running => _phase != Phase.Idle; + + public UtilityPlaybook? Loaded => _session.Current?.playbook; + + public IReadOnlyList Steps => PlaybookUtility.Ordered(Loaded); + + public ePlaybookStart Start(string map) + { + if (Running) + { + return ePlaybookStart.AlreadyRunning; + } + + UtilityPlaybook? playbook = Loaded; + + if (playbook == null) + { + return ePlaybookStart.NoPlaybook; + } + + if ( + !string.IsNullOrEmpty(playbook.map_name) + && !string.IsNullOrEmpty(map) + && !string.Equals(playbook.map_name, map, StringComparison.OrdinalIgnoreCase) + ) + { + return ePlaybookStart.WrongMap; + } + + List steps = PlaybookUtility.Ordered(playbook); + + if (steps.Count == 0) + { + return ePlaybookStart.NoSteps; + } + + _steps = steps; + _name = string.IsNullOrEmpty(playbook.name) ? "execute" : playbook.name!; + _phase = Phase.Countdown; + _startsAt = DateTime.UtcNow.AddSeconds(CountdownSeconds); + _elapsedMs = -1; + _announced = -1; + + return ePlaybookStart.Started; + } + + public bool Stop() + { + if (!Running) + { + return false; + } + + _phase = Phase.Idle; + _steps = new List(); + + return true; + } + + // A map change takes the geometry the steps refer to with it. + public void Reset() + { + _phase = Phase.Idle; + _steps = new List(); + _lineups.Clear(); + } + + // The shared fast job. Sub-second offsets are the whole point of an execute, + // which is why the step clock lives here and not on the one second job. + public void Tick() + { + if (_phase == Phase.Idle) + { + return; + } + + DateTime now = DateTime.UtcNow; + + if (_phase == Phase.Countdown) + { + if (now < _startsAt) + { + return; + } + + _phase = Phase.Running; + Chat?.Invoke($"{_name} go"); + } + + int elapsed = (int)(now - _startsAt).TotalMilliseconds; + + foreach (UtilityPlaybookStep step in PlaybookUtility.Due(_steps, _elapsedMs, elapsed)) + { + Fire(step); + } + + _elapsedMs = elapsed; + + if (elapsed > PlaybookUtility.DurationMs(_steps) + PlaybookUtility.TailMs) + { + _phase = Phase.Idle; + Chat?.Invoke($"{_name} complete"); + } + } + + // The shared slow job, which is only the countdown: a number that changes + // once a second does not need a finer clock than that. + public void Second() + { + if (_phase != Phase.Countdown) + { + return; + } + + int remaining = (int)Math.Ceiling((_startsAt - DateTime.UtcNow).TotalSeconds); + + if (remaining <= 0 || remaining == _announced) + { + return; + } + + _announced = remaining; + + foreach (ulong steamId in _system.ConnectedSteamIds()) + { + Center?.Invoke(steamId, $"{_name}\n{remaining}"); + } + } + + private void Fire(UtilityPlaybookStep step) + { + LineupRecord? lineup = LineupFor(step); + + if (lineup == null) + { + return; + } + + int order = _steps.IndexOf(step) + 1; + string name = string.IsNullOrEmpty(lineup.name) ? lineup.utility_type : lineup.name; + string note = string.IsNullOrWhiteSpace(step.note) ? "" : $" - {step.note}"; + + var targets = _system + .ConnectedSteamIds() + .Where(steamId => PlaybookUtility.IsFor(step, steamId)) + .ToList(); + + // An assigned step whose player is not on the server is announced and + // skipped: silently handing their smoke to everybody would rehearse an + // execute nobody is going to run. + if (targets.Count == 0) + { + Chat?.Invoke( + PlaybookUtility.IsAssigned(step) + ? $"{order}/{_steps.Count} {name}{note} - {step.assigned_steam_id} is not here" + : $"{order}/{_steps.Count} {name}{note} - nobody to throw it" + ); + return; + } + + foreach (ulong steamId in targets) + { + Load?.Invoke(steamId, lineup); + Tell?.Invoke(steamId, $"{order}/{_steps.Count} {name}{note}"); + } + } + + private LineupRecord? LineupFor(UtilityPlaybookStep step) + { + string id = step.utility_lineup_id ?? ""; + + if (!string.IsNullOrEmpty(id) && _lineups.TryGetValue(id, out LineupRecord? cached)) + { + return cached; + } + + LineupRecord? lineup = step.ToLineup(); + + if (lineup != null && !string.IsNullOrEmpty(id)) + { + _lineups[id] = lineup; + } + + return lineup; + } +} diff --git a/apps/utility-css/src/Services/PracticeRecorder.cs b/apps/utility-css/src/Services/PracticeRecorder.cs new file mode 100644 index 00000000..3270d36c --- /dev/null +++ b/apps/utility-css/src/Services/PracticeRecorder.cs @@ -0,0 +1,520 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Utils; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace UtilityPractice; + +// Turns a thrown grenade into a reproducible lineup. +// +// The engine hands us both halves directly, so none of this is guesswork: +// CBaseCSGrenadeProjectile carries m_vInitialPosition/m_vInitialVelocity (the +// physics seed) and CBaseGrenade carries m_hThrower (who owns it). That last +// one is why several players can practise in one server without their throws +// crossing: a projectile names its own owner, so nothing is keyed on "whoever +// threw last". +public class PracticeRecorder +{ + // Guard rails, so a stuck projectile or a spammed throw cannot grow + // unbounded on a long-lived practice server. + private const int MaxTrackedProjectiles = 64; + private const int MaxRawPoints = 2048; + private const int ForceFinalizeTicks = 64 * 30; + private const int MaxHistoryPerPlayer = 20; + + // Sample every other tick: 32Hz is well past what a replayed line needs, + // and bounces are captured exactly regardless via m_nBounces. + private const int SampleEveryTicks = 2; + + private readonly ILogger _logger; + + private class ArmedState + { + public bool PinPulled; + public bool Released; + public ThrowSnapshot? Frozen; + } + + private class TrackedProjectile + { + public required ulong ThrowerSteamId; + public required string UtilityType; + public required ThrowSnapshot Release; + public required int StartTick; + public Vec3 InitialPosition; + public Vec3 InitialVelocity; + public int LastBounces; + public List Raw = new List(); + } + + // FL_ONGROUND, the same flag the snapshot itself records. + private const uint FlOnGround = 1 << 0; + + // Horizontal units/sec that still counts as standing still. Not zero: a + // player settling onto a lineup leaves small residual velocity behind. + private const float StationarySpeed = 12f; + + // 64 ticks/sec. A run-up that began five seconds ago is not a run-up. + private const int StationaryMaxAgeTicks = 64 * 5; + + // Consecutive still ticks before a position counts as a standstill. A + // strafe that reverses (S then D) drags velocity through zero for a tick + // or two, and that instant is mid-run-up, not a place anyone stood. + private const int StationarySettleTicks = 4; + + // Where a throw is set up from, which is not where the player leaves the + // ground. A run- or jump-throw is aimed from a standstill and then walked + // into, so the release origin is mid-air and the last grounded tick is the + // takeoff point: neither is somewhere a player can stand and repeat it. + private struct StationaryAnchor + { + public Vec3 Position; + public int Tick; + } + + private readonly Dictionary _armed = new(); + private readonly Dictionary _stationary = new(); + private readonly Dictionary _settling = new(); + private readonly Dictionary _pending = new(); + private readonly Dictionary _tracked = new(); + private readonly Dictionary> _history = new(); + + private int _tick; + + public PracticeRecorder(ILogger logger) + { + _logger = logger; + } + + // Raised on the release edge with the thrower and what they threw, so + // whoever hands the grenade back does not have to re-derive either. + public event Action? Thrown; + + // Raised once a throw is over and its landing point is known. This is the + // only place a completed throw exists, so scoring reads it from here rather + // than hooking the detonate events a second time and re-deriving the owner. + public event Action? Finalized; + + public IReadOnlyList HistoryFor(ulong steamId) + { + return _history.TryGetValue(steamId, out var records) + ? records + : new List(); + } + + public LineupRecord? LastThrow(ulong steamId, int back = 0) + { + var records = HistoryFor(steamId); + int index = records.Count - 1 - back; + return index >= 0 && index < records.Count ? records[index] : null; + } + + public void Reset() + { + _armed.Clear(); + _stationary.Clear(); + _settling.Clear(); + _pending.Clear(); + _tracked.Clear(); + } + + // Only does work while something is in flight or someone is holding a + // pulled pin, so an idle practice server pays one branch per tick. + public void OnTick() + { + _tick++; + + WatchArmedGrenades(); + SampleProjectiles(); + } + + private void WatchArmedGrenades() + { + foreach (CCSPlayerController player in Utilities.GetPlayers()) + { + if (!player.IsValid || player.IsBot) + { + continue; + } + + CCSPlayerPawn? pawn = player.PlayerPawn.Value; + + if (pawn == null || !pawn.IsValid) + { + _armed.Remove(player.SteamID); + continue; + } + + TrackStationary(player.SteamID, pawn); + + CBasePlayerWeapon? active = pawn.WeaponServices?.ActiveWeapon.Value; + + if (active == null || !active.IsValid) + { + _armed.Remove(player.SteamID); + continue; + } + + CBaseCSGrenade? grenade = TryAsGrenade(active); + if (grenade == null) + { + _armed.Remove(player.SteamID); + continue; + } + + if (!_armed.TryGetValue(player.SteamID, out ArmedState? state)) + { + state = new ArmedState(); + _armed[player.SteamID] = state; + } + + if (grenade.PinPulled) + { + state.PinPulled = true; + } + + // m_fThrowTime going non-zero is the release edge. Freeze the + // player's state right here: by the time the projectile entity + // exists they have already started moving again. + if (state.PinPulled && !state.Released && grenade.ThrowTime > 0) + { + state.Released = true; + state.Frozen = Snapshot(player, pawn, grenade, StanceFor(player.SteamID)); + _pending[player.SteamID] = state.Frozen; + } + } + } + + private static CBaseCSGrenade? TryAsGrenade(CBasePlayerWeapon weapon) + { + string designer = weapon.DesignerName ?? ""; + if (!PracticeLineupUtility.IsGrenadeWeapon(designer)) + { + return null; + } + + try + { + return weapon.As(); + } + catch + { + return null; + } + } + + private void TrackStationary(ulong steamId, CCSPlayerPawn pawn) + { + Vector velocity = pawn.AbsVelocity ?? new Vector(0, 0, 0); + + bool still = + (pawn.Flags & FlOnGround) != 0 + && new Vec3(velocity.X, velocity.Y, 0f).LengthXY() <= StationarySpeed; + + if (!still) + { + _settling.Remove(steamId); + return; + } + + if (!_settling.TryGetValue(steamId, out int since)) + { + _settling[steamId] = _tick; + return; + } + + if (_tick - since < StationarySettleTicks) + { + return; + } + + Vector here = pawn.AbsOrigin ?? new Vector(0, 0, 0); + + _stationary[steamId] = new StationaryAnchor + { + Position = new Vec3(here.X, here.Y, here.Z), + Tick = _tick, + }; + } + + // Past the window there is no standstill worth returning to, and the + // release origin is all that is left. + private Vec3? StanceFor(ulong steamId) + { + if ( + _stationary.TryGetValue(steamId, out StationaryAnchor anchor) + && _tick - anchor.Tick <= StationaryMaxAgeTicks + ) + { + return anchor.Position; + } + + return null; + } + + private ThrowSnapshot Snapshot( + CCSPlayerController player, + CCSPlayerPawn pawn, + CBaseCSGrenade grenade, + Vec3? stance + ) + { + Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); + Vector velocity = pawn.AbsVelocity ?? new Vector(0, 0, 0); + QAngle angles = pawn.EyeAngles; + + float eyeZ = origin.Z + (pawn.ViewOffset?.Z ?? 64f); + + bool ducked = pawn.MovementServices?.As()?.Ducked ?? false; + uint buttons = 0; + bool walking = false; + + var movement = pawn.MovementServices?.As(); + if (movement != null) + { + buttons = (uint)movement.Buttons.ButtonStates[0]; + // IN_SPEED + walking = (buttons & (1 << 16)) != 0; + } + + return new ThrowSnapshot + { + // The stance, not the release point: this is where the lineup says + // to stand, and standing is something you can only do on the floor. + feet_position = stance ?? new Vec3(origin.X, origin.Y, origin.Z), + eye_position = new Vec3(origin.X, origin.Y, eyeZ), + pitch = angles.X, + yaw = angles.Y, + velocity = new Vec3(velocity.X, velocity.Y, velocity.Z), + speed = new Vec3(velocity.X, velocity.Y, 0f).LengthXY(), + on_ground = (pawn.Flags & FlOnGround) != 0, + ducked = ducked, + walking = walking, + throw_strength_raw = grenade.ThrowStrength, + jump_throw = grenade.JumpThrow, + buttons = buttons, + tick = _tick, + }; + } + + // A projectile appearing is what links a frozen snapshot to a physical + // grenade. m_hThrower is read off the entity rather than assumed, so two + // players throwing on the same tick cannot be confused for one another. + public void OnProjectileCreated(CEntityInstance entity) + { + string designer = entity.DesignerName ?? ""; + string? utilityType = PracticeLineupUtility.UtilityTypeForProjectile(designer); + + // Anything that is not a grenade leaves silently: every entity in the + // map comes through here. Past this line it IS a throw, so a drop is + // worth saying out loud -- a silently dropped throw is what makes + // ".save" claim you never threw anything. + if (utilityType == null) + { + return; + } + + if (_tracked.Count >= MaxTrackedProjectiles) + { + _logger.LogWarning( + "dropped a {type}: already tracking {count} projectiles", + utilityType, + _tracked.Count + ); + return; + } + + CBaseCSGrenadeProjectile projectile; + try + { + projectile = entity.As(); + } + catch + { + return; + } + + CCSPlayerPawn? throwerPawn = projectile.Thrower.Value?.As(); + CCSPlayerController? thrower = throwerPawn?.Controller.Value?.As(); + + if (thrower == null || !thrower.IsValid) + { + return; + } + + if (!_pending.Remove(thrower.SteamID, out ThrowSnapshot? release)) + { + // No frozen snapshot: the pin/throw edge was missed (hot reload + // mid-throw, or a scripted give). Record what is still true rather + // than dropping the throw entirely. + release = new ThrowSnapshot { tick = _tick }; + } + + Vector initialPosition = projectile.InitialPosition; + Vector initialVelocity = projectile.InitialVelocity; + + _tracked[entity.Index] = new TrackedProjectile + { + ThrowerSteamId = thrower.SteamID, + UtilityType = utilityType, + Release = release, + StartTick = _tick, + InitialPosition = new Vec3( + initialPosition.X, + initialPosition.Y, + initialPosition.Z + ), + InitialVelocity = new Vec3( + initialVelocity.X, + initialVelocity.Y, + initialVelocity.Z + ), + }; + + if (_armed.TryGetValue(thrower.SteamID, out ArmedState? state)) + { + state.PinPulled = false; + state.Released = false; + state.Frozen = null; + } + + Thrown?.Invoke(thrower.SteamID, utilityType); + } + + private void SampleProjectiles() + { + if (_tracked.Count == 0) + { + return; + } + + var expired = new List(); + + foreach ((uint index, TrackedProjectile tracked) in _tracked) + { + CBaseCSGrenadeProjectile? projectile = Utilities.GetEntityFromIndex( + (int)index + ); + + if (projectile == null || !projectile.IsValid) + { + expired.Add(index); + continue; + } + + if (_tick - tracked.StartTick > ForceFinalizeTicks) + { + expired.Add(index); + continue; + } + + Vector? origin = projectile.AbsOrigin; + if (origin == null || tracked.Raw.Count >= MaxRawPoints) + { + continue; + } + + // A bounce is where the path turns. Sampling can miss it, the + // counter cannot. + bool bounced = projectile.Bounces > tracked.LastBounces; + if (bounced) + { + tracked.LastBounces = projectile.Bounces; + } + + if (bounced || _tick % SampleEveryTicks == 0) + { + tracked.Raw.Add( + new TrajectoryPoint + { + p = new Vec3(origin.X, origin.Y, origin.Z), + t = _tick, + bounce = bounced, + } + ); + } + } + + foreach (uint index in expired) + { + FinalizeByIndex(index, null); + } + } + + // Called from the detonate handlers, which carry the projectile's entity + // index for every utility except molotovs. + public void OnDetonated(uint entityIndex, Vec3 position) + { + FinalizeByIndex(entityIndex, position); + } + + // EventMolotovDetonate carries no entity id, so the thrower is the only + // handle available. + public void OnMolotovDetonated(ulong steamId, Vec3 position) + { + foreach ((uint index, TrackedProjectile tracked) in _tracked) + { + if (tracked.ThrowerSteamId == steamId && tracked.UtilityType == "Molotov") + { + FinalizeByIndex(index, position); + return; + } + } + } + + private void FinalizeByIndex(uint entityIndex, Vec3? detonation) + { + if (!_tracked.Remove(entityIndex, out TrackedProjectile? tracked)) + { + return; + } + + Vec3 landing = + detonation + ?? ( + tracked.Raw.Count > 0 + ? tracked.Raw[^1].p + : tracked.InitialPosition + ); + + var record = new LineupRecord + { + client_id = Guid.NewGuid().ToString(), + utility_type = tracked.UtilityType, + author_steam_id = tracked.ThrowerSteamId.ToString(), + release = tracked.Release, + initial_position = tracked.InitialPosition, + initial_velocity = tracked.InitialVelocity, + detonation_position = landing, + bounces = tracked.LastBounces, + flight_time = (_tick - tracked.StartTick) / 64f, + // The plugin watched this throw happen, so it is exact by + // observation. It is never sent: the panel owns provenance and + // stamps its own on ingest. + confidence = LineupRecord.Exact, + technique = TrajectoryUtility.ClassifyTechnique(tracked.Release).ToString(), + strength = TrajectoryUtility + .ClassifyStrength(tracked.Release.throw_strength_raw) + .ToString(), + trajectory = TrajectoryUtility.Simplify(tracked.Raw), + recorded_tickrate = 64, + plugin_runtime = "counterstrikesharp", + }; + + if (!_history.TryGetValue(tracked.ThrowerSteamId, out List? records)) + { + records = new List(); + _history[tracked.ThrowerSteamId] = records; + } + + records.Add(record); + while (records.Count > MaxHistoryPerPlayer) + { + records.RemoveAt(0); + } + + Finalized?.Invoke(record); + } +} diff --git a/apps/utility-css/src/Services/PracticeReplay.cs b/apps/utility-css/src/Services/PracticeReplay.cs new file mode 100644 index 00000000..0d5b412d --- /dev/null +++ b/apps/utility-css/src/Services/PracticeReplay.cs @@ -0,0 +1,662 @@ +using System.Drawing; +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Utils; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace UtilityPractice; + +// Puts a player back where a lineup was thrown from, and draws the line the +// grenade took so they can see the throw before they make it. +public class PracticeReplay +{ + private const float GhostSeconds = 8f; + private const float GhostWidth = 1.6f; + + // A simplified line is a few dozen points; a long one is strided rather + // than spawning an entity per segment. + private const int MaxGhostSegments = 32; + + private const float MarkerHeight = 42f; + + private const float BloomWidth = 1.1f; + + // One beam per occupied voxel is thousands of entities for a single smoke. + // The outline is contoured down to fit this, and a server full of people + // previewing at once is capped again on top of it. + private const int MaxBloomBeams = 48; + private const int MaxBloomBeamsTotal = 240; + + private readonly UtilityConfig _config; + private readonly ILogger _logger; + + private enum GhostKind + { + Line, + Bloom, + } + + private class Ghost + { + public required ulong OwnerSteamId; + public required GhostKind Kind; + public required DateTime ExpiresAt; + public required List Beams; + } + + private readonly List _ghosts = new List(); + + // In-world markers for the lineup currently loaded. Deliberately NOT part + // of _ghosts: ghosts are filtered per viewer, and a marker is meant to be + // seen by everyone on the server. + private readonly List _markerBeams = new(); + private readonly List _markerTexts = new(); + + public PracticeReplay(UtilityConfig config, ILogger logger) + { + _config = config; + _logger = logger; + } + + // Wired by the plugin rather than injected: PracticeSystem already depends + // on this service, so asking for it back would close the cycle. + public Func WantsGhosts { get; set; } = _ => true; + + public void Load(CCSPlayerController player, LineupRecord lineup) + { + CCSPlayerPawn? pawn = player.PlayerPawn.Value; + + if (pawn == null || !pawn.IsValid) + { + return; + } + + Vec3 feet = lineup.release.feet_position; + var position = new Vector(feet.x, feet.y, feet.z); + // Yaw for the body, pitch for the eyes, and never the two together: a + // pawn's rotation is which way it faces, so a lineup's -63 pitch fed + // into it lies the player on their back. + var facing = new QAngle(0, lineup.release.yaw, 0); + var aim = new QAngle(lineup.release.pitch, lineup.release.yaw, 0); + + pawn.Teleport(position, facing, new Vector(0, 0, 0)); + pawn.EyeAngles.X = aim.X; + pawn.EyeAngles.Y = aim.Y; + pawn.EyeAngles.Z = 0; + + // A single application is not enough: the client re-predicts from the + // command it had in flight and snaps the view back. + ReapplyAngles(player, facing, aim, 2); + + GiveUtility(player, lineup.utility_type); + + ShowMarkers(lineup); + + player.PrintToCenter(Describe(lineup)); + } + + // Tier 1 preview: the recorded line, drawn as beam segments with a marker + // where it lands. + public void ShowGhost(CCSPlayerController player, LineupRecord lineup) + { + if (!_config.GhostPreview || !WantsGhosts(player.SteamID)) + { + return; + } + + ClearKind(player.SteamID, GhostKind.Line); + + Color color = ColorFor(lineup.utility_type); + var beams = new List(); + + // A lineup fetched from the panel arrives without its flight path, and + // the marker alone is the honest answer until the path has been + // fetched: a straight beam to the landing spot is a wrong line, not a + // missing one. + List points = GhostPoints(lineup); + + for (int index = 0; index < points.Count - 1; index++) + { + CEnvBeam? beam = CreateBeam(points[index], points[index + 1], color, GhostWidth); + if (beam != null) + { + beams.Add(beam); + } + } + + Vec3 landing = lineup.detonation_position; + CEnvBeam? marker = CreateBeam( + landing, + new Vec3(landing.x, landing.y, landing.z + MarkerHeight), + color, + GhostWidth * 2f + ); + + if (marker != null) + { + beams.Add(marker); + } + + if (beams.Count == 0) + { + return; + } + + GhostsChanged(); + _ghosts.Add( + new Ghost + { + OwnerSteamId = player.SteamID, + Kind = GhostKind.Line, + ExpiresAt = DateTime.UtcNow.AddSeconds(GhostSeconds), + Beams = beams, + } + ); + } + + // The measured bloom, outlined where it would actually sit. Answers how + // many beams it took: zero when the panel has no measurement for this + // lineup, which is a normal answer and not a failure. + public int ShowBloom(CCSPlayerController player, LineupRecord lineup) + { + ClearKind(player.SteamID, GhostKind.Bloom); + + if (!_config.GhostPreview || !WantsGhosts(player.SteamID)) + { + return 0; + } + + int budget = Math.Min(MaxBloomBeams, MaxBloomBeamsTotal - BloomBeamCount()); + + if (budget <= 0) + { + return 0; + } + + List outline = SmokeVolumeUtility.Outline( + lineup.smoke_volume, + new SmokeOutlineOptions { MaxSegments = budget } + ); + + if (outline.Count == 0) + { + return 0; + } + + Color color = ColorFor(lineup.utility_type); + var beams = new List(); + + foreach (BloomSegment segment in outline) + { + CEnvBeam? beam = CreateBeam(segment.a, segment.b, color, BloomWidth); + + if (beam != null) + { + beams.Add(beam); + } + } + + if (beams.Count == 0) + { + return 0; + } + + // Held until it is toggled off rather than expiring: a player lining a + // throw up is looking at it for as long as that takes. + GhostsChanged(); + _ghosts.Add( + new Ghost + { + OwnerSteamId = player.SteamID, + Kind = GhostKind.Bloom, + ExpiresAt = DateTime.MaxValue, + Beams = beams, + } + ); + + return beams.Count; + } + + public void ClearBloom(ulong steamId) + { + ClearKind(steamId, GhostKind.Bloom); + } + + public void ClearGhosts(ulong steamId) + { + for (int index = _ghosts.Count - 1; index >= 0; index--) + { + if (_ghosts[index].OwnerSteamId != steamId) + { + continue; + } + + Kill(_ghosts[index]); + _ghosts.RemoveAt(index); + GhostsChanged(); + } + } + + private void ClearKind(ulong steamId, GhostKind kind) + { + for (int index = _ghosts.Count - 1; index >= 0; index--) + { + if (_ghosts[index].OwnerSteamId != steamId || _ghosts[index].Kind != kind) + { + continue; + } + + Kill(_ghosts[index]); + _ghosts.RemoveAt(index); + GhostsChanged(); + } + } + + private int BloomBeamCount() + { + return _ghosts + .Where(ghost => ghost.Kind == GhostKind.Bloom) + .Sum(ghost => ghost.Beams.Count); + } + + public void ClearAll() + { + foreach (Ghost ghost in _ghosts) + { + Kill(ghost); + } + + _ghosts.Clear(); + GhostsChanged(); + + ClearMarkers(); + } + + public void Sweep() + { + DateTime now = DateTime.UtcNow; + + for (int index = _ghosts.Count - 1; index >= 0; index--) + { + if (_ghosts[index].ExpiresAt > now) + { + continue; + } + + Kill(_ghosts[index]); + _ghosts.RemoveAt(index); + GhostsChanged(); + } + } + + public bool HasGhosts => _ghosts.Count > 0; + + // Beams are the only entities this plugin spawns, so a transmit filter + // built from this list can never hide anything else by accident. + // + // Cached, because the caller is CheckTransmit and that runs every frame: + // rebuilding this per frame allocates a list per frame forever. Invalidated + // by every path that adds or removes a ghost. + private (uint index, ulong owner)[]? _ghostIndexes; + + public IReadOnlyList<(uint index, ulong owner)> GhostEntities() + { + if (_ghostIndexes != null) + { + return _ghostIndexes; + } + + var indexes = new List<(uint index, ulong owner)>(); + + foreach (Ghost ghost in _ghosts) + { + foreach (CEnvBeam beam in ghost.Beams) + { + if (beam.IsValid) + { + indexes.Add((beam.Index, ghost.OwnerSteamId)); + } + } + } + + _ghostIndexes = indexes.ToArray(); + return _ghostIndexes; + } + + // Every mutation of _ghosts goes through here, so the cache cannot outlive + // the set it describes. + private void GhostsChanged() + { + _ghostIndexes = null; + } + + public static string Describe(LineupRecord lineup) + { + string name = string.IsNullOrEmpty(lineup.name) ? "unnamed" : lineup.name; + string strength = string.IsNullOrEmpty(lineup.strength) ? "" : $" / {lineup.strength}"; + + return $"{name}\n{lineup.utility_type} - {lineup.technique}{strength}"; + } + + private void GiveUtility(CCSPlayerController player, string utilityType) + { + string? weapon = PracticeLineupUtility.WeaponForUtilityType(utilityType); + + if (weapon == null) + { + return; + } + + if (!HasWeapon(player, weapon)) + { + player.GiveNamedItem(weapon); + } + + // There is no server-side "select this weapon" in CounterStrikeSharp, + // so the switch goes through the client. + player.ExecuteClientCommand($"use {weapon}"); + } + + private static bool HasWeapon(CCSPlayerController player, string designerName) + { + CPlayer_WeaponServices? weapons = player.PlayerPawn.Value?.WeaponServices; + + if (weapons == null) + { + return false; + } + + foreach (var handle in weapons.MyWeapons) + { + if (handle.Value?.DesignerName == designerName) + { + return true; + } + } + + return false; + } + + private static void ReapplyAngles( + CCSPlayerController player, + QAngle facing, + QAngle aim, + int frames + ) + { + if (frames <= 0) + { + return; + } + + Server.NextFrame(() => + { + CCSPlayerPawn? pawn = player.IsValid ? player.PlayerPawn.Value : null; + + if (pawn == null || !pawn.IsValid) + { + return; + } + + pawn.Teleport(null, facing, new Vector(0, 0, 0)); + pawn.EyeAngles.X = aim.X; + pawn.EyeAngles.Y = aim.Y; + pawn.EyeAngles.Z = 0; + + ReapplyAngles(player, facing, aim, frames - 1); + }); + } + + // Empty when the path is unknown; ShowGhost still draws the marker. + private static List GhostPoints(LineupRecord lineup) + { + var points = new List(); + + if (lineup.trajectory.Count == 0) + { + return points; + } + + int stride = Math.Max(1, (int)Math.Ceiling(lineup.trajectory.Count / (double)MaxGhostSegments)); + + // The seed is where the grenade actually left the hand. Drawing the line + // from it only asks that the point be real, not that the throw be + // reproducible, so this is the looser of the two questions. + if (lineup.HasPhysicsSeed()) + { + points.Add(lineup.initial_position); + } + + for (int index = 0; index < lineup.trajectory.Count; index++) + { + // Bounces are where the line changes direction, so they survive + // striding. + if (index % stride == 0 || lineup.trajectory[index].bounce) + { + points.Add(lineup.trajectory[index].p); + } + } + + points.Add(lineup.detonation_position); + + return points; + } + + // Valve's own guides mark three things per lineup -- where you stand, what + // you look at, and where it lands -- and that split is the right one, so + // these mirror it. Drawn from entities the server owns rather than the + // annotation system, which is client-side and cannot be driven from here. + private void ShowMarkers(LineupRecord lineup) + { + ClearMarkers(); + + if (!_config.GhostPreview) + { + return; + } + + Color color = ColorFor(lineup.utility_type); + 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 + ); + + 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 + // rather than floating an arbitrary distance away. + Vec3 eye = lineup.release.eye_position; + float reach = new Vec3(landing.x - eye.x, landing.y - eye.y, 0f).LengthXY(); + + if (reach > 1f) + { + double yaw = lineup.release.yaw * Math.PI / 180.0; + double pitch = lineup.release.pitch * Math.PI / 180.0; + float flat = (float)Math.Cos(pitch); + + var aim = new Vec3( + eye.x + (float)(Math.Cos(yaw) * flat) * reach, + eye.y + (float)(Math.Sin(yaw) * flat) * reach, + // CS2 pitch is negative looking up, so the sign flips here. + eye.z + (float)(-Math.Sin(pitch)) * reach + ); + + Label(aim, "AIM", color); + Ring(aim, 10f, color, 1f); + } + } + + private void Ring(Vec3 center, float radius, Color color, float width) + { + const int Segments = 10; + + for (int index = 0; index < Segments; index++) + { + double a = index * 2 * Math.PI / Segments; + double b = (index + 1) * 2 * Math.PI / Segments; + + CEnvBeam? beam = CreateBeam( + new Vec3( + center.x + (float)(Math.Cos(a) * radius), + center.y + (float)(Math.Sin(a) * radius), + center.z + 2f + ), + new Vec3( + center.x + (float)(Math.Cos(b) * radius), + center.y + (float)(Math.Sin(b) * radius), + center.z + 2f + ), + color, + width + ); + + if (beam != null) + { + _markerBeams.Add(beam); + } + } + } + + private void Label(Vec3 at, string text, Color color) + { + try + { + CPointWorldText? label = Utilities.CreateEntityByName( + "point_worldtext" + ); + + if (label == null || !label.IsValid) + { + return; + } + + label.MessageText = text; + label.Color = color; + label.FontSize = 60; + label.FontName = "Arial Black"; + label.Fullbright = true; + label.WorldUnitsPerPx = 0.15f; + label.Enabled = true; + label.JustifyHorizontal = PointWorldTextJustifyHorizontal_t + .POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER; + label.JustifyVertical = PointWorldTextJustifyVertical_t + .POINT_WORLD_TEXT_JUSTIFY_VERTICAL_CENTER; + // Always readable, wherever the reader is standing. + label.ReorientMode = PointWorldTextReorientMode_t + .POINT_WORLD_TEXT_REORIENT_AROUND_UP; + + label.Teleport( + new Vector(at.x, at.y, at.z), + new QAngle(0, 0, 0), + new Vector(0, 0, 0) + ); + + label.DispatchSpawn(); + + _markerTexts.Add(label); + } + catch (Exception error) + { + _logger.LogError(error, "unable to place a lineup marker"); + } + } + + public void ClearMarkers() + { + foreach (CEnvBeam beam in _markerBeams) + { + if (beam.IsValid) + { + beam.Remove(); + } + } + + foreach (CPointWorldText label in _markerTexts) + { + if (label.IsValid) + { + label.Remove(); + } + } + + _markerBeams.Clear(); + _markerTexts.Clear(); + } + + private CEnvBeam? CreateBeam(Vec3 start, Vec3 end, Color color, float width) + { + try + { + CEnvBeam? beam = Utilities.CreateEntityByName("env_beam"); + + if (beam == null || !beam.IsValid) + { + return null; + } + + beam.Render = color; + beam.Width = width; + + beam.Teleport( + new Vector(start.x, start.y, start.z), + new QAngle(0, 0, 0), + new Vector(0, 0, 0) + ); + + beam.EndPos.X = end.x; + beam.EndPos.Y = end.y; + beam.EndPos.Z = end.z; + Utilities.SetStateChanged(beam, "CBeam", "m_vecEndPos"); + + beam.DispatchSpawn(); + + return beam; + } + catch (Exception error) + { + _logger.LogError(error, "unable to draw a lineup preview"); + return null; + } + } + + private static void Kill(Ghost ghost) + { + foreach (CEnvBeam beam in ghost.Beams) + { + if (beam.IsValid) + { + beam.Remove(); + } + } + } + + private static Color ColorFor(string utilityType) + { + switch (utilityType) + { + case "Smoke": + return Color.FromArgb(255, 220, 220, 220); + case "Flash": + return Color.FromArgb(255, 120, 180, 255); + case "HighExplosive": + return Color.FromArgb(255, 255, 90, 90); + case "Molotov": + return Color.FromArgb(255, 255, 150, 40); + default: + return Color.FromArgb(255, 200, 120, 255); + } + } +} diff --git a/apps/utility-css/src/Services/PracticeScore.cs b/apps/utility-css/src/Services/PracticeScore.cs new file mode 100644 index 00000000..5d7ca870 --- /dev/null +++ b/apps/utility-css/src/Services/PracticeScore.cs @@ -0,0 +1,160 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Utils; +using FiveStack.Entities.Practice; + +namespace UtilityPractice; + +// Scores a throw against the lineup the thrower had loaded. +// +// The panel recomputes the distance from the lineup it owns and treats what is +// reported here as advisory, so this reports and does not argue: the success +// flag is only filled in once the panel has told us what radius it is using, +// and the streak a player is shown is always the panel's answer. +public class PracticeScore +{ + private readonly UtilityConfig _config; + private readonly UtilityApiClient _api; + private readonly PracticeSession _session; + private readonly PracticeSystem _system; + + // The last radius the panel used, so the advisory flag is a belief we were + // given rather than a number hard-coded here. + private float? _radius; + + private readonly HashSet _mastered = new HashSet(); + + public PracticeScore( + UtilityConfig config, + UtilityApiClient api, + PracticeSession session, + PracticeSystem system + ) + { + _config = config; + _api = api; + _session = session; + _system = system; + } + + // Raised once a throw has been through the panel, or once it is known that + // it could not be. A null result is "not scored", which is not the same as + // a miss: a drill counts on being able to tell the two apart. + public event Action? Scored; + + public void Reset() + { + _mastered.Clear(); + } + + // Raised by the recorder once a throw is over, which is the only moment + // both the thrower and the landing point are known. + public void OnFinalized(LineupRecord thrown) + { + if (!_config.IsConnected() || !ulong.TryParse(thrown.author_steam_id, out ulong steamId)) + { + return; + } + + LineupRecord? loaded = _system.StateFor(steamId).Loaded; + + // Nothing loaded is not a practice attempt, and a lineup that only + // exists on this server has no id for the panel to score against. + if (loaded == null || string.IsNullOrEmpty(loaded.id)) + { + return; + } + + // Throwing a flash while a smoke lineup is loaded is a different throw, + // not a missed one. + if (loaded.utility_type != thrown.utility_type) + { + return; + } + + Vec3 landing = thrown.detonation_position; + float distance = (landing - loaded.detonation_position).Length(); + + var payload = UtilityPracticeResultPayload.For( + _config.ServerId, + _session.Current?.id ?? Guid.Empty, + loaded.id, + steamId, + landing, + _radius == null ? null : distance <= _radius + ); + + string lineupId = loaded.id; + string key = $"{lineupId}:{steamId}"; + string name = string.IsNullOrEmpty(loaded.name) ? "that lineup" : loaded.name; + + _ = Task.Run(async () => + { + UtilityPracticeResult? result = await _api.PracticeResult(payload); + + Server.NextFrame(() => Report(steamId, lineupId, key, name, result, distance)); + }); + } + + private void Report( + ulong steamId, + string lineupId, + string key, + string name, + UtilityPracticeResult? result, + float measured + ) + { + if (result != null) + { + _radius = result.radius; + } + + Announce(steamId, key, name, result, measured); + + // Raised last and unconditionally: the verdict belongs on the player's + // screen before whatever a run says about it, and a run's bookkeeping + // is not allowed to depend on the thrower still standing there. + Scored?.Invoke(steamId, lineupId, result); + } + + private void Announce( + ulong steamId, + string key, + string name, + UtilityPracticeResult? result, + float measured + ) + { + CCSPlayerController? player = Utilities.GetPlayerFromSteamId(steamId); + + if (player == null || !player.IsValid) + { + return; + } + + if (result == null) + { + player.PrintToChat( + $" {ChatColors.Grey}{measured:0}u from {name} {ChatColors.Default}(not scored; the panel did not answer)" + ); + return; + } + + player.PrintToChat( + result.success + ? $" {ChatColors.Green}hit {ChatColors.Default}{name} {ChatColors.Grey}{result.distance:0}u - streak {result.current_streak} (best {result.best_streak})" + : $" {ChatColors.Red}miss {ChatColors.Default}{name} {ChatColors.Grey}{result.distance:0}u, needs {result.radius:0}u - {result.successes}/{result.attempts}" + ); + + if (result.mastered_at == null || !_mastered.Add(key)) + { + return; + } + + player.PrintToChat( + $" {ChatColors.Gold}mastered {ChatColors.Default}{name} {ChatColors.Grey}({result.best_streak} in a row)" + ); + player.PrintToCenter($"mastered\n{name}"); + } +} diff --git a/apps/utility-css/src/Services/PracticeSession.cs b/apps/utility-css/src/Services/PracticeSession.cs new file mode 100644 index 00000000..36d45781 --- /dev/null +++ b/apps/utility-css/src/Services/PracticeSession.cs @@ -0,0 +1,54 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace UtilityPractice; + +// A practice server never loads the match plugin, so this is where it learns +// who it is hosting for. The roster is the door policy; the connect hook reads +// nothing else. +public class PracticeSession +{ + private readonly UtilityApiClient _api; + private readonly ILogger _logger; + + private PracticeSessionData? _session; + + public PracticeSession(UtilityApiClient api, ILogger logger) + { + _api = api; + _logger = logger; + } + + public event Action? Refreshed; + + public PracticeSessionData? Current => _session; + + public async Task Refresh() + { + PracticeSessionData? session = await _api.Session(); + + // A failed fetch must not empty the roster: everyone already connected + // stays connected, and the door keeps the policy it had. + if (session == null) + { + _logger.LogWarning("unable to refresh the practice session; keeping the last roster"); + return; + } + + _session = session; + + _logger.LogInformation( + "practice session {id} ({players} players allowed)", + session.id, + session.allowed_steam_ids.Count + ); + + Refreshed?.Invoke(session); + } + + public bool IsAllowed(ulong steamId) + { + return _session != null && PracticeConnectUtility.IsOnRoster(_session, steamId); + } +} diff --git a/apps/utility-css/src/Services/PracticeSystem.cs b/apps/utility-css/src/Services/PracticeSystem.cs new file mode 100644 index 00000000..beda5efd --- /dev/null +++ b/apps/utility-css/src/Services/PracticeSystem.cs @@ -0,0 +1,393 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Memory; +using CounterStrikeSharp.API.Modules.Utils; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace UtilityPractice; + +public class PracticeState +{ + public LineupRecord? Loaded { 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; + + public Dictionary Positions { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public bool Noclip { get; set; } + public bool God { get; set; } + + // On by default: a lineup preview is one player's working note, not + // something the rest of the server asked to look at. + public bool Solo { get; set; } = true; + + // Off by default: the bloom outline is dozens of entities, and a player who + // has not asked for it should not be paying for it. + public bool Bloom { get; set; } + + // On by default, because a person loading a lineup wants to see the line. + // A capture client turns it off: beams drawn over the map end up in the + // clip instead of the throw. + public bool Ghosts { get; set; } = true; + + // 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. + public HashSet WarnedInexact { get; } = new HashSet(); + + public DateTime? TimerStartedAt { get; set; } +} + +// Per-player practice state, plus the one repeating job the whole plugin +// shares. One timer iterating players, never a timer per player: a practice +// server with ten people on it would otherwise be running ten of everything. +public class PracticeSystem +{ + private const int MaxSavedPositions = 32; + + private readonly UtilityConfig _config; + private readonly PracticeReplay _replay; + private readonly ILogger _logger; + + private readonly Dictionary _states = new(); + private readonly List<(ulong steamId, string weapon)> _regive = new(); + + public PracticeSystem( + UtilityConfig config, + PracticeReplay replay, + ILogger logger + ) + { + _config = config; + _replay = replay; + _logger = logger; + } + + public PracticeState StateFor(ulong steamId) + { + if (!_states.TryGetValue(steamId, out PracticeState? state)) + { + state = new PracticeState(); + _states[steamId] = state; + } + + return state; + } + + // Defaults to true for a player with no state yet, so a preview is never + // broadcast to the server on the strength of a missing dictionary entry. + public bool IsSolo(ulong steamId) + { + return !_states.TryGetValue(steamId, out PracticeState? state) || state.Solo; + } + + // Defaults to true for a player with no state yet, matching the config + // default rather than silently disabling previews for everybody. + public bool WantsGhosts(ulong steamId) + { + return !_states.TryGetValue(steamId, out PracticeState? state) || state.Ghosts; + } + + public void Forget(ulong steamId) + { + _states.Remove(steamId); + _replay.ClearGhosts(steamId); + _regive.RemoveAll(pending => pending.steamId == steamId); + } + + public void Reset() + { + _states.Clear(); + _regive.Clear(); + _replay.ClearAll(); + } + + // The shared second: re-assert the flags the engine keeps resetting, show + // whoever is timing themselves how long they have been at it, and retire + // expired previews. + public void Tick() + { + foreach (CCSPlayerController player in Utilities.GetPlayers()) + { + if (!player.IsValid || player.IsBot) + { + continue; + } + + if (!_states.TryGetValue(player.SteamID, out PracticeState? state)) + { + continue; + } + + CCSPlayerPawn? pawn = player.PlayerPawn.Value; + + if (pawn == null || !pawn.IsValid || !player.PawnIsAlive) + { + continue; + } + + ApplyFlags(pawn, state); + + if (state.TimerStartedAt != null) + { + double elapsed = (DateTime.UtcNow - state.TimerStartedAt.Value).TotalSeconds; + player.PrintToCenter($"{elapsed:0.0}s"); + } + } + + _replay.Sweep(); + } + + // Called on the release edge, so a thrown grenade is in the player's hand + // again a tick or two later. + public void OnThrown(ulong steamId, string utilityType) + { + if (!_config.InfiniteUtility) + { + return; + } + + string? weapon = PracticeLineupUtility.WeaponForUtilityType(utilityType); + + if (weapon == null) + { + return; + } + + _regive.Add((steamId, weapon)); + } + + // sv_infinite_ammo also freezes the throw animation and the pin, which + // makes every recorded release strength wrong; handing the grenade back is + // the only version that leaves the throw itself alone. + public void RefillUtility() + { + if (_regive.Count == 0) + { + return; + } + + var pending = _regive.ToList(); + _regive.Clear(); + + foreach ((ulong steamId, string weapon) in pending) + { + CCSPlayerController? player = Utilities.GetPlayerFromSteamId(steamId); + + if (player == null || !player.IsValid || !player.PawnIsAlive) + { + continue; + } + + if (HasWeapon(player, weapon)) + { + continue; + } + + player.GiveNamedItem(weapon); + } + } + + // The whole bag, every time somebody is alive without it. A practice + // server that makes you buy your utility before every throw is a practice + // server nobody uses; mp_maxmoney only helps if you remember to go and buy. + private static readonly string[] Loadout = new[] + { + "weapon_smokegrenade", + "weapon_flashbang", + "weapon_hegrenade", + "weapon_molotov", + "weapon_incgrenade", + "weapon_decoy", + }; + + public void GiveUtility(CCSPlayerController player) + { + if (!player.IsValid || !player.PawnIsAlive) + { + return; + } + + bool isCt = player.Team == CsTeam.CounterTerrorist; + + foreach (string weapon in Loadout) + { + // One firebomb per side, and it is not the same one. + if (weapon == "weapon_molotov" && isCt) + { + continue; + } + + if (weapon == "weapon_incgrenade" && !isCt) + { + continue; + } + + if (HasWeapon(player, weapon)) + { + continue; + } + + player.GiveNamedItem(weapon); + } + } + + public List ConnectedSteamIds() + { + return Utilities + .GetPlayers() + .Where(player => player.IsValid && !player.IsBot) + .Select(player => player.SteamID) + .ToList(); + } + + public bool SavePosition(CCSPlayerController player, string name) + { + PracticeState state = StateFor(player.SteamID); + + if ( + state.Positions.Count >= MaxSavedPositions + && !state.Positions.ContainsKey(name) + ) + { + return false; + } + + ThrowSnapshot? here = Where(player); + + if (here == null) + { + return false; + } + + state.Positions[name] = here; + return true; + } + + public static ThrowSnapshot? Where(CCSPlayerController player) + { + CCSPlayerPawn? pawn = player.PlayerPawn.Value; + Vector? origin = pawn?.AbsOrigin; + + if (pawn == null || origin == null) + { + return null; + } + + return new ThrowSnapshot + { + feet_position = new Vec3(origin.X, origin.Y, origin.Z), + pitch = pawn.EyeAngles.X, + yaw = pawn.EyeAngles.Y, + }; + } + + public static void TeleportTo(CCSPlayerController player, ThrowSnapshot position) + { + CCSPlayerPawn? pawn = player.PlayerPawn.Value; + + if (pawn == null || !pawn.IsValid) + { + return; + } + + // Yaw only for the body. A pawn's rotation is which way it faces, and + // a lineup's pitch is where the player is LOOKING -- feeding -63 into + // the body lies the model on its back. The aim goes on the eyes below. + pawn.Teleport( + new Vector( + position.feet_position.x, + position.feet_position.y, + position.feet_position.z + ), + new QAngle(0, position.yaw, 0), + new Vector(0, 0, 0) + ); + + pawn.EyeAngles.X = position.pitch; + pawn.EyeAngles.Y = position.yaw; + pawn.EyeAngles.Z = 0; + } + + public static List SpawnPoints() + { + var spawns = new List(); + + foreach (string designer in new[] { "info_player_terrorist", "info_player_counterterrorist" }) + { + foreach (CBaseEntity spawn in Utilities.FindAllEntitiesByDesignerName(designer)) + { + Vector? origin = spawn.AbsOrigin; + + if (origin == null) + { + continue; + } + + spawns.Add( + new ThrowSnapshot + { + feet_position = new Vec3(origin.X, origin.Y, origin.Z), + yaw = spawn.AbsRotation?.Y ?? 0f, + } + ); + } + } + + return spawns; + } + + // Re-asserted every second because respawning resets both. Only a move + // type this plugin set is ever undone: forcing MOVETYPE_WALK on everyone + // would break ladders and spectating for players who never asked for it. + private static void ApplyFlags(CCSPlayerPawn pawn, PracticeState state) + { + if (state.Noclip && pawn.MoveType != MoveType_t.MOVETYPE_NOCLIP) + { + SetMoveType(pawn, MoveType_t.MOVETYPE_NOCLIP); + } + else if (!state.Noclip && pawn.MoveType == MoveType_t.MOVETYPE_NOCLIP) + { + SetMoveType(pawn, MoveType_t.MOVETYPE_WALK); + } + + bool takesDamage = !state.God; + + if (pawn.TakesDamage != takesDamage) + { + pawn.TakesDamage = takesDamage; + } + } + + private static void SetMoveType(CCSPlayerPawn pawn, MoveType_t moveType) + { + pawn.MoveType = moveType; + // m_MoveType alone is cosmetic; the engine reads m_nActualMoveType. + Schema.SetSchemaValue(pawn.Handle, "CBaseEntity", "m_nActualMoveType", (byte)moveType); + Utilities.SetStateChanged(pawn, "CBaseEntity", "m_MoveType"); + } + + private static bool HasWeapon(CCSPlayerController player, string designerName) + { + CPlayer_WeaponServices? weapons = player.PlayerPawn.Value?.WeaponServices; + + if (weapons == null) + { + return false; + } + + foreach (var handle in weapons.MyWeapons) + { + if (handle.Value?.DesignerName == designerName) + { + return true; + } + } + + return false; + } +} diff --git a/apps/utility-css/src/Services/UtilityApiClient.cs b/apps/utility-css/src/Services/UtilityApiClient.cs new file mode 100644 index 00000000..7b12428f --- /dev/null +++ b/apps/utility-css/src/Services/UtilityApiClient.cs @@ -0,0 +1,461 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace UtilityPractice; + +// Everything the plugin says to the panel goes through here. Two rules hold for +// every method: it never throws at its caller, and it never runs on the game +// thread past its first await, because a practice server that stutters while a +// lineup uploads is worse than one that loses the upload. +// +// This is also the only place that knows the API's shapes. The API owns the +// wire contract, so LineupRecord is translated to and from it here rather than +// being sent as-is. +public class UtilityApiClient +{ + // A save that could not reach the panel is worth keeping, but only so many: + // an offline practice server left running overnight must not grow forever. + private const int MaxQueued = 64; + + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + + private readonly UtilityConfig _config; + private readonly ILogger _logger; + + private readonly object _queueLock = new object(); + private readonly Queue _retryQueue = new Queue(); + private readonly Queue _resultQueue = + new Queue(); + private readonly SemaphoreSlim _draining = new SemaphoreSlim(1, 1); + + public UtilityApiClient(UtilityConfig config, ILogger logger) + { + _config = config; + _logger = logger; + } + + private class IngestResponse + { + public string? id { get; set; } + } + + public int QueuedCount + { + get + { + lock (_queueLock) + { + return _retryQueue.Count + _resultQueue.Count; + } + } + } + + public async Task Ingest(LineupRecord record) + { + string? id = await Post(record); + + if (id == null) + { + Enqueue(record); + return null; + } + + // The panel is reachable again, so anything held back can go now. + _ = Drain(); + + return id; + } + + public async Task?> Library(string map, ulong steamId) + { + string? body = await SendText( + HttpMethod.Get, + $"/utility/library?map={Uri.EscapeDataString(map)}&steam_id={steamId}", + null + ); + + if (body == null) + { + return null; + } + + try + { + List? rows = ReadList(body, "lineups", "utility"); + + return rows == null + ? new List() + : rows.Select(row => row.ToLineup()).ToList(); + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the lineup library"); + return null; + } + } + + // A library row carries no flight path and no measured bloom, so both cost + // one more call. + public async Task Trajectory(string id, ulong steamId) + { + byte[]? body = await Send( + HttpMethod.Get, + $"/utility/{Uri.EscapeDataString(id)}/trajectory?steam_id={steamId}", + null + ); + + if (body == null) + { + return null; + } + + try + { + return UtilityTrajectoryArtifact.Parse(body); + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the trajectory for {id}", id); + return null; + } + } + + // The only way the panel learns anybody is on this server. A match server + // reports connects over the match-events socket; a practice server has no + // such socket, and without this every session reads as empty and gets + // reaped out from under whoever is throwing. + public async Task Occupancy(IReadOnlyCollection steamIds) + { + string body = JsonSerializer.Serialize( + new { steam_ids = steamIds.Select(id => id.ToString()).ToArray() }, + PracticeJson.Options + ); + + await SendText(HttpMethod.Post, "/utility/occupancy", body); + } + + public async Task Session() + { + string? body = await SendText(HttpMethod.Get, "/utility/session", null); + + if (body == null) + { + return null; + } + + try + { + return JsonSerializer + .Deserialize(body, PracticeJson.Options) + ?.ToSession(); + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the practice session"); + return null; + } + } + + // The panel recomputes the distance from the lineup it owns, so this is a + // report and not a claim. A result that could not be delivered goes on the + // same retry queue a save does -- nobody is waiting to be told about it by + // then, which is why only the live attempt answers. + public async Task PracticeResult(UtilityPracticeResultPayload payload) + { + UtilityPracticeResult? result = await PostResult(payload); + + if (result == null) + { + EnqueueResult(payload); + return null; + } + + _ = Drain(); + + return result; + } + + public async Task Delete(string id) + { + return await SendText(HttpMethod.Delete, $"/utility/{Uri.EscapeDataString(id)}", null) + != null; + } + + // Retries oldest first: a player's saves replay in the order they threw + // them, so the library reads the way the session went. + public async Task Drain() + { + if (!_config.IsConnected() || !await _draining.WaitAsync(0)) + { + return; + } + + try + { + while (true) + { + LineupRecord? record; + lock (_queueLock) + { + if (!_retryQueue.TryPeek(out record)) + { + break; + } + } + + if (await Post(record) == null) + { + return; + } + + lock (_queueLock) + { + _retryQueue.TryDequeue(out _); + } + } + + while (true) + { + UtilityPracticeResultPayload? result; + lock (_queueLock) + { + if (!_resultQueue.TryPeek(out result)) + { + return; + } + } + + if (await PostResult(result) == null) + { + return; + } + + lock (_queueLock) + { + _resultQueue.TryDequeue(out _); + } + } + } + finally + { + _draining.Release(); + } + } + + private void Enqueue(LineupRecord record) + { + lock (_queueLock) + { + while (_retryQueue.Count >= MaxQueued) + { + _retryQueue.TryDequeue(out _); + } + + _retryQueue.Enqueue(record); + } + } + + private void EnqueueResult(UtilityPracticeResultPayload payload) + { + lock (_queueLock) + { + while (_resultQueue.Count >= MaxQueued) + { + _resultQueue.TryDequeue(out _); + } + + _resultQueue.Enqueue(payload); + } + } + + private async Task Post(LineupRecord record) + { + string? body; + + try + { + body = JsonSerializer.Serialize( + UtilityIngestPayload.From(record), + PracticeJson.Options + ); + } + catch (Exception error) + { + // Unserializable means it will never succeed; dropping it beats + // wedging the queue behind it. + _logger.LogError(error, "unable to serialize lineup {client_id}", record.client_id); + return null; + } + + string? response = await SendText(HttpMethod.Post, "/utility/ingest", body); + + if (response == null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(response, PracticeJson.Options)?.id; + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the ingest response"); + return null; + } + } + + private async Task PostResult(UtilityPracticeResultPayload payload) + { + payload.server_id = string.IsNullOrEmpty(_config.ServerId) ? null : _config.ServerId; + + string body; + + try + { + body = JsonSerializer.Serialize(payload, PracticeJson.Options); + } + catch (Exception error) + { + _logger.LogError(error, "unable to serialize a practice result"); + return null; + } + + string? response = await SendText(HttpMethod.Post, "/utility/practice-result", body); + + if (response == null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(response, PracticeJson.Options); + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the practice result"); + return null; + } + } + + // Accepts either a bare array or an envelope naming one, so a wrapper key + // on the API side is not a silently empty library. + private static List? ReadList(string body, params string[] properties) + { + using JsonDocument document = JsonDocument.Parse(body); + + if (document.RootElement.ValueKind == JsonValueKind.Array) + { + return document.RootElement.Deserialize>(PracticeJson.Options); + } + + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (string property in properties) + { + if ( + document.RootElement.TryGetProperty(property, out JsonElement value) + && value.ValueKind == JsonValueKind.Array + ) + { + return value.Deserialize>(PracticeJson.Options); + } + } + + return null; + } + + private async Task SendText(HttpMethod method, string path, string? body) + { + byte[]? response = await Send(method, path, body); + + return response == null ? null : PracticeJson.Text(response); + } + + private async Task Send(HttpMethod method, string path, string? body) + { + if (!_config.IsConnected()) + { + return null; + } + + try + { + using var request = new HttpRequestMessage(method, Url(path)); + + if (!string.IsNullOrEmpty(_config.ServerApiPassword)) + { + request.Headers.TryAddWithoutValidation( + "x-server-api-password", + _config.ServerApiPassword + ); + } + + if (body != null) + { + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + } + + using var timeout = new CancellationTokenSource(RequestTimeout); + using HttpResponseMessage response = await HttpClientProvider.Client.SendAsync( + request, + timeout.Token + ); + + if (!response.IsSuccessStatusCode) + { + // The status alone says a request failed; the body says why. + // "player is not in this match lineup" and "this server has no + // live match" are both 400 and mean completely different things. + string reason = ""; + + try + { + reason = await response.Content.ReadAsStringAsync(); + } + catch + { + // A failure we cannot read is still a failure worth logging. + } + + _logger.LogWarning( + "{method} {path} returned {status}: {reason}", + method.Method, + path, + (int)response.StatusCode, + reason.Length > 500 ? reason.Substring(0, 500) : reason + ); + return null; + } + + return await response.Content.ReadAsByteArrayAsync(); + } + catch (Exception error) + { + _logger.LogError(error, "{method} {path} failed", method.Method, path); + return null; + } + } + + // Every utility endpoint resolves the session from the server rather than from + // anything the caller names, and it needs the server id to do it. + private string Url(string path) + { + if (string.IsNullOrEmpty(_config.ServerId)) + { + return $"{_config.UtilityUrl}{path}"; + } + + string separator = path.Contains('?') ? "&" : "?"; + + return $"{_config.UtilityUrl}{path}{separator}server_id={Uri.EscapeDataString(_config.ServerId)}"; + } +} diff --git a/apps/utility-css/src/Services/UtilityConfig.cs b/apps/utility-css/src/Services/UtilityConfig.cs new file mode 100644 index 00000000..1b437f09 --- /dev/null +++ b/apps/utility-css/src/Services/UtilityConfig.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace UtilityPractice; + +// Configuration arrives as addons/counterstrikesharp/configs/utility-practice.json, +// written by the panel from the registry's wiring block. The url and api key are +// provisioned per install, exactly as the inventory plugin receives its own. +public class UtilityConfig +{ + public string UtilityUrl { get; private set; } = ""; + + // The plugin key alone buys nothing: every utility endpoint also wants the + // server to prove which server it is. + public string ServerId { get; private set; } = ""; + public string ServerApiPassword { get; private set; } = ""; + public bool RecordEnabled { get; private set; } = true; + public bool ReplayEnabled { get; private set; } = true; + public bool InfiniteUtility { get; private set; } = true; + public bool NoFlash { get; private set; } = true; + public bool GhostPreview { get; private set; } = true; + public int MaxSaved { get; private set; } = 200; + + private readonly ILogger _logger; + + public UtilityConfig(ILogger logger) + { + _logger = logger; + } + + private class ConfigFile + { + public string? utility_url { get; set; } + public string? server_id { get; set; } + public string? server_api_password { get; set; } + public bool? np_record_enabled { get; set; } + public bool? np_replay_enabled { get; set; } + public bool? np_infinite_utility { get; set; } + public bool? np_no_flash { get; set; } + public bool? np_ghost_preview { get; set; } + public int? np_max_saved { get; set; } + } + + // Candidates rather than one path: the registry writes + // addons/{runtime}/configs/utility-practice.json, but the two runtimes root + // their plugin directories differently and an operator may drop the file + // beside the plugin instead. + public void Load(params string[] configDirectories) + { + // Env wins over the file so an operator can override a provisioned key + // without editing a file the panel rewrites. + UtilityUrl = Environment.GetEnvironmentVariable("UTILITY_URL") ?? ""; + ServerId = Environment.GetEnvironmentVariable("SERVER_ID") ?? ""; + ServerApiPassword = Environment.GetEnvironmentVariable("SERVER_API_PASSWORD") ?? ""; + + string? path = configDirectories + .Where(directory => !string.IsNullOrEmpty(directory)) + .Select(directory => Path.Join(directory, "utility-practice.json")) + .FirstOrDefault(File.Exists); + + if (path != null) + { + try + { + ConfigFile? parsed = JsonSerializer.Deserialize( + File.ReadAllText(path) + ); + + if (parsed != null) + { + if (string.IsNullOrEmpty(UtilityUrl)) + { + UtilityUrl = parsed.utility_url ?? ""; + } + if (string.IsNullOrEmpty(ServerId)) + { + ServerId = parsed.server_id ?? ""; + } + if (string.IsNullOrEmpty(ServerApiPassword)) + { + ServerApiPassword = parsed.server_api_password ?? ""; + } + RecordEnabled = parsed.np_record_enabled ?? RecordEnabled; + ReplayEnabled = parsed.np_replay_enabled ?? ReplayEnabled; + InfiniteUtility = parsed.np_infinite_utility ?? InfiniteUtility; + NoFlash = parsed.np_no_flash ?? NoFlash; + GhostPreview = parsed.np_ghost_preview ?? GhostPreview; + MaxSaved = parsed.np_max_saved ?? MaxSaved; + } + } + catch (Exception error) + { + _logger.LogError(error, "unable to read {path}", path); + } + } + + UtilityUrl = UtilityUrl.TrimEnd('/'); + + if (string.IsNullOrEmpty(UtilityUrl) || string.IsNullOrEmpty(ServerApiPassword)) + { + // Not fatal: local practice commands still work, saves just cannot + // reach the panel. Saying so once at load beats a silent failure on + // the player's first .save. + _logger.LogWarning( + "utility practice is not connected to a panel; lineups cannot be saved or loaded" + ); + } + } + + public bool IsConnected() + { + return !string.IsNullOrEmpty(UtilityUrl) && !string.IsNullOrEmpty(ServerApiPassword); + } +} diff --git a/apps/utility-css/src/UtilityPractice.csproj b/apps/utility-css/src/UtilityPractice.csproj new file mode 100644 index 00000000..6f9f6a26 --- /dev/null +++ b/apps/utility-css/src/UtilityPractice.csproj @@ -0,0 +1,43 @@ + + + true + UtilityPractice + UtilityPractice + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/utility-css/src/UtilityPracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs new file mode 100644 index 00000000..5fe32494 --- /dev/null +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -0,0 +1,458 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes; +using CounterStrikeSharp.API.Modules.Cvars; +using CounterStrikeSharp.API.Modules.Memory; +using CounterStrikeSharp.API.Modules.Timers; +using CounterStrikeSharp.API.Modules.Utils; +using FiveStack.Entities.Practice; +using Microsoft.Extensions.Logging; +using Timer = CounterStrikeSharp.API.Modules.Timers.Timer; + +namespace UtilityPractice; + +// Standalone plugin, installed through the 5stack game-plugin registry and +// bound to the utility-practice game mode. It is only ever loaded on a practice +// server, so nothing here checks whether practice is "enabled" -- being loaded +// at all is the gate. +[MinimumApiVersion(80)] +public partial class UtilityPracticePlugin : BasePlugin +{ + private readonly UtilityConfig _config; + private readonly UtilityApiClient _api; + private readonly PracticeSession _session; + private readonly PracticeRecorder _recorder; + private readonly PracticeLibrary _library; + private readonly PracticeReplay _replay; + private readonly PracticeSystem _system; + private readonly PracticeScore _score; + private readonly PracticePlaybook _playbook; + private readonly PracticeDrill _drill; + private readonly ILogger _logger; + + private Timer? _secondTimer; + private Timer? _refillTimer; + + public override string ModuleName => "UtilityPractice"; + public override string ModuleVersion => "__RELEASE_VERSION__"; + public override string ModuleAuthor => "5Stack.gg"; + public override string ModuleDescription => + "Records grenade lineups in game and replays saved lineups back to the thrower"; + + public UtilityPracticePlugin( + UtilityConfig config, + UtilityApiClient api, + PracticeSession session, + PracticeRecorder recorder, + PracticeLibrary library, + PracticeReplay replay, + PracticeSystem system, + PracticeScore score, + PracticePlaybook playbook, + PracticeDrill drill, + ILogger logger + ) + { + _config = config; + _api = api; + _session = session; + _recorder = recorder; + _library = library; + _replay = replay; + _system = system; + _score = score; + _playbook = playbook; + _drill = drill; + _logger = logger; + } + + public override void Load(bool hotReload) + { + _config.Load(Path.Join(ModuleDirectory, "../../configs"), ModuleDirectory); + + _session.Refreshed += OnSessionRefreshed; + _recorder.Thrown += _system.OnThrown; + _recorder.Finalized += _score.OnFinalized; + _recorder.Thrown += _drill.OnThrown; + _score.Scored += _drill.OnScored; + _replay.WantsGhosts = _system.WantsGhosts; + + WirePlaybook(); + WireDrill(); + + RegisterListener(_recorder.OnTick); + // A grenade's thrower and initial velocity are not populated at the + // moment the entity is created -- read them there and every throw is + // dropped for having no thrower. One frame later they are set. + RegisterListener(entity => + Server.NextFrame(() => + { + if (entity != null && entity.IsValid) + { + _recorder.OnProjectileCreated(entity); + } + }) + ); + RegisterListener(OnMapStart); + RegisterListener(OnClientAuthorized); + RegisterListener(OnClientDisconnect); + RegisterListener(OnCheckTransmit); + + ConnectClientFunc.Hook(ConnectClientHook, HookMode.Pre); + + // One repeating job for the whole plugin, not one per player. The + // execute runner rides the same two rather than starting a third. + _secondTimer = AddTimer(1f, OnSecond, TimerFlags.REPEAT); + _refillTimer = AddTimer(0.1f, OnFastTick, TimerFlags.REPEAT); + + // Only on a hot reload -- see the swiftly plugin: a cold boot has no map + // yet, and OnMapChange does both of these when it arrives. + if (hotReload) + { + _library.SetMap(Server.MapName); + ApplyPracticeCfg(); + RefreshEverything(); + } + + _logger.LogInformation( + "utility practice {version} loaded (connected: {connected})", + ModuleVersion, + _config.IsConnected() + ); + } + + public override void Unload(bool hotReload) + { + _session.Refreshed -= OnSessionRefreshed; + _recorder.Thrown -= _system.OnThrown; + _recorder.Finalized -= _score.OnFinalized; + _recorder.Thrown -= _drill.OnThrown; + _score.Scored -= _drill.OnScored; + + ConnectClientFunc.Unhook(ConnectClientHook, HookMode.Pre); + + _secondTimer?.Kill(); + _secondTimer = null; + _refillTimer?.Kill(); + _refillTimer = null; + + _playbook.Reset(); + _drill.Reset(); + _system.Reset(); + } + + private void OnSecond() + { + EndWarmup(); + RespawnTheDead(); + KeepEveryoneStocked(); + ReportOccupancy(); + _system.Tick(); + _playbook.Second(); + _drill.Second(); + } + + // Nobody stays dead on a practice server. Rejoining while dead, falling off + // something, or a stray molotov all leave a player spectating a map they + // came here to throw on -- and no round ever ends to bring them back. + private void RespawnTheDead() + { + foreach (CCSPlayerController player in Utilities.GetPlayers()) + { + if (player == null || !player.IsValid || player.IsBot || player.PawnIsAlive) + { + continue; + } + + if (player.Team is CsTeam.CounterTerrorist or CsTeam.Terrorist) + { + player.Respawn(); + } + } + } + + // Every second rather than only on spawn: a respawn, a team switch and a + // round reset all hand a player an empty bag, and the cost of checking is + // one loop over the weapons they already have. + private int _occupancyTicks; + + // Every few seconds, not every one: the panel only needs to know somebody + // is here, and the reaper's clocks are measured in minutes. + private void ReportOccupancy() + { + if (++_occupancyTicks < OccupancySeconds) + { + return; + } + + _occupancyTicks = 0; + + var present = new List(); + + foreach (CCSPlayerController player in Utilities.GetPlayers()) + { + if (player != null && player.IsValid && !player.IsBot) + { + present.Add(player.SteamID); + } + } + + _ = Task.Run(() => _api.Occupancy(present)); + } + + private int _warmupTicks; + + // A practice server is never in warmup. Enforced rather than set once: + // mp_warmup_end at map load runs before warmup has begun, and the game + // starts one of its own whenever it feels like it -- on the first connect, + // on a restart, after a mode cfg lands. + private void EndWarmup() + { + if (--_warmupTicks > 0) + { + return; + } + + CCSGameRules? rules = Utilities + .FindAllEntitiesByDesignerName("cs_gamerules") + .FirstOrDefault() + ?.GameRules; + + if (rules == null || !rules.WarmupPeriod) + { + return; + } + + // Not every tick: the command takes a moment to land, and re-issuing it + // in the meantime achieves nothing. + _warmupTicks = WarmupRetrySeconds; + Server.ExecuteCommand("mp_warmup_end"); + } + + private void KeepEveryoneStocked() + { + foreach (CCSPlayerController player in Utilities.GetPlayers()) + { + if (player == null || !player.IsValid || player.IsBot) + { + continue; + } + + _system.GiveUtility(player); + } + } + + private void OnFastTick() + { + _system.RefillUtility(); + _playbook.Tick(); + } + + // A step stands a player on its lineup by the same path .load does, so the + // teleport, the utility and the preview cannot drift apart. + private void WirePlaybook() + { + _playbook.Load = (steamId, lineup) => + { + CCSPlayerController? player = Utilities.GetPlayerFromSteamId(steamId); + + if (player != null && player.IsValid) + { + Apply(player, lineup); + } + }; + + _playbook.Chat = message => Server.PrintToChatAll($" {ChatColors.Green}{message}"); + + _playbook.Tell = (steamId, message) => Tell(steamId, $" {ChatColors.Green}{message}"); + + _playbook.Center = (steamId, message) => + { + CCSPlayerController? player = Utilities.GetPlayerFromSteamId(steamId); + + if (player != null && player.IsValid) + { + player.PrintToCenter(message); + } + }; + } + + // A drill stands a player on its lineup by the same path .load does, and + // says so only to them: several people drill in one server. + private void WireDrill() + { + _drill.Load = (steamId, lineup) => + { + CCSPlayerController? player = Utilities.GetPlayerFromSteamId(steamId); + + if (player == null || !player.IsValid || !_config.ReplayEnabled) + { + return false; + } + + Apply(player, lineup); + + return true; + }; + + _drill.Tell = (steamId, message) => Tell(steamId, $" {ChatColors.Green}{message}"); + + _drill.Note = (steamId, message) => Tell(steamId, $" {ChatColors.Grey}{message}"); + + _drill.Center = (steamId, message) => + { + CCSPlayerController? player = Utilities.GetPlayerFromSteamId(steamId); + + if (player != null && player.IsValid) + { + player.PrintToCenter(message); + } + }; + } + + private void OnMapStart(string mapName) + { + _recorder.Reset(); + _playbook.Reset(); + _drill.Reset(); + _score.Reset(); + _system.Reset(); + _library.SetMap(mapName); + + ApplyPracticeCfg(); + + RefreshEverything(); + } + + // 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. + private void RefreshEverything() + { + _ = Task.Run(async () => + { + await _session.Refresh(); + await _api.Drain(); + }); + + foreach (CCSPlayerController player in Utilities.GetPlayers()) + { + if (player.IsValid && !player.IsBot) + { + _library.Refresh(player.SteamID); + } + } + } + + // The state a practice server has to be in, applied by the plugin rather + // than a game mode cfg: a practice server may be a third-party dedicated + // box that no mode was ever selected for, and without this it sits in + // warmup with no money and no utility. + private readonly HashSet _welcomed = new(); + + private const int OccupancySeconds = 15; + private const int WarmupRetrySeconds = 3; + + private const float CfgReapplySeconds = 3f; + + private static readonly string[] PracticeCfg = new[] + { + "sv_cheats 1", + // Nothing ends the round: a kill or an expired timer would reset + // everyone mid-lineup. + "mp_ignore_round_win_conditions 1", + "mp_warmuptime 1", + "mp_warmup_pausetimer 0", + "mp_halftime 0", + "mp_match_can_clinch 0", + "mp_team_intro_time 0", + "mp_round_restart_delay 0", + "mp_warmup_end", + "mp_freezetime 0", + "mp_roundtime 60", + "mp_roundtime_defuse 60", + "mp_respawn_immunitytime 0", + "mp_buy_anywhere 1", + "mp_buytime 60000", + "mp_maxmoney 65535", + "mp_startmoney 65535", + "mp_afterroundmoney 65535", + "mp_death_drop_gun 0", + "mp_death_drop_grenade 0", + "mp_solid_teammates 0", + "mp_teammates_are_enemies 0", + "sv_grenade_trajectory_prac_pipreview 1", + // The trail is how you see WHERE it went wrong rather than just that it + // did. Ten seconds outlives the throw and the walk back to the spot. + "sv_grenade_trajectory_prac_trailtime 10", + // Valve's own map-guide editor. Every annotation_* command is client + // side, so a plugin can never draw one for a player -- but this cvar + // decides whether they may draw their own, and it ships at view-only. + // On a practice server there is no reason to withhold the editor. + "sv_allow_annotations_access_level 2", + "sv_infinite_ammo 1", + "ammo_grenade_limit_total 5", + "sv_full_alltalk 1", + "tv_enable 0", + }; + + private void ApplyPracticeCfg() + { + // Twice, and the second one is the one that usually takes. On a map + // change the frame after load is before warmup has begun, so + // mp_warmup_end there ends nothing and the server sits in a countdown. + Server.NextFrame(() => RunPracticeCfg()); + AddTimer(CfgReapplySeconds, () => RunPracticeCfg()); + } + + private void RunPracticeCfg() + { + Server.ExecuteCommand(string.Join(";", PracticeCfg)); + + // The map change did not take the session with it, and sv_password is + // the one thing here that is per-session rather than per-map. + PracticeSessionData? session = _session.Current; + + if (session == null || string.IsNullOrEmpty(session.password)) + { + return; + } + + var password = ConVar.Find("sv_password"); + password?.SetValue(session.password); + } + + private void OnSessionRefreshed(PracticeSessionData session) + { + if (string.IsNullOrEmpty(session.password)) + { + _logger.LogWarning( + "practice session {session} carries no password; the connect hook has nothing to present", + session.id + ); + return; + } + + SetPasswordBuffer(session.password); + + // The buffer only substitutes this password into the connect call -- + // the server still has to be the one asking for it. Without this the + // hook hands over a password sv_password never heard of, and every + // assigned player is turned away with "bad password". + var password = ConVar.Find("sv_password"); + + if (password is null) + { + _logger.LogError( + "could not find sv_password; assigned players will be rejected" + ); + return; + } + + password.SetValue(session.password); + + _logger.LogInformation( + "practice session {session} password applied to sv_password", + session.id + ); + } +} diff --git a/apps/utility-css/src/UtilityPracticeServiceCollection.cs b/apps/utility-css/src/UtilityPracticeServiceCollection.cs new file mode 100644 index 00000000..516f15f5 --- /dev/null +++ b/apps/utility-css/src/UtilityPracticeServiceCollection.cs @@ -0,0 +1,23 @@ +using CounterStrikeSharp.API.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace UtilityPractice; + +// CounterStrikeSharp discovers this automatically and builds the plugin's +// container from it, mirroring the match plugin's FiveStackServiceCollection. +public class UtilityPracticeServiceCollection : IPluginServiceCollection +{ + public void ConfigureServices(IServiceCollection serviceCollection) + { + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + } +} diff --git a/apps/utility-css/test/DrillUtilityTests.cs b/apps/utility-css/test/DrillUtilityTests.cs new file mode 100644 index 00000000..3db0ce91 --- /dev/null +++ b/apps/utility-css/test/DrillUtilityTests.cs @@ -0,0 +1,432 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +// A drill is a queue and a verdict. The ways it goes wrong quietly are a run +// that hands out a lineup the panel cannot score, a "worst first" that is +// really alphabetical, and a count somebody typed being taken literally. +public class DrillUtilityTests +{ + private static LineupRecord Lineup(string id, string? name = null, string utility = "Smoke") + { + return new LineupRecord + { + id = id, + client_id = id, + name = name ?? id, + utility_type = utility, + release = new ThrowSnapshot { feet_position = new Vec3(100f, 200f, 64f) }, + detonation_position = new Vec3(900f, -400f, 64f), + }; + } + + private static UtilityPracticeResult Result( + bool success, + int attempts, + int successes, + bool mastered = false + ) + { + return new UtilityPracticeResult + { + success = success, + distance = 40f, + radius = 80f, + attempts = attempts, + successes = successes, + current_streak = success ? 1 : 0, + best_streak = 1, + mastered_at = mastered ? DateTime.UtcNow : null, + }; + } + + private static Func Progress( + params (string id, int attempts, int successes)[] rows + ) + { + var book = rows.ToDictionary( + row => row.id, + row => new DrillProgress { Attempts = row.attempts, Successes = row.successes } + ); + + return lineup => + lineup.id != null && book.TryGetValue(lineup.id, out DrillProgress? progress) + ? progress + : null; + } + + [Fact] + public void ALineupThePanelHasNeverSeenCannotBeDrilled() + { + LineupRecord local = Lineup("keep"); + local.id = null; + + Assert.False(DrillUtility.IsDrillable(local)); + } + + [Fact] + public void ALineupWithNoOriginCannotBeDrilled() + { + LineupRecord lineup = Lineup("a"); + lineup.release = new ThrowSnapshot(); + + Assert.False(DrillUtility.IsDrillable(lineup)); + } + + [Fact] + public void ALineupWithNoLandingPointCannotBeDrilled() + { + LineupRecord lineup = Lineup("a"); + lineup.detonation_position = new Vec3(0f, 0f, 0f); + + Assert.False(DrillUtility.IsDrillable(lineup)); + } + + [Fact] + public void ASavedLineupCanBeDrilled() + { + Assert.True(DrillUtility.IsDrillable(Lineup("a"))); + } + + [Fact] + public void DrillableDropsWhatCannotBeScored() + { + LineupRecord local = Lineup("local"); + local.id = ""; + + List drillable = DrillUtility.Drillable( + new[] { Lineup("a"), local, Lineup("b") } + ); + + Assert.Equal(new[] { "a", "b" }, drillable.Select(lineup => lineup.id)); + } + + [Fact] + public void ANamelessLineupFallsBackToItsUtility() + { + LineupRecord lineup = Lineup("a", name: ""); + + Assert.Equal("Smoke", DrillUtility.Name(lineup)); + } + + [Fact] + public void AnUnattemptedLineupSortsBetweenMissedAndPerfect() + { + float missed = DrillUtility.Priority(new DrillProgress { Attempts = 4, Successes = 0 }); + float perfect = DrillUtility.Priority(new DrillProgress { Attempts = 4, Successes = 4 }); + + Assert.True(missed < DrillUtility.UnattemptedPriority); + Assert.True(DrillUtility.UnattemptedPriority < perfect); + Assert.Equal(DrillUtility.UnattemptedPriority, DrillUtility.Priority(null)); + } + + [Fact] + public void AMasteredLineupIsAlwaysLast() + { + float mastered = DrillUtility.Priority( + new DrillProgress + { + Attempts = 10, + Successes = 10, + Mastered = true, + } + ); + + Assert.True( + mastered > DrillUtility.Priority(new DrillProgress { Attempts = 1, Successes = 1 }) + ); + } + + [Fact] + public void WorstFirstPutsTheOnesGoingWorstFirst() + { + var lineups = new[] { Lineup("perfect"), Lineup("half"), Lineup("never"), Lineup("bad") }; + + List ordered = DrillUtility.WorstFirst( + lineups, + Progress(("perfect", 6, 6), ("half", 6, 3), ("bad", 6, 1)) + ); + + Assert.Equal( + new[] { "bad", "half", "never", "perfect" }, + ordered.Select(lineup => lineup.id) + ); + } + + // Two lineups going equally badly are not equally well known. + [Fact] + public void EquallyBadLineupsAreOrderedByHowMuchIsKnown() + { + List ordered = DrillUtility.WorstFirst( + new[] { Lineup("thin"), Lineup("thick") }, + Progress(("thin", 1, 0), ("thick", 12, 0)) + ); + + Assert.Equal(new[] { "thick", "thin" }, ordered.Select(lineup => lineup.id)); + } + + [Fact] + public void AQueueIsAsLongAsItWasAskedFor() + { + List queue = DrillUtility.Queue( + new[] { Lineup("a"), Lineup("b"), Lineup("c") }, + 7, + eDrillOrder.Random, + _ => null, + new Random(4) + ); + + Assert.Equal(7, queue.Count); + } + + [Fact] + public void ARunIsCappedNoMatterWhatWasAskedFor() + { + List queue = DrillUtility.Queue( + new[] { Lineup("a"), Lineup("b") }, + 5000, + eDrillOrder.Random, + _ => null, + new Random(4) + ); + + Assert.Equal(DrillUtility.MaxCount, queue.Count); + } + + // A book shorter than the run is drilled in whole passes, so nothing comes + // round twice before everything has come round once. + [Fact] + public void EveryLineupIsDrilledBeforeAnyIsRepeated() + { + var lineups = new[] { Lineup("a"), Lineup("b"), Lineup("c"), Lineup("d") }; + + List queue = DrillUtility.Queue( + lineups, + 8, + eDrillOrder.Random, + _ => null, + new Random(11) + ); + + Assert.Equal(4, queue.Take(4).Select(lineup => lineup.id).Distinct().Count()); + Assert.Equal(4, queue.Skip(4).Select(lineup => lineup.id).Distinct().Count()); + } + + [Fact] + public void APassSeamNeverRepeatsTheSameLineupBackToBack() + { + var lineups = new[] { Lineup("a"), Lineup("b"), Lineup("c") }; + + for (int seed = 0; seed < 50; seed++) + { + List queue = DrillUtility.Queue( + lineups, + 12, + eDrillOrder.Random, + _ => null, + new Random(seed) + ); + + for (int index = 1; index < queue.Count; index++) + { + Assert.NotEqual(queue[index - 1].client_id, queue[index].client_id); + } + } + } + + [Fact] + public void AWorstFirstQueueStartsWithTheWorst() + { + List queue = DrillUtility.Queue( + new[] { Lineup("good"), Lineup("bad") }, + 2, + eDrillOrder.Worst, + Progress(("good", 5, 5), ("bad", 5, 0)), + new Random(1) + ); + + Assert.Equal(new[] { "bad", "good" }, queue.Select(lineup => lineup.id)); + } + + [Fact] + public void AnEmptyLibraryIsAnEmptyQueue() + { + Assert.Empty( + DrillUtility.Queue( + new List(), + 10, + eDrillOrder.Random, + _ => null, + new Random(1) + ) + ); + } + + [Fact] + public void ALibraryOfUnscorableLineupsIsAnEmptyQueue() + { + LineupRecord local = Lineup("local"); + local.id = null; + + Assert.Empty( + DrillUtility.Queue(new[] { local }, 10, eDrillOrder.Random, _ => null, new Random(1)) + ); + } + + [Fact] + public void NoArgumentsIsAShuffledRunOfTheDefaultLength() + { + DrillRequest request = DrillUtility.Parse(""); + + Assert.True(request.Valid); + Assert.False(request.Stop); + Assert.Equal(eDrillOrder.Random, request.Order); + Assert.Equal(DrillUtility.DefaultCount, request.Count); + } + + [Fact] + public void StopIsRead() + { + Assert.True(DrillUtility.Parse(" stop ").Stop); + Assert.True(DrillUtility.Parse("end").Stop); + } + + [Fact] + public void ACountIsRead() + { + Assert.Equal(25, DrillUtility.Parse("25").Count); + } + + [Fact] + public void ACountIsCappedRatherThanRefused() + { + DrillRequest request = DrillUtility.Parse("900"); + + Assert.True(request.Valid); + Assert.Equal(DrillUtility.MaxCount, request.Count); + } + + // A player typing this into chat is not consulting a usage line. + [Fact] + public void OrderAndCountAreReadInEitherOrder() + { + DrillRequest first = DrillUtility.Parse("worst 12"); + DrillRequest second = DrillUtility.Parse("12 worst"); + + Assert.Equal(eDrillOrder.Worst, first.Order); + Assert.Equal(12, first.Count); + Assert.Equal(eDrillOrder.Worst, second.Order); + Assert.Equal(12, second.Count); + } + + [Fact] + public void RandomCanBeAskedForOutLoud() + { + Assert.Equal(eDrillOrder.Random, DrillUtility.Parse("random").Order); + } + + [Fact] + public void AnUnreadableArgumentIsARefusalRatherThanAGuess() + { + Assert.False(DrillUtility.Parse("banana").Valid); + Assert.False(DrillUtility.Parse("0").Valid); + Assert.False(DrillUtility.Parse("-3").Valid); + } + + [Fact] + public void QuotesAndSpacingAreTolerated() + { + DrillRequest request = DrillUtility.Parse("\" worst 8 \""); + + Assert.True(request.Valid); + Assert.Equal(eDrillOrder.Worst, request.Order); + Assert.Equal(8, request.Count); + } + + // The panel's counters are absolute, so a result replaces what we thought + // rather than adding to it: two throws are not four attempts. + [Fact] + public void AResultReplacesTheProgressItReportsOn() + { + var book = new DrillProgressBook(); + + book.Record(1, "lineup", Result(true, attempts: 3, successes: 1)); + book.Record(1, "lineup", Result(true, attempts: 4, successes: 2)); + + DrillProgress? progress = book.For(1, "lineup"); + + Assert.NotNull(progress); + Assert.Equal(4, progress!.Attempts); + Assert.Equal(2, progress.Successes); + Assert.Equal(0.5f, progress.Rate); + } + + [Fact] + public void ProgressIsPerPlayer() + { + var book = new DrillProgressBook(); + + book.Record(1, "lineup", Result(true, attempts: 10, successes: 10)); + + Assert.NotNull(book.For(1, "lineup")); + Assert.Null(book.For(2, "lineup")); + } + + [Fact] + public void AThrowThePanelDidNotAnswerTeachesNothing() + { + var book = new DrillProgressBook(); + + book.Record(1, "lineup", null); + + Assert.Null(book.For(1, "lineup")); + } + + [Fact] + public void MasteryIsCarriedThrough() + { + var book = new DrillProgressBook(); + + book.Record(1, "lineup", Result(true, attempts: 9, successes: 9, mastered: true)); + + Assert.True(book.For(1, "lineup")!.Mastered); + } + + [Fact] + public void ForgettingAPlayerForgetsTheirProgress() + { + var book = new DrillProgressBook(); + + book.Record(1, "lineup", Result(true, attempts: 2, successes: 2)); + book.Record(2, "lineup", Result(true, attempts: 2, successes: 2)); + book.Forget(1); + + Assert.Null(book.For(1, "lineup")); + Assert.NotNull(book.For(2, "lineup")); + } + + [Fact] + public void ClearingTheBookForgetsEverybody() + { + var book = new DrillProgressBook(); + + book.Record(1, "lineup", Result(true, attempts: 2, successes: 2)); + book.Clear(); + + Assert.Null(book.For(1, "lineup")); + } + + [Fact] + public void TheBookAnswersForWholeLineups() + { + var book = new DrillProgressBook(); + + book.Record(7, "lineup", Result(false, attempts: 5, successes: 1)); + + Func lookup = book.Lookup(7); + + Assert.Equal(5, lookup(Lineup("lineup"))!.Attempts); + Assert.Null(lookup(Lineup("other"))); + } +} diff --git a/apps/utility-css/test/FiveStack.Tests.csproj b/apps/utility-css/test/FiveStack.Tests.csproj new file mode 100644 index 00000000..79121e9f --- /dev/null +++ b/apps/utility-css/test/FiveStack.Tests.csproj @@ -0,0 +1,43 @@ + + + false + bin/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/utility-css/test/PlaybookUtilityTests.cs b/apps/utility-css/test/PlaybookUtilityTests.cs new file mode 100644 index 00000000..78d94cdd --- /dev/null +++ b/apps/utility-css/test/PlaybookUtilityTests.cs @@ -0,0 +1,233 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +// An execute is a clock, and the two ways it goes wrong are silent: a step that +// fires twice, and a step that never fires at all. +public class PlaybookUtilityTests +{ + private static UtilityPlaybookStep Step( + int order, + int offsetMs, + string? assigned = null, + bool withLineup = true, + bool seeded = false, + string? confidence = null + ) + { + return new UtilityPlaybookStep + { + utility_lineup_id = $"lineup-{order}", + step_order = order, + offset_ms = offsetMs, + assigned_steam_id = assigned, + note = $"step {order}", + lineup = withLineup + ? new UtilityLibraryRow + { + id = $"row-{order}", + name = $"utility {order}", + utility_type = "Smoke", + origin_x = 1f, + origin_y = 2f, + origin_z = 3f, + land_x = 4f, + land_y = 5f, + land_z = 6f, + initial_pos_x = seeded ? 11f : null, + initial_pos_y = seeded ? 22f : null, + initial_pos_z = seeded ? 33f : null, + initial_vel_x = seeded ? 400f : null, + initial_vel_y = seeded ? -500f : null, + initial_vel_z = seeded ? 600f : null, + confidence = confidence, + } + : null, + }; + } + + private static UtilityPlaybook Playbook(params UtilityPlaybookStep[] steps) + { + return new UtilityPlaybook + { + id = "book", + name = "A execute", + map_name = "de_mirage", + side = "TERRORIST", + steps = steps.ToList(), + }; + } + + [Fact] + public void NoPlaybookIsNoSteps() + { + Assert.Empty(PlaybookUtility.Ordered(null)); + } + + [Fact] + public void StepsAreOrderedByStepOrder() + { + var ordered = PlaybookUtility.Ordered( + Playbook(Step(3, 0), Step(1, 900), Step(2, 400)) + ); + + Assert.Equal(new[] { 1, 2, 3 }, ordered.Select(step => step.step_order)); + } + + // A step whose lineup the panel declined to inline cannot be loaded, and + // teleporting somebody onto nothing is worse than skipping it. + [Fact] + public void AStepWithNoLineupIsDropped() + { + var ordered = PlaybookUtility.Ordered( + Playbook(Step(1, 0), Step(2, 100, withLineup: false)) + ); + + Assert.Single(ordered); + Assert.Equal(1, ordered[0].step_order); + } + + [Fact] + public void ABookLongerThanItsOwnCapIsTruncated() + { + var steps = Enumerable + .Range(0, PlaybookUtility.MaxSteps + 10) + .Select(index => Step(index, index * 100)) + .ToArray(); + + Assert.Equal(PlaybookUtility.MaxSteps, PlaybookUtility.Ordered(Playbook(steps)).Count); + } + + [Fact] + public void AStepAtZeroFiresExactlyOnce() + { + var steps = PlaybookUtility.Ordered(Playbook(Step(1, 0), Step(2, 500))); + + Assert.Single(PlaybookUtility.Due(steps, -1, 0)); + Assert.Empty(PlaybookUtility.Due(steps, 0, 0)); + Assert.Empty(PlaybookUtility.Due(steps, 0, 100)); + } + + [Fact] + public void AWindowClaimsEveryStepInsideIt() + { + var steps = PlaybookUtility.Ordered( + Playbook(Step(1, 0), Step(2, 100), Step(3, 200), Step(4, 5000)) + ); + + var due = PlaybookUtility.Due(steps, -1, 250); + + Assert.Equal(new[] { 1, 2, 3 }, due.Select(step => step.step_order)); + } + + [Fact] + public void WalkingTheWindowFiresEveryStepOnce() + { + var steps = PlaybookUtility.Ordered( + Playbook(Step(1, 0), Step(2, 100), Step(3, 100), Step(4, 2500)) + ); + + var fired = new List(); + + for (int elapsed = 0; elapsed <= 3000; elapsed += 100) + { + fired.AddRange( + PlaybookUtility.Due(steps, elapsed - 100, elapsed).Select(step => step.step_order) + ); + } + + Assert.Equal(new[] { 1, 2, 3, 4 }, fired); + } + + [Fact] + public void TheDurationIsTheLastOffset() + { + Assert.Equal(0, PlaybookUtility.DurationMs(new List())); + Assert.Equal( + 2500, + PlaybookUtility.DurationMs( + PlaybookUtility.Ordered(Playbook(Step(1, 0), Step(2, 2500))) + ) + ); + } + + [Fact] + public void AnUnassignedStepBelongsToEveryone() + { + UtilityPlaybookStep step = Step(1, 0); + + Assert.False(PlaybookUtility.IsAssigned(step)); + Assert.True(PlaybookUtility.IsFor(step, 76561198000000001)); + Assert.True(PlaybookUtility.IsFor(step, 76561198000000002)); + } + + [Fact] + public void AnAssignedStepBelongsToOnlyThatPlayer() + { + UtilityPlaybookStep step = Step(1, 0, assigned: " 76561198000000001 "); + + Assert.True(PlaybookUtility.IsAssigned(step)); + Assert.True(PlaybookUtility.IsFor(step, 76561198000000001)); + Assert.False(PlaybookUtility.IsFor(step, 76561198000000002)); + } + + // The step names the lineup; the inlined row is only its geometry. Scoring + // posts the step's id, so the two must not be allowed to disagree. + [Fact] + public void AStepsLineupCarriesTheStepsLineupId() + { + LineupRecord? lineup = Step(1, 0).ToLineup(); + + Assert.NotNull(lineup); + Assert.Equal("lineup-1", lineup!.id); + Assert.Equal("lineup-1", lineup.client_id); + Assert.Equal("Smoke", lineup.utility_type); + Assert.Equal(4f, lineup.detonation_position.x); + } + + [Fact] + public void AStepWithNoLineupConvertsToNothing() + { + Assert.Null(Step(1, 0, withLineup: false).ToLineup()); + } + + // A step inlines the same library row, so an execute re-emits its throws + // exactly wherever the panel has a seed for them. + [Fact] + public void AStepInheritsTheSeedOfTheLineupItNames() + { + LineupRecord? seeded = Step(1, 0, seeded: true).ToLineup(); + + Assert.NotNull(seeded); + Assert.Equal(11f, seeded!.initial_position.x); + Assert.Equal(400f, seeded.initial_velocity.x); + Assert.True(seeded.initial_velocity.Length() > 0f); + } + + // An execute re-emits a step exactly only where the panel vouched for it; + // a mined step is something to practise toward, not to replay. + [Fact] + public void AStepIsReplayedOnlyWhenThePanelCalledItExact() + { + LineupRecord? exact = Step(1, 0, seeded: true, confidence: "exact").ToLineup(); + LineupRecord? mined = Step(2, 0, seeded: true, confidence: "derived").ToLineup(); + LineupRecord? unknown = Step(3, 0, seeded: true).ToLineup(); + + Assert.True(exact!.IsExactlyReplayable()); + Assert.False(mined!.IsExactlyReplayable()); + Assert.False(unknown!.IsExactlyReplayable()); + + Assert.True(mined.IsKnownInexact()); + Assert.False(unknown.IsKnownInexact()); + } + + [Fact] + public void AStepNamingASeedlessLineupIsNotReplayable() + { + LineupRecord? plain = Step(1, 0).ToLineup(); + + Assert.NotNull(plain); + Assert.Equal(0f, plain!.initial_velocity.Length()); + Assert.Equal(0f, plain.initial_position.Length()); + } +} diff --git a/apps/utility-css/test/PracticeCalibrationUtilityTests.cs b/apps/utility-css/test/PracticeCalibrationUtilityTests.cs new file mode 100644 index 00000000..10aeeae4 --- /dev/null +++ b/apps/utility-css/test/PracticeCalibrationUtilityTests.cs @@ -0,0 +1,349 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +// The gate. Every test here is about refusing rather than solving: the failure +// this exists to prevent is a solve that ran anyway and handed back lineups +// that land somewhere plausible and cannot be thrown. +public class PracticeCalibrationUtilityTests +{ + private static readonly Vec3 Eye = new Vec3(100f, 200f, 64f); + private static readonly Vec3 Landing = new Vec3(900f, 1200f, 0f); + + // A throw as a perfect engine would have recorded it: the seed is exactly + // what the launch model predicts. + private static LineupRecord Sample( + float pitch = -15f, + float yaw = 40f, + float strength = 1f, + int bounces = 0, + string id = "sample" + ) + { + LaunchSeed seed = PracticeLaunchUtility.Seed( + Eye, + pitch, + yaw, + strength, + new Vec3(0f, 0f, 0f) + ); + + return new LineupRecord + { + client_id = id, + utility_type = "Smoke", + bounces = bounces, + release = new ThrowSnapshot + { + feet_position = new Vec3(Eye.x, Eye.y, 0f), + eye_position = Eye, + pitch = pitch, + yaw = yaw, + on_ground = true, + speed = 0f, + throw_strength_raw = strength, + }, + initial_position = seed.position, + initial_velocity = seed.velocity, + detonation_position = Landing, + }; + } + + [Fact] + public void APerfectSampleClearsTheLaunchModel() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + + Assert.True(PracticeCalibrationUtility.LaunchModelPassed(report)); + Assert.Single(report.launch_checks); + Assert.True(report.launch_checks[0].passed); + Assert.Equal(1f, report.CorrectionFor(nameof(eThrowStrength.Full)), 3); + } + + // Passing the launch model is not permission to solve. Only a live seed + // replay grants that, and it has not happened yet. + [Fact] + public void ClearingTheLaunchModelIsNotReady() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + + Assert.False(report.CanSolve()); + Assert.Equal(nameof(eCalibrationStatus.Unknown), report.status); + } + + [Fact] + public void RefusesWhenTheThrowDirectionIsWrong() + { + LineupRecord sample = Sample(); + LaunchSeed skewed = PracticeLaunchUtility.Seed( + Eye, + sample.release.pitch - 4f, + sample.release.yaw, + 1f, + new Vec3(0f, 0f, 0f) + ); + sample.initial_velocity = skewed.velocity; + + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { sample } + ); + + Assert.Equal(nameof(eCalibrationStatus.LaunchModelMismatch), report.status); + Assert.Contains("pitch remap", report.message); + Assert.False(report.CanSolve()); + } + + [Fact] + public void RefusesWhenTheGrenadeSpawnsSomewhereElse() + { + LineupRecord sample = Sample(); + sample.initial_position = sample.initial_position + new Vec3(0f, 0f, 20f); + + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { sample } + ); + + Assert.Equal(nameof(eCalibrationStatus.LaunchModelMismatch), report.status); + Assert.Contains("forward offset", report.message); + } + + [Fact] + public void RefusesWhenTheSpeedFormulaIsWrong() + { + LineupRecord sample = Sample(); + sample.initial_velocity = sample.initial_velocity * 3f; + + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { sample } + ); + + Assert.Equal(nameof(eCalibrationStatus.LaunchModelMismatch), report.status); + Assert.Contains("speed formula", report.message); + } + + // A constant being a few percent out is absorbed rather than refused: the + // measured ratio is carried into every throw the solver makes. + [Fact] + public void CarriesASmallSpeedErrorForwardAsACorrection() + { + LineupRecord sample = Sample(); + sample.initial_velocity = sample.initial_velocity * 1.08f; + + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { sample } + ); + + Assert.True(PracticeCalibrationUtility.LaunchModelPassed(report)); + Assert.Equal(1.08f, report.CorrectionFor(nameof(eThrowStrength.Full)), 3); + } + + // A throw made on the move is measured a tick away from where the engine + // read it, so it fails the model for a reason that is not the model. + [Fact] + public void OnlyStandingThrowsAreUsable() + { + LineupRecord running = Sample(); + running.release.speed = 220f; + + LineupRecord jumping = Sample(); + jumping.release.jump_throw = true; + + LineupRecord airborne = Sample(); + airborne.release.on_ground = false; + + Assert.False(PracticeCalibrationUtility.IsUsableSample(running)); + Assert.False(PracticeCalibrationUtility.IsUsableSample(jumping)); + Assert.False(PracticeCalibrationUtility.IsUsableSample(airborne)); + Assert.True(PracticeCalibrationUtility.IsUsableSample(Sample())); + } + + // A throw whose release edge was missed has a zeroed snapshot; comparing + // the model against it would compare it against nothing. + [Fact] + public void ASnapshotlessThrowIsNotASample() + { + LineupRecord sample = Sample(); + sample.release = new ThrowSnapshot { on_ground = true }; + + Assert.False(PracticeCalibrationUtility.IsUsableSample(sample)); + } + + [Fact] + public void NothingToCalibrateAgainstIsSaidPlainly() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new List() + ); + + Assert.Equal(nameof(eCalibrationStatus.NoSample), report.status); + Assert.Contains("throw one grenade", report.message); + Assert.False(report.CanSolve()); + } + + [Fact] + public void OnlyMeasuredStrengthsBecomeSolvable() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample(strength: 1f, id: "a"), Sample(strength: 0.5f, id: "b") } + ); + + PracticeCalibrationUtility.WithSeedReplay( + report, + Sample(), + Landing + new Vec3(2f, 0f, 0f) + ); + + Assert.Equal( + new[] { nameof(eThrowStrength.Full), nameof(eThrowStrength.Half) }, + report.SolvableStrengths() + ); + Assert.DoesNotContain(nameof(eThrowStrength.Drop), report.SolvableStrengths()); + } + + [Fact] + public void AReproducedLandingOpensTheGate() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + + PracticeCalibrationUtility.WithSeedReplay( + report, + Sample(), + Landing + new Vec3(3f, 4f, 0f) + ); + + Assert.Equal(nameof(eCalibrationStatus.Ready), report.status); + Assert.True(report.CanSolve()); + Assert.Equal(5f, report.seed_replay_error, 3); + } + + [Fact] + public void AMissedReproductionShutsIt() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + + PracticeCalibrationUtility.WithSeedReplay( + report, + Sample(), + Landing + new Vec3(0f, 400f, 0f) + ); + + Assert.Equal(nameof(eCalibrationStatus.SeedReplayMismatch), report.status); + Assert.False(report.CanSolve()); + Assert.Contains("does not reproduce a seeded throw", report.message); + } + + [Fact] + public void AGrenadeThatNeverLandedIsNotAPass() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + + PracticeCalibrationUtility.WithSeedReplay(report, Sample(), null); + + Assert.Equal(nameof(eCalibrationStatus.SeedReplayTimedOut), report.status); + Assert.False(report.CanSolve()); + } + + // The tolerance is the whole claim. A throw just inside it passes and one + // just outside does not, so a change to the constant is a change to the + // claim rather than a quiet loosening. + [Fact] + public void TheToleranceIsTheClaim() + { + CalibrationReport inside = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + PracticeCalibrationUtility.WithSeedReplay( + inside, + Sample(), + Landing + + new Vec3(PracticeCalibrationUtility.SeedReplayTolerance - 0.5f, 0f, 0f) + ); + + CalibrationReport outside = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + PracticeCalibrationUtility.WithSeedReplay( + outside, + Sample(), + Landing + + new Vec3(PracticeCalibrationUtility.SeedReplayTolerance + 0.5f, 0f, 0f) + ); + + Assert.True(inside.CanSolve()); + Assert.False(outside.CanSolve()); + } + + // A grenade that clipped three corners tests the collision mesh as much as + // the premise, so it is the last throw to reach for. + [Fact] + public void TheCleanestThrowIsReplayed() + { + LineupRecord bouncy = Sample(bounces: 5, id: "bouncy"); + LineupRecord clean = Sample(bounces: 0, id: "clean"); + + LineupRecord? picked = PracticeCalibrationUtility.PickReplaySample( + new[] { bouncy, clean } + ); + + Assert.Equal("clean", picked?.client_id); + } + + [Fact] + public void PickingAReplayNeedsAUsableThrow() + { + LineupRecord moving = Sample(); + moving.release.speed = 250f; + + Assert.Null(PracticeCalibrationUtility.PickReplaySample(new[] { moving })); + } + + [Fact] + public void SamplesAreCappedAndNewestFirst() + { + var pool = new List(); + + for (int index = 0; index < 20; index++) + { + pool.Add(Sample(id: $"throw-{index}")); + } + + List samples = PracticeCalibrationUtility.Samples(pool); + + Assert.Equal(PracticeCalibrationUtility.MaxSamples, samples.Count); + Assert.Equal("throw-19", samples[0].client_id); + } + + [Fact] + public void AnUnsupportedRuntimeIsItsOwnAnswer() + { + CalibrationReport report = PracticeCalibrationUtility.Unsupported("de_nuke", "no emit api"); + + Assert.Equal(nameof(eCalibrationStatus.Unsupported), report.status); + Assert.False(report.CanSolve()); + Assert.Equal("de_nuke", report.map); + } +} diff --git a/apps/utility-css/test/PracticeConnectUtilityTests.cs b/apps/utility-css/test/PracticeConnectUtilityTests.cs new file mode 100644 index 00000000..aa58969a --- /dev/null +++ b/apps/utility-css/test/PracticeConnectUtilityTests.cs @@ -0,0 +1,197 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +public class PracticeConnectUtilityTests +{ + private static readonly Guid MatchId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private const string Password = "practice-password"; + private const ulong Member = 76561198000000001UL; + private const ulong Stranger = 76561198000000002UL; + + private static PracticeSessionData Session() + { + return new PracticeSessionData + { + id = Guid.NewGuid(), + match_id = MatchId, + password = Password, + allowed_steam_ids = new List { Member.ToString() }, + }; + } + + private static string Token(string type, string role, ulong steamId) + { + return $"{type}:{role}:{ConnectAuth.ComputeExpectedToken(Password, type, role, steamId, MatchId)}"; + } + + // An unloaded roster must not read as "everyone is welcome". + [Fact] + public void WithoutASessionTheEnginesPasswordCheckStays() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize(null, Member, "anything"); + + Assert.Equal(ePracticeConnect.PasswordCheck, decision.action); + Assert.Null(decision.pending_role); + } + + [Fact] + public void AConnectWithNoTokenIsRejected() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize(Session(), Member, null); + + Assert.Equal(ePracticeConnect.Reject, decision.action); + } + + [Fact] + public void TheSessionPasswordItselfAuthorizes() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + Password + ); + + Assert.Equal(ePracticeConnect.Authorized, decision.action); + } + + // The roster is checked before the token, so a player the panel invited + // gets in whatever their client sent. + [Fact] + public void ARosterMemberIsAuthorizedWithoutAValidToken() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Member, + "garbage" + ); + + Assert.Equal(ePracticeConnect.Authorized, decision.action); + } + + [Fact] + public void RosterMatchingIgnoresSurroundingWhitespace() + { + var session = Session(); + session.allowed_steam_ids = new List { $" {Stranger} " }; + + Assert.True(PracticeConnectUtility.IsOnRoster(session, Stranger)); + Assert.False(PracticeConnectUtility.IsOnRoster(session, Member)); + } + + [Fact] + public void ATokenThatIsNotThreePartsIsRejected() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + "game:administrator" + ); + + Assert.Equal(ePracticeConnect.Reject, decision.action); + } + + [Theory] + [InlineData("administrator", "admin")] + [InlineData("streamer", "streamer")] + [InlineData("match_organizer", "organizer")] + [InlineData("tournament_organizer", "organizer")] + public void APrivilegedGameTokenCarriesItsRole(string role, string expected) + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + Token("game", role, Stranger) + ); + + Assert.Equal(ePracticeConnect.Authorized, decision.action); + Assert.Equal(expected, decision.pending_role); + } + + [Fact] + public void AnOrdinaryGameTokenAuthorizesWithNoRole() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + Token("game", "verified_user", Stranger) + ); + + Assert.Equal(ePracticeConnect.Authorized, decision.action); + Assert.Null(decision.pending_role); + } + + // Only "game" tokens hand out roles: a tv connection is still just a + // spectator. + [Fact] + public void ATvTokenNeverCarriesARole() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + Token("tv", "administrator", Stranger) + ); + + Assert.Equal(ePracticeConnect.Authorized, decision.action); + Assert.Null(decision.pending_role); + } + + [Fact] + public void TheUrlSafeAlphabetIsAccepted() + { + string token = Token("game", "administrator", Stranger); + string urlSafe = token.Replace("+", "-").Replace("/", "_"); + + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + urlSafe + ); + + Assert.Equal(ePracticeConnect.Authorized, decision.action); + } + + // A token signed for somebody else is not proof of anything, but neither is + // it grounds to refuse: the password may still be right. + [Fact] + public void ATokenSignedForAnotherPlayerFallsBackToThePasswordCheck() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + Token("game", "administrator", Member) + ); + + Assert.Equal(ePracticeConnect.PasswordCheck, decision.action); + } + + // A bad tv token is different: nothing but the token can authorise a tv + // connection, so the auth ticket is stripped instead. + [Fact] + public void ABadTvTokenIsRejected() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + Token("tv", "streamer", Member) + ); + + Assert.Equal(ePracticeConnect.Reject, decision.action); + } + + [Fact] + public void ATokenSignedWithAnotherSessionsPasswordDoesNotAuthorize() + { + var session = Session(); + session.password = "a-different-password"; + + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + session, + Stranger, + Token("game", "administrator", Stranger) + ); + + Assert.Equal(ePracticeConnect.PasswordCheck, decision.action); + } +} diff --git a/apps/utility-css/test/PracticeDrillRunTests.cs b/apps/utility-css/test/PracticeDrillRunTests.cs new file mode 100644 index 00000000..b7d559f2 --- /dev/null +++ b/apps/utility-css/test/PracticeDrillRunTests.cs @@ -0,0 +1,560 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +// A run advances on a scored throw and on nothing else. The failures that +// matter are the silent ones: a step that resolves on the throw itself, so a +// miss is skipped past before it is read, and a step that never resolves at +// all because the panel stopped answering. +public class PracticeDrillRunTests +{ + private static readonly DateTime Now = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + private static LineupRecord Lineup(string id, string utility = "Smoke") + { + return new LineupRecord + { + id = id, + client_id = id, + name = id, + utility_type = utility, + release = new ThrowSnapshot { feet_position = new Vec3(10f, 20f, 30f) }, + detonation_position = new Vec3(900f, 900f, 30f), + }; + } + + private static PracticeDrillRun Run(params string[] ids) + { + return new PracticeDrillRun(ids.Select(id => Lineup(id)).ToList()); + } + + private static UtilityPracticeResult Result(bool success) + { + return new UtilityPracticeResult + { + success = success, + distance = success ? 20f : 300f, + radius = 80f, + attempts = 1, + successes = success ? 1 : 0, + current_streak = success ? 1 : 0, + best_streak = success ? 1 : 0, + }; + } + + // Move on, throw, score -- the whole loop, once, in the order the runner + // drives it. + private static void Throws(PracticeDrillRun run, bool hit) + { + LineupRecord? lineup = run.Next(); + + Assert.NotNull(lineup); + Assert.True(run.Thrown(lineup!.utility_type, Now)); + Assert.True(run.Score(lineup.id, Result(hit))); + } + + [Fact] + public void ARunHandsOutItsQueueInOrder() + { + PracticeDrillRun run = Run("a", "b", "c"); + + Assert.Equal("a", run.Next()!.id); + Assert.Equal("b", run.Next()!.id); + Assert.Equal("c", run.Next()!.id); + Assert.Null(run.Next()); + Assert.Equal(eDrillEnd.Completed, run.Ending); + } + + [Fact] + public void PositionReadsAsAPlaceInTheRun() + { + PracticeDrillRun run = Run("a", "b"); + + run.Next(); + + Assert.Equal(1, run.Position); + Assert.Equal(2, run.Length); + } + + [Fact] + public void AFinishedRunHandsOutNothingMore() + { + PracticeDrillRun run = Run("a", "b"); + + run.Next(); + run.End(eDrillEnd.Stopped); + + Assert.Null(run.Next()); + Assert.Equal(eDrillEnd.Stopped, run.Ending); + } + + [Fact] + public void StoppingTwiceKeepsTheFirstReason() + { + PracticeDrillRun run = Run("a"); + + run.End(eDrillEnd.Stopped); + run.End(eDrillEnd.Completed); + + Assert.Equal(eDrillEnd.Stopped, run.Ending); + } + + // Throwing a flash while a smoke is loaded is a different throw, not a + // missed one. + [Fact] + public void AThrowOfTheWrongUtilityIsNotAnAttempt() + { + PracticeDrillRun run = Run("a"); + run.Next(); + + Assert.False(run.Thrown("Flash", Now)); + Assert.False(run.Waiting); + } + + [Fact] + public void AThrowOfTheRightUtilityIsWaitedOn() + { + PracticeDrillRun run = Run("a"); + run.Next(); + + Assert.True(run.Thrown("Smoke", Now)); + Assert.True(run.Waiting); + } + + [Fact] + public void ASecondThrowOfTheSameStepIsIgnored() + { + PracticeDrillRun run = Run("a"); + run.Next(); + run.Thrown("Smoke", Now); + + Assert.False(run.Thrown("Smoke", Now.AddSeconds(1))); + } + + [Fact] + public void AScoreForSomethingElseIsNotThisStep() + { + PracticeDrillRun run = Run("a"); + run.Next(); + run.Thrown("Smoke", Now); + + Assert.False(run.Score("another-lineup", Result(true))); + Assert.True(run.Waiting); + } + + [Fact] + public void AScoreWithNothingInFlightIsIgnored() + { + PracticeDrillRun run = Run("a"); + run.Next(); + + Assert.False(run.Score("a", Result(true))); + Assert.Equal(0, run.Attempts); + } + + [Fact] + public void AHitCountsAndBuildsTheStreak() + { + PracticeDrillRun run = Run("a", "b"); + + Throws(run, hit: true); + Throws(run, hit: true); + + Assert.Equal(2, run.Hits); + Assert.Equal(0, run.Misses); + Assert.Equal(2, run.Streak); + Assert.Equal(2, run.BestStreak); + } + + [Fact] + public void AMissBreaksTheStreakButKeepsTheBest() + { + PracticeDrillRun run = Run("a", "b", "c"); + + Throws(run, hit: true); + Throws(run, hit: true); + Throws(run, hit: false); + + Assert.Equal(2, run.Hits); + Assert.Equal(1, run.Misses); + Assert.Equal(0, run.Streak); + Assert.Equal(2, run.BestStreak); + } + + // Nobody knows whether it landed, so it is not a miss and it does not + // break a streak. + [Fact] + public void AThrowThePanelDidNotAnswerIsNotAMiss() + { + PracticeDrillRun run = Run("a", "b"); + + Throws(run, hit: true); + run.Next(); + + run.Thrown("Smoke", Now); + Assert.True(run.Score("b", null)); + + Assert.Equal(1, run.Unscored); + Assert.Equal(0, run.Misses); + Assert.Equal(1, run.Streak); + } + + [Fact] + public void ARunGivesUpOnAPanelThatKeepsNotAnswering() + { + PracticeDrillRun run = Run("a", "b", "c", "d"); + + for (int step = 0; step < DrillUtility.MaxUnscoredInARow; step++) + { + run.Next(); + run.Thrown("Smoke", Now); + run.Score(run.Current!.id, null); + } + + Assert.Equal(eDrillEnd.Unscorable, run.Ending); + } + + [Fact] + public void AnAnsweredThrowForgivesTheOnesBefore() + { + PracticeDrillRun run = Run("a", "b", "c", "d", "e"); + + run.Next(); + run.Thrown("Smoke", Now); + run.Score("a", null); + + Throws(run, hit: true); + + run.Next(); + run.Thrown("Smoke", Now); + run.Score("c", null); + run.Next(); + run.Thrown("Smoke", Now); + run.Score("d", null); + + Assert.Equal(eDrillEnd.Running, run.Ending); + } + + [Fact] + public void AThrowIsWaitedOnUntilItsDeadline() + { + PracticeDrillRun run = Run("a"); + run.Next(); + run.Thrown("Smoke", Now); + + Assert.False(run.Expired(Now.AddSeconds(DrillUtility.ScoreWaitSeconds - 1))); + Assert.True(run.Expired(Now.AddSeconds(DrillUtility.ScoreWaitSeconds))); + Assert.Equal(1, run.Unscored); + } + + [Fact] + public void NothingExpiresWhenNothingIsInFlight() + { + PracticeDrillRun run = Run("a"); + run.Next(); + + Assert.False(run.Expired(Now.AddHours(1))); + } + + [Fact] + public void AnExpiredThrowIsOnlyWrittenOffOnce() + { + PracticeDrillRun run = Run("a"); + run.Next(); + run.Thrown("Smoke", Now); + + Assert.True(run.Expired(Now.AddMinutes(5))); + Assert.False(run.Expired(Now.AddMinutes(6))); + Assert.Equal(1, run.Unscored); + } + + // The answer arrived after the run stopped waiting for it; the step it + // belonged to is gone. + [Fact] + public void AScoreThatArrivesAfterTheDeadlineIsIgnored() + { + PracticeDrillRun run = Run("a", "b"); + run.Next(); + run.Thrown("Smoke", Now); + run.Expired(Now.AddMinutes(1)); + run.Next(); + + Assert.False(run.Score("a", Result(true))); + Assert.Equal(0, run.Hits); + } + + [Fact] + public void MovingOnDropsAThrowNobodyAnsweredFor() + { + PracticeDrillRun run = Run("a", "b"); + run.Next(); + run.Thrown("Smoke", Now); + run.Next(); + + Assert.False(run.Waiting); + } + + [Fact] + public void ALineupThatCannotBeStoodOnIsDropped() + { + PracticeDrillRun run = Run("a", "b"); + + run.Next(); + run.CannotLoad(); + + Assert.Equal(1, run.Dropped); + Assert.Equal(eDrillEnd.Running, run.Ending); + } + + [Fact] + public void ARunOfLineupsThatCannotBeStoodOnEndsTheRun() + { + PracticeDrillRun run = Run("a", "b", "c", "d"); + + for (int step = 0; step < DrillUtility.MaxUnloadableInARow; step++) + { + run.Next(); + run.CannotLoad(); + } + + Assert.Equal(eDrillEnd.Unloadable, run.Ending); + } + + [Fact] + public void OneLineupThatLoadsForgivesTheOnesBefore() + { + PracticeDrillRun run = Run("a", "b", "c", "d", "e"); + + run.Next(); + run.CannotLoad(); + run.Next(); + run.CannotLoad(); + run.Next(); + run.Loaded(); + run.Next(); + run.CannotLoad(); + run.Next(); + run.CannotLoad(); + + Assert.Equal(eDrillEnd.Running, run.Ending); + Assert.Equal(4, run.Dropped); + } + + [Fact] + public void ASkippedLineupIsNeitherAHitNorAMiss() + { + PracticeDrillRun run = Run("a", "b"); + + Throws(run, hit: true); + run.Next(); + + Assert.True(run.Skip()); + Assert.Equal(1, run.Skipped); + Assert.Equal(1, run.Hits); + Assert.Equal(0, run.Misses); + Assert.Equal(0, run.Streak); + } + + [Fact] + public void SkippingDropsTheThrowInFlightWithIt() + { + PracticeDrillRun run = Run("a", "b"); + run.Next(); + run.Thrown("Smoke", Now); + run.Skip(); + + Assert.False(run.Waiting); + Assert.False(run.Score("a", Result(true))); + } + + [Fact] + public void ThereIsNothingToSkipBeforeARunStarts() + { + Assert.False(Run("a").Skip()); + } + + [Fact] + public void ASummaryIsHitsOutOfAttemptsAndTheBestStreak() + { + PracticeDrillRun run = Run("a", "b", "c"); + + Throws(run, hit: true); + Throws(run, hit: true); + Throws(run, hit: false); + run.Next(); + + List summary = run.Summary(); + + Assert.Contains("2/3 hit", summary[0]); + Assert.Contains("best streak 2", summary[0]); + Assert.Contains("over", summary[0]); + } + + // The run is supposed to point at what to practise next. + [Fact] + public void ASummaryNamesWhatWasMissedMostOften() + { + PracticeDrillRun run = Run("xbox", "window", "xbox"); + + Throws(run, hit: false); + Throws(run, hit: true); + Throws(run, hit: false); + + run.Next(); + + string missed = run.Summary().Single(line => line.StartsWith("missed: ")); + + Assert.Equal("missed: xbox (2)", missed); + } + + [Fact] + public void ASummaryNamesWhatWasSkipped() + { + PracticeDrillRun run = Run("a", "b"); + + run.Next(); + run.Skip(); + run.Next(); + run.Skip(); + run.Next(); + + Assert.Contains(run.Summary(), line => line == "skipped: a, b"); + } + + [Fact] + public void ASummarySaysHowManyThrowsWereNeverScored() + { + PracticeDrillRun run = Run("a", "b"); + + run.Next(); + run.Thrown("Smoke", Now); + run.Score("a", null); + run.Next(); + + Assert.Contains(run.Summary(), line => line.Contains("1 throw could not be scored")); + } + + [Fact] + public void ASummarySaysWhatCouldNotBeLoaded() + { + PracticeDrillRun run = Run("a", "b"); + + run.Next(); + run.CannotLoad(); + run.Next(); + run.CannotLoad(); + run.Next(); + + Assert.Contains(run.Summary(), line => line == "2 could not be loaded"); + } + + [Fact] + public void ASummarySaysWhyARunStoppedEarly() + { + PracticeDrillRun stopped = Run("a", "b"); + stopped.Next(); + stopped.End(eDrillEnd.Stopped); + + PracticeDrillRun unscorable = Run("a", "b"); + unscorable.Next(); + unscorable.End(eDrillEnd.Unscorable); + + PracticeDrillRun unloadable = Run("a", "b"); + unloadable.Next(); + unloadable.End(eDrillEnd.Unloadable); + + Assert.Contains("stopped", stopped.Summary()[0]); + Assert.Contains("the panel is not scoring throws right now", unscorable.Summary()[0]); + Assert.Contains("could not be loaded", unloadable.Summary()[0]); + } + + [Fact] + public void ARunThatWasNeverThrownSummarisesAsNothing() + { + PracticeDrillRun run = Run("a"); + run.Next(); + run.End(eDrillEnd.Stopped); + + List summary = run.Summary(); + + Assert.Single(summary); + Assert.Contains("0/0 hit", summary[0]); + } + + [Fact] + public void TwoRunsKeepTheirOwnCounts() + { + PracticeDrillRun mine = Run("a", "b"); + PracticeDrillRun theirs = Run("a", "b"); + + Throws(mine, hit: true); + Throws(theirs, hit: false); + + Assert.Equal(1, mine.Hits); + Assert.Equal(0, mine.Misses); + Assert.Equal(0, theirs.Hits); + Assert.Equal(1, theirs.Misses); + } +} + +public class PracticeDrillRunRepTests +{ + private static LineupRecord Lineup(string id) + { + return new LineupRecord { id = id, client_id = id, utility_type = "Smoke" }; + } + + [Fact] + public void EachLineupIsRepeatedBeforeTheNext() + { + var run = new PracticeDrillRun(new[] { Lineup("a"), Lineup("b") }, 3); + + // Consecutive, not interleaved: you throw the same lineup until it is + // learned rather than being sent round the map three times. + Assert.Equal("a", run.Next()?.id); + Assert.Equal("a", run.Next()?.id); + Assert.Equal("a", run.Next()?.id); + Assert.Equal("b", run.Next()?.id); + } + + [Fact] + public void RepAndPositionReadAsProgress() + { + var run = new PracticeDrillRun(new[] { Lineup("a"), Lineup("b") }, 3); + + run.Next(); + Assert.Equal(1, run.Position); + Assert.Equal(1, run.Rep); + + run.Next(); + Assert.Equal(1, run.Position); + Assert.Equal(2, run.Rep); + + run.Next(); + run.Next(); + Assert.Equal(2, run.Position); + Assert.Equal(1, run.Rep); + } + + [Fact] + public void TheRunEndsAfterEveryRepOfEveryLineup() + { + var run = new PracticeDrillRun(new[] { Lineup("a") }, 2); + + Assert.NotNull(run.Next()); + Assert.NotNull(run.Next()); + Assert.Null(run.Next()); + Assert.True(run.Finished); + } + + [Fact] + public void OneRepIsTheOldBehaviour() + { + var run = new PracticeDrillRun(new[] { Lineup("a"), Lineup("b") }); + + Assert.Equal("a", run.Next()?.id); + Assert.Equal("b", run.Next()?.id); + Assert.Null(run.Next()); + } +} diff --git a/apps/utility-css/test/PracticeJsonTests.cs b/apps/utility-css/test/PracticeJsonTests.cs new file mode 100644 index 00000000..eaa05d55 --- /dev/null +++ b/apps/utility-css/test/PracticeJsonTests.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +public class PracticeJsonTests +{ + [Fact] + public void ASessionReadsBackFromThePanelsSpelling() + { + const string json = + "{\"id\":\"11111111-1111-1111-1111-111111111111\",\"match_id\":\"22222222-2222-2222-2222-222222222222\",\"password\":\"pw\",\"map\":\"de_mirage\",\"allowed_steam_ids\":[\"1\",\"2\"]}"; + + PracticeSessionData? session = JsonSerializer.Deserialize( + json, + PracticeJson.Options + ); + + Assert.NotNull(session); + Assert.Equal("pw", session!.password); + Assert.Equal("de_mirage", session.map); + Assert.Equal(2, session.allowed_steam_ids.Count); + Assert.Equal(Guid.Parse("22222222-2222-2222-2222-222222222222"), session.match_id); + } + + [Fact] + public void APathPointReadsBackFieldByField() + { + const string json = "[{\"tick\":7,\"x\":1.5,\"y\":-2.5,\"z\":3}]"; + + List? path = JsonSerializer.Deserialize>( + json, + PracticeJson.Options + ); + + UtilityPathPoint point = Assert.Single(path!); + Assert.Equal(7, point.tick); + Assert.Equal(1.5f, point.x); + Assert.Equal(-2.5f, point.y); + Assert.Equal(3f, point.z); + } + + // The panel decides which fields a row carries, so a partial row must read + // rather than throw. + [Fact] + public void AMissingFieldReadsAsAbsentRatherThanFailing() + { + const string json = "{\"id\":\"x\",\"name\":\"only a name\"}"; + + UtilityLibraryRow? row = JsonSerializer.Deserialize( + json, + PracticeJson.Options + ); + + Assert.NotNull(row); + Assert.Null(row!.origin_x); + + LineupRecord lineup = row.ToLineup(); + Assert.Equal(0f, lineup.release.feet_position.x); + Assert.Equal(0f, lineup.flight_time); + } +} diff --git a/apps/utility-css/test/PracticeLaunchUtilityTests.cs b/apps/utility-css/test/PracticeLaunchUtilityTests.cs new file mode 100644 index 00000000..95736933 --- /dev/null +++ b/apps/utility-css/test/PracticeLaunchUtilityTests.cs @@ -0,0 +1,196 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +// The launch model is the only physics the solver contains, and none of it can +// be verified from here -- these tests pin the shape of the function so a +// change to it is deliberate. Whether the constants match CS2 is a question +// only a live server answers, which is what calibration is for. +public class PracticeLaunchUtilityTests +{ + private static readonly Vec3 Eye = new Vec3(100f, 200f, 64f); + + [Fact] + public void BendsTheAimDownAtTheHorizon() + { + Assert.Equal(-10f, PracticeLaunchUtility.RemapPitch(0f), 3); + } + + [Fact] + public void RemapRoundTrips() + { + foreach (float pitch in new[] { -80f, -45f, -10f, 0f, 12f, 60f }) + { + float back = PracticeLaunchUtility.UnremapPitch( + PracticeLaunchUtility.RemapPitch(pitch) + ); + + Assert.Equal(pitch, back, 3); + } + } + + [Fact] + public void NormalizesAnUnwrappedPitch() + { + Assert.Equal(-30f, PracticeLaunchUtility.NormalizePitch(330f), 3); + Assert.Equal(45f, PracticeLaunchUtility.NormalizePitch(405f), 3); + } + + [Fact] + public void ForwardFollowsTheSourceConvention() + { + Vec3 level = PracticeLaunchUtility.Forward(0f, 0f); + Assert.Equal(1f, level.x, 4); + Assert.Equal(0f, level.y, 4); + Assert.Equal(0f, level.z, 4); + + // Positive pitch is looking down. + Assert.True(PracticeLaunchUtility.Forward(45f, 0f).z < 0f); + Assert.True(PracticeLaunchUtility.Forward(-45f, 0f).z > 0f); + + Vec3 quarter = PracticeLaunchUtility.Forward(0f, 90f); + Assert.Equal(0f, quarter.x, 4); + Assert.Equal(1f, quarter.y, 4); + } + + // The grenade does not leave along the crosshair, and a solver that assumed + // it did would report an aim a degree or two under every throw it found. + [Fact] + public void ThrowDirectionIsNotTheCrosshair() + { + Vec3 crosshair = PracticeLaunchUtility.Forward(0f, 0f); + Vec3 thrown = PracticeLaunchUtility.ThrowDirection(0f, 0f); + + Assert.True(PracticeLaunchUtility.AngleBetween(crosshair, thrown) > 9f); + Assert.True(thrown.z > 0f); + } + + [Fact] + public void SpeedSaturates() + { + Assert.Equal( + PracticeLaunchUtility.MaxSpeed, + PracticeLaunchUtility.BaseSpeed(-60f), + 3 + ); + Assert.True(PracticeLaunchUtility.BaseSpeed(0f) < PracticeLaunchUtility.MaxSpeed); + Assert.True(PracticeLaunchUtility.BaseSpeed(45f) < PracticeLaunchUtility.BaseSpeed(0f)); + } + + [Fact] + public void StrengthScaleIsMonotoneAndFullIsUnscaled() + { + Assert.Equal(1f, PracticeLaunchUtility.StrengthScale(1f), 4); + Assert.Equal(PracticeLaunchUtility.MinStrengthScale, PracticeLaunchUtility.StrengthScale(0f), 4); + Assert.True( + PracticeLaunchUtility.StrengthScale(0.5f) > PracticeLaunchUtility.StrengthScale(0f) + ); + Assert.True( + PracticeLaunchUtility.StrengthScale(0.5f) < PracticeLaunchUtility.StrengthScale(1f) + ); + } + + [Fact] + public void MapsTheThreeReleasesAPlayerCanMake() + { + Assert.Equal(1f, PracticeLaunchUtility.RawStrength(eThrowStrength.Full)); + Assert.Equal(0.5f, PracticeLaunchUtility.RawStrength(eThrowStrength.Half)); + Assert.Equal(0f, PracticeLaunchUtility.RawStrength(eThrowStrength.Drop)); + } + + [Fact] + public void SeedSpawnsAheadOfTheEyeAlongTheThrow() + { + LaunchSeed seed = PracticeLaunchUtility.Seed( + Eye, + -12f, + 35f, + 1f, + new Vec3(0f, 0f, 0f) + ); + + Assert.Equal( + PracticeLaunchUtility.ForwardOffset, + (seed.position - Eye).Length(), + 2 + ); + Assert.Equal( + 0f, + PracticeLaunchUtility.AngleBetween(seed.position - Eye, seed.velocity), + 2 + ); + Assert.Equal(seed.speed, seed.velocity.Length(), 2); + } + + [Fact] + public void SeedCarriesTheThrowersOwnVelocity() + { + var running = new Vec3(0f, 250f, 0f); + + LaunchSeed still = PracticeLaunchUtility.Seed(Eye, 0f, 0f, 1f, new Vec3(0f, 0f, 0f)); + LaunchSeed moving = PracticeLaunchUtility.Seed(Eye, 0f, 0f, 1f, running); + + Assert.Equal( + running.y * PracticeLaunchUtility.PlayerVelocityScale, + moving.velocity.y - still.velocity.y, + 2 + ); + } + + [Fact] + public void SpeedCorrectionScalesTheRelease() + { + LaunchSeed plain = PracticeLaunchUtility.Seed(Eye, -10f, 0f, 1f, new Vec3(0f, 0f, 0f)); + LaunchSeed corrected = PracticeLaunchUtility.Seed( + Eye, + -10f, + 0f, + 1f, + new Vec3(0f, 0f, 0f), + 1.25f + ); + + Assert.Equal(plain.speed * 1.25f, corrected.speed, 2); + } + + [Fact] + public void BearingPointsAtTheTarget() + { + Assert.Equal( + 90f, + PracticeLaunchUtility.BearingTo(new Vec3(0f, 0f, 0f), new Vec3(0f, 500f, 0f)), + 3 + ); + Assert.Equal( + 0f, + PracticeLaunchUtility.BearingTo(new Vec3(0f, 0f, 0f), new Vec3(500f, 0f, 200f)), + 3 + ); + } + + [Fact] + public void PredictReadsTheReleaseSnapshot() + { + var release = new ThrowSnapshot + { + eye_position = Eye, + pitch = -20f, + yaw = 15f, + throw_strength_raw = 0.5f, + velocity = new Vec3(0f, 0f, 0f), + }; + + LaunchSeed predicted = PracticeLaunchUtility.Predict(release); + LaunchSeed direct = PracticeLaunchUtility.Seed( + Eye, + -20f, + 15f, + 0.5f, + new Vec3(0f, 0f, 0f) + ); + + Assert.Equal(direct.speed, predicted.speed, 3); + Assert.Equal(direct.position.x, predicted.position.x, 3); + } +} diff --git a/apps/utility-css/test/PracticeLineupUtilityTests.cs b/apps/utility-css/test/PracticeLineupUtilityTests.cs new file mode 100644 index 00000000..fab368ee --- /dev/null +++ b/apps/utility-css/test/PracticeLineupUtilityTests.cs @@ -0,0 +1,428 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +public class PracticeLineupUtilityTests +{ + private static LineupRecord Lineup(string name, float x = 0f, float y = 0f) + { + return new LineupRecord + { + name = name, + release = new ThrowSnapshot { feet_position = new Vec3(x, y, 0f) }, + }; + } + + [Fact] + public void MapsProjectilesToUtilityTypes() + { + Assert.Equal("Smoke", PracticeLineupUtility.UtilityTypeForProjectile("smokegrenade_projectile")); + Assert.Equal("Flash", PracticeLineupUtility.UtilityTypeForProjectile("flashbang_projectile")); + Assert.Equal("HighExplosive", PracticeLineupUtility.UtilityTypeForProjectile("hegrenade_projectile")); + Assert.Equal("Decoy", PracticeLineupUtility.UtilityTypeForProjectile("decoy_projectile")); + Assert.Null(PracticeLineupUtility.UtilityTypeForProjectile("weapon_ak47")); + } + + // Both entity names produce a Molotov: incendiary and molotov differ to the + // engine but are one lineup type to a player. + [Fact] + public void TreatsIncendiaryAndMolotovAsOneType() + { + Assert.Equal("Molotov", PracticeLineupUtility.UtilityTypeForProjectile("molotov_projectile")); + Assert.Equal("Molotov", PracticeLineupUtility.UtilityTypeForProjectile("incendiarygrenade_projectile")); + } + + [Fact] + public void MapsUtilityTypesBackToWeapons() + { + Assert.Equal("weapon_smokegrenade", PracticeLineupUtility.WeaponForUtilityType("Smoke")); + Assert.Null(PracticeLineupUtility.WeaponForUtilityType("NotAThing")); + } + + [Fact] + public void RecognisesGrenadesInHand() + { + Assert.True(PracticeLineupUtility.IsGrenadeWeapon("weapon_smokegrenade")); + Assert.True(PracticeLineupUtility.IsGrenadeWeapon("weapon_incgrenade")); + Assert.False(PracticeLineupUtility.IsGrenadeWeapon("weapon_ak47")); + } + + // Typing a full name must win outright, even when another lineup is closer. + [Fact] + public void ExactNameBeatsProximity() + { + var lineups = new[] { Lineup("Window", 1000f), Lineup("Window Long", 0f) }; + var resolved = PracticeLineupUtility.Resolve(lineups, "Window", new Vec3(0f, 0f, 0f)); + Assert.Equal("Window", resolved?.name); + } + + [Fact] + public void UniquePrefixResolves() + { + var lineups = new[] { Lineup("Window"), Lineup("Jungle") }; + Assert.Equal("Jungle", PracticeLineupUtility.Resolve(lineups, "Jun")?.name); + } + + [Fact] + public void AmbiguousPrefixFallsBackToNearest() + { + var lineups = new[] { Lineup("Window A", 900f), Lineup("Window B", 10f) }; + var resolved = PracticeLineupUtility.Resolve(lineups, "Window", new Vec3(0f, 0f, 0f)); + Assert.Equal("Window B", resolved?.name); + } + + [Fact] + public void EmptyQueryPicksTheNearest() + { + var lineups = new[] { Lineup("Far", 900f), Lineup("Near", 5f) }; + var resolved = PracticeLineupUtility.Resolve(lineups, "", new Vec3(0f, 0f, 0f)); + Assert.Equal("Near", resolved?.name); + } + + [Fact] + public void NoMatchResolvesToNothing() + { + var lineups = new[] { Lineup("Window") }; + Assert.Null(PracticeLineupUtility.Resolve(lineups, "Ramp")); + Assert.Null(PracticeLineupUtility.Resolve(Array.Empty(), "Window")); + } + + [Fact] + public void FilterWithoutAQueryKeepsEverything() + { + var lineups = new[] { Lineup("Window"), Lineup("Jungle") }; + Assert.Equal(2, PracticeLineupUtility.Filter(lineups, "").Count); + } + + [Fact] + public void FilterMatchesAnywhereInTheNameAndIgnoresCase() + { + var lineups = new[] { Lineup("Window Long"), Lineup("Deep Jungle"), Lineup("Ramp") }; + var matches = PracticeLineupUtility.Filter(lineups, "un"); + + Assert.Single(matches); + Assert.Equal("Deep Jungle", matches[0].name); + } + + // .next and .prev walk this list, so the order is the one the player would + // expect: whatever is closest first. + [Fact] + public void FilterOrdersByDistanceWhenGivenAPosition() + { + var lineups = new[] { Lineup("Window Far", 900f), Lineup("Window Near", 5f) }; + var matches = PracticeLineupUtility.Filter(lineups, "Window", new Vec3(0f, 0f, 0f)); + + Assert.Equal("Window Near", matches[0].name); + Assert.Equal("Window Far", matches[1].name); + } + + [Fact] + public void FilterReturnsNothingWhenTheQueryMatchesNothing() + { + var lineups = new[] { Lineup("Window") }; + Assert.Empty(PracticeLineupUtility.Filter(lineups, "Ramp")); + } + + [Fact] + public void NormalizingIsCaseInsensitive() + { + Assert.Equal("HighExplosive", PracticeLineupUtility.NormalizeUtilityType("he")); + Assert.Equal("Flash", PracticeLineupUtility.NormalizeUtilityType("FLASHBANG")); + } + + // An unknown value is passed through rather than guessed at: the API will + // reject it loudly, which beats storing it as the wrong type. + [Fact] + public void NormalizingLeavesAnUnknownTypeAlone() + { + Assert.Equal("Banana", PracticeLineupUtility.NormalizeUtilityType("Banana")); + } +} + +public class UtilityBySpotTests +{ + private static List<(float, float, float, List)> Group( + params (float x, float y, float z, string type)[] throws + ) + { + return PracticeLineupUtility.UtilityBySpot(throws, 40f, 72f); + } + + [Fact] + public void TwoSmokesFromOneSpotAreOneSmoke() + { + var spots = Group((100f, 100f, 0f, "Smoke"), (108f, 96f, 0f, "Smoke")); + + Assert.Single(spots); + Assert.Equal(new[] { "Smoke" }, spots[0].Item4); + } + + [Fact] + public void ASpotWithTwoKindsShowsBoth() + { + var spots = Group((100f, 100f, 0f, "Smoke"), (110f, 100f, 0f, "Flash")); + + Assert.Single(spots); + Assert.Equal(new[] { "Smoke", "Flash" }, spots[0].Item4); + } + + [Fact] + public void SpotsFurtherApartThanTheRadiusStaySeparate() + { + var spots = Group((100f, 100f, 0f, "Smoke"), (200f, 100f, 0f, "Smoke")); + + Assert.Equal(2, spots.Count); + } + + [Fact] + public void TheSamePositionOnAnotherFloorIsAnotherSpot() + { + var spots = Group((100f, 100f, 0f, "Smoke"), (100f, 100f, 128f, "Smoke")); + + Assert.Equal(2, spots.Count); + } + + [Fact] + public void GroupingIsByDistanceNotByAGrid() + { + // Two throws either side of a grid line are one spot; a naive round() + // would split them and draw the model twice. + var spots = Group((39f, 0f, 0f, "Smoke"), (41f, 0f, 0f, "Smoke")); + + Assert.Single(spots); + } + + [Fact] + public void NothingInNothingOut() + { + Assert.Empty(PracticeLineupUtility.UtilityBySpot([], 40f, 72f)); + } +} + +public class AimMissTests +{ + [Fact] + public void InsideToleranceIsFullyOn() + { + Assert.Equal(0f, PracticeLineupUtility.AimMiss(0.2f, 0.35f)); + Assert.Equal(0f, PracticeLineupUtility.AimMiss(0.35f, 0.35f)); + } + + [Fact] + public void JustOutsideToleranceIsNotYetRed() + { + float miss = PracticeLineupUtility.AimMiss(0.4f, 0.35f); + + Assert.True(miss > 0f); + Assert.True(miss < 0.1f); + } + + [Fact] + public void FarOffIsFullyRed() + { + Assert.Equal(1f, PracticeLineupUtility.AimMiss(90f, 0.35f)); + } + + [Fact] + public void AWiderToleranceStaysGreenLonger() + { + Assert.Equal(0f, PracticeLineupUtility.AimMiss(1.5f, 2f)); + Assert.True(PracticeLineupUtility.AimMiss(1.5f, 0.35f) > 0f); + } + + [Fact] + public void ALineupThatNeverSaidFallsBackToTheDefault() + { + Assert.Equal( + PracticeLineupUtility.AimMiss(0.5f, PracticeLineupUtility.DefaultAimTolerance), + PracticeLineupUtility.AimMiss(0.5f, 0f) + ); + } + + [Fact] + public void ErrorIsTheWorseOfTheTwoAxes() + { + Assert.Equal(3f, PracticeLineupUtility.AimError(0f, 3f, 0f, 0f)); + Assert.Equal(3f, PracticeLineupUtility.AimError(3f, 0f, 0f, 0f)); + } + + [Fact] + public void ErrorTakesTheShortWayRoundTheCircle() + { + // 359 and 1 are two degrees apart, not 358. + Assert.Equal(2f, PracticeLineupUtility.AimError(359f, 0f, 1f, 0f)); + } + + [Fact] + public void MissNeverLeavesTheZeroToOneRange() + { + foreach (float error in new[] { 0f, 0.01f, 1f, 5f, 50f, 179f }) + { + float miss = PracticeLineupUtility.AimMiss(error, 0.35f); + + Assert.InRange(miss, 0f, 1f); + } + } +} + +public class StanceMissTests +{ + [Fact] + public void StandingOnTheSpotIsFullyOn() + { + Assert.Equal(0f, PracticeLineupUtility.StanceMiss(0f)); + Assert.Equal(0f, PracticeLineupUtility.StanceMiss(8f)); + } + + [Fact] + public void DriftingOffRampsUp() + { + float near = PracticeLineupUtility.StanceMiss(12f); + float far = PracticeLineupUtility.StanceMiss(30f); + + Assert.True(near > 0f); + Assert.True(far > near); + Assert.True(far < 1f); + } + + [Fact] + public void WellOffTheSpotIsFullyRed() + { + Assert.Equal(1f, PracticeLineupUtility.StanceMiss(48f)); + Assert.Equal(1f, PracticeLineupUtility.StanceMiss(500f)); + } + + [Fact] + public void StanceToleranceIsTighterThanTheSpotItself() + { + // SpotRadius asks "is this the same place"; this asks "are you on it". + Assert.True(PracticeLineupUtility.StanceToleranceUnits < 40f); + } +} + +public class MissBucketTests +{ + [Fact] + public void GreenIsReservedForInsideTolerance() + { + Assert.Equal(0, PracticeLineupUtility.MissBucket(0f, 5)); + + // The smallest possible miss is already NOT green -- this is the whole + // point: the colour and LINED UP must never disagree. + Assert.NotEqual(0, PracticeLineupUtility.MissBucket(0.001f, 5)); + } + + [Fact] + public void OutsideToleranceRampsAcrossTheRemainingSteps() + { + Assert.Equal(1, PracticeLineupUtility.MissBucket(0.05f, 5)); + Assert.Equal(4, PracticeLineupUtility.MissBucket(1f, 5)); + Assert.Equal(4, PracticeLineupUtility.MissBucket(0.9f, 5)); + } + + [Fact] + public void EveryMissLandsInsideTheStepRange() + { + foreach (float miss in new[] { 0f, 0.001f, 0.2f, 0.5f, 0.99f, 1f }) + { + Assert.InRange(PracticeLineupUtility.MissBucket(miss, 5), 0, 4); + } + } +} + +public class TechniqueLabelTests +{ + [Theory] + [InlineData("Stationary", "STAND STILL")] + [InlineData("Walking", "WALK AND THROW")] + [InlineData("Running", "RUN AND THROW")] + [InlineData("Crouch", "CROUCH THROW")] + [InlineData("Jump", "JUMP THROW")] + [InlineData("RunJump", "RUN + JUMP THROW")] + [InlineData("WalkJump", "WALK + JUMP THROW")] + [InlineData("CrouchJump", "CROUCH + JUMP THROW")] + public void EveryTechniqueHasItsOwnInstruction(string technique, string expected) + { + Assert.Equal(expected, PracticeLineupUtility.TechniqueLabel(technique)); + } + + [Fact] + public void NoTechniqueIsSilentlyTreatedAsStandingStill() + { + // The bug this guards: the old switch matched "Run"/"Walk" while the + // enum says Running/Walking, so a running throw was taught as a + // standing one and simply never landed. + foreach (string name in Enum.GetNames()) + { + string label = PracticeLineupUtility.TechniqueLabel(name); + + if (name != nameof(eThrowTechnique.Stationary)) + { + Assert.NotEqual("STAND STILL", label); + } + } + } + + [Theory] + [InlineData("Full", "LEFT CLICK")] + [InlineData("Half", "LEFT + RIGHT CLICK")] + [InlineData("Drop", "RIGHT CLICK")] + public void EveryStrengthHasItsOwnClick(string strength, string expected) + { + Assert.Equal(expected, PracticeLineupUtility.StrengthLabel(strength)); + } +} + +public class TrackedHtmlTests +{ + [Fact] + public void WordGapsSurviveMarkupCollapsing() + { + string html = PracticeLineupUtility.TrackedHtml("stand in"); + + // Every gap has to be non-breaking, or HTML folds the three spaces + // between two words down to one and the words run together. + Assert.DoesNotContain(" ", html); + Assert.Equal("S T A N D   I N", html); + } + + [Fact] + public void PlainTrackingIsUntouched() + { + Assert.Equal("S T A N D", PracticeLineupUtility.Tracked("stand")); + } +} + +public class TitleCaseTests +{ + [Theory] + [InlineData("new window", "New Window")] + [InlineData("CONNECTOR", "Connector")] + [InlineData("SMOKE - JUMP THROW - LEFT CLICK", "Smoke - Jump Throw - Left Click")] + [InlineData("a", "A")] + public void WordsAreCapitalisedAndTheRestLowered(string input, string expected) + { + Assert.Equal(expected, PracticeLineupUtility.TitleCase(input)); + } + + [Fact] + public void HyphensAreNotWordBreaks() + { + // "Write-Up" reads worse than "Write-up", so only whitespace splits. + Assert.Equal("Write-up On The Web", PracticeLineupUtility.TitleCase("WRITE-UP ON THE WEB")); + } + + [Fact] + public void EmptyInputStaysEmpty() + { + Assert.Equal("", PracticeLineupUtility.TitleCase(null)); + Assert.Equal("", PracticeLineupUtility.TitleCase(" ")); + } + + [Fact] + public void RunsOfSpacesDoNotCrash() + { + Assert.Equal("Two Gaps", PracticeLineupUtility.TitleCase("two gaps")); + } +} diff --git a/apps/utility-css/test/PracticeSignalUtilityTests.cs b/apps/utility-css/test/PracticeSignalUtilityTests.cs new file mode 100644 index 00000000..cf8c7b39 --- /dev/null +++ b/apps/utility-css/test/PracticeSignalUtilityTests.cs @@ -0,0 +1,149 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +// A contract with another process rather than with a person. Every assertion +// here is a thing an external clip recorder would break on, which is why they +// are pinned rather than left to whatever the formatter happens to do. +public class PracticeSignalUtilityTests +{ + // The shape a reader outside this repo matches on. + private static readonly Regex Line = new Regex( + @"^\[utility-practice\] ghost_detonated utility=(?\S+) lineup=(?\S+) lineup_id=(?\S+) steam=(?\d+) x=(?-?\d+\.\d\d) y=(?-?\d+\.\d\d) z=(?-?\d+\.\d\d)$" + ); + + [Fact] + public void TheDetonationLineHasTheShapeAReaderExpects() + { + string line = PracticeSignalUtility.GhostDetonatedLine( + "Smoke", + new Vec3(-1234.5f, 567.25f, 64f), + "client-1", + "panel-1", + 76561198000000001 + ); + + Match match = Line.Match(line); + + Assert.True(match.Success, line); + Assert.Equal("Smoke", match.Groups["utility"].Value); + Assert.Equal("client-1", match.Groups["lineup"].Value); + Assert.Equal("panel-1", match.Groups["lineup_id"].Value); + Assert.Equal("76561198000000001", match.Groups["steam"].Value); + Assert.Equal("-1234.50", match.Groups["x"].Value); + Assert.Equal("567.25", match.Groups["y"].Value); + Assert.Equal("64.00", match.Groups["z"].Value); + } + + // A lineup thrown from throw history has no panel id yet. The field stays + // present so a reader can key on names and not on how many fields there + // happen to be this time. + [Fact] + public void AnAbsentIdIsStillAField() + { + string line = PracticeSignalUtility.GhostDetonatedLine( + "Molotov", + new Vec3(0f, 0f, 0f), + "client-1", + null, + 1 + ); + + Assert.True(Line.IsMatch(line), line); + Assert.Contains("lineup_id=-", line); + } + + // A server running under a locale where the decimal separator is a comma + // would otherwise emit "x=-1234,50" and split every reader's parser. + [Fact] + public void TheLineIsTheSameInEveryLocale() + { + CultureInfo original = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + + string line = PracticeSignalUtility.GhostDetonatedLine( + "Flash", + new Vec3(-1234.5f, 567.25f, 64f), + "client-1", + "panel-1", + 7 + ); + + Assert.Contains("x=-1234.50", line); + Assert.True(Line.IsMatch(line), line); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void TheLineIsOneLineAndSpaceSeparable() + { + string line = PracticeSignalUtility.GhostDetonatedLine( + "HighExplosive", + new Vec3(1f, 2f, 3f), + "a name with spaces", + "panel-1", + 9 + ); + + Assert.DoesNotContain("\n", line); + Assert.Contains("lineup=a_name_with_spaces", line); + Assert.True(Line.IsMatch(line), line); + } + + // An external caller has to be able to say what it wants. Reading "off" as + // "toggle" would make the command a coin flip for anything that cannot see + // the current state. + [Fact] + public void ExplicitTogglesAreExplicit() + { + Assert.True(PracticeSignalUtility.TryParseToggle("off", true, out bool off)); + Assert.False(off); + + Assert.True(PracticeSignalUtility.TryParseToggle("on", false, out bool on)); + Assert.True(on); + + Assert.True(PracticeSignalUtility.TryParseToggle("OFF", false, out bool stillOff)); + Assert.False(stillOff); + + foreach (string yes in new[] { "1", "true", "yes" }) + { + Assert.True(PracticeSignalUtility.TryParseToggle(yes, false, out bool value)); + Assert.True(value); + } + + foreach (string no in new[] { "0", "false", "no" }) + { + Assert.True(PracticeSignalUtility.TryParseToggle(no, true, out bool value)); + Assert.False(value); + } + } + + [Fact] + public void NoArgumentToggles() + { + Assert.True(PracticeSignalUtility.TryParseToggle("", true, out bool fromOn)); + Assert.False(fromOn); + + Assert.True(PracticeSignalUtility.TryParseToggle(null, false, out bool fromOff)); + Assert.True(fromOff); + + Assert.True(PracticeSignalUtility.TryParseToggle(" ", true, out bool spaces)); + Assert.False(spaces); + } + + [Fact] + public void GarbageIsRefusedRatherThanGuessed() + { + Assert.False(PracticeSignalUtility.TryParseToggle("maybe", true, out bool value)); + Assert.True(value); + } +} diff --git a/apps/utility-css/test/PracticeSolverPlanTests.cs b/apps/utility-css/test/PracticeSolverPlanTests.cs new file mode 100644 index 00000000..30f3ea10 --- /dev/null +++ b/apps/utility-css/test/PracticeSolverPlanTests.cs @@ -0,0 +1,374 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +// The search, driven against a stand-in for the server. +// +// The oracle here is deliberately not a physics model -- it is a landing +// function with the one property that matters: it is piecewise. There is a +// wall, and throws that clear it and throws that do not are unrelated. A search +// that only ever walked downhill would sit against that wall forever, which is +// why the sweep and the several-basin refinement exist and why these tests are +// worth having. +public class PracticeSolverPlanTests +{ + private static readonly Vec3 Eye = new Vec3(0f, 0f, 64f); + private static readonly Vec3 Target = new Vec3(1200f, 0f, 0f); + + private class Oracle + { + public float YawStar; + public float PitchStar; + public float WallPitch = float.MaxValue; + public float Floor; + public bool Lost; + public float Constant = -1f; + + public int Thrown; + public readonly List Keys = new List(); + + public virtual SolveObservation Throw(SolveCandidate candidate) + { + Thrown++; + Keys.Add(PracticeSolverUtility.CandidateKey(candidate)); + + if (Lost) + { + return new SolveObservation { candidate = candidate }; + } + + float error = + Constant >= 0f + ? Constant + : candidate.pitch > WallPitch + ? 850f + : Floor + + (MathF.Abs(candidate.yaw - YawStar) * 12f) + + (MathF.Abs(candidate.pitch - PitchStar) * 15f); + + return new SolveObservation + { + candidate = candidate, + landing = Target + new Vec3(error, 0f, 0f), + distance = error, + landed = true, + }; + } + } + + private static SolveRequest Request(float tolerance = 40f, int grenades = 300) + { + return new SolveRequest + { + map = "de_mirage", + utility_type = "Smoke", + target = Target, + eye = Eye, + feet = new Vec3(0f, 0f, 0f), + tolerance = tolerance, + max_grenades = grenades, + batch_size = 20, + max_seconds = 120f, + strengths = new List { nameof(eThrowStrength.Full) }, + }; + } + + private static SolveResult Run( + SolveRequest request, + Oracle oracle, + out PracticeSolverPlan plan + ) + { + PracticeSolverPlan built = new PracticeSolverPlan(request); + plan = built; + + while (true) + { + List batch = built.NextBatch(); + + if (batch.Count == 0) + { + break; + } + + foreach (SolveCandidate candidate in batch) + { + built.Observe(oracle.Throw(candidate)); + } + } + + return built.Finish(1f); + } + + [Fact] + public void ConvergesThroughAWall() + { + var oracle = new Oracle + { + YawStar = 2f, + PitchStar = -20f, + WallPitch = -5f, + }; + + SolveResult result = Run(Request(), oracle, out PracticeSolverPlan plan); + + Assert.Equal(nameof(eSolveOutcome.Converged), result.outcome); + Assert.NotNull(result.best); + Assert.True(result.best!.distance <= plan.Request.tolerance); + Assert.True(result.thrown < plan.Request.max_grenades); + // The sweep alone does not land inside tolerance here; if it ever does, + // this test has stopped testing the refinement. + Assert.True(result.batches > 1); + } + + // The reason several basins are kept. The throw that looks best after the + // sweep bottoms out above tolerance; the answer is in a basin that was + // second at the time and would have been thrown away by anything that + // refined only the leader. + [Fact] + public void TheSecondBestBasinCanStillWin() + { + var oracle = new SplitOracle(); + + SolveResult result = Run(Request(tolerance: 40f), oracle, out _); + + Assert.Equal(nameof(eSolveOutcome.Converged), result.outcome); + Assert.NotNull(result.best); + Assert.True( + MathF.Abs(result.best!.candidate.yaw - SplitOracle.FarYaw) < 8f, + $"the winning throw came from the wrong basin: yaw {result.best.candidate.yaw}" + ); + } + + private class SplitOracle : Oracle + { + public const float NearYaw = 0f; + public const float NearPitch = -18f; + public const float FarYaw = 22f; + public const float FarPitch = -19.5f; + + // The near basin is smooth, obvious and never good enough. + private const float NearFloor = 55f; + + public override SolveObservation Throw(SolveCandidate candidate) + { + Thrown++; + Keys.Add(PracticeSolverUtility.CandidateKey(candidate)); + + float near = + NearFloor + + (MathF.Abs(candidate.yaw - NearYaw) * 30f) + + (MathF.Abs(candidate.pitch - NearPitch) * 30f); + + float far = + (MathF.Abs(candidate.yaw - FarYaw) * 12f) + + (MathF.Abs(candidate.pitch - FarPitch) * 40f); + + float error = MathF.Min(near, far); + + return new SolveObservation + { + candidate = candidate, + landing = Target + new Vec3(error, 0f, 0f), + distance = error, + landed = true, + }; + } + } + + // A search that cannot get anywhere has to say so. Silently returning its + // best miss is how a lineup nobody can throw ends up in a library. + [Fact] + public void GivesUpLoudlyWhenNothingImproves() + { + var oracle = new Oracle { Constant = 500f }; + + SolveResult result = Run(Request(), oracle, out PracticeSolverPlan plan); + + Assert.Equal(nameof(eSolveOutcome.NoProgress), result.outcome); + Assert.False(result.Converged()); + Assert.Contains("500", result.message); + Assert.True(result.thrown < plan.Request.max_grenades); + } + + [Fact] + public void StopsAtTheGrenadeCap() + { + var oracle = new Oracle { Constant = 500f }; + SolveRequest request = Request(grenades: 40); + + SolveResult result = Run(request, oracle, out _); + + Assert.Equal(nameof(eSolveOutcome.GrenadeCap), result.outcome); + Assert.Equal(40, result.thrown); + Assert.Equal(40, oracle.Thrown); + } + + [Fact] + public void NeverThrowsMoreThanItWasAllowed() + { + foreach (int cap in new[] { 20, 45, 100, 300 }) + { + var oracle = new Oracle { Constant = 500f }; + SolveResult result = Run(Request(grenades: cap), oracle, out _); + + Assert.True(oracle.Thrown <= cap, $"threw {oracle.Thrown} with a cap of {cap}"); + Assert.Equal(oracle.Thrown, result.thrown); + } + } + + [Fact] + public void NeverThrowsTheSameAimTwice() + { + var oracle = new Oracle { YawStar = 40f, PitchStar = -30f }; + + Run(Request(tolerance: 8f), oracle, out _); + + Assert.Equal(oracle.Keys.Count, oracle.Keys.Distinct().Count()); + } + + [Fact] + public void BatchesAreBounded() + { + var plan = new PracticeSolverPlan(Request()); + var oracle = new Oracle { Constant = 500f }; + + while (true) + { + List batch = plan.NextBatch(); + + if (batch.Count == 0) + { + break; + } + + Assert.True(batch.Count <= plan.Request.batch_size); + + foreach (SolveCandidate candidate in batch) + { + plan.Observe(oracle.Throw(candidate)); + } + } + } + + [Fact] + public void NoCalibratedStrengthMeansNoThrows() + { + SolveRequest request = Request(); + request.strengths = new List(); + + var plan = new PracticeSolverPlan(request); + + Assert.Empty(plan.NextBatch()); + Assert.Equal(nameof(eSolveOutcome.NoCandidates), plan.Finish(0f).outcome); + Assert.Contains("no strength has been calibrated", plan.Finish(0f).message); + } + + [Fact] + public void ATargetUnderfootIsRefusedBeforeAnythingIsThrown() + { + SolveRequest request = Request(); + request.target = new Vec3(Eye.x + 5f, Eye.y, Eye.z); + + var plan = new PracticeSolverPlan(request); + + Assert.Empty(plan.NextBatch()); + Assert.Equal(nameof(eSolveOutcome.NoCandidates), plan.Finish(0f).outcome); + Assert.Equal(0, plan.Thrown); + } + + [Fact] + public void GrenadesThatNeverLandedAreNotAnAnswer() + { + var oracle = new Oracle { Lost = true }; + + SolveResult result = Run(Request(), oracle, out _); + + Assert.Null(result.best); + Assert.Equal(nameof(eSolveOutcome.NoProgress), result.outcome); + Assert.Contains("no grenade reported a landing", result.message); + } + + [Fact] + public void TheClockIsACap() + { + var plan = new PracticeSolverPlan(Request()); + var oracle = new Oracle { Constant = 500f }; + + foreach (SolveCandidate candidate in plan.NextBatch()) + { + plan.Observe(oracle.Throw(candidate)); + } + + Assert.False(plan.Expired(10f)); + Assert.True(plan.Expired(plan.Request.max_seconds)); + Assert.Equal(nameof(eSolveOutcome.TimedOut), plan.Finish(500f).outcome); + } + + // A solve that already has its answer stops asking for grenades. + [Fact] + public void ConvergingEndsTheSearch() + { + var oracle = new Oracle { YawStar = 0f, PitchStar = -18f }; + var plan = new PracticeSolverPlan(Request()); + + foreach (SolveCandidate candidate in plan.NextBatch()) + { + plan.Observe(oracle.Throw(candidate)); + } + + Assert.True(plan.Converged()); + Assert.Empty(plan.NextBatch()); + Assert.Equal(nameof(eSolveOutcome.Converged), plan.Finish(1f).outcome); + } + + [Fact] + public void ProgressSaysWhereItIs() + { + var plan = new PracticeSolverPlan(Request()); + + Assert.Contains("nothing landed yet", plan.Progress()); + Assert.Equal("sweep", plan.Phase); + + var oracle = new Oracle { Constant = 137f }; + + foreach (SolveCandidate candidate in plan.NextBatch()) + { + plan.Observe(oracle.Throw(candidate)); + } + + Assert.Contains("137u", plan.Progress()); + Assert.Contains("20/300", plan.Progress()); + } + + // The sweep is the part that finds basins, so it must not be allowed to eat + // the budget the refinement needs. + [Fact] + public void TheSweepLeavesRoomToRefine() + { + var plan = new PracticeSolverPlan(Request(grenades: 100)); + var oracle = new Oracle { Constant = 500f }; + int sweepThrows = 0; + + while (plan.Phase == "sweep") + { + List batch = plan.NextBatch(); + + if (batch.Count == 0) + { + break; + } + + sweepThrows += batch.Count; + + foreach (SolveCandidate candidate in batch) + { + plan.Observe(oracle.Throw(candidate)); + } + } + + Assert.True(sweepThrows <= 60 + plan.Request.batch_size); + Assert.True(sweepThrows > 0); + } +} diff --git a/apps/utility-css/test/PracticeSolverUtilityTests.cs b/apps/utility-css/test/PracticeSolverUtilityTests.cs new file mode 100644 index 00000000..720e1995 --- /dev/null +++ b/apps/utility-css/test/PracticeSolverUtilityTests.cs @@ -0,0 +1,519 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +public class PracticeSolverUtilityTests +{ + private static readonly Vec3 Eye = new Vec3(0f, 0f, 64f); + private static readonly Vec3 Target = new Vec3(1000f, 0f, 0f); + + private static SolveRequest Request(params string[] strengths) + { + return PracticeSolverUtility.Defaults( + new SolveRequest + { + map = "de_mirage", + utility_type = "Smoke", + target = Target, + eye = Eye, + feet = new Vec3(0f, 0f, 0f), + strengths = strengths.Length == 0 + ? new List { nameof(eThrowStrength.Full) } + : strengths.ToList(), + } + ); + } + + private static CalibrationReport Calibration(float correction = 1f) + { + return new CalibrationReport + { + map = "de_mirage", + status = nameof(eCalibrationStatus.Ready), + speed_corrections = new Dictionary + { + { nameof(eThrowStrength.Full), correction }, + }, + }; + } + + [Fact] + public void DefaultsFillInAndClamp() + { + SolveRequest request = PracticeSolverUtility.Defaults(new SolveRequest()); + + Assert.Equal(PracticeSolverUtility.DefaultTolerance, request.tolerance); + Assert.Equal(PracticeSolverUtility.DefaultBatchSize, request.batch_size); + Assert.Equal(PracticeSolverUtility.DefaultMaxGrenades, request.max_grenades); + + SolveRequest silly = PracticeSolverUtility.Defaults( + new SolveRequest + { + tolerance = 5000f, + batch_size = 900, + max_grenades = 100000, + max_seconds = 99999f, + } + ); + + Assert.Equal(PracticeSolverUtility.MaxTolerance, silly.tolerance); + Assert.Equal(PracticeSolverUtility.MaxBatchSize, silly.batch_size); + Assert.Equal(PracticeSolverUtility.MaxGrenadeCap, silly.max_grenades); + Assert.Equal(PracticeSolverUtility.MaxSecondsCap, silly.max_seconds); + } + + // A cap below a batch would emit nothing at all. + [Fact] + public void TheGrenadeCapNeverFallsBelowOneBatch() + { + SolveRequest request = PracticeSolverUtility.Defaults( + new SolveRequest { batch_size = 20, max_grenades = 3 } + ); + + Assert.Equal(20, request.max_grenades); + } + + [Fact] + public void NoClearedStrengthMeansNothingToThrow() + { + SolveRequest request = PracticeSolverUtility.Defaults( + new SolveRequest { target = Target, eye = Eye } + ); + + Assert.Empty(PracticeSolverUtility.CoarseSweep(request)); + } + + [Fact] + public void ATargetUnderfootIsNotAThrow() + { + SolveRequest request = Request(); + request.target = new Vec3(Eye.x + 4f, Eye.y, Eye.z); + + Assert.Empty(PracticeSolverUtility.CoarseSweep(request)); + } + + [Fact] + public void TheSweepStartsOnTheDirectBearing() + { + List sweep = PracticeSolverUtility.CoarseSweep(Request()); + + Assert.NotEmpty(sweep); + Assert.Equal( + PracticeLaunchUtility.BearingTo(Eye, Target), + sweep[0].yaw, + 3 + ); + } + + // Truncating the sweep to fit the budget has to drop the least likely + // throws, not an arbitrary corner of the grid. + [Fact] + public void TheSweepIsOrderedByHowLikelyAThrowIs() + { + List sweep = PracticeSolverUtility.CoarseSweep(Request()); + float bearing = PracticeLaunchUtility.BearingTo(Eye, Target); + + float previous = 0f; + + foreach (SolveCandidate candidate in sweep) + { + float offset = MathF.Abs( + PracticeLaunchUtility.NormalizeYaw(candidate.yaw - bearing) + ); + + Assert.True(offset >= previous - 0.001f); + previous = offset; + } + } + + [Fact] + public void EveryClearedStrengthIsSwept() + { + List sweep = PracticeSolverUtility.CoarseSweep( + Request( + nameof(eThrowStrength.Full), + nameof(eThrowStrength.Half), + nameof(eThrowStrength.Drop) + ) + ); + + Assert.Equal( + 3, + sweep.Select(candidate => candidate.strength_bucket).Distinct().Count() + ); + } + + [Fact] + public void NeighboursAreTheEightAround() + { + var centre = new SolveCandidate + { + pitch = -10f, + yaw = 30f, + strength = 1f, + strength_bucket = nameof(eThrowStrength.Full), + }; + + List neighbours = PracticeSolverUtility.Neighbours(centre, 3f); + + Assert.Equal(8, neighbours.Count); + Assert.DoesNotContain( + neighbours, + candidate => + MathF.Abs(candidate.pitch - centre.pitch) < 0.001f + && MathF.Abs(candidate.yaw - centre.yaw) < 0.001f + ); + Assert.All( + neighbours, + candidate => Assert.Equal(centre.strength_bucket, candidate.strength_bucket) + ); + } + + [Fact] + public void NeighboursStayInsideALegalPitch() + { + var steep = new SolveCandidate + { + pitch = -88f, + yaw = 0f, + strength_bucket = nameof(eThrowStrength.Full), + }; + + Assert.All( + PracticeSolverUtility.Neighbours(steep, 10f), + candidate => Assert.True(candidate.pitch >= -89f && candidate.pitch <= 89f) + ); + } + + // Two aims a degree apart thrown at different strengths are different + // throws, not neighbours, so refining one says nothing about the other. + [Fact] + public void DifferentStrengthsAreNeverTheSameBasin() + { + var full = new SolveCandidate + { + pitch = 0f, + yaw = 0f, + strength_bucket = nameof(eThrowStrength.Full), + }; + var half = new SolveCandidate + { + pitch = 0f, + yaw = 0f, + strength_bucket = nameof(eThrowStrength.Half), + }; + + Assert.Equal(float.MaxValue, PracticeSolverUtility.Separation(full, half)); + Assert.Equal(0f, PracticeSolverUtility.Separation(full, full)); + } + + [Fact] + public void RefinementPicksSeparatedBasinsNotNeighbours() + { + var observations = new List + { + Observation(0f, 0f, 10f), + Observation(0.5f, 0.5f, 11f), + Observation(30f, 0f, 40f), + Observation(-30f, 0f, 50f), + }; + + List picked = PracticeSolverUtility.PickDistinct( + observations, + 4, + PracticeSolverUtility.MinSeparationDegrees + ); + + Assert.Equal(3, picked.Count); + Assert.Equal(10f, picked[0].distance); + Assert.DoesNotContain(picked, observation => observation.distance == 11f); + } + + [Fact] + public void AGrenadeThatNeverLandedCanNeverWin() + { + var lost = Observation(0f, 0f, 1f); + lost.landed = false; + + List picked = PracticeSolverUtility.PickDistinct( + new[] { lost, Observation(40f, 0f, 900f) }, + 4, + PracticeSolverUtility.MinSeparationDegrees + ); + + Assert.Single(picked); + Assert.Equal(900f, picked[0].distance); + } + + [Fact] + public void NearIdenticalAimsShareAKey() + { + var first = new SolveCandidate + { + pitch = 10f, + yaw = 20f, + strength_bucket = nameof(eThrowStrength.Full), + }; + var second = new SolveCandidate + { + pitch = 10.001f, + yaw = 20.001f, + strength_bucket = nameof(eThrowStrength.Full), + }; + var apart = new SolveCandidate + { + pitch = 10.5f, + yaw = 20f, + strength_bucket = nameof(eThrowStrength.Full), + }; + + Assert.Equal( + PracticeSolverUtility.CandidateKey(first), + PracticeSolverUtility.CandidateKey(second) + ); + Assert.NotEqual( + PracticeSolverUtility.CandidateKey(first), + PracticeSolverUtility.CandidateKey(apart) + ); + } + + [Fact] + public void TheMeasuredSpeedCorrectionReachesTheThrow() + { + SolveRequest request = Request(); + var candidate = new SolveCandidate + { + pitch = -10f, + yaw = 0f, + strength = 1f, + strength_bucket = nameof(eThrowStrength.Full), + }; + + LaunchSeed plain = PracticeSolverUtility.SeedFor(request, candidate, Calibration()); + LaunchSeed corrected = PracticeSolverUtility.SeedFor( + request, + candidate, + Calibration(1.2f) + ); + + Assert.Equal(plain.speed * 1.2f, corrected.speed, 2); + } + + // The seed is the point of a solve: without it the lineup is a suggestion, + // with it the plugin can throw the winning grenade again exactly. + [Fact] + public void TheWinningThrowBecomesAReplayableLineup() + { + SolveRequest request = Request(); + request.name = "window"; + request.requested_by = "76561198000000001"; + + var best = new SolveObservation + { + candidate = new SolveCandidate + { + pitch = -12.5f, + yaw = 3.5f, + strength = 1f, + strength_bucket = nameof(eThrowStrength.Full), + }, + landing = new Vec3(1002f, 3f, 0f), + distance = 3.6f, + landed = true, + bounces = 2, + }; + + LineupRecord lineup = PracticeSolverUtility.ToLineup( + request, + best, + Calibration(), + "swiftlys2", + "1.2.3" + ); + + Assert.True(lineup.HasPhysicsSeed()); + Assert.True(lineup.IsExactlyReplayable()); + Assert.Equal("window", lineup.name); + Assert.Equal("de_mirage", lineup.map); + Assert.Equal(nameof(eThrowTechnique.Stationary), lineup.technique); + Assert.Equal(nameof(eThrowStrength.Full), lineup.strength); + Assert.Equal(-12.5f, lineup.release.pitch, 3); + Assert.Equal(3.5f, lineup.release.yaw, 3); + Assert.Equal(2, lineup.bounces); + Assert.Equal(best.landing.x, lineup.detonation_position.x, 3); + Assert.Equal("swiftlys2", lineup.plugin_runtime); + } + + // A solved throw is stationary by construction, so a release snapshot that + // said otherwise would send a player somewhere they cannot reproduce it. + [Fact] + public void TheSolvedReleaseIsAStandingThrow() + { + LineupRecord lineup = PracticeSolverUtility.ToLineup( + Request(), + new SolveObservation + { + candidate = new SolveCandidate + { + strength = 1f, + strength_bucket = nameof(eThrowStrength.Full), + }, + landed = true, + }, + Calibration(), + "swiftlys2", + "" + ); + + Assert.True(lineup.release.on_ground); + Assert.False(lineup.release.jump_throw); + Assert.False(lineup.release.ducked); + Assert.Equal(0f, lineup.release.speed); + } + + // The re-throw is the difference between a measurement and a coincidence, + // so a confirmation that did not land is a failure and not a shrug. + [Fact] + public void AConfirmationHasToLandAndBeClose() + { + SolveRequest request = Request(); + request.tolerance = 40f; + + Assert.True( + PracticeSolverUtility.Confirms( + new SolveObservation { landed = true, distance = 39f }, + request + ) + ); + Assert.False( + PracticeSolverUtility.Confirms( + new SolveObservation { landed = true, distance = 41f }, + request + ) + ); + Assert.False( + PracticeSolverUtility.Confirms( + new SolveObservation { landed = false, distance = 1f }, + request + ) + ); + Assert.False(PracticeSolverUtility.Confirms(new SolveObservation(), request)); + } + + [Fact] + public void ParsesTheRconForm() + { + Assert.True( + PracticeSolverUtility.TryParse( + new[] + { + "target=1000,-250.5,64", + "from=0,0,0", + "utility=HE", + "tolerance=25", + "grenades=80", + "seconds=30", + "steam=76561198000000001", + "name=window smoke", + }, + out SolveRequest request, + out string error + ) + ); + + Assert.Equal("", error); + Assert.Equal(1000f, request.target.x, 3); + Assert.Equal(-250.5f, request.target.y, 3); + Assert.Equal("HighExplosive", request.utility_type); + Assert.Equal(25f, request.tolerance, 3); + Assert.Equal(80, request.max_grenades); + Assert.Equal("window smoke", request.name); + Assert.Equal("76561198000000001", request.requested_by); + } + + // from= is a floor position, because that is what a player reads off the + // map; the throw itself comes out of the eyes. + [Fact] + public void AGivenThrowingPositionStandsUp() + { + PracticeSolverUtility.TryParse( + new[] { "target=500,0,0", "from=10,20,30" }, + out SolveRequest request, + out _ + ); + + Assert.Equal(30f, request.feet.z, 3); + Assert.Equal( + 30f + PracticeSolverUtility.StandingEyeHeight, + request.eye.z, + 3 + ); + } + + [Fact] + public void RefusesACallWithNoTarget() + { + Assert.False( + PracticeSolverUtility.TryParse( + new[] { "utility=Smoke" }, + out _, + out string error + ) + ); + + Assert.Contains("target=x,y,z is required", error); + } + + [Fact] + public void RefusesAMalformedPoint() + { + Assert.False( + PracticeSolverUtility.TryParse(new[] { "target=1000,64" }, out _, out string error) + ); + + Assert.Contains("target must be x,y,z", error); + } + + // A positional argument list over RCON is a solve for the wrong point that + // nobody notices, so anything that is not key=value is an error. + [Fact] + public void RefusesPositionalArguments() + { + Assert.False( + PracticeSolverUtility.TryParse( + new[] { "1000", "0", "64" }, + out _, + out string error + ) + ); + + Assert.Contains("every argument is key=value", error); + } + + [Fact] + public void RefusesAnUnknownArgument() + { + Assert.False( + PracticeSolverUtility.TryParse(new[] { "target=1,2,3", "wind=5" }, out _, out string error) + ); + + Assert.Contains("unknown argument", error); + } + + private static SolveObservation Observation(float yaw, float pitch, float distance) + { + return new SolveObservation + { + candidate = new SolveCandidate + { + pitch = pitch, + yaw = yaw, + strength = 1f, + strength_bucket = nameof(eThrowStrength.Full), + }, + distance = distance, + landed = true, + }; + } +} diff --git a/apps/utility-css/test/SmokeVolumeUtilityTests.cs b/apps/utility-css/test/SmokeVolumeUtilityTests.cs new file mode 100644 index 00000000..4755a8ca --- /dev/null +++ b/apps/utility-css/test/SmokeVolumeUtilityTests.cs @@ -0,0 +1,341 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +// The bloom outline is the only thing in the plugin that turns bytes into +// entities, so these pin both halves of it: the packing the parser chose, and +// the entity budget that keeps a smoke from spawning a thousand beams. +public class SmokeVolumeUtilityTests +{ + private static SmokeVolume Volume( + int dx, + int dy, + int dz, + byte[]? cells = null, + float vs = 8f, + float ox = 0f, + float oy = 0f, + float oz = 0f + ) + { + return new SmokeVolume + { + ox = ox, + oy = oy, + oz = oz, + vs = vs, + dx = dx, + dy = dy, + dz = dz, + den = cells == null ? null : Encode(cells), + }; + } + + // Two cells per byte, low nibble first. + private static string Encode(byte[] cells) + { + var packed = new byte[(cells.Length + 1) / 2]; + + for (int index = 0; index < cells.Length; index++) + { + byte value = (byte)(cells[index] & 0x0F); + + if ((index & 1) == 0) + { + packed[index >> 1] |= value; + } + else + { + packed[index >> 1] |= (byte)(value << 4); + } + } + + return System.Convert.ToBase64String(packed); + } + + [Fact] + public void TheLowNibbleOfAByteIsTheFirstCell() + { + SmokeVolume volume = Volume(2, 1, 1); + volume.den = System.Convert.ToBase64String(new byte[] { 0xF0 }); + + byte[] density = SmokeVolumeUtility.Decode(volume); + + Assert.Equal(0, SmokeVolumeUtility.Density(density, volume, 0, 0, 0)); + Assert.Equal(15, SmokeVolumeUtility.Density(density, volume, 1, 0, 0)); + } + + [Fact] + public void CellsAreOrderedXMajorThenYThenZ() + { + SmokeVolume volume = Volume(2, 2, 2, new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }); + byte[] density = SmokeVolumeUtility.Decode(volume); + + Assert.Equal(1, SmokeVolumeUtility.Density(density, volume, 0, 0, 0)); + Assert.Equal(2, SmokeVolumeUtility.Density(density, volume, 1, 0, 0)); + Assert.Equal(3, SmokeVolumeUtility.Density(density, volume, 0, 1, 0)); + Assert.Equal(4, SmokeVolumeUtility.Density(density, volume, 1, 1, 0)); + Assert.Equal(5, SmokeVolumeUtility.Density(density, volume, 0, 0, 1)); + Assert.Equal(8, SmokeVolumeUtility.Density(density, volume, 1, 1, 1)); + } + + [Fact] + public void ACellOutsideTheGridIsClearRatherThanAnError() + { + SmokeVolume volume = Volume(2, 2, 2, new byte[] { 9, 9, 9, 9, 9, 9, 9, 9 }); + byte[] density = SmokeVolumeUtility.Decode(volume); + + Assert.Equal(0, SmokeVolumeUtility.Density(density, volume, -1, 0, 0)); + Assert.Equal(0, SmokeVolumeUtility.Density(density, volume, 2, 0, 0)); + Assert.Equal(0, SmokeVolumeUtility.Density(density, volume, 0, 0, 2)); + } + + // The array is optional in the contract; the box is then the measurement. + [Fact] + public void AVolumeWithNoGridIsSolid() + { + SmokeVolume volume = Volume(3, 3, 3); + byte[] density = SmokeVolumeUtility.Decode(volume); + + Assert.Equal(27, density.Length); + Assert.All(density, cell => Assert.Equal(15, cell)); + } + + [Fact] + public void ADenShorterThanTheGridLeavesTheRestClear() + { + SmokeVolume volume = Volume(4, 1, 1); + volume.den = System.Convert.ToBase64String(new byte[] { 0x21 }); + + byte[] density = SmokeVolumeUtility.Decode(volume); + + Assert.Equal(1, SmokeVolumeUtility.Density(density, volume, 0, 0, 0)); + Assert.Equal(2, SmokeVolumeUtility.Density(density, volume, 1, 0, 0)); + Assert.Equal(0, SmokeVolumeUtility.Density(density, volume, 2, 0, 0)); + Assert.Equal(0, SmokeVolumeUtility.Density(density, volume, 3, 0, 0)); + } + + [Fact] + public void GarbageBase64DecodesToNothingRatherThanThrowing() + { + SmokeVolume volume = Volume(2, 2, 1); + volume.den = "this is not base64 !!"; + + byte[] density = SmokeVolumeUtility.Decode(volume); + + Assert.Equal(4, density.Length); + Assert.All(density, cell => Assert.Equal(0, cell)); + } + + [Fact] + public void AGridBiggerThanTheCellCeilingIsRefused() + { + SmokeVolume volume = Volume(512, 512, 512); + + Assert.Empty(SmokeVolumeUtility.Decode(volume)); + Assert.Empty(SmokeVolumeUtility.Outline(volume)); + } + + [Fact] + public void NoVolumeOutlinesToNothing() + { + Assert.Empty(SmokeVolumeUtility.Outline(null)); + } + + [Fact] + public void AnEmptyGridOutlinesToNothing() + { + SmokeVolume volume = Volume(4, 4, 4, new byte[64]); + + Assert.Empty(SmokeVolumeUtility.Outline(volume)); + } + + [Fact] + public void ASolidBoxOutlinesToOneRectanglePerLevel() + { + SmokeVolume volume = Volume(4, 4, 4, ox: 100f, oy: 200f, oz: 300f); + + List segments = SmokeVolumeUtility.Outline(volume); + + Assert.Equal(12, segments.Count); + + foreach (BloomSegment segment in segments) + { + Assert.InRange(segment.a.x, 100f, 132f); + Assert.InRange(segment.a.y, 200f, 232f); + Assert.InRange(segment.a.z, 300f, 332f); + Assert.InRange(segment.b.x, 100f, 132f); + Assert.InRange(segment.b.y, 200f, 232f); + } + } + + [Fact] + public void EveryLevelOfTheOutlineSitsAtADifferentHeight() + { + SmokeVolume volume = Volume(4, 4, 4); + + var heights = SmokeVolumeUtility + .Outline(volume) + .Select(segment => segment.a.z) + .Distinct() + .ToList(); + + Assert.Equal(3, heights.Count); + } + + [Fact] + public void ASingleLayerOnlyDrawsOneContour() + { + SmokeVolume volume = Volume(4, 4, 1); + + List segments = SmokeVolumeUtility.Outline(volume); + + Assert.Equal(4, segments.Count); + } + + // A pillar inside the bloom is the thing a player is looking for, so the + // hole gets its own loop rather than being swallowed by the silhouette. + [Fact] + public void AHoleInTheBloomIsOutlinedToo() + { + var cells = new byte[7 * 7]; + + for (int index = 0; index < cells.Length; index++) + { + cells[index] = 15; + } + + for (int j = 2; j <= 4; j++) + { + for (int i = 2; i <= 4; i++) + { + cells[(j * 7) + i] = 0; + } + } + + SmokeVolume volume = Volume(7, 7, 1, cells); + List segments = SmokeVolumeUtility.Outline(volume); + + Assert.Equal(8, segments.Count); + } + + // One stray dense cell is measurement noise, not somewhere to throw at. + [Fact] + public void ASingleStrayCellIsNotWorthABeam() + { + var cells = new byte[5 * 5]; + cells[(2 * 5) + 2] = 15; + + SmokeVolume volume = Volume(5, 5, 1, cells); + + Assert.Empty(SmokeVolumeUtility.Outline(volume)); + } + + [Fact] + public void TheEntityBudgetIsNeverExceeded() + { + SmokeVolume volume = Sphere(18, 8f); + + foreach (int budget in new[] { 4, 8, 16, 48, 96 }) + { + List segments = SmokeVolumeUtility.Outline( + volume, + new SmokeOutlineOptions { MaxSegments = budget } + ); + + Assert.True( + segments.Count <= budget, + $"{segments.Count} segments for a budget of {budget}" + ); + } + } + + [Fact] + public void ARealisticBloomStillDrawsSomething() + { + List segments = SmokeVolumeUtility.Outline(Sphere(18, 8f)); + + Assert.NotEmpty(segments); + Assert.All( + segments, + segment => + Assert.True( + (segment.b - segment.a).Length() > 0f, + "a zero length beam draws nothing and still costs an entity" + ) + ); + } + + [Fact] + public void ADenserThresholdOutlinesASmallerShape() + { + SmokeVolume volume = Sphere(18, 8f, falloff: true); + + int wide = SmokeVolumeUtility + .Outline(volume, new SmokeOutlineOptions { MinDensity = 1, MaxLevels = 1 }) + .Sum(segment => (int)(segment.b - segment.a).LengthXY()); + + int tight = SmokeVolumeUtility + .Outline(volume, new SmokeOutlineOptions { MinDensity = 12, MaxLevels = 1 }) + .Sum(segment => (int)(segment.b - segment.a).LengthXY()); + + Assert.True(tight < wide, $"{tight} is not tighter than {wide}"); + } + + // A flood filled bloom clipped by a wall must never be reported as covering + // the wall: the outline is an exact staircase, simplified inwards only by + // the epsilon, so nothing is drawn past the last occupied cell. + [Fact] + public void TheOutlineStaysInsideTheMeasuredCells() + { + var cells = new byte[8 * 8]; + + for (int j = 0; j < 8; j++) + { + for (int i = 0; i < 4; i++) + { + cells[(j * 8) + i] = 15; + } + } + + SmokeVolume volume = Volume(8, 8, 1, cells); + + foreach (BloomSegment segment in SmokeVolumeUtility.Outline(volume)) + { + Assert.InRange(segment.a.x, 0f, 32f); + Assert.InRange(segment.b.x, 0f, 32f); + } + } + + private static SmokeVolume Sphere(int diameter, float vs, bool falloff = false) + { + var cells = new byte[diameter * diameter * diameter]; + float radius = diameter / 2f; + + for (int k = 0; k < diameter; k++) + { + for (int j = 0; j < diameter; j++) + { + for (int i = 0; i < diameter; i++) + { + float dx = i - radius + 0.5f; + float dy = j - radius + 0.5f; + float dz = k - radius + 0.5f; + float distance = MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz)); + + if (distance > radius) + { + continue; + } + + cells[(((k * diameter) + j) * diameter) + i] = falloff + ? (byte)Math.Clamp((int)(15f * (1f - (distance / radius))), 1, 15) + : (byte)15; + } + } + } + + SmokeVolume volume = Volume(diameter, diameter, diameter, cells, vs); + return volume; + } +} diff --git a/apps/utility-css/test/TrajectoryUtilityTests.cs b/apps/utility-css/test/TrajectoryUtilityTests.cs new file mode 100644 index 00000000..3fbe33ab --- /dev/null +++ b/apps/utility-css/test/TrajectoryUtilityTests.cs @@ -0,0 +1,203 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +public class TrajectoryUtilityTests +{ + private static ThrowSnapshot Release( + float speed = 0f, + bool onGround = true, + bool ducked = false, + bool walking = false, + bool jumpThrow = false, + float velocityZ = 0f + ) + { + return new ThrowSnapshot + { + speed = speed, + on_ground = onGround, + ducked = ducked, + walking = walking, + jump_throw = jumpThrow, + velocity = new Vec3(speed, 0f, velocityZ), + }; + } + + [Theory] + [InlineData(1.0f, eThrowStrength.Full)] + [InlineData(0.75f, eThrowStrength.Full)] + [InlineData(0.5f, eThrowStrength.Half)] + [InlineData(0.25f, eThrowStrength.Half)] + [InlineData(0.0f, eThrowStrength.Drop)] + public void ClassifiesTheThreeReleaseStrengths(float raw, eThrowStrength expected) + { + Assert.Equal(expected, TrajectoryUtility.ClassifyStrength(raw)); + } + + [Fact] + public void StandingStillIsStationary() + { + Assert.Equal( + eThrowTechnique.Stationary, + TrajectoryUtility.ClassifyTechnique(Release()) + ); + } + + [Fact] + public void WalkSpeedIsWalkingAndAboveItIsRunning() + { + Assert.Equal( + eThrowTechnique.Walking, + TrajectoryUtility.ClassifyTechnique(Release(speed: 120f)) + ); + Assert.Equal( + eThrowTechnique.Running, + TrajectoryUtility.ClassifyTechnique(Release(speed: 240f)) + ); + } + + [Fact] + public void HoldingWalkIsWalkingEvenAtRunSpeed() + { + Assert.Equal( + eThrowTechnique.Walking, + TrajectoryUtility.ClassifyTechnique(Release(speed: 240f, walking: true)) + ); + } + + // A hand-timed jump throw does not always set m_bJumpThrow, so leaving the + // ground has to count on its own or half of all lineups misclassify. + [Fact] + public void LeavingTheGroundCountsAsAJumpWithoutTheFlag() + { + Assert.Equal( + eThrowTechnique.Jump, + TrajectoryUtility.ClassifyTechnique(Release(onGround: false)) + ); + Assert.Equal( + eThrowTechnique.Jump, + TrajectoryUtility.ClassifyTechnique(Release(velocityZ: 200f)) + ); + } + + [Fact] + public void JumpComposesWithMovementAndStance() + { + Assert.Equal( + eThrowTechnique.RunJump, + TrajectoryUtility.ClassifyTechnique(Release(speed: 240f, jumpThrow: true)) + ); + Assert.Equal( + eThrowTechnique.WalkJump, + TrajectoryUtility.ClassifyTechnique(Release(speed: 100f, jumpThrow: true)) + ); + Assert.Equal( + eThrowTechnique.CrouchJump, + TrajectoryUtility.ClassifyTechnique(Release(jumpThrow: true, ducked: true)) + ); + Assert.Equal( + eThrowTechnique.Crouch, + TrajectoryUtility.ClassifyTechnique(Release(ducked: true)) + ); + } + + [Fact] + public void DerivesAnglesFromAVelocityVector() + { + var (pitch, yaw) = TrajectoryUtility.AnglesFromVelocity(new Vec3(100f, 0f, 0f)); + Assert.Equal(0f, yaw, 3); + Assert.Equal(0f, pitch, 3); + + (pitch, yaw) = TrajectoryUtility.AnglesFromVelocity(new Vec3(0f, 100f, 0f)); + Assert.Equal(90f, yaw, 3); + + // Up is negative pitch in the engine's convention. + (pitch, yaw) = TrajectoryUtility.AnglesFromVelocity(new Vec3(0f, 0f, 100f)); + Assert.Equal(-90f, pitch, 3); + } + + [Fact] + public void AnglesFromAZeroVectorDoNotProduceNaN() + { + var (pitch, yaw) = TrajectoryUtility.AnglesFromVelocity(new Vec3(0f, 0f, 0f)); + Assert.False(float.IsNaN(pitch)); + Assert.False(float.IsNaN(yaw)); + } + + private static TrajectoryPoint Point(float x, float y, float z, int t, bool bounce = false) + { + return new TrajectoryPoint + { + p = new Vec3(x, y, z), + t = t, + bounce = bounce, + }; + } + + [Fact] + public void SimplifyCollapsesAStraightRun() + { + var points = new List(); + for (int i = 0; i <= 20; i++) + { + points.Add(Point(i * 10f, 0f, 0f, i)); + } + + var simplified = TrajectoryUtility.Simplify(points); + + Assert.Equal(2, simplified.Count); + Assert.Equal(0f, simplified[0].p.x); + Assert.Equal(200f, simplified[^1].p.x); + } + + [Fact] + public void SimplifyKeepsTheShapeOfACurve() + { + var points = new List(); + for (int i = 0; i <= 40; i++) + { + float x = i * 10f; + points.Add(Point(x, 0f, -(x * x) / 400f, i)); + } + + var simplified = TrajectoryUtility.Simplify(points); + + Assert.True(simplified.Count > 2, "an arc must not collapse to a line"); + Assert.True( + simplified.Count < points.Count, + "an arc should still compact substantially" + ); + } + + // A bounce is where the path changes direction. Dropping one is how a + // replayed line ends up going through a wall. + [Fact] + public void SimplifyNeverDropsABounce() + { + var points = new List(); + for (int i = 0; i <= 10; i++) + { + points.Add(Point(i * 10f, 0f, 0f, i)); + } + points[5].bounce = true; + for (int i = 11; i <= 20; i++) + { + points.Add(Point(100f, (i - 10) * 10f, 0f, i)); + } + + var simplified = TrajectoryUtility.Simplify(points); + + Assert.Contains(simplified, p => p.bounce); + Assert.Equal(1, simplified.Count(p => p.bounce)); + } + + [Fact] + public void SimplifyPassesThroughShortPaths() + { + var points = new List { Point(0f, 0f, 0f, 0), Point(10f, 0f, 0f, 1) }; + Assert.Equal(2, TrajectoryUtility.Simplify(points).Count); + Assert.Empty(TrajectoryUtility.Simplify(new List())); + } +} diff --git a/apps/utility-css/test/UtilityArtifactTests.cs b/apps/utility-css/test/UtilityArtifactTests.cs new file mode 100644 index 00000000..4d62ec34 --- /dev/null +++ b/apps/utility-css/test/UtilityArtifactTests.cs @@ -0,0 +1,338 @@ +using System.IO.Compression; +using System.Text.Json; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +// The three shapes the panel added, pinned to the spellings it actually sends. +// Every failure here is silent rather than loud: a preview that draws nothing, +// a roster that reads as empty, or a result the panel answers 403 to. +public class UtilityArtifactTests +{ + // The artifact is the playback blob's shape, so the path is nested. + private const string Artifact = """ + { + "schema_version": 3, + "map_name": "de_mirage", + "grenade_trajectories": [ + { + "round": 1, + "grenade_id": 1, + "type": "Smoke", + "points": [ + { "tick": 0, "x": 1, "y": 2, "z": 3 }, + { "tick": 8, "x": 4.5, "y": 5.5, "z": 6.5 } + ] + } + ], + "smoke_volumes": [ + { + "gid": 1, + "round": 1, + "start_tick": 128, + "ox": -100, "oy": -200, "oz": 64, + "vs": 8, "dx": 4, "dy": 5, "dz": 6, + "den": "AAAA" + } + ] + } + """; + + [Fact] + public void ThePathIsReadOutOfTheNestedTrajectory() + { + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse(Artifact); + + Assert.Equal(2, artifact.path.Count); + Assert.Equal(0, artifact.path[0].t); + Assert.Equal(1f, artifact.path[0].p.x); + Assert.Equal(8, artifact.path[1].t); + Assert.Equal(6.5f, artifact.path[1].p.z); + } + + [Fact] + public void TheSmokeVolumeIsReadOutOfTheArtifact() + { + SmokeVolume? volume = UtilityTrajectoryArtifact.Parse(Artifact).smoke_volume; + + Assert.NotNull(volume); + Assert.Equal(-100f, volume!.ox); + Assert.Equal(64f, volume.oz); + Assert.Equal(8f, volume.vs); + Assert.Equal(4, volume.dx); + Assert.Equal(5, volume.dy); + Assert.Equal(6, volume.dz); + Assert.Equal("AAAA", volume.den); + } + + // The blob is stored gzipped and streamed back byte for byte, so what + // arrives is compressed and reading it as text would be mojibake. + [Fact] + public void AGzippedArtifactIsUnpackedFirst() + { + var plain = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(Artifact)); + var compressed = new MemoryStream(); + + using (var gzip = new GZipStream(compressed, CompressionMode.Compress, true)) + { + plain.CopyTo(gzip); + } + + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse(compressed.ToArray()); + + Assert.Equal(2, artifact.path.Count); + Assert.NotNull(artifact.smoke_volume); + } + + [Fact] + public void APlainBodyIsReadAsItIs() + { + Assert.Equal("{}", PracticeJson.Text(System.Text.Encoding.UTF8.GetBytes("{}"))); + } + + [Fact] + public void AFlatPathIsStillRead() + { + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( + """{"path":[{"tick":4,"x":1,"y":2,"z":3}]}""" + ); + + Assert.Single(artifact.path); + Assert.Equal(4, artifact.path[0].t); + } + + [Fact] + public void ABareArrayIsStillRead() + { + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( + """[{"tick":4,"x":1,"y":2,"z":3}]""" + ); + + Assert.Single(artifact.path); + } + + // Not every lineup is a smoke and not every map has a collision mesh. + [Fact] + public void AMissingSmokeVolumeIsNotAnError() + { + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( + """{"grenade_trajectories":[{"points":[]}],"smoke_volumes":[]}""" + ); + + Assert.Empty(artifact.path); + Assert.Null(artifact.smoke_volume); + } + + [Fact] + public void AVolumeWithNoExtentIsNotAVolume() + { + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( + """{"smoke_volumes":[{"ox":0,"oy":0,"oz":0,"vs":0,"dx":0,"dy":0,"dz":0}]}""" + ); + + Assert.Null(artifact.smoke_volume); + } + + [Fact] + public void AnArtifactWithNothingInItReadsAsEmpty() + { + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse("{}"); + + Assert.Empty(artifact.path); + Assert.Null(artifact.smoke_volume); + } + + [Fact] + public void TheSessionIsReadInTheApiSpelling() + { + PracticeSessionData session = JsonSerializer + .Deserialize( + """ + { + "session_id": "11111111-1111-1111-1111-111111111111", + "match_id": "22222222-2222-2222-2222-222222222222", + "map_name": "de_nuke", + "password": "hunter2", + "steam_ids": ["76561198000000001", "76561198000000002"], + "playbook": null + } + """, + PracticeJson.Options + )! + .ToSession(); + + Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), session.id); + Assert.Equal(Guid.Parse("22222222-2222-2222-2222-222222222222"), session.match_id); + Assert.Equal("de_nuke", session.map); + Assert.Equal("hunter2", session.password); + Assert.Equal(2, session.allowed_steam_ids.Count); + Assert.Null(session.playbook); + } + + // An unparsed roster reads as "nobody is allowed", which is why both + // spellings are accepted rather than the newest one only. + [Fact] + public void TheOlderSessionSpellingStillFillsTheRoster() + { + PracticeSessionData session = JsonSerializer + .Deserialize( + """ + { + "id": "11111111-1111-1111-1111-111111111111", + "match_id": "22222222-2222-2222-2222-222222222222", + "map": "de_nuke", + "password": "hunter2", + "allowed_steam_ids": ["76561198000000001"] + } + """, + PracticeJson.Options + )! + .ToSession(); + + Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), session.id); + Assert.Equal("de_nuke", session.map); + Assert.Single(session.allowed_steam_ids); + } + + [Fact] + public void APlaybookOnTheSessionArrivesWithItsSteps() + { + PracticeSessionData session = JsonSerializer + .Deserialize( + """ + { + "session_id": "11111111-1111-1111-1111-111111111111", + "match_id": "22222222-2222-2222-2222-222222222222", + "map_name": "de_mirage", + "password": "", + "steam_ids": [], + "playbook": { + "id": "33333333-3333-3333-3333-333333333333", + "name": "A split", + "map_name": "de_mirage", + "side": "TERRORIST", + "steps": [ + { + "utility_lineup_id": "44444444-4444-4444-4444-444444444444", + "step_order": 1, + "offset_ms": 0, + "assigned_steam_id": "76561198000000001", + "note": "jungle smoke", + "lineup": { + "id": "44444444-4444-4444-4444-444444444444", + "name": "jungle", + "map_name": "de_mirage", + "utility_type": "Smoke", + "side": "TERRORIST", + "origin_x": 1, "origin_y": 2, "origin_z": 3, + "view_yaw": 90, "view_pitch": -20, + "land_x": 400, "land_y": 500, "land_z": 60 + } + } + ] + } + } + """, + PracticeJson.Options + )! + .ToSession(); + + UtilityPlaybook? playbook = session.playbook; + + Assert.NotNull(playbook); + Assert.Equal("A split", playbook!.name); + + var steps = PlaybookUtility.Ordered(playbook); + + Assert.Single(steps); + Assert.Equal("jungle smoke", steps[0].note); + Assert.True(PlaybookUtility.IsFor(steps[0], 76561198000000001)); + + LineupRecord? lineup = steps[0].ToLineup(); + + Assert.NotNull(lineup); + Assert.Equal("44444444-4444-4444-4444-444444444444", lineup!.id); + Assert.Equal(400f, lineup.detonation_position.x); + } + + [Fact] + public void AResultNamesTheServerAndTheSessionItBelongsTo() + { + UtilityPracticeResultPayload payload = UtilityPracticeResultPayload.For( + "55555555-5555-5555-5555-555555555555", + Guid.Parse("11111111-1111-1111-1111-111111111111"), + "44444444-4444-4444-4444-444444444444", + 76561198000000001, + new Vec3(1f, 2f, 3f), + true + ); + + Assert.Equal("55555555-5555-5555-5555-555555555555", payload.server_id); + Assert.Equal("11111111-1111-1111-1111-111111111111", payload.session_id); + Assert.Equal("44444444-4444-4444-4444-444444444444", payload.utility_lineup_id); + Assert.Equal("76561198000000001", payload.steam_id); + Assert.Equal(3f, payload.land_z); + Assert.True(payload.success); + } + + // The API rejects a session_id that disagrees with the one it resolved from + // the server, so an unknown session must be left out rather than sent empty. + [Fact] + public void AnUnknownSessionIsLeftOutOfTheResult() + { + UtilityPracticeResultPayload payload = UtilityPracticeResultPayload.For( + null, + Guid.Empty, + "44444444-4444-4444-4444-444444444444", + 76561198000000001, + new Vec3(1f, 2f, 3f), + null + ); + + string json = JsonSerializer.Serialize(payload, PracticeJson.Options); + + Assert.DoesNotContain("session_id", json); + Assert.DoesNotContain("server_id", json); + Assert.DoesNotContain("success", json); + Assert.Contains("\"utility_lineup_id\"", json); + } + + [Fact] + public void AResultIsReadBackWithThePanelsRadius() + { + UtilityPracticeResult? result = JsonSerializer.Deserialize( + """ + { + "success": true, + "distance": 42.5, + "radius": 96, + "attempts": 7, + "successes": 4, + "current_streak": 3, + "best_streak": 5, + "mastered_at": "2026-08-18T12:00:00.000Z" + } + """, + PracticeJson.Options + ); + + Assert.NotNull(result); + Assert.True(result!.success); + Assert.Equal(42.5f, result.distance); + Assert.Equal(96f, result.radius); + Assert.Equal(3, result.current_streak); + Assert.NotNull(result.mastered_at); + } + + [Fact] + public void AResultThatHasNotBeenMasteredCarriesNoDate() + { + UtilityPracticeResult? result = JsonSerializer.Deserialize( + """{"success":false,"distance":300,"radius":96,"attempts":1,"successes":0,"current_streak":0,"best_streak":0,"mastered_at":null}""", + PracticeJson.Options + ); + + Assert.NotNull(result); + Assert.Null(result!.mastered_at); + } +} diff --git a/apps/utility-css/test/UtilityWireTests.cs b/apps/utility-css/test/UtilityWireTests.cs new file mode 100644 index 00000000..d769b089 --- /dev/null +++ b/apps/utility-css/test/UtilityWireTests.cs @@ -0,0 +1,568 @@ +using System.Text.Json; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +// The API owns the wire contract, so these pin the translation to it. Every +// case here is one that fails silently rather than loudly: a field that lands +// in the wrong column, a unit that is a thousand times out, or a spelling the +// panel's enum does not have. +public class UtilityWireTests +{ + private static LineupRecord Lineup() + { + return new LineupRecord + { + id = "server-side-id", + client_id = "local-id", + map = "de_mirage", + name = "A site window smoke", + utility_type = "Smoke", + side = "TERRORIST", + visibility = "Private", + author_steam_id = "76561198000000001", + release = new ThrowSnapshot + { + feet_position = new Vec3(100f, 200f, 300f), + eye_position = new Vec3(100f, 200f, 364f), + pitch = -12.5f, + yaw = 90f, + jump_throw = true, + }, + initial_position = new Vec3(1f, 2f, 3f), + initial_velocity = new Vec3(4f, 5f, 6f), + detonation_position = new Vec3(-500f, -600f, 128f), + bounces = 2, + flight_time = 1.5f, + technique = "RunJump", + strength = "Full", + recorded_tickrate = 64, + confidence = LineupRecord.Exact, + plugin_runtime = "counterstrikesharp", + plugin_version = "0.0.1", + trajectory = new List + { + new TrajectoryPoint { p = new Vec3(1f, 2f, 3f), t = 10 }, + new TrajectoryPoint { p = new Vec3(4f, 5f, 6f), t = 12, bounce = true }, + }, + }; + } + + [Fact] + public void EveryFieldLandsInTheColumnTheApiNames() + { + UtilityIngestPayload payload = UtilityIngestPayload.From(Lineup()); + + Assert.Equal("76561198000000001", payload.author_steam_id); + Assert.Equal("Smoke", payload.utility_type); + Assert.Equal("TERRORIST", payload.side); + Assert.Equal("RunJump", payload.technique); + Assert.Equal("Full", payload.throw_strength); + Assert.True(payload.jump_throw_bind); + + Assert.Equal(100f, payload.origin_x); + Assert.Equal(200f, payload.origin_y); + Assert.Equal(300f, payload.origin_z); + Assert.Equal(364f, payload.eye_z); + + Assert.Equal(90f, payload.view_yaw); + Assert.Equal(-12.5f, payload.view_pitch); + + Assert.Equal(-500f, payload.land_x); + Assert.Equal(-600f, payload.land_y); + Assert.Equal(128f, payload.land_z); + + Assert.Equal("A site window smoke", payload.name); + Assert.Equal(64, payload.tick_rate); + } + + // Seconds on this side, milliseconds on the API's. Getting this wrong + // produces a plausible-looking number rather than an error. + [Fact] + public void FlightTimeCrossesTheWireInMilliseconds() + { + UtilityIngestPayload payload = UtilityIngestPayload.From(Lineup()); + + Assert.Equal(1500, payload.flight_time_ms); + } + + [Theory] + [InlineData(0f, 0)] + [InlineData(0.001f, 1)] + [InlineData(2.4f, 2400)] + [InlineData(20f, 20000)] + public void MillisecondsAreRoundedNotTruncated(float seconds, int expected) + { + Assert.Equal(expected, UtilityIngestPayload.MillisecondsFromSeconds(seconds)); + } + + // 62.5 is exactly representable, so this pins the midpoint rule rather + // than tolerating whatever the default happens to be. + [Fact] + public void AMidpointRoundsAwayFromZero() + { + Assert.Equal(63, UtilityIngestPayload.MillisecondsFromSeconds(0.0625f)); + } + + [Theory] + [InlineData("HE", "HighExplosive")] + [InlineData("HEGrenade", "HighExplosive")] + [InlineData("HighExplosive", "HighExplosive")] + [InlineData("Flashbang", "Flash")] + [InlineData("Incendiary", "Molotov")] + [InlineData("Smoke", "Smoke")] + [InlineData("Decoy", "Decoy")] + public void TheUtilityTypeIsSentInTheApisSpelling(string recorded, string expected) + { + LineupRecord lineup = Lineup(); + lineup.utility_type = recorded; + + Assert.Equal(expected, UtilityIngestPayload.From(lineup).utility_type); + } + + [Fact] + public void ThePathIsSentAsObjectsNotPackedArrays() + { + string json = JsonSerializer.Serialize( + UtilityIngestPayload.From(Lineup()), + PracticeJson.Options + ); + + Assert.Contains("\"path\":[{\"tick\":10,\"x\":1,\"y\":2,\"z\":3}", json); + Assert.DoesNotContain("[[", json); + } + + // Sending a field the payload does not name is not harmless: the API + // derives the map from the server's own match row and rejects a mismatch. + [Theory] + [InlineData("\"map\"")] + [InlineData("\"client_id\"")] + [InlineData("\"initial_position\"")] + [InlineData("\"initial_velocity\"")] + [InlineData("\"bounces\"")] + [InlineData("\"visibility\"")] + [InlineData("\"plugin_runtime\"")] + [InlineData("\"plugin_version\"")] + [InlineData("\"workshop_map_id\"")] + [InlineData("\"release\"")] + [InlineData("\"trajectory\"")] + [InlineData("\"flight_time\":")] + [InlineData("\"confidence\"")] + public void FieldsTheApiDoesNotAcceptAreNotSent(string absent) + { + string json = JsonSerializer.Serialize( + UtilityIngestPayload.From(Lineup()), + PracticeJson.Options + ); + + Assert.DoesNotContain(absent, json); + } + + private static UtilityLibraryRow Row() + { + return new UtilityLibraryRow + { + id = "panel-id", + name = "Window smoke", + map_name = "de_mirage", + utility_type = "HE", + side = "CT", + technique = "Jump", + throw_strength = "Half", + jump_throw_bind = true, + origin_x = 10f, + origin_y = 20f, + origin_z = 30f, + eye_z = 94f, + view_yaw = 45f, + view_pitch = -20f, + land_x = 700f, + land_y = 800f, + land_z = 90f, + flight_time_ms = 2400, + visibility = "Team", + author_steam_id = "76561198000000009", + }; + } + + [Fact] + public void ALibraryRowBecomesALineupTheReplayCanStandOn() + { + LineupRecord lineup = Row().ToLineup(); + + Assert.Equal("panel-id", lineup.id); + Assert.Equal("Window smoke", lineup.name); + Assert.Equal("de_mirage", lineup.map); + Assert.Equal("CT", lineup.side); + Assert.Equal("Jump", lineup.technique); + Assert.Equal("Half", lineup.strength); + Assert.Equal("Team", lineup.visibility); + Assert.Equal("76561198000000009", lineup.author_steam_id); + + Assert.Equal(10f, lineup.release.feet_position.x); + Assert.Equal(20f, lineup.release.feet_position.y); + Assert.Equal(30f, lineup.release.feet_position.z); + Assert.Equal(94f, lineup.release.eye_position.z); + Assert.Equal(45f, lineup.release.yaw); + Assert.Equal(-20f, lineup.release.pitch); + Assert.True(lineup.release.jump_throw); + + Assert.Equal(700f, lineup.detonation_position.x); + Assert.Equal(90f, lineup.detonation_position.z); + } + + [Fact] + public void MillisecondsComeBackAsSeconds() + { + Assert.Equal(2.4f, Row().ToLineup().flight_time); + } + + [Fact] + public void ARowsTypeIsNormalizedOnTheWayInToo() + { + Assert.Equal("HighExplosive", Row().ToLineup().utility_type); + } + + // .delete and the .next/.prev walk both key off client_id, so a fetched + // lineup has to keep a stable one across reloads. + [Fact] + public void ThePanelsIdBecomesTheLocalIdentity() + { + Assert.Equal("panel-id", Row().ToLineup().client_id); + } + + // The library response has no path in it at all; the preview needs a + // second call, and code that assumed otherwise would draw nothing. + [Fact] + public void ALibraryRowCarriesNoTrajectory() + { + Assert.Empty(Row().ToLineup().trajectory); + } + + // The seed columns are nullable by design: a lineup mined from a demo, + // imported or authored by hand was never watched by a plugin. A zero + // velocity is exactly the predicate PracticeReplay.HasPhysicsSeed reads as + // "do not re-emit this", so a row with no seed has to land on it. + [Fact] + public void ARowWithNoSeedIsNotReplayable() + { + LineupRecord lineup = Row().ToLineup(); + + Assert.False(Row().HasSeed()); + Assert.Equal(0f, lineup.initial_velocity.Length()); + Assert.Equal(0f, lineup.initial_position.Length()); + } + + [Fact] + public void ARowWithASeedIsReplayableExactly() + { + UtilityLibraryRow row = Seeded(); + LineupRecord lineup = row.ToLineup(); + + Assert.True(row.HasSeed()); + + Assert.Equal(11f, lineup.initial_position.x); + Assert.Equal(22f, lineup.initial_position.y); + Assert.Equal(33f, lineup.initial_position.z); + + Assert.Equal(400f, lineup.initial_velocity.x); + Assert.Equal(-500f, lineup.initial_velocity.y); + Assert.Equal(600f, lineup.initial_velocity.z); + + Assert.True(lineup.initial_velocity.Length() > 0f); + } + + // Half a seed is worse than none: a position without a velocity would put + // the replay's origin somewhere real and its aim at nothing. + [Theory] + [InlineData("initial_pos_z")] + [InlineData("initial_vel_x")] + [InlineData("initial_vel_y")] + [InlineData("initial_vel_z")] + public void HalfASeedIsNoSeed(string missing) + { + UtilityLibraryRow row = Seeded(); + + switch (missing) + { + case "initial_pos_z": + row.initial_pos_z = null; + break; + case "initial_vel_x": + row.initial_vel_x = null; + break; + case "initial_vel_y": + row.initial_vel_y = null; + break; + default: + row.initial_vel_z = null; + break; + } + + LineupRecord lineup = row.ToLineup(); + + Assert.False(row.HasSeed()); + Assert.Equal(0f, lineup.initial_velocity.Length()); + Assert.Equal(0f, lineup.initial_position.Length()); + } + + // A grenade never leaves the hand at rest, so all six columns present and + // the velocity zero is an unfilled row rather than a throw. Taking it would + // fire the replay out of the world origin. + [Fact] + public void AZeroedSeedIsNoSeed() + { + UtilityLibraryRow row = Seeded(); + row.initial_vel_x = 0f; + row.initial_vel_y = 0f; + row.initial_vel_z = 0f; + + LineupRecord lineup = row.ToLineup(); + + Assert.False(row.HasSeed()); + Assert.Equal(0f, lineup.initial_velocity.Length()); + Assert.Equal(0f, lineup.initial_position.Length()); + } + + // An oracle solver is only worth running if the seed it found comes back + // out of the panel able to reproduce the throw it found. + [Fact] + public void ASeedSurvivesTheRoundTripThroughBothShapes() + { + LineupRecord original = Lineup(); + UtilityLibraryRow row = Seeded(); + + row.initial_pos_x = original.initial_position.x; + row.initial_pos_y = original.initial_position.y; + row.initial_pos_z = original.initial_position.z; + row.initial_vel_x = original.initial_velocity.x; + row.initial_vel_y = original.initial_velocity.y; + row.initial_vel_z = original.initial_velocity.z; + + LineupRecord back = row.ToLineup(); + + Assert.Equal(original.initial_position.x, back.initial_position.x); + Assert.Equal(original.initial_position.y, back.initial_position.y); + Assert.Equal(original.initial_position.z, back.initial_position.z); + Assert.Equal(original.initial_velocity.x, back.initial_velocity.x); + Assert.Equal(original.initial_velocity.y, back.initial_velocity.y); + Assert.Equal(original.initial_velocity.z, back.initial_velocity.z); + } + + // A seed and an exact lineup are the same signal today and not the same + // statement: the panel stamps a plugin-recorded lineup "exact" whether or + // not it captured a seed, and a mined lineup that later acquires one is + // still a path fitted to a demo. Re-emit needs both. + [Fact] + public void ExactWithASeedIsExactlyReplayable() + { + LineupRecord lineup = Seeded("exact").ToLineup(); + + Assert.True(lineup.HasPhysicsSeed()); + Assert.True(lineup.IsExactlyReplayable()); + Assert.False(lineup.IsKnownInexact()); + } + + [Theory] + [InlineData("derived")] + [InlineData("low")] + public void ASeedOnAnInexactLineupIsNotReplayed(string confidence) + { + LineupRecord lineup = Seeded(confidence).ToLineup(); + + Assert.Equal(confidence, lineup.confidence); + Assert.True(lineup.HasPhysicsSeed()); + Assert.False(lineup.IsExactlyReplayable()); + Assert.True(lineup.IsKnownInexact()); + } + + // An older panel does not send the field at all. Defaulting that to exact + // would put the bug back on the deployments least able to spot it. + [Fact] + public void AMissingConfidenceIsNotExact() + { + LineupRecord lineup = Seeded(null).ToLineup(); + + Assert.Null(lineup.confidence); + Assert.True(lineup.HasPhysicsSeed()); + Assert.False(lineup.IsExactlyReplayable()); + } + + // Unknown is not the same as bad. Warning about every lineup an older panel + // returns would teach a player to ignore the warning that matters. + [Fact] + public void AMissingConfidenceIsNotWarnedAbout() + { + Assert.False(Seeded(null).ToLineup().IsKnownInexact()); + Assert.False(Row().ToLineup().IsKnownInexact()); + } + + [Fact] + public void ExactWithNoSeedIsStillNotReplayable() + { + UtilityLibraryRow row = Row(); + row.confidence = "exact"; + + LineupRecord lineup = row.ToLineup(); + + Assert.False(lineup.HasPhysicsSeed()); + Assert.False(lineup.IsExactlyReplayable()); + } + + [Fact] + public void ConfidenceIsMatchedWhateverItsCasing() + { + Assert.True(Seeded("Exact").ToLineup().IsExactlyReplayable()); + Assert.True(Seeded("EXACT").ToLineup().IsExactlyReplayable()); + } + + // A lineup recorded in this session was watched by the plugin, so + // PracticeRecorder stamps it exact as it finalizes -- without that stamp + // the gate below would refuse to replay the one kind of throw the plugin + // measured itself. + [Fact] + public void ALineupRecordedHereIsExactlyReplayable() + { + LineupRecord recorded = Lineup(); + + Assert.Equal(LineupRecord.Exact, recorded.confidence); + Assert.True(recorded.IsExactlyReplayable()); + } + + private static UtilityLibraryRow Seeded(string? confidence = null) + { + UtilityLibraryRow row = Row(); + + row.initial_pos_x = 11f; + row.initial_pos_y = 22f; + row.initial_pos_z = 33f; + row.initial_vel_x = 400f; + row.initial_vel_y = -500f; + row.initial_vel_z = 600f; + row.confidence = confidence; + + return row; + } + + [Fact] + public void APositionSurvivesTheRoundTripThroughBothShapes() + { + LineupRecord original = Lineup(); + UtilityIngestPayload payload = UtilityIngestPayload.From(original); + + var row = new UtilityLibraryRow + { + id = "panel-id", + name = payload.name, + utility_type = payload.utility_type, + side = payload.side, + technique = payload.technique, + throw_strength = payload.throw_strength, + jump_throw_bind = payload.jump_throw_bind, + origin_x = payload.origin_x, + origin_y = payload.origin_y, + origin_z = payload.origin_z, + eye_z = payload.eye_z, + view_yaw = payload.view_yaw, + view_pitch = payload.view_pitch, + land_x = payload.land_x, + land_y = payload.land_y, + land_z = payload.land_z, + flight_time_ms = payload.flight_time_ms, + }; + + LineupRecord back = row.ToLineup(); + + Assert.Equal(original.release.feet_position.x, back.release.feet_position.x); + Assert.Equal(original.release.feet_position.z, back.release.feet_position.z); + Assert.Equal(original.release.eye_position.z, back.release.eye_position.z); + Assert.Equal(original.release.yaw, back.release.yaw); + Assert.Equal(original.release.pitch, back.release.pitch); + Assert.Equal(original.detonation_position.y, back.detonation_position.y); + Assert.Equal(original.flight_time, back.flight_time); + Assert.Equal(original.utility_type, back.utility_type); + Assert.Equal(original.technique, back.technique); + } + + [Fact] + public void NullsAreOmittedRatherThanSentAsNull() + { + var bare = new LineupRecord { utility_type = "Smoke" }; + + string json = JsonSerializer.Serialize( + UtilityIngestPayload.From(bare), + PracticeJson.Options + ); + + Assert.DoesNotContain("null", json); + Assert.DoesNotContain("\"description\"", json); + Assert.DoesNotContain("\"match_id\"", json); + } +} + +public class UtilityIngestSeedTests +{ + private static LineupRecord Thrown() + { + return new LineupRecord + { + utility_type = "Smoke", + initial_position = new Vec3(100f, 200f, 64f), + initial_velocity = new Vec3(700f, -120f, 260f), + }; + } + + [Fact] + public void AThrownGrenadeCarriesItsPhysicsSeed() + { + UtilityIngestPayload payload = UtilityIngestPayload.From(Thrown()); + + Assert.Equal(100f, payload.initial_pos_x); + Assert.Equal(200f, payload.initial_pos_y); + Assert.Equal(64f, payload.initial_pos_z); + Assert.Equal(700f, payload.initial_vel_x); + Assert.Equal(-120f, payload.initial_vel_y); + Assert.Equal(260f, payload.initial_vel_z); + } + + [Fact] + public void ARecordWithNoSeedSendsNoneOfIt() + { + // The panel rejects a partial seed outright, and a struct default of + // (0,0,0) would otherwise be stored as a throw from the world origin. + UtilityIngestPayload payload = UtilityIngestPayload.From( + new LineupRecord { utility_type = "Smoke" } + ); + + Assert.Null(payload.initial_pos_x); + Assert.Null(payload.initial_pos_y); + Assert.Null(payload.initial_pos_z); + Assert.Null(payload.initial_vel_x); + Assert.Null(payload.initial_vel_y); + Assert.Null(payload.initial_vel_z); + } + + [Fact] + public void TheSeedSurvivesIngestAndComesBackOutOfTheLibrary() + { + UtilityIngestPayload sent = UtilityIngestPayload.From(Thrown()); + + // What the panel stores and hands back is the library row, so the + // round trip is only closed if it rebuilds the same seed. + LineupRecord back = new UtilityLibraryRow + { + utility_type = "Smoke", + initial_pos_x = sent.initial_pos_x, + initial_pos_y = sent.initial_pos_y, + initial_pos_z = sent.initial_pos_z, + initial_vel_x = sent.initial_vel_x, + initial_vel_y = sent.initial_vel_y, + initial_vel_z = sent.initial_vel_z, + }.ToLineup(); + + Assert.Equal(Thrown().initial_position.x, back.initial_position.x); + Assert.Equal(Thrown().initial_position.z, back.initial_position.z); + Assert.Equal(Thrown().initial_velocity.x, back.initial_velocity.x); + Assert.Equal(Thrown().initial_velocity.z, back.initial_velocity.z); + } +} diff --git a/apps/utility-sw/scripts/dev.sh b/apps/utility-sw/scripts/dev.sh new file mode 100755 index 00000000..fcba65ea --- /dev/null +++ b/apps/utility-sw/scripts/dev.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# +# Dev hot-reload loop. +# +# Runs inside the codepier "dev" container (dotnet SDK image) with the repo +# synced to /opt/5stack. It builds the plugin and copies the output into +# /opt/dev, which the server pod symlinks in as its FiveStack plugin +# (see scripts/setup.sh). SwiftlyS2 "AutoHotReload" (cfg/core.jsonc) then +# reloads the plugin when UtilityPractice.dll changes. +# +# Chain: dotnet watch build -> apps/utility-sw/src/build/net10.0 -> cp -> /opt/dev -> SwiftlyS2 reload + +log() { echo "[dev.sh $(date '+%H:%M:%S')] $*"; } + +PROJECT="/opt/5stack/apps/utility-sw/src/UtilityPractice.csproj" +BUILD_OUTPUT="/opt/5stack/apps/utility-sw/src/build/net10.0" + +# Write straight into the shared plugin-dir mount when the pod provides it +# (the game server's AutoHotReload watches that directory); otherwise the sw +# subfolder of the dev volume (css and sw dev builds share the volume). +PLUGIN_MOUNT="/opt/instance/game/csgo/addons/swiftlys2/plugins/UtilityPractice" +if mountpoint -q "$PLUGIN_MOUNT" 2>/dev/null; then + DEV_DIR="$PLUGIN_MOUNT" +else + DEV_DIR="/opt/dev/utility-sw" + mkdir -p "$DEV_DIR" +fi + +log "starting dev hot-reload" +log " project: $PROJECT" +log " build output: $BUILD_OUTPUT" +log " dev dir: $DEV_DIR" + +log "installing inotify-tools" +if apt-get update -qq && apt-get install -y -qq inotify-tools; then + log "inotify-tools ready" +else + log "ERROR: failed to install inotify-tools (are we root?) - aborting" + exit 1 +fi + +mkdir -p "$DEV_DIR" + +# Copy the current build output into /opt/dev. +sync_to_dev() { + if [ ! -d "$BUILD_OUTPUT" ]; then + log "WARNING: $BUILD_OUTPUT missing, nothing to copy (build failed?)" + return 1 + fi + if cp -r "$BUILD_OUTPUT"/. "$DEV_DIR"/ 2>/tmp/dev-cp.err; then + if [ -f "$DEV_DIR/UtilityPractice.dll" ]; then + log "synced -> $DEV_DIR (UtilityPractice.dll $(stat -c%s "$DEV_DIR/UtilityPractice.dll" 2>/dev/null || echo '?') bytes)" + else + log "WARNING: copied but $DEV_DIR/UtilityPractice.dll is missing" + fi + else + log "ERROR: copy to $DEV_DIR failed: $(cat /tmp/dev-cp.err)" + return 1 + fi +} + +# Variable to store the PID of dotnet watch process +dotnet_watch_pid="" + +# Function to kill the dotnet watch build process +kill_dotnet_watch() { + if [ -n "$dotnet_watch_pid" ]; then + log "stopping dotnet watch (pid $dotnet_watch_pid)" + kill "$dotnet_watch_pid" 2>/dev/null + fi +} +trap kill_dotnet_watch EXIT + +log "running initial build" +if dotnet build "$PROJECT"; then + log "initial build succeeded" + sync_to_dev +else + log "ERROR: initial build FAILED - watch will keep retrying on the next change" +fi + +log "starting 'dotnet watch build' in background" +dotnet watch build --project "$PROJECT" & +dotnet_watch_pid=$! +log "dotnet watch pid=$dotnet_watch_pid" + +# Wait for the output dir to appear (first successful build) before watching it, +# otherwise inotifywait errors immediately and spins. +until [ -d "$BUILD_OUTPUT" ]; do + log "waiting for $BUILD_OUTPUT to exist (build in progress / failing)..." + sleep 2 +done + +log "watching $BUILD_OUTPUT for changes" +while true; do + event=$(inotifywait -r -e modify,create,delete,move --format '%e %w%f' "$BUILD_OUTPUT" 2>/dev/null) + log "change detected: ${event:-}" + sync_to_dev +done diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs new file mode 100644 index 00000000..fbde364c --- /dev/null +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -0,0 +1,1144 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Commands; +using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.SchemaDefinitions; +using static SwiftlyS2.Shared.Helper; + +namespace UtilityPractice; + +// Registered unprefixed, so Swiftly exposes each verb as sw_ in the +// console and as "." in chat. Replies are always to the caller: a +// practice server is several people working on unrelated things in the same +// map. +public partial class UtilityPracticePlugin +{ + [Command("save", registerRaw: false, permission: "")] + public void OnSave(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + string name = string.Join(" ", context.Args).Trim().Trim('"'); + + if (string.IsNullOrEmpty(name)) + { + Reply(context, $" {ChatColors.Red}usage: .save "); + return; + } + + LineupRecord? thrown = _recorder.LastThrow(player.SteamID); + + if (thrown == null) + { + Reply(context, $" {ChatColors.Red}throw something first"); + return; + } + + if (_library.For(player.SteamID).Count >= _config.MaxSaved) + { + Reply( + context, + $" {ChatColors.Red}you already have {_config.MaxSaved} saved lineups on this map" + ); + return; + } + + thrown.name = name; + thrown.map = _library.Map; + thrown.side = player.Controller.Team == Team.CT ? "CT" : "TERRORIST"; + thrown.visibility = nameof(eLineupVisibility.Private); + thrown.plugin_version = ModuleVersion; + + _library.Add(player.SteamID, thrown); + + // A lineup you just saved is the lineup you are working on, so it + // becomes the loaded one and gets its markers straight away. Without + // this the library holds it but nothing on screen does, and it takes a + // .next or .prev -- which only walk results from an EARLIER query -- to + // make it appear. + PracticeState saved = _system.StateFor(player.SteamID); + + saved.Loaded = thrown; + saved.Results.Clear(); + saved.Results.Add(thrown); + saved.Index = 0; + + // Deliberately not the full .load: the player is already standing on + // the spot they just threw from, and teleporting them onto it would + // yank the view for no reason. + _replay.ShowMarkersFor(player, thrown); + + Reply(context, $" {ChatColors.Green}saved {ChatColors.Default}{name}"); + + ulong steamId = player.SteamID; + + _ = Task.Run(async () => + { + string? id = await _api.Ingest(thrown); + + Core.Scheduler.NextTick(() => + { + if (id != null) + { + thrown.id = id; + return; + } + + Tell(steamId, $" {ChatColors.Red}{name} could not reach the panel; it will retry"); + }); + }); + } + + [Command("load", registerRaw: false, permission: "")] + public void OnLoad(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + string query = string.Join(" ", context.Args).Trim().Trim('"'); + PracticeState state = _system.StateFor(player.SteamID); + Vec3? near = PracticeSystem.Where(player)?.feet_position; + + LineupRecord? lineup = _library.Resolve(player.SteamID, query, near); + + if (lineup == null) + { + // An empty library and a query that matches nothing are different + // problems, and saying "no lineup matches" for both sends people + // hunting for a typo when the library never loaded at all. + if (_library.For(player.SteamID).Count == 0) + { + Reply( + context, + $" {ChatColors.Red}no lineups loaded for this map. " + + $"{ChatColors.Default}fetching..." + ); + + ulong steamId = player.SteamID; + + _library.Refresh( + steamId, + count => + Tell( + steamId, + count < 0 + ? $" {ChatColors.Red}could not reach the library (check the server logs)" + : count == 0 + ? $" {ChatColors.Red}you have no lineups saved for this map" + : $" {ChatColors.Green}loaded {count} lineup(s) -- try .load again" + ) + ); + + return; + } + + Reply(context, $" {ChatColors.Red}no lineup matches \"{query}\""); + return; + } + + state.Results.Clear(); + state.Results.AddRange( + PracticeLineupUtility.Filter(_library.For(player.SteamID), query, near) + ); + state.Index = state.Results.FindIndex(match => match.client_id == lineup.client_id); + + Apply(player, lineup); + } + + [Command("list", registerRaw: false, permission: "")] + public void OnList(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + IReadOnlyList lineups = _library.For(player.SteamID); + + if (lineups.Count == 0) + { + Reply(context, $" {ChatColors.Grey}no saved lineups on {_library.Map}"); + return; + } + + Reply(context, $" {ChatColors.Green}{lineups.Count} lineups on {_library.Map}"); + + foreach (LineupRecord lineup in lineups) + { + Reply( + context, + $" {ChatColors.Default}{lineup.name} {ChatColors.Grey}({lineup.utility_type}, {lineup.technique})" + ); + } + } + + [Command("next", registerRaw: false, permission: "")] + public void OnNext(ICommandContext context) + { + Step(context, 1); + } + + [Command("prev", registerRaw: false, permission: "")] + public void OnPrev(ICommandContext context) + { + Step(context, -1); + } + + [Command("rethrow", registerRaw: false, permission: "")] + public void OnRethrow(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + LineupRecord? loaded = _system.StateFor(player.SteamID).Loaded; + + if (loaded == null) + { + Reply(context, $" {ChatColors.Red}nothing loaded"); + return; + } + + Apply(player, loaded); + } + + [Command("last", registerRaw: false, permission: "")] + public void OnLast(ICommandContext context) + { + Back(context, 0); + } + + [Command("back", registerRaw: false, permission: "")] + public void OnBack(ICommandContext context) + { + if (!int.TryParse(string.Join(" ", context.Args).Trim(), out int back) || back < 0) + { + Reply(context, $" {ChatColors.Red}usage: .back "); + return; + } + + Back(context, back); + } + + [Command("clear", registerRaw: false, permission: "")] + public void OnClear(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + state.Loaded = null; + state.Results.Clear(); + state.Index = -1; + state.Bloom = false; + + _replay.ClearGhosts(player.SteamID); + + // 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"); + } + + [Command("bloom", registerRaw: false, permission: "")] + public void OnBloom(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + LineupRecord? loaded = state.Loaded; + + if (loaded == null) + { + Reply(context, $" {ChatColors.Red}load a lineup first"); + return; + } + + if (!_config.GhostPreview) + { + Reply(context, $" {ChatColors.Red}previews are disabled on this server"); + return; + } + + state.Bloom = !state.Bloom; + + if (!state.Bloom) + { + _replay.ClearBloom(player.SteamID); + Reply(context, $" {ChatColors.Green}bloom off"); + return; + } + + Reply(context, $" {ChatColors.Grey}outlining {loaded.name}..."); + + ulong steamId = player.SteamID; + + // The same fetch .load already made, and free once it has landed. + _library.EnsureTrajectory(loaded, steamId, fetched => DrawBloom(steamId, fetched)); + } + + [Command("playbook", registerRaw: false, permission: "")] + public void OnPlaybook(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + string argument = string.Join(" ", context.Args).Trim().Trim('"'); + + if (argument.Equals("stop", StringComparison.OrdinalIgnoreCase)) + { + if (!_playbook.Stop()) + { + Reply(context, $" {ChatColors.Red}nothing is running"); + return; + } + + Core.PlayerManager.SendChat( + $" {ChatColors.Green}{player.Controller.PlayerName} stopped the execute".Colored() + ); + return; + } + + StartPlaybook(player, context); + } + + [Command("run", registerRaw: false, permission: "")] + public void OnRun(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + StartPlaybook(player, context); + } + + // The way out that does not need remembering which mode you are in. + [Command("cancel", registerRaw: false, permission: "")] + public void OnCancel(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + Reply( + context, + _drill.Stop(player.SteamID) + ? $" {ChatColors.Green}drill stopped" + : $" {ChatColors.Grey}nothing to cancel" + ); + } + + [Command("drill", registerRaw: false, permission: "")] + public void OnDrill(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + // A bare .drill while one is running ends it. Toggling off with the + // same word you started with is what a player reaches for first, and + // Stop is a no-op when there is nothing to stop, so this cannot + // swallow a genuine start. + if ( + string.IsNullOrWhiteSpace(string.Join(" ", context.Args)) + && _drill.Stop(player.SteamID) + ) + { + Reply(context, $" {ChatColors.Green}drill stopped"); + return; + } + + DrillRequest request = DrillUtility.Parse(string.Join(" ", context.Args)); + + if (!request.Valid) + { + Reply( + context, + $" {ChatColors.Red}usage: .drill [count] [worst|random] / .drill / .cancel" + ); + return; + } + + if (request.Stop) + { + if (!_drill.Stop(player.SteamID)) + { + Reply(context, $" {ChatColors.Red}you are not drilling"); + } + return; + } + + switch (_drill.Start(player.SteamID, request.Order, request.Count)) + { + case eDrillStart.AlreadyRunning: + Reply(context, $" {ChatColors.Red}already drilling; .drill stop first"); + return; + case eDrillStart.ReplayDisabled: + Reply(context, $" {ChatColors.Red}replay is disabled on this server"); + return; + case eDrillStart.NotConnected: + Reply( + context, + $" {ChatColors.Red}this server has no panel, so a throw cannot be scored" + ); + return; + case eDrillStart.NothingToDrill: + Reply( + context, + $" {ChatColors.Red}nothing on {_library.Map} to drill; save some lineups or .reload" + ); + return; + } + } + + [Command("skip", registerRaw: false, permission: "")] + public void OnSkip(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + if (!_drill.Skip(player.SteamID)) + { + Reply(context, $" {ChatColors.Red}you are not drilling"); + } + } + + // The other end of the throw. Loading a lineup puts you where it is thrown + // FROM; this puts you where it lands, which is the only way to see what the + // smoke actually covers without throwing it and running. + [Command("jump", registerRaw: false, permission: "")] + public void OnJump(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + LineupRecord? lineup = _system.StateFor(player.SteamID).Loaded; + + if (lineup == null) + { + Reply(context, $" {ChatColors.Grey}load a lineup first"); + return; + } + + if (!_replay.JumpToLanding(player, lineup)) + { + Reply(context, $" {ChatColors.Red}could not move you there"); + return; + } + + Reply( + context, + $" {ChatColors.Green}moved to where {ChatColors.Default}{lineup.name}" + + $" {ChatColors.Green}lands" + ); + } + + [Command("pos", registerRaw: false, permission: "")] + public void OnPos(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + string[] args = context + .Args.Select(argument => argument.Trim()) + .Where(argument => argument.Length > 0) + .ToArray(); + + if (args.Length == 0) + { + if (state.Positions.Count == 0) + { + Reply(context, $" {ChatColors.Grey}no saved positions"); + return; + } + + Reply( + context, + $" {ChatColors.Green}positions: {ChatColors.Default}{string.Join(", ", state.Positions.Keys)}" + ); + return; + } + + if (args[0].Equals("save", StringComparison.OrdinalIgnoreCase)) + { + if (args.Length < 2) + { + Reply(context, $" {ChatColors.Red}usage: .pos save "); + return; + } + + if (!_system.SavePosition(player, args[1])) + { + Reply(context, $" {ChatColors.Red}unable to save that position"); + return; + } + + Reply(context, $" {ChatColors.Green}saved position {ChatColors.Default}{args[1]}"); + return; + } + + if (!state.Positions.TryGetValue(args[0], out ThrowSnapshot? position)) + { + Reply(context, $" {ChatColors.Red}no position named {args[0]}"); + return; + } + + PracticeSystem.TeleportTo(player, position); + } + + [Command("spawn", registerRaw: false, permission: "")] + public void OnSpawn(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + List spawns = _system.SpawnPoints(); + + if (spawns.Count == 0) + { + Reply(context, $" {ChatColors.Red}this map has no spawn points"); + return; + } + + if (!int.TryParse(string.Join(" ", context.Args).Trim(), out int index)) + { + Reply(context, $" {ChatColors.Red}usage: .spawn <1-{spawns.Count}>"); + return; + } + + index = Math.Clamp(index, 1, spawns.Count); + + PracticeSystem.TeleportTo(player, spawns[index - 1]); + Reply(context, $" {ChatColors.Green}spawn {index}/{spawns.Count}"); + } + + [Command("noclip", registerRaw: false, permission: "")] + public void OnNoclip(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + state.Noclip = !state.Noclip; + + Reply(context, $" {ChatColors.Green}noclip {Toggle(state.Noclip)}"); + } + + [Command("god", registerRaw: false, permission: "")] + public void OnGod(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + state.God = !state.God; + + Reply(context, $" {ChatColors.Green}god {Toggle(state.God)}"); + } + + [Command("timer", registerRaw: false, permission: "")] + public void OnTimer(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + + if (state.TimerStartedAt == null) + { + state.TimerStartedAt = DateTime.UtcNow; + Reply(context, $" {ChatColors.Green}timer started"); + return; + } + + double elapsed = (DateTime.UtcNow - state.TimerStartedAt.Value).TotalSeconds; + state.TimerStartedAt = null; + + Reply(context, $" {ChatColors.Green}timer stopped at {elapsed:0.00}s"); + } + + [Command("solo", registerRaw: false, permission: "")] + public void OnSolo(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + state.Solo = !state.Solo; + + Reply( + context, + state.Solo + ? $" {ChatColors.Green}solo on {ChatColors.Grey}(you only see your own previews)" + : $" {ChatColors.Green}solo off {ChatColors.Grey}(you see everyone's previews)" + ); + } + + [Command("delete", registerRaw: false, permission: "")] + public void OnDelete(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + LineupRecord? loaded = state.Loaded; + + if (loaded == null) + { + Reply(context, $" {ChatColors.Red}load a lineup first"); + return; + } + + _library.Remove(player.SteamID, loaded); + state.Results.RemoveAll(match => match.client_id == loaded.client_id); + state.Loaded = null; + _replay.ClearGhosts(player.SteamID); + + Reply(context, $" {ChatColors.Green}deleted {ChatColors.Default}{loaded.name}"); + + if (loaded.id == null) + { + return; + } + + string id = loaded.id; + ulong steamId = player.SteamID; + + _ = Task.Run(async () => + { + bool deleted = await _api.Delete(id); + + if (deleted) + { + return; + } + + Core.Scheduler.NextTick(() => + Tell(steamId, $" {ChatColors.Red}{loaded.name} is still on the panel; try .reload") + ); + }); + } + + [Command("reload", registerRaw: false, permission: "")] + public void OnReload(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + ulong steamId = player.SteamID; + + Reply(context, $" {ChatColors.Grey}reloading..."); + + // The library about to be replaced is the one these markers came from. + PracticeState reloading = _system.StateFor(steamId); + + reloading.Loaded = null; + reloading.Results.Clear(); + reloading.Index = -1; + + _replay.ClearGhosts(steamId); + _replay.ClearMarkers(); + + _library.Refresh( + steamId, + count => + { + Tell( + steamId, + count < 0 + ? $" {ChatColors.Red}the panel did not answer" + : $" {ChatColors.Green}{count} lineups on {_library.Map}" + ); + } + ); + } + + [Command("help", registerRaw: false, permission: "")] + public void OnPracticeHelp(ICommandContext context) + { + if (context.Sender == null) + { + return; + } + + foreach (string line in HelpLines) + { + Reply(context, line); + } + } + + // Server-only, like utility_practice_refresh below: the panel sends this + // over RCON when somebody presses "load me in" on the website, so the + // command has to name the player rather than being spoken by them. + [Command("utility_practice_load", registerRaw: true, permission: "")] + public void OnRemoteLoad(ICommandContext context) + { + if (context.IsSentByPlayer) + { + return; + } + + string[] args = context.Args.ToArray(); + + if (args.Length < 2 || !ulong.TryParse(args[0].Trim(), out ulong steamId)) + { + Reply(context, "usage: utility_practice_load "); + return; + } + + string lineupId = args[1].Trim().Trim('"'); + + if (string.IsNullOrEmpty(lineupId)) + { + Reply(context, "usage: utility_practice_load "); + return; + } + + RemoteLoad(steamId, lineupId, refreshed: false); + } + + // The drill twin of utility_practice_load: the panel names a set of lineups + // and the run is built from exactly those, in the order they arrived. + [Command("utility_practice_drill", registerRaw: true, permission: "")] + public void OnRemoteDrill(ICommandContext context) + { + if (context.IsSentByPlayer) + { + return; + } + + string[] args = context.Args.ToArray(); + + if (args.Length < 2 || !ulong.TryParse(args[0].Trim(), out ulong steamId)) + { + Reply(context, "usage: utility_practice_drill "); + return; + } + + string[] ids = args[1] + .Trim() + .Trim('"') + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (ids.Length == 0) + { + Reply(context, "usage: utility_practice_drill "); + return; + } + + RemoteDrill(steamId, ids, refreshed: false); + } + + private void RemoteDrill(ulong steamId, string[] ids, bool refreshed) + { + IPlayer? player = _system.Find(steamId); + + if (player == null || !player.IsValid) + { + return; + } + + IReadOnlyList library = _library.For(steamId); + + List queue = ids.Select(id => + PracticeLineupUtility.ById(library, id) + ) + .OfType() + .ToList(); + + // Same reasoning as RemoteLoad: the panel sends lineups this server has + // never cached, so one refresh before giving up. All or nothing -- + // drilling half a set silently would be worse than saying no. + if (queue.Count < ids.Length && !refreshed) + { + _library.Refresh(steamId, _ => RemoteDrill(steamId, ids, refreshed: true)); + return; + } + + if (queue.Count == 0) + { + Tell(steamId, $" {ChatColors.Red}none of those lineups are on this server"); + return; + } + + switch (_drill.StartWith(steamId, queue)) + { + case eDrillStart.AlreadyRunning: + Tell(steamId, $" {ChatColors.Red}already drilling; .cancel first"); + return; + case eDrillStart.ReplayDisabled: + Tell(steamId, $" {ChatColors.Red}replay is disabled on this server"); + return; + case eDrillStart.NotConnected: + Tell(steamId, $" {ChatColors.Red}this server has no panel to score throws"); + return; + case eDrillStart.NothingToDrill: + Tell(steamId, $" {ChatColors.Red}nothing to drill"); + return; + } + } + + private void RemoteLoad(ulong steamId, string lineupId, bool refreshed) + { + IPlayer? player = _system.Find(steamId); + + if (player == null || !player.IsValid) + { + return; + } + + LineupRecord? lineup = PracticeLineupUtility.ById(_library.For(steamId), lineupId); + + if (lineup != null) + { + Apply(player, lineup); + return; + } + + // Not in the cached library. That is the normal case rather than an + // error: the panel sends lineups this player has never loaded here -- + // a scratch throw off the meta browser, or one saved on another + // device -- and the cache is only refreshed on demand. One refresh, + // then give up; retrying past that would hammer the panel every time + // somebody sends a lineup that really is gone. + if (refreshed) + { + Tell(steamId, $" {ChatColors.Red}that lineup is not available on this server"); + return; + } + + _library.Refresh(steamId, _ => RemoteLoad(steamId, lineupId, refreshed: true)); + } + + // Server-only and deliberately unprefixed, like the match plugin's + // get_match: the panel calls it when the roster or the library changes. + [Command("utility_practice_refresh", registerRaw: true, permission: "")] + public void OnRefresh(ICommandContext context) + { + if (context.IsSentByPlayer) + { + return; + } + + RefreshEverything(); + } + + private const float WelcomeDelaySeconds = 2f; + + // Deliberately short. The full list is sixteen lines and reads as spam on + // every join; these are the four that get somebody throwing, and .help is + // where the rest lives. + private static readonly string[] WelcomeLines = new[] + { + $" {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}.help {ChatColors.Grey}everything else", + }; + + private static readonly string[] HelpLines = new[] + { + $" {ChatColors.Green}utility practice", + $" {ChatColors.Default}.save {ChatColors.Grey}saves your last throw", + $" {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}.last / .back {ChatColors.Grey}back to a throw you made", + $" {ChatColors.Default}.list / .reload / .delete {ChatColors.Grey}manage your library", + $" {ChatColors.Default}.pos save / .pos {ChatColors.Grey}saved positions", + $" {ChatColors.Default}.spawn {ChatColors.Grey}teleports to a spawn point", + $" {ChatColors.Default}.bloom {ChatColors.Grey}outlines where the loaded smoke lands", + $" {ChatColors.Default}.solve [name] {ChatColors.Grey}finds a throw onto the spot you are looking at", + $" {ChatColors.Default}.drill [count] [worst] / .skip {ChatColors.Grey}drills your book and scores it", + $" {ChatColors.Default}.drill / .cancel {ChatColors.Grey}stops a drill you are in", + $" {ChatColors.Default}.playbook / .run / .playbook stop {ChatColors.Grey}the loaded execute", + $" {ChatColors.Default}.noclip / .god / .timer / .solo / .clear", + }; + + private void StartPlaybook(IPlayer player, ICommandContext context) + { + switch (_playbook.Start(_library.Map)) + { + case ePlaybookStart.NoPlaybook: + Reply(context, $" {ChatColors.Red}no execute is loaded on this session"); + return; + case ePlaybookStart.NoSteps: + Reply(context, $" {ChatColors.Red}that execute has no steps"); + return; + case ePlaybookStart.WrongMap: + Reply(context, $" {ChatColors.Red}that execute is for another map"); + return; + case ePlaybookStart.AlreadyRunning: + Reply(context, $" {ChatColors.Red}already running; .playbook stop first"); + return; + } + + IReadOnlyList steps = _playbook.Steps; + + Core.PlayerManager.SendChat( + $" {ChatColors.Green}{player.Controller.PlayerName} started {ChatColors.Default}{_playbook.Loaded?.name} {ChatColors.Grey}({steps.Count} steps)".Colored() + ); + + for (int index = 0; index < steps.Count; index++) + { + UtilityPlaybookStep step = steps[index]; + string who = PlaybookUtility.IsAssigned(step) ? step.assigned_steam_id! : "anyone"; + + Reply( + context, + $" {ChatColors.Grey}{index + 1}. {step.offset_ms / 1000f:0.0}s {ChatColors.Default}{step.lineup?.name} {ChatColors.Grey}{who}" + ); + } + } + + // A mined lineup's stance and aim are fitted to the flight the demo + // recorded, which puts them a degree or two out. That is close enough to + // practise toward and not close enough to trust, so the player is told + // rather than left reading it as a precise alignment. + private void WarnIfInexact(IPlayer player, LineupRecord lineup) + { + if (!lineup.IsKnownInexact()) + { + return; + } + + if (!_system.StateFor(player.SteamID).WarnedInexact.Add(lineup.client_id)) + { + return; + } + + player.SendChat( + $" {ChatColors.Yellow}{lineup.name} is {lineup.confidence}, not measured {ChatColors.Grey}- the aim is inferred to a degree or two, so walk it in".Colored() + ); + } + + // The measurement rides along with the flight path, so the outline cannot + // be drawn until that fetch has landed. + private void DrawBloom(ulong steamId, LineupRecord fetched) + { + PracticeState state = _system.StateFor(steamId); + + if (!state.Bloom || state.Loaded != fetched) + { + return; + } + + IPlayer? player = _system.Find(steamId); + + if (player == null || !player.IsValid) + { + return; + } + + int beams = _replay.ShowBloom(player, fetched); + + // A real smoke is the measurement itself rather than a drawing of it, + // so Swiftly shows both: the outline is there instantly, the cloud + // fills it in a second later. + bool smoke = _replay.ShowBloomSmoke(player, fetched); + + if (beams == 0 && !smoke) + { + Tell(steamId, $" {ChatColors.Grey}no measured bloom for {fetched.name}"); + return; + } + + Tell(steamId, $" {ChatColors.Green}bloom on {ChatColors.Grey}({beams} lines)"); + } + + private void Step(ICommandContext context, int direction) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + + if (state.Results.Count == 0) + { + Reply(context, $" {ChatColors.Red}load something first"); + return; + } + + state.Index = + ((state.Index + direction) % state.Results.Count + state.Results.Count) + % state.Results.Count; + + Apply(player, state.Results[state.Index]); + } + + private void Back(ICommandContext context, int back) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + LineupRecord? thrown = _recorder.LastThrow(player.SteamID, back); + + if (thrown == null) + { + Reply(context, $" {ChatColors.Red}no throw that far back"); + return; + } + + Apply(player, thrown); + } + + private void Apply(IPlayer player, LineupRecord lineup) + { + if (!_config.ReplayEnabled) + { + player.SendChat($" {ChatColors.Red}replay is disabled on this server".Colored()); + return; + } + + _system.StateFor(player.SteamID).Loaded = lineup; + + // 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. + _replay.Load(player, lineup); + _replay.ThrowGhostProjectile(player, lineup); + WarnIfInexact(player, lineup); + + ulong steamId = player.SteamID; + + _library.EnsureTrajectory( + lineup, + steamId, + fetched => + { + // The player may have loaded something else while the path was + // in flight; drawing it now would replace what they are looking + // at with the previous lineup. + if (_system.StateFor(steamId).Loaded != fetched) + { + return; + } + + IPlayer? still = _system.Find(steamId); + + if (still != null && still.IsValid) + { + } + + DrawBloom(steamId, fetched); + } + ); + } + + private static void Reply(ICommandContext context, string message) + { + context.Reply(message.Colored()); + } + + private static string Toggle(bool on) + { + return on ? "on" : "off"; + } + + private void Tell(ulong steamId, string message) + { + IPlayer? player = _system.Find(steamId); + + if (player == null || !player.IsValid) + { + return; + } + + player.SendChat(message.Colored()); + } +} diff --git a/apps/utility-sw/src/Commands/Solver.cs b/apps/utility-sw/src/Commands/Solver.cs new file mode 100644 index 00000000..2c757f4a --- /dev/null +++ b/apps/utility-sw/src/Commands/Solver.cs @@ -0,0 +1,473 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Commands; +using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.SchemaDefinitions; +using SwiftlyS2.Shared.Trace; +using static SwiftlyS2.Shared.Helper; + +namespace UtilityPractice; + +// The solver's surface: one verb for the panel to drive over RCON, one for a +// player standing where they want to throw from, and one to inspect the gate +// without spending three hundred grenades finding out it is shut. +public partial class UtilityPracticePlugin +{ + [Command("utility_solver_calibrate", registerRaw: true, permission: "")] + public void OnSolverCalibrate(ICommandContext context) + { + bool force = context.Args.Any(argument => + argument.Equals("force", StringComparison.OrdinalIgnoreCase) + ); + + string map = _library.Map; + CalibrationReport? cached = _solver.CalibrationFor(map); + + if (cached != null && !force && !ShouldRetry(cached)) + { + ReportCalibration(context, cached, cached: true); + return; + } + + if (force) + { + _solver.Forget(map); + } + + Reply(context, $" {ChatColors.Grey}calibrating the solver on {map}..."); + + if (!_solver.Calibrate(map, CalibrationSamples(), report => ReportCalibration(context, report, cached: false))) + { + Reply(context, $" {ChatColors.Red}{_solver.BusyWith} is already running"); + } + } + + // Named arguments, because this one is called by a machine over RCON where + // a positional list quietly solves for the wrong point. + [Command("utility_solver_solve", registerRaw: true, permission: "")] + public void OnSolverSolve(ICommandContext context) + { + if ( + context.Args.Length == 1 + && context.Args[0].Equals("cancel", StringComparison.OrdinalIgnoreCase) + ) + { + Reply( + context, + _solver.Cancel() + ? $" {ChatColors.Green}solve cancelled" + : $" {ChatColors.Red}nothing is solving" + ); + return; + } + + if (!PracticeSolverUtility.TryParse(context.Args, out SolveRequest request, out string error)) + { + Reply(context, $" {ChatColors.Red}{error}"); + Reply( + context, + $" {ChatColors.Grey}usage: utility_solver_solve target=x,y,z [from=x,y,z] [utility=Smoke] [steam=id] [name=...] [tolerance=40] [grenades=300] [seconds=120]" + ); + return; + } + + if (request.eye.Length() <= 0f) + { + IPlayer? sender = context.Sender; + ThrowSnapshot? standing = sender == null ? null : PracticeSystem.Where(sender); + + if (sender == null || standing == null) + { + Reply( + context, + $" {ChatColors.Red}from=x,y,z is required when the caller is not standing in the map" + ); + return; + } + + request.feet = standing.feet_position; + request.eye = EyeOf(sender) ?? standing.feet_position; + + if (string.IsNullOrEmpty(request.requested_by)) + { + request.requested_by = sender.SteamID.ToString(); + } + } + + Begin(context, request); + } + + [Command("solve", registerRaw: false, permission: "")] + public void OnSolve(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + string argument = string.Join(" ", context.Args).Trim().Trim('"'); + + if (argument.Equals("stop", StringComparison.OrdinalIgnoreCase)) + { + Reply( + context, + _solver.Cancel() + ? $" {ChatColors.Green}solve cancelled" + : $" {ChatColors.Red}nothing is solving" + ); + return; + } + + Vec3? target = AimPoint(player); + + if (target == null) + { + Reply(context, $" {ChatColors.Red}look at the spot you want it to land on"); + return; + } + + ThrowSnapshot? standing = PracticeSystem.Where(player); + Vec3? eye = EyeOf(player); + + if (standing == null || eye == null) + { + Reply(context, $" {ChatColors.Red}stand somewhere first"); + return; + } + + string utility = UtilityInHand(player); + + var request = new SolveRequest + { + map = _library.Map, + utility_type = utility, + side = player.Controller.Team == Team.CT ? "CT" : "TERRORIST", + name = argument.Length > 0 ? argument : $"solved {utility.ToLowerInvariant()}", + target = target.Value, + eye = eye.Value, + feet = standing.feet_position, + requested_by = player.SteamID.ToString(), + }; + + PracticeSolverUtility.Defaults(request); + + Reply( + context, + $" {ChatColors.Grey}solving a {utility.ToLowerInvariant()} onto {Point(target.Value)}..." + ); + + Begin(context, request); + } + + // Nothing is emitted until the gate says the engine reproduces a seeded + // throw on this map. The refusal is the feature: three hundred grenades + // against a false premise produce lineups that look right and are not. + private void Begin(ICommandContext context, SolveRequest request) + { + if (_solver.IsBusy) + { + Reply(context, $" {ChatColors.Red}{_solver.BusyWith} is already running"); + return; + } + + if (string.IsNullOrEmpty(request.map)) + { + request.map = _library.Map; + } + + string map = request.map; + CalibrationReport? cached = _solver.CalibrationFor(map); + + if (cached != null && !ShouldRetry(cached)) + { + if (!cached.CanSolve()) + { + Refuse(context, cached); + return; + } + + Launch(context, request, cached); + return; + } + + Reply(context, $" {ChatColors.Grey}calibrating the solver on {map} first..."); + + bool started = _solver.Calibrate( + map, + CalibrationSamples(), + report => + { + if (!report.CanSolve()) + { + Refuse(context, report); + return; + } + + Reply(context, $" {ChatColors.Green}calibrated: {report.message}"); + Launch(context, request, report); + } + ); + + if (!started) + { + Reply(context, $" {ChatColors.Red}{_solver.BusyWith} is already running"); + } + } + + private void Launch(ICommandContext context, SolveRequest request, CalibrationReport report) + { + ulong steamId = ulong.TryParse(request.requested_by, out ulong parsed) ? parsed : 0; + + bool started = _solver.Start( + request, + report, + progress => Say(context, steamId, $" {ChatColors.Grey}{progress}"), + (result, lineup) => Finished(context, request, steamId, result, lineup) + ); + + if (!started) + { + Reply(context, $" {ChatColors.Red}{_solver.BusyWith} is already running"); + return; + } + + Reply( + context, + $" {ChatColors.Green}solving {ChatColors.Default}{request.utility_type} {ChatColors.Grey}(up to {request.max_grenades} grenades / {request.max_seconds:0}s, within {request.tolerance:0}u)" + ); + } + + private void Finished( + ICommandContext context, + SolveRequest request, + ulong steamId, + SolveResult result, + LineupRecord? lineup + ) + { + if (lineup == null) + { + Say( + context, + steamId, + $" {ChatColors.Red}no throw found {ChatColors.Grey}({result.outcome}: {result.message})" + ); + return; + } + + lineup.plugin_version = ModuleVersion; + + Say( + context, + steamId, + $" {ChatColors.Green}solved {ChatColors.Default}{lineup.name} {ChatColors.Grey}{result.message}" + ); + Say( + context, + steamId, + $" {ChatColors.Grey}stand at {Point(lineup.release.feet_position)}, look {lineup.release.yaw:0.0} / {lineup.release.pitch:0.0}, {lineup.strength?.ToLowerInvariant()} throw" + ); + + // A lineup with no author is one the panel has nowhere to file. The + // throw is still worth reporting -- an operator solving over RCON to see + // whether a spot is reachable does not always want it saved. + if (steamId == 0) + { + Say( + context, + steamId, + $" {ChatColors.Grey}not saved: pass steam= to file it against an author" + ); + return; + } + + _library.Add(steamId, lineup); + + IPlayer? player = _system.Find(steamId); + + if (player != null && player.IsValid) + { + Apply(player, lineup); + } + + // The same hand-off .save makes, so a solved lineup reaches the panel by + // the one path that already retries. + _ = Task.Run(async () => + { + string? id = await _api.Ingest(lineup); + + Core.Scheduler.NextTick(() => + { + if (id != null) + { + lineup.id = id; + return; + } + + Tell( + steamId, + $" {ChatColors.Red}{lineup.name} could not reach the panel; it will retry" + ); + }); + }); + } + + private void Refuse(ICommandContext context, CalibrationReport report) + { + Reply(context, $" {ChatColors.Red}refusing to solve: {report.message}"); + Reply(context, $" {ChatColors.Grey}{Detail(report)}"); + } + + private void ReportCalibration( + ICommandContext context, + CalibrationReport report, + bool cached + ) + { + string suffix = cached ? " (cached)" : ""; + + Reply( + context, + report.CanSolve() + ? $" {ChatColors.Green}{report.map}: ready{suffix} {ChatColors.Grey}{report.message}" + : $" {ChatColors.Red}{report.map}: {report.status}{suffix} {ChatColors.Grey}{report.message}" + ); + Reply(context, $" {ChatColors.Grey}{Detail(report)}"); + + foreach (LaunchCheck check in report.launch_checks) + { + Reply( + context, + $" {ChatColors.Grey} {check.strength} @ {check.pitch:0.0} pitch: {check.position_error:0.00}u, {check.direction_error:0.000} deg, {check.speed_ratio:0.000}x {(check.passed ? "ok" : "FAILED")}" + ); + } + } + + private static string Detail(CalibrationReport report) + { + string corrections = + report.speed_corrections.Count == 0 + ? "none" + : string.Join( + ", ", + report.speed_corrections.Select(pair => $"{pair.Key} {pair.Value:0.000}x") + ); + + string replay = + report.seed_replay_error < 0f + ? "not run" + : $"{report.seed_replay_error:0.0}u"; + + return $"{report.launch_checks.Count} samples, worst {report.WorstPositionError():0.00}u / {report.WorstDirectionError():0.000} deg, speed {corrections}, seed replay {replay}"; + } + + // Only throws this session can calibrate. A lineup from the panel carries a + // seed but not the stance or the release strength behind it, so it can say + // nothing about whether the launch model is right -- and a sample that + // cannot fail is not a check. + private List CalibrationSamples() + { + var samples = new List(); + + foreach (ulong steamId in _system.ConnectedSteamIds()) + { + samples.AddRange(_recorder.HistoryFor(steamId)); + } + + return samples; + } + + // A missing sample is the one failure that fixes itself: somebody throws a + // grenade and the answer changes. Everything else is a property of the + // build and stays cached until the map or the plugin changes. + private static bool ShouldRetry(CalibrationReport report) + { + return report.status == nameof(eCalibrationStatus.NoSample); + } + + private Vec3? AimPoint(IPlayer player) + { + CCSPlayerPawn? pawn = player.PlayerPawn; + Vec3? eye = EyeOf(player); + + if (pawn == null || !pawn.IsValid || eye == null) + { + return null; + } + + var start = new Vector(eye.Value.x, eye.Value.y, eye.Value.z); + QAngle angles = pawn.EyeAngles; + + // The trace starts inside the thrower's own bounding box, so the pawn + // has to be excluded or the crosshair resolves to the player's chest. + TraceParams options = TraceParams + .Builder() + .WithLineRay() + .IgnoreEntities(new[] { (CEntityInstance)pawn }) + .Build(); + + TraceResult trace = Core.Trace.TraceShapeAngle(in start, in angles, 8192f, options); + + if (!trace.DidHit) + { + return null; + } + + Vector hit = trace.HitPoint; + + return new Vec3(hit.X, hit.Y, hit.Z); + } + + private static Vec3? EyeOf(IPlayer player) + { + CCSPlayerPawn? pawn = player.PlayerPawn; + Vector? origin = pawn?.AbsOrigin; + + if (pawn == null || !pawn.IsValid || origin == null) + { + return null; + } + + return new Vec3( + origin.Value.X, + origin.Value.Y, + origin.Value.Z + pawn.ViewOffset.Z.Value + ); + } + + private static string UtilityInHand(IPlayer player) + { + CBasePlayerWeapon? active = player.PlayerPawn?.WeaponServices?.ActiveWeapon.Value; + + if (active == null || !active.IsValid) + { + return nameof(eUtilityType.Smoke); + } + + return PracticeLineupUtility.UtilityTypeForWeapon(active.DesignerName ?? "") + ?? nameof(eUtilityType.Smoke); + } + + private static string Point(Vec3 point) + { + return $"{point.x:0} {point.y:0} {point.z:0}"; + } + + // A solve outlives the command that started it, and an RCON caller is long + // gone by the time it lands. Whoever is still there hears about it. + private void Say(ICommandContext context, ulong steamId, string message) + { + if (steamId != 0 && _system.Find(steamId) != null) + { + Tell(steamId, message); + return; + } + + Reply(context, message); + } +} diff --git a/apps/utility-sw/src/Events/PracticeConnect.cs b/apps/utility-sw/src/Events/PracticeConnect.cs new file mode 100644 index 00000000..aa61f10e --- /dev/null +++ b/apps/utility-sw/src/Events/PracticeConnect.cs @@ -0,0 +1,235 @@ +using System.Runtime.InteropServices; +using System.Text; +using FiveStack.Enums; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared.Memory; + +namespace UtilityPractice; + +[UnmanagedFunctionPointer(CallingConvention.Cdecl)] +public delegate nint ConnectClientDelegate( + nint param1, + nint param2, + nint param3, + nint param4, + nint param5, + nint param6, + nint param7, + int param8, + bool param9 +); + +// A practice server is not public. It never loads the match plugin, so the +// door is here: the same ConnectClient hook the match plugin uses, deciding +// against the practice session's roster instead of a match lineup. +public partial class UtilityPracticePlugin +{ + private static int PasswordBufferLength = 86; + public static nint PasswordBuffer { get; set; } = nint.Zero; + public static Dictionary PendingPlayers = new(); + + /** + * Signature near: + * "CNetworkGameServerBase::ConnectClient( name='%s', remote='%s' )\n" + * + * Function signature: + *
+     * virtual CServerSideClientBase* CNetworkGameServerBase::ConnectClient(
+     *     const char* name,
+     *     ns_address* address,
+     *     void* netInfo,
+     *     C2S_CONNECT_Message* connectMsg,
+     *     const char* password,
+     *     const byte* authTicket,
+     *     int authTicketLength,
+     *     bool isLowViolence
+     * );
+     * 
+ */ + private static string ConnectClientSignature = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ? "55 48 89 E5 41 57 49 89 D7 41 56 41 89 CE 41 55 41 54 49 89 F4 53 48 89 FB 48 81 EC ? ? ? ?" + : "48 89 5C 24 18 44 89 4C 24 20 55 41 54 41 55 41 56 41 57 48 8D 6C 24 F1 48 81 EC ? ? ? ? 81 64 24 54 FF FF 0F FF"; + + private IUnmanagedFunction? _connectClientFunc; + private Guid _connectClientHookId; + + private void InitializeConnectClientHook() + { + try + { + if (_connectClientFunc != null) + { + return; + } + + var address = Core.Memory.GetAddressBySignature(Library.Engine, ConnectClientSignature); + + if (address == null || address == nint.Zero) + { + _logger.LogWarning("Failed to find ConnectClient signature"); + return; + } + + _connectClientFunc = Core.Memory.GetUnmanagedFunctionByAddress( + address.Value + ); + + if (_connectClientFunc == null) + { + _logger.LogWarning("Failed to get unmanaged function for ConnectClient"); + return; + } + + _logger.LogInformation("ConnectClient hook installed"); + + _connectClientHookId = _connectClientFunc.AddHook( + (next) => + { + return ( + nint param1, + nint param2, + nint param3, + nint param4, + nint param5, + nint param6, + nint param7, + int param8, + bool param9 + ) => + { + var token = Marshal.PtrToStringUTF8(param6); + + ulong steamId = 0; + unsafe + { + if (param7 != nint.Zero && param8 >= 8) + { + var authTicket = new Span((byte*)param7, param8); + steamId = MemoryMarshal.Read(authTicket[..8]); + } + } + + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + _session.Current, + steamId, + token + ); + + if (decision.pending_role != null) + { + PendingPlayers[steamId] = decision.pending_role; + } + + // Never the token itself -- it is the server password. + // Everything else about the decision, because a connect + // that fails silently is the hardest thing here to + // diagnose from the outside. + _logger.LogInformation( + "connect {steamId}: {action} (token: {hasToken}, roster: {roster}, password ready: {ready})", + steamId, + decision.action, + token != null, + _session.Current?.allowed_steam_ids.Count ?? -1, + PasswordBuffer != nint.Zero + ); + + if ( + decision.action == ePracticeConnect.Authorized + && PasswordBuffer != nint.Zero + ) + { + return next()( + param1, + param2, + param3, + param4, + param5, + PasswordBuffer, + param7, + param8, + param9 + ); + } + + if (decision.action == ePracticeConnect.Reject) + { + return next()( + param1, + param2, + param3, + param4, + param5, + param6, + nint.Zero, + 0, + param9 + ); + } + + return next()( + param1, + param2, + param3, + param4, + param5, + param6, + param7, + param8, + param9 + ); + }; + } + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize ConnectClient hook"); + } + } + + private void UninstallConnectClientHook() + { + try + { + if (_connectClientFunc != null && _connectClientHookId != Guid.Empty) + { + _connectClientFunc.RemoveHook(_connectClientHookId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to remove ConnectClient hook"); + } + + _connectClientFunc = null; + _connectClientHookId = Guid.Empty; + + if (PasswordBuffer != nint.Zero) + { + Marshal.FreeCoTaskMem(PasswordBuffer); + PasswordBuffer = nint.Zero; + } + } + + public static void SetPasswordBuffer(string password) + { + if (PasswordBuffer == nint.Zero) + { + PasswordBuffer = Marshal.StringToCoTaskMemUTF8(new string('\0', PasswordBufferLength)); + } + + StrCpy(PasswordBuffer, password); + } + + private static unsafe void StrCpy(nint dst, string src) + { + Span buffer = stackalloc byte[PasswordBufferLength]; + + int length = Encoding.UTF8.GetBytes(src, buffer[..(buffer.Length - 1)]); + buffer[length] = (byte)'\0'; + + var dstBuffer = new Span((byte*)dst, PasswordBufferLength); + buffer.CopyTo(dstBuffer); + } +} diff --git a/apps/utility-sw/src/Events/PracticeGrenade.cs b/apps/utility-sw/src/Events/PracticeGrenade.cs new file mode 100644 index 00000000..fd8a1289 --- /dev/null +++ b/apps/utility-sw/src/Events/PracticeGrenade.cs @@ -0,0 +1,95 @@ +using FiveStack.Entities.Practice; +using SwiftlyS2.Shared.GameEventDefinitions; +using SwiftlyS2.Shared.GameEvents; +using SwiftlyS2.Shared.Misc; +using SwiftlyS2.Shared.Players; + +namespace UtilityPractice; + +// Detonation is the other half of a recording: the projectile stops existing +// and we finally know where the lineup lands. +// +// Every event here except the molotov carries the projectile's entity index, so +// the throw it belongs to is a dictionary lookup. EventMolotovDetonate carries +// only the thrower, which is why it takes the thrower-based path. +public partial class UtilityPracticePlugin +{ + [GameEventHandler(HookMode.Post)] + public HookResult OnSmokeDetonate(EventSmokegrenadeDetonate @event) + { + Detonated(EntityIndex(@event.EntityID), new Vec3(@event.X, @event.Y, @event.Z)); + return HookResult.Continue; + } + + [GameEventHandler(HookMode.Post)] + public HookResult OnFlashDetonate(EventFlashbangDetonate @event) + { + Detonated(EntityIndex(@event.EntityID), new Vec3(@event.X, @event.Y, @event.Z)); + return HookResult.Continue; + } + + [GameEventHandler(HookMode.Post)] + public HookResult OnHeDetonate(EventHegrenadeDetonate @event) + { + Detonated(EntityIndex(@event.EntityID), new Vec3(@event.X, @event.Y, @event.Z)); + return HookResult.Continue; + } + + [GameEventHandler(HookMode.Post)] + public HookResult OnDecoyStarted(EventDecoyStarted @event) + { + Detonated(EntityIndex(@event.EntityID), new Vec3(@event.X, @event.Y, @event.Z)); + return HookResult.Continue; + } + + // No entity id on this one -- the thrower is the only handle we get. + [GameEventHandler(HookMode.Post)] + public HookResult OnMolotovDetonate(EventMolotovDetonate @event) + { + // Without an entity index there is no way to tell a solver molotov from + // the thrower's own, and guessing wrong finalizes a real lineup at the + // solver's landing point. + IPlayer? thrower = @event.UserIdPlayer; + if (thrower == null || !thrower.IsValid || _solver.EmittingMolotovs) + { + return HookResult.Continue; + } + + var position = new Vec3(@event.X, @event.Y, @event.Z); + + // The replay is asked first on purpose. While one of this player's + // ghosts is in the air the two molotovs cannot be told apart, and + // handing the ghost's landing point to the recorder finalizes the throw + // they actually made at a spot it never reached -- a hit nobody threw. + // Left alone, the real projectile finalizes off the recorder's own + // sampling a tick after it goes out, which is where it really landed. + string? ghost = _replay.GhostMolotovDetonated(thrower.SteamID, position); + + if (ghost != null) + { + Signal(ghost); + return HookResult.Continue; + } + + _recorder.OnMolotovDetonated(thrower.SteamID, position); + + return HookResult.Continue; + } + + // The three readers of a detonation. The recorder ignores an index it never + // tracked and the solver ignores one it never emitted, so a projectile only + // ever lands in one of them. + private void Detonated(uint entityIndex, Vec3 position) + { + _recorder.OnDetonated(entityIndex, position); + _solver.OnDetonated(entityIndex, position); + Signal(_replay.GhostDetonated(entityIndex, position)); + } + + // Swiftly types the field as a signed short; the unsigned round trip keeps + // a high index from arriving as a negative number. + private static uint EntityIndex(short entityId) + { + return (ushort)entityId; + } +} diff --git a/apps/utility-sw/src/Events/PracticePlayer.cs b/apps/utility-sw/src/Events/PracticePlayer.cs new file mode 100644 index 00000000..094e4fb3 --- /dev/null +++ b/apps/utility-sw/src/Events/PracticePlayer.cs @@ -0,0 +1,67 @@ +using SwiftlyS2.Shared.GameEventDefinitions; +using SwiftlyS2.Shared.GameEvents; +using SwiftlyS2.Shared.Misc; +using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.SchemaDefinitions; + +namespace UtilityPractice; + +public partial class UtilityPracticePlugin +{ + // Practising smokes through your own flash is nobody's idea of practice. + // Joining a team is the moment somebody is actually in the server and + // able to read chat -- connect is too early, and a practice server whose + // commands nobody knows about is a practice server nobody can use. + [GameEventHandler(HookMode.Post)] + public HookResult OnPlayerJoinTeam(EventPlayerTeam @event) + { + IPlayer? player = @event.UserIdPlayer; + + if (player == null || !player.IsValid || player.IsFakeClient) + { + return HookResult.Continue; + } + + ulong steamId = player.SteamID; + + // Once per connection, not once per team change: switching sides to + // line something up should not re-print the menu every time. + if (!_welcomed.Add(steamId)) + { + return HookResult.Continue; + } + + Core.Scheduler.DelayBySeconds( + WelcomeDelaySeconds, + () => + { + foreach (string line in WelcomeLines) + { + Tell(steamId, line); + } + } + ); + + return HookResult.Continue; + } + + [GameEventHandler(HookMode.Post)] + public HookResult OnPlayerBlind(EventPlayerBlind @event) + { + if (!_config.NoFlash) + { + return HookResult.Continue; + } + + CCSPlayerPawn? pawn = @event.UserIdPawn; + + if (pawn == null || !pawn.IsValid) + { + return HookResult.Continue; + } + + pawn.FlashDuration = 0f; + + return HookResult.Continue; + } +} diff --git a/apps/utility-sw/src/Services/PracticeDrill.cs b/apps/utility-sw/src/Services/PracticeDrill.cs new file mode 100644 index 00000000..2f255a1d --- /dev/null +++ b/apps/utility-sw/src/Services/PracticeDrill.cs @@ -0,0 +1,320 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; + +namespace UtilityPractice; + +// Turns the library into practice: pick a lineup, stand the player on it, wait +// for the throw to be scored, move on, and say what the run came to. +// +// It owns no timer of its own -- Second is the plugin's shared one second job, +// which is only the watchdog for a throw the panel never answered. Nothing here +// touches a player either: standing somebody on a lineup is what .load already +// does, so it goes back out through the plugin exactly as the playbook's steps +// do. Everything is keyed by steam id, because several people drill in one +// server and none of them are told about each other's runs. +public class PracticeDrill +{ + private readonly UtilityConfig _config; + private readonly PracticeLibrary _library; + + private readonly Dictionary _runs = + new Dictionary(); + + private readonly DrillProgressBook _progress = new DrillProgressBook(); + private readonly Random _random = new Random(); + + public PracticeDrill(UtilityConfig config, PracticeLibrary library) + { + _config = config; + _library = library; + } + + // Wired by the plugin rather than injected, the same way the playbook's + // are. Load answers false when the lineup could not be stood on, which is + // the only thing the runner cannot work out for itself. + public Func? Load { get; set; } + + // A repeat of the lineup already loaded: hand the grenade back where they + // stand, and leave it to them to walk to the spot again. + public Action? Rearm { get; set; } + public Action? Tell { get; set; } + public Action? Note { get; set; } + public Action? Center { get; set; } + + public eDrillStart Start(ulong steamId, eDrillOrder order, int count) + { + if (_runs.ContainsKey(steamId)) + { + return eDrillStart.AlreadyRunning; + } + + if (!_config.ReplayEnabled) + { + return eDrillStart.ReplayDisabled; + } + + // Every attempt is scored by the panel, so a server that has none has + // no drill to offer -- only teleports. + if (!_config.IsConnected()) + { + return eDrillStart.NotConnected; + } + + List queue = DrillUtility.Queue( + _library.For(steamId), + count, + order, + _progress.Lookup(steamId), + _random + ); + + return Begin(steamId, queue, Ordering(order)); + } + + // A drill over lineups somebody picked rather than a slice of their book -- + // the website sending "drill these" is the same run, chosen differently. + public eDrillStart StartWith(ulong steamId, IReadOnlyList queue) + { + if (_runs.ContainsKey(steamId)) + { + return eDrillStart.AlreadyRunning; + } + + if (!_config.ReplayEnabled) + { + return eDrillStart.ReplayDisabled; + } + + if (!_config.IsConnected()) + { + return eDrillStart.NotConnected; + } + + return Begin(steamId, queue.ToList(), "as sent"); + } + + private eDrillStart Begin(ulong steamId, List queue, string ordering) + { + if (queue.Count == 0) + { + return eDrillStart.NothingToDrill; + } + + var run = new PracticeDrillRun(queue, DrillReps); + _runs[steamId] = run; + + Tell?.Invoke( + steamId, + $"drill started - {queue.Count} lineups x{DrillReps}, {ordering}" + + " (.skip to pass, .cancel to end)" + ); + + Advance(steamId, run); + + return eDrillStart.Started; + } + + public bool Stop(ulong steamId) + { + if (!_runs.TryGetValue(steamId, out PracticeDrillRun? run)) + { + return false; + } + + run.End(eDrillEnd.Stopped); + Finish(steamId, run); + + return true; + } + + public bool Skip(ulong steamId) + { + if (!_runs.TryGetValue(steamId, out PracticeDrillRun? run) || !run.Skip()) + { + return false; + } + + Advance(steamId, run); + + return true; + } + + // The recorder's release edge. A grenade the plugin emitted never gets + // here: the recorder drops a projectile it threw itself before it raises + // anything, so a preview or a solve cannot become somebody's attempt. + public void OnThrown(ulong steamId, string utilityType) + { + if (_runs.TryGetValue(steamId, out PracticeDrillRun? run)) + { + run.Thrown(utilityType, DateTime.UtcNow); + } + } + + // The panel's answer, or the fact that there was not one. + public void OnScored(ulong steamId, string lineupId, UtilityPracticeResult? result) + { + // Recorded whether or not this player is drilling: a worst-first run + // reads what the panel has already said about a lineup, and a throw + // made after .load says as much about it as one made in a run. + _progress.Record(steamId, lineupId, result); + + if (!_runs.TryGetValue(steamId, out PracticeDrillRun? run) || !run.Score(lineupId, result)) + { + return; + } + + if (result == null) + { + Note?.Invoke(steamId, "that throw was not scored, so it does not count"); + } + + Advance(steamId, run); + } + + // The shared slow job. A throw whose answer never came is the one way a + // drill can stop advancing without anybody being told, so it is the one + // thing this watches for. + public void Second() + { + if (_runs.Count == 0) + { + return; + } + + DateTime now = DateTime.UtcNow; + + // Finishing a run removes it, so the sweep walks a copy of the keys. + foreach (ulong steamId in _runs.Keys.ToList()) + { + if (!_runs.TryGetValue(steamId, out PracticeDrillRun? run) || !run.Expired(now)) + { + continue; + } + + Note?.Invoke(steamId, "nothing came back for that throw; the panel may be down"); + + Advance(steamId, run); + } + } + + // A player who has left cannot be told anything, so their run ends where it + // stands rather than printing a summary into an empty seat. + // Three goes at each lineup before moving on. One is a tour of the map, + // not practice: the point of a drill is throwing the same thing until it + // stops being luck. + public const int DrillReps = 3; + + // The lineup this player is being drilled on, or null when no run is going. + public LineupRecord? Current(ulong steamId) + { + return _runs.TryGetValue(steamId, out PracticeDrillRun? run) && !run.Finished + ? run.Current + : null; + } + + // Whether this player owes the run an answer -- they have thrown and the + // panel has not scored it yet. Used to hold their next grenade back: a + // drill where you can spam three smokes before the first is judged is not + // measuring anything. + public bool Waiting(ulong steamId) + { + return _runs.TryGetValue(steamId, out PracticeDrillRun? run) && run.Waiting; + } + + // What the panels say while a drill is on. Null when there is no run. + public string? Progress(ulong steamId) + { + if (!_runs.TryGetValue(steamId, out PracticeDrillRun? run) || run.Finished) + { + return null; + } + + string tally = run.Attempts == 0 ? "no throws yet" : Tally(run); + + return $"Drill {run.Position}/{run.Length} - rep {run.Rep}/{run.Reps} - {tally}"; + } + + public void Forget(ulong steamId) + { + _runs.Remove(steamId); + _progress.Forget(steamId); + } + + // A map change replaces the library every queue was built from. + public void Reset() + { + _runs.Clear(); + _progress.Clear(); + } + + private void Advance(ulong steamId, PracticeDrillRun run) + { + while (true) + { + LineupRecord? next = run.Next(); + + if (next == null) + { + Finish(steamId, run); + return; + } + + // A later rep is the SAME lineup, already loaded and already + // marked. Re-loading it would teleport the player back onto the + // spot, and walking back is part of the throw. + if (run.Rep > 1) + { + Rearm?.Invoke(steamId, next); + run.Loaded(); + + Note?.Invoke( + steamId, + $"{run.Position}/{run.Length} rep {run.Rep}/{run.Reps} " + + $"{DrillUtility.Name(next)} - {Tally(run)}" + ); + + return; + } + + if (Load?.Invoke(steamId, next) == true) + { + run.Loaded(); + + Note?.Invoke( + steamId, + $"{run.Position}/{run.Length} rep {run.Rep}/{run.Reps} " + + $"{DrillUtility.Name(next)} - {Tally(run)}" + ); + + return; + } + + Note?.Invoke(steamId, $"{DrillUtility.Name(next)} could not be loaded; skipping it"); + + run.CannotLoad(); + } + } + + private void Finish(ulong steamId, PracticeDrillRun run) + { + _runs.Remove(steamId); + + foreach (string line in run.Summary()) + { + Tell?.Invoke(steamId, line); + } + + Center?.Invoke(steamId, $"drill\n{run.Hits}/{run.Attempts}"); + } + + private static string Tally(PracticeDrillRun run) + { + return $"{run.Hits} hit, {run.Misses} miss, streak {run.Streak}"; + } + + private static string Ordering(eDrillOrder order) + { + return order == eDrillOrder.Worst ? "worst first" : "shuffled"; + } +} diff --git a/apps/utility-sw/src/Services/PracticeLibrary.cs b/apps/utility-sw/src/Services/PracticeLibrary.cs new file mode 100644 index 00000000..754e31a3 --- /dev/null +++ b/apps/utility-sw/src/Services/PracticeLibrary.cs @@ -0,0 +1,138 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared; + +namespace UtilityPractice; + +// The saved lineups for the map this server is on, one list per player. The +// panel already filters by map and by who is allowed to see what, so nothing +// here re-decides visibility. +public class PracticeLibrary +{ + private readonly ISwiftlyCore _core; + private readonly UtilityApiClient _api; + private readonly ILogger _logger; + + private readonly Dictionary> _lineups = new(); + private string _map = ""; + + public PracticeLibrary( + ISwiftlyCore core, + UtilityApiClient api, + ILogger logger + ) + { + _core = core; + _api = api; + _logger = logger; + } + + public string Map => _map; + + public void SetMap(string map) + { + if (_map == map) + { + return; + } + + _map = map; + _lineups.Clear(); + } + + public IReadOnlyList For(ulong steamId) + { + return _lineups.TryGetValue(steamId, out List? lineups) + ? lineups + : new List(); + } + + public LineupRecord? Resolve(ulong steamId, string query, Vec3? near = null) + { + return PracticeLineupUtility.Resolve(For(steamId), query, near); + } + + public void Add(ulong steamId, LineupRecord lineup) + { + if (!_lineups.TryGetValue(steamId, out List? lineups)) + { + lineups = new List(); + _lineups[steamId] = lineups; + } + + lineups.RemoveAll(existing => existing.client_id == lineup.client_id); + lineups.Add(lineup); + } + + public void Remove(ulong steamId, LineupRecord lineup) + { + if (_lineups.TryGetValue(steamId, out List? lineups)) + { + lineups.RemoveAll(existing => existing.client_id == lineup.client_id); + } + } + + // A library row carries no flight path and no measured bloom, so neither + // can be drawn until they have been fetched. Everything else about a lineup + // -- where to stand, where to look, what to hold -- is already in hand, + // which is why .load teleports first and only then waits on this. + public void EnsureTrajectory(LineupRecord lineup, ulong steamId, Action ready) + { + if (lineup.trajectory.Count > 0 || string.IsNullOrEmpty(lineup.id)) + { + ready(lineup); + return; + } + + string id = lineup.id; + + _ = Task.Run(async () => + { + UtilityTrajectoryArtifact? artifact = await _api.Trajectory(id, steamId); + + _core.Scheduler.NextTick(() => + { + if (artifact != null) + { + lineup.trajectory = artifact.path; + lineup.smoke_volume = artifact.smoke_volume; + } + + ready(lineup); + }); + }); + } + + // Fetches off the game thread and applies on it, so a slow panel cannot + // stall a tick and the dictionary is only ever touched from one thread. + public void Refresh(ulong steamId, Action? done = null) + { + string map = _map; + + _ = Task.Run(async () => + { + List? lineups = await _api.Library(map, steamId); + + _core.Scheduler.NextTick(() => + { + if (lineups == null) + { + done?.Invoke(-1); + return; + } + + // The map can change while the request is in flight; dropping + // the answer beats showing inferno lineups on mirage. + if (map != _map) + { + done?.Invoke(-1); + return; + } + + _lineups[steamId] = lineups; + done?.Invoke(lineups.Count); + }); + }); + } +} diff --git a/apps/utility-sw/src/Services/PracticePlaybook.cs b/apps/utility-sw/src/Services/PracticePlaybook.cs new file mode 100644 index 00000000..d32a979f --- /dev/null +++ b/apps/utility-sw/src/Services/PracticePlaybook.cs @@ -0,0 +1,254 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; + +namespace UtilityPractice; + +public enum ePlaybookStart +{ + Started, + NoPlaybook, + NoSteps, + WrongMap, + AlreadyRunning, +} + +// Runs the execute the panel loaded onto this session: a countdown, then each +// step at its own offset. +// +// It owns no timer of its own. Tick is the plugin's shared fast job and Second +// is the shared slow one, because ten people practising must not mean ten +// clocks. Everything that touches a player goes back out through the plugin, so +// a step puts somebody on a lineup by exactly the same path .load does. +public class PracticePlaybook +{ + public const int CountdownSeconds = 5; + + private enum Phase + { + Idle, + Countdown, + Running, + } + + private readonly PracticeSession _session; + private readonly PracticeSystem _system; + + private Phase _phase = Phase.Idle; + private string _name = ""; + private List _steps = new List(); + + // When t=0 is, which is the end of the countdown rather than the moment + // .playbook was typed. + private DateTime _startsAt = DateTime.MinValue; + + // The last elapsed time already fired. Negative so a step at offset zero is + // still in the first window. + private int _elapsedMs = -1; + private int _announced = -1; + + // A step names a lineup, and the same lineup can appear in several steps + // and several runs. Keeping one record per id means one trajectory fetch + // per id, and the identity check .load relies on keeps working. + private readonly Dictionary _lineups = + new Dictionary(); + + public PracticePlaybook(PracticeSession session, PracticeSystem system) + { + _session = session; + _system = system; + } + + // Wired by the plugin rather than injected: standing a player on a lineup is + // what .load already does, and a second implementation of it would be a + // second answer to the same question. + public Action? Load { get; set; } + public Action? Chat { get; set; } + public Action? Tell { get; set; } + public Action? Center { get; set; } + + public bool Running => _phase != Phase.Idle; + + public UtilityPlaybook? Loaded => _session.Current?.playbook; + + public IReadOnlyList Steps => PlaybookUtility.Ordered(Loaded); + + public ePlaybookStart Start(string map) + { + if (Running) + { + return ePlaybookStart.AlreadyRunning; + } + + UtilityPlaybook? playbook = Loaded; + + if (playbook == null) + { + return ePlaybookStart.NoPlaybook; + } + + if ( + !string.IsNullOrEmpty(playbook.map_name) + && !string.IsNullOrEmpty(map) + && !string.Equals(playbook.map_name, map, StringComparison.OrdinalIgnoreCase) + ) + { + return ePlaybookStart.WrongMap; + } + + List steps = PlaybookUtility.Ordered(playbook); + + if (steps.Count == 0) + { + return ePlaybookStart.NoSteps; + } + + _steps = steps; + _name = string.IsNullOrEmpty(playbook.name) ? "execute" : playbook.name!; + _phase = Phase.Countdown; + _startsAt = DateTime.UtcNow.AddSeconds(CountdownSeconds); + _elapsedMs = -1; + _announced = -1; + + return ePlaybookStart.Started; + } + + public bool Stop() + { + if (!Running) + { + return false; + } + + _phase = Phase.Idle; + _steps = new List(); + + return true; + } + + // A map change takes the geometry the steps refer to with it. + public void Reset() + { + _phase = Phase.Idle; + _steps = new List(); + _lineups.Clear(); + } + + // The shared fast job. Sub-second offsets are the whole point of an execute, + // which is why the step clock lives here and not on the one second job. + public void Tick() + { + if (_phase == Phase.Idle) + { + return; + } + + DateTime now = DateTime.UtcNow; + + if (_phase == Phase.Countdown) + { + if (now < _startsAt) + { + return; + } + + _phase = Phase.Running; + Chat?.Invoke($"{_name} go"); + } + + int elapsed = (int)(now - _startsAt).TotalMilliseconds; + + foreach (UtilityPlaybookStep step in PlaybookUtility.Due(_steps, _elapsedMs, elapsed)) + { + Fire(step); + } + + _elapsedMs = elapsed; + + if (elapsed > PlaybookUtility.DurationMs(_steps) + PlaybookUtility.TailMs) + { + _phase = Phase.Idle; + Chat?.Invoke($"{_name} complete"); + } + } + + // The shared slow job, which is only the countdown: a number that changes + // once a second does not need a finer clock than that. + public void Second() + { + if (_phase != Phase.Countdown) + { + return; + } + + int remaining = (int)Math.Ceiling((_startsAt - DateTime.UtcNow).TotalSeconds); + + if (remaining <= 0 || remaining == _announced) + { + return; + } + + _announced = remaining; + + foreach (ulong steamId in _system.ConnectedSteamIds()) + { + Center?.Invoke(steamId, $"{_name}\n{remaining}"); + } + } + + private void Fire(UtilityPlaybookStep step) + { + LineupRecord? lineup = LineupFor(step); + + if (lineup == null) + { + return; + } + + int order = _steps.IndexOf(step) + 1; + string name = string.IsNullOrEmpty(lineup.name) ? lineup.utility_type : lineup.name; + string note = string.IsNullOrWhiteSpace(step.note) ? "" : $" - {step.note}"; + + var targets = _system + .ConnectedSteamIds() + .Where(steamId => PlaybookUtility.IsFor(step, steamId)) + .ToList(); + + // An assigned step whose player is not on the server is announced and + // skipped: silently handing their smoke to everybody would rehearse an + // execute nobody is going to run. + if (targets.Count == 0) + { + Chat?.Invoke( + PlaybookUtility.IsAssigned(step) + ? $"{order}/{_steps.Count} {name}{note} - {step.assigned_steam_id} is not here" + : $"{order}/{_steps.Count} {name}{note} - nobody to throw it" + ); + return; + } + + foreach (ulong steamId in targets) + { + Load?.Invoke(steamId, lineup); + Tell?.Invoke(steamId, $"{order}/{_steps.Count} {name}{note}"); + } + } + + private LineupRecord? LineupFor(UtilityPlaybookStep step) + { + string id = step.utility_lineup_id ?? ""; + + if (!string.IsNullOrEmpty(id) && _lineups.TryGetValue(id, out LineupRecord? cached)) + { + return cached; + } + + LineupRecord? lineup = step.ToLineup(); + + if (lineup != null && !string.IsNullOrEmpty(id)) + { + _lineups[id] = lineup; + } + + return lineup; + } +} diff --git a/apps/utility-sw/src/Services/PracticeRecorder.cs b/apps/utility-sw/src/Services/PracticeRecorder.cs new file mode 100644 index 00000000..5bbd3942 --- /dev/null +++ b/apps/utility-sw/src/Services/PracticeRecorder.cs @@ -0,0 +1,609 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.SchemaDefinitions; + +namespace UtilityPractice; + +// Turns a thrown grenade into a reproducible lineup. +// +// The engine hands us both halves directly, so none of this is guesswork: +// CBaseCSGrenadeProjectile carries m_vInitialPosition/m_vInitialVelocity (the +// physics seed) and CBaseGrenade carries m_hThrower (who owns it). That last +// one is why several players can practise in one server without their throws +// crossing: a projectile names its own owner, so nothing is keyed on "whoever +// threw last". +public class PracticeRecorder +{ + // Guard rails, so a stuck projectile or a spammed throw cannot grow + // unbounded on a long-lived practice server. + private const int MaxTrackedProjectiles = 64; + private const int MaxRawPoints = 2048; + private const int ForceFinalizeTicks = 64 * 30; + private const int MaxHistoryPerPlayer = 20; + + // Sample every other tick: 32Hz is well past what a replayed line needs, + // and bounces are captured exactly regardless via m_nBounces. + private const int SampleEveryTicks = 2; + + private readonly ISwiftlyCore _core; + private readonly ILogger _logger; + + private class ArmedState + { + public bool PinPulled; + public bool Released; + public ThrowSnapshot? Frozen; + + // Where they were standing before the jump. A jump-throw releases in + // mid-air, and teleporting somebody back to a point in mid-air drops + // them into whatever is underneath -- which is how ".load" ends up + // inside a wall next to the thing you were standing beside. + public Vec3? Stance; + } + + // FL_ONGROUND, the same flag the snapshot itself records. + private const uint FlOnGround = 1 << 0; + + // Horizontal units/sec that still counts as standing still. Not zero: a + // player settling onto a lineup leaves small residual velocity behind. + private const float StationarySpeed = 12f; + + // 64 ticks/sec. A run-up that began five seconds ago is not a run-up. + private const int StationaryMaxAgeTicks = 64 * 5; + + // Consecutive still ticks before a position counts as a standstill. A + // strafe that reverses (S then D) drags velocity through zero for a tick + // or two, and that instant is mid-run-up, not a place anyone stood. + private const int StationarySettleTicks = 4; + + // Where a throw is set up from, which is not where the player leaves the + // ground. A run- or jump-throw is aimed from a standstill and then walked + // into, so the last grounded tick is the takeoff point: teleporting to it + // drops the player mid-run-up with the run-up already spent. + private struct StationaryAnchor + { + public Vec3 Position; + public int Tick; + } + + private class TrackedProjectile + { + public required ulong ThrowerSteamId; + public required string UtilityType; + public required ThrowSnapshot Release; + public required int StartTick; + public Vec3 InitialPosition; + public Vec3 InitialVelocity; + public int LastBounces; + public List Raw = new List(); + } + + private readonly Dictionary _armed = new(); + private readonly Dictionary _stationary = new(); + private readonly Dictionary _settling = new(); + private readonly Dictionary _pending = new(); + private readonly Dictionary _tracked = new(); + private readonly Dictionary> _history = new(); + + private int _tick; + + public PracticeRecorder(ISwiftlyCore core, ILogger logger) + { + _core = core; + _logger = logger; + } + + // Raised on the release edge with the thrower and what they threw, so + // whoever hands the grenade back does not have to re-derive either. + public event Action? Thrown; + + // Raised once a throw is over and its landing point is known. This is the + // only place a completed throw exists, so scoring reads it from here rather + // than hooking the detonate events a second time and re-deriving the owner. + public event Action? Finalized; + + public IReadOnlyList HistoryFor(ulong steamId) + { + return _history.TryGetValue(steamId, out var records) + ? records + : new List(); + } + + public LineupRecord? LastThrow(ulong steamId, int back = 0) + { + var records = HistoryFor(steamId); + int index = records.Count - 1 - back; + return index >= 0 && index < records.Count ? records[index] : null; + } + + // Set while the plugin emits a projectile of its own. A preview grenade is + // not a throw anybody made: recording it would save a lineup nobody threw, + // and scoring it would count an attempt nobody took. + public bool Emitting { get; set; } + + public void Forget(uint entityIndex) + { + _tracked.Remove(entityIndex); + } + + public void Reset() + { + _armed.Clear(); + _stationary.Clear(); + _settling.Clear(); + _pending.Clear(); + _tracked.Clear(); + } + + // Only does work while something is in flight or someone is holding a + // pulled pin, so an idle practice server pays one branch per tick. + public void OnTick() + { + _tick++; + + WatchArmedGrenades(); + SampleProjectiles(); + } + + private void WatchArmedGrenades() + { + foreach (IPlayer player in _core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || player.IsFakeClient) + { + continue; + } + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + _armed.Remove(player.SteamID); + continue; + } + + TrackStationary(player.SteamID, pawn); + + CBasePlayerWeapon? active = pawn.WeaponServices?.ActiveWeapon.Value; + + if (active == null || !active.IsValid) + { + _armed.Remove(player.SteamID); + continue; + } + + CBaseCSGrenade? grenade = TryAsGrenade(active); + if (grenade == null) + { + _armed.Remove(player.SteamID); + continue; + } + + if (!_armed.TryGetValue(player.SteamID, out ArmedState? state)) + { + state = new ArmedState(); + _armed[player.SteamID] = state; + } + + if (grenade.PinPulled) + { + state.PinPulled = true; + } + + if (state.PinPulled && !state.Released) + { + Vector here = pawn.AbsOrigin ?? new Vector(0, 0, 0); + + if ((pawn.Flags & FlOnGround) != 0) + { + state.Stance = new Vec3(here.X, here.Y, here.Z); + } + } + + // m_fThrowTime going non-zero is the release edge. Freeze the + // player's state right here: by the time the projectile entity + // exists they have already started moving again. + if (state.PinPulled && !state.Released && grenade.ThrowTime.Value > 0) + { + state.Released = true; + + Vec3? anchor = StanceFor(player.SteamID, state); + Vector releasedAt = pawn.AbsOrigin ?? new Vector(0, 0, 0); + + // Every argument about where a lineup "should" be has come down + // to which of these three the recorder picked, so it says so. + _logger.LogInformation( + "throw by {steam}: standstill {anchor} (age {age} ticks), takeoff {takeoff}, released at {released} -> stored {chosen}", + player.SteamID, + _stationary.TryGetValue(player.SteamID, out StationaryAnchor found) + ? $"{found.Position.x:0.##},{found.Position.y:0.##},{found.Position.z:0.##}" + : "none", + _stationary.TryGetValue(player.SteamID, out StationaryAnchor aged) + ? (_tick - aged.Tick).ToString() + : "-", + state.Stance == null + ? "none" + : $"{state.Stance.Value.x:0.##},{state.Stance.Value.y:0.##},{state.Stance.Value.z:0.##}", + $"{releasedAt.X:0.##},{releasedAt.Y:0.##},{releasedAt.Z:0.##}", + anchor == null + ? "release origin" + : $"{anchor.Value.x:0.##},{anchor.Value.y:0.##},{anchor.Value.z:0.##}" + ); + + state.Frozen = Snapshot(pawn, grenade, anchor); + _pending[player.SteamID] = state.Frozen; + } + } + } + + private static CBaseCSGrenade? TryAsGrenade(CBasePlayerWeapon weapon) + { + string designer = weapon.DesignerName ?? ""; + if (!PracticeLineupUtility.IsGrenadeWeapon(designer)) + { + return null; + } + + try + { + return weapon.As(); + } + catch + { + return null; + } + } + + private void TrackStationary(ulong steamId, CCSPlayerPawn pawn) + { + Vector velocity = pawn.AbsVelocity; + + bool still = + (pawn.Flags & FlOnGround) != 0 + && new Vec3(velocity.X, velocity.Y, 0f).LengthXY() <= StationarySpeed; + + if (!still) + { + _settling.Remove(steamId); + return; + } + + if (!_settling.TryGetValue(steamId, out int since)) + { + _settling[steamId] = _tick; + return; + } + + if (_tick - since < StationarySettleTicks) + { + return; + } + + Vector here = pawn.AbsOrigin ?? new Vector(0, 0, 0); + + _stationary[steamId] = new StationaryAnchor + { + Position = new Vec3(here.X, here.Y, here.Z), + Tick = _tick, + }; + } + + // Past the window there is no standstill worth returning to, so the last + // grounded position is the better of two imperfect answers. + private Vec3? StanceFor(ulong steamId, ArmedState state) + { + // The last GROUNDED point while the pin was held is the throw's own + // takeoff, and for a jump throw from a standstill that is exactly where + // the player was standing. It already solves the airborne-release + // problem, because a grounded point is never the apex. + // + // The remembered standstill is only a fallback for the case Stance + // cannot answer -- a pin pulled in mid-air. Preferring it outright was + // wrong: it can be a spot the player stood at seconds ago and walked + // away from, which lands the lineup a stride to one side of where they + // actually threw from. + if (state.Stance != null) + { + return state.Stance; + } + + if ( + _stationary.TryGetValue(steamId, out StationaryAnchor anchor) + && _tick - anchor.Tick <= StationaryMaxAgeTicks + ) + { + return anchor.Position; + } + + return null; + } + + private ThrowSnapshot Snapshot(CCSPlayerPawn pawn, CBaseCSGrenade grenade, Vec3? stance) + { + Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); + Vector velocity = pawn.AbsVelocity; + QAngle angles = pawn.EyeAngles; + + float eyeZ = origin.Z + pawn.ViewOffset.Z.Value; + + CCSPlayer_MovementServices? movement = pawn.MovementServices; + bool ducked = movement?.Ducked ?? false; + uint buttons = 0; + bool walking = false; + + if (movement != null) + { + buttons = (uint)movement.Buttons.ButtonStates[0]; + // IN_SPEED + walking = (buttons & (1 << 16)) != 0; + } + + return new ThrowSnapshot + { + // The stance, not the release point: this is where the lineup says + // to stand, and standing is something you can only do on the floor. + feet_position = stance ?? new Vec3(origin.X, origin.Y, origin.Z), + eye_position = new Vec3(origin.X, origin.Y, eyeZ), + pitch = angles.X, + yaw = angles.Y, + velocity = new Vec3(velocity.X, velocity.Y, velocity.Z), + speed = new Vec3(velocity.X, velocity.Y, 0f).LengthXY(), + on_ground = (pawn.Flags & FlOnGround) != 0, + ducked = ducked, + walking = walking, + throw_strength_raw = grenade.ThrowStrength, + jump_throw = grenade.JumpThrow, + buttons = buttons, + tick = _tick, + }; + } + + // A projectile appearing is what links a frozen snapshot to a physical + // grenade. m_hThrower is read off the entity rather than assumed, so two + // players throwing on the same tick cannot be confused for one another. + public void OnProjectileCreated(CEntityInstance entity) + { + string designer = entity.DesignerName ?? ""; + string? utilityType = PracticeLineupUtility.UtilityTypeForProjectile(designer); + + // Anything that is not a grenade leaves silently: every entity in the + // map comes through here. Past this line it IS a throw, so a drop is + // worth saying out loud -- a silently dropped throw is what makes + // ".save" claim you never threw anything. + if (utilityType == null) + { + return; + } + + // The engine just told us what a real one of these looks like. + if (entity is CBaseModelEntity model && model.IsValid) + { + PracticeLineupUtility.LearnUtilityModel(utilityType, model.GetModel()); + } + + if (Emitting) + { + return; + } + + if (_tracked.Count >= MaxTrackedProjectiles) + { + _logger.LogWarning( + "dropped a {type}: already tracking {count} projectiles", + utilityType, + _tracked.Count + ); + return; + } + + CBaseCSGrenadeProjectile projectile; + try + { + projectile = entity.As(); + } + catch + { + return; + } + + CBaseEntity? thrower = projectile.Thrower.Value; + IPlayer? player = + thrower == null ? null : _core.PlayerManager.GetPlayerFromPawn(thrower.As()); + + if (player == null || !player.IsValid) + { + _logger.LogWarning( + "dropped a {type}: no thrower on the projectile yet", + utilityType + ); + return; + } + + if (!_pending.Remove(player.SteamID, out ThrowSnapshot? release)) + { + // No frozen snapshot: the pin/throw edge was missed (hot reload + // mid-throw, or a scripted give). Record what is still true rather + // than dropping the throw entirely. + release = new ThrowSnapshot { tick = _tick }; + } + + Vector initialPosition = projectile.InitialPosition; + Vector initialVelocity = projectile.InitialVelocity; + + _tracked[entity.Index] = new TrackedProjectile + { + ThrowerSteamId = player.SteamID, + UtilityType = utilityType, + Release = release, + StartTick = _tick, + InitialPosition = new Vec3( + initialPosition.X, + initialPosition.Y, + initialPosition.Z + ), + InitialVelocity = new Vec3( + initialVelocity.X, + initialVelocity.Y, + initialVelocity.Z + ), + }; + + if (_armed.TryGetValue(player.SteamID, out ArmedState? state)) + { + state.PinPulled = false; + state.Released = false; + state.Frozen = null; + } + + Thrown?.Invoke(player.SteamID, utilityType); + } + + private void SampleProjectiles() + { + if (_tracked.Count == 0) + { + return; + } + + var expired = new List(); + + foreach ((uint index, TrackedProjectile tracked) in _tracked) + { + CBaseCSGrenadeProjectile? projectile = TryProjectileAt(index); + + if (projectile == null || !projectile.IsValid) + { + expired.Add(index); + continue; + } + + if (_tick - tracked.StartTick > ForceFinalizeTicks) + { + expired.Add(index); + continue; + } + + Vector? origin = projectile.AbsOrigin; + if (origin == null || tracked.Raw.Count >= MaxRawPoints) + { + continue; + } + + // A bounce is where the path turns. Sampling can miss it, the + // counter cannot. + bool bounced = projectile.Bounces > tracked.LastBounces; + if (bounced) + { + tracked.LastBounces = projectile.Bounces; + } + + if (bounced || _tick % SampleEveryTicks == 0) + { + tracked.Raw.Add( + new TrajectoryPoint + { + p = new Vec3(origin.Value.X, origin.Value.Y, origin.Value.Z), + t = _tick, + bounce = bounced, + } + ); + } + } + + foreach (uint index in expired) + { + FinalizeByIndex(index, null); + } + } + + private CBaseCSGrenadeProjectile? TryProjectileAt(uint index) + { + try + { + return _core.EntitySystem.GetEntityByIndex(index); + } + catch + { + return null; + } + } + + // Called from the detonate handlers, which carry the projectile's entity + // index for every utility except molotovs. + public void OnDetonated(uint entityIndex, Vec3 position) + { + FinalizeByIndex(entityIndex, position); + } + + // EventMolotovDetonate carries no entity id, so the thrower is the only + // handle available. + public void OnMolotovDetonated(ulong steamId, Vec3 position) + { + foreach ((uint index, TrackedProjectile tracked) in _tracked) + { + if (tracked.ThrowerSteamId == steamId && tracked.UtilityType == "Molotov") + { + FinalizeByIndex(index, position); + return; + } + } + } + + private void FinalizeByIndex(uint entityIndex, Vec3? detonation) + { + if (!_tracked.Remove(entityIndex, out TrackedProjectile? tracked)) + { + return; + } + + Vec3 landing = + detonation + ?? ( + tracked.Raw.Count > 0 + ? tracked.Raw[^1].p + : tracked.InitialPosition + ); + + var record = new LineupRecord + { + client_id = Guid.NewGuid().ToString(), + utility_type = tracked.UtilityType, + author_steam_id = tracked.ThrowerSteamId.ToString(), + release = tracked.Release, + initial_position = tracked.InitialPosition, + initial_velocity = tracked.InitialVelocity, + detonation_position = landing, + bounces = tracked.LastBounces, + flight_time = (_tick - tracked.StartTick) / 64f, + // The plugin watched this throw happen, so it is exact by + // observation. It is never sent: the panel owns provenance and + // stamps its own on ingest. + confidence = LineupRecord.Exact, + technique = TrajectoryUtility.ClassifyTechnique(tracked.Release).ToString(), + strength = TrajectoryUtility + .ClassifyStrength(tracked.Release.throw_strength_raw) + .ToString(), + trajectory = TrajectoryUtility.Simplify(tracked.Raw), + recorded_tickrate = 64, + plugin_runtime = "swiftlys2", + }; + + if (!_history.TryGetValue(tracked.ThrowerSteamId, out List? records)) + { + records = new List(); + _history[tracked.ThrowerSteamId] = records; + } + + records.Add(record); + while (records.Count > MaxHistoryPerPlayer) + { + records.RemoveAt(0); + } + + Finalized?.Invoke(record); + } +} diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs new file mode 100644 index 00000000..b0af58da --- /dev/null +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -0,0 +1,2108 @@ +using System.Globalization; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.EntitySystem; +using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.SchemaDefinitions; +using SwiftlyS2.Shared.Trace; + +namespace UtilityPractice; + +// Puts a player back where a lineup was thrown from, and draws the line the +// grenade took so they can see the throw before they make it. +public class PracticeReplay +{ + + // A simplified line is a few dozen points; a long one is strided rather + // than spawning an entity per segment. + + private const float MarkerHeight = 42f; + + private const float BloomWidth = 1.1f; + + // The landing point is where the grenade came to rest, so a smoke emitted + // exactly there starts inside the floor. A hand's height above it falls + // back onto the same spot. + private const float BloomSmokeLift = 8f; + + // One beam per occupied voxel is thousands of entities for a single smoke. + // The outline is contoured down to fit this, and a server full of people + // previewing at once is capped again on top of it. + private const int MaxBloomBeams = 48; + private const int MaxBloomBeamsTotal = 240; + + private readonly ISwiftlyCore _core; + private readonly UtilityConfig _config; + private readonly PracticeRecorder _recorder; + private readonly ILogger _logger; + + private enum GhostKind + { + Bloom, + } + + private class Ghost + { + public required ulong OwnerSteamId; + public required GhostKind Kind; + public required DateTime ExpiresAt; + public required List Beams; + } + + private readonly List _ghosts = new List(); + + // In-world markers for the lineup currently loaded. Deliberately NOT part + // of _ghosts: ghosts are filtered per viewer, and a marker is meant to be + // seen by everyone on the server. + // How far in front of the stance the reticle hangs. Deliberately short: an + // aim ray traced to the far side of the map climbs thousands of units on + // the upward pitch a smoke needs, and puts the marker on a ceiling nobody + // is looking at. A player lines a crosshair up against something a few + // metres away, so that is where the target goes -- unless a wall is nearer, + // in which case it lands on the wall. + // Traced far enough to reach real geometry: a crosshair placement is only + // exact if it sits on something in the world, so the ring goes wherever the + // aim ray actually lands. + private const float AimTraceRange = 4096f; + + // Players catch line traces, and both marker traces suffered for it: the + // aim trace was offset forward to clear the thrower's hull, which broke + // walls nearer than the offset and still let any OTHER body in the ray + // catch it -- the crosshair drew wherever somebody was standing and only + // corrected on the next redraw. Skipping pawns in the filter kills the + // whole class instead of one case of it. + // Bodies wander through rays, and every marker this plugin draws sits + // exactly where its rays go -- the floating grenade model lives ~16u above + // the stance eye, dead in the path of any steep upward throw, which is + // where "the crosshair is suddenly low and close" came from. None of these + // is ever the wall being aimed at. + private static readonly HashSet TraceInvisible = new() + { + "player", + "prop_physics_override", + "env_beam", + "point_worldtext", + }; + + private static TraceParams SkipMarkers() + { + var parameters = new TraceParams(); + + parameters.IterateEntities = true; + parameters.ShouldHitEntity = entity => + { + string designer = entity.DesignerName ?? ""; + + // Held weapons follow players through rays, and projectiles are + // wherever somebody last threw one. + return !TraceInvisible.Contains(designer) + && !designer.StartsWith("weapon_") + && !designer.EndsWith("_projectile"); + }; + + return parameters; + } + + + // Only used when the ray leaves the map without hitting anything -- aiming + // up over open ground has no surface to mark, and a ring hung out at the + // full trace length would be a speck against the skybox. + // Deliberately far. A crosshair marker hung close to the player shifts + // across the skyline with every step, so standing a few units off the spot + // aims you somewhere else entirely. Far away it behaves like the horizon: + // the direction is what matters and small stance errors stop mattering. + private const float AimFallbackRange = 2400f; + + // A jump tops out around 54 units, so anything further below a recorded + // position is a different floor and must not be snapped to. + private const float GroundSnapRange = 96f; + + // Thinner than the ghost line at 1.6: a marker is an outline, and a ring + // is a dozen overlapping segments whose glow compounds into a blob at + // anything heavier. + private const float MarkerWidth = 0.6f; + + // 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; + + // Roughly where the grenade sits in a player's hand, so it reads as "this + // is what you throw from here" rather than as litter on the floor. + // Side by side when one spot wants more than one kind of grenade. + private const float UtilityModelSpacing = 16f; + + // Above standing eye height (64) on purpose. .load stands the player ON + // the ring, and at chest height the grenade is inside their camera. + public const float UtilityModelHeight = 80f; + + // The aim reticle is the one marker that is not a place the utility goes, + // so it never wears the utility's colour. + private static readonly Color AimColor = new Color(255, 235, 120, 255); + + // 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. + private readonly List _markerBeams = new(); + private readonly List _markerTexts = new(); + private readonly List _markerProps = 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 + // 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. + public readonly List Aims = new(); + + // The gate and its tether, which belong to the SPOT rather than to any + // one throw off it, and are recoloured by where the player is standing. + public readonly List Stance = new(); + + public Vec3 At; + public int Bucket = -1; + } + + private class Aim + { + public LineupRecord Lineup = null!; + public readonly List Beams = new(); + public int Bucket = -1; + } + + // The lined-up crosshair: barely-there green. Beams render bright against + // the world, so a dark colour is how a beam whispers. + private static readonly Color AimSettled = new Color(18, 52, 26, 255); + + // What .tintest settled: a "Color" INPUT reaches clients, where assigning + // Render on a live beam moves the value on the server and nowhere else. + // This is why colour changes are one input per beam instead of the + // despawn-and-respawn machinery that used to live here. + private static void Recolour(List beams, Color color) + { + foreach (CEnvBeam beam in beams) + { + if (beam.IsValid) + { + beam.AcceptInput( + "Color", + $"{color.R} {color.G} {color.B}", + null, + null, + 0 + ); + } + } + } + + // Coarse on purpose: this decides how often a crosshair is torn down and + // rebuilt, and the eye cannot separate neighbouring shades anyway. + private const int MissBuckets = 5; + + private static int BucketFor(float miss) + { + return PracticeLineupUtility.MissBucket(miss, MissBuckets); + } + + private static Color ColorForBucket(int bucket) + { + return MissColor(bucket / (float)(MissBuckets - 1)); + } + + // The reticle currently being drawn, so its beams can be collected apart + // from the rest of the selection. + private Aim? _aimInto; + + // Where each lineup's aim ray lands, traced once. client_id keyed; + // dropped on map change with everything else. + private readonly Dictionary _aimHits = new(); + + // Same, for the spot furniture. + private List? _stanceInto; + + private readonly Dictionary _selections = new(); + + // Which list the drawing helpers append to. Null means the shared layer. + private Selection? _drawingInto; + + // Which of the drawn throws is the one the player is looking toward. Every + // throw off a spot is drawn -- you cannot choose between options you cannot + // see -- and this one is drawn heavier so it stands out from its siblings. + private LineupRecord? _focused; + + // A real smoke emitted at the landing point: the only preview with perfect + // fidelity, because it is the same cloud the throw would make. + private readonly Dictionary _bloomSmoke = new(); + + // Ghost projectiles the plugin has in the air, so their detonation can be + // announced. A grenade nobody threw is invisible to everything outside this + // process -- no demo event names it as a throw, and nothing else knows it + // exists -- which is why the plugin has to say so itself. + private class GhostThrow + { + public required ulong OwnerSteamId; + public required string UtilityType; + public required string ClientId; + public required string? LineupId; + public required DateTime ExpiresAt; + } + + private const float GhostThrowSeconds = 30f; + + private readonly Dictionary _ghostThrows = new(); + + public PracticeReplay( + ISwiftlyCore core, + UtilityConfig config, + PracticeRecorder recorder, + ILogger logger + ) + { + _core = core; + _config = config; + _recorder = recorder; + _logger = logger; + } + + // Wired by the plugin rather than injected: PracticeSystem already depends + // on this service, so asking for it back would close the cycle. + public Func IsSolo { get; set; } = _ => true; + + // The whole library for a player, so loading one lineup still draws the + // rest. Supplied by the plugin, which owns the library. + public Func> All { 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. + + // --------------------------------------------------------------------- + // CRASH BISECT. Every one of these makes the plugin touch the engine for + // something other than moving a player, and all of them are off. A fresh + // instance should stay up with the plugin still recording, saving, loading + // and teleporting -- it just draws nothing and spawns nothing. + // + // Turn ONE back on, redeploy, play until it either crashes or clearly does + // not, and that names the culprit. Turning two on at once wastes the run. + // --------------------------------------------------------------------- + + // Beams and world text: rings/gates, labels, connectors, the flight line. + public const bool DrawMarkers = true; + + // prop_physics_override grenade models floating over a spot, and the + // collision clearing that follows them. + public const bool DrawModels = true; + + // The measured bloom outline. + public const bool DrawBloom = true; + + // REAL projectiles: EmitSmokeGrenade / EmitFlashbang / EmitHEGrenade / + // EmitMolotov, plus the bloom's live smoke. + public const bool EmitGrenades = true; + + // So the server log says exactly which of these is live. Without it there + // is no way to tell a switch that is off from a build that never deployed. + public static string SwitchState() + { + return $"markers={DrawMarkers} models={DrawModels} " + + $"bloom={DrawBloom} grenades={EmitGrenades}"; + } + + public void Load(IPlayer player, LineupRecord lineup) + { + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return; + } + + // The same floor the stance marker is drawn on, so .load puts the + // player standing on the ring rather than dropping into it. + Vec3 feet = Grounded(lineup.release.feet_position); + var position = new Vector(feet.x, feet.y, feet.z); + + // Yaw for the body, pitch for the eyes, and never the two together: a + // pawn's rotation is which way it faces, so a lineup's -63 pitch fed + // into it lies the player on their back. + var facing = new QAngle(0, lineup.release.yaw, 0); + var aim = new QAngle(lineup.release.pitch, lineup.release.yaw, 0); + + player.Teleport(position, facing, new Vector(0, 0, 0)); + pawn.EyeAngles = aim; + + // A single application is not enough: the client re-predicts from the + // command it had in flight and snaps the view back. + ReapplyAngles(player, facing, aim, 2); + + GiveUtility(player, lineup.utility_type); + + // Drawn a tick later, from where the player ACTUALLY ended up. The + // engine resolves the floor by standing them on it, which beats any + // trace we could run: a stored origin can be a jump height off (an + // editor-authored lineup has no way to know the floor) and the marker + // still lands under their feet. + _core.Scheduler.NextTick(() => + { + if (!player.IsValid) + { + return; + } + + CCSPlayerPawn? settled = player.PlayerPawn; + Vec3 standing = feet; + + if (settled != null && settled.IsValid) + { + Vector landed = settled.AbsOrigin ?? new Vector(feet.x, feet.y, feet.z); + standing = new Vec3(landed.X, landed.Y, landed.Z); + } + + // Everything on the map, with this one in focus. + IReadOnlyList everything = All(player.SteamID); + + IReadOnlyList library = + everything.Count > 0 ? everything : new[] { lineup }; + + // Whatever else is throwable from this spot comes up with it. + List here = SpotAt(library, standing); + + if (!here.Any(entry => entry.client_id == lineup.client_id)) + { + here.Add(lineup); + } + + ShowLibrary(library); + ShowSelection(player, here, standing); + }); + + player.SendCenter(Describe(lineup)); + } + + // The measured bloom, outlined where it would actually sit. Answers how + // many beams it took: zero when the panel has no measurement for this + // lineup, which is a normal answer and not a failure. + public int ShowBloom(IPlayer player, LineupRecord lineup) + { + if (!DrawBloom) + { + return 0; + } + + ClearKind(player.SteamID, GhostKind.Bloom); + + if (!_config.GhostPreview) + { + return 0; + } + + int budget = Math.Min(MaxBloomBeams, MaxBloomBeamsTotal - BloomBeamCount()); + + if (budget <= 0) + { + return 0; + } + + List outline = SmokeVolumeUtility.Outline( + lineup.smoke_volume, + new SmokeOutlineOptions { MaxSegments = budget } + ); + + if (outline.Count == 0) + { + return 0; + } + + Color color = ColorFor(lineup.utility_type); + var beams = new List(); + + foreach (BloomSegment segment in outline) + { + CEnvBeam? beam = CreateBeam(segment.a, segment.b, color, BloomWidth); + + if (beam != null) + { + beams.Add(beam); + } + } + + if (beams.Count == 0) + { + return 0; + } + + // Held until it is toggled off rather than expiring: a player lining a + // throw up is looking at it for as long as that takes. + _ghosts.Add( + new Ghost + { + OwnerSteamId = player.SteamID, + Kind = GhostKind.Bloom, + ExpiresAt = DateTime.MaxValue, + Beams = beams, + } + ); + + return beams.Count; + } + + // The outline is a drawing of the measurement; this is the measurement's + // subject. Only Swiftly can emit one, so only Swiftly offers it. + public bool ShowBloomSmoke(IPlayer player, LineupRecord lineup) + { + if (!EmitGrenades) + { + return false; + } + + ClearBloomSmoke(player.SteamID); + + if (lineup.utility_type != "Smoke") + { + return false; + } + + CBasePlayerPawn? pawn = player.Pawn; + + if (pawn == null || !pawn.IsValid) + { + return false; + } + + Vec3 landing = lineup.detonation_position; + + _recorder.Emitting = true; + + try + { + CSmokeGrenadeProjectile smoke = _core.Game.EmitSmokeGrenade( + new Vector(landing.x, landing.y, landing.z + BloomSmokeLift), + new QAngle(0, 0, 0), + new Vector(0, 0, 0), + player.Controller.Team, + pawn + ); + + if (!smoke.IsValid) + { + return false; + } + + // Belt and braces with the flag above: whichever way round the + // engine raises entity creation, this grenade is not a lineup. + _recorder.Forget(smoke.Index); + + _bloomSmoke[player.SteamID] = smoke; + + return true; + } + catch (Exception error) + { + _logger.LogError(error, "unable to emit a bloom preview smoke"); + return false; + } + finally + { + _recorder.Emitting = false; + } + } + + public void ClearBloomSmoke(ulong steamId) + { + if (!_bloomSmoke.Remove(steamId, out CSmokeGrenadeProjectile? smoke)) + { + return; + } + + if (smoke.IsValid) + { + smoke.Despawn(); + } + } + + // 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) + { + if (!EmitGrenades) + { + return; + } + + if (!_config.GhostProjectile) + { + return; + } + + CBasePlayerPawn? pawn = player.Pawn; + + if (pawn == null || !pawn.IsValid) + { + return; + } + + // Both halves or nothing. A zeroed seed would launch the grenade out of + // the map origin, and a seed the panel did not call exact belongs to a + // path fitted to a demo rather than to a throw the engine performed -- + // practise toward that one, never replay it. + if (!lineup.IsExactlyReplayable()) + { + return; + } + + Vec3 seedPosition = lineup.initial_position; + Vec3 seedVelocity = lineup.initial_velocity; + + var position = new Vector(seedPosition.x, seedPosition.y, seedPosition.z); + var velocity = new Vector(seedVelocity.x, seedVelocity.y, seedVelocity.z); + + (float pitch, float yaw) = TrajectoryUtility.AnglesFromVelocity(seedVelocity); + var angles = new QAngle(pitch, yaw, 0); + + Team team = player.Controller.Team; + + _recorder.Emitting = true; + + try + { + CBaseCSGrenadeProjectile projectile; + + switch (lineup.utility_type) + { + case "Smoke": + projectile = _core.Game.EmitSmokeGrenade( + position, + angles, + velocity, + team, + pawn + ); + break; + case "Flash": + projectile = _core.Game.EmitFlashbang(position, angles, velocity, pawn); + break; + case "HighExplosive": + projectile = _core.Game.EmitHEGrenade(position, angles, velocity, pawn); + break; + case "Molotov": + projectile = _core.Game.EmitMolotov(position, angles, velocity, team, pawn); + break; + default: + projectile = _core.Game.EmitDecoy(position, angles, velocity, pawn); + break; + } + + if (projectile.IsValid) + { + _recorder.Forget(projectile.Index); + + _ghostThrows[projectile.Index] = new GhostThrow + { + OwnerSteamId = player.SteamID, + UtilityType = lineup.utility_type, + ClientId = lineup.client_id, + LineupId = lineup.id, + ExpiresAt = DateTime.UtcNow.AddSeconds(GhostThrowSeconds), + }; + } + } + catch (Exception error) + { + _logger.LogError(error, "unable to replay {utility}", lineup.utility_type); + } + finally + { + _recorder.Emitting = false; + } + } + + // Answers the console line for a ghost the plugin emitted, or null when the + // detonation belongs to somebody's real throw. + public string? GhostDetonated(uint entityIndex, Vec3 position) + { + return _ghostThrows.Remove(entityIndex, out GhostThrow? ghost) + ? Announce(ghost, position) + : null; + } + + // EventMolotovDetonate carries no entity index, so a ghost molotov is + // matched by its owner. The plugin only ever has one ghost in the air per + // player, so there is nothing to disambiguate between. + public string? GhostMolotovDetonated(ulong steamId, Vec3 position) + { + foreach ((uint index, GhostThrow ghost) in _ghostThrows) + { + if (ghost.OwnerSteamId == steamId && ghost.UtilityType == "Molotov") + { + _ghostThrows.Remove(index); + return Announce(ghost, position); + } + } + + return null; + } + + private static string Announce(GhostThrow ghost, Vec3 position) + { + return PracticeSignalUtility.GhostDetonatedLine( + ghost.UtilityType, + position, + ghost.ClientId, + ghost.LineupId, + ghost.OwnerSteamId + ); + } + + public void ClearBloom(ulong steamId) + { + ClearKind(steamId, GhostKind.Bloom); + ClearBloomSmoke(steamId); + } + + public void ClearGhosts(ulong steamId) + { + for (int index = _ghosts.Count - 1; index >= 0; index--) + { + if (_ghosts[index].OwnerSteamId != steamId) + { + continue; + } + + Kill(_ghosts[index]); + _ghosts.RemoveAt(index); + } + + ClearBloomSmoke(steamId); + } + + public void ClearAll() + { + foreach (Ghost ghost in _ghosts) + { + Kill(ghost); + } + + _ghosts.Clear(); + _ghostThrows.Clear(); + + ClearMarkers(); + + foreach (ulong steamId in _bloomSmoke.Keys.ToList()) + { + ClearBloomSmoke(steamId); + } + } + + private void ClearKind(ulong steamId, GhostKind kind) + { + for (int index = _ghosts.Count - 1; index >= 0; index--) + { + if (_ghosts[index].OwnerSteamId != steamId || _ghosts[index].Kind != kind) + { + continue; + } + + Kill(_ghosts[index]); + _ghosts.RemoveAt(index); + } + } + + private int BloomBeamCount() + { + return _ghosts + .Where(ghost => ghost.Kind == GhostKind.Bloom) + .Sum(ghost => ghost.Beams.Count); + } + + public void Sweep() + { + DateTime now = DateTime.UtcNow; + + // A ghost that never reported a detonation is a projectile that was + // removed some other way. Its entry is dropped rather than left to + // shadow whatever lands on that index next. + foreach ((uint index, GhostThrow ghost) in _ghostThrows.ToList()) + { + if (ghost.ExpiresAt <= now) + { + _ghostThrows.Remove(index); + } + } + + foreach ((ulong owner, CSmokeGrenadeProjectile smoke) in _bloomSmoke.ToList()) + { + if (!smoke.IsValid) + { + _bloomSmoke.Remove(owner); + } + } + + for (int index = _ghosts.Count - 1; index >= 0; index--) + { + if (_ghosts[index].ExpiresAt > now) + { + continue; + } + + Kill(_ghosts[index]); + _ghosts.RemoveAt(index); + } + } + + // What to actually do once the crosshair is on the reticle. Everything the + // lineup knows about the throw, in the order a player performs it. + public static string ThrowHint(LineupRecord lineup) + { + string bind = lineup.release.jump_throw ? " + JUMP-THROW BIND" : ""; + + // No LINED UP banner: the crosshair fading to nothing already says it, + // and this line has a standing job on the card rather than appearing + // only at the moment of success. + return $"{lineup.utility_type.ToUpperInvariant()} - " + + $"{PracticeLineupUtility.TechniqueLabel(lineup.technique)} - " + + $"{PracticeLineupUtility.StrengthLabel(lineup.strength)}{bind}"; + } + + public static string Describe(LineupRecord lineup) + { + string name = string.IsNullOrEmpty(lineup.name) ? "unnamed" : lineup.name; + string strength = string.IsNullOrEmpty(lineup.strength) ? "" : $" / {lineup.strength}"; + + return $"{name}\n{lineup.utility_type} - {lineup.technique}{strength}"; + } + + // Public so a drill can re-arm a player for another rep without moving + // them: the point of a repeat is throwing the same lineup again, and being + // teleported back onto the spot each time takes the walk-up away. + public void GiveUtility(IPlayer player, string utilityType) + { + string? weapon = PracticeLineupUtility.WeaponForUtilityType(utilityType); + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (weapon == null || pawn == null || !pawn.IsValid) + { + return; + } + + if (!HasWeapon(pawn, weapon)) + { + pawn.ItemServices?.GiveItem(weapon); + } + + pawn.WeaponServices?.SelectWeaponByDesignerName(weapon); + } + + private static bool HasWeapon(CCSPlayerPawn pawn, string designerName) + { + CPlayer_WeaponServices? weapons = pawn.WeaponServices; + + if (weapons == null) + { + return false; + } + + foreach (CBasePlayerWeapon weapon in weapons.MyValidWeapons) + { + if (weapon.DesignerName == designerName) + { + return true; + } + } + + return false; + } + + private void ReapplyAngles(IPlayer player, QAngle facing, QAngle aim, int frames) + { + if (frames <= 0) + { + return; + } + + _core.Scheduler.NextTick(() => + { + if (!player.IsValid) + { + return; + } + + player.Teleport(null, facing, new Vector(0, 0, 0)); + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn != null && pawn.IsValid) + { + pawn.EyeAngles = aim; + } + + ReapplyAngles(player, facing, aim, frames - 1); + }); + } + + // Valve's own guides mark three things per lineup -- where you stand, what + // you look at, and where it lands -- and that split is the right one, so + // these mirror it. It is drawn from entities the server owns rather than + // the annotation system, which is client-side and cannot be driven from + // here at all. + // The stance is passed in rather than traced again: by the time markers are + // drawn the player is standing on the spot, and a downward trace from + // inside their own hull hits them instead of the floor. Load works it out + // before the teleport, while the spot is still empty. + // Stands the player where the utility lands, looking back down the throw. + // + // Grounded, because a detonation is a point in the air and teleporting into + // it drops the player out of it -- the useful place to inspect a smoke from + // is the floor underneath it. Facing back toward the stance because the + // question at the landing end is always "where did this come from". + public bool JumpToLanding(IPlayer player, LineupRecord lineup) + { + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return false; + } + + Vec3 landing = Grounded(lineup.detonation_position); + Vec3 stance = lineup.release.feet_position; + + float yaw = (float)( + Math.Atan2(stance.y - landing.y, stance.x - landing.x) * 180.0 / Math.PI + ); + + var position = new Vector(landing.x, landing.y, landing.z); + var facing = new QAngle(0, yaw, 0); + + player.Teleport(position, facing, new Vector(0, 0, 0)); + pawn.EyeAngles = facing; + + // The client re-predicts from the command it had in flight and snaps + // the view back, so once is not enough. + ReapplyAngles(player, facing, facing, 2); + + return true; + } + + // Markers for a lineup the player is already standing on, with no teleport: + // used straight after .save, where moving them would be pointless. + public void ShowMarkersFor(IPlayer player, LineupRecord lineup) + { + if (!DrawMarkers) + { + return; + } + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return; + } + + Vector feet = pawn.AbsOrigin ?? new Vector(0, 0, 0); + IReadOnlyList everything = All(player.SteamID); + + var standing = new Vec3(feet.X, feet.Y, feet.Z); + IReadOnlyList library = + everything.Count > 0 ? everything : new[] { lineup }; + + List here = SpotAt(library, standing); + + if (!here.Any(entry => entry.client_id == lineup.client_id)) + { + here.Add(lineup); + } + + ShowLibrary(library); + ShowSelection(player, here, standing); + } + + // Every lineup on the map at once. Loading one and cycling with .next hides + // the thing a practice server is for: seeing where all the smokes go and + // walking between them. The focused one gets the full treatment; the rest + // stay as a stance ring and a landing ring so a map full of them is still + // readable and still cheap. + // How close a player has to be to a stance ring to be "in" it. A little + // wider than the ring itself so stepping onto the marker counts. + public const float SpotRadius = 40f; + + // How far above or below a spot still counts as the same place to stand. + public const float SpotHeight = 72f; + + // Every lineup on the map, plus the full crosshair treatment for the ones + // the player can actually throw from where they are standing. One spot + // often has several throws off it, and the whole point of standing there is + // to see all of them at once. + public void ShowAllMarkers( + IEnumerable lineups, + IReadOnlyCollection active, + Vec3 stance + ) + { + ShowLibrary(lineups); + ShowSelection(null, active, stance); + } + + // The library layer. Every lineup gets the same quiet treatment -- no + // exclusions, because a lineup one player has focused is still just a ring + // to everyone else. + // The most lineups the resting layer will draw. Each costs ~8 networked + // entities (7 beams + a label) plus a prop per spot, and the panel serves + // up to 500 -- drawn in full that is thousands of edicts, which is a server + // crash delivered by popularity rather than by any bug. The API returns + // newest first, so what survives the cap is the newest. + private const int MaxLibraryDrawn = 150; + + public void ShowLibrary(IEnumerable lineups) + { + if (!DrawMarkers) + { + return; + } + + ClearSharedMarkers(); + + _drawingInto = null; + + List all = lineups.ToList(); + List drawn = all.Take(MaxLibraryDrawn).ToList(); + + if (drawn.Count < all.Count) + { + // Never silently: a capped map reads as "this is everything". + _logger.LogWarning( + "library draw capped at {drawn} of {total} lineups", + drawn.Count, + all.Count + ); + } + + lineups = drawn; + + foreach (LineupRecord lineup in lineups) + { + Color type = ColorFor(lineup.utility_type); + Vec3 feet = Grounded(lineup.release.feet_position); + + // Seven beams where there used to be twenty-one. A map holds + // hundreds of these at once, so the resting state has to be the + // cheapest thing on screen as well as the quietest -- and no + // connecting line at rest, which is what turned a busy map into a + // cat's cradle. + // 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. + Needle(feet, lineup.release.yaw, 13f, AmberDim, MarkerWidth); + Diamond(lineup.detonation_position, 22f, type, MarkerWidth); + } + + ShowSpotUtility(lineups); + } + + // 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 + // a smoke and a flash still shows both, because that is a real choice about + // what to equip. + private void ShowSpotUtility(IEnumerable lineups) + { + List<(float x, float y, float z, List types)> spots = + PracticeLineupUtility.UtilityBySpot( + lineups.Select(lineup => + { + Vec3 feet = Grounded(lineup.release.feet_position); + + return (feet.x, feet.y, feet.z, lineup.utility_type); + }), + SpotRadius, + SpotHeight + ); + + foreach ((float x, float y, float z, List types) spot in spots) + { + for (int index = 0; index < spot.types.Count; index += 1) + { + // Centred row, so a single grenade sits over the middle of the + // ring and two straddle it rather than one sitting off to a side. + float offset = (index - ((spot.types.Count - 1) / 2f)) * UtilityModelSpacing; + + UtilityModel(spot.types[index], new Vec3(spot.x + offset, spot.y, spot.z)); + } + } + } + + // One player's focused lineups: the big ring, the STAND label and the aim + // crosshair. Hidden from every other viewer, so nobody else's movement can + // take it away. A null owner draws into the shared layer, which is only for + // the single-player paths that predate the split. + public void ShowSelection( + IPlayer? owner, + IReadOnlyCollection active, + Vec3 stance, + LineupRecord? focused = null + ) + { + if (!DrawMarkers) + { + return; + } + + _focused = focused; + + if (owner == null) + { + _drawingInto = null; + + if (active.Count > 0) + { + ShowStance(Grounded(active.First().release.feet_position)); + } + + foreach (LineupRecord lineup in active) + { + ShowMarkers(lineup); + } + + return; + } + + ClearSelection(owner.SteamID); + + if (active.Count == 0) + { + return; + } + + var selection = new Selection(); + + _selections[owner.SteamID] = selection; + _drawingInto = selection; + + // The gate marks the RECORDED spot, never where the player happens to + // be standing -- SpotWatch passes the player's own position, and a gate + // drawn under their feet can never tell them they are off it. + selection.At = Grounded(active.First().release.feet_position); + + try + { + _stanceInto = selection.Stance; + + try + { + ShowStance(selection.At); + } + finally + { + _stanceInto = null; + } + + foreach (LineupRecord lineup in active) + { + ShowMarkers(lineup); + } + } + finally + { + _drawingInto = null; + } + + } + + // The gate answers the other half of the question. A player who is off the + // angle and a player who is off the spot both see "not yet" -- in the same + // colours, on the marker that is actually wrong. + private void TintStance(Selection selection, Vec3 feet) + { + int bucket = BucketFor( + PracticeLineupUtility.StanceMiss( + new Vec3(selection.At.x - feet.x, selection.At.y - feet.y, 0f).LengthXY() + ) + ); + + if (bucket == selection.Bucket) + { + return; + } + + selection.Bucket = bucket; + Recolour(selection.Stance, ColorForBucket(bucket)); + } + + public void ClearSelectionFor(ulong steamId) + { + ClearSelection(steamId); + } + + private void ClearSelection(ulong steamId) + { + if (!_selections.TryGetValue(steamId, out Selection? selection)) + { + return; + } + + foreach (CEnvBeam beam in selection.Beams) + { + if (beam.IsValid) + { + beam.Despawn(); + } + } + + foreach (CPointWorldText text in selection.Texts) + { + if (text.IsValid) + { + text.Despawn(); + } + } + + _selections.Remove(steamId); + } + + // The lineups throwable from where this player is standing, which is what + // decides whose crosshairs get drawn. + public static List SpotAt( + IEnumerable lineups, + Vec3 at + ) + { + return lineups + .Where(lineup => + { + Vec3 feet = lineup.release.feet_position; + + return new Vec3(feet.x - at.x, feet.y - at.y, 0f).LengthXY() + <= SpotRadius + && Math.Abs(feet.z - at.z) <= SpotHeight; + }) + .ToList(); + } + + // Everything about the PLACE rather than any throw off it. Drawn once per + // selection: calling this per lineup stacked identical gates on each other + // and, worse, piled the labels into an unreadable smear. + // Feet are aimed the same way a crosshair is, so the spot gets the same + // instrument: a circle flat on the floor with four arms stopping short of + // the middle -- the gap IS where the feet go -- the whole thing tinted + // green/red by how close the player is standing. This replaces a bracket + // gate and a vertical pillar that said "here" from afar but nothing about + // how close you were once you arrived. + private void GroundReticle(Vec3 at, Color color) + { + float z = at.z + 1.5f; + + for (int index = 0; index < StanceRingSegments; index++) + { + double a = index * 2 * Math.PI / StanceRingSegments; + double b = (index + 1) * 2 * Math.PI / StanceRingSegments; + + AddMarkerBeam( + new Vec3( + at.x + (float)(Math.Cos(a) * StanceRingRadius), + at.y + (float)(Math.Sin(a) * StanceRingRadius), + z + ), + new Vec3( + at.x + (float)(Math.Cos(b) * StanceRingRadius), + at.y + (float)(Math.Sin(b) * StanceRingRadius), + z + ), + color, + StanceWidth + ); + } + + // The same grammar as the reticle in the air, at the same proportions: + // four arms stopping short of the middle so the gap IS the point, and + // a dot small enough that standing over it means standing on the exact + // spot. Finer than the ring around it -- the ring is for finding, the + // crosshair is for placing. + float gap = StanceRingRadius * 0.12f; + float arm = StanceRingRadius * 0.62f; + + foreach ((float x, float y) in new[] { (1f, 0f), (-1f, 0f), (0f, 1f), (0f, -1f) }) + { + AddMarkerBeam( + new Vec3(at.x + (x * gap), at.y + (y * gap), z), + new Vec3(at.x + (x * arm), at.y + (y * arm), z), + color, + StanceCrossWidth + ); + } + + float dot = Math.Max(StanceRingRadius * 0.03f, 0.6f); + + AddMarkerBeam( + new Vec3(at.x - dot, at.y, z), + new Vec3(at.x + dot, at.y, z), + color, + StanceCrossWidth * 1.6f + ); + AddMarkerBeam( + new Vec3(at.x, at.y - dot, z), + new Vec3(at.x, at.y + dot, z), + color, + StanceCrossWidth * 1.6f + ); + } + + private void ShowStance(Vec3 stance) + { + GroundReticle(stance, ColorForBucket(MissBuckets - 1)); + } + + private void ShowMarkers(LineupRecord lineup) + { + // Deliberately not gated behind the ghost preview: a preview is an + // optional extra, but where to stand and where to point IS the lineup. + // There is no useful state where a loaded lineup shows neither. + Color color = ColorFor(lineup.utility_type); + Vec3 landing = lineup.detonation_position; + + 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); + } + + // 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) + { + // 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 + // player position, and tracing the same angles from a different origin + // lands on a different piece of wall: that is the whole reason the + // crosshair "sometimes" appeared in the right place. The point a throw + // is aimed at is a fact about the lineup and the map, so nothing about + // the viewer may enter into it. + // + // Not the release point either: a run- or jump-throw leaves the hand a + // whole run-up away from where it is set up. + Vec3 spot = Grounded(lineup.release.feet_position); + + var eye = new Vec3( + spot.x, + spot.y, + spot.z + PracticeSolverUtility.StandingEyeHeight + ); + + double yaw = lineup.release.yaw * Math.PI / 180.0; + double pitch = lineup.release.pitch * Math.PI / 180.0; + float flat = (float)Math.Cos(pitch); + + // CS2 pitch is negative looking up, so the sign flips here. + var dir = new Vec3( + (float)(Math.Cos(yaw) * flat), + (float)(Math.Sin(yaw) * flat), + (float)(-Math.Sin(pitch)) + ); + + var from = new Vector(eye.x, eye.y, eye.z); + var to = new Vector( + eye.x + dir.x * AimTraceRange, + eye.y + dir.y * AimTraceRange, + eye.z + dir.z * AimTraceRange + ); + + // Traced once per lineup and remembered: the wall a throw points at is + // a static fact of the map, so a reticle that lands in two different + // places across two redraws is always wrong at least once. The cache + // also outlives whatever transient -- a body, a thrown grenade -- might + // wander through the ray on a later redraw. + if (!_aimHits.TryGetValue(lineup.client_id, out Vec3 hit)) + { + try + { + var trace = _core.Trace.TraceShapeLine(from, to, SkipMarkers()); + + hit = trace.DidHit + ? new Vec3(trace.EndPos.X, trace.EndPos.Y, trace.EndPos.Z) + : new Vec3( + eye.x + dir.x * AimFallbackRange, + eye.y + dir.y * AimFallbackRange, + eye.z + dir.z * AimFallbackRange + ); + } + catch (Exception error) + { + _logger.LogError(error, "unable to trace a lineup's aim"); + hit = new Vec3( + eye.x + dir.x * AimFallbackRange, + eye.y + dir.y * AimFallbackRange, + eye.z + dir.z * AimFallbackRange + ); + } + + _aimHits[lineup.client_id] = hit; + } + + // Pulled back off the surface so the reticle does not z-fight with the + // wall it is drawn on. + var center = new Vec3( + hit.x - dir.x * 2f, + hit.y - dir.y * 2f, + hit.z - dir.z * 2f + ); + + float away = new Vec3(center.x - eye.x, center.y - eye.y, center.z - eye.z).Length(); + + // Sized by distance so it looks the same from the stance whether the + // 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); + + // 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 + // stance and landing rings at a glance. + // 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); + + // 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 + // reads as "further away", which is exactly the wrong thing to say + // about a point you are being asked to cover precisely. + var aim = new Aim { Lineup = lineup }; + + _aimInto = aim; + + try + { + Reticle(center, dir, size, ColorForBucket(MissBuckets - 1), weight); + } + finally + { + _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); + } + + // Red at a glance, green when the throw is on. Amber through the middle so + // the last fraction of a degree still has somewhere to go -- a hard + // two-colour switch gives no sense of getting warmer. + private static Color MissColor(float miss) + { + miss = Math.Clamp(miss, 0f, 1f); + + return new Color( + (int)(60f + (195f * miss)), + (int)(230f - (190f * miss)), + (int)(90f * (1f - miss)), + 255 + ); + } + + // Called as the player moves, not as they walk onto a spot: the markers are + // already drawn, and all that changes is how wrong they are. + public void TintAim(IPlayer player, float eyeYaw, float eyePitch, Vec3 feet) + { + if (!DrawMarkers) + { + return; + } + + if (!_selections.TryGetValue(player.SteamID, out Selection? selection)) + { + return; + } + + TintStance(selection, feet); + + foreach (Aim aim in selection.Aims) + { + int bucket = BucketFor( + PracticeLineupUtility.AimMiss( + PracticeLineupUtility.AimError( + eyeYaw, + eyePitch, + aim.Lineup.release.yaw, + aim.Lineup.release.pitch + ), + aim.Lineup.aim_tolerance + ) + ); + + if (bucket == aim.Bucket) + { + continue; + } + + aim.Bucket = bucket; + + // On the angle the crosshair has done its job, and full-strength + // beams would now be sitting exactly where the player needs to see + // the world. Faded to a whisper rather than removed: it stays a + // reference point if they drift, without costing them the view. + Recolour( + aim.Beams, + bucket == 0 ? AimSettled : ColorForBucket(bucket) + ); + } + } + + // A box with a ring inside it, drawn in the plane facing back down the aim + // ray so it reads as something to line a crosshair up with. + private void Reticle(Vec3 center, Vec3 forward, float size, Color color, float width) + { + Vec3 right = Cross(forward, new Vec3(0, 0, 1)); + + // Looking straight up or down leaves no horizon to take "right" from. + if (right.Length() < 0.001f) + { + right = new Vec3(1, 0, 0); + } + + right = Normalize(right); + + Vec3 up = Normalize(Cross(right, forward)); + + Vec3 Corner(float x, float y) => + new Vec3( + center.x + right.x * x + up.x * y, + center.y + right.y * x + up.y * y, + center.z + right.z * x + up.z * y + ); + + // A crosshair, not an area. The four arms stop short of the middle so + // the gap they leave IS the aim point -- a ring or a box tells you + // roughly where to look, and roughly is what this is meant to replace. + float gap = size * 0.12f; + float arm = size * 0.62f; + + AddMarkerBeam(Corner(gap, 0), Corner(arm, 0), color, width); + AddMarkerBeam(Corner(-gap, 0), Corner(-arm, 0), color, width); + AddMarkerBeam(Corner(0, gap), Corner(0, arm), color, width); + AddMarkerBeam(Corner(0, -gap), Corner(0, -arm), color, width); + + // 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); + + AddMarkerBeam(Corner(-dot, 0), Corner(dot, 0), color, width * 1.6f); + AddMarkerBeam(Corner(0, -dot), Corner(0, dot), color, width * 1.6f); + + // Corner brackets rather than a ring, so the crosshair speaks the same + // language as the gate on the floor -- and eight beams instead of + // sixteen, on the marker a spot draws once per throw. + float edge = size; + float bracket = size * 0.34f; + + foreach (int horizontal in new[] { 1, -1 }) + { + foreach (int vertical in new[] { 1, -1 }) + { + float x = edge * horizontal; + float y = edge * vertical; + + AddMarkerBeam( + Corner(x, y), + Corner(x - (bracket * horizontal), y), + color, + width * 0.6f + ); + AddMarkerBeam( + Corner(x, y), + Corner(x, y - (bracket * vertical)), + color, + width * 0.6f + ); + } + } + } + + // Where a player would be STANDING at this spot. Lineups recorded before + // the recorder learned to keep the standstill hold the release origin, + // which for a jump throw is a jump height up in the air -- and a marker + // floating at head height is not somewhere anyone can stand. + private Vec3 Grounded(Vec3 position) + { + try + { + // Started above the point on purpose: a trace that begins flush + // against a surface can report no hit at all, which is exactly the + // case for a lineup already standing on the floor. + var from = new Vector(position.x, position.y, position.z + 8f); + var to = new Vector( + position.x, + position.y, + position.z - GroundSnapRange + ); + + var trace = _core.Trace.TraceShapeLine(from, to, SkipMarkers()); + + if (!trace.DidHit) + { + _logger.LogWarning( + "lineup stance at {x},{y},{z} found no floor within {range} units", + position.x, + position.y, + position.z, + GroundSnapRange + ); + return position; + } + + float drop = position.z - trace.EndPos.Z; + + if (drop > 1f) + { + _logger.LogInformation( + "lineup stance lowered {drop} units onto the floor (recorded airborne)", + drop + ); + } + + return new Vec3(position.x, position.y, trace.EndPos.Z); + } + catch (Exception error) + { + _logger.LogError(error, "unable to find the floor under a lineup"); + return position; + } + } + + // The grenade itself, floating over the spot at about eye height: a ring + // says where to stand, and this says what to throw from it without reading + // a colour off a beam. + // The panel's --tac-amber ramp, straight off assets/css/tailwind.css. + // Amber is the colour of YOU in this scheme -- where to stand, which way to + // 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; + + private const float StanceCrossWidth = 0.9f; + + // A circle a player fits inside, with the crosshair gap at its centre. + private const float StanceRingRadius = 22f; + private const int StanceRingSegments = 14; + + // 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 + // predecessor's litter is a mark it can read back off the world itself. + private const string MarkerTag = "5stack_utility_marker"; + + // 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. + private static readonly string[] MarkerClasses = + { + "env_beam", + "point_worldtext", + "prop_physics_override", + }; + + private static CEntityKeyValues Tagged() + { + var keys = new CEntityKeyValues(); + + keys.SetString("targetname", MarkerTag); + + return keys; + } + + // 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() + { + int swept = 0; + + foreach (string designer in MarkerClasses) + { + try + { + foreach ( + CBaseEntity entity in _core.EntitySystem.GetAllEntitiesByDesignerName( + designer + ) + ) + { + if (!entity.IsValid || entity.Entity?.Name != MarkerTag) + { + continue; + } + + entity.Despawn(); + swept += 1; + } + } + catch (Exception error) + { + _logger.LogWarning(error, "unable to sweep {designer} markers", designer); + } + } + + ForgetMarkers(); + + return swept; + } + + // FSOLID_NOT_SOLID. + private const byte NotSolid = 4; + + // Flat forward/right for a yaw, so a marker can be built facing the throw + // instead of facing whatever way the map's axes happen to run. + private static (Vec3 forward, Vec3 right) Bearing(float yaw) + { + double radians = yaw * Math.PI / 180d; + var forward = new Vec3((float)Math.Cos(radians), (float)Math.Sin(radians), 0f); + + return (forward, new Vec3(forward.y, -forward.x, 0f)); + } + + // A needle, not a wedge: the shaft lies exactly on the throw's yaw, so a + // player standing on the spot can set their crosshair BY it the same way + // the reticle in the air is set. The fat chevron this replaces pointed + // "roughly there", which stops being useful the moment feet are down and + // precision becomes the whole question. + private void Needle(Vec3 at, float yaw, float size, Color color, float width) + { + (Vec3 forward, Vec3 right) = Bearing(yaw); + float z = at.z + 1.5f; + + Vec3 At(float along, float across) + { + return new Vec3( + at.x + (forward.x * along) + (right.x * across), + at.y + (forward.y * along) + (right.y * across), + z + ); + } + + Vec3 tip = At(size * 2.2f, 0f); + + AddMarkerBeam(At(size * 0.5f, 0f), tip, color, width); + AddMarkerBeam(At(size * 1.75f, size * 0.35f), tip, color, width); + AddMarkerBeam(At(size * 1.75f, -size * 0.35f), tip, color, width); + } + + // Four beams, and deliberately not a circle: the landing marker and the + // stance marker must never be mistaken for each other at a glance. + private void Diamond(Vec3 at, float radius, Color color, float width) + { + var north = new Vec3(at.x, at.y + radius, at.z); + var east = new Vec3(at.x + radius, at.y, at.z); + var south = new Vec3(at.x, at.y - radius, at.z); + var west = new Vec3(at.x - radius, at.y, at.z); + + AddMarkerBeam(north, east, color, width); + AddMarkerBeam(east, south, color, width); + AddMarkerBeam(south, west, color, width); + AddMarkerBeam(west, north, color, width); + } + + private void UtilityModel(string utilityType, Vec3 at) + { + if (!DrawModels) + { + return; + } + + if (!Sane(at)) + { + return; + } + + string? model = PracticeLineupUtility.ModelForUtilityType(utilityType); + + if (model == null) + { + return; + } + + try + { + // A physics prop, not a dynamic one. Grenade models carry propdata, + // and CS2 deletes them off a prop_dynamic on sight -- "which has + // propdata which means that it be used on a prop_physics" -- so the + // engine's own answer is the class to use. prop_dynamic_override + // does NOT bypass that check here the way it did in Source 1. + CPhysicsProp prop = + _core.EntitySystem.CreateEntityByDesignerName( + "prop_physics_override" + ); + + if (!prop.IsValid) + { + return; + } + + // The model arrives as a spawn KEYVALUE, never through SetModel. + // Both orderings of SetModel are wrong: after DispatchSpawn the + // entity is already networked without a model and stays the ERROR + // model, and before it the entity is still in the staging list, + // which trips the SetupModel assertion in skeletoninstance.cpp. + var keys = new CEntityKeyValues(); + var origin = new Vector(at.x, at.y, at.z + UtilityModelHeight); + + keys.SetString("targetname", MarkerTag); + keys.SetString("model", model); + keys.SetString("solid", "0"); + keys.SetString( + "origin", + $"{origin.X.ToString(CultureInfo.InvariantCulture)} " + + $"{origin.Y.ToString(CultureInfo.InvariantCulture)} " + + $"{origin.Z.ToString(CultureInfo.InvariantCulture)}" + ); + + prop.DispatchSpawn(keys); + + prop.Teleport(origin, new QAngle(0, 0, 0), new Vector(0, 0, 0)); + + // Otherwise it is a physics object: it falls off the marker, and a + // player can shoot it across the map. + prop.AcceptInput("DisableMotion", "", null, null, 0); + + // The model floats at chest height ON the spot a player is being + // told to stand, so anything short of completely intangible traps + // them inside it. The "solid" keyvalue alone does not survive + // prop_physics building its own VPhysics on spawn. + prop.Collision.SolidType = SolidType_t.SOLID_NONE; + prop.Collision.SolidFlags = NotSolid; + prop.Collision.CollisionGroup = (byte)CollisionGroup.Nonphysical; + prop.Collision.CollisionAttribute.CollisionGroup = (byte)CollisionGroup.Nonphysical; + prop.Collision.CollisionAttribute.InteractsAs = 0; + prop.Collision.CollisionAttribute.InteractsWith = 0; + prop.CollisionRulesChanged(); + + // Through-wall findability, on the model itself rather than a + // separate marker: the grenade IS the sign for "there is a lineup + // here", so it carries its own outline. Set on the spawn tick so it + // rides the entity's first snapshot -- the same networking caution + // that forced the beam rebuilds. Type 3 is the through-walls + // outline; team -1 shows it to everyone. + prop.Glow.GlowType = 3; + prop.Glow.GlowColorOverride = ColorFor(utilityType); + prop.Glow.GlowRange = UtilityGlowRange; + prop.Glow.GlowRangeMin = 0; + prop.Glow.GlowTeam = -1; + + // Without this the outline only exists through walls: the moment + // the model is actually on screen the glow is culled, which reads + // as "it vanishes when I look at it". + prop.Glow.EligibleForScreenHighlight = true; + prop.Glow.Glowing = true; + + _markerProps.Add(prop); + } + catch (Exception error) + { + _logger.LogError(error, "unable to show a lineup's utility model"); + } + } + + private void AddMarkerBeam(Vec3 start, Vec3 end, Color color, float width) + { + CEnvBeam? beam = CreateBeam(start, end, color, width); + + if (beam == null) + { + return; + } + + _aimInto?.Beams.Add(beam); + _stanceInto?.Add(beam); + + if (_drawingInto != null) + { + _drawingInto.Beams.Add(beam); + } + else + { + _markerBeams.Add(beam); + } + } + + private static Vec3 Cross(Vec3 a, Vec3 b) + { + return new Vec3( + a.y * b.z - a.z * b.y, + a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x + ); + } + + private static Vec3 Normalize(Vec3 v) + { + float length = v.Length(); + + return length < 0.0001f ? v : new Vec3(v.x / length, v.y / length, v.z / length); + } + + // facing: where the text should read from, normally the spot the player is + // standing on. Passing null keeps the auto-reorient, which is right for a + // label lying on the floor and wrong for one on a wall. + 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, and is spawned + // with no angle of its own. Aiming one by hand is what produced + // text lying on its side and mirrored: point_worldtext draws in its + // own flat plane, so any hand-set angle is a plane you end up + // reading edge-on or from behind. There is no orientation worth + // computing here -- the engine already knows where the reader is. + 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, 0, 0); + + label.Teleport( + new Vector(at.x, at.y, at.z), + angle, + new Vector(0, 0, 0) + ); + + label.DispatchSpawn(Tagged()); + + 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. + public void ForgetMarkers() + { + _aimHits.Clear(); + _markerBeams.Clear(); + _markerTexts.Clear(); + _markerProps.Clear(); + _selections.Clear(); + _drawingInto = null; + } + + // The library layer only. The selection layer belongs to individual players + // and outlives a library redraw. + private void ClearSharedMarkers() + { + foreach (CEnvBeam beam in _markerBeams) + { + if (beam.IsValid) + { + beam.Despawn(); + } + } + + foreach (CPointWorldText label in _markerTexts) + { + if (label.IsValid) + { + label.Despawn(); + } + } + + foreach (CPhysicsProp prop in _markerProps) + { + if (prop.IsValid) + { + prop.Despawn(); + } + } + + _markerBeams.Clear(); + _markerTexts.Clear(); + _markerProps.Clear(); + } + + public void ClearMarkers() + { + foreach (ulong steamId in _selections.Keys.ToList()) + { + ClearSelection(steamId); + } + + foreach (CEnvBeam beam in _markerBeams) + { + if (beam.IsValid) + { + beam.Despawn(); + } + } + + foreach (CPointWorldText label in _markerTexts) + { + if (label.IsValid) + { + label.Despawn(); + } + } + + foreach (CPhysicsProp prop in _markerProps) + { + if (prop.IsValid) + { + prop.Despawn(); + } + } + + _markerBeams.Clear(); + _markerTexts.Clear(); + _markerProps.Clear(); + } + + // A NaN or an infinity reaching Teleport takes the whole server down inside + // native code, where the try/catch below cannot see it -- so coordinates are + // checked before the engine ever sees them, not after it faults. + private static bool Sane(Vec3 point) + { + return float.IsFinite(point.x) && float.IsFinite(point.y) && float.IsFinite(point.z); + } + + private CEnvBeam? CreateBeam(Vec3 start, Vec3 end, Color color, float width) + { + if (!Sane(start) || !Sane(end)) + { + _logger.LogWarning( + "refusing to draw a beam at {sx},{sy},{sz} -> {ex},{ey},{ez}", + start.x, + start.y, + start.z, + end.x, + end.y, + end.z + ); + + return null; + } + + try + { + CEnvBeam beam = _core.EntitySystem.CreateEntityByDesignerName("env_beam"); + + if (!beam.IsValid) + { + return null; + } + + beam.Render = color; + beam.Width = width; + + beam.Teleport( + new Vector(start.x, start.y, start.z), + new QAngle(0, 0, 0), + new Vector(0, 0, 0) + ); + + beam.EndPos = new Vector(end.x, end.y, end.z); + + beam.DispatchSpawn(Tagged()); + + return beam; + } + catch (Exception error) + { + _logger.LogError(error, "unable to draw a lineup preview"); + return null; + } + } + + private void Kill(Ghost ghost) + { + foreach (CEnvBeam beam in ghost.Beams) + { + if (!beam.IsValid) + { + continue; + } + + beam.Despawn(); + } + } + + private static Color ColorFor(string utilityType) + { + switch (utilityType) + { + case "Smoke": + return new Color(220, 220, 220, 255); + case "Flash": + return new Color(120, 180, 255, 255); + case "HighExplosive": + return new Color(255, 90, 90, 255); + case "Molotov": + return new Color(255, 150, 40, 255); + default: + return new Color(200, 120, 255, 255); + } + } +} diff --git a/apps/utility-sw/src/Services/PracticeScore.cs b/apps/utility-sw/src/Services/PracticeScore.cs new file mode 100644 index 00000000..bd0d7e63 --- /dev/null +++ b/apps/utility-sw/src/Services/PracticeScore.cs @@ -0,0 +1,165 @@ +using FiveStack.Entities.Practice; +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Players; +using static SwiftlyS2.Shared.Helper; + +namespace UtilityPractice; + +// Scores a throw against the lineup the thrower had loaded. +// +// The panel recomputes the distance from the lineup it owns and treats what is +// reported here as advisory, so this reports and does not argue: the success +// flag is only filled in once the panel has told us what radius it is using, +// and the streak a player is shown is always the panel's answer. +public class PracticeScore +{ + private readonly ISwiftlyCore _core; + private readonly UtilityConfig _config; + private readonly UtilityApiClient _api; + private readonly PracticeSession _session; + private readonly PracticeSystem _system; + + // The last radius the panel used, so the advisory flag is a belief we were + // given rather than a number hard-coded here. + private float? _radius; + + private readonly HashSet _mastered = new HashSet(); + + public PracticeScore( + ISwiftlyCore core, + UtilityConfig config, + UtilityApiClient api, + PracticeSession session, + PracticeSystem system + ) + { + _core = core; + _config = config; + _api = api; + _session = session; + _system = system; + } + + // Raised once a throw has been through the panel, or once it is known that + // it could not be. A null result is "not scored", which is not the same as + // a miss: a drill counts on being able to tell the two apart. + public event Action? Scored; + + public void Reset() + { + _mastered.Clear(); + } + + // Raised by the recorder once a throw is over, which is the only moment + // both the thrower and the landing point are known. + public void OnFinalized(LineupRecord thrown) + { + if (!_config.IsConnected() || !ulong.TryParse(thrown.author_steam_id, out ulong steamId)) + { + return; + } + + LineupRecord? loaded = _system.StateFor(steamId).Loaded; + + // Nothing loaded is not a practice attempt, and a lineup that only + // exists on this server has no id for the panel to score against. + if (loaded == null || string.IsNullOrEmpty(loaded.id)) + { + return; + } + + // Throwing a flash while a smoke lineup is loaded is a different throw, + // not a missed one. + if (loaded.utility_type != thrown.utility_type) + { + return; + } + + Vec3 landing = thrown.detonation_position; + float distance = (landing - loaded.detonation_position).Length(); + + var payload = UtilityPracticeResultPayload.For( + _config.ServerId, + _session.Current?.id ?? Guid.Empty, + loaded.id, + steamId, + landing, + _radius == null ? null : distance <= _radius + ); + + string lineupId = loaded.id; + string key = $"{lineupId}:{steamId}"; + string name = string.IsNullOrEmpty(loaded.name) ? "that lineup" : loaded.name; + + _ = Task.Run(async () => + { + UtilityPracticeResult? result = await _api.PracticeResult(payload); + + _core.Scheduler.NextTick(() => Report(steamId, lineupId, key, name, result, distance)); + }); + } + + private void Report( + ulong steamId, + string lineupId, + string key, + string name, + UtilityPracticeResult? result, + float measured + ) + { + if (result != null) + { + _radius = result.radius; + } + + Announce(steamId, key, name, result, measured); + + // Raised last and unconditionally: the verdict belongs on the player's + // screen before whatever a run says about it, and a run's bookkeeping + // is not allowed to depend on the thrower still standing there. + Scored?.Invoke(steamId, lineupId, result); + } + + private void Announce( + ulong steamId, + string key, + string name, + UtilityPracticeResult? result, + float measured + ) + { + IPlayer? player = _system.Find(steamId); + + if (player == null || !player.IsValid) + { + return; + } + + if (result == null) + { + player.SendChat( + $" {ChatColors.Grey}{measured:0}u from {name} {ChatColors.Default}(not scored; the panel did not answer)".Colored() + ); + return; + } + + player.SendChat( + ( + result.success + ? $" {ChatColors.Green}hit {ChatColors.Default}{name} {ChatColors.Grey}{result.distance:0}u - streak {result.current_streak} (best {result.best_streak})" + : $" {ChatColors.Red}miss {ChatColors.Default}{name} {ChatColors.Grey}{result.distance:0}u, needs {result.radius:0}u - {result.successes}/{result.attempts}" + ).Colored() + ); + + if (result.mastered_at == null || !_mastered.Add(key)) + { + return; + } + + player.SendChat( + $" {ChatColors.Gold}mastered {ChatColors.Default}{name} {ChatColors.Grey}({result.best_streak} in a row)".Colored() + ); + player.SendCenter($"mastered\n{name}"); + } +} diff --git a/apps/utility-sw/src/Services/PracticeSession.cs b/apps/utility-sw/src/Services/PracticeSession.cs new file mode 100644 index 00000000..ec6ee0a9 --- /dev/null +++ b/apps/utility-sw/src/Services/PracticeSession.cs @@ -0,0 +1,94 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace UtilityPractice; + +// A practice server never loads the match plugin, so this is where it learns +// who it is hosting for. The roster is the door policy; the connect hook reads +// nothing else. +public class PracticeSession +{ + private readonly UtilityApiClient _api; + private readonly ILogger _logger; + + private PracticeSessionData? _session; + + public PracticeSession(UtilityApiClient api, ILogger logger) + { + _api = api; + _logger = logger; + } + + public event Action? Refreshed; + + public PracticeSessionData? Current => _session; + + private int _refreshing; + private DateTime _lastAttempt = DateTime.MinValue; + + // The map-load fetch is one shot, and an unattended server has nobody on it + // to cause another -- a render pod cannot even connect until the roster + // exists. One transient failure of that single call used to brick the + // server silently. This is the safety net: keep asking, spaced out, only + // while we still know nothing. + public void RetryIfMissing(TimeSpan minInterval) + { + if (_session != null) + { + return; + } + + if (DateTime.UtcNow - _lastAttempt < minInterval) + { + return; + } + + if (Interlocked.Exchange(ref _refreshing, 1) == 1) + { + return; + } + + _lastAttempt = DateTime.UtcNow; + + _ = Task.Run(async () => + { + try + { + await Refresh(); + } + finally + { + Interlocked.Exchange(ref _refreshing, 0); + } + }); + } + + public async Task Refresh() + { + PracticeSessionData? session = await _api.Session(); + + // A failed fetch must not empty the roster: everyone already connected + // stays connected, and the door keeps the policy it had. + if (session == null) + { + _logger.LogWarning("unable to refresh the practice session; keeping the last roster"); + return; + } + + _session = session; + + _logger.LogInformation( + "practice session {id} ({players} players allowed)", + session.id, + session.allowed_steam_ids.Count + ); + + Refreshed?.Invoke(session); + } + + public bool IsAllowed(ulong steamId) + { + return _session != null && PracticeConnectUtility.IsOnRoster(_session, steamId); + } +} diff --git a/apps/utility-sw/src/Services/PracticeSolver.cs b/apps/utility-sw/src/Services/PracticeSolver.cs new file mode 100644 index 00000000..3716d90c --- /dev/null +++ b/apps/utility-sw/src/Services/PracticeSolver.cs @@ -0,0 +1,766 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.SchemaDefinitions; + +namespace UtilityPractice; + +// Finds a throw that lands on a chosen point by throwing. +// +// There is no physics in here. Reimplementing CS2's grenade behaviour against a +// collision mesh is weeks of work that drifts every time the game updates, and +// it is unnecessary: a practice server already is the physics engine. The +// solver emits real grenades from real seeds and reads where they really went, +// so the only thing it can be wrong about is which throws it chose to try. +// +// Everything that decides anything lives in PracticeSolverPlan and +// PracticeCalibrationUtility, which have no engine types in them and are tested +// without a server. This class is the part that cannot be: emitting, hiding, +// sampling, reaping. +public class PracticeSolver +{ + // A grenade that has not reported in ten seconds is stuck in geometry or + // was eaten by something. Its slot is worth more than its answer. + private const int ProjectileTimeoutTicks = 64 * 10; + + // Grenades collide with each other. Twenty released from one eye position + // on one tick would spend the batch bouncing off their own siblings, and + // every landing point in it would be a measurement of that rather than of + // the map. Spacing the releases puts about twenty units between consecutive + // grenades on similar lines, which is more than a grenade is wide. + private const int EmitEveryTicks = 2; + + private readonly ISwiftlyCore _core; + private readonly PracticeRecorder _recorder; + private readonly ILogger _logger; + + private enum FlightKind + { + Solve, + Calibration, + Confirmation, + } + + private class InFlight + { + public required SolveCandidate Candidate; + public required int StartTick; + public required FlightKind Kind; + public Vec3 Last; + public bool Seen; + public int Bounces; + } + + private readonly Dictionary _inFlight = new(); + private readonly Queue _releasing = new(); + private readonly Dictionary _calibrated = new(); + + private PracticeSolverPlan? _plan; + private CalibrationReport _calibration = new CalibrationReport(); + private DateTime _startedAt; + private ulong _owner; + private Action? _progress; + private Action? _finished; + + private bool _confirming; + private SolveObservation? _confirmation; + + private CalibrationReport? _calibrating; + private LineupRecord? _replaySample; + private Action? _calibrationDone; + + private int _tick; + private int _lastRelease; + + public PracticeSolver( + ISwiftlyCore core, + PracticeRecorder recorder, + ILogger logger + ) + { + _core = core; + _recorder = recorder; + _logger = logger; + } + + public bool IsBusy => _plan != null || _calibrating != null; + + public string BusyWith => _plan != null ? "a solve" : "a calibration"; + + // EventMolotovDetonate carries no entity index, so the recorder matches a + // molotov to a throw by its thrower. A solver molotov is owned by a real + // player's pawn, which means the recorder would read it as that player's + // own throw landing and finalize their lineup at the solver's landing + // point. While solver molotovs are in the air, nobody's molotov is + // attributable. + public bool EmittingMolotovs => + (_plan != null && _plan.Request.utility_type == "Molotov") + || (_calibrating != null && _replaySample?.utility_type == "Molotov"); + + public CalibrationReport? CalibrationFor(string map) + { + return _calibrated.TryGetValue(map, out CalibrationReport? report) ? report : null; + } + + public void Forget(string map) + { + _calibrated.Remove(map); + } + + public void Reset() + { + foreach (uint index in _inFlight.Keys.ToList()) + { + Retire(index); + } + + _inFlight.Clear(); + _releasing.Clear(); + _calibrated.Clear(); + _plan = null; + _confirming = false; + _confirmation = null; + _calibrating = null; + _replaySample = null; + _progress = null; + _finished = null; + _calibrationDone = null; + } + + // Re-emits a throw whose landing point is already known and answers whether + // the engine put it back in the same place. + // + // This is the precondition for everything else. A solve that ran without it + // would emit hundreds of grenades against an assumption nobody had ever + // tested, and hand back lineups that are confidently wrong -- which a player + // discovers only by walking to the spot and throwing. + public bool Calibrate( + string map, + IEnumerable samples, + Action done + ) + { + if (IsBusy) + { + return false; + } + + var pool = samples.ToList(); + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel(map, pool); + + if (!PracticeCalibrationUtility.LaunchModelPassed(report)) + { + return Answer(report, done); + } + + LineupRecord? sample = PracticeCalibrationUtility.PickReplaySample(pool); + IPlayer? owner = Owner(); + + if (sample == null) + { + report.status = nameof(eCalibrationStatus.NoSample); + report.message = "no throw to replay"; + return Answer(report, done); + } + + if (owner == null) + { + report.status = nameof(eCalibrationStatus.NoSample); + report.message = + "a seeded replay needs a live player to own the projectile, and nobody is on the server"; + return Answer(report, done); + } + + uint? index = Emit( + sample.utility_type, + new LaunchSeed + { + position = sample.initial_position, + velocity = sample.initial_velocity, + }, + owner + ); + + if (index == null) + { + report.status = nameof(eCalibrationStatus.Unsupported); + report.message = "the server refused to emit a grenade"; + return Answer(report, done); + } + + _calibrating = report; + _replaySample = sample; + _calibrationDone = done; + + _inFlight[index.Value] = new InFlight + { + Candidate = default, + StartTick = _tick, + Kind = FlightKind.Calibration, + }; + + return true; + } + + public bool Start( + SolveRequest request, + CalibrationReport calibration, + Action progress, + Action finished + ) + { + if (IsBusy) + { + return false; + } + + request.strengths = calibration.SolvableStrengths(); + + _owner = ulong.TryParse(request.requested_by, out ulong steamId) ? steamId : 0; + _plan = new PracticeSolverPlan(request); + _calibration = calibration; + _startedAt = DateTime.UtcNow; + _progress = progress; + _finished = finished; + _confirming = false; + _confirmation = null; + + return true; + } + + public bool Cancel() + { + if (_plan == null) + { + return false; + } + + Complete(Elapsed(), cancelled: true); + return true; + } + + // Sampling has to happen on the game tick: a projectile that vanishes + // between two slower polls takes its landing point with it. Releasing does + // too, because the spacing between grenades is measured in ticks. + public void OnTick() + { + _tick++; + + Release(); + + if (_inFlight.Count == 0) + { + return; + } + + var expired = new List(); + + foreach ((uint index, InFlight flight) in _inFlight) + { + CBaseCSGrenadeProjectile? projectile = TryProjectileAt(index); + + if (projectile == null || !projectile.IsValid) + { + expired.Add(index); + continue; + } + + if (_tick - flight.StartTick > ProjectileTimeoutTicks) + { + expired.Add(index); + continue; + } + + Vector? origin = projectile.AbsOrigin; + + if (origin != null) + { + flight.Last = new Vec3(origin.Value.X, origin.Value.Y, origin.Value.Z); + flight.Seen = true; + } + + flight.Bounces = projectile.Bounces; + } + + foreach (uint index in expired) + { + Land(index, null); + } + } + + // Called from the same detonate handlers the recorder uses. Only ever + // matches a projectile this class emitted; a player's throw is not in the + // dictionary and falls straight through. + public void OnDetonated(uint entityIndex, Vec3 position) + { + if (_inFlight.ContainsKey(entityIndex)) + { + Land(entityIndex, position); + } + } + + // Drives the batches. Nothing new is queued while anything is still in the + // air or waiting to be released: a batch is the unit of both the entity + // budget and the reporting. + public void Pump() + { + if (_plan == null || _inFlight.Count > 0 || _releasing.Count > 0) + { + return; + } + + if (_confirming) + { + Confirmed(); + return; + } + + float elapsed = Elapsed(); + + if (_plan.Expired(elapsed)) + { + Complete(elapsed, cancelled: false); + return; + } + + if (_plan.Batches > 0) + { + _progress?.Invoke(_plan.Progress()); + } + + List batch = _plan.NextBatch(); + + if (batch.Count == 0) + { + Complete(elapsed, cancelled: false); + return; + } + + foreach (SolveCandidate candidate in batch) + { + _releasing.Enqueue(candidate); + } + } + + // Entity indices are reused, so a standing block outlives its entity unless + // it is lifted. Solver projectiles come and go by the hundred, which makes + // this the one place a leaked block would be guaranteed rather than + // unlikely. + public void RefreshVisibility() + { + foreach (uint index in _inFlight.Keys) + { + Hide(index); + } + } + + private void Release() + { + if (_releasing.Count == 0 || _plan == null) + { + return; + } + + if (_tick - _lastRelease < EmitEveryTicks) + { + return; + } + + _lastRelease = _tick; + + SolveCandidate candidate = _releasing.Dequeue(); + IPlayer? owner = Owner(); + + if (owner == null) + { + _releasing.Clear(); + _progress?.Invoke("nobody is on the server to own the projectiles"); + Complete(Elapsed(), cancelled: true); + return; + } + + LaunchSeed seed = PracticeSolverUtility.SeedFor(_plan.Request, candidate, _calibration); + uint? index = Emit(_plan.Request.utility_type, seed, owner); + + if (index == null) + { + _plan.Observe(new SolveObservation { candidate = candidate }); + return; + } + + _inFlight[index.Value] = new InFlight + { + Candidate = candidate, + StartTick = _tick, + Kind = FlightKind.Solve, + }; + } + + private bool Answer(CalibrationReport report, Action done) + { + _calibrated[report.map] = report; + done(report); + return true; + } + + private float Elapsed() + { + return (float)(DateTime.UtcNow - _startedAt).TotalSeconds; + } + + private void Land(uint index, Vec3? detonation) + { + if (!_inFlight.Remove(index, out InFlight? flight)) + { + return; + } + + Retire(index); + + Vec3? landing = detonation ?? (flight.Seen ? flight.Last : null); + + if (flight.Kind == FlightKind.Calibration) + { + FinishCalibration(landing); + return; + } + + if (_plan == null) + { + return; + } + + var observation = new SolveObservation + { + candidate = flight.Candidate, + landing = landing ?? new Vec3(0f, 0f, 0f), + distance = + landing == null + ? float.MaxValue + : (landing.Value - _plan.Request.target).Length(), + landed = landing != null, + bounces = flight.Bounces, + }; + + if (flight.Kind == FlightKind.Confirmation) + { + _confirmation = observation; + return; + } + + _plan.Observe(observation); + } + + private void FinishCalibration(Vec3? landing) + { + CalibrationReport? report = _calibrating; + LineupRecord? sample = _replaySample; + Action? done = _calibrationDone; + + _calibrating = null; + _replaySample = null; + _calibrationDone = null; + + if (report == null || sample == null) + { + return; + } + + PracticeCalibrationUtility.WithSeedReplay(report, sample, landing); + _calibrated[report.map] = report; + + done?.Invoke(report); + } + + // The winning throw, thrown once more on its own. + // + // A search grenade shares the air with nineteen others, and grenades bounce + // off each other. A candidate that was deflected onto the target by a + // sibling looks like the answer and is not, and nothing later in the + // pipeline could tell the difference -- the lineup would simply not + // reproduce for whoever saved it. One grenade with an empty sky is what + // separates a measurement from a coincidence. + private bool Confirm(SolveObservation best) + { + if (_plan == null) + { + return false; + } + + IPlayer? owner = Owner(); + + if (owner == null) + { + return false; + } + + LaunchSeed seed = PracticeSolverUtility.SeedFor( + _plan.Request, + best.candidate, + _calibration + ); + + uint? index = Emit(_plan.Request.utility_type, seed, owner); + + if (index == null) + { + return false; + } + + _confirming = true; + _confirmation = null; + + _inFlight[index.Value] = new InFlight + { + Candidate = best.candidate, + StartTick = _tick, + Kind = FlightKind.Confirmation, + }; + + _progress?.Invoke("confirming the winning throw on its own..."); + + return true; + } + + private void Confirmed() + { + // A confirmation grenade that never reported is still an answer: it + // is a throw that did not reproduce. + SolveObservation confirmation = _confirmation ?? new SolveObservation(); + + _confirming = false; + _confirmation = null; + + Complete(Elapsed(), cancelled: false, confirmation: confirmation); + } + + private void Complete(float elapsed, bool cancelled, SolveObservation? confirmation = null) + { + PracticeSolverPlan? plan = _plan; + + if (plan == null) + { + return; + } + + SolveResult result = plan.Finish(elapsed); + + // Converged on paper. Throw it once more alone before saying so. + if ( + confirmation == null + && !cancelled + && !_confirming + && result.Converged() + && result.best != null + && Confirm(result.best) + ) + { + return; + } + + Action? finished = _finished; + CalibrationReport calibration = _calibration; + + _plan = null; + _progress = null; + _finished = null; + _confirming = false; + _confirmation = null; + _releasing.Clear(); + + foreach (uint index in _inFlight.Keys.ToList()) + { + Retire(index); + } + + _inFlight.Clear(); + + LineupRecord? lineup = null; + + if (result.Converged() && result.best != null) + { + if ( + confirmation != null + && PracticeSolverUtility.Confirms(confirmation, plan.Request) + ) + { + // The confirmation is the throw that gets saved: it is the one + // nothing else was in the air for. + result.best = confirmation; + result.message = + $"{result.message}, confirmed at {confirmation.distance:0.0}u"; + + lineup = PracticeSolverUtility.ToLineup( + plan.Request, + confirmation, + calibration, + "swiftlys2", + "" + ); + } + else if (confirmation != null) + { + result.outcome = nameof(eSolveOutcome.NoProgress); + result.message = confirmation.landed + ? $"the winning throw landed {confirmation.distance:0.0}u away on its own, so what the search found was solver grenades colliding rather than a lineup" + : "the winning throw did not report a landing when thrown on its own"; + } + else + { + result.outcome = nameof(eSolveOutcome.Refused); + result.message = "stopped before the winning throw could be confirmed"; + } + } + else if (cancelled) + { + result.outcome = nameof(eSolveOutcome.Refused); + result.message = "cancelled"; + } + + finished?.Invoke(result, lineup); + } + + private IPlayer? Owner() + { + IPlayer? preferred = null; + + foreach (IPlayer player in _core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || player.IsFakeClient || !player.IsAlive) + { + continue; + } + + CBasePlayerPawn? pawn = player.Pawn; + + if (pawn == null || !pawn.IsValid) + { + continue; + } + + if (player.SteamID == _owner) + { + return player; + } + + preferred ??= player; + } + + return preferred; + } + + private uint? Emit(string utilityType, LaunchSeed seed, IPlayer owner) + { + CBasePlayerPawn? pawn = owner.Pawn; + + if (pawn == null || !pawn.IsValid) + { + return null; + } + + var position = new Vector(seed.position.x, seed.position.y, seed.position.z); + var velocity = new Vector(seed.velocity.x, seed.velocity.y, seed.velocity.z); + + (float pitch, float yaw) = TrajectoryUtility.AnglesFromVelocity(seed.velocity); + var angles = new QAngle(pitch, yaw, 0); + + Team team = owner.Controller.Team; + + // The recorder's own guard against counting a grenade the plugin threw + // as a lineup somebody made or an attempt somebody took. A solve emits + // hundreds, so this is the difference between a solve and a library + // full of rubbish. + _recorder.Emitting = true; + + try + { + CBaseCSGrenadeProjectile projectile; + + switch (utilityType) + { + case "Smoke": + projectile = _core.Game.EmitSmokeGrenade( + position, + angles, + velocity, + team, + pawn + ); + break; + case "Flash": + projectile = _core.Game.EmitFlashbang(position, angles, velocity, pawn); + break; + case "HighExplosive": + projectile = _core.Game.EmitHEGrenade(position, angles, velocity, pawn); + break; + case "Molotov": + projectile = _core.Game.EmitMolotov(position, angles, velocity, team, pawn); + break; + default: + projectile = _core.Game.EmitDecoy(position, angles, velocity, pawn); + break; + } + + if (!projectile.IsValid) + { + return null; + } + + _recorder.Forget(projectile.Index); + Hide(projectile.Index); + + return projectile.Index; + } + catch (Exception error) + { + _logger.LogError(error, "unable to emit a solver {utility}", utilityType); + return null; + } + finally + { + _recorder.Emitting = false; + } + } + + // Deferred a tick for two reasons: this is usually called from inside the + // projectile's own detonate event, and the transmit block has to outlive + // the entity or it shows for the frame between the two. + private void Retire(uint index) + { + _core.Scheduler.NextTick(() => + { + CBaseCSGrenadeProjectile? projectile = TryProjectileAt(index); + + if (projectile != null && projectile.IsValid) + { + projectile.Despawn(); + } + + Unhide(index); + }); + } + + private void Hide(uint index) + { + } + + private void Unhide(uint index) + { + } + + private CBaseCSGrenadeProjectile? TryProjectileAt(uint index) + { + try + { + return _core.EntitySystem.GetEntityByIndex(index); + } + catch + { + return null; + } + } +} diff --git a/apps/utility-sw/src/Services/PracticeSystem.cs b/apps/utility-sw/src/Services/PracticeSystem.cs new file mode 100644 index 00000000..d8cbe0b0 --- /dev/null +++ b/apps/utility-sw/src/Services/PracticeSystem.cs @@ -0,0 +1,457 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.SchemaDefinitions; + +namespace UtilityPractice; + +public class PracticeState +{ + public LineupRecord? Loaded { 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; + + public Dictionary Positions { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public bool Noclip { get; set; } + public bool God { get; set; } + + // On by default: a lineup preview is one player's working note, not + // something the rest of the server asked to look at. + public bool Solo { get; set; } = true; + + // Off by default: the bloom outline is dozens of entities, and a player who + // has not asked for it should not be paying for it. + public bool Bloom { 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. + public HashSet WarnedInexact { get; } = new HashSet(); + + public DateTime? TimerStartedAt { get; set; } +} + +// Per-player practice state, plus the one repeating job the whole plugin +// shares. One timer iterating players, never a timer per player: a practice +// server with ten people on it would otherwise be running ten of everything. +public class PracticeSystem +{ + private const int MaxSavedPositions = 32; + + private readonly ISwiftlyCore _core; + private readonly UtilityConfig _config; + private readonly PracticeReplay _replay; + private readonly ILogger _logger; + + private readonly Dictionary _states = new(); + private readonly List<(ulong steamId, string weapon)> _regive = new(); + + // Who a solve made invulnerable who had not asked for it, so the flag is + // handed back rather than left on. + private readonly HashSet _shielded = new(); + + public PracticeSystem( + ISwiftlyCore core, + UtilityConfig config, + PracticeReplay replay, + ILogger logger + ) + { + _core = core; + _config = config; + _replay = replay; + _logger = logger; + } + + // Wired by the plugin rather than injected, the same way the replay's solo + // check is: the solver already depends on this service's siblings, and + // asking for it back would close the cycle. + public Func SolveRunning { get; set; } = () => false; + + public PracticeState StateFor(ulong steamId) + { + if (!_states.TryGetValue(steamId, out PracticeState? state)) + { + state = new PracticeState(); + _states[steamId] = state; + } + + return state; + } + + // Defaults to true for a player with no state yet, so a preview is never + // broadcast to the server on the strength of a missing dictionary entry. + public bool IsSolo(ulong steamId) + { + return !_states.TryGetValue(steamId, out PracticeState? state) || state.Solo; + } + + public void Forget(ulong steamId) + { + _states.Remove(steamId); + _shielded.Remove(steamId); + _replay.ClearGhosts(steamId); + _regive.RemoveAll(pending => pending.steamId == steamId); + } + + public void Reset() + { + _states.Clear(); + _regive.Clear(); + _shielded.Clear(); + _replay.ClearAll(); + } + + // The shared second: re-assert the flags the engine keeps resetting, show + // whoever is timing themselves how long they have been at it, and retire + // expired previews. + public void Tick() + { + bool solving = SolveRunning(); + + foreach (IPlayer player in _core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || player.IsFakeClient) + { + continue; + } + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid || !player.IsAlive) + { + continue; + } + + if (!_states.TryGetValue(player.SteamID, out PracticeState? state)) + { + Shield(player.SteamID, pawn, solving); + continue; + } + + ApplyFlags(pawn, state, solving); + + if (state.TimerStartedAt != null) + { + double elapsed = (DateTime.UtcNow - state.TimerStartedAt.Value).TotalSeconds; + player.SendCenter($"{elapsed:0.0}s"); + } + } + + _replay.Sweep(); + } + + // Called on the release edge, so a thrown grenade is in the player's hand + // again a tick or two later. + // Set by the plugin to the drill's Waiting check. A player mid-drill who + // has thrown and not been scored yet gets nothing back until the panel has + // answered -- otherwise three smokes are in the air before the first is + // judged and the run is measuring nothing. + public Func? HoldUtility { get; set; } + + public void OnThrown(ulong steamId, string utilityType) + { + if (!_config.InfiniteUtility) + { + return; + } + + if (HoldUtility?.Invoke(steamId) == true) + { + return; + } + + string? weapon = PracticeLineupUtility.WeaponForUtilityType(utilityType); + + if (weapon == null) + { + return; + } + + _regive.Add((steamId, weapon)); + } + + // sv_infinite_ammo also freezes the throw animation and the pin, which + // makes every recorded release strength wrong; handing the grenade back is + // the only version that leaves the throw itself alone. + public void RefillUtility() + { + if (_regive.Count == 0) + { + return; + } + + var pending = _regive.ToList(); + _regive.Clear(); + + foreach ((ulong steamId, string weapon) in pending) + { + IPlayer? player = Find(steamId); + CCSPlayerPawn? pawn = player?.PlayerPawn; + + if (player == null || !player.IsAlive || pawn == null || !pawn.IsValid) + { + continue; + } + + if (HasWeapon(pawn, weapon)) + { + continue; + } + + pawn.ItemServices?.GiveItem(weapon); + } + } + + // The whole bag, every time somebody is alive without it. A practice + // server that makes you buy your utility before every throw is a practice + // server nobody uses; mp_maxmoney only helps if you remember to go and buy. + private static readonly string[] Loadout = new[] + { + "weapon_smokegrenade", + "weapon_flashbang", + "weapon_hegrenade", + "weapon_molotov", + "weapon_incgrenade", + "weapon_decoy", + }; + + public void GiveUtility(IPlayer player) + { + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid || !player.IsAlive) + { + return; + } + + bool isCt = player.Controller.Team == Team.CT; + + foreach (string weapon in Loadout) + { + // One firebomb per side, and it is not the same one. + if (weapon == "weapon_molotov" && isCt) + { + continue; + } + + if (weapon == "weapon_incgrenade" && !isCt) + { + continue; + } + + if (HasWeapon(pawn, weapon)) + { + continue; + } + + pawn.ItemServices?.GiveItem(weapon); + } + } + + public IPlayer? Find(ulong steamId) + { + foreach (IPlayer player in _core.PlayerManager.GetAllPlayers()) + { + if (player != null && player.IsValid && player.SteamID == steamId) + { + return player; + } + } + + return null; + } + + public List ConnectedSteamIds() + { + return _core + .PlayerManager.GetAllPlayers() + .Where(player => player != null && player.IsValid && !player.IsFakeClient) + .Select(player => player.SteamID) + .ToList(); + } + + public bool SavePosition(IPlayer player, string name) + { + PracticeState state = StateFor(player.SteamID); + + if ( + state.Positions.Count >= MaxSavedPositions + && !state.Positions.ContainsKey(name) + ) + { + return false; + } + + ThrowSnapshot? here = Where(player); + + if (here == null) + { + return false; + } + + state.Positions[name] = here; + return true; + } + + public static ThrowSnapshot? Where(IPlayer player) + { + CCSPlayerPawn? pawn = player.PlayerPawn; + Vector? origin = pawn?.AbsOrigin; + + if (pawn == null || origin == null) + { + return null; + } + + return new ThrowSnapshot + { + feet_position = new Vec3(origin.Value.X, origin.Value.Y, origin.Value.Z), + pitch = pawn.EyeAngles.X, + yaw = pawn.EyeAngles.Y, + }; + } + + public static void TeleportTo(IPlayer player, ThrowSnapshot position) + { + // Yaw only for the body. A pawn's rotation is which way it faces, and + // a lineup's pitch is where the player is LOOKING -- feeding -63 into + // the body lies the model on its back. The aim goes on the eyes below. + player.Teleport( + new Vector( + position.feet_position.x, + position.feet_position.y, + position.feet_position.z + ), + new QAngle(0, position.yaw, 0), + new Vector(0, 0, 0) + ); + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn != null && pawn.IsValid) + { + pawn.EyeAngles = new QAngle(position.pitch, position.yaw, 0); + } + } + + public List SpawnPoints() + { + var spawns = new List(); + + foreach ( + string designer in new[] { "info_player_terrorist", "info_player_counterterrorist" } + ) + { + foreach ( + CBaseEntity spawn in _core.EntitySystem.GetAllEntitiesByDesignerName( + designer + ) + ) + { + Vector? origin = spawn.AbsOrigin; + + if (origin == null) + { + continue; + } + + spawns.Add( + new ThrowSnapshot + { + feet_position = new Vec3( + origin.Value.X, + origin.Value.Y, + origin.Value.Z + ), + yaw = spawn.AbsRotation?.Y ?? 0f, + } + ); + } + } + + return spawns; + } + + // Somebody who has never run a practice command still gets caught by a + // solve's HE and molotovs. Only players this shielded are ever handed back + // to the engine's own answer, so a flag somebody else owns is left alone. + private void Shield(ulong steamId, CCSPlayerPawn pawn, bool solving) + { + if (solving) + { + if (pawn.TakesDamage) + { + pawn.TakesDamage = false; + _shielded.Add(steamId); + } + + return; + } + + if (_shielded.Remove(steamId) && !pawn.TakesDamage) + { + pawn.TakesDamage = true; + } + } + + // Re-asserted every second because respawning resets both. Only a move + // type this plugin set is ever undone: forcing MOVETYPE_WALK on everyone + // would break ladders and spectating for players who never asked for it. + private static void ApplyFlags(CCSPlayerPawn pawn, PracticeState state, bool solving) + { + if (state.Noclip && pawn.MoveType != MoveType_t.MOVETYPE_NOCLIP) + { + SetMoveType(pawn, MoveType_t.MOVETYPE_NOCLIP); + } + else if (!state.Noclip && pawn.MoveType == MoveType_t.MOVETYPE_NOCLIP) + { + SetMoveType(pawn, MoveType_t.MOVETYPE_WALK); + } + + // A solve throws hundreds of live grenades at a point somebody may be + // standing near. Being blown up by the tool you asked for help from is + // not a practice outcome. + bool takesDamage = !state.God && !solving; + + if (pawn.TakesDamage != takesDamage) + { + pawn.TakesDamage = takesDamage; + } + } + + private static void SetMoveType(CCSPlayerPawn pawn, MoveType_t moveType) + { + pawn.MoveType = moveType; + // m_MoveType alone is cosmetic; the engine reads m_nActualMoveType. + pawn.ActualMoveType = moveType; + pawn.MoveTypeUpdated(); + } + + private static bool HasWeapon(CCSPlayerPawn pawn, string designerName) + { + CPlayer_WeaponServices? weapons = pawn.WeaponServices; + + if (weapons == null) + { + return false; + } + + foreach (CBasePlayerWeapon weapon in weapons.MyValidWeapons) + { + if (weapon.DesignerName == designerName) + { + return true; + } + } + + return false; + } +} diff --git a/apps/utility-sw/src/Services/UtilityApiClient.cs b/apps/utility-sw/src/Services/UtilityApiClient.cs new file mode 100644 index 00000000..7b12428f --- /dev/null +++ b/apps/utility-sw/src/Services/UtilityApiClient.cs @@ -0,0 +1,461 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace UtilityPractice; + +// Everything the plugin says to the panel goes through here. Two rules hold for +// every method: it never throws at its caller, and it never runs on the game +// thread past its first await, because a practice server that stutters while a +// lineup uploads is worse than one that loses the upload. +// +// This is also the only place that knows the API's shapes. The API owns the +// wire contract, so LineupRecord is translated to and from it here rather than +// being sent as-is. +public class UtilityApiClient +{ + // A save that could not reach the panel is worth keeping, but only so many: + // an offline practice server left running overnight must not grow forever. + private const int MaxQueued = 64; + + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + + private readonly UtilityConfig _config; + private readonly ILogger _logger; + + private readonly object _queueLock = new object(); + private readonly Queue _retryQueue = new Queue(); + private readonly Queue _resultQueue = + new Queue(); + private readonly SemaphoreSlim _draining = new SemaphoreSlim(1, 1); + + public UtilityApiClient(UtilityConfig config, ILogger logger) + { + _config = config; + _logger = logger; + } + + private class IngestResponse + { + public string? id { get; set; } + } + + public int QueuedCount + { + get + { + lock (_queueLock) + { + return _retryQueue.Count + _resultQueue.Count; + } + } + } + + public async Task Ingest(LineupRecord record) + { + string? id = await Post(record); + + if (id == null) + { + Enqueue(record); + return null; + } + + // The panel is reachable again, so anything held back can go now. + _ = Drain(); + + return id; + } + + public async Task?> Library(string map, ulong steamId) + { + string? body = await SendText( + HttpMethod.Get, + $"/utility/library?map={Uri.EscapeDataString(map)}&steam_id={steamId}", + null + ); + + if (body == null) + { + return null; + } + + try + { + List? rows = ReadList(body, "lineups", "utility"); + + return rows == null + ? new List() + : rows.Select(row => row.ToLineup()).ToList(); + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the lineup library"); + return null; + } + } + + // A library row carries no flight path and no measured bloom, so both cost + // one more call. + public async Task Trajectory(string id, ulong steamId) + { + byte[]? body = await Send( + HttpMethod.Get, + $"/utility/{Uri.EscapeDataString(id)}/trajectory?steam_id={steamId}", + null + ); + + if (body == null) + { + return null; + } + + try + { + return UtilityTrajectoryArtifact.Parse(body); + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the trajectory for {id}", id); + return null; + } + } + + // The only way the panel learns anybody is on this server. A match server + // reports connects over the match-events socket; a practice server has no + // such socket, and without this every session reads as empty and gets + // reaped out from under whoever is throwing. + public async Task Occupancy(IReadOnlyCollection steamIds) + { + string body = JsonSerializer.Serialize( + new { steam_ids = steamIds.Select(id => id.ToString()).ToArray() }, + PracticeJson.Options + ); + + await SendText(HttpMethod.Post, "/utility/occupancy", body); + } + + public async Task Session() + { + string? body = await SendText(HttpMethod.Get, "/utility/session", null); + + if (body == null) + { + return null; + } + + try + { + return JsonSerializer + .Deserialize(body, PracticeJson.Options) + ?.ToSession(); + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the practice session"); + return null; + } + } + + // The panel recomputes the distance from the lineup it owns, so this is a + // report and not a claim. A result that could not be delivered goes on the + // same retry queue a save does -- nobody is waiting to be told about it by + // then, which is why only the live attempt answers. + public async Task PracticeResult(UtilityPracticeResultPayload payload) + { + UtilityPracticeResult? result = await PostResult(payload); + + if (result == null) + { + EnqueueResult(payload); + return null; + } + + _ = Drain(); + + return result; + } + + public async Task Delete(string id) + { + return await SendText(HttpMethod.Delete, $"/utility/{Uri.EscapeDataString(id)}", null) + != null; + } + + // Retries oldest first: a player's saves replay in the order they threw + // them, so the library reads the way the session went. + public async Task Drain() + { + if (!_config.IsConnected() || !await _draining.WaitAsync(0)) + { + return; + } + + try + { + while (true) + { + LineupRecord? record; + lock (_queueLock) + { + if (!_retryQueue.TryPeek(out record)) + { + break; + } + } + + if (await Post(record) == null) + { + return; + } + + lock (_queueLock) + { + _retryQueue.TryDequeue(out _); + } + } + + while (true) + { + UtilityPracticeResultPayload? result; + lock (_queueLock) + { + if (!_resultQueue.TryPeek(out result)) + { + return; + } + } + + if (await PostResult(result) == null) + { + return; + } + + lock (_queueLock) + { + _resultQueue.TryDequeue(out _); + } + } + } + finally + { + _draining.Release(); + } + } + + private void Enqueue(LineupRecord record) + { + lock (_queueLock) + { + while (_retryQueue.Count >= MaxQueued) + { + _retryQueue.TryDequeue(out _); + } + + _retryQueue.Enqueue(record); + } + } + + private void EnqueueResult(UtilityPracticeResultPayload payload) + { + lock (_queueLock) + { + while (_resultQueue.Count >= MaxQueued) + { + _resultQueue.TryDequeue(out _); + } + + _resultQueue.Enqueue(payload); + } + } + + private async Task Post(LineupRecord record) + { + string? body; + + try + { + body = JsonSerializer.Serialize( + UtilityIngestPayload.From(record), + PracticeJson.Options + ); + } + catch (Exception error) + { + // Unserializable means it will never succeed; dropping it beats + // wedging the queue behind it. + _logger.LogError(error, "unable to serialize lineup {client_id}", record.client_id); + return null; + } + + string? response = await SendText(HttpMethod.Post, "/utility/ingest", body); + + if (response == null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(response, PracticeJson.Options)?.id; + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the ingest response"); + return null; + } + } + + private async Task PostResult(UtilityPracticeResultPayload payload) + { + payload.server_id = string.IsNullOrEmpty(_config.ServerId) ? null : _config.ServerId; + + string body; + + try + { + body = JsonSerializer.Serialize(payload, PracticeJson.Options); + } + catch (Exception error) + { + _logger.LogError(error, "unable to serialize a practice result"); + return null; + } + + string? response = await SendText(HttpMethod.Post, "/utility/practice-result", body); + + if (response == null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(response, PracticeJson.Options); + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the practice result"); + return null; + } + } + + // Accepts either a bare array or an envelope naming one, so a wrapper key + // on the API side is not a silently empty library. + private static List? ReadList(string body, params string[] properties) + { + using JsonDocument document = JsonDocument.Parse(body); + + if (document.RootElement.ValueKind == JsonValueKind.Array) + { + return document.RootElement.Deserialize>(PracticeJson.Options); + } + + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (string property in properties) + { + if ( + document.RootElement.TryGetProperty(property, out JsonElement value) + && value.ValueKind == JsonValueKind.Array + ) + { + return value.Deserialize>(PracticeJson.Options); + } + } + + return null; + } + + private async Task SendText(HttpMethod method, string path, string? body) + { + byte[]? response = await Send(method, path, body); + + return response == null ? null : PracticeJson.Text(response); + } + + private async Task Send(HttpMethod method, string path, string? body) + { + if (!_config.IsConnected()) + { + return null; + } + + try + { + using var request = new HttpRequestMessage(method, Url(path)); + + if (!string.IsNullOrEmpty(_config.ServerApiPassword)) + { + request.Headers.TryAddWithoutValidation( + "x-server-api-password", + _config.ServerApiPassword + ); + } + + if (body != null) + { + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + } + + using var timeout = new CancellationTokenSource(RequestTimeout); + using HttpResponseMessage response = await HttpClientProvider.Client.SendAsync( + request, + timeout.Token + ); + + if (!response.IsSuccessStatusCode) + { + // The status alone says a request failed; the body says why. + // "player is not in this match lineup" and "this server has no + // live match" are both 400 and mean completely different things. + string reason = ""; + + try + { + reason = await response.Content.ReadAsStringAsync(); + } + catch + { + // A failure we cannot read is still a failure worth logging. + } + + _logger.LogWarning( + "{method} {path} returned {status}: {reason}", + method.Method, + path, + (int)response.StatusCode, + reason.Length > 500 ? reason.Substring(0, 500) : reason + ); + return null; + } + + return await response.Content.ReadAsByteArrayAsync(); + } + catch (Exception error) + { + _logger.LogError(error, "{method} {path} failed", method.Method, path); + return null; + } + } + + // Every utility endpoint resolves the session from the server rather than from + // anything the caller names, and it needs the server id to do it. + private string Url(string path) + { + if (string.IsNullOrEmpty(_config.ServerId)) + { + return $"{_config.UtilityUrl}{path}"; + } + + string separator = path.Contains('?') ? "&" : "?"; + + return $"{_config.UtilityUrl}{path}{separator}server_id={Uri.EscapeDataString(_config.ServerId)}"; + } +} diff --git a/apps/utility-sw/src/Services/UtilityConfig.cs b/apps/utility-sw/src/Services/UtilityConfig.cs new file mode 100644 index 00000000..d4be690e --- /dev/null +++ b/apps/utility-sw/src/Services/UtilityConfig.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace UtilityPractice; + +// Configuration arrives as addons/counterstrikesharp/configs/utility-practice.json, +// written by the panel from the registry's wiring block. The url and api key are +// provisioned per install, exactly as the inventory plugin receives its own. +public class UtilityConfig +{ + public string UtilityUrl { get; private set; } = ""; + + // The plugin key alone buys nothing: every utility endpoint also wants the + // server to prove which server it is. + public string ServerId { get; private set; } = ""; + public string ServerApiPassword { get; private set; } = ""; + public bool RecordEnabled { get; private set; } = true; + public bool ReplayEnabled { get; private set; } = true; + public bool InfiniteUtility { get; private set; } = true; + public bool NoFlash { get; private set; } = true; + public bool GhostPreview { get; private set; } = true; + public bool GhostProjectile { get; private set; } = false; + public int MaxSaved { get; private set; } = 200; + + private readonly ILogger _logger; + + public UtilityConfig(ILogger logger) + { + _logger = logger; + } + + private class ConfigFile + { + public string? utility_url { get; set; } + public string? server_id { get; set; } + public string? server_api_password { get; set; } + public bool? np_record_enabled { get; set; } + public bool? np_replay_enabled { get; set; } + public bool? np_infinite_utility { get; set; } + public bool? np_no_flash { get; set; } + public bool? np_ghost_preview { get; set; } + public bool? np_ghost_projectile { get; set; } + public int? np_max_saved { get; set; } + } + + // Candidates rather than one path: the registry writes + // addons/{runtime}/configs/utility-practice.json, but the two runtimes root + // their plugin directories differently and an operator may drop the file + // beside the plugin instead. + public void Load(params string[] configDirectories) + { + // Env wins over the file so an operator can override a provisioned key + // without editing a file the panel rewrites. + UtilityUrl = Environment.GetEnvironmentVariable("UTILITY_URL") ?? ""; + ServerId = Environment.GetEnvironmentVariable("SERVER_ID") ?? ""; + ServerApiPassword = Environment.GetEnvironmentVariable("SERVER_API_PASSWORD") ?? ""; + + string? path = configDirectories + .Where(directory => !string.IsNullOrEmpty(directory)) + .Select(directory => Path.Join(directory, "utility-practice.json")) + .FirstOrDefault(File.Exists); + + if (path != null) + { + try + { + ConfigFile? parsed = JsonSerializer.Deserialize( + File.ReadAllText(path) + ); + + if (parsed != null) + { + if (string.IsNullOrEmpty(UtilityUrl)) + { + UtilityUrl = parsed.utility_url ?? ""; + } + if (string.IsNullOrEmpty(ServerId)) + { + ServerId = parsed.server_id ?? ""; + } + if (string.IsNullOrEmpty(ServerApiPassword)) + { + ServerApiPassword = parsed.server_api_password ?? ""; + } + RecordEnabled = parsed.np_record_enabled ?? RecordEnabled; + ReplayEnabled = parsed.np_replay_enabled ?? ReplayEnabled; + InfiniteUtility = parsed.np_infinite_utility ?? InfiniteUtility; + NoFlash = parsed.np_no_flash ?? NoFlash; + GhostPreview = parsed.np_ghost_preview ?? GhostPreview; + GhostProjectile = parsed.np_ghost_projectile ?? GhostProjectile; + MaxSaved = parsed.np_max_saved ?? MaxSaved; + } + } + catch (Exception error) + { + _logger.LogError(error, "unable to read {path}", path); + } + } + + UtilityUrl = UtilityUrl.TrimEnd('/'); + + // A doubled scheme dials a host literally named "https" and then dies + // quietly on DNS -- the exact failure is invisible from outside the + // pod, so it is collapsed here and the resolved URL is said out loud. + UtilityUrl = System.Text.RegularExpressions.Regex.Replace( + UtilityUrl, + "^(https?://)+", + "$1" + ); + + if (!string.IsNullOrEmpty(UtilityUrl)) + { + _logger.LogInformation("utility practice panel: {url}", UtilityUrl); + } + + if (string.IsNullOrEmpty(UtilityUrl) || string.IsNullOrEmpty(ServerApiPassword)) + { + // Not fatal: local practice commands still work, saves just cannot + // reach the panel. Saying so once at load beats a silent failure on + // the player's first .save. + _logger.LogWarning( + "utility practice is not connected to a panel; lineups cannot be saved or loaded" + ); + } + } + + public bool IsConnected() + { + return !string.IsNullOrEmpty(UtilityUrl) && !string.IsNullOrEmpty(ServerApiPassword); + } +} diff --git a/apps/utility-sw/src/UtilityPractice.csproj b/apps/utility-sw/src/UtilityPractice.csproj new file mode 100644 index 00000000..4d29bbdd --- /dev/null +++ b/apps/utility-sw/src/UtilityPractice.csproj @@ -0,0 +1,51 @@ + + + true + true + UtilityPractice + UtilityPractice + $(MSBuildThisFileDirectory)build/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs new file mode 100644 index 00000000..3feb122b --- /dev/null +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -0,0 +1,1294 @@ +using System.Reflection; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Events; +using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.Plugins; +using static SwiftlyS2.Shared.Helper; +using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.SchemaDefinitions; + +namespace UtilityPractice; + +// Standalone plugin, installed through the 5stack game-plugin registry and +// bound to the utility-practice game mode. It is only ever loaded on a practice +// server, so nothing here checks whether practice is "enabled" -- being loaded +// at all is the gate. +[PluginMetadata( + Id = "UtilityPractice", + Version = "__RELEASE_VERSION__", + Name = "utility-practice", + Author = "5Stack.gg", + Description = "Records grenade lineups in game and replays saved lineups back to the thrower" +)] +public partial class UtilityPracticePlugin : BasePlugin +{ + private ILogger _logger = null!; + private IServiceProvider _serviceProvider = null!; + private UtilityConfig _config = null!; + private UtilityApiClient _api = null!; + private PracticeSession _session = null!; + private PracticeRecorder _recorder = null!; + private PracticeLibrary _library = null!; + private PracticeReplay _replay = null!; + private PracticeSystem _system = null!; + private PracticeScore _score = null!; + private PracticePlaybook _playbook = null!; + private PracticeDrill _drill = null!; + private PracticeSolver _solver = null!; + + private CancellationTokenSource? _secondTimer; + private CancellationTokenSource? _refillTimer; + + private EventDelegates.OnTick? _tickHandler; + private EventDelegates.OnEntityCreated? _entityCreatedHandler; + private readonly HashSet _welcomed = new(); + private EventDelegates.OnMapLoad? _mapLoadHandler; + private EventDelegates.OnClientDisconnected? _disconnectHandler; + private EventDelegates.OnPrecacheResource? _precacheHandler; + private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; + + public UtilityPracticePlugin(ISwiftlyCore core) + : base(core) { } + + public string ModuleVersion => + typeof(UtilityPracticePlugin).GetCustomAttribute()?.Version ?? "unknown"; + + public override void Load(bool hotReload) + { + ServiceCollection services = new(); + services + .AddSwiftly(Core) + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); + + _serviceProvider = services.BuildServiceProvider(); + _logger = _serviceProvider.GetRequiredService>(); + _config = _serviceProvider.GetRequiredService(); + _api = _serviceProvider.GetRequiredService(); + _session = _serviceProvider.GetRequiredService(); + _recorder = _serviceProvider.GetRequiredService(); + _library = _serviceProvider.GetRequiredService(); + _replay = _serviceProvider.GetRequiredService(); + _system = _serviceProvider.GetRequiredService(); + _score = _serviceProvider.GetRequiredService(); + _playbook = _serviceProvider.GetRequiredService(); + _drill = _serviceProvider.GetRequiredService(); + _solver = _serviceProvider.GetRequiredService(); + + // addons/swiftlys2/configs is two levels up from + // addons/swiftlys2/plugins/UtilityPractice. + string pluginDirectory = + Path.GetDirectoryName(typeof(UtilityPracticePlugin).Assembly.Location) ?? ""; + _config.Load(Path.Join(pluginDirectory, "../../configs"), pluginDirectory); + + _replay.IsSolo = _system.IsSolo; + _replay.All = steamId => _library.For(steamId); + // A solve rains live HE and molotovs on a map people are standing in. + _system.SolveRunning = () => _solver.IsBusy; + _session.Refreshed += OnSessionRefreshed; + _recorder.Thrown += _system.OnThrown; + _recorder.Finalized += _score.OnFinalized; + _recorder.Thrown += _drill.OnThrown; + _system.HoldUtility = _drill.Waiting; + _score.Scored += _drill.OnScored; + _score.Scored += OnScoredHint; + + WirePlaybook(); + WireDrill(); + + _tickHandler = OnGameTick; + Core.Event.OnTick += _tickHandler; + + // A grenade's thrower and initial velocity are not populated at the + // moment the entity is created -- read them there and every throw is + // dropped for having no thrower. One tick later they are set. + _entityCreatedHandler = @event => + { + CEntityInstance entity = @event.Entity; + Core.Scheduler.NextTick(() => + { + if (entity.IsValid) + { + _recorder.OnProjectileCreated(entity); + } + }); + }; + Core.Event.OnEntityCreated += _entityCreatedHandler; + + _mapLoadHandler = @event => OnMapLoad(@event.MapName); + Core.Event.OnMapLoad += _mapLoadHandler; + + // The grenade models floated over each lineup have to be in the map's + // precache list or they render as ERROR. This fires at map load, which + // is why a plugin hot-reloaded mid-map cannot show them until the next + // map change. + _precacheHandler = @event => + { + foreach (string model in PracticeLineupUtility.AllUtilityModels()) + { + @event.AddItem(model); + } + }; + Core.Event.OnPrecacheResource += _precacheHandler; + + _disconnectHandler = @event => + ForPlayer( + @event.PlayerId, + steamId => + { + _welcomed.Remove(steamId); + OnPlayerGone(steamId); + } + ); + Core.Event.OnClientDisconnected += _disconnectHandler; + + // Refresh FETCHES; it does not draw. Somebody who joins and runs no + // command should still see every lineup on the map. + _authorizeHandler = @event => + ForPlayer(@event.PlayerId, steamId => RefreshAndShow(steamId)); + Core.Event.OnClientSteamAuthorize += _authorizeHandler; + + InitializeConnectClientHook(); + + // One repeating job for the whole plugin, not one per player. These + // deliberately do not get StopOnMapChange: the plugin is not reloaded + // on a map change, so a timer that stopped there would never come back. + _secondTimer = Core.Scheduler.RepeatBySeconds(1, OnSecond); + _refillTimer = Core.Scheduler.RepeatBySeconds(0.1f, OnFastTick); + + // Only on a hot reload. A cold boot has no engine globals yet -- asking + // for the map here is what stopped the plugin loading at all -- and the + // map arrives moments later with OnMapLoad, which does both of these. + // A hot reload has already missed that event, so this is its only chance. + // Whatever the last instance drew is still in the world and nothing in + // this one has a handle to it. + int swept = _replay.SweepMarkers(); + + if (swept > 0) + { + _logger.LogInformation("swept {swept} marker(s) left by a previous load", swept); + } + + if (hotReload) + { + _library.SetMap(Core.Engine.GlobalVars.MapName.ToString()); + ApplyPracticeCfg(); + RefreshEverything(); + } + + _logger.LogInformation( + "utility practice {version} loaded (connected: {connected}) [{switches}]", + ModuleVersion, + _config.IsConnected(), + PracticeReplay.SwitchState() + ); + } + + public override void Unload() + { + // 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. + _replay.SweepMarkers(); + + _session.Refreshed -= OnSessionRefreshed; + _recorder.Thrown -= _system.OnThrown; + _recorder.Finalized -= _score.OnFinalized; + _recorder.Thrown -= _drill.OnThrown; + _score.Scored -= _drill.OnScored; + _score.Scored -= OnScoredHint; + + if (_tickHandler != null) + { + Core.Event.OnTick -= _tickHandler; + } + + if (_entityCreatedHandler != null) + { + Core.Event.OnEntityCreated -= _entityCreatedHandler; + } + + if (_mapLoadHandler != null) + { + Core.Event.OnMapLoad -= _mapLoadHandler; + } + + if (_disconnectHandler != null) + { + Core.Event.OnClientDisconnected -= _disconnectHandler; + + if (_precacheHandler != null) + { + Core.Event.OnPrecacheResource -= _precacheHandler; + } + } + + if (_authorizeHandler != null) + { + Core.Event.OnClientSteamAuthorize -= _authorizeHandler; + } + + UninstallConnectClientHook(); + + _secondTimer?.Cancel(); + _secondTimer = null; + _refillTimer?.Cancel(); + _refillTimer = null; + + _playbook.Reset(); + _drill.Reset(); + _system.Reset(); + _solver.Reset(); + } + + // The plugin's one line to a machine. Written straight to the server + // console rather than through the logger, so the text an external recorder + // greps for is the text this repo wrote, with no level, category or colour + // in front of it. + private void Signal(string? line) + { + if (!string.IsNullOrEmpty(line)) + { + Core.ConsoleOutput.WriteToServerConsole(line + "\n"); + } + } + + // The recorder and the solver both sample projectiles, and both have to do + // it on the game tick: a grenade that vanishes between two slower polls + // takes its landing point with it. + private void OnGameTick() + { + _recorder.OnTick(); + _solver.OnTick(); + AimFeedback(); + + // Cheap: it only redraws when the set of lineups under the player's + // feet actually changes, which is when they step onto or off a spot. + if (_aimTick % SpotWatchEveryTicks == 0) + { + SpotWatch(); + } + } + + // What LINED UP means is the lineup's own business -- the same number that + // turns its crosshair green, so the two can never disagree. + private static float ToleranceFor(LineupRecord lineup) + { + return lineup.aim_tolerance > 0f + ? lineup.aim_tolerance + : PracticeLineupUtility.DefaultAimTolerance; + } + + // Centre text has to be re-sent to stay on screen. Four ticks is sixteen + // updates a second, which is fast enough that a hundredth-of-a-degree + // readout tracks the mouse instead of lagging behind it. + private const int AimFeedbackEveryTicks = 4; + + // Walking pace does not need sixty-four checks a second. + private const int SpotWatchEveryTicks = 16; + + // How near the crosshair has to be to a ring to count as pointing at it. + // Tight enough that two rings a stride apart are separable at the distance + // you would stand to look at them. + private const double RingHoverDegrees = 7.0; + + // Below this a ring is under the player's feet, and the angle to it says + // nothing about which way they are facing. + private const float RingHoverMinDistance = 96f; + + // How near the crosshair has to be to a throw's recorded aim to count as + // meaning that throw. Generous: this is "which of these did you mean", not + // the tenth-of-a-degree check that says you are on the line. + private const float AimPickDegrees = 25f; + + private int _aimTick; + + // Lining a crosshair up is the part of a lineup that cannot be shown by + // standing somewhere, so the moment it IS lined up is the moment to say + // how to throw -- while they are still looking at the reticle. + // Which spot each player is standing in, so walking into a stance ring can + // light up everything throwable from it without redrawing every tick. + private readonly Dictionary _standingIn = new(); + + // A spot is identified by the set of lineups thrown from it, so stepping + // between two overlapping spots counts as a change. + private void SpotWatch() + { + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || player.IsFakeClient) + { + continue; + } + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + continue; + } + + 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); + List here = PracticeReplay.SpotAt(library, at); + + // Which ring the player is LOOKING at. Where two stances overlap, + // walking in cannot say which throw is meant -- and picking one by + // arrival order means the aim marker changes depending on which + // side you stepped in from. Pointing at a ring is unambiguous. + LineupRecord? aimedAt = LookingAt(pawn, at, library, here); + + // Standing on a spot shows every throw off it -- you cannot choose + // between options you cannot see. Looking toward one only decides + // which is drawn heavy. Off a spot, pointing at a ring across the + // room still shows that one on its own. + List show = + here.Count > 0 + ? here + : aimedAt != null + ? new List { aimedAt } + : new List(); + + // A drill asks for ONE throw, so it draws one. Showing the other + // lineups off the same spot leaves the player picking between + // crosshairs when the whole point is that the run has already + // chosen for them. + LineupRecord? drilling = _drill.Current(player.SteamID); + + if (drilling != null) + { + show = new List { drilling }; + aimedAt = drilling; + } + + string key = string.Join( + ",", + show.Select(entry => entry.client_id) + .OrderBy(id => id) + .Append(aimedAt?.client_id ?? "-") + ); + + if (_standingIn.TryGetValue(player.SteamID, out string? was) && was == key) + { + continue; + } + + _standingIn[player.SteamID] = key; + + // The library is already drawn; only this player's selection moves. + _replay.ShowSelection(player, show, at, aimedAt); + + // Looking at a ring IS choosing it: the crosshair, the name and the + // angular guidance have to describe one throw, and they read the + // loaded lineup. Only while standing on a spot, so glancing across + // the map at a distant ring cannot silently retarget the loaded one. + if (aimedAt != null && here.Count > 0) + { + _system.StateFor(player.SteamID).Loaded = aimedAt; + } + } + } + + // The stance ring the player's crosshair is nearest to, by angle rather + // than distance so a ring across the room can be picked as easily as one + // underfoot. + // Which throw the player means, decided by where they are LOOKING rather + // than which ring they are nearest. + // + // Two candidates, in order: + // - the throw whose recorded aim is closest to the player's current view, + // which is what "look toward the smoke" means when several throws share + // one spot and every ring is under your feet; + // - failing that, a ring they are pointing at from a distance, which is + // how you pick a spot across the room. + private static LineupRecord? LookingAt( + CCSPlayerPawn pawn, + Vec3 at, + IReadOnlyList library, + IReadOnlyList here + ) + { + QAngle eyes = pawn.EyeAngles; + + if (here.Count > 0) + { + LineupRecord? bestAim = null; + float bestOff = AimPickDegrees; + + foreach (LineupRecord lineup in here) + { + float off = PracticeLineupUtility.AimError( + eyes.Y, + eyes.X, + lineup.release.yaw, + lineup.release.pitch + ); + + if (off < bestOff) + { + bestOff = off; + bestAim = lineup; + } + } + + if (bestAim != null) + { + return bestAim; + } + + // Standing on a spot but looking nowhere near any of its throws: + // naming one anyway would be the arbitrary pick this replaced. + if (here.Count > 1) + { + return null; + } + } + + double yaw = eyes.Y * Math.PI / 180.0; + double pitch = eyes.X * Math.PI / 180.0; + double flat = Math.Cos(pitch); + + var view = new Vec3( + (float)(Math.Cos(yaw) * flat), + (float)(Math.Sin(yaw) * flat), + (float)(-Math.Sin(pitch)) + ); + + var eye = new Vec3(at.x, at.y, at.z + 64f); + + LineupRecord? best = null; + double bestAngle = RingHoverDegrees; + + foreach (LineupRecord lineup in library) + { + Vec3 feet = lineup.release.feet_position; + + // The floating grenade is the spot's face, so pointing at it has + // to count the same as pointing at the ground ring under it -- + // with no ground text left, the model is what a player actually + // aims at to ask "what is this one called". + foreach ( + float lift in new[] { 0f, PracticeReplay.UtilityModelHeight } + ) + { + var toRing = new Vec3( + feet.x - eye.x, + feet.y - eye.y, + feet.z + lift - eye.z + ); + float length = toRing.Length(); + + // A ring you are standing on is straight down from the eye, + // which is never what "looking at" means. + if (length < RingHoverMinDistance) + { + continue; + } + + double dot = + (view.x * toRing.x + view.y * toRing.y + view.z * toRing.z) + / length; + + double angle = + Math.Acos(Math.Clamp(dot, -1.0, 1.0)) * 180.0 / Math.PI; + + if (angle < bestAngle) + { + bestAngle = angle; + best = lineup; + } + } + } + + return best; + } + + private void AimFeedback() + { + if (++_aimTick % AimFeedbackEveryTicks != 0) + { + return; + } + + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || player.IsFakeClient) + { + continue; + } + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + continue; + } + + QAngle eyes = pawn.EyeAngles; + + Vector standing = pawn.AbsOrigin ?? new Vector(0, 0, 0); + + _replay.TintAim( + player, + eyes.Y, + eyes.X, + new Vec3(standing.X, standing.Y, standing.Z) + ); + + Panels(player, pawn); + } + } + + // First match wins. The order is the order a player needs the answer in: + // how to throw it once they are on the angle, otherwise what they are + // pointing at, otherwise what they are standing on. + // Two panels, deliberately: the card (centre HTML) answers "what is this + // and how do I throw it", the steps line (centre text) answers "what have + // I not done yet". One channel had to keep swapping between the two, so + // reading the instructions meant losing the guidance and back again. + // Chat rather than a HUD panel, precisely BECAUSE chat stacks: a line that + // scrolls away with the rest of the log is the right home for a tip. On a + // panel it would either sit there forever or fight the three that are + // already earning their place. + private readonly Dictionary _hintedAt = new(); + + // Long enough that nobody reads it twice in a practice run they are + // concentrating on. + private const int HintCooldownTicks = 180 * 64; + + // A landed throw is the moment .next actually means something, so the tip + // gets a much shorter gap there -- but not none, or a player working one + // spot hard would be told the same thing after every smoke. + private const int HintAfterHitTicks = 30 * 64; + + private void Hint(IPlayer player, int cooldown) + { + if ( + _hintedAt.TryGetValue(player.SteamID, out int last) + && _aimTick - last < cooldown + ) + { + return; + } + + _hintedAt[player.SteamID] = _aimTick; + + Tell( + player.SteamID, + $" {ChatColors.Grey}tip: {ChatColors.Default}.next{ChatColors.Grey} and " + + $"{ChatColors.Default}.prev{ChatColors.Grey} walk through the lineups" + ); + } + + // Landing one is the natural point to move on, so that is where the nudge + // to do so belongs. + private void OnScoredHint(ulong steamId, string lineupId, UtilityPracticeResult? result) + { + if (result?.success != true) + { + return; + } + + IPlayer? player = _system.Find(steamId); + + if (player != null && player.IsValid) + { + Hint(player, HintAfterHitTicks); + } + } + + private void Panels(IPlayer player, CCSPlayerPawn pawn) + { + + (LineupRecord? lineup, bool onSpot, bool onAngle) = Focused(player, pawn); + + // Null, not "": an empty string is CONTENT to Send, and the title would + // never clear. + Send( + player, + PanelKind.Title, + lineup == null ? null : PracticeLineupUtility.TitleCase(lineup.name) + ); + if (lineup != null) + { + Hint(player, HintCooldownTicks); + } + + Send( + player, + PanelKind.Card, + lineup == null ? null : Card(lineup, _drill.Progress(player.SteamID)) + ); + Send( + player, + PanelKind.Steps, + lineup == null ? null : Headline(lineup, onSpot, onAngle) + ); + } + + private enum PanelKind + { + Title, + Card, + Steps, + } + + // What each panel is currently showing, so an unchanged panel is left + // alone. Re-sending centre HTML restarts its fade-in, which at sixteen + // times a second is a strobe rather than a message -- the panel has to be + // written only when what it says actually changes. + private readonly Dictionary<(ulong, PanelKind), string> _showing = new(); + + // The HTML panel holds for as long as it is told to, so its keepalive can + // be rare -- and it needs to be, because every write restarts its fade-in. + private const int PanelHoldMilliseconds = 60000; + + // Half the hold, in ticks, so the two can never drift into a gap. + private const int StepsKeepAliveTicks = (PanelHoldMilliseconds / 1000 / 2) * 64; + + // Centre text expires on the game's own short schedule and takes no + // duration, so the card has to be re-sent often to stay up at all. This is + // only safe because that channel does not animate on write: the same rate + // on the HTML panel is exactly the strobe this arrangement was made to fix. + private const int CardKeepAliveTicks = 64; + + private void Send(IPlayer player, PanelKind kind, string? content) + { + (ulong, PanelKind) key = (player.SteamID, kind); + bool had = _showing.TryGetValue(key, out string? showing); + + if (content == null) + { + // Cleared the moment it stops being true, rather than left to time + // out: a stale instruction is worse than no instruction. + if (had) + { + _showing.Remove(key); + Clear(player, kind); + } + + return; + } + + int keepAlive = kind == PanelKind.Steps ? StepsKeepAliveTicks : CardKeepAliveTicks; + + if (had && showing == content && _aimTick % keepAlive != 0) + { + return; + } + + _showing[key] = content; + Write(player, kind, content); + } + + private static void Write(IPlayer player, PanelKind kind, string content) + { + // Steps take the animating HTML panel, the card takes the quiet one. + // The panel that flashes on every write is the one whose message is + // supposed to be changing, and the panel that can be killed in a + // millisecond is the one that has to vanish the instant it comes true. + if (kind == PanelKind.Steps) + { + player.SendCenterHTML(content, PanelHoldMilliseconds); + + return; + } + + // Alert is the third place on screen that holds still. It is why the + // name, the throw details and the outstanding step can sit apart from + // each other rather than stacking into one block. + if (kind == PanelKind.Title) + { + player.SendAlert(content); + + return; + } + + player.SendCenter(content); + } + + // Clearing is a WRITE with the shortest possible life, not a write with the + // panel's usual hold: sending blank content on the sixty-second hold left + // an empty panel sitting on screen for a minute. + private const int PanelClearMilliseconds = 1; + + private void Clear(IPlayer player, PanelKind kind) + { + if (kind == PanelKind.Steps) + { + player.SendCenterHTML("", PanelClearMilliseconds); + + return; + } + + // Neither the card nor the title is written blank. Those channels take + // no duration, so a blank write just starts another full-length message + // that happens to be empty -- slower to clear than the line it + // replaced. Writing nothing lets them lapse on the game's schedule. + } + + private (LineupRecord? lineup, bool onSpot, bool onAngle) Focused( + 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); + List here = PracticeReplay.SpotAt(library, at); + LineupRecord? aimedAt = LookingAt(pawn, at, library, here); + LineupRecord? loaded = _system.StateFor(player.SteamID).Loaded; + + // A drill has already decided what the player is working on, and it + // stays decided even when they walk off the spot -- the panels are how + // they find their way back to it, so dropping them there would be + // exactly backwards. + LineupRecord? drilling = _drill.Current(player.SteamID); + + if (drilling != null) + { + loaded = drilling; + } + + // Otherwise a loaded lineup only owns the panels while the player is + // actually at its spot or looking at it. Walk away and the panels go: + // instructions for a throw you are nowhere near are just something + // stuck to the screen, and .load is not a commitment to read about it + // forever. + if (loaded != null && (drilling != null || here.Contains(loaded) || aimedAt == loaded)) + { + QAngle eyes = pawn.EyeAngles; + Vec3 spot = loaded.release.feet_position; + + bool onAngle = + PracticeLineupUtility.AimError( + eyes.Y, + eyes.X, + loaded.release.yaw, + loaded.release.pitch + ) <= ToleranceFor(loaded); + + // Both halves, not just the angle. Saying you are lined up while + // you stand in the wrong place is worse than saying nothing -- the + // throw misses and the lineup gets blamed. + bool onSpot = + PracticeLineupUtility.StanceMiss( + new Vec3(spot.x - origin.X, spot.y - origin.Y, 0f).LengthXY() + ) == 0f; + + return (loaded, onSpot, onAngle); + } + + // Nothing loaded here, so nothing is done yet: the panels describe what + // the player is pointing at or standing on, and the steps line gives + // them the first thing to do about it. + if (aimedAt != null) + { + return (aimedAt, here.Contains(aimedAt), false); + } + + if (here.Count == 1) + { + return (here[0], true, false); + } + + return (null, false, false); + } + + // The reference card: what this throw is and how it is thrown. Stays up + // the whole time a lineup is in focus, because it is the thing a player + // reads once and glances back at -- never the thing that nags. + // Plain text, because the card sits on the channel that does not animate. + // No escaping needed here for the same reason -- a lineup name is user text + // and this channel renders it literally, which is exactly what we want. + // How the throw is made, and whether there is more to read about it. The + // name is not here -- it has its own channel, which is the whole reason + // these ended up in three places instead of one. + private static string Card(LineupRecord lineup, string? drill) + { + string details = string.IsNullOrWhiteSpace(lineup.description) + ? "" + : "\nWrite-up on the web"; + + // While a drill is running, where you are in it belongs next to the + // throw you are being asked to make -- not in chat, where it scrolls + // away between attempts. + string progress = drill == null ? "" : $"\n{drill}"; + + return $"{PracticeLineupUtility.TitleCase(PracticeReplay.ThrowHint(lineup))}" + + $"{details}{progress}"; + } + + // The one step that is not done yet, on the animating channel -- where a + // flash on each change reads as the instruction CHANGING rather than as the + // reference card blinking at you. It closes the moment the player is lined + // up: silence, alongside the crosshair fading out, IS the success signal. + // CS2 offers exactly two persistent on-screen text channels: centre text + // and centre HTML. The third (CUserMessageHudMsg, the positioned game_text + // element) compiles and sends but never renders, so the name takes its own + // line at the top of the HTML panel rather than a channel of its own. + // Throw details stay on the other channel entirely, which was the point. + // How to throw it, and what is left to do. Both belong on the animating + // panel: they are the lines that CHANGE, so a flash on write reads as the + // instruction moving on rather than as the title blinking. + // The step and nothing else. This panel exists to nag and then get out of + // the way, so it carries only what is still undone. + private static string? Headline(LineupRecord lineup, bool onSpot, bool onAngle) + { + return Steps(onSpot, onAngle); + } + + private static string? Steps(bool onSpot, bool onAngle) + { + if (!onSpot) + { + return Instruction("stand in the circle", "#f99e2f"); + } + + if (!onAngle) + { + return Instruction("match the crosshair", "#f99e2f"); + } + + return null; + } + + private static string Instruction(string text, string color) + { + return $"" + + $"{PracticeLineupUtility.TrackedHtml(text)}"; + } + + // Shortest way round the circle, so 359 and 1 are two degrees apart. + + private void OnSecond() + { + // Until the session is known, the door policy is "nobody" -- so this + // comes before everything else that assumes people can get in. + _session.RetryIfMissing(TimeSpan.FromSeconds(15)); + EndWarmup(); + RespawnTheDead(); + KeepEveryoneStocked(); + ReportOccupancy(); + _system.Tick(); + _playbook.Second(); + _drill.Second(); + _solver.RefreshVisibility(); + } + + // Nobody stays dead on a practice server. Rejoining while dead, falling off + // something, or a stray molotov all leave a player spectating a map they + // came here to throw on -- and no round ever ends to bring them back. + private void RespawnTheDead() + { + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || player.IsFakeClient || player.IsAlive) + { + continue; + } + + if (player.Controller.Team is Team.CT or Team.T) + { + player.Respawn(); + } + } + } + + // Every second rather than only on spawn: a respawn, a team switch and a + // round reset all hand a player an empty bag, and the cost of checking is + // one loop over the weapons they already have. + private int _occupancyTicks; + + // Every few seconds, not every one: the panel only needs to know somebody + // is here, and the reaper's clocks are measured in minutes. + private void ReportOccupancy() + { + if (++_occupancyTicks < OccupancySeconds) + { + return; + } + + _occupancyTicks = 0; + + var present = new List(); + + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player != null && player.IsValid && !player.IsFakeClient) + { + present.Add(player.SteamID); + } + } + + _ = Task.Run(() => _api.Occupancy(present)); + } + + private int _warmupTicks; + + // A practice server is never in warmup. Enforced rather than set once: + // mp_warmup_end at map load runs before warmup has begun, and the game + // starts one of its own whenever it feels like it -- on the first connect, + // on a restart, after a mode cfg lands. + private void EndWarmup() + { + if (--_warmupTicks > 0) + { + return; + } + + CCSGameRules? rules = Core + .EntitySystem.GetAllEntitiesByDesignerName("cs_gamerules") + .FirstOrDefault() + ?.GameRules; + + if (rules == null || !rules.WarmupPeriod) + { + return; + } + + // Not every tick: the command takes a moment to land, and re-issuing it + // in the meantime achieves nothing. + _warmupTicks = WarmupRetrySeconds; + Core.Engine.ExecuteCommand("mp_warmup_end"); + } + + private void KeepEveryoneStocked() + { + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || player.IsFakeClient) + { + continue; + } + + _system.GiveUtility(player); + } + } + + private void OnFastTick() + { + _system.RefillUtility(); + _playbook.Tick(); + _solver.Pump(); + } + + // A step stands a player on its lineup by the same path .load does, so the + // teleport, the utility and the preview cannot drift apart. + private void WirePlaybook() + { + _playbook.Load = (steamId, lineup) => + { + IPlayer? player = _system.Find(steamId); + + if (player != null && player.IsValid) + { + Apply(player, lineup); + } + }; + + _playbook.Chat = message => + Core.PlayerManager.SendChat($" {ChatColors.Green}{message}".Colored()); + + _playbook.Tell = (steamId, message) => Tell(steamId, $" {ChatColors.Green}{message}"); + + _playbook.Center = (steamId, message) => + { + IPlayer? player = _system.Find(steamId); + + if (player != null && player.IsValid) + { + player.SendCenter(message); + } + }; + } + + // A drill stands a player on its lineup by the same path .load does, and + // says so only to them: several people drill in one server. + private void WireDrill() + { + _drill.Load = (steamId, lineup) => + { + IPlayer? player = _system.Find(steamId); + + if (player == null || !player.IsValid || !_config.ReplayEnabled) + { + return false; + } + + Apply(player, lineup); + + return true; + }; + + _drill.Rearm = (steamId, lineup) => + { + IPlayer? player = _system.Find(steamId); + + if (player != null && player.IsValid) + { + _replay.GiveUtility(player, lineup.utility_type); + } + }; + + _drill.Tell = (steamId, message) => Tell(steamId, $" {ChatColors.Green}{message}"); + + _drill.Note = (steamId, message) => Tell(steamId, $" {ChatColors.Grey}{message}"); + + _drill.Center = (steamId, message) => + { + IPlayer? player = _system.Find(steamId); + + if (player != null && player.IsValid) + { + player.SendCenter(message); + } + }; + } + + // A run ends with the player who was in it: the map is still standing, but + // nobody is left to be teleported or told anything. + private void OnPlayerGone(ulong steamId) + { + _drill.Forget(steamId); + _system.Forget(steamId); + + // Their selection is theirs: leaving it behind leaks the entities and + // leaves standing transmit blocks pointing at indices the engine will + // hand to something else. + _replay.ClearSelectionFor(steamId); + _standingIn.Remove(steamId); + _hintedAt.Remove(steamId); + _showing.Remove((steamId, PanelKind.Title)); + _showing.Remove((steamId, PanelKind.Card)); + _showing.Remove((steamId, PanelKind.Steps)); + } + + // Swiftly's client events carry a slot, not a steam id. + private void ForPlayer(int playerId, Action action) + { + IPlayer? player = Core.PlayerManager.GetPlayer(playerId); + + if (player == null || !player.IsValid || player.IsFakeClient) + { + return; + } + + action(player.SteamID); + } + + private void OnMapLoad(string mapName) + { + _recorder.Reset(); + _playbook.Reset(); + _drill.Reset(); + _score.Reset(); + _system.Reset(); + // A calibration is a statement about one map's collision mesh, so it + // does not survive the mesh being replaced. + _solver.Reset(); + + // Anything that did survive the map change is despawned outright, and + // the handles are dropped either way -- a stale handle can be recycled + // into a NEW entity, which makes despawning it later actively harmful. + _replay.SweepMarkers(); + + _library.SetMap(mapName); + + ApplyPracticeCfg(); + + + RefreshEverything(); + } + + // 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. + private void RefreshAndShow(ulong steamId) + { + _library.Refresh( + steamId, + count => + { + if (count <= 0) + { + return; + } + + IReadOnlyList library = _library.For(steamId); + + _replay.ShowLibrary(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 them, they can walk them. Any earlier search is + // discarded on purpose: this only runs on join, map change and + // an explicit refresh, and a search from before any of those is + // describing a map state that no longer exists. + PracticeState state = _system.StateFor(steamId); + + state.Results.Clear(); + state.Results.AddRange(library); + state.Index = -1; + } + ); + } + + private void RefreshEverything() + { + _ = Task.Run(async () => + { + await _session.Refresh(); + await _api.Drain(); + }); + + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player != null && player.IsValid && !player.IsFakeClient) + { + RefreshAndShow(player.SteamID); + } + } + } + + private void OnSessionRefreshed(PracticeSessionData session) + { + if (string.IsNullOrEmpty(session.password)) + { + _logger.LogWarning( + "practice session {session} carries no password; the connect hook has nothing to present", + session.id + ); + return; + } + + SetPasswordBuffer(session.password); + + // The buffer only substitutes this password into the connect call -- + // the server still has to be the one asking for it. Without this the + // hook hands over a password sv_password never heard of, and every + // assigned player is turned away with "bad password". + if (!TrySetConVar("sv_password", session.password)) + { + _logger.LogError( + "could not set sv_password; assigned players will be rejected" + ); + return; + } + + _logger.LogInformation( + "practice session {session} password applied to sv_password", + session.id + ); + } + + // The state a practice server has to be in, applied by the plugin rather + // than a game mode cfg: a practice server may be a third-party dedicated + // box that no mode was ever selected for, and without this it sits in + // warmup with no money and no utility. + private const int OccupancySeconds = 15; + private const int WarmupRetrySeconds = 3; + + private const float CfgReapplySeconds = 3f; + + private static readonly string[] PracticeCfg = new[] + { + "sv_cheats 1", + // Nothing ends the round: a kill or an expired timer would reset + // everyone mid-lineup. + "mp_ignore_round_win_conditions 1", + "mp_warmuptime 1", + "mp_warmup_pausetimer 0", + "mp_halftime 0", + "mp_match_can_clinch 0", + "mp_team_intro_time 0", + "mp_round_restart_delay 0", + "mp_warmup_end", + "mp_freezetime 0", + "mp_roundtime 60", + "mp_roundtime_defuse 60", + "mp_respawn_immunitytime 0", + "mp_buy_anywhere 1", + "mp_buytime 60000", + "mp_maxmoney 65535", + "mp_startmoney 65535", + "mp_afterroundmoney 65535", + "mp_death_drop_gun 0", + "mp_death_drop_grenade 0", + "mp_solid_teammates 0", + "mp_teammates_are_enemies 0", + "sv_grenade_trajectory_prac_pipreview 1", + // The trail is how you see WHERE it went wrong rather than just that it + // did. Ten seconds outlives the throw and the walk back to the spot. + "sv_grenade_trajectory_prac_trailtime 10", + // Valve's own map-guide editor. Every annotation_* command is client + // side, so a plugin can never draw one for a player -- but this cvar + // decides whether they may draw their own, and it ships at view-only. + // On a practice server there is no reason to withhold the editor. + "sv_allow_annotations_access_level 2", + "sv_infinite_ammo 1", + "ammo_grenade_limit_total 5", + "sv_full_alltalk 1", + "tv_enable 0", + }; + + private void ApplyPracticeCfg() + { + // Twice, and the second one is the one that usually takes. On a map + // change the tick after load is before warmup has begun, so + // mp_warmup_end there ends nothing and the server sits in a countdown. + Core.Scheduler.NextTick(() => RunPracticeCfg()); + Core.Scheduler.DelayBySeconds(CfgReapplySeconds, () => RunPracticeCfg()); + } + + private void RunPracticeCfg() + { + Core.Engine.ExecuteCommand(string.Join(";", PracticeCfg)); + + // The map change did not take the session with it, and sv_password is + // the one thing here that is per-session rather than per-map. + PracticeSessionData? session = _session.Current; + + if (session != null && !string.IsNullOrEmpty(session.password)) + { + TrySetConVar("sv_password", session.password); + } + } + + private bool TrySetConVar(string name, string value) + { + try + { + var conVar = Core.ConVar.Find(name); + + if (conVar == null) + { + return false; + } + + conVar.Value = value; + return true; + } + catch (Exception error) + { + _logger.LogWarning(error, "failed setting convar {Name}", name); + return false; + } + } +} diff --git a/apps/utility-sw/test/DrillUtilityTests.cs b/apps/utility-sw/test/DrillUtilityTests.cs new file mode 100644 index 00000000..3db0ce91 --- /dev/null +++ b/apps/utility-sw/test/DrillUtilityTests.cs @@ -0,0 +1,432 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +// A drill is a queue and a verdict. The ways it goes wrong quietly are a run +// that hands out a lineup the panel cannot score, a "worst first" that is +// really alphabetical, and a count somebody typed being taken literally. +public class DrillUtilityTests +{ + private static LineupRecord Lineup(string id, string? name = null, string utility = "Smoke") + { + return new LineupRecord + { + id = id, + client_id = id, + name = name ?? id, + utility_type = utility, + release = new ThrowSnapshot { feet_position = new Vec3(100f, 200f, 64f) }, + detonation_position = new Vec3(900f, -400f, 64f), + }; + } + + private static UtilityPracticeResult Result( + bool success, + int attempts, + int successes, + bool mastered = false + ) + { + return new UtilityPracticeResult + { + success = success, + distance = 40f, + radius = 80f, + attempts = attempts, + successes = successes, + current_streak = success ? 1 : 0, + best_streak = 1, + mastered_at = mastered ? DateTime.UtcNow : null, + }; + } + + private static Func Progress( + params (string id, int attempts, int successes)[] rows + ) + { + var book = rows.ToDictionary( + row => row.id, + row => new DrillProgress { Attempts = row.attempts, Successes = row.successes } + ); + + return lineup => + lineup.id != null && book.TryGetValue(lineup.id, out DrillProgress? progress) + ? progress + : null; + } + + [Fact] + public void ALineupThePanelHasNeverSeenCannotBeDrilled() + { + LineupRecord local = Lineup("keep"); + local.id = null; + + Assert.False(DrillUtility.IsDrillable(local)); + } + + [Fact] + public void ALineupWithNoOriginCannotBeDrilled() + { + LineupRecord lineup = Lineup("a"); + lineup.release = new ThrowSnapshot(); + + Assert.False(DrillUtility.IsDrillable(lineup)); + } + + [Fact] + public void ALineupWithNoLandingPointCannotBeDrilled() + { + LineupRecord lineup = Lineup("a"); + lineup.detonation_position = new Vec3(0f, 0f, 0f); + + Assert.False(DrillUtility.IsDrillable(lineup)); + } + + [Fact] + public void ASavedLineupCanBeDrilled() + { + Assert.True(DrillUtility.IsDrillable(Lineup("a"))); + } + + [Fact] + public void DrillableDropsWhatCannotBeScored() + { + LineupRecord local = Lineup("local"); + local.id = ""; + + List drillable = DrillUtility.Drillable( + new[] { Lineup("a"), local, Lineup("b") } + ); + + Assert.Equal(new[] { "a", "b" }, drillable.Select(lineup => lineup.id)); + } + + [Fact] + public void ANamelessLineupFallsBackToItsUtility() + { + LineupRecord lineup = Lineup("a", name: ""); + + Assert.Equal("Smoke", DrillUtility.Name(lineup)); + } + + [Fact] + public void AnUnattemptedLineupSortsBetweenMissedAndPerfect() + { + float missed = DrillUtility.Priority(new DrillProgress { Attempts = 4, Successes = 0 }); + float perfect = DrillUtility.Priority(new DrillProgress { Attempts = 4, Successes = 4 }); + + Assert.True(missed < DrillUtility.UnattemptedPriority); + Assert.True(DrillUtility.UnattemptedPriority < perfect); + Assert.Equal(DrillUtility.UnattemptedPriority, DrillUtility.Priority(null)); + } + + [Fact] + public void AMasteredLineupIsAlwaysLast() + { + float mastered = DrillUtility.Priority( + new DrillProgress + { + Attempts = 10, + Successes = 10, + Mastered = true, + } + ); + + Assert.True( + mastered > DrillUtility.Priority(new DrillProgress { Attempts = 1, Successes = 1 }) + ); + } + + [Fact] + public void WorstFirstPutsTheOnesGoingWorstFirst() + { + var lineups = new[] { Lineup("perfect"), Lineup("half"), Lineup("never"), Lineup("bad") }; + + List ordered = DrillUtility.WorstFirst( + lineups, + Progress(("perfect", 6, 6), ("half", 6, 3), ("bad", 6, 1)) + ); + + Assert.Equal( + new[] { "bad", "half", "never", "perfect" }, + ordered.Select(lineup => lineup.id) + ); + } + + // Two lineups going equally badly are not equally well known. + [Fact] + public void EquallyBadLineupsAreOrderedByHowMuchIsKnown() + { + List ordered = DrillUtility.WorstFirst( + new[] { Lineup("thin"), Lineup("thick") }, + Progress(("thin", 1, 0), ("thick", 12, 0)) + ); + + Assert.Equal(new[] { "thick", "thin" }, ordered.Select(lineup => lineup.id)); + } + + [Fact] + public void AQueueIsAsLongAsItWasAskedFor() + { + List queue = DrillUtility.Queue( + new[] { Lineup("a"), Lineup("b"), Lineup("c") }, + 7, + eDrillOrder.Random, + _ => null, + new Random(4) + ); + + Assert.Equal(7, queue.Count); + } + + [Fact] + public void ARunIsCappedNoMatterWhatWasAskedFor() + { + List queue = DrillUtility.Queue( + new[] { Lineup("a"), Lineup("b") }, + 5000, + eDrillOrder.Random, + _ => null, + new Random(4) + ); + + Assert.Equal(DrillUtility.MaxCount, queue.Count); + } + + // A book shorter than the run is drilled in whole passes, so nothing comes + // round twice before everything has come round once. + [Fact] + public void EveryLineupIsDrilledBeforeAnyIsRepeated() + { + var lineups = new[] { Lineup("a"), Lineup("b"), Lineup("c"), Lineup("d") }; + + List queue = DrillUtility.Queue( + lineups, + 8, + eDrillOrder.Random, + _ => null, + new Random(11) + ); + + Assert.Equal(4, queue.Take(4).Select(lineup => lineup.id).Distinct().Count()); + Assert.Equal(4, queue.Skip(4).Select(lineup => lineup.id).Distinct().Count()); + } + + [Fact] + public void APassSeamNeverRepeatsTheSameLineupBackToBack() + { + var lineups = new[] { Lineup("a"), Lineup("b"), Lineup("c") }; + + for (int seed = 0; seed < 50; seed++) + { + List queue = DrillUtility.Queue( + lineups, + 12, + eDrillOrder.Random, + _ => null, + new Random(seed) + ); + + for (int index = 1; index < queue.Count; index++) + { + Assert.NotEqual(queue[index - 1].client_id, queue[index].client_id); + } + } + } + + [Fact] + public void AWorstFirstQueueStartsWithTheWorst() + { + List queue = DrillUtility.Queue( + new[] { Lineup("good"), Lineup("bad") }, + 2, + eDrillOrder.Worst, + Progress(("good", 5, 5), ("bad", 5, 0)), + new Random(1) + ); + + Assert.Equal(new[] { "bad", "good" }, queue.Select(lineup => lineup.id)); + } + + [Fact] + public void AnEmptyLibraryIsAnEmptyQueue() + { + Assert.Empty( + DrillUtility.Queue( + new List(), + 10, + eDrillOrder.Random, + _ => null, + new Random(1) + ) + ); + } + + [Fact] + public void ALibraryOfUnscorableLineupsIsAnEmptyQueue() + { + LineupRecord local = Lineup("local"); + local.id = null; + + Assert.Empty( + DrillUtility.Queue(new[] { local }, 10, eDrillOrder.Random, _ => null, new Random(1)) + ); + } + + [Fact] + public void NoArgumentsIsAShuffledRunOfTheDefaultLength() + { + DrillRequest request = DrillUtility.Parse(""); + + Assert.True(request.Valid); + Assert.False(request.Stop); + Assert.Equal(eDrillOrder.Random, request.Order); + Assert.Equal(DrillUtility.DefaultCount, request.Count); + } + + [Fact] + public void StopIsRead() + { + Assert.True(DrillUtility.Parse(" stop ").Stop); + Assert.True(DrillUtility.Parse("end").Stop); + } + + [Fact] + public void ACountIsRead() + { + Assert.Equal(25, DrillUtility.Parse("25").Count); + } + + [Fact] + public void ACountIsCappedRatherThanRefused() + { + DrillRequest request = DrillUtility.Parse("900"); + + Assert.True(request.Valid); + Assert.Equal(DrillUtility.MaxCount, request.Count); + } + + // A player typing this into chat is not consulting a usage line. + [Fact] + public void OrderAndCountAreReadInEitherOrder() + { + DrillRequest first = DrillUtility.Parse("worst 12"); + DrillRequest second = DrillUtility.Parse("12 worst"); + + Assert.Equal(eDrillOrder.Worst, first.Order); + Assert.Equal(12, first.Count); + Assert.Equal(eDrillOrder.Worst, second.Order); + Assert.Equal(12, second.Count); + } + + [Fact] + public void RandomCanBeAskedForOutLoud() + { + Assert.Equal(eDrillOrder.Random, DrillUtility.Parse("random").Order); + } + + [Fact] + public void AnUnreadableArgumentIsARefusalRatherThanAGuess() + { + Assert.False(DrillUtility.Parse("banana").Valid); + Assert.False(DrillUtility.Parse("0").Valid); + Assert.False(DrillUtility.Parse("-3").Valid); + } + + [Fact] + public void QuotesAndSpacingAreTolerated() + { + DrillRequest request = DrillUtility.Parse("\" worst 8 \""); + + Assert.True(request.Valid); + Assert.Equal(eDrillOrder.Worst, request.Order); + Assert.Equal(8, request.Count); + } + + // The panel's counters are absolute, so a result replaces what we thought + // rather than adding to it: two throws are not four attempts. + [Fact] + public void AResultReplacesTheProgressItReportsOn() + { + var book = new DrillProgressBook(); + + book.Record(1, "lineup", Result(true, attempts: 3, successes: 1)); + book.Record(1, "lineup", Result(true, attempts: 4, successes: 2)); + + DrillProgress? progress = book.For(1, "lineup"); + + Assert.NotNull(progress); + Assert.Equal(4, progress!.Attempts); + Assert.Equal(2, progress.Successes); + Assert.Equal(0.5f, progress.Rate); + } + + [Fact] + public void ProgressIsPerPlayer() + { + var book = new DrillProgressBook(); + + book.Record(1, "lineup", Result(true, attempts: 10, successes: 10)); + + Assert.NotNull(book.For(1, "lineup")); + Assert.Null(book.For(2, "lineup")); + } + + [Fact] + public void AThrowThePanelDidNotAnswerTeachesNothing() + { + var book = new DrillProgressBook(); + + book.Record(1, "lineup", null); + + Assert.Null(book.For(1, "lineup")); + } + + [Fact] + public void MasteryIsCarriedThrough() + { + var book = new DrillProgressBook(); + + book.Record(1, "lineup", Result(true, attempts: 9, successes: 9, mastered: true)); + + Assert.True(book.For(1, "lineup")!.Mastered); + } + + [Fact] + public void ForgettingAPlayerForgetsTheirProgress() + { + var book = new DrillProgressBook(); + + book.Record(1, "lineup", Result(true, attempts: 2, successes: 2)); + book.Record(2, "lineup", Result(true, attempts: 2, successes: 2)); + book.Forget(1); + + Assert.Null(book.For(1, "lineup")); + Assert.NotNull(book.For(2, "lineup")); + } + + [Fact] + public void ClearingTheBookForgetsEverybody() + { + var book = new DrillProgressBook(); + + book.Record(1, "lineup", Result(true, attempts: 2, successes: 2)); + book.Clear(); + + Assert.Null(book.For(1, "lineup")); + } + + [Fact] + public void TheBookAnswersForWholeLineups() + { + var book = new DrillProgressBook(); + + book.Record(7, "lineup", Result(false, attempts: 5, successes: 1)); + + Func lookup = book.Lookup(7); + + Assert.Equal(5, lookup(Lineup("lineup"))!.Attempts); + Assert.Null(lookup(Lineup("other"))); + } +} diff --git a/apps/utility-sw/test/FiveStack.Tests.csproj b/apps/utility-sw/test/FiveStack.Tests.csproj new file mode 100644 index 00000000..79121e9f --- /dev/null +++ b/apps/utility-sw/test/FiveStack.Tests.csproj @@ -0,0 +1,43 @@ + + + false + bin/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/utility-sw/test/PlaybookUtilityTests.cs b/apps/utility-sw/test/PlaybookUtilityTests.cs new file mode 100644 index 00000000..78d94cdd --- /dev/null +++ b/apps/utility-sw/test/PlaybookUtilityTests.cs @@ -0,0 +1,233 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +// An execute is a clock, and the two ways it goes wrong are silent: a step that +// fires twice, and a step that never fires at all. +public class PlaybookUtilityTests +{ + private static UtilityPlaybookStep Step( + int order, + int offsetMs, + string? assigned = null, + bool withLineup = true, + bool seeded = false, + string? confidence = null + ) + { + return new UtilityPlaybookStep + { + utility_lineup_id = $"lineup-{order}", + step_order = order, + offset_ms = offsetMs, + assigned_steam_id = assigned, + note = $"step {order}", + lineup = withLineup + ? new UtilityLibraryRow + { + id = $"row-{order}", + name = $"utility {order}", + utility_type = "Smoke", + origin_x = 1f, + origin_y = 2f, + origin_z = 3f, + land_x = 4f, + land_y = 5f, + land_z = 6f, + initial_pos_x = seeded ? 11f : null, + initial_pos_y = seeded ? 22f : null, + initial_pos_z = seeded ? 33f : null, + initial_vel_x = seeded ? 400f : null, + initial_vel_y = seeded ? -500f : null, + initial_vel_z = seeded ? 600f : null, + confidence = confidence, + } + : null, + }; + } + + private static UtilityPlaybook Playbook(params UtilityPlaybookStep[] steps) + { + return new UtilityPlaybook + { + id = "book", + name = "A execute", + map_name = "de_mirage", + side = "TERRORIST", + steps = steps.ToList(), + }; + } + + [Fact] + public void NoPlaybookIsNoSteps() + { + Assert.Empty(PlaybookUtility.Ordered(null)); + } + + [Fact] + public void StepsAreOrderedByStepOrder() + { + var ordered = PlaybookUtility.Ordered( + Playbook(Step(3, 0), Step(1, 900), Step(2, 400)) + ); + + Assert.Equal(new[] { 1, 2, 3 }, ordered.Select(step => step.step_order)); + } + + // A step whose lineup the panel declined to inline cannot be loaded, and + // teleporting somebody onto nothing is worse than skipping it. + [Fact] + public void AStepWithNoLineupIsDropped() + { + var ordered = PlaybookUtility.Ordered( + Playbook(Step(1, 0), Step(2, 100, withLineup: false)) + ); + + Assert.Single(ordered); + Assert.Equal(1, ordered[0].step_order); + } + + [Fact] + public void ABookLongerThanItsOwnCapIsTruncated() + { + var steps = Enumerable + .Range(0, PlaybookUtility.MaxSteps + 10) + .Select(index => Step(index, index * 100)) + .ToArray(); + + Assert.Equal(PlaybookUtility.MaxSteps, PlaybookUtility.Ordered(Playbook(steps)).Count); + } + + [Fact] + public void AStepAtZeroFiresExactlyOnce() + { + var steps = PlaybookUtility.Ordered(Playbook(Step(1, 0), Step(2, 500))); + + Assert.Single(PlaybookUtility.Due(steps, -1, 0)); + Assert.Empty(PlaybookUtility.Due(steps, 0, 0)); + Assert.Empty(PlaybookUtility.Due(steps, 0, 100)); + } + + [Fact] + public void AWindowClaimsEveryStepInsideIt() + { + var steps = PlaybookUtility.Ordered( + Playbook(Step(1, 0), Step(2, 100), Step(3, 200), Step(4, 5000)) + ); + + var due = PlaybookUtility.Due(steps, -1, 250); + + Assert.Equal(new[] { 1, 2, 3 }, due.Select(step => step.step_order)); + } + + [Fact] + public void WalkingTheWindowFiresEveryStepOnce() + { + var steps = PlaybookUtility.Ordered( + Playbook(Step(1, 0), Step(2, 100), Step(3, 100), Step(4, 2500)) + ); + + var fired = new List(); + + for (int elapsed = 0; elapsed <= 3000; elapsed += 100) + { + fired.AddRange( + PlaybookUtility.Due(steps, elapsed - 100, elapsed).Select(step => step.step_order) + ); + } + + Assert.Equal(new[] { 1, 2, 3, 4 }, fired); + } + + [Fact] + public void TheDurationIsTheLastOffset() + { + Assert.Equal(0, PlaybookUtility.DurationMs(new List())); + Assert.Equal( + 2500, + PlaybookUtility.DurationMs( + PlaybookUtility.Ordered(Playbook(Step(1, 0), Step(2, 2500))) + ) + ); + } + + [Fact] + public void AnUnassignedStepBelongsToEveryone() + { + UtilityPlaybookStep step = Step(1, 0); + + Assert.False(PlaybookUtility.IsAssigned(step)); + Assert.True(PlaybookUtility.IsFor(step, 76561198000000001)); + Assert.True(PlaybookUtility.IsFor(step, 76561198000000002)); + } + + [Fact] + public void AnAssignedStepBelongsToOnlyThatPlayer() + { + UtilityPlaybookStep step = Step(1, 0, assigned: " 76561198000000001 "); + + Assert.True(PlaybookUtility.IsAssigned(step)); + Assert.True(PlaybookUtility.IsFor(step, 76561198000000001)); + Assert.False(PlaybookUtility.IsFor(step, 76561198000000002)); + } + + // The step names the lineup; the inlined row is only its geometry. Scoring + // posts the step's id, so the two must not be allowed to disagree. + [Fact] + public void AStepsLineupCarriesTheStepsLineupId() + { + LineupRecord? lineup = Step(1, 0).ToLineup(); + + Assert.NotNull(lineup); + Assert.Equal("lineup-1", lineup!.id); + Assert.Equal("lineup-1", lineup.client_id); + Assert.Equal("Smoke", lineup.utility_type); + Assert.Equal(4f, lineup.detonation_position.x); + } + + [Fact] + public void AStepWithNoLineupConvertsToNothing() + { + Assert.Null(Step(1, 0, withLineup: false).ToLineup()); + } + + // A step inlines the same library row, so an execute re-emits its throws + // exactly wherever the panel has a seed for them. + [Fact] + public void AStepInheritsTheSeedOfTheLineupItNames() + { + LineupRecord? seeded = Step(1, 0, seeded: true).ToLineup(); + + Assert.NotNull(seeded); + Assert.Equal(11f, seeded!.initial_position.x); + Assert.Equal(400f, seeded.initial_velocity.x); + Assert.True(seeded.initial_velocity.Length() > 0f); + } + + // An execute re-emits a step exactly only where the panel vouched for it; + // a mined step is something to practise toward, not to replay. + [Fact] + public void AStepIsReplayedOnlyWhenThePanelCalledItExact() + { + LineupRecord? exact = Step(1, 0, seeded: true, confidence: "exact").ToLineup(); + LineupRecord? mined = Step(2, 0, seeded: true, confidence: "derived").ToLineup(); + LineupRecord? unknown = Step(3, 0, seeded: true).ToLineup(); + + Assert.True(exact!.IsExactlyReplayable()); + Assert.False(mined!.IsExactlyReplayable()); + Assert.False(unknown!.IsExactlyReplayable()); + + Assert.True(mined.IsKnownInexact()); + Assert.False(unknown.IsKnownInexact()); + } + + [Fact] + public void AStepNamingASeedlessLineupIsNotReplayable() + { + LineupRecord? plain = Step(1, 0).ToLineup(); + + Assert.NotNull(plain); + Assert.Equal(0f, plain!.initial_velocity.Length()); + Assert.Equal(0f, plain.initial_position.Length()); + } +} diff --git a/apps/utility-sw/test/PracticeCalibrationUtilityTests.cs b/apps/utility-sw/test/PracticeCalibrationUtilityTests.cs new file mode 100644 index 00000000..10aeeae4 --- /dev/null +++ b/apps/utility-sw/test/PracticeCalibrationUtilityTests.cs @@ -0,0 +1,349 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +// The gate. Every test here is about refusing rather than solving: the failure +// this exists to prevent is a solve that ran anyway and handed back lineups +// that land somewhere plausible and cannot be thrown. +public class PracticeCalibrationUtilityTests +{ + private static readonly Vec3 Eye = new Vec3(100f, 200f, 64f); + private static readonly Vec3 Landing = new Vec3(900f, 1200f, 0f); + + // A throw as a perfect engine would have recorded it: the seed is exactly + // what the launch model predicts. + private static LineupRecord Sample( + float pitch = -15f, + float yaw = 40f, + float strength = 1f, + int bounces = 0, + string id = "sample" + ) + { + LaunchSeed seed = PracticeLaunchUtility.Seed( + Eye, + pitch, + yaw, + strength, + new Vec3(0f, 0f, 0f) + ); + + return new LineupRecord + { + client_id = id, + utility_type = "Smoke", + bounces = bounces, + release = new ThrowSnapshot + { + feet_position = new Vec3(Eye.x, Eye.y, 0f), + eye_position = Eye, + pitch = pitch, + yaw = yaw, + on_ground = true, + speed = 0f, + throw_strength_raw = strength, + }, + initial_position = seed.position, + initial_velocity = seed.velocity, + detonation_position = Landing, + }; + } + + [Fact] + public void APerfectSampleClearsTheLaunchModel() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + + Assert.True(PracticeCalibrationUtility.LaunchModelPassed(report)); + Assert.Single(report.launch_checks); + Assert.True(report.launch_checks[0].passed); + Assert.Equal(1f, report.CorrectionFor(nameof(eThrowStrength.Full)), 3); + } + + // Passing the launch model is not permission to solve. Only a live seed + // replay grants that, and it has not happened yet. + [Fact] + public void ClearingTheLaunchModelIsNotReady() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + + Assert.False(report.CanSolve()); + Assert.Equal(nameof(eCalibrationStatus.Unknown), report.status); + } + + [Fact] + public void RefusesWhenTheThrowDirectionIsWrong() + { + LineupRecord sample = Sample(); + LaunchSeed skewed = PracticeLaunchUtility.Seed( + Eye, + sample.release.pitch - 4f, + sample.release.yaw, + 1f, + new Vec3(0f, 0f, 0f) + ); + sample.initial_velocity = skewed.velocity; + + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { sample } + ); + + Assert.Equal(nameof(eCalibrationStatus.LaunchModelMismatch), report.status); + Assert.Contains("pitch remap", report.message); + Assert.False(report.CanSolve()); + } + + [Fact] + public void RefusesWhenTheGrenadeSpawnsSomewhereElse() + { + LineupRecord sample = Sample(); + sample.initial_position = sample.initial_position + new Vec3(0f, 0f, 20f); + + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { sample } + ); + + Assert.Equal(nameof(eCalibrationStatus.LaunchModelMismatch), report.status); + Assert.Contains("forward offset", report.message); + } + + [Fact] + public void RefusesWhenTheSpeedFormulaIsWrong() + { + LineupRecord sample = Sample(); + sample.initial_velocity = sample.initial_velocity * 3f; + + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { sample } + ); + + Assert.Equal(nameof(eCalibrationStatus.LaunchModelMismatch), report.status); + Assert.Contains("speed formula", report.message); + } + + // A constant being a few percent out is absorbed rather than refused: the + // measured ratio is carried into every throw the solver makes. + [Fact] + public void CarriesASmallSpeedErrorForwardAsACorrection() + { + LineupRecord sample = Sample(); + sample.initial_velocity = sample.initial_velocity * 1.08f; + + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { sample } + ); + + Assert.True(PracticeCalibrationUtility.LaunchModelPassed(report)); + Assert.Equal(1.08f, report.CorrectionFor(nameof(eThrowStrength.Full)), 3); + } + + // A throw made on the move is measured a tick away from where the engine + // read it, so it fails the model for a reason that is not the model. + [Fact] + public void OnlyStandingThrowsAreUsable() + { + LineupRecord running = Sample(); + running.release.speed = 220f; + + LineupRecord jumping = Sample(); + jumping.release.jump_throw = true; + + LineupRecord airborne = Sample(); + airborne.release.on_ground = false; + + Assert.False(PracticeCalibrationUtility.IsUsableSample(running)); + Assert.False(PracticeCalibrationUtility.IsUsableSample(jumping)); + Assert.False(PracticeCalibrationUtility.IsUsableSample(airborne)); + Assert.True(PracticeCalibrationUtility.IsUsableSample(Sample())); + } + + // A throw whose release edge was missed has a zeroed snapshot; comparing + // the model against it would compare it against nothing. + [Fact] + public void ASnapshotlessThrowIsNotASample() + { + LineupRecord sample = Sample(); + sample.release = new ThrowSnapshot { on_ground = true }; + + Assert.False(PracticeCalibrationUtility.IsUsableSample(sample)); + } + + [Fact] + public void NothingToCalibrateAgainstIsSaidPlainly() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new List() + ); + + Assert.Equal(nameof(eCalibrationStatus.NoSample), report.status); + Assert.Contains("throw one grenade", report.message); + Assert.False(report.CanSolve()); + } + + [Fact] + public void OnlyMeasuredStrengthsBecomeSolvable() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample(strength: 1f, id: "a"), Sample(strength: 0.5f, id: "b") } + ); + + PracticeCalibrationUtility.WithSeedReplay( + report, + Sample(), + Landing + new Vec3(2f, 0f, 0f) + ); + + Assert.Equal( + new[] { nameof(eThrowStrength.Full), nameof(eThrowStrength.Half) }, + report.SolvableStrengths() + ); + Assert.DoesNotContain(nameof(eThrowStrength.Drop), report.SolvableStrengths()); + } + + [Fact] + public void AReproducedLandingOpensTheGate() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + + PracticeCalibrationUtility.WithSeedReplay( + report, + Sample(), + Landing + new Vec3(3f, 4f, 0f) + ); + + Assert.Equal(nameof(eCalibrationStatus.Ready), report.status); + Assert.True(report.CanSolve()); + Assert.Equal(5f, report.seed_replay_error, 3); + } + + [Fact] + public void AMissedReproductionShutsIt() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + + PracticeCalibrationUtility.WithSeedReplay( + report, + Sample(), + Landing + new Vec3(0f, 400f, 0f) + ); + + Assert.Equal(nameof(eCalibrationStatus.SeedReplayMismatch), report.status); + Assert.False(report.CanSolve()); + Assert.Contains("does not reproduce a seeded throw", report.message); + } + + [Fact] + public void AGrenadeThatNeverLandedIsNotAPass() + { + CalibrationReport report = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + + PracticeCalibrationUtility.WithSeedReplay(report, Sample(), null); + + Assert.Equal(nameof(eCalibrationStatus.SeedReplayTimedOut), report.status); + Assert.False(report.CanSolve()); + } + + // The tolerance is the whole claim. A throw just inside it passes and one + // just outside does not, so a change to the constant is a change to the + // claim rather than a quiet loosening. + [Fact] + public void TheToleranceIsTheClaim() + { + CalibrationReport inside = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + PracticeCalibrationUtility.WithSeedReplay( + inside, + Sample(), + Landing + + new Vec3(PracticeCalibrationUtility.SeedReplayTolerance - 0.5f, 0f, 0f) + ); + + CalibrationReport outside = PracticeCalibrationUtility.CheckLaunchModel( + "de_mirage", + new[] { Sample() } + ); + PracticeCalibrationUtility.WithSeedReplay( + outside, + Sample(), + Landing + + new Vec3(PracticeCalibrationUtility.SeedReplayTolerance + 0.5f, 0f, 0f) + ); + + Assert.True(inside.CanSolve()); + Assert.False(outside.CanSolve()); + } + + // A grenade that clipped three corners tests the collision mesh as much as + // the premise, so it is the last throw to reach for. + [Fact] + public void TheCleanestThrowIsReplayed() + { + LineupRecord bouncy = Sample(bounces: 5, id: "bouncy"); + LineupRecord clean = Sample(bounces: 0, id: "clean"); + + LineupRecord? picked = PracticeCalibrationUtility.PickReplaySample( + new[] { bouncy, clean } + ); + + Assert.Equal("clean", picked?.client_id); + } + + [Fact] + public void PickingAReplayNeedsAUsableThrow() + { + LineupRecord moving = Sample(); + moving.release.speed = 250f; + + Assert.Null(PracticeCalibrationUtility.PickReplaySample(new[] { moving })); + } + + [Fact] + public void SamplesAreCappedAndNewestFirst() + { + var pool = new List(); + + for (int index = 0; index < 20; index++) + { + pool.Add(Sample(id: $"throw-{index}")); + } + + List samples = PracticeCalibrationUtility.Samples(pool); + + Assert.Equal(PracticeCalibrationUtility.MaxSamples, samples.Count); + Assert.Equal("throw-19", samples[0].client_id); + } + + [Fact] + public void AnUnsupportedRuntimeIsItsOwnAnswer() + { + CalibrationReport report = PracticeCalibrationUtility.Unsupported("de_nuke", "no emit api"); + + Assert.Equal(nameof(eCalibrationStatus.Unsupported), report.status); + Assert.False(report.CanSolve()); + Assert.Equal("de_nuke", report.map); + } +} diff --git a/apps/utility-sw/test/PracticeConnectUtilityTests.cs b/apps/utility-sw/test/PracticeConnectUtilityTests.cs new file mode 100644 index 00000000..aa58969a --- /dev/null +++ b/apps/utility-sw/test/PracticeConnectUtilityTests.cs @@ -0,0 +1,197 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +public class PracticeConnectUtilityTests +{ + private static readonly Guid MatchId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private const string Password = "practice-password"; + private const ulong Member = 76561198000000001UL; + private const ulong Stranger = 76561198000000002UL; + + private static PracticeSessionData Session() + { + return new PracticeSessionData + { + id = Guid.NewGuid(), + match_id = MatchId, + password = Password, + allowed_steam_ids = new List { Member.ToString() }, + }; + } + + private static string Token(string type, string role, ulong steamId) + { + return $"{type}:{role}:{ConnectAuth.ComputeExpectedToken(Password, type, role, steamId, MatchId)}"; + } + + // An unloaded roster must not read as "everyone is welcome". + [Fact] + public void WithoutASessionTheEnginesPasswordCheckStays() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize(null, Member, "anything"); + + Assert.Equal(ePracticeConnect.PasswordCheck, decision.action); + Assert.Null(decision.pending_role); + } + + [Fact] + public void AConnectWithNoTokenIsRejected() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize(Session(), Member, null); + + Assert.Equal(ePracticeConnect.Reject, decision.action); + } + + [Fact] + public void TheSessionPasswordItselfAuthorizes() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + Password + ); + + Assert.Equal(ePracticeConnect.Authorized, decision.action); + } + + // The roster is checked before the token, so a player the panel invited + // gets in whatever their client sent. + [Fact] + public void ARosterMemberIsAuthorizedWithoutAValidToken() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Member, + "garbage" + ); + + Assert.Equal(ePracticeConnect.Authorized, decision.action); + } + + [Fact] + public void RosterMatchingIgnoresSurroundingWhitespace() + { + var session = Session(); + session.allowed_steam_ids = new List { $" {Stranger} " }; + + Assert.True(PracticeConnectUtility.IsOnRoster(session, Stranger)); + Assert.False(PracticeConnectUtility.IsOnRoster(session, Member)); + } + + [Fact] + public void ATokenThatIsNotThreePartsIsRejected() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + "game:administrator" + ); + + Assert.Equal(ePracticeConnect.Reject, decision.action); + } + + [Theory] + [InlineData("administrator", "admin")] + [InlineData("streamer", "streamer")] + [InlineData("match_organizer", "organizer")] + [InlineData("tournament_organizer", "organizer")] + public void APrivilegedGameTokenCarriesItsRole(string role, string expected) + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + Token("game", role, Stranger) + ); + + Assert.Equal(ePracticeConnect.Authorized, decision.action); + Assert.Equal(expected, decision.pending_role); + } + + [Fact] + public void AnOrdinaryGameTokenAuthorizesWithNoRole() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + Token("game", "verified_user", Stranger) + ); + + Assert.Equal(ePracticeConnect.Authorized, decision.action); + Assert.Null(decision.pending_role); + } + + // Only "game" tokens hand out roles: a tv connection is still just a + // spectator. + [Fact] + public void ATvTokenNeverCarriesARole() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + Token("tv", "administrator", Stranger) + ); + + Assert.Equal(ePracticeConnect.Authorized, decision.action); + Assert.Null(decision.pending_role); + } + + [Fact] + public void TheUrlSafeAlphabetIsAccepted() + { + string token = Token("game", "administrator", Stranger); + string urlSafe = token.Replace("+", "-").Replace("/", "_"); + + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + urlSafe + ); + + Assert.Equal(ePracticeConnect.Authorized, decision.action); + } + + // A token signed for somebody else is not proof of anything, but neither is + // it grounds to refuse: the password may still be right. + [Fact] + public void ATokenSignedForAnotherPlayerFallsBackToThePasswordCheck() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + Token("game", "administrator", Member) + ); + + Assert.Equal(ePracticeConnect.PasswordCheck, decision.action); + } + + // A bad tv token is different: nothing but the token can authorise a tv + // connection, so the auth ticket is stripped instead. + [Fact] + public void ABadTvTokenIsRejected() + { + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + Session(), + Stranger, + Token("tv", "streamer", Member) + ); + + Assert.Equal(ePracticeConnect.Reject, decision.action); + } + + [Fact] + public void ATokenSignedWithAnotherSessionsPasswordDoesNotAuthorize() + { + var session = Session(); + session.password = "a-different-password"; + + PracticeConnectDecision decision = PracticeConnectUtility.Authorize( + session, + Stranger, + Token("game", "administrator", Stranger) + ); + + Assert.Equal(ePracticeConnect.PasswordCheck, decision.action); + } +} diff --git a/apps/utility-sw/test/PracticeDrillRunTests.cs b/apps/utility-sw/test/PracticeDrillRunTests.cs new file mode 100644 index 00000000..b7d559f2 --- /dev/null +++ b/apps/utility-sw/test/PracticeDrillRunTests.cs @@ -0,0 +1,560 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +// A run advances on a scored throw and on nothing else. The failures that +// matter are the silent ones: a step that resolves on the throw itself, so a +// miss is skipped past before it is read, and a step that never resolves at +// all because the panel stopped answering. +public class PracticeDrillRunTests +{ + private static readonly DateTime Now = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + private static LineupRecord Lineup(string id, string utility = "Smoke") + { + return new LineupRecord + { + id = id, + client_id = id, + name = id, + utility_type = utility, + release = new ThrowSnapshot { feet_position = new Vec3(10f, 20f, 30f) }, + detonation_position = new Vec3(900f, 900f, 30f), + }; + } + + private static PracticeDrillRun Run(params string[] ids) + { + return new PracticeDrillRun(ids.Select(id => Lineup(id)).ToList()); + } + + private static UtilityPracticeResult Result(bool success) + { + return new UtilityPracticeResult + { + success = success, + distance = success ? 20f : 300f, + radius = 80f, + attempts = 1, + successes = success ? 1 : 0, + current_streak = success ? 1 : 0, + best_streak = success ? 1 : 0, + }; + } + + // Move on, throw, score -- the whole loop, once, in the order the runner + // drives it. + private static void Throws(PracticeDrillRun run, bool hit) + { + LineupRecord? lineup = run.Next(); + + Assert.NotNull(lineup); + Assert.True(run.Thrown(lineup!.utility_type, Now)); + Assert.True(run.Score(lineup.id, Result(hit))); + } + + [Fact] + public void ARunHandsOutItsQueueInOrder() + { + PracticeDrillRun run = Run("a", "b", "c"); + + Assert.Equal("a", run.Next()!.id); + Assert.Equal("b", run.Next()!.id); + Assert.Equal("c", run.Next()!.id); + Assert.Null(run.Next()); + Assert.Equal(eDrillEnd.Completed, run.Ending); + } + + [Fact] + public void PositionReadsAsAPlaceInTheRun() + { + PracticeDrillRun run = Run("a", "b"); + + run.Next(); + + Assert.Equal(1, run.Position); + Assert.Equal(2, run.Length); + } + + [Fact] + public void AFinishedRunHandsOutNothingMore() + { + PracticeDrillRun run = Run("a", "b"); + + run.Next(); + run.End(eDrillEnd.Stopped); + + Assert.Null(run.Next()); + Assert.Equal(eDrillEnd.Stopped, run.Ending); + } + + [Fact] + public void StoppingTwiceKeepsTheFirstReason() + { + PracticeDrillRun run = Run("a"); + + run.End(eDrillEnd.Stopped); + run.End(eDrillEnd.Completed); + + Assert.Equal(eDrillEnd.Stopped, run.Ending); + } + + // Throwing a flash while a smoke is loaded is a different throw, not a + // missed one. + [Fact] + public void AThrowOfTheWrongUtilityIsNotAnAttempt() + { + PracticeDrillRun run = Run("a"); + run.Next(); + + Assert.False(run.Thrown("Flash", Now)); + Assert.False(run.Waiting); + } + + [Fact] + public void AThrowOfTheRightUtilityIsWaitedOn() + { + PracticeDrillRun run = Run("a"); + run.Next(); + + Assert.True(run.Thrown("Smoke", Now)); + Assert.True(run.Waiting); + } + + [Fact] + public void ASecondThrowOfTheSameStepIsIgnored() + { + PracticeDrillRun run = Run("a"); + run.Next(); + run.Thrown("Smoke", Now); + + Assert.False(run.Thrown("Smoke", Now.AddSeconds(1))); + } + + [Fact] + public void AScoreForSomethingElseIsNotThisStep() + { + PracticeDrillRun run = Run("a"); + run.Next(); + run.Thrown("Smoke", Now); + + Assert.False(run.Score("another-lineup", Result(true))); + Assert.True(run.Waiting); + } + + [Fact] + public void AScoreWithNothingInFlightIsIgnored() + { + PracticeDrillRun run = Run("a"); + run.Next(); + + Assert.False(run.Score("a", Result(true))); + Assert.Equal(0, run.Attempts); + } + + [Fact] + public void AHitCountsAndBuildsTheStreak() + { + PracticeDrillRun run = Run("a", "b"); + + Throws(run, hit: true); + Throws(run, hit: true); + + Assert.Equal(2, run.Hits); + Assert.Equal(0, run.Misses); + Assert.Equal(2, run.Streak); + Assert.Equal(2, run.BestStreak); + } + + [Fact] + public void AMissBreaksTheStreakButKeepsTheBest() + { + PracticeDrillRun run = Run("a", "b", "c"); + + Throws(run, hit: true); + Throws(run, hit: true); + Throws(run, hit: false); + + Assert.Equal(2, run.Hits); + Assert.Equal(1, run.Misses); + Assert.Equal(0, run.Streak); + Assert.Equal(2, run.BestStreak); + } + + // Nobody knows whether it landed, so it is not a miss and it does not + // break a streak. + [Fact] + public void AThrowThePanelDidNotAnswerIsNotAMiss() + { + PracticeDrillRun run = Run("a", "b"); + + Throws(run, hit: true); + run.Next(); + + run.Thrown("Smoke", Now); + Assert.True(run.Score("b", null)); + + Assert.Equal(1, run.Unscored); + Assert.Equal(0, run.Misses); + Assert.Equal(1, run.Streak); + } + + [Fact] + public void ARunGivesUpOnAPanelThatKeepsNotAnswering() + { + PracticeDrillRun run = Run("a", "b", "c", "d"); + + for (int step = 0; step < DrillUtility.MaxUnscoredInARow; step++) + { + run.Next(); + run.Thrown("Smoke", Now); + run.Score(run.Current!.id, null); + } + + Assert.Equal(eDrillEnd.Unscorable, run.Ending); + } + + [Fact] + public void AnAnsweredThrowForgivesTheOnesBefore() + { + PracticeDrillRun run = Run("a", "b", "c", "d", "e"); + + run.Next(); + run.Thrown("Smoke", Now); + run.Score("a", null); + + Throws(run, hit: true); + + run.Next(); + run.Thrown("Smoke", Now); + run.Score("c", null); + run.Next(); + run.Thrown("Smoke", Now); + run.Score("d", null); + + Assert.Equal(eDrillEnd.Running, run.Ending); + } + + [Fact] + public void AThrowIsWaitedOnUntilItsDeadline() + { + PracticeDrillRun run = Run("a"); + run.Next(); + run.Thrown("Smoke", Now); + + Assert.False(run.Expired(Now.AddSeconds(DrillUtility.ScoreWaitSeconds - 1))); + Assert.True(run.Expired(Now.AddSeconds(DrillUtility.ScoreWaitSeconds))); + Assert.Equal(1, run.Unscored); + } + + [Fact] + public void NothingExpiresWhenNothingIsInFlight() + { + PracticeDrillRun run = Run("a"); + run.Next(); + + Assert.False(run.Expired(Now.AddHours(1))); + } + + [Fact] + public void AnExpiredThrowIsOnlyWrittenOffOnce() + { + PracticeDrillRun run = Run("a"); + run.Next(); + run.Thrown("Smoke", Now); + + Assert.True(run.Expired(Now.AddMinutes(5))); + Assert.False(run.Expired(Now.AddMinutes(6))); + Assert.Equal(1, run.Unscored); + } + + // The answer arrived after the run stopped waiting for it; the step it + // belonged to is gone. + [Fact] + public void AScoreThatArrivesAfterTheDeadlineIsIgnored() + { + PracticeDrillRun run = Run("a", "b"); + run.Next(); + run.Thrown("Smoke", Now); + run.Expired(Now.AddMinutes(1)); + run.Next(); + + Assert.False(run.Score("a", Result(true))); + Assert.Equal(0, run.Hits); + } + + [Fact] + public void MovingOnDropsAThrowNobodyAnsweredFor() + { + PracticeDrillRun run = Run("a", "b"); + run.Next(); + run.Thrown("Smoke", Now); + run.Next(); + + Assert.False(run.Waiting); + } + + [Fact] + public void ALineupThatCannotBeStoodOnIsDropped() + { + PracticeDrillRun run = Run("a", "b"); + + run.Next(); + run.CannotLoad(); + + Assert.Equal(1, run.Dropped); + Assert.Equal(eDrillEnd.Running, run.Ending); + } + + [Fact] + public void ARunOfLineupsThatCannotBeStoodOnEndsTheRun() + { + PracticeDrillRun run = Run("a", "b", "c", "d"); + + for (int step = 0; step < DrillUtility.MaxUnloadableInARow; step++) + { + run.Next(); + run.CannotLoad(); + } + + Assert.Equal(eDrillEnd.Unloadable, run.Ending); + } + + [Fact] + public void OneLineupThatLoadsForgivesTheOnesBefore() + { + PracticeDrillRun run = Run("a", "b", "c", "d", "e"); + + run.Next(); + run.CannotLoad(); + run.Next(); + run.CannotLoad(); + run.Next(); + run.Loaded(); + run.Next(); + run.CannotLoad(); + run.Next(); + run.CannotLoad(); + + Assert.Equal(eDrillEnd.Running, run.Ending); + Assert.Equal(4, run.Dropped); + } + + [Fact] + public void ASkippedLineupIsNeitherAHitNorAMiss() + { + PracticeDrillRun run = Run("a", "b"); + + Throws(run, hit: true); + run.Next(); + + Assert.True(run.Skip()); + Assert.Equal(1, run.Skipped); + Assert.Equal(1, run.Hits); + Assert.Equal(0, run.Misses); + Assert.Equal(0, run.Streak); + } + + [Fact] + public void SkippingDropsTheThrowInFlightWithIt() + { + PracticeDrillRun run = Run("a", "b"); + run.Next(); + run.Thrown("Smoke", Now); + run.Skip(); + + Assert.False(run.Waiting); + Assert.False(run.Score("a", Result(true))); + } + + [Fact] + public void ThereIsNothingToSkipBeforeARunStarts() + { + Assert.False(Run("a").Skip()); + } + + [Fact] + public void ASummaryIsHitsOutOfAttemptsAndTheBestStreak() + { + PracticeDrillRun run = Run("a", "b", "c"); + + Throws(run, hit: true); + Throws(run, hit: true); + Throws(run, hit: false); + run.Next(); + + List summary = run.Summary(); + + Assert.Contains("2/3 hit", summary[0]); + Assert.Contains("best streak 2", summary[0]); + Assert.Contains("over", summary[0]); + } + + // The run is supposed to point at what to practise next. + [Fact] + public void ASummaryNamesWhatWasMissedMostOften() + { + PracticeDrillRun run = Run("xbox", "window", "xbox"); + + Throws(run, hit: false); + Throws(run, hit: true); + Throws(run, hit: false); + + run.Next(); + + string missed = run.Summary().Single(line => line.StartsWith("missed: ")); + + Assert.Equal("missed: xbox (2)", missed); + } + + [Fact] + public void ASummaryNamesWhatWasSkipped() + { + PracticeDrillRun run = Run("a", "b"); + + run.Next(); + run.Skip(); + run.Next(); + run.Skip(); + run.Next(); + + Assert.Contains(run.Summary(), line => line == "skipped: a, b"); + } + + [Fact] + public void ASummarySaysHowManyThrowsWereNeverScored() + { + PracticeDrillRun run = Run("a", "b"); + + run.Next(); + run.Thrown("Smoke", Now); + run.Score("a", null); + run.Next(); + + Assert.Contains(run.Summary(), line => line.Contains("1 throw could not be scored")); + } + + [Fact] + public void ASummarySaysWhatCouldNotBeLoaded() + { + PracticeDrillRun run = Run("a", "b"); + + run.Next(); + run.CannotLoad(); + run.Next(); + run.CannotLoad(); + run.Next(); + + Assert.Contains(run.Summary(), line => line == "2 could not be loaded"); + } + + [Fact] + public void ASummarySaysWhyARunStoppedEarly() + { + PracticeDrillRun stopped = Run("a", "b"); + stopped.Next(); + stopped.End(eDrillEnd.Stopped); + + PracticeDrillRun unscorable = Run("a", "b"); + unscorable.Next(); + unscorable.End(eDrillEnd.Unscorable); + + PracticeDrillRun unloadable = Run("a", "b"); + unloadable.Next(); + unloadable.End(eDrillEnd.Unloadable); + + Assert.Contains("stopped", stopped.Summary()[0]); + Assert.Contains("the panel is not scoring throws right now", unscorable.Summary()[0]); + Assert.Contains("could not be loaded", unloadable.Summary()[0]); + } + + [Fact] + public void ARunThatWasNeverThrownSummarisesAsNothing() + { + PracticeDrillRun run = Run("a"); + run.Next(); + run.End(eDrillEnd.Stopped); + + List summary = run.Summary(); + + Assert.Single(summary); + Assert.Contains("0/0 hit", summary[0]); + } + + [Fact] + public void TwoRunsKeepTheirOwnCounts() + { + PracticeDrillRun mine = Run("a", "b"); + PracticeDrillRun theirs = Run("a", "b"); + + Throws(mine, hit: true); + Throws(theirs, hit: false); + + Assert.Equal(1, mine.Hits); + Assert.Equal(0, mine.Misses); + Assert.Equal(0, theirs.Hits); + Assert.Equal(1, theirs.Misses); + } +} + +public class PracticeDrillRunRepTests +{ + private static LineupRecord Lineup(string id) + { + return new LineupRecord { id = id, client_id = id, utility_type = "Smoke" }; + } + + [Fact] + public void EachLineupIsRepeatedBeforeTheNext() + { + var run = new PracticeDrillRun(new[] { Lineup("a"), Lineup("b") }, 3); + + // Consecutive, not interleaved: you throw the same lineup until it is + // learned rather than being sent round the map three times. + Assert.Equal("a", run.Next()?.id); + Assert.Equal("a", run.Next()?.id); + Assert.Equal("a", run.Next()?.id); + Assert.Equal("b", run.Next()?.id); + } + + [Fact] + public void RepAndPositionReadAsProgress() + { + var run = new PracticeDrillRun(new[] { Lineup("a"), Lineup("b") }, 3); + + run.Next(); + Assert.Equal(1, run.Position); + Assert.Equal(1, run.Rep); + + run.Next(); + Assert.Equal(1, run.Position); + Assert.Equal(2, run.Rep); + + run.Next(); + run.Next(); + Assert.Equal(2, run.Position); + Assert.Equal(1, run.Rep); + } + + [Fact] + public void TheRunEndsAfterEveryRepOfEveryLineup() + { + var run = new PracticeDrillRun(new[] { Lineup("a") }, 2); + + Assert.NotNull(run.Next()); + Assert.NotNull(run.Next()); + Assert.Null(run.Next()); + Assert.True(run.Finished); + } + + [Fact] + public void OneRepIsTheOldBehaviour() + { + var run = new PracticeDrillRun(new[] { Lineup("a"), Lineup("b") }); + + Assert.Equal("a", run.Next()?.id); + Assert.Equal("b", run.Next()?.id); + Assert.Null(run.Next()); + } +} diff --git a/apps/utility-sw/test/PracticeJsonTests.cs b/apps/utility-sw/test/PracticeJsonTests.cs new file mode 100644 index 00000000..eaa05d55 --- /dev/null +++ b/apps/utility-sw/test/PracticeJsonTests.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +public class PracticeJsonTests +{ + [Fact] + public void ASessionReadsBackFromThePanelsSpelling() + { + const string json = + "{\"id\":\"11111111-1111-1111-1111-111111111111\",\"match_id\":\"22222222-2222-2222-2222-222222222222\",\"password\":\"pw\",\"map\":\"de_mirage\",\"allowed_steam_ids\":[\"1\",\"2\"]}"; + + PracticeSessionData? session = JsonSerializer.Deserialize( + json, + PracticeJson.Options + ); + + Assert.NotNull(session); + Assert.Equal("pw", session!.password); + Assert.Equal("de_mirage", session.map); + Assert.Equal(2, session.allowed_steam_ids.Count); + Assert.Equal(Guid.Parse("22222222-2222-2222-2222-222222222222"), session.match_id); + } + + [Fact] + public void APathPointReadsBackFieldByField() + { + const string json = "[{\"tick\":7,\"x\":1.5,\"y\":-2.5,\"z\":3}]"; + + List? path = JsonSerializer.Deserialize>( + json, + PracticeJson.Options + ); + + UtilityPathPoint point = Assert.Single(path!); + Assert.Equal(7, point.tick); + Assert.Equal(1.5f, point.x); + Assert.Equal(-2.5f, point.y); + Assert.Equal(3f, point.z); + } + + // The panel decides which fields a row carries, so a partial row must read + // rather than throw. + [Fact] + public void AMissingFieldReadsAsAbsentRatherThanFailing() + { + const string json = "{\"id\":\"x\",\"name\":\"only a name\"}"; + + UtilityLibraryRow? row = JsonSerializer.Deserialize( + json, + PracticeJson.Options + ); + + Assert.NotNull(row); + Assert.Null(row!.origin_x); + + LineupRecord lineup = row.ToLineup(); + Assert.Equal(0f, lineup.release.feet_position.x); + Assert.Equal(0f, lineup.flight_time); + } +} diff --git a/apps/utility-sw/test/PracticeLaunchUtilityTests.cs b/apps/utility-sw/test/PracticeLaunchUtilityTests.cs new file mode 100644 index 00000000..95736933 --- /dev/null +++ b/apps/utility-sw/test/PracticeLaunchUtilityTests.cs @@ -0,0 +1,196 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +// The launch model is the only physics the solver contains, and none of it can +// be verified from here -- these tests pin the shape of the function so a +// change to it is deliberate. Whether the constants match CS2 is a question +// only a live server answers, which is what calibration is for. +public class PracticeLaunchUtilityTests +{ + private static readonly Vec3 Eye = new Vec3(100f, 200f, 64f); + + [Fact] + public void BendsTheAimDownAtTheHorizon() + { + Assert.Equal(-10f, PracticeLaunchUtility.RemapPitch(0f), 3); + } + + [Fact] + public void RemapRoundTrips() + { + foreach (float pitch in new[] { -80f, -45f, -10f, 0f, 12f, 60f }) + { + float back = PracticeLaunchUtility.UnremapPitch( + PracticeLaunchUtility.RemapPitch(pitch) + ); + + Assert.Equal(pitch, back, 3); + } + } + + [Fact] + public void NormalizesAnUnwrappedPitch() + { + Assert.Equal(-30f, PracticeLaunchUtility.NormalizePitch(330f), 3); + Assert.Equal(45f, PracticeLaunchUtility.NormalizePitch(405f), 3); + } + + [Fact] + public void ForwardFollowsTheSourceConvention() + { + Vec3 level = PracticeLaunchUtility.Forward(0f, 0f); + Assert.Equal(1f, level.x, 4); + Assert.Equal(0f, level.y, 4); + Assert.Equal(0f, level.z, 4); + + // Positive pitch is looking down. + Assert.True(PracticeLaunchUtility.Forward(45f, 0f).z < 0f); + Assert.True(PracticeLaunchUtility.Forward(-45f, 0f).z > 0f); + + Vec3 quarter = PracticeLaunchUtility.Forward(0f, 90f); + Assert.Equal(0f, quarter.x, 4); + Assert.Equal(1f, quarter.y, 4); + } + + // The grenade does not leave along the crosshair, and a solver that assumed + // it did would report an aim a degree or two under every throw it found. + [Fact] + public void ThrowDirectionIsNotTheCrosshair() + { + Vec3 crosshair = PracticeLaunchUtility.Forward(0f, 0f); + Vec3 thrown = PracticeLaunchUtility.ThrowDirection(0f, 0f); + + Assert.True(PracticeLaunchUtility.AngleBetween(crosshair, thrown) > 9f); + Assert.True(thrown.z > 0f); + } + + [Fact] + public void SpeedSaturates() + { + Assert.Equal( + PracticeLaunchUtility.MaxSpeed, + PracticeLaunchUtility.BaseSpeed(-60f), + 3 + ); + Assert.True(PracticeLaunchUtility.BaseSpeed(0f) < PracticeLaunchUtility.MaxSpeed); + Assert.True(PracticeLaunchUtility.BaseSpeed(45f) < PracticeLaunchUtility.BaseSpeed(0f)); + } + + [Fact] + public void StrengthScaleIsMonotoneAndFullIsUnscaled() + { + Assert.Equal(1f, PracticeLaunchUtility.StrengthScale(1f), 4); + Assert.Equal(PracticeLaunchUtility.MinStrengthScale, PracticeLaunchUtility.StrengthScale(0f), 4); + Assert.True( + PracticeLaunchUtility.StrengthScale(0.5f) > PracticeLaunchUtility.StrengthScale(0f) + ); + Assert.True( + PracticeLaunchUtility.StrengthScale(0.5f) < PracticeLaunchUtility.StrengthScale(1f) + ); + } + + [Fact] + public void MapsTheThreeReleasesAPlayerCanMake() + { + Assert.Equal(1f, PracticeLaunchUtility.RawStrength(eThrowStrength.Full)); + Assert.Equal(0.5f, PracticeLaunchUtility.RawStrength(eThrowStrength.Half)); + Assert.Equal(0f, PracticeLaunchUtility.RawStrength(eThrowStrength.Drop)); + } + + [Fact] + public void SeedSpawnsAheadOfTheEyeAlongTheThrow() + { + LaunchSeed seed = PracticeLaunchUtility.Seed( + Eye, + -12f, + 35f, + 1f, + new Vec3(0f, 0f, 0f) + ); + + Assert.Equal( + PracticeLaunchUtility.ForwardOffset, + (seed.position - Eye).Length(), + 2 + ); + Assert.Equal( + 0f, + PracticeLaunchUtility.AngleBetween(seed.position - Eye, seed.velocity), + 2 + ); + Assert.Equal(seed.speed, seed.velocity.Length(), 2); + } + + [Fact] + public void SeedCarriesTheThrowersOwnVelocity() + { + var running = new Vec3(0f, 250f, 0f); + + LaunchSeed still = PracticeLaunchUtility.Seed(Eye, 0f, 0f, 1f, new Vec3(0f, 0f, 0f)); + LaunchSeed moving = PracticeLaunchUtility.Seed(Eye, 0f, 0f, 1f, running); + + Assert.Equal( + running.y * PracticeLaunchUtility.PlayerVelocityScale, + moving.velocity.y - still.velocity.y, + 2 + ); + } + + [Fact] + public void SpeedCorrectionScalesTheRelease() + { + LaunchSeed plain = PracticeLaunchUtility.Seed(Eye, -10f, 0f, 1f, new Vec3(0f, 0f, 0f)); + LaunchSeed corrected = PracticeLaunchUtility.Seed( + Eye, + -10f, + 0f, + 1f, + new Vec3(0f, 0f, 0f), + 1.25f + ); + + Assert.Equal(plain.speed * 1.25f, corrected.speed, 2); + } + + [Fact] + public void BearingPointsAtTheTarget() + { + Assert.Equal( + 90f, + PracticeLaunchUtility.BearingTo(new Vec3(0f, 0f, 0f), new Vec3(0f, 500f, 0f)), + 3 + ); + Assert.Equal( + 0f, + PracticeLaunchUtility.BearingTo(new Vec3(0f, 0f, 0f), new Vec3(500f, 0f, 200f)), + 3 + ); + } + + [Fact] + public void PredictReadsTheReleaseSnapshot() + { + var release = new ThrowSnapshot + { + eye_position = Eye, + pitch = -20f, + yaw = 15f, + throw_strength_raw = 0.5f, + velocity = new Vec3(0f, 0f, 0f), + }; + + LaunchSeed predicted = PracticeLaunchUtility.Predict(release); + LaunchSeed direct = PracticeLaunchUtility.Seed( + Eye, + -20f, + 15f, + 0.5f, + new Vec3(0f, 0f, 0f) + ); + + Assert.Equal(direct.speed, predicted.speed, 3); + Assert.Equal(direct.position.x, predicted.position.x, 3); + } +} diff --git a/apps/utility-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs new file mode 100644 index 00000000..fab368ee --- /dev/null +++ b/apps/utility-sw/test/PracticeLineupUtilityTests.cs @@ -0,0 +1,428 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +public class PracticeLineupUtilityTests +{ + private static LineupRecord Lineup(string name, float x = 0f, float y = 0f) + { + return new LineupRecord + { + name = name, + release = new ThrowSnapshot { feet_position = new Vec3(x, y, 0f) }, + }; + } + + [Fact] + public void MapsProjectilesToUtilityTypes() + { + Assert.Equal("Smoke", PracticeLineupUtility.UtilityTypeForProjectile("smokegrenade_projectile")); + Assert.Equal("Flash", PracticeLineupUtility.UtilityTypeForProjectile("flashbang_projectile")); + Assert.Equal("HighExplosive", PracticeLineupUtility.UtilityTypeForProjectile("hegrenade_projectile")); + Assert.Equal("Decoy", PracticeLineupUtility.UtilityTypeForProjectile("decoy_projectile")); + Assert.Null(PracticeLineupUtility.UtilityTypeForProjectile("weapon_ak47")); + } + + // Both entity names produce a Molotov: incendiary and molotov differ to the + // engine but are one lineup type to a player. + [Fact] + public void TreatsIncendiaryAndMolotovAsOneType() + { + Assert.Equal("Molotov", PracticeLineupUtility.UtilityTypeForProjectile("molotov_projectile")); + Assert.Equal("Molotov", PracticeLineupUtility.UtilityTypeForProjectile("incendiarygrenade_projectile")); + } + + [Fact] + public void MapsUtilityTypesBackToWeapons() + { + Assert.Equal("weapon_smokegrenade", PracticeLineupUtility.WeaponForUtilityType("Smoke")); + Assert.Null(PracticeLineupUtility.WeaponForUtilityType("NotAThing")); + } + + [Fact] + public void RecognisesGrenadesInHand() + { + Assert.True(PracticeLineupUtility.IsGrenadeWeapon("weapon_smokegrenade")); + Assert.True(PracticeLineupUtility.IsGrenadeWeapon("weapon_incgrenade")); + Assert.False(PracticeLineupUtility.IsGrenadeWeapon("weapon_ak47")); + } + + // Typing a full name must win outright, even when another lineup is closer. + [Fact] + public void ExactNameBeatsProximity() + { + var lineups = new[] { Lineup("Window", 1000f), Lineup("Window Long", 0f) }; + var resolved = PracticeLineupUtility.Resolve(lineups, "Window", new Vec3(0f, 0f, 0f)); + Assert.Equal("Window", resolved?.name); + } + + [Fact] + public void UniquePrefixResolves() + { + var lineups = new[] { Lineup("Window"), Lineup("Jungle") }; + Assert.Equal("Jungle", PracticeLineupUtility.Resolve(lineups, "Jun")?.name); + } + + [Fact] + public void AmbiguousPrefixFallsBackToNearest() + { + var lineups = new[] { Lineup("Window A", 900f), Lineup("Window B", 10f) }; + var resolved = PracticeLineupUtility.Resolve(lineups, "Window", new Vec3(0f, 0f, 0f)); + Assert.Equal("Window B", resolved?.name); + } + + [Fact] + public void EmptyQueryPicksTheNearest() + { + var lineups = new[] { Lineup("Far", 900f), Lineup("Near", 5f) }; + var resolved = PracticeLineupUtility.Resolve(lineups, "", new Vec3(0f, 0f, 0f)); + Assert.Equal("Near", resolved?.name); + } + + [Fact] + public void NoMatchResolvesToNothing() + { + var lineups = new[] { Lineup("Window") }; + Assert.Null(PracticeLineupUtility.Resolve(lineups, "Ramp")); + Assert.Null(PracticeLineupUtility.Resolve(Array.Empty(), "Window")); + } + + [Fact] + public void FilterWithoutAQueryKeepsEverything() + { + var lineups = new[] { Lineup("Window"), Lineup("Jungle") }; + Assert.Equal(2, PracticeLineupUtility.Filter(lineups, "").Count); + } + + [Fact] + public void FilterMatchesAnywhereInTheNameAndIgnoresCase() + { + var lineups = new[] { Lineup("Window Long"), Lineup("Deep Jungle"), Lineup("Ramp") }; + var matches = PracticeLineupUtility.Filter(lineups, "un"); + + Assert.Single(matches); + Assert.Equal("Deep Jungle", matches[0].name); + } + + // .next and .prev walk this list, so the order is the one the player would + // expect: whatever is closest first. + [Fact] + public void FilterOrdersByDistanceWhenGivenAPosition() + { + var lineups = new[] { Lineup("Window Far", 900f), Lineup("Window Near", 5f) }; + var matches = PracticeLineupUtility.Filter(lineups, "Window", new Vec3(0f, 0f, 0f)); + + Assert.Equal("Window Near", matches[0].name); + Assert.Equal("Window Far", matches[1].name); + } + + [Fact] + public void FilterReturnsNothingWhenTheQueryMatchesNothing() + { + var lineups = new[] { Lineup("Window") }; + Assert.Empty(PracticeLineupUtility.Filter(lineups, "Ramp")); + } + + [Fact] + public void NormalizingIsCaseInsensitive() + { + Assert.Equal("HighExplosive", PracticeLineupUtility.NormalizeUtilityType("he")); + Assert.Equal("Flash", PracticeLineupUtility.NormalizeUtilityType("FLASHBANG")); + } + + // An unknown value is passed through rather than guessed at: the API will + // reject it loudly, which beats storing it as the wrong type. + [Fact] + public void NormalizingLeavesAnUnknownTypeAlone() + { + Assert.Equal("Banana", PracticeLineupUtility.NormalizeUtilityType("Banana")); + } +} + +public class UtilityBySpotTests +{ + private static List<(float, float, float, List)> Group( + params (float x, float y, float z, string type)[] throws + ) + { + return PracticeLineupUtility.UtilityBySpot(throws, 40f, 72f); + } + + [Fact] + public void TwoSmokesFromOneSpotAreOneSmoke() + { + var spots = Group((100f, 100f, 0f, "Smoke"), (108f, 96f, 0f, "Smoke")); + + Assert.Single(spots); + Assert.Equal(new[] { "Smoke" }, spots[0].Item4); + } + + [Fact] + public void ASpotWithTwoKindsShowsBoth() + { + var spots = Group((100f, 100f, 0f, "Smoke"), (110f, 100f, 0f, "Flash")); + + Assert.Single(spots); + Assert.Equal(new[] { "Smoke", "Flash" }, spots[0].Item4); + } + + [Fact] + public void SpotsFurtherApartThanTheRadiusStaySeparate() + { + var spots = Group((100f, 100f, 0f, "Smoke"), (200f, 100f, 0f, "Smoke")); + + Assert.Equal(2, spots.Count); + } + + [Fact] + public void TheSamePositionOnAnotherFloorIsAnotherSpot() + { + var spots = Group((100f, 100f, 0f, "Smoke"), (100f, 100f, 128f, "Smoke")); + + Assert.Equal(2, spots.Count); + } + + [Fact] + public void GroupingIsByDistanceNotByAGrid() + { + // Two throws either side of a grid line are one spot; a naive round() + // would split them and draw the model twice. + var spots = Group((39f, 0f, 0f, "Smoke"), (41f, 0f, 0f, "Smoke")); + + Assert.Single(spots); + } + + [Fact] + public void NothingInNothingOut() + { + Assert.Empty(PracticeLineupUtility.UtilityBySpot([], 40f, 72f)); + } +} + +public class AimMissTests +{ + [Fact] + public void InsideToleranceIsFullyOn() + { + Assert.Equal(0f, PracticeLineupUtility.AimMiss(0.2f, 0.35f)); + Assert.Equal(0f, PracticeLineupUtility.AimMiss(0.35f, 0.35f)); + } + + [Fact] + public void JustOutsideToleranceIsNotYetRed() + { + float miss = PracticeLineupUtility.AimMiss(0.4f, 0.35f); + + Assert.True(miss > 0f); + Assert.True(miss < 0.1f); + } + + [Fact] + public void FarOffIsFullyRed() + { + Assert.Equal(1f, PracticeLineupUtility.AimMiss(90f, 0.35f)); + } + + [Fact] + public void AWiderToleranceStaysGreenLonger() + { + Assert.Equal(0f, PracticeLineupUtility.AimMiss(1.5f, 2f)); + Assert.True(PracticeLineupUtility.AimMiss(1.5f, 0.35f) > 0f); + } + + [Fact] + public void ALineupThatNeverSaidFallsBackToTheDefault() + { + Assert.Equal( + PracticeLineupUtility.AimMiss(0.5f, PracticeLineupUtility.DefaultAimTolerance), + PracticeLineupUtility.AimMiss(0.5f, 0f) + ); + } + + [Fact] + public void ErrorIsTheWorseOfTheTwoAxes() + { + Assert.Equal(3f, PracticeLineupUtility.AimError(0f, 3f, 0f, 0f)); + Assert.Equal(3f, PracticeLineupUtility.AimError(3f, 0f, 0f, 0f)); + } + + [Fact] + public void ErrorTakesTheShortWayRoundTheCircle() + { + // 359 and 1 are two degrees apart, not 358. + Assert.Equal(2f, PracticeLineupUtility.AimError(359f, 0f, 1f, 0f)); + } + + [Fact] + public void MissNeverLeavesTheZeroToOneRange() + { + foreach (float error in new[] { 0f, 0.01f, 1f, 5f, 50f, 179f }) + { + float miss = PracticeLineupUtility.AimMiss(error, 0.35f); + + Assert.InRange(miss, 0f, 1f); + } + } +} + +public class StanceMissTests +{ + [Fact] + public void StandingOnTheSpotIsFullyOn() + { + Assert.Equal(0f, PracticeLineupUtility.StanceMiss(0f)); + Assert.Equal(0f, PracticeLineupUtility.StanceMiss(8f)); + } + + [Fact] + public void DriftingOffRampsUp() + { + float near = PracticeLineupUtility.StanceMiss(12f); + float far = PracticeLineupUtility.StanceMiss(30f); + + Assert.True(near > 0f); + Assert.True(far > near); + Assert.True(far < 1f); + } + + [Fact] + public void WellOffTheSpotIsFullyRed() + { + Assert.Equal(1f, PracticeLineupUtility.StanceMiss(48f)); + Assert.Equal(1f, PracticeLineupUtility.StanceMiss(500f)); + } + + [Fact] + public void StanceToleranceIsTighterThanTheSpotItself() + { + // SpotRadius asks "is this the same place"; this asks "are you on it". + Assert.True(PracticeLineupUtility.StanceToleranceUnits < 40f); + } +} + +public class MissBucketTests +{ + [Fact] + public void GreenIsReservedForInsideTolerance() + { + Assert.Equal(0, PracticeLineupUtility.MissBucket(0f, 5)); + + // The smallest possible miss is already NOT green -- this is the whole + // point: the colour and LINED UP must never disagree. + Assert.NotEqual(0, PracticeLineupUtility.MissBucket(0.001f, 5)); + } + + [Fact] + public void OutsideToleranceRampsAcrossTheRemainingSteps() + { + Assert.Equal(1, PracticeLineupUtility.MissBucket(0.05f, 5)); + Assert.Equal(4, PracticeLineupUtility.MissBucket(1f, 5)); + Assert.Equal(4, PracticeLineupUtility.MissBucket(0.9f, 5)); + } + + [Fact] + public void EveryMissLandsInsideTheStepRange() + { + foreach (float miss in new[] { 0f, 0.001f, 0.2f, 0.5f, 0.99f, 1f }) + { + Assert.InRange(PracticeLineupUtility.MissBucket(miss, 5), 0, 4); + } + } +} + +public class TechniqueLabelTests +{ + [Theory] + [InlineData("Stationary", "STAND STILL")] + [InlineData("Walking", "WALK AND THROW")] + [InlineData("Running", "RUN AND THROW")] + [InlineData("Crouch", "CROUCH THROW")] + [InlineData("Jump", "JUMP THROW")] + [InlineData("RunJump", "RUN + JUMP THROW")] + [InlineData("WalkJump", "WALK + JUMP THROW")] + [InlineData("CrouchJump", "CROUCH + JUMP THROW")] + public void EveryTechniqueHasItsOwnInstruction(string technique, string expected) + { + Assert.Equal(expected, PracticeLineupUtility.TechniqueLabel(technique)); + } + + [Fact] + public void NoTechniqueIsSilentlyTreatedAsStandingStill() + { + // The bug this guards: the old switch matched "Run"/"Walk" while the + // enum says Running/Walking, so a running throw was taught as a + // standing one and simply never landed. + foreach (string name in Enum.GetNames()) + { + string label = PracticeLineupUtility.TechniqueLabel(name); + + if (name != nameof(eThrowTechnique.Stationary)) + { + Assert.NotEqual("STAND STILL", label); + } + } + } + + [Theory] + [InlineData("Full", "LEFT CLICK")] + [InlineData("Half", "LEFT + RIGHT CLICK")] + [InlineData("Drop", "RIGHT CLICK")] + public void EveryStrengthHasItsOwnClick(string strength, string expected) + { + Assert.Equal(expected, PracticeLineupUtility.StrengthLabel(strength)); + } +} + +public class TrackedHtmlTests +{ + [Fact] + public void WordGapsSurviveMarkupCollapsing() + { + string html = PracticeLineupUtility.TrackedHtml("stand in"); + + // Every gap has to be non-breaking, or HTML folds the three spaces + // between two words down to one and the words run together. + Assert.DoesNotContain(" ", html); + Assert.Equal("S T A N D   I N", html); + } + + [Fact] + public void PlainTrackingIsUntouched() + { + Assert.Equal("S T A N D", PracticeLineupUtility.Tracked("stand")); + } +} + +public class TitleCaseTests +{ + [Theory] + [InlineData("new window", "New Window")] + [InlineData("CONNECTOR", "Connector")] + [InlineData("SMOKE - JUMP THROW - LEFT CLICK", "Smoke - Jump Throw - Left Click")] + [InlineData("a", "A")] + public void WordsAreCapitalisedAndTheRestLowered(string input, string expected) + { + Assert.Equal(expected, PracticeLineupUtility.TitleCase(input)); + } + + [Fact] + public void HyphensAreNotWordBreaks() + { + // "Write-Up" reads worse than "Write-up", so only whitespace splits. + Assert.Equal("Write-up On The Web", PracticeLineupUtility.TitleCase("WRITE-UP ON THE WEB")); + } + + [Fact] + public void EmptyInputStaysEmpty() + { + Assert.Equal("", PracticeLineupUtility.TitleCase(null)); + Assert.Equal("", PracticeLineupUtility.TitleCase(" ")); + } + + [Fact] + public void RunsOfSpacesDoNotCrash() + { + Assert.Equal("Two Gaps", PracticeLineupUtility.TitleCase("two gaps")); + } +} diff --git a/apps/utility-sw/test/PracticeSignalUtilityTests.cs b/apps/utility-sw/test/PracticeSignalUtilityTests.cs new file mode 100644 index 00000000..cf8c7b39 --- /dev/null +++ b/apps/utility-sw/test/PracticeSignalUtilityTests.cs @@ -0,0 +1,149 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +// A contract with another process rather than with a person. Every assertion +// here is a thing an external clip recorder would break on, which is why they +// are pinned rather than left to whatever the formatter happens to do. +public class PracticeSignalUtilityTests +{ + // The shape a reader outside this repo matches on. + private static readonly Regex Line = new Regex( + @"^\[utility-practice\] ghost_detonated utility=(?\S+) lineup=(?\S+) lineup_id=(?\S+) steam=(?\d+) x=(?-?\d+\.\d\d) y=(?-?\d+\.\d\d) z=(?-?\d+\.\d\d)$" + ); + + [Fact] + public void TheDetonationLineHasTheShapeAReaderExpects() + { + string line = PracticeSignalUtility.GhostDetonatedLine( + "Smoke", + new Vec3(-1234.5f, 567.25f, 64f), + "client-1", + "panel-1", + 76561198000000001 + ); + + Match match = Line.Match(line); + + Assert.True(match.Success, line); + Assert.Equal("Smoke", match.Groups["utility"].Value); + Assert.Equal("client-1", match.Groups["lineup"].Value); + Assert.Equal("panel-1", match.Groups["lineup_id"].Value); + Assert.Equal("76561198000000001", match.Groups["steam"].Value); + Assert.Equal("-1234.50", match.Groups["x"].Value); + Assert.Equal("567.25", match.Groups["y"].Value); + Assert.Equal("64.00", match.Groups["z"].Value); + } + + // A lineup thrown from throw history has no panel id yet. The field stays + // present so a reader can key on names and not on how many fields there + // happen to be this time. + [Fact] + public void AnAbsentIdIsStillAField() + { + string line = PracticeSignalUtility.GhostDetonatedLine( + "Molotov", + new Vec3(0f, 0f, 0f), + "client-1", + null, + 1 + ); + + Assert.True(Line.IsMatch(line), line); + Assert.Contains("lineup_id=-", line); + } + + // A server running under a locale where the decimal separator is a comma + // would otherwise emit "x=-1234,50" and split every reader's parser. + [Fact] + public void TheLineIsTheSameInEveryLocale() + { + CultureInfo original = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + + string line = PracticeSignalUtility.GhostDetonatedLine( + "Flash", + new Vec3(-1234.5f, 567.25f, 64f), + "client-1", + "panel-1", + 7 + ); + + Assert.Contains("x=-1234.50", line); + Assert.True(Line.IsMatch(line), line); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void TheLineIsOneLineAndSpaceSeparable() + { + string line = PracticeSignalUtility.GhostDetonatedLine( + "HighExplosive", + new Vec3(1f, 2f, 3f), + "a name with spaces", + "panel-1", + 9 + ); + + Assert.DoesNotContain("\n", line); + Assert.Contains("lineup=a_name_with_spaces", line); + Assert.True(Line.IsMatch(line), line); + } + + // An external caller has to be able to say what it wants. Reading "off" as + // "toggle" would make the command a coin flip for anything that cannot see + // the current state. + [Fact] + public void ExplicitTogglesAreExplicit() + { + Assert.True(PracticeSignalUtility.TryParseToggle("off", true, out bool off)); + Assert.False(off); + + Assert.True(PracticeSignalUtility.TryParseToggle("on", false, out bool on)); + Assert.True(on); + + Assert.True(PracticeSignalUtility.TryParseToggle("OFF", false, out bool stillOff)); + Assert.False(stillOff); + + foreach (string yes in new[] { "1", "true", "yes" }) + { + Assert.True(PracticeSignalUtility.TryParseToggle(yes, false, out bool value)); + Assert.True(value); + } + + foreach (string no in new[] { "0", "false", "no" }) + { + Assert.True(PracticeSignalUtility.TryParseToggle(no, true, out bool value)); + Assert.False(value); + } + } + + [Fact] + public void NoArgumentToggles() + { + Assert.True(PracticeSignalUtility.TryParseToggle("", true, out bool fromOn)); + Assert.False(fromOn); + + Assert.True(PracticeSignalUtility.TryParseToggle(null, false, out bool fromOff)); + Assert.True(fromOff); + + Assert.True(PracticeSignalUtility.TryParseToggle(" ", true, out bool spaces)); + Assert.False(spaces); + } + + [Fact] + public void GarbageIsRefusedRatherThanGuessed() + { + Assert.False(PracticeSignalUtility.TryParseToggle("maybe", true, out bool value)); + Assert.True(value); + } +} diff --git a/apps/utility-sw/test/PracticeSolverPlanTests.cs b/apps/utility-sw/test/PracticeSolverPlanTests.cs new file mode 100644 index 00000000..30f3ea10 --- /dev/null +++ b/apps/utility-sw/test/PracticeSolverPlanTests.cs @@ -0,0 +1,374 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +// The search, driven against a stand-in for the server. +// +// The oracle here is deliberately not a physics model -- it is a landing +// function with the one property that matters: it is piecewise. There is a +// wall, and throws that clear it and throws that do not are unrelated. A search +// that only ever walked downhill would sit against that wall forever, which is +// why the sweep and the several-basin refinement exist and why these tests are +// worth having. +public class PracticeSolverPlanTests +{ + private static readonly Vec3 Eye = new Vec3(0f, 0f, 64f); + private static readonly Vec3 Target = new Vec3(1200f, 0f, 0f); + + private class Oracle + { + public float YawStar; + public float PitchStar; + public float WallPitch = float.MaxValue; + public float Floor; + public bool Lost; + public float Constant = -1f; + + public int Thrown; + public readonly List Keys = new List(); + + public virtual SolveObservation Throw(SolveCandidate candidate) + { + Thrown++; + Keys.Add(PracticeSolverUtility.CandidateKey(candidate)); + + if (Lost) + { + return new SolveObservation { candidate = candidate }; + } + + float error = + Constant >= 0f + ? Constant + : candidate.pitch > WallPitch + ? 850f + : Floor + + (MathF.Abs(candidate.yaw - YawStar) * 12f) + + (MathF.Abs(candidate.pitch - PitchStar) * 15f); + + return new SolveObservation + { + candidate = candidate, + landing = Target + new Vec3(error, 0f, 0f), + distance = error, + landed = true, + }; + } + } + + private static SolveRequest Request(float tolerance = 40f, int grenades = 300) + { + return new SolveRequest + { + map = "de_mirage", + utility_type = "Smoke", + target = Target, + eye = Eye, + feet = new Vec3(0f, 0f, 0f), + tolerance = tolerance, + max_grenades = grenades, + batch_size = 20, + max_seconds = 120f, + strengths = new List { nameof(eThrowStrength.Full) }, + }; + } + + private static SolveResult Run( + SolveRequest request, + Oracle oracle, + out PracticeSolverPlan plan + ) + { + PracticeSolverPlan built = new PracticeSolverPlan(request); + plan = built; + + while (true) + { + List batch = built.NextBatch(); + + if (batch.Count == 0) + { + break; + } + + foreach (SolveCandidate candidate in batch) + { + built.Observe(oracle.Throw(candidate)); + } + } + + return built.Finish(1f); + } + + [Fact] + public void ConvergesThroughAWall() + { + var oracle = new Oracle + { + YawStar = 2f, + PitchStar = -20f, + WallPitch = -5f, + }; + + SolveResult result = Run(Request(), oracle, out PracticeSolverPlan plan); + + Assert.Equal(nameof(eSolveOutcome.Converged), result.outcome); + Assert.NotNull(result.best); + Assert.True(result.best!.distance <= plan.Request.tolerance); + Assert.True(result.thrown < plan.Request.max_grenades); + // The sweep alone does not land inside tolerance here; if it ever does, + // this test has stopped testing the refinement. + Assert.True(result.batches > 1); + } + + // The reason several basins are kept. The throw that looks best after the + // sweep bottoms out above tolerance; the answer is in a basin that was + // second at the time and would have been thrown away by anything that + // refined only the leader. + [Fact] + public void TheSecondBestBasinCanStillWin() + { + var oracle = new SplitOracle(); + + SolveResult result = Run(Request(tolerance: 40f), oracle, out _); + + Assert.Equal(nameof(eSolveOutcome.Converged), result.outcome); + Assert.NotNull(result.best); + Assert.True( + MathF.Abs(result.best!.candidate.yaw - SplitOracle.FarYaw) < 8f, + $"the winning throw came from the wrong basin: yaw {result.best.candidate.yaw}" + ); + } + + private class SplitOracle : Oracle + { + public const float NearYaw = 0f; + public const float NearPitch = -18f; + public const float FarYaw = 22f; + public const float FarPitch = -19.5f; + + // The near basin is smooth, obvious and never good enough. + private const float NearFloor = 55f; + + public override SolveObservation Throw(SolveCandidate candidate) + { + Thrown++; + Keys.Add(PracticeSolverUtility.CandidateKey(candidate)); + + float near = + NearFloor + + (MathF.Abs(candidate.yaw - NearYaw) * 30f) + + (MathF.Abs(candidate.pitch - NearPitch) * 30f); + + float far = + (MathF.Abs(candidate.yaw - FarYaw) * 12f) + + (MathF.Abs(candidate.pitch - FarPitch) * 40f); + + float error = MathF.Min(near, far); + + return new SolveObservation + { + candidate = candidate, + landing = Target + new Vec3(error, 0f, 0f), + distance = error, + landed = true, + }; + } + } + + // A search that cannot get anywhere has to say so. Silently returning its + // best miss is how a lineup nobody can throw ends up in a library. + [Fact] + public void GivesUpLoudlyWhenNothingImproves() + { + var oracle = new Oracle { Constant = 500f }; + + SolveResult result = Run(Request(), oracle, out PracticeSolverPlan plan); + + Assert.Equal(nameof(eSolveOutcome.NoProgress), result.outcome); + Assert.False(result.Converged()); + Assert.Contains("500", result.message); + Assert.True(result.thrown < plan.Request.max_grenades); + } + + [Fact] + public void StopsAtTheGrenadeCap() + { + var oracle = new Oracle { Constant = 500f }; + SolveRequest request = Request(grenades: 40); + + SolveResult result = Run(request, oracle, out _); + + Assert.Equal(nameof(eSolveOutcome.GrenadeCap), result.outcome); + Assert.Equal(40, result.thrown); + Assert.Equal(40, oracle.Thrown); + } + + [Fact] + public void NeverThrowsMoreThanItWasAllowed() + { + foreach (int cap in new[] { 20, 45, 100, 300 }) + { + var oracle = new Oracle { Constant = 500f }; + SolveResult result = Run(Request(grenades: cap), oracle, out _); + + Assert.True(oracle.Thrown <= cap, $"threw {oracle.Thrown} with a cap of {cap}"); + Assert.Equal(oracle.Thrown, result.thrown); + } + } + + [Fact] + public void NeverThrowsTheSameAimTwice() + { + var oracle = new Oracle { YawStar = 40f, PitchStar = -30f }; + + Run(Request(tolerance: 8f), oracle, out _); + + Assert.Equal(oracle.Keys.Count, oracle.Keys.Distinct().Count()); + } + + [Fact] + public void BatchesAreBounded() + { + var plan = new PracticeSolverPlan(Request()); + var oracle = new Oracle { Constant = 500f }; + + while (true) + { + List batch = plan.NextBatch(); + + if (batch.Count == 0) + { + break; + } + + Assert.True(batch.Count <= plan.Request.batch_size); + + foreach (SolveCandidate candidate in batch) + { + plan.Observe(oracle.Throw(candidate)); + } + } + } + + [Fact] + public void NoCalibratedStrengthMeansNoThrows() + { + SolveRequest request = Request(); + request.strengths = new List(); + + var plan = new PracticeSolverPlan(request); + + Assert.Empty(plan.NextBatch()); + Assert.Equal(nameof(eSolveOutcome.NoCandidates), plan.Finish(0f).outcome); + Assert.Contains("no strength has been calibrated", plan.Finish(0f).message); + } + + [Fact] + public void ATargetUnderfootIsRefusedBeforeAnythingIsThrown() + { + SolveRequest request = Request(); + request.target = new Vec3(Eye.x + 5f, Eye.y, Eye.z); + + var plan = new PracticeSolverPlan(request); + + Assert.Empty(plan.NextBatch()); + Assert.Equal(nameof(eSolveOutcome.NoCandidates), plan.Finish(0f).outcome); + Assert.Equal(0, plan.Thrown); + } + + [Fact] + public void GrenadesThatNeverLandedAreNotAnAnswer() + { + var oracle = new Oracle { Lost = true }; + + SolveResult result = Run(Request(), oracle, out _); + + Assert.Null(result.best); + Assert.Equal(nameof(eSolveOutcome.NoProgress), result.outcome); + Assert.Contains("no grenade reported a landing", result.message); + } + + [Fact] + public void TheClockIsACap() + { + var plan = new PracticeSolverPlan(Request()); + var oracle = new Oracle { Constant = 500f }; + + foreach (SolveCandidate candidate in plan.NextBatch()) + { + plan.Observe(oracle.Throw(candidate)); + } + + Assert.False(plan.Expired(10f)); + Assert.True(plan.Expired(plan.Request.max_seconds)); + Assert.Equal(nameof(eSolveOutcome.TimedOut), plan.Finish(500f).outcome); + } + + // A solve that already has its answer stops asking for grenades. + [Fact] + public void ConvergingEndsTheSearch() + { + var oracle = new Oracle { YawStar = 0f, PitchStar = -18f }; + var plan = new PracticeSolverPlan(Request()); + + foreach (SolveCandidate candidate in plan.NextBatch()) + { + plan.Observe(oracle.Throw(candidate)); + } + + Assert.True(plan.Converged()); + Assert.Empty(plan.NextBatch()); + Assert.Equal(nameof(eSolveOutcome.Converged), plan.Finish(1f).outcome); + } + + [Fact] + public void ProgressSaysWhereItIs() + { + var plan = new PracticeSolverPlan(Request()); + + Assert.Contains("nothing landed yet", plan.Progress()); + Assert.Equal("sweep", plan.Phase); + + var oracle = new Oracle { Constant = 137f }; + + foreach (SolveCandidate candidate in plan.NextBatch()) + { + plan.Observe(oracle.Throw(candidate)); + } + + Assert.Contains("137u", plan.Progress()); + Assert.Contains("20/300", plan.Progress()); + } + + // The sweep is the part that finds basins, so it must not be allowed to eat + // the budget the refinement needs. + [Fact] + public void TheSweepLeavesRoomToRefine() + { + var plan = new PracticeSolverPlan(Request(grenades: 100)); + var oracle = new Oracle { Constant = 500f }; + int sweepThrows = 0; + + while (plan.Phase == "sweep") + { + List batch = plan.NextBatch(); + + if (batch.Count == 0) + { + break; + } + + sweepThrows += batch.Count; + + foreach (SolveCandidate candidate in batch) + { + plan.Observe(oracle.Throw(candidate)); + } + } + + Assert.True(sweepThrows <= 60 + plan.Request.batch_size); + Assert.True(sweepThrows > 0); + } +} diff --git a/apps/utility-sw/test/PracticeSolverUtilityTests.cs b/apps/utility-sw/test/PracticeSolverUtilityTests.cs new file mode 100644 index 00000000..720e1995 --- /dev/null +++ b/apps/utility-sw/test/PracticeSolverUtilityTests.cs @@ -0,0 +1,519 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +public class PracticeSolverUtilityTests +{ + private static readonly Vec3 Eye = new Vec3(0f, 0f, 64f); + private static readonly Vec3 Target = new Vec3(1000f, 0f, 0f); + + private static SolveRequest Request(params string[] strengths) + { + return PracticeSolverUtility.Defaults( + new SolveRequest + { + map = "de_mirage", + utility_type = "Smoke", + target = Target, + eye = Eye, + feet = new Vec3(0f, 0f, 0f), + strengths = strengths.Length == 0 + ? new List { nameof(eThrowStrength.Full) } + : strengths.ToList(), + } + ); + } + + private static CalibrationReport Calibration(float correction = 1f) + { + return new CalibrationReport + { + map = "de_mirage", + status = nameof(eCalibrationStatus.Ready), + speed_corrections = new Dictionary + { + { nameof(eThrowStrength.Full), correction }, + }, + }; + } + + [Fact] + public void DefaultsFillInAndClamp() + { + SolveRequest request = PracticeSolverUtility.Defaults(new SolveRequest()); + + Assert.Equal(PracticeSolverUtility.DefaultTolerance, request.tolerance); + Assert.Equal(PracticeSolverUtility.DefaultBatchSize, request.batch_size); + Assert.Equal(PracticeSolverUtility.DefaultMaxGrenades, request.max_grenades); + + SolveRequest silly = PracticeSolverUtility.Defaults( + new SolveRequest + { + tolerance = 5000f, + batch_size = 900, + max_grenades = 100000, + max_seconds = 99999f, + } + ); + + Assert.Equal(PracticeSolverUtility.MaxTolerance, silly.tolerance); + Assert.Equal(PracticeSolverUtility.MaxBatchSize, silly.batch_size); + Assert.Equal(PracticeSolverUtility.MaxGrenadeCap, silly.max_grenades); + Assert.Equal(PracticeSolverUtility.MaxSecondsCap, silly.max_seconds); + } + + // A cap below a batch would emit nothing at all. + [Fact] + public void TheGrenadeCapNeverFallsBelowOneBatch() + { + SolveRequest request = PracticeSolverUtility.Defaults( + new SolveRequest { batch_size = 20, max_grenades = 3 } + ); + + Assert.Equal(20, request.max_grenades); + } + + [Fact] + public void NoClearedStrengthMeansNothingToThrow() + { + SolveRequest request = PracticeSolverUtility.Defaults( + new SolveRequest { target = Target, eye = Eye } + ); + + Assert.Empty(PracticeSolverUtility.CoarseSweep(request)); + } + + [Fact] + public void ATargetUnderfootIsNotAThrow() + { + SolveRequest request = Request(); + request.target = new Vec3(Eye.x + 4f, Eye.y, Eye.z); + + Assert.Empty(PracticeSolverUtility.CoarseSweep(request)); + } + + [Fact] + public void TheSweepStartsOnTheDirectBearing() + { + List sweep = PracticeSolverUtility.CoarseSweep(Request()); + + Assert.NotEmpty(sweep); + Assert.Equal( + PracticeLaunchUtility.BearingTo(Eye, Target), + sweep[0].yaw, + 3 + ); + } + + // Truncating the sweep to fit the budget has to drop the least likely + // throws, not an arbitrary corner of the grid. + [Fact] + public void TheSweepIsOrderedByHowLikelyAThrowIs() + { + List sweep = PracticeSolverUtility.CoarseSweep(Request()); + float bearing = PracticeLaunchUtility.BearingTo(Eye, Target); + + float previous = 0f; + + foreach (SolveCandidate candidate in sweep) + { + float offset = MathF.Abs( + PracticeLaunchUtility.NormalizeYaw(candidate.yaw - bearing) + ); + + Assert.True(offset >= previous - 0.001f); + previous = offset; + } + } + + [Fact] + public void EveryClearedStrengthIsSwept() + { + List sweep = PracticeSolverUtility.CoarseSweep( + Request( + nameof(eThrowStrength.Full), + nameof(eThrowStrength.Half), + nameof(eThrowStrength.Drop) + ) + ); + + Assert.Equal( + 3, + sweep.Select(candidate => candidate.strength_bucket).Distinct().Count() + ); + } + + [Fact] + public void NeighboursAreTheEightAround() + { + var centre = new SolveCandidate + { + pitch = -10f, + yaw = 30f, + strength = 1f, + strength_bucket = nameof(eThrowStrength.Full), + }; + + List neighbours = PracticeSolverUtility.Neighbours(centre, 3f); + + Assert.Equal(8, neighbours.Count); + Assert.DoesNotContain( + neighbours, + candidate => + MathF.Abs(candidate.pitch - centre.pitch) < 0.001f + && MathF.Abs(candidate.yaw - centre.yaw) < 0.001f + ); + Assert.All( + neighbours, + candidate => Assert.Equal(centre.strength_bucket, candidate.strength_bucket) + ); + } + + [Fact] + public void NeighboursStayInsideALegalPitch() + { + var steep = new SolveCandidate + { + pitch = -88f, + yaw = 0f, + strength_bucket = nameof(eThrowStrength.Full), + }; + + Assert.All( + PracticeSolverUtility.Neighbours(steep, 10f), + candidate => Assert.True(candidate.pitch >= -89f && candidate.pitch <= 89f) + ); + } + + // Two aims a degree apart thrown at different strengths are different + // throws, not neighbours, so refining one says nothing about the other. + [Fact] + public void DifferentStrengthsAreNeverTheSameBasin() + { + var full = new SolveCandidate + { + pitch = 0f, + yaw = 0f, + strength_bucket = nameof(eThrowStrength.Full), + }; + var half = new SolveCandidate + { + pitch = 0f, + yaw = 0f, + strength_bucket = nameof(eThrowStrength.Half), + }; + + Assert.Equal(float.MaxValue, PracticeSolverUtility.Separation(full, half)); + Assert.Equal(0f, PracticeSolverUtility.Separation(full, full)); + } + + [Fact] + public void RefinementPicksSeparatedBasinsNotNeighbours() + { + var observations = new List + { + Observation(0f, 0f, 10f), + Observation(0.5f, 0.5f, 11f), + Observation(30f, 0f, 40f), + Observation(-30f, 0f, 50f), + }; + + List picked = PracticeSolverUtility.PickDistinct( + observations, + 4, + PracticeSolverUtility.MinSeparationDegrees + ); + + Assert.Equal(3, picked.Count); + Assert.Equal(10f, picked[0].distance); + Assert.DoesNotContain(picked, observation => observation.distance == 11f); + } + + [Fact] + public void AGrenadeThatNeverLandedCanNeverWin() + { + var lost = Observation(0f, 0f, 1f); + lost.landed = false; + + List picked = PracticeSolverUtility.PickDistinct( + new[] { lost, Observation(40f, 0f, 900f) }, + 4, + PracticeSolverUtility.MinSeparationDegrees + ); + + Assert.Single(picked); + Assert.Equal(900f, picked[0].distance); + } + + [Fact] + public void NearIdenticalAimsShareAKey() + { + var first = new SolveCandidate + { + pitch = 10f, + yaw = 20f, + strength_bucket = nameof(eThrowStrength.Full), + }; + var second = new SolveCandidate + { + pitch = 10.001f, + yaw = 20.001f, + strength_bucket = nameof(eThrowStrength.Full), + }; + var apart = new SolveCandidate + { + pitch = 10.5f, + yaw = 20f, + strength_bucket = nameof(eThrowStrength.Full), + }; + + Assert.Equal( + PracticeSolverUtility.CandidateKey(first), + PracticeSolverUtility.CandidateKey(second) + ); + Assert.NotEqual( + PracticeSolverUtility.CandidateKey(first), + PracticeSolverUtility.CandidateKey(apart) + ); + } + + [Fact] + public void TheMeasuredSpeedCorrectionReachesTheThrow() + { + SolveRequest request = Request(); + var candidate = new SolveCandidate + { + pitch = -10f, + yaw = 0f, + strength = 1f, + strength_bucket = nameof(eThrowStrength.Full), + }; + + LaunchSeed plain = PracticeSolverUtility.SeedFor(request, candidate, Calibration()); + LaunchSeed corrected = PracticeSolverUtility.SeedFor( + request, + candidate, + Calibration(1.2f) + ); + + Assert.Equal(plain.speed * 1.2f, corrected.speed, 2); + } + + // The seed is the point of a solve: without it the lineup is a suggestion, + // with it the plugin can throw the winning grenade again exactly. + [Fact] + public void TheWinningThrowBecomesAReplayableLineup() + { + SolveRequest request = Request(); + request.name = "window"; + request.requested_by = "76561198000000001"; + + var best = new SolveObservation + { + candidate = new SolveCandidate + { + pitch = -12.5f, + yaw = 3.5f, + strength = 1f, + strength_bucket = nameof(eThrowStrength.Full), + }, + landing = new Vec3(1002f, 3f, 0f), + distance = 3.6f, + landed = true, + bounces = 2, + }; + + LineupRecord lineup = PracticeSolverUtility.ToLineup( + request, + best, + Calibration(), + "swiftlys2", + "1.2.3" + ); + + Assert.True(lineup.HasPhysicsSeed()); + Assert.True(lineup.IsExactlyReplayable()); + Assert.Equal("window", lineup.name); + Assert.Equal("de_mirage", lineup.map); + Assert.Equal(nameof(eThrowTechnique.Stationary), lineup.technique); + Assert.Equal(nameof(eThrowStrength.Full), lineup.strength); + Assert.Equal(-12.5f, lineup.release.pitch, 3); + Assert.Equal(3.5f, lineup.release.yaw, 3); + Assert.Equal(2, lineup.bounces); + Assert.Equal(best.landing.x, lineup.detonation_position.x, 3); + Assert.Equal("swiftlys2", lineup.plugin_runtime); + } + + // A solved throw is stationary by construction, so a release snapshot that + // said otherwise would send a player somewhere they cannot reproduce it. + [Fact] + public void TheSolvedReleaseIsAStandingThrow() + { + LineupRecord lineup = PracticeSolverUtility.ToLineup( + Request(), + new SolveObservation + { + candidate = new SolveCandidate + { + strength = 1f, + strength_bucket = nameof(eThrowStrength.Full), + }, + landed = true, + }, + Calibration(), + "swiftlys2", + "" + ); + + Assert.True(lineup.release.on_ground); + Assert.False(lineup.release.jump_throw); + Assert.False(lineup.release.ducked); + Assert.Equal(0f, lineup.release.speed); + } + + // The re-throw is the difference between a measurement and a coincidence, + // so a confirmation that did not land is a failure and not a shrug. + [Fact] + public void AConfirmationHasToLandAndBeClose() + { + SolveRequest request = Request(); + request.tolerance = 40f; + + Assert.True( + PracticeSolverUtility.Confirms( + new SolveObservation { landed = true, distance = 39f }, + request + ) + ); + Assert.False( + PracticeSolverUtility.Confirms( + new SolveObservation { landed = true, distance = 41f }, + request + ) + ); + Assert.False( + PracticeSolverUtility.Confirms( + new SolveObservation { landed = false, distance = 1f }, + request + ) + ); + Assert.False(PracticeSolverUtility.Confirms(new SolveObservation(), request)); + } + + [Fact] + public void ParsesTheRconForm() + { + Assert.True( + PracticeSolverUtility.TryParse( + new[] + { + "target=1000,-250.5,64", + "from=0,0,0", + "utility=HE", + "tolerance=25", + "grenades=80", + "seconds=30", + "steam=76561198000000001", + "name=window smoke", + }, + out SolveRequest request, + out string error + ) + ); + + Assert.Equal("", error); + Assert.Equal(1000f, request.target.x, 3); + Assert.Equal(-250.5f, request.target.y, 3); + Assert.Equal("HighExplosive", request.utility_type); + Assert.Equal(25f, request.tolerance, 3); + Assert.Equal(80, request.max_grenades); + Assert.Equal("window smoke", request.name); + Assert.Equal("76561198000000001", request.requested_by); + } + + // from= is a floor position, because that is what a player reads off the + // map; the throw itself comes out of the eyes. + [Fact] + public void AGivenThrowingPositionStandsUp() + { + PracticeSolverUtility.TryParse( + new[] { "target=500,0,0", "from=10,20,30" }, + out SolveRequest request, + out _ + ); + + Assert.Equal(30f, request.feet.z, 3); + Assert.Equal( + 30f + PracticeSolverUtility.StandingEyeHeight, + request.eye.z, + 3 + ); + } + + [Fact] + public void RefusesACallWithNoTarget() + { + Assert.False( + PracticeSolverUtility.TryParse( + new[] { "utility=Smoke" }, + out _, + out string error + ) + ); + + Assert.Contains("target=x,y,z is required", error); + } + + [Fact] + public void RefusesAMalformedPoint() + { + Assert.False( + PracticeSolverUtility.TryParse(new[] { "target=1000,64" }, out _, out string error) + ); + + Assert.Contains("target must be x,y,z", error); + } + + // A positional argument list over RCON is a solve for the wrong point that + // nobody notices, so anything that is not key=value is an error. + [Fact] + public void RefusesPositionalArguments() + { + Assert.False( + PracticeSolverUtility.TryParse( + new[] { "1000", "0", "64" }, + out _, + out string error + ) + ); + + Assert.Contains("every argument is key=value", error); + } + + [Fact] + public void RefusesAnUnknownArgument() + { + Assert.False( + PracticeSolverUtility.TryParse(new[] { "target=1,2,3", "wind=5" }, out _, out string error) + ); + + Assert.Contains("unknown argument", error); + } + + private static SolveObservation Observation(float yaw, float pitch, float distance) + { + return new SolveObservation + { + candidate = new SolveCandidate + { + pitch = pitch, + yaw = yaw, + strength = 1f, + strength_bucket = nameof(eThrowStrength.Full), + }, + distance = distance, + landed = true, + }; + } +} diff --git a/apps/utility-sw/test/SmokeVolumeUtilityTests.cs b/apps/utility-sw/test/SmokeVolumeUtilityTests.cs new file mode 100644 index 00000000..4755a8ca --- /dev/null +++ b/apps/utility-sw/test/SmokeVolumeUtilityTests.cs @@ -0,0 +1,341 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +// The bloom outline is the only thing in the plugin that turns bytes into +// entities, so these pin both halves of it: the packing the parser chose, and +// the entity budget that keeps a smoke from spawning a thousand beams. +public class SmokeVolumeUtilityTests +{ + private static SmokeVolume Volume( + int dx, + int dy, + int dz, + byte[]? cells = null, + float vs = 8f, + float ox = 0f, + float oy = 0f, + float oz = 0f + ) + { + return new SmokeVolume + { + ox = ox, + oy = oy, + oz = oz, + vs = vs, + dx = dx, + dy = dy, + dz = dz, + den = cells == null ? null : Encode(cells), + }; + } + + // Two cells per byte, low nibble first. + private static string Encode(byte[] cells) + { + var packed = new byte[(cells.Length + 1) / 2]; + + for (int index = 0; index < cells.Length; index++) + { + byte value = (byte)(cells[index] & 0x0F); + + if ((index & 1) == 0) + { + packed[index >> 1] |= value; + } + else + { + packed[index >> 1] |= (byte)(value << 4); + } + } + + return System.Convert.ToBase64String(packed); + } + + [Fact] + public void TheLowNibbleOfAByteIsTheFirstCell() + { + SmokeVolume volume = Volume(2, 1, 1); + volume.den = System.Convert.ToBase64String(new byte[] { 0xF0 }); + + byte[] density = SmokeVolumeUtility.Decode(volume); + + Assert.Equal(0, SmokeVolumeUtility.Density(density, volume, 0, 0, 0)); + Assert.Equal(15, SmokeVolumeUtility.Density(density, volume, 1, 0, 0)); + } + + [Fact] + public void CellsAreOrderedXMajorThenYThenZ() + { + SmokeVolume volume = Volume(2, 2, 2, new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }); + byte[] density = SmokeVolumeUtility.Decode(volume); + + Assert.Equal(1, SmokeVolumeUtility.Density(density, volume, 0, 0, 0)); + Assert.Equal(2, SmokeVolumeUtility.Density(density, volume, 1, 0, 0)); + Assert.Equal(3, SmokeVolumeUtility.Density(density, volume, 0, 1, 0)); + Assert.Equal(4, SmokeVolumeUtility.Density(density, volume, 1, 1, 0)); + Assert.Equal(5, SmokeVolumeUtility.Density(density, volume, 0, 0, 1)); + Assert.Equal(8, SmokeVolumeUtility.Density(density, volume, 1, 1, 1)); + } + + [Fact] + public void ACellOutsideTheGridIsClearRatherThanAnError() + { + SmokeVolume volume = Volume(2, 2, 2, new byte[] { 9, 9, 9, 9, 9, 9, 9, 9 }); + byte[] density = SmokeVolumeUtility.Decode(volume); + + Assert.Equal(0, SmokeVolumeUtility.Density(density, volume, -1, 0, 0)); + Assert.Equal(0, SmokeVolumeUtility.Density(density, volume, 2, 0, 0)); + Assert.Equal(0, SmokeVolumeUtility.Density(density, volume, 0, 0, 2)); + } + + // The array is optional in the contract; the box is then the measurement. + [Fact] + public void AVolumeWithNoGridIsSolid() + { + SmokeVolume volume = Volume(3, 3, 3); + byte[] density = SmokeVolumeUtility.Decode(volume); + + Assert.Equal(27, density.Length); + Assert.All(density, cell => Assert.Equal(15, cell)); + } + + [Fact] + public void ADenShorterThanTheGridLeavesTheRestClear() + { + SmokeVolume volume = Volume(4, 1, 1); + volume.den = System.Convert.ToBase64String(new byte[] { 0x21 }); + + byte[] density = SmokeVolumeUtility.Decode(volume); + + Assert.Equal(1, SmokeVolumeUtility.Density(density, volume, 0, 0, 0)); + Assert.Equal(2, SmokeVolumeUtility.Density(density, volume, 1, 0, 0)); + Assert.Equal(0, SmokeVolumeUtility.Density(density, volume, 2, 0, 0)); + Assert.Equal(0, SmokeVolumeUtility.Density(density, volume, 3, 0, 0)); + } + + [Fact] + public void GarbageBase64DecodesToNothingRatherThanThrowing() + { + SmokeVolume volume = Volume(2, 2, 1); + volume.den = "this is not base64 !!"; + + byte[] density = SmokeVolumeUtility.Decode(volume); + + Assert.Equal(4, density.Length); + Assert.All(density, cell => Assert.Equal(0, cell)); + } + + [Fact] + public void AGridBiggerThanTheCellCeilingIsRefused() + { + SmokeVolume volume = Volume(512, 512, 512); + + Assert.Empty(SmokeVolumeUtility.Decode(volume)); + Assert.Empty(SmokeVolumeUtility.Outline(volume)); + } + + [Fact] + public void NoVolumeOutlinesToNothing() + { + Assert.Empty(SmokeVolumeUtility.Outline(null)); + } + + [Fact] + public void AnEmptyGridOutlinesToNothing() + { + SmokeVolume volume = Volume(4, 4, 4, new byte[64]); + + Assert.Empty(SmokeVolumeUtility.Outline(volume)); + } + + [Fact] + public void ASolidBoxOutlinesToOneRectanglePerLevel() + { + SmokeVolume volume = Volume(4, 4, 4, ox: 100f, oy: 200f, oz: 300f); + + List segments = SmokeVolumeUtility.Outline(volume); + + Assert.Equal(12, segments.Count); + + foreach (BloomSegment segment in segments) + { + Assert.InRange(segment.a.x, 100f, 132f); + Assert.InRange(segment.a.y, 200f, 232f); + Assert.InRange(segment.a.z, 300f, 332f); + Assert.InRange(segment.b.x, 100f, 132f); + Assert.InRange(segment.b.y, 200f, 232f); + } + } + + [Fact] + public void EveryLevelOfTheOutlineSitsAtADifferentHeight() + { + SmokeVolume volume = Volume(4, 4, 4); + + var heights = SmokeVolumeUtility + .Outline(volume) + .Select(segment => segment.a.z) + .Distinct() + .ToList(); + + Assert.Equal(3, heights.Count); + } + + [Fact] + public void ASingleLayerOnlyDrawsOneContour() + { + SmokeVolume volume = Volume(4, 4, 1); + + List segments = SmokeVolumeUtility.Outline(volume); + + Assert.Equal(4, segments.Count); + } + + // A pillar inside the bloom is the thing a player is looking for, so the + // hole gets its own loop rather than being swallowed by the silhouette. + [Fact] + public void AHoleInTheBloomIsOutlinedToo() + { + var cells = new byte[7 * 7]; + + for (int index = 0; index < cells.Length; index++) + { + cells[index] = 15; + } + + for (int j = 2; j <= 4; j++) + { + for (int i = 2; i <= 4; i++) + { + cells[(j * 7) + i] = 0; + } + } + + SmokeVolume volume = Volume(7, 7, 1, cells); + List segments = SmokeVolumeUtility.Outline(volume); + + Assert.Equal(8, segments.Count); + } + + // One stray dense cell is measurement noise, not somewhere to throw at. + [Fact] + public void ASingleStrayCellIsNotWorthABeam() + { + var cells = new byte[5 * 5]; + cells[(2 * 5) + 2] = 15; + + SmokeVolume volume = Volume(5, 5, 1, cells); + + Assert.Empty(SmokeVolumeUtility.Outline(volume)); + } + + [Fact] + public void TheEntityBudgetIsNeverExceeded() + { + SmokeVolume volume = Sphere(18, 8f); + + foreach (int budget in new[] { 4, 8, 16, 48, 96 }) + { + List segments = SmokeVolumeUtility.Outline( + volume, + new SmokeOutlineOptions { MaxSegments = budget } + ); + + Assert.True( + segments.Count <= budget, + $"{segments.Count} segments for a budget of {budget}" + ); + } + } + + [Fact] + public void ARealisticBloomStillDrawsSomething() + { + List segments = SmokeVolumeUtility.Outline(Sphere(18, 8f)); + + Assert.NotEmpty(segments); + Assert.All( + segments, + segment => + Assert.True( + (segment.b - segment.a).Length() > 0f, + "a zero length beam draws nothing and still costs an entity" + ) + ); + } + + [Fact] + public void ADenserThresholdOutlinesASmallerShape() + { + SmokeVolume volume = Sphere(18, 8f, falloff: true); + + int wide = SmokeVolumeUtility + .Outline(volume, new SmokeOutlineOptions { MinDensity = 1, MaxLevels = 1 }) + .Sum(segment => (int)(segment.b - segment.a).LengthXY()); + + int tight = SmokeVolumeUtility + .Outline(volume, new SmokeOutlineOptions { MinDensity = 12, MaxLevels = 1 }) + .Sum(segment => (int)(segment.b - segment.a).LengthXY()); + + Assert.True(tight < wide, $"{tight} is not tighter than {wide}"); + } + + // A flood filled bloom clipped by a wall must never be reported as covering + // the wall: the outline is an exact staircase, simplified inwards only by + // the epsilon, so nothing is drawn past the last occupied cell. + [Fact] + public void TheOutlineStaysInsideTheMeasuredCells() + { + var cells = new byte[8 * 8]; + + for (int j = 0; j < 8; j++) + { + for (int i = 0; i < 4; i++) + { + cells[(j * 8) + i] = 15; + } + } + + SmokeVolume volume = Volume(8, 8, 1, cells); + + foreach (BloomSegment segment in SmokeVolumeUtility.Outline(volume)) + { + Assert.InRange(segment.a.x, 0f, 32f); + Assert.InRange(segment.b.x, 0f, 32f); + } + } + + private static SmokeVolume Sphere(int diameter, float vs, bool falloff = false) + { + var cells = new byte[diameter * diameter * diameter]; + float radius = diameter / 2f; + + for (int k = 0; k < diameter; k++) + { + for (int j = 0; j < diameter; j++) + { + for (int i = 0; i < diameter; i++) + { + float dx = i - radius + 0.5f; + float dy = j - radius + 0.5f; + float dz = k - radius + 0.5f; + float distance = MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz)); + + if (distance > radius) + { + continue; + } + + cells[(((k * diameter) + j) * diameter) + i] = falloff + ? (byte)Math.Clamp((int)(15f * (1f - (distance / radius))), 1, 15) + : (byte)15; + } + } + } + + SmokeVolume volume = Volume(diameter, diameter, diameter, cells, vs); + return volume; + } +} diff --git a/apps/utility-sw/test/TrajectoryUtilityTests.cs b/apps/utility-sw/test/TrajectoryUtilityTests.cs new file mode 100644 index 00000000..3fbe33ab --- /dev/null +++ b/apps/utility-sw/test/TrajectoryUtilityTests.cs @@ -0,0 +1,203 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +public class TrajectoryUtilityTests +{ + private static ThrowSnapshot Release( + float speed = 0f, + bool onGround = true, + bool ducked = false, + bool walking = false, + bool jumpThrow = false, + float velocityZ = 0f + ) + { + return new ThrowSnapshot + { + speed = speed, + on_ground = onGround, + ducked = ducked, + walking = walking, + jump_throw = jumpThrow, + velocity = new Vec3(speed, 0f, velocityZ), + }; + } + + [Theory] + [InlineData(1.0f, eThrowStrength.Full)] + [InlineData(0.75f, eThrowStrength.Full)] + [InlineData(0.5f, eThrowStrength.Half)] + [InlineData(0.25f, eThrowStrength.Half)] + [InlineData(0.0f, eThrowStrength.Drop)] + public void ClassifiesTheThreeReleaseStrengths(float raw, eThrowStrength expected) + { + Assert.Equal(expected, TrajectoryUtility.ClassifyStrength(raw)); + } + + [Fact] + public void StandingStillIsStationary() + { + Assert.Equal( + eThrowTechnique.Stationary, + TrajectoryUtility.ClassifyTechnique(Release()) + ); + } + + [Fact] + public void WalkSpeedIsWalkingAndAboveItIsRunning() + { + Assert.Equal( + eThrowTechnique.Walking, + TrajectoryUtility.ClassifyTechnique(Release(speed: 120f)) + ); + Assert.Equal( + eThrowTechnique.Running, + TrajectoryUtility.ClassifyTechnique(Release(speed: 240f)) + ); + } + + [Fact] + public void HoldingWalkIsWalkingEvenAtRunSpeed() + { + Assert.Equal( + eThrowTechnique.Walking, + TrajectoryUtility.ClassifyTechnique(Release(speed: 240f, walking: true)) + ); + } + + // A hand-timed jump throw does not always set m_bJumpThrow, so leaving the + // ground has to count on its own or half of all lineups misclassify. + [Fact] + public void LeavingTheGroundCountsAsAJumpWithoutTheFlag() + { + Assert.Equal( + eThrowTechnique.Jump, + TrajectoryUtility.ClassifyTechnique(Release(onGround: false)) + ); + Assert.Equal( + eThrowTechnique.Jump, + TrajectoryUtility.ClassifyTechnique(Release(velocityZ: 200f)) + ); + } + + [Fact] + public void JumpComposesWithMovementAndStance() + { + Assert.Equal( + eThrowTechnique.RunJump, + TrajectoryUtility.ClassifyTechnique(Release(speed: 240f, jumpThrow: true)) + ); + Assert.Equal( + eThrowTechnique.WalkJump, + TrajectoryUtility.ClassifyTechnique(Release(speed: 100f, jumpThrow: true)) + ); + Assert.Equal( + eThrowTechnique.CrouchJump, + TrajectoryUtility.ClassifyTechnique(Release(jumpThrow: true, ducked: true)) + ); + Assert.Equal( + eThrowTechnique.Crouch, + TrajectoryUtility.ClassifyTechnique(Release(ducked: true)) + ); + } + + [Fact] + public void DerivesAnglesFromAVelocityVector() + { + var (pitch, yaw) = TrajectoryUtility.AnglesFromVelocity(new Vec3(100f, 0f, 0f)); + Assert.Equal(0f, yaw, 3); + Assert.Equal(0f, pitch, 3); + + (pitch, yaw) = TrajectoryUtility.AnglesFromVelocity(new Vec3(0f, 100f, 0f)); + Assert.Equal(90f, yaw, 3); + + // Up is negative pitch in the engine's convention. + (pitch, yaw) = TrajectoryUtility.AnglesFromVelocity(new Vec3(0f, 0f, 100f)); + Assert.Equal(-90f, pitch, 3); + } + + [Fact] + public void AnglesFromAZeroVectorDoNotProduceNaN() + { + var (pitch, yaw) = TrajectoryUtility.AnglesFromVelocity(new Vec3(0f, 0f, 0f)); + Assert.False(float.IsNaN(pitch)); + Assert.False(float.IsNaN(yaw)); + } + + private static TrajectoryPoint Point(float x, float y, float z, int t, bool bounce = false) + { + return new TrajectoryPoint + { + p = new Vec3(x, y, z), + t = t, + bounce = bounce, + }; + } + + [Fact] + public void SimplifyCollapsesAStraightRun() + { + var points = new List(); + for (int i = 0; i <= 20; i++) + { + points.Add(Point(i * 10f, 0f, 0f, i)); + } + + var simplified = TrajectoryUtility.Simplify(points); + + Assert.Equal(2, simplified.Count); + Assert.Equal(0f, simplified[0].p.x); + Assert.Equal(200f, simplified[^1].p.x); + } + + [Fact] + public void SimplifyKeepsTheShapeOfACurve() + { + var points = new List(); + for (int i = 0; i <= 40; i++) + { + float x = i * 10f; + points.Add(Point(x, 0f, -(x * x) / 400f, i)); + } + + var simplified = TrajectoryUtility.Simplify(points); + + Assert.True(simplified.Count > 2, "an arc must not collapse to a line"); + Assert.True( + simplified.Count < points.Count, + "an arc should still compact substantially" + ); + } + + // A bounce is where the path changes direction. Dropping one is how a + // replayed line ends up going through a wall. + [Fact] + public void SimplifyNeverDropsABounce() + { + var points = new List(); + for (int i = 0; i <= 10; i++) + { + points.Add(Point(i * 10f, 0f, 0f, i)); + } + points[5].bounce = true; + for (int i = 11; i <= 20; i++) + { + points.Add(Point(100f, (i - 10) * 10f, 0f, i)); + } + + var simplified = TrajectoryUtility.Simplify(points); + + Assert.Contains(simplified, p => p.bounce); + Assert.Equal(1, simplified.Count(p => p.bounce)); + } + + [Fact] + public void SimplifyPassesThroughShortPaths() + { + var points = new List { Point(0f, 0f, 0f, 0), Point(10f, 0f, 0f, 1) }; + Assert.Equal(2, TrajectoryUtility.Simplify(points).Count); + Assert.Empty(TrajectoryUtility.Simplify(new List())); + } +} diff --git a/apps/utility-sw/test/UtilityArtifactTests.cs b/apps/utility-sw/test/UtilityArtifactTests.cs new file mode 100644 index 00000000..4d62ec34 --- /dev/null +++ b/apps/utility-sw/test/UtilityArtifactTests.cs @@ -0,0 +1,338 @@ +using System.IO.Compression; +using System.Text.Json; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +// The three shapes the panel added, pinned to the spellings it actually sends. +// Every failure here is silent rather than loud: a preview that draws nothing, +// a roster that reads as empty, or a result the panel answers 403 to. +public class UtilityArtifactTests +{ + // The artifact is the playback blob's shape, so the path is nested. + private const string Artifact = """ + { + "schema_version": 3, + "map_name": "de_mirage", + "grenade_trajectories": [ + { + "round": 1, + "grenade_id": 1, + "type": "Smoke", + "points": [ + { "tick": 0, "x": 1, "y": 2, "z": 3 }, + { "tick": 8, "x": 4.5, "y": 5.5, "z": 6.5 } + ] + } + ], + "smoke_volumes": [ + { + "gid": 1, + "round": 1, + "start_tick": 128, + "ox": -100, "oy": -200, "oz": 64, + "vs": 8, "dx": 4, "dy": 5, "dz": 6, + "den": "AAAA" + } + ] + } + """; + + [Fact] + public void ThePathIsReadOutOfTheNestedTrajectory() + { + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse(Artifact); + + Assert.Equal(2, artifact.path.Count); + Assert.Equal(0, artifact.path[0].t); + Assert.Equal(1f, artifact.path[0].p.x); + Assert.Equal(8, artifact.path[1].t); + Assert.Equal(6.5f, artifact.path[1].p.z); + } + + [Fact] + public void TheSmokeVolumeIsReadOutOfTheArtifact() + { + SmokeVolume? volume = UtilityTrajectoryArtifact.Parse(Artifact).smoke_volume; + + Assert.NotNull(volume); + Assert.Equal(-100f, volume!.ox); + Assert.Equal(64f, volume.oz); + Assert.Equal(8f, volume.vs); + Assert.Equal(4, volume.dx); + Assert.Equal(5, volume.dy); + Assert.Equal(6, volume.dz); + Assert.Equal("AAAA", volume.den); + } + + // The blob is stored gzipped and streamed back byte for byte, so what + // arrives is compressed and reading it as text would be mojibake. + [Fact] + public void AGzippedArtifactIsUnpackedFirst() + { + var plain = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(Artifact)); + var compressed = new MemoryStream(); + + using (var gzip = new GZipStream(compressed, CompressionMode.Compress, true)) + { + plain.CopyTo(gzip); + } + + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse(compressed.ToArray()); + + Assert.Equal(2, artifact.path.Count); + Assert.NotNull(artifact.smoke_volume); + } + + [Fact] + public void APlainBodyIsReadAsItIs() + { + Assert.Equal("{}", PracticeJson.Text(System.Text.Encoding.UTF8.GetBytes("{}"))); + } + + [Fact] + public void AFlatPathIsStillRead() + { + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( + """{"path":[{"tick":4,"x":1,"y":2,"z":3}]}""" + ); + + Assert.Single(artifact.path); + Assert.Equal(4, artifact.path[0].t); + } + + [Fact] + public void ABareArrayIsStillRead() + { + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( + """[{"tick":4,"x":1,"y":2,"z":3}]""" + ); + + Assert.Single(artifact.path); + } + + // Not every lineup is a smoke and not every map has a collision mesh. + [Fact] + public void AMissingSmokeVolumeIsNotAnError() + { + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( + """{"grenade_trajectories":[{"points":[]}],"smoke_volumes":[]}""" + ); + + Assert.Empty(artifact.path); + Assert.Null(artifact.smoke_volume); + } + + [Fact] + public void AVolumeWithNoExtentIsNotAVolume() + { + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( + """{"smoke_volumes":[{"ox":0,"oy":0,"oz":0,"vs":0,"dx":0,"dy":0,"dz":0}]}""" + ); + + Assert.Null(artifact.smoke_volume); + } + + [Fact] + public void AnArtifactWithNothingInItReadsAsEmpty() + { + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse("{}"); + + Assert.Empty(artifact.path); + Assert.Null(artifact.smoke_volume); + } + + [Fact] + public void TheSessionIsReadInTheApiSpelling() + { + PracticeSessionData session = JsonSerializer + .Deserialize( + """ + { + "session_id": "11111111-1111-1111-1111-111111111111", + "match_id": "22222222-2222-2222-2222-222222222222", + "map_name": "de_nuke", + "password": "hunter2", + "steam_ids": ["76561198000000001", "76561198000000002"], + "playbook": null + } + """, + PracticeJson.Options + )! + .ToSession(); + + Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), session.id); + Assert.Equal(Guid.Parse("22222222-2222-2222-2222-222222222222"), session.match_id); + Assert.Equal("de_nuke", session.map); + Assert.Equal("hunter2", session.password); + Assert.Equal(2, session.allowed_steam_ids.Count); + Assert.Null(session.playbook); + } + + // An unparsed roster reads as "nobody is allowed", which is why both + // spellings are accepted rather than the newest one only. + [Fact] + public void TheOlderSessionSpellingStillFillsTheRoster() + { + PracticeSessionData session = JsonSerializer + .Deserialize( + """ + { + "id": "11111111-1111-1111-1111-111111111111", + "match_id": "22222222-2222-2222-2222-222222222222", + "map": "de_nuke", + "password": "hunter2", + "allowed_steam_ids": ["76561198000000001"] + } + """, + PracticeJson.Options + )! + .ToSession(); + + Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), session.id); + Assert.Equal("de_nuke", session.map); + Assert.Single(session.allowed_steam_ids); + } + + [Fact] + public void APlaybookOnTheSessionArrivesWithItsSteps() + { + PracticeSessionData session = JsonSerializer + .Deserialize( + """ + { + "session_id": "11111111-1111-1111-1111-111111111111", + "match_id": "22222222-2222-2222-2222-222222222222", + "map_name": "de_mirage", + "password": "", + "steam_ids": [], + "playbook": { + "id": "33333333-3333-3333-3333-333333333333", + "name": "A split", + "map_name": "de_mirage", + "side": "TERRORIST", + "steps": [ + { + "utility_lineup_id": "44444444-4444-4444-4444-444444444444", + "step_order": 1, + "offset_ms": 0, + "assigned_steam_id": "76561198000000001", + "note": "jungle smoke", + "lineup": { + "id": "44444444-4444-4444-4444-444444444444", + "name": "jungle", + "map_name": "de_mirage", + "utility_type": "Smoke", + "side": "TERRORIST", + "origin_x": 1, "origin_y": 2, "origin_z": 3, + "view_yaw": 90, "view_pitch": -20, + "land_x": 400, "land_y": 500, "land_z": 60 + } + } + ] + } + } + """, + PracticeJson.Options + )! + .ToSession(); + + UtilityPlaybook? playbook = session.playbook; + + Assert.NotNull(playbook); + Assert.Equal("A split", playbook!.name); + + var steps = PlaybookUtility.Ordered(playbook); + + Assert.Single(steps); + Assert.Equal("jungle smoke", steps[0].note); + Assert.True(PlaybookUtility.IsFor(steps[0], 76561198000000001)); + + LineupRecord? lineup = steps[0].ToLineup(); + + Assert.NotNull(lineup); + Assert.Equal("44444444-4444-4444-4444-444444444444", lineup!.id); + Assert.Equal(400f, lineup.detonation_position.x); + } + + [Fact] + public void AResultNamesTheServerAndTheSessionItBelongsTo() + { + UtilityPracticeResultPayload payload = UtilityPracticeResultPayload.For( + "55555555-5555-5555-5555-555555555555", + Guid.Parse("11111111-1111-1111-1111-111111111111"), + "44444444-4444-4444-4444-444444444444", + 76561198000000001, + new Vec3(1f, 2f, 3f), + true + ); + + Assert.Equal("55555555-5555-5555-5555-555555555555", payload.server_id); + Assert.Equal("11111111-1111-1111-1111-111111111111", payload.session_id); + Assert.Equal("44444444-4444-4444-4444-444444444444", payload.utility_lineup_id); + Assert.Equal("76561198000000001", payload.steam_id); + Assert.Equal(3f, payload.land_z); + Assert.True(payload.success); + } + + // The API rejects a session_id that disagrees with the one it resolved from + // the server, so an unknown session must be left out rather than sent empty. + [Fact] + public void AnUnknownSessionIsLeftOutOfTheResult() + { + UtilityPracticeResultPayload payload = UtilityPracticeResultPayload.For( + null, + Guid.Empty, + "44444444-4444-4444-4444-444444444444", + 76561198000000001, + new Vec3(1f, 2f, 3f), + null + ); + + string json = JsonSerializer.Serialize(payload, PracticeJson.Options); + + Assert.DoesNotContain("session_id", json); + Assert.DoesNotContain("server_id", json); + Assert.DoesNotContain("success", json); + Assert.Contains("\"utility_lineup_id\"", json); + } + + [Fact] + public void AResultIsReadBackWithThePanelsRadius() + { + UtilityPracticeResult? result = JsonSerializer.Deserialize( + """ + { + "success": true, + "distance": 42.5, + "radius": 96, + "attempts": 7, + "successes": 4, + "current_streak": 3, + "best_streak": 5, + "mastered_at": "2026-08-18T12:00:00.000Z" + } + """, + PracticeJson.Options + ); + + Assert.NotNull(result); + Assert.True(result!.success); + Assert.Equal(42.5f, result.distance); + Assert.Equal(96f, result.radius); + Assert.Equal(3, result.current_streak); + Assert.NotNull(result.mastered_at); + } + + [Fact] + public void AResultThatHasNotBeenMasteredCarriesNoDate() + { + UtilityPracticeResult? result = JsonSerializer.Deserialize( + """{"success":false,"distance":300,"radius":96,"attempts":1,"successes":0,"current_streak":0,"best_streak":0,"mastered_at":null}""", + PracticeJson.Options + ); + + Assert.NotNull(result); + Assert.Null(result!.mastered_at); + } +} diff --git a/apps/utility-sw/test/UtilityWireTests.cs b/apps/utility-sw/test/UtilityWireTests.cs new file mode 100644 index 00000000..d769b089 --- /dev/null +++ b/apps/utility-sw/test/UtilityWireTests.cs @@ -0,0 +1,568 @@ +using System.Text.Json; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +// The API owns the wire contract, so these pin the translation to it. Every +// case here is one that fails silently rather than loudly: a field that lands +// in the wrong column, a unit that is a thousand times out, or a spelling the +// panel's enum does not have. +public class UtilityWireTests +{ + private static LineupRecord Lineup() + { + return new LineupRecord + { + id = "server-side-id", + client_id = "local-id", + map = "de_mirage", + name = "A site window smoke", + utility_type = "Smoke", + side = "TERRORIST", + visibility = "Private", + author_steam_id = "76561198000000001", + release = new ThrowSnapshot + { + feet_position = new Vec3(100f, 200f, 300f), + eye_position = new Vec3(100f, 200f, 364f), + pitch = -12.5f, + yaw = 90f, + jump_throw = true, + }, + initial_position = new Vec3(1f, 2f, 3f), + initial_velocity = new Vec3(4f, 5f, 6f), + detonation_position = new Vec3(-500f, -600f, 128f), + bounces = 2, + flight_time = 1.5f, + technique = "RunJump", + strength = "Full", + recorded_tickrate = 64, + confidence = LineupRecord.Exact, + plugin_runtime = "counterstrikesharp", + plugin_version = "0.0.1", + trajectory = new List + { + new TrajectoryPoint { p = new Vec3(1f, 2f, 3f), t = 10 }, + new TrajectoryPoint { p = new Vec3(4f, 5f, 6f), t = 12, bounce = true }, + }, + }; + } + + [Fact] + public void EveryFieldLandsInTheColumnTheApiNames() + { + UtilityIngestPayload payload = UtilityIngestPayload.From(Lineup()); + + Assert.Equal("76561198000000001", payload.author_steam_id); + Assert.Equal("Smoke", payload.utility_type); + Assert.Equal("TERRORIST", payload.side); + Assert.Equal("RunJump", payload.technique); + Assert.Equal("Full", payload.throw_strength); + Assert.True(payload.jump_throw_bind); + + Assert.Equal(100f, payload.origin_x); + Assert.Equal(200f, payload.origin_y); + Assert.Equal(300f, payload.origin_z); + Assert.Equal(364f, payload.eye_z); + + Assert.Equal(90f, payload.view_yaw); + Assert.Equal(-12.5f, payload.view_pitch); + + Assert.Equal(-500f, payload.land_x); + Assert.Equal(-600f, payload.land_y); + Assert.Equal(128f, payload.land_z); + + Assert.Equal("A site window smoke", payload.name); + Assert.Equal(64, payload.tick_rate); + } + + // Seconds on this side, milliseconds on the API's. Getting this wrong + // produces a plausible-looking number rather than an error. + [Fact] + public void FlightTimeCrossesTheWireInMilliseconds() + { + UtilityIngestPayload payload = UtilityIngestPayload.From(Lineup()); + + Assert.Equal(1500, payload.flight_time_ms); + } + + [Theory] + [InlineData(0f, 0)] + [InlineData(0.001f, 1)] + [InlineData(2.4f, 2400)] + [InlineData(20f, 20000)] + public void MillisecondsAreRoundedNotTruncated(float seconds, int expected) + { + Assert.Equal(expected, UtilityIngestPayload.MillisecondsFromSeconds(seconds)); + } + + // 62.5 is exactly representable, so this pins the midpoint rule rather + // than tolerating whatever the default happens to be. + [Fact] + public void AMidpointRoundsAwayFromZero() + { + Assert.Equal(63, UtilityIngestPayload.MillisecondsFromSeconds(0.0625f)); + } + + [Theory] + [InlineData("HE", "HighExplosive")] + [InlineData("HEGrenade", "HighExplosive")] + [InlineData("HighExplosive", "HighExplosive")] + [InlineData("Flashbang", "Flash")] + [InlineData("Incendiary", "Molotov")] + [InlineData("Smoke", "Smoke")] + [InlineData("Decoy", "Decoy")] + public void TheUtilityTypeIsSentInTheApisSpelling(string recorded, string expected) + { + LineupRecord lineup = Lineup(); + lineup.utility_type = recorded; + + Assert.Equal(expected, UtilityIngestPayload.From(lineup).utility_type); + } + + [Fact] + public void ThePathIsSentAsObjectsNotPackedArrays() + { + string json = JsonSerializer.Serialize( + UtilityIngestPayload.From(Lineup()), + PracticeJson.Options + ); + + Assert.Contains("\"path\":[{\"tick\":10,\"x\":1,\"y\":2,\"z\":3}", json); + Assert.DoesNotContain("[[", json); + } + + // Sending a field the payload does not name is not harmless: the API + // derives the map from the server's own match row and rejects a mismatch. + [Theory] + [InlineData("\"map\"")] + [InlineData("\"client_id\"")] + [InlineData("\"initial_position\"")] + [InlineData("\"initial_velocity\"")] + [InlineData("\"bounces\"")] + [InlineData("\"visibility\"")] + [InlineData("\"plugin_runtime\"")] + [InlineData("\"plugin_version\"")] + [InlineData("\"workshop_map_id\"")] + [InlineData("\"release\"")] + [InlineData("\"trajectory\"")] + [InlineData("\"flight_time\":")] + [InlineData("\"confidence\"")] + public void FieldsTheApiDoesNotAcceptAreNotSent(string absent) + { + string json = JsonSerializer.Serialize( + UtilityIngestPayload.From(Lineup()), + PracticeJson.Options + ); + + Assert.DoesNotContain(absent, json); + } + + private static UtilityLibraryRow Row() + { + return new UtilityLibraryRow + { + id = "panel-id", + name = "Window smoke", + map_name = "de_mirage", + utility_type = "HE", + side = "CT", + technique = "Jump", + throw_strength = "Half", + jump_throw_bind = true, + origin_x = 10f, + origin_y = 20f, + origin_z = 30f, + eye_z = 94f, + view_yaw = 45f, + view_pitch = -20f, + land_x = 700f, + land_y = 800f, + land_z = 90f, + flight_time_ms = 2400, + visibility = "Team", + author_steam_id = "76561198000000009", + }; + } + + [Fact] + public void ALibraryRowBecomesALineupTheReplayCanStandOn() + { + LineupRecord lineup = Row().ToLineup(); + + Assert.Equal("panel-id", lineup.id); + Assert.Equal("Window smoke", lineup.name); + Assert.Equal("de_mirage", lineup.map); + Assert.Equal("CT", lineup.side); + Assert.Equal("Jump", lineup.technique); + Assert.Equal("Half", lineup.strength); + Assert.Equal("Team", lineup.visibility); + Assert.Equal("76561198000000009", lineup.author_steam_id); + + Assert.Equal(10f, lineup.release.feet_position.x); + Assert.Equal(20f, lineup.release.feet_position.y); + Assert.Equal(30f, lineup.release.feet_position.z); + Assert.Equal(94f, lineup.release.eye_position.z); + Assert.Equal(45f, lineup.release.yaw); + Assert.Equal(-20f, lineup.release.pitch); + Assert.True(lineup.release.jump_throw); + + Assert.Equal(700f, lineup.detonation_position.x); + Assert.Equal(90f, lineup.detonation_position.z); + } + + [Fact] + public void MillisecondsComeBackAsSeconds() + { + Assert.Equal(2.4f, Row().ToLineup().flight_time); + } + + [Fact] + public void ARowsTypeIsNormalizedOnTheWayInToo() + { + Assert.Equal("HighExplosive", Row().ToLineup().utility_type); + } + + // .delete and the .next/.prev walk both key off client_id, so a fetched + // lineup has to keep a stable one across reloads. + [Fact] + public void ThePanelsIdBecomesTheLocalIdentity() + { + Assert.Equal("panel-id", Row().ToLineup().client_id); + } + + // The library response has no path in it at all; the preview needs a + // second call, and code that assumed otherwise would draw nothing. + [Fact] + public void ALibraryRowCarriesNoTrajectory() + { + Assert.Empty(Row().ToLineup().trajectory); + } + + // The seed columns are nullable by design: a lineup mined from a demo, + // imported or authored by hand was never watched by a plugin. A zero + // velocity is exactly the predicate PracticeReplay.HasPhysicsSeed reads as + // "do not re-emit this", so a row with no seed has to land on it. + [Fact] + public void ARowWithNoSeedIsNotReplayable() + { + LineupRecord lineup = Row().ToLineup(); + + Assert.False(Row().HasSeed()); + Assert.Equal(0f, lineup.initial_velocity.Length()); + Assert.Equal(0f, lineup.initial_position.Length()); + } + + [Fact] + public void ARowWithASeedIsReplayableExactly() + { + UtilityLibraryRow row = Seeded(); + LineupRecord lineup = row.ToLineup(); + + Assert.True(row.HasSeed()); + + Assert.Equal(11f, lineup.initial_position.x); + Assert.Equal(22f, lineup.initial_position.y); + Assert.Equal(33f, lineup.initial_position.z); + + Assert.Equal(400f, lineup.initial_velocity.x); + Assert.Equal(-500f, lineup.initial_velocity.y); + Assert.Equal(600f, lineup.initial_velocity.z); + + Assert.True(lineup.initial_velocity.Length() > 0f); + } + + // Half a seed is worse than none: a position without a velocity would put + // the replay's origin somewhere real and its aim at nothing. + [Theory] + [InlineData("initial_pos_z")] + [InlineData("initial_vel_x")] + [InlineData("initial_vel_y")] + [InlineData("initial_vel_z")] + public void HalfASeedIsNoSeed(string missing) + { + UtilityLibraryRow row = Seeded(); + + switch (missing) + { + case "initial_pos_z": + row.initial_pos_z = null; + break; + case "initial_vel_x": + row.initial_vel_x = null; + break; + case "initial_vel_y": + row.initial_vel_y = null; + break; + default: + row.initial_vel_z = null; + break; + } + + LineupRecord lineup = row.ToLineup(); + + Assert.False(row.HasSeed()); + Assert.Equal(0f, lineup.initial_velocity.Length()); + Assert.Equal(0f, lineup.initial_position.Length()); + } + + // A grenade never leaves the hand at rest, so all six columns present and + // the velocity zero is an unfilled row rather than a throw. Taking it would + // fire the replay out of the world origin. + [Fact] + public void AZeroedSeedIsNoSeed() + { + UtilityLibraryRow row = Seeded(); + row.initial_vel_x = 0f; + row.initial_vel_y = 0f; + row.initial_vel_z = 0f; + + LineupRecord lineup = row.ToLineup(); + + Assert.False(row.HasSeed()); + Assert.Equal(0f, lineup.initial_velocity.Length()); + Assert.Equal(0f, lineup.initial_position.Length()); + } + + // An oracle solver is only worth running if the seed it found comes back + // out of the panel able to reproduce the throw it found. + [Fact] + public void ASeedSurvivesTheRoundTripThroughBothShapes() + { + LineupRecord original = Lineup(); + UtilityLibraryRow row = Seeded(); + + row.initial_pos_x = original.initial_position.x; + row.initial_pos_y = original.initial_position.y; + row.initial_pos_z = original.initial_position.z; + row.initial_vel_x = original.initial_velocity.x; + row.initial_vel_y = original.initial_velocity.y; + row.initial_vel_z = original.initial_velocity.z; + + LineupRecord back = row.ToLineup(); + + Assert.Equal(original.initial_position.x, back.initial_position.x); + Assert.Equal(original.initial_position.y, back.initial_position.y); + Assert.Equal(original.initial_position.z, back.initial_position.z); + Assert.Equal(original.initial_velocity.x, back.initial_velocity.x); + Assert.Equal(original.initial_velocity.y, back.initial_velocity.y); + Assert.Equal(original.initial_velocity.z, back.initial_velocity.z); + } + + // A seed and an exact lineup are the same signal today and not the same + // statement: the panel stamps a plugin-recorded lineup "exact" whether or + // not it captured a seed, and a mined lineup that later acquires one is + // still a path fitted to a demo. Re-emit needs both. + [Fact] + public void ExactWithASeedIsExactlyReplayable() + { + LineupRecord lineup = Seeded("exact").ToLineup(); + + Assert.True(lineup.HasPhysicsSeed()); + Assert.True(lineup.IsExactlyReplayable()); + Assert.False(lineup.IsKnownInexact()); + } + + [Theory] + [InlineData("derived")] + [InlineData("low")] + public void ASeedOnAnInexactLineupIsNotReplayed(string confidence) + { + LineupRecord lineup = Seeded(confidence).ToLineup(); + + Assert.Equal(confidence, lineup.confidence); + Assert.True(lineup.HasPhysicsSeed()); + Assert.False(lineup.IsExactlyReplayable()); + Assert.True(lineup.IsKnownInexact()); + } + + // An older panel does not send the field at all. Defaulting that to exact + // would put the bug back on the deployments least able to spot it. + [Fact] + public void AMissingConfidenceIsNotExact() + { + LineupRecord lineup = Seeded(null).ToLineup(); + + Assert.Null(lineup.confidence); + Assert.True(lineup.HasPhysicsSeed()); + Assert.False(lineup.IsExactlyReplayable()); + } + + // Unknown is not the same as bad. Warning about every lineup an older panel + // returns would teach a player to ignore the warning that matters. + [Fact] + public void AMissingConfidenceIsNotWarnedAbout() + { + Assert.False(Seeded(null).ToLineup().IsKnownInexact()); + Assert.False(Row().ToLineup().IsKnownInexact()); + } + + [Fact] + public void ExactWithNoSeedIsStillNotReplayable() + { + UtilityLibraryRow row = Row(); + row.confidence = "exact"; + + LineupRecord lineup = row.ToLineup(); + + Assert.False(lineup.HasPhysicsSeed()); + Assert.False(lineup.IsExactlyReplayable()); + } + + [Fact] + public void ConfidenceIsMatchedWhateverItsCasing() + { + Assert.True(Seeded("Exact").ToLineup().IsExactlyReplayable()); + Assert.True(Seeded("EXACT").ToLineup().IsExactlyReplayable()); + } + + // A lineup recorded in this session was watched by the plugin, so + // PracticeRecorder stamps it exact as it finalizes -- without that stamp + // the gate below would refuse to replay the one kind of throw the plugin + // measured itself. + [Fact] + public void ALineupRecordedHereIsExactlyReplayable() + { + LineupRecord recorded = Lineup(); + + Assert.Equal(LineupRecord.Exact, recorded.confidence); + Assert.True(recorded.IsExactlyReplayable()); + } + + private static UtilityLibraryRow Seeded(string? confidence = null) + { + UtilityLibraryRow row = Row(); + + row.initial_pos_x = 11f; + row.initial_pos_y = 22f; + row.initial_pos_z = 33f; + row.initial_vel_x = 400f; + row.initial_vel_y = -500f; + row.initial_vel_z = 600f; + row.confidence = confidence; + + return row; + } + + [Fact] + public void APositionSurvivesTheRoundTripThroughBothShapes() + { + LineupRecord original = Lineup(); + UtilityIngestPayload payload = UtilityIngestPayload.From(original); + + var row = new UtilityLibraryRow + { + id = "panel-id", + name = payload.name, + utility_type = payload.utility_type, + side = payload.side, + technique = payload.technique, + throw_strength = payload.throw_strength, + jump_throw_bind = payload.jump_throw_bind, + origin_x = payload.origin_x, + origin_y = payload.origin_y, + origin_z = payload.origin_z, + eye_z = payload.eye_z, + view_yaw = payload.view_yaw, + view_pitch = payload.view_pitch, + land_x = payload.land_x, + land_y = payload.land_y, + land_z = payload.land_z, + flight_time_ms = payload.flight_time_ms, + }; + + LineupRecord back = row.ToLineup(); + + Assert.Equal(original.release.feet_position.x, back.release.feet_position.x); + Assert.Equal(original.release.feet_position.z, back.release.feet_position.z); + Assert.Equal(original.release.eye_position.z, back.release.eye_position.z); + Assert.Equal(original.release.yaw, back.release.yaw); + Assert.Equal(original.release.pitch, back.release.pitch); + Assert.Equal(original.detonation_position.y, back.detonation_position.y); + Assert.Equal(original.flight_time, back.flight_time); + Assert.Equal(original.utility_type, back.utility_type); + Assert.Equal(original.technique, back.technique); + } + + [Fact] + public void NullsAreOmittedRatherThanSentAsNull() + { + var bare = new LineupRecord { utility_type = "Smoke" }; + + string json = JsonSerializer.Serialize( + UtilityIngestPayload.From(bare), + PracticeJson.Options + ); + + Assert.DoesNotContain("null", json); + Assert.DoesNotContain("\"description\"", json); + Assert.DoesNotContain("\"match_id\"", json); + } +} + +public class UtilityIngestSeedTests +{ + private static LineupRecord Thrown() + { + return new LineupRecord + { + utility_type = "Smoke", + initial_position = new Vec3(100f, 200f, 64f), + initial_velocity = new Vec3(700f, -120f, 260f), + }; + } + + [Fact] + public void AThrownGrenadeCarriesItsPhysicsSeed() + { + UtilityIngestPayload payload = UtilityIngestPayload.From(Thrown()); + + Assert.Equal(100f, payload.initial_pos_x); + Assert.Equal(200f, payload.initial_pos_y); + Assert.Equal(64f, payload.initial_pos_z); + Assert.Equal(700f, payload.initial_vel_x); + Assert.Equal(-120f, payload.initial_vel_y); + Assert.Equal(260f, payload.initial_vel_z); + } + + [Fact] + public void ARecordWithNoSeedSendsNoneOfIt() + { + // The panel rejects a partial seed outright, and a struct default of + // (0,0,0) would otherwise be stored as a throw from the world origin. + UtilityIngestPayload payload = UtilityIngestPayload.From( + new LineupRecord { utility_type = "Smoke" } + ); + + Assert.Null(payload.initial_pos_x); + Assert.Null(payload.initial_pos_y); + Assert.Null(payload.initial_pos_z); + Assert.Null(payload.initial_vel_x); + Assert.Null(payload.initial_vel_y); + Assert.Null(payload.initial_vel_z); + } + + [Fact] + public void TheSeedSurvivesIngestAndComesBackOutOfTheLibrary() + { + UtilityIngestPayload sent = UtilityIngestPayload.From(Thrown()); + + // What the panel stores and hands back is the library row, so the + // round trip is only closed if it rebuilds the same seed. + LineupRecord back = new UtilityLibraryRow + { + utility_type = "Smoke", + initial_pos_x = sent.initial_pos_x, + initial_pos_y = sent.initial_pos_y, + initial_pos_z = sent.initial_pos_z, + initial_vel_x = sent.initial_vel_x, + initial_vel_y = sent.initial_vel_y, + initial_vel_z = sent.initial_vel_z, + }.ToLineup(); + + Assert.Equal(Thrown().initial_position.x, back.initial_position.x); + Assert.Equal(Thrown().initial_position.z, back.initial_position.z); + Assert.Equal(Thrown().initial_velocity.x, back.initial_velocity.x); + Assert.Equal(Thrown().initial_velocity.z, back.initial_velocity.z); + } +} diff --git a/codepier.yaml b/codepier.yaml index dfdc92f7..ddd9144e 100644 --- a/codepier.yaml +++ b/codepier.yaml @@ -4,6 +4,8 @@ container: dev workload: - dev-game-server - dev-swiftly-game-server + - dev-utility-game-server + - dev-utility-swiftly-game-server supplemental: true workdir: /opt/5stack sync: diff --git a/scripts/dev.sh b/scripts/dev.sh index 24613b71..3eb23545 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -9,6 +9,8 @@ cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" case "${CODEPIER_DEPLOYMENT:-}" in dev-game-server) app="counterstrikesharp" ;; dev-swiftly-game-server) app="swiftly" ;; + dev-utility-game-server) app="utility-css" ;; + dev-utility-swiftly-game-server) app="utility-sw" ;; "") echo "not inside a codepier pod; run 'codepier up' from the repo root first" >&2 exit 1 diff --git a/scripts/tail.sh b/scripts/tail.sh index 70da95b8..93be0413 100755 --- a/scripts/tail.sh +++ b/scripts/tail.sh @@ -5,12 +5,15 @@ # # ./scripts/tail.sh pick interactively # ./scripts/tail.sh swiftly +# ./scripts/tail.sh utility-sw set -euo pipefail workload_for() { case "$1" in counterstrikesharp | css) echo "dev-game-server" ;; swiftly | sw) echo "dev-swiftly-game-server" ;; + utility-css | ucss) echo "dev-utility-game-server" ;; + utility-sw | usw) echo "dev-utility-swiftly-game-server" ;; *) return 1 ;; esac } @@ -19,13 +22,13 @@ plugin="${1:-}" if [ -z "$plugin" ]; then PS3="plugin: " - select plugin in counterstrikesharp swiftly; do + select plugin in counterstrikesharp swiftly utility-css utility-sw; do [ -n "$plugin" ] && break done fi if ! workload="$(workload_for "$plugin")"; then - echo "usage: $0 [counterstrikesharp|swiftly]" >&2 + echo "usage: $0 [counterstrikesharp|swiftly|utility-css|utility-sw]" >&2 exit 1 fi @@ -39,7 +42,7 @@ fi # reload bug has actually bitten. Otherwise every plugin line shows up twice. STDOUT_QUIET_SECONDS=10 -if [ "$workload" = "dev-swiftly-game-server" ] && command -v kubectl >/dev/null 2>&1; then +if [[ "$workload" == *swiftly-game-server ]] && command -v kubectl >/dev/null 2>&1; then export KUBECONFIG="${KUBECONFIG:-$HOME/.kube/5stackgg}" pod=$(kubectl -n 5stack get pods -l app="$workload" \ --field-selector=status.phase=Running \ diff --git a/shared/dotnet/FiveStack.Entities/Practice/BloomSegment.cs b/shared/dotnet/FiveStack.Entities/Practice/BloomSegment.cs new file mode 100644 index 00000000..e5c1417d --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/BloomSegment.cs @@ -0,0 +1,15 @@ +namespace FiveStack.Entities.Practice; + +// One line of a bloom outline, in world units. The renderer turns each of these +// into exactly one beam, so the number of them is the entity budget. +public struct BloomSegment +{ + public Vec3 a { get; set; } + public Vec3 b { get; set; } + + public BloomSegment(Vec3 a, Vec3 b) + { + this.a = a; + this.b = b; + } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/CalibrationReport.cs b/shared/dotnet/FiveStack.Entities/Practice/CalibrationReport.cs new file mode 100644 index 00000000..5331759e --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/CalibrationReport.cs @@ -0,0 +1,55 @@ +using FiveStack.Enums; + +namespace FiveStack.Entities.Practice; + +// The solver's licence to run on one map, and the evidence behind it. +// +// Held per map per server boot: a map change replaces the collision mesh the +// whole exercise is about, and a plugin reload is the only thing that could +// have changed the launch model. +public class CalibrationReport +{ + public string map { get; set; } = ""; + public string status { get; set; } = nameof(eCalibrationStatus.Unknown); + public string message { get; set; } = ""; + + public List launch_checks { get; set; } = new List(); + + // Measured release speed over predicted, per strength bucket. Only buckets + // somebody actually threw appear here, and the solver searches only these: + // an unmeasured bucket is a guess about the strength curve, and a guess is + // exactly what this whole file exists to refuse. + public Dictionary speed_corrections { get; set; } = + new Dictionary(); + + // Distance between where the re-emitted grenade landed and where the + // original one did. Negative until the replay has run. + public float seed_replay_error { get; set; } = -1f; + public string seed_replay_client_id { get; set; } = ""; + public string seed_replay_utility { get; set; } = ""; + + public bool CanSolve() + { + return status == nameof(eCalibrationStatus.Ready) && speed_corrections.Count > 0; + } + + public List SolvableStrengths() + { + return speed_corrections.Keys.OrderBy(name => name, StringComparer.Ordinal).ToList(); + } + + public float CorrectionFor(string strength) + { + return speed_corrections.TryGetValue(strength, out float correction) ? correction : 1f; + } + + public float WorstPositionError() + { + return launch_checks.Count == 0 ? 0f : launch_checks.Max(check => check.position_error); + } + + public float WorstDirectionError() + { + return launch_checks.Count == 0 ? 0f : launch_checks.Max(check => check.direction_error); + } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/LaunchCheck.cs b/shared/dotnet/FiveStack.Entities/Practice/LaunchCheck.cs new file mode 100644 index 00000000..7dd89c72 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/LaunchCheck.cs @@ -0,0 +1,21 @@ +namespace FiveStack.Entities.Practice; + +// One recorded throw held up against the launch model, so a failed calibration +// says which throw disagreed and by how much rather than just "no". +public class LaunchCheck +{ + public string client_id { get; set; } = ""; + public string strength { get; set; } = ""; + public float pitch { get; set; } + + public float position_error { get; set; } + public float direction_error { get; set; } + + // Observed release speed over predicted. One is a perfect model; the solver + // carries whatever this is forward as a correction rather than insisting on + // one, because a systematic few percent is a constant being slightly off + // and not the formula being wrong. + public float speed_ratio { get; set; } + + public bool passed { get; set; } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/LaunchSeed.cs b/shared/dotnet/FiveStack.Entities/Practice/LaunchSeed.cs new file mode 100644 index 00000000..4793d7aa --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/LaunchSeed.cs @@ -0,0 +1,17 @@ +namespace FiveStack.Entities.Practice; + +// What the engine is handed to start a grenade: the point it appears at and the +// velocity it leaves with. The same pair LineupRecord stores as +// initial_position / initial_velocity, which is why a solved throw is exactly +// replayable without anything else being saved. +public struct LaunchSeed +{ + public Vec3 position { get; set; } + public Vec3 velocity { get; set; } + + // Unit throw direction and the speed along it, kept apart so calibration + // can fault the aim and the strength separately -- a wrong direction and a + // wrong speed are different bugs with different fixes. + public Vec3 direction { get; set; } + public float speed { get; set; } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs b/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs new file mode 100644 index 00000000..656e27ce --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs @@ -0,0 +1,97 @@ +using FiveStack.Enums; + +namespace FiveStack.Entities.Practice; + +// One recorded lineup, as it crosses the wire to the panel. Field names match +// the API's ingest contract exactly. +public class LineupRecord +{ + public string? id { get; set; } + + // Plugin-generated, so a retry after a timeout cannot create a duplicate. + public string client_id { get; set; } = ""; + + public string map { get; set; } = ""; + public string? workshop_map_id { get; set; } + public string name { get; set; } = ""; + + public string utility_type { get; set; } = nameof(eUtilityType.Smoke); + public string side { get; set; } = "TERRORIST"; + public string visibility { get; set; } = nameof(eLineupVisibility.Private); + public string author_steam_id { get; set; } = ""; + + public ThrowSnapshot release { get; set; } = new ThrowSnapshot(); + + // The engine's own physics seed, straight off the projectile. Replaying + // from these reproduces the throw exactly; the eye angles above only + // approximate it, because the release adds the player's own velocity. + public Vec3 initial_position { get; set; } + public Vec3 initial_velocity { get; set; } + + public Vec3 detonation_position { get; set; } + public int bounces { get; set; } + public float flight_time { get; set; } + + public string technique { get; set; } = nameof(eThrowTechnique.Stationary); + public string? strength { get; set; } + + // How close the crosshair has to be, in degrees, before this throw counts + // as lined up. Per lineup: a tight one wants a green zone you can only + // reach deliberately, a forgiving one wants a green zone you can find at a + // glance. Zero or missing means fall back to the plugin's own default. + public float aim_tolerance { get; set; } + + // The author's write-up, when there is one. The plugin only ever says that + // it EXISTS -- a paragraph does not belong in centre text. + public string? description { get; set; } + + // The panel's word for how this lineup was arrived at: exact, derived or + // low. Null for a lineup recorded in this session, which has not been + // through the panel yet, and for anything an older panel returned. + public string? confidence { get; set; } + + public List trajectory { 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. + public SmokeVolume? smoke_volume { get; set; } + + // A trajectory only replays under the physics it was recorded with. + public int recorded_tickrate { get; set; } + public string plugin_runtime { get; set; } = ""; + public string plugin_version { get; set; } = ""; + + public const string Exact = "exact"; + + // m_vInitialVelocity is never zero for a grenade that was actually thrown, + // so this is what separates a lineup with a physics seed from one without. + public bool HasPhysicsSeed() + { + return initial_velocity.Length() > 0f; + } + + // Whether the engine can be handed this seed and reproduce the throw. + // + // A seed is not the same statement as an exact lineup, even where the two + // travel together today: the panel stamps a plugin-recorded lineup "exact" + // whether or not it captured a seed, and a mined lineup that later acquires + // one is still a path fitted to a demo. Both halves are required, and an + // absent confidence counts as no -- an older panel does not send the field + // at all, and defaulting that to exact would put the bug back on precisely + // the deployments least able to spot it. + public bool IsExactlyReplayable() + { + return HasPhysicsSeed() + && string.Equals(confidence, Exact, StringComparison.OrdinalIgnoreCase); + } + + // True only where the panel said so: an unknown provenance is not the same + // as a bad one, and warning about every lineup an older panel returns would + // teach a player to ignore the warning that matters. + public bool IsKnownInexact() + { + return !string.IsNullOrEmpty(confidence) + && !string.Equals(confidence, Exact, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs b/shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs new file mode 100644 index 00000000..5f2ecbd1 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs @@ -0,0 +1,16 @@ +namespace FiveStack.Entities.Practice; + +// What the panel tells a practice server about the session it is hosting: who +// may join, and the password the connect tokens are signed with. A practice +// server never loads the match plugin, so this is the only roster it has. +public class PracticeSessionData +{ + public Guid id { get; set; } + public Guid match_id { get; set; } + public string password { get; set; } = ""; + public string map { get; set; } = ""; + public List allowed_steam_ids { get; set; } = new List(); + + // Null unless the panel has loaded an execute onto this session. + public UtilityPlaybook? playbook { get; set; } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/SmokeVolume.cs b/shared/dotnet/FiveStack.Entities/Practice/SmokeVolume.cs new file mode 100644 index 00000000..28a7d8f0 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/SmokeVolume.cs @@ -0,0 +1,23 @@ +namespace FiveStack.Entities.Practice; + +// One smoke's measured density grid, in the same EventSmokeVolume shape the +// demo playback blob carries. +// +// den is base64, two cells per byte with the low nibble first, over dx*dy*dz +// cells, x-major then y then z: cell (i,j,k) is at index (k*dy + j)*dx + i and +// has its minimum corner at (ox,oy,oz) + (i,j,k)*vs, in source units. 0 is +// clear, 15 is fully dense. +public class SmokeVolume +{ + public float ox { get; set; } + public float oy { get; set; } + public float oz { get; set; } + + public float vs { get; set; } + + public int dx { get; set; } + public int dy { get; set; } + public int dz { get; set; } + + public string? den { get; set; } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/SolveCandidate.cs b/shared/dotnet/FiveStack.Entities/Practice/SolveCandidate.cs new file mode 100644 index 00000000..206e5775 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/SolveCandidate.cs @@ -0,0 +1,15 @@ +namespace FiveStack.Entities.Practice; + +// One throw the solver is about to make: an aim and a release, which is the +// whole of what a human can control. +public struct SolveCandidate +{ + public float pitch { get; set; } + public float yaw { get; set; } + + // m_flThrowStrength, and the bucket a player would recognise it as. The raw + // value drives the emit; the bucket names the calibration measurement that + // licensed it. + public float strength { get; set; } + public string strength_bucket { get; set; } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/SolveObservation.cs b/shared/dotnet/FiveStack.Entities/Practice/SolveObservation.cs new file mode 100644 index 00000000..3093cdbd --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/SolveObservation.cs @@ -0,0 +1,16 @@ +namespace FiveStack.Entities.Practice; + +// What the server did with a candidate. This is the only measurement in the +// solver: no model produced it, a real grenade did. +public class SolveObservation +{ + public SolveCandidate candidate { get; set; } + public Vec3 landing { get; set; } + + // Distance from the requested target. float.MaxValue for a grenade that + // never reported, so a lost projectile can never win. + public float distance { get; set; } = float.MaxValue; + + public bool landed { get; set; } + public int bounces { get; set; } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/SolveRequest.cs b/shared/dotnet/FiveStack.Entities/Practice/SolveRequest.cs new file mode 100644 index 00000000..5c153396 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/SolveRequest.cs @@ -0,0 +1,30 @@ +namespace FiveStack.Entities.Practice; + +// One question for the solver: land this kind of utility on this point, thrown +// from here. +public class SolveRequest +{ + public string map { get; set; } = ""; + public string utility_type { get; set; } = "Smoke"; + public string side { get; set; } = "TERRORIST"; + public string name { get; set; } = ""; + + public Vec3 target { get; set; } + + // Where the grenade leaves from and where the player stands to do it. Both + // are carried because a lineup has to tell a human where to stand, and the + // eye is what the throw is actually computed from. + public Vec3 eye { get; set; } + public Vec3 feet { get; set; } + + public float tolerance { get; set; } + public int max_grenades { get; set; } + public int batch_size { get; set; } + public float max_seconds { get; set; } + + // The strength buckets calibration cleared. Empty is not "search them all", + // it is "nothing is safe to search". + public List strengths { get; set; } = new List(); + + public string requested_by { get; set; } = ""; +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/SolveResult.cs b/shared/dotnet/FiveStack.Entities/Practice/SolveResult.cs new file mode 100644 index 00000000..b974eddb --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/SolveResult.cs @@ -0,0 +1,21 @@ +using FiveStack.Enums; + +namespace FiveStack.Entities.Practice; + +// How a solve ended, and the best real throw it saw. +public class SolveResult +{ + public string outcome { get; set; } = nameof(eSolveOutcome.Running); + public string message { get; set; } = ""; + + public SolveObservation? best { get; set; } + + public int thrown { get; set; } + public int batches { get; set; } + public float elapsed_seconds { get; set; } + + public bool Converged() + { + return outcome == nameof(eSolveOutcome.Converged); + } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/ThrowSnapshot.cs b/shared/dotnet/FiveStack.Entities/Practice/ThrowSnapshot.cs new file mode 100644 index 00000000..0b702717 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/ThrowSnapshot.cs @@ -0,0 +1,30 @@ +namespace FiveStack.Entities.Practice; + +// Player state at the release tick. This is the half a human has to reproduce: +// where to stand, where to look, what to hold. +public class ThrowSnapshot +{ + public Vec3 feet_position { get; set; } + + // AbsOrigin + ViewOffset. Where the projectile actually leaves from, which + // is not the same as where the player is standing. + public Vec3 eye_position { get; set; } + + public float pitch { get; set; } + public float yaw { get; set; } + + public Vec3 velocity { get; set; } + public float speed { get; set; } + + public bool on_ground { get; set; } + public bool ducked { get; set; } + public bool walking { get; set; } + + // Stored raw as well as bucketed. If the Full/Half/Drop thresholds turn out + // wrong they can be re-derived without re-recording anything. + public float throw_strength_raw { get; set; } + public bool jump_throw { get; set; } + + public uint buttons { get; set; } + public int tick { get; set; } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/TrajectoryPoint.cs b/shared/dotnet/FiveStack.Entities/Practice/TrajectoryPoint.cs new file mode 100644 index 00000000..17d90a0f --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/TrajectoryPoint.cs @@ -0,0 +1,11 @@ +namespace FiveStack.Entities.Practice; + +public class TrajectoryPoint +{ + public Vec3 p { get; set; } + public int t { get; set; } + + // Sampling is lossy, but a bounce is where the path changes direction, so + // simplification must never drop one. + public bool bounce { get; set; } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs new file mode 100644 index 00000000..490043f9 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs @@ -0,0 +1,117 @@ +using FiveStack.Utilities; + +namespace FiveStack.Entities.Practice; + +// The body of POST /utility/ingest, exactly as the API defines it. +// +// LineupRecord stays the plugin's own model; this flat shape is the only thing +// that crosses the wire, because it maps one to one onto the columns the panel +// writes. Anything the payload does not name is dropped rather than sent: the +// map in particular is derived from the authenticated server's own match row, +// and sending our own would be rejected as a mismatch. +public class UtilityIngestPayload +{ + public string? match_id { get; set; } + public string? author_steam_id { get; set; } + public string? utility_type { get; set; } + public string? side { get; set; } + public string? technique { get; set; } + public string? throw_strength { get; set; } + public bool? jump_throw_bind { get; set; } + + public float? origin_x { get; set; } + public float? origin_y { get; set; } + public float? origin_z { get; set; } + public float? eye_z { get; set; } + + public float? view_yaw { get; set; } + public float? view_pitch { get; set; } + + // The physics seed: where the projectile actually came into being and how + // fast it was going. Sent whole or not at all -- the panel rejects a + // partial seed, and everything downstream reads a zero velocity as "there + // is no seed" rather than as a grenade launched from the world origin. + public float? initial_pos_x { get; set; } + public float? initial_pos_y { get; set; } + public float? initial_pos_z { get; set; } + + public float? initial_vel_x { get; set; } + public float? initial_vel_y { get; set; } + public float? initial_vel_z { get; set; } + + public float? land_x { get; set; } + public float? land_y { get; set; } + public float? land_z { get; set; } + + public int? flight_time_ms { get; set; } + public string? name { get; set; } + public string? description { get; set; } + public int? tick_rate { get; set; } + + public List? path { get; set; } + + public static UtilityIngestPayload From(LineupRecord lineup) + { + // Vec3 is a struct, so an unrecorded seed is (0,0,0) rather than null. + // A grenade that left somebody's hand is always moving, which is what + // separates the two. + bool seeded = lineup.initial_velocity.Length() > 0.0001f; + + return new UtilityIngestPayload + { + author_steam_id = Text(lineup.author_steam_id), + utility_type = PracticeLineupUtility.NormalizeUtilityType(lineup.utility_type), + side = Text(lineup.side), + technique = Text(lineup.technique), + throw_strength = Text(lineup.strength), + jump_throw_bind = lineup.release.jump_throw, + + origin_x = lineup.release.feet_position.x, + origin_y = lineup.release.feet_position.y, + origin_z = lineup.release.feet_position.z, + eye_z = lineup.release.eye_position.z, + + view_yaw = lineup.release.yaw, + view_pitch = lineup.release.pitch, + + land_x = lineup.detonation_position.x, + land_y = lineup.detonation_position.y, + land_z = lineup.detonation_position.z, + + initial_pos_x = seeded ? lineup.initial_position.x : null, + initial_pos_y = seeded ? lineup.initial_position.y : null, + initial_pos_z = seeded ? lineup.initial_position.z : null, + + initial_vel_x = seeded ? lineup.initial_velocity.x : null, + initial_vel_y = seeded ? lineup.initial_velocity.y : null, + initial_vel_z = seeded ? lineup.initial_velocity.z : null, + + flight_time_ms = MillisecondsFromSeconds(lineup.flight_time), + name = Text(lineup.name), + tick_rate = lineup.recorded_tickrate, + + path = lineup + .trajectory.Select(point => new UtilityPathPoint + { + tick = point.t, + x = point.p.x, + y = point.p.y, + z = point.p.z, + }) + .ToList(), + }; + } + + // The plugin measures flight in seconds and the API stores milliseconds. A + // missed conversion here is silently wrong rather than an error, which is + // why the arithmetic lives in one named place. + public static int MillisecondsFromSeconds(float seconds) + { + return (int)MathF.Round(seconds * 1000f, MidpointRounding.AwayFromZero); + } + + private static string? Text(string? value) + { + return string.IsNullOrEmpty(value) ? null : value; + } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs new file mode 100644 index 00000000..46a325b2 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs @@ -0,0 +1,134 @@ +using FiveStack.Utilities; + +namespace FiveStack.Entities.Practice; + +// A row of GET /utility/library, exactly as the API returns it. The steps of a +// playbook inline the same shape. +// +// There is no flight path here. A row carries enough to stand a player on the +// lineup, point them at it and re-emit the throw exactly, but a preview of the +// line itself needs a second call to GET /utility/{id}/trajectory. +public class UtilityLibraryRow +{ + public string? id { get; set; } + public string? name { get; set; } + public string? map_name { get; set; } + public string? utility_type { get; set; } + public string? side { get; set; } + public string? technique { get; set; } + public string? throw_strength { get; set; } + public bool? jump_throw_bind { get; set; } + + public double? aim_tolerance { get; set; } + + public string? description { get; set; } + + public float? origin_x { get; set; } + public float? origin_y { get; set; } + public float? origin_z { get; set; } + public float? eye_z { get; set; } + + public float? view_yaw { get; set; } + public float? view_pitch { get; set; } + + public float? land_x { get; set; } + public float? land_y { get; set; } + public float? land_z { get; set; } + + // The engine's own physics seed. Null by design: a lineup mined from a + // demo, authored in the editor or imported was never watched by a plugin + // and has no seed to give. + public float? initial_pos_x { get; set; } + public float? initial_pos_y { get; set; } + public float? initial_pos_z { get; set; } + public float? initial_vel_x { get; set; } + public float? initial_vel_y { get; set; } + public float? initial_vel_z { get; set; } + + public int? flight_time_ms { get; set; } + public string? visibility { get; set; } + public string? confidence { get; set; } + public string? author_steam_id { get; set; } + + public LineupRecord ToLineup() + { + float originX = origin_x ?? 0f; + float originY = origin_y ?? 0f; + + var lineup = new LineupRecord + { + id = id, + // The panel's id is the only stable identity a fetched lineup has, + // so it doubles as the local one and .delete keeps working after a + // reload. + client_id = id ?? Guid.NewGuid().ToString(), + name = name ?? "", + map = map_name ?? "", + utility_type = PracticeLineupUtility.NormalizeUtilityType(utility_type ?? ""), + side = side ?? "TERRORIST", + technique = technique ?? "", + strength = throw_strength, + aim_tolerance = (float)(aim_tolerance ?? 0d), + description = description, + visibility = visibility ?? "Private", + confidence = confidence, + author_steam_id = author_steam_id ?? "", + + release = new ThrowSnapshot + { + feet_position = new Vec3(originX, originY, origin_z ?? 0f), + eye_position = new Vec3(originX, originY, eye_z ?? 0f), + yaw = view_yaw ?? 0f, + pitch = view_pitch ?? 0f, + jump_throw = jump_throw_bind ?? false, + }, + + detonation_position = new Vec3(land_x ?? 0f, land_y ?? 0f, land_z ?? 0f), + flight_time = (flight_time_ms ?? 0) / 1000f, + }; + + // A seed is one thing, not six numbers, so it is taken whole or not at + // all. Everything downstream reads a zero velocity as "there is no + // seed", and a null quietly widened into a zero is what would launch a + // replayed grenade out of the world origin instead of refusing to fire. + if (HasSeed()) + { + lineup.initial_position = new Vec3( + initial_pos_x!.Value, + initial_pos_y!.Value, + initial_pos_z!.Value + ); + lineup.initial_velocity = new Vec3( + initial_vel_x!.Value, + initial_vel_y!.Value, + initial_vel_z!.Value + ); + } + + return lineup; + } + + // A thrown grenade never leaves the hand at rest, so a stored velocity of + // zero is a column that was never filled in rather than a throw that stood + // still. + public bool HasSeed() + { + if ( + initial_pos_x == null + || initial_pos_y == null + || initial_pos_z == null + || initial_vel_x == null + || initial_vel_y == null + || initial_vel_z == null + ) + { + return false; + } + + return new Vec3( + initial_vel_x.Value, + initial_vel_y.Value, + initial_vel_z.Value + ).Length() > 0f; + } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/UtilityPathPoint.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityPathPoint.cs new file mode 100644 index 00000000..cd966883 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityPathPoint.cs @@ -0,0 +1,12 @@ +namespace FiveStack.Entities.Practice; + +// One sample of a flight path, in the API's spelling. Deliberately an object +// per point rather than a packed array: the panel validates and stores these +// field by field, and it is the API that owns the wire contract. +public class UtilityPathPoint +{ + public int? tick { get; set; } + public float? x { get; set; } + public float? y { get; set; } + public float? z { get; set; } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/UtilityPlaybook.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityPlaybook.cs new file mode 100644 index 00000000..07d34105 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityPlaybook.cs @@ -0,0 +1,47 @@ +namespace FiveStack.Entities.Practice; + +// An execute, as GET /utility/session returns it when one is loaded. Steps arrive +// ordered by step_order. +public class UtilityPlaybook +{ + public string? id { get; set; } + public string? name { get; set; } + public string? map_name { get; set; } + public string? side { get; set; } + + public List steps { get; set; } = new List(); +} + +// One throw of an execute. A step with no assigned_steam_id belongs to whoever +// is standing there, which is why it prompts everyone rather than nobody. +public class UtilityPlaybookStep +{ + public string? utility_lineup_id { get; set; } + public int step_order { get; set; } + public int offset_ms { get; set; } + public string? assigned_steam_id { get; set; } + public string? note { get; set; } + + public UtilityLibraryRow? lineup { get; set; } + + // The step's own id is the authority: a book can name a lineup whose row + // the panel declined to inline, and loading the wrong geometry is worse + // than loading none. + public LineupRecord? ToLineup() + { + if (lineup == null) + { + return null; + } + + LineupRecord record = lineup.ToLineup(); + + if (!string.IsNullOrEmpty(utility_lineup_id)) + { + record.id = utility_lineup_id; + record.client_id = utility_lineup_id; + } + + return record; + } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/UtilityPracticeResult.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityPracticeResult.cs new file mode 100644 index 00000000..61a73bec --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityPracticeResult.cs @@ -0,0 +1,57 @@ +namespace FiveStack.Entities.Practice; + +// The body of POST /utility/practice-result, exactly as the API defines it. +// +// success is advisory and the server recomputes the distance from the lineup it +// owns, so this is only ever filled in from a radius the API itself has already +// handed back -- a guessed radius here reports a hit the panel then logs as a +// lie. +public class UtilityPracticeResultPayload +{ + public string? server_id { get; set; } + public string? session_id { get; set; } + public string? utility_lineup_id { get; set; } + public string? steam_id { get; set; } + + public float? land_x { get; set; } + public float? land_y { get; set; } + public float? land_z { get; set; } + + public bool? success { get; set; } + + public static UtilityPracticeResultPayload For( + string? serverId, + Guid sessionId, + string lineupId, + ulong steamId, + Vec3 landing, + bool? success + ) + { + return new UtilityPracticeResultPayload + { + server_id = string.IsNullOrEmpty(serverId) ? null : serverId, + session_id = sessionId == Guid.Empty ? null : sessionId.ToString(), + utility_lineup_id = lineupId, + steam_id = steamId.ToString(), + land_x = landing.x, + land_y = landing.y, + land_z = landing.z, + success = success, + }; + } +} + +// What the API answers with. The radius is the panel's, not ours: a hard-coded +// one here would tell a player they missed a throw the panel counted. +public class UtilityPracticeResult +{ + public bool success { get; set; } + public float distance { get; set; } + public float radius { get; set; } + public int attempts { get; set; } + public int successes { get; set; } + public int current_streak { get; set; } + public int best_streak { get; set; } + public DateTime? mastered_at { get; set; } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/UtilitySessionRow.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilitySessionRow.cs new file mode 100644 index 00000000..3d8564de --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilitySessionRow.cs @@ -0,0 +1,39 @@ +namespace FiveStack.Entities.Practice; + +// The body of GET /utility/session, exactly as the API returns it. +// +// The API's spelling and the plugin's model have drifted apart more than once +// (session_id/id, map_name/map, steam_ids/allowed_steam_ids), and the failure +// mode is silent: an unparsed roster reads as "nobody is allowed" rather than +// as an error. Both spellings are accepted here so a rename on either side +// cannot empty the door policy. +public class UtilitySessionRow +{ + public string? id { get; set; } + public string? session_id { get; set; } + public string? match_id { get; set; } + public string? password { get; set; } + public string? map { get; set; } + public string? map_name { get; set; } + public List? steam_ids { get; set; } + public List? allowed_steam_ids { get; set; } + public UtilityPlaybook? playbook { get; set; } + + public PracticeSessionData ToSession() + { + return new PracticeSessionData + { + id = Id(session_id ?? id), + match_id = Id(match_id), + password = password ?? "", + map = map_name ?? map ?? "", + allowed_steam_ids = steam_ids ?? allowed_steam_ids ?? new List(), + playbook = playbook, + }; + } + + private static Guid Id(string? value) + { + return Guid.TryParse(value, out Guid parsed) ? parsed : Guid.Empty; + } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/UtilityTrajectoryArtifact.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityTrajectoryArtifact.cs new file mode 100644 index 00000000..2b31b19e --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityTrajectoryArtifact.cs @@ -0,0 +1,168 @@ +using System.Text.Json; +using FiveStack.Utilities; + +namespace FiveStack.Entities.Practice; + +// What GET /utility/{id}/trajectory answers with. +// +// The artifact is deliberately the same top-level shape as the demo playback +// blob, so the flight path is nested under grenade_trajectories rather than +// being the document. Earlier, flatter spellings are still read: the API owns +// the contract, and a preview that silently draws nothing is the worst way to +// discover it moved. +public class UtilityTrajectoryArtifact +{ + public List path { get; set; } = new List(); + public SmokeVolume? smoke_volume { get; set; } + + public static UtilityTrajectoryArtifact Parse(byte[] body) + { + return Parse(PracticeJson.Text(body)); + } + + public static UtilityTrajectoryArtifact Parse(string body) + { + var artifact = new UtilityTrajectoryArtifact(); + + using JsonDocument document = JsonDocument.Parse(body); + JsonElement root = document.RootElement; + + if (root.ValueKind == JsonValueKind.Array) + { + artifact.path = Points(root); + return artifact; + } + + if (root.ValueKind != JsonValueKind.Object) + { + return artifact; + } + + artifact.path = PathFrom(root); + artifact.smoke_volume = VolumeFrom(root); + + return artifact; + } + + private static List PathFrom(JsonElement root) + { + foreach (string property in new[] { "path", "trajectory", "points" }) + { + if (Array(root, property, out JsonElement flat)) + { + return Points(flat); + } + } + + if (!Array(root, "grenade_trajectories", out JsonElement trajectories)) + { + return new List(); + } + + foreach (JsonElement trajectory in trajectories.EnumerateArray()) + { + if (trajectory.ValueKind == JsonValueKind.Object && Array(trajectory, "points", out JsonElement points)) + { + return Points(points); + } + } + + return new List(); + } + + private static SmokeVolume? VolumeFrom(JsonElement root) + { + if ( + root.TryGetProperty("smoke_volume", out JsonElement single) + && single.ValueKind == JsonValueKind.Object + ) + { + return Volume(single); + } + + if (!Array(root, "smoke_volumes", out JsonElement volumes)) + { + return null; + } + + foreach (JsonElement volume in volumes.EnumerateArray()) + { + if (volume.ValueKind == JsonValueKind.Object) + { + return Volume(volume); + } + } + + return null; + } + + private static SmokeVolume? Volume(JsonElement element) + { + var volume = new SmokeVolume + { + ox = Number(element, "ox"), + oy = Number(element, "oy"), + oz = Number(element, "oz"), + vs = Number(element, "vs"), + dx = (int)Number(element, "dx"), + dy = (int)Number(element, "dy"), + dz = (int)Number(element, "dz"), + den = Text(element, "den"), + }; + + // A grid with no extent is not a measurement, and treating it as one + // draws a bloom outline collapsed onto a point. + if (volume.vs <= 0f || volume.dx <= 0 || volume.dy <= 0 || volume.dz <= 0) + { + return null; + } + + return volume; + } + + private static List Points(JsonElement array) + { + var points = new List(); + + foreach (JsonElement point in array.EnumerateArray()) + { + if (point.ValueKind != JsonValueKind.Object) + { + continue; + } + + points.Add( + new TrajectoryPoint + { + p = new Vec3(Number(point, "x"), Number(point, "y"), Number(point, "z")), + t = (int)Number(point, "tick"), + } + ); + } + + return points; + } + + private static bool Array(JsonElement element, string property, out JsonElement value) + { + return element.TryGetProperty(property, out value) + && value.ValueKind == JsonValueKind.Array; + } + + private static float Number(JsonElement element, string property) + { + return element.TryGetProperty(property, out JsonElement value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetDouble(out double parsed) + ? (float)parsed + : 0f; + } + + private static string? Text(JsonElement element, string property) + { + return element.TryGetProperty(property, out JsonElement value) + && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/Vec3.cs b/shared/dotnet/FiveStack.Entities/Practice/Vec3.cs new file mode 100644 index 00000000..432edf7c --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/Vec3.cs @@ -0,0 +1,55 @@ +namespace FiveStack.Entities.Practice; + +// Shared code is compiled into every plugin app and must not reference either +// game framework, so positions cross this boundary as plain floats rather than +// CounterStrikeSharp's Vector or Swiftly's. +public struct Vec3 +{ + public float x { get; set; } + public float y { get; set; } + public float z { get; set; } + + public Vec3(float x, float y, float z) + { + this.x = x; + this.y = y; + this.z = z; + } + + public float Length() + { + return MathF.Sqrt((x * x) + (y * y) + (z * z)); + } + + public float LengthXY() + { + return MathF.Sqrt((x * x) + (y * y)); + } + + public float Dot(Vec3 other) + { + return (x * other.x) + (y * other.y) + (z * other.z); + } + + public Vec3 Normalized() + { + float length = Length(); + + return length <= float.Epsilon ? new Vec3(0f, 0f, 0f) : this * (1f / length); + } + + public static Vec3 operator -(Vec3 a, Vec3 b) + { + return new Vec3(a.x - b.x, a.y - b.y, a.z - b.z); + } + + public static Vec3 operator +(Vec3 a, Vec3 b) + { + return new Vec3(a.x + b.x, a.y + b.y, a.z + b.z); + } + + public static Vec3 operator *(Vec3 a, float scale) + { + return new Vec3(a.x * scale, a.y * scale, a.z * scale); + } +} diff --git a/shared/dotnet/FiveStack.Enums/eCalibrationStatus.cs b/shared/dotnet/FiveStack.Enums/eCalibrationStatus.cs new file mode 100644 index 00000000..e286bf61 --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eCalibrationStatus.cs @@ -0,0 +1,34 @@ +namespace FiveStack.Enums; + +// Whether the solver is allowed to run on this map. +// +// The solver assumes the engine, handed a recorded throw's physics seed, will +// reproduce that throw. Nothing else in the plugin depends on that being true, +// so it is checked once per map before the first solve rather than assumed. +public enum eCalibrationStatus +{ + // No verdict yet: either nothing has been attempted on this map, or the + // launch model agreed and the live seed replay has not run. + Unknown, + + // Nobody has thrown a grenade this session and the library has nothing + // with a seed, so there is nothing to check against. + NoSample, + + // The launch model does not reproduce the seed the engine recorded for a + // real throw. Solving would still land grenades, but the aim it reported + // back would be wrong, which is the failure a player cannot detect. + LaunchModelMismatch, + + // The re-emitted grenade did not land where the original one did. The + // premise of the whole solver is false on this build. + SeedReplayMismatch, + + // The re-emitted grenade never reported a landing at all. + SeedReplayTimedOut, + + // This runtime has no way to emit a grenade. + Unsupported, + + Ready, +} diff --git a/shared/dotnet/FiveStack.Enums/eDrillEnd.cs b/shared/dotnet/FiveStack.Enums/eDrillEnd.cs new file mode 100644 index 00000000..4b600582 --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eDrillEnd.cs @@ -0,0 +1,24 @@ +namespace FiveStack.Enums; + +// How a run ended. The two failures are said out loud rather than left as a +// drill that quietly stops advancing. +public enum eDrillEnd +{ + Running, + + // Every lineup in the queue was thrown, skipped or dropped. + Completed, + + // The player asked it to stop. + Stopped, + + // The panel stopped answering, so throws stopped being scored. + Unscorable, + + // Lineup after lineup could not be stood on. + Unloadable, + + // The player left or the map changed. Nobody is there to be told, so this + // one is never summarised. + Abandoned, +} diff --git a/shared/dotnet/FiveStack.Enums/eDrillOrder.cs b/shared/dotnet/FiveStack.Enums/eDrillOrder.cs new file mode 100644 index 00000000..4e89def0 --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eDrillOrder.cs @@ -0,0 +1,13 @@ +namespace FiveStack.Enums; + +// The order a drill hands out lineups. +public enum eDrillOrder +{ + // A shuffled pass through the book, which is the honest default: at the + // start of a session the panel's progress says nothing, so ordering by it + // would only mean "alphabetical" while pretending to mean something. + Random, + + // The ones the panel says are going worst, first. + Worst, +} diff --git a/shared/dotnet/FiveStack.Enums/eDrillStart.cs b/shared/dotnet/FiveStack.Enums/eDrillStart.cs new file mode 100644 index 00000000..2efbc4b7 --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eDrillStart.cs @@ -0,0 +1,22 @@ +namespace FiveStack.Enums; + +// Why a drill did or did not start. Every refusal is a named one: a .drill that +// does nothing and says nothing reads as a broken command. +public enum eDrillStart +{ + Started, + + // This player is already in a run. + AlreadyRunning, + + // The server does not allow a lineup to teleport anybody, so there is no + // drill to run. + ReplayDisabled, + + // No panel, so no scoring, so no run: the whole point of a drill is the + // number at the end. + NotConnected, + + // The library is empty, or nothing in it can be drilled. + NothingToDrill, +} diff --git a/shared/dotnet/FiveStack.Enums/eLineupVisibility.cs b/shared/dotnet/FiveStack.Enums/eLineupVisibility.cs new file mode 100644 index 00000000..c9adc85a --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eLineupVisibility.cs @@ -0,0 +1,9 @@ +namespace FiveStack.Enums; + +// Mirrors public.e_utility_visibility. +public enum eLineupVisibility +{ + Private, + Team, + Public, +} diff --git a/shared/dotnet/FiveStack.Enums/ePracticeConnect.cs b/shared/dotnet/FiveStack.Enums/ePracticeConnect.cs new file mode 100644 index 00000000..d7f44f7f --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/ePracticeConnect.cs @@ -0,0 +1,17 @@ +namespace FiveStack.Enums; + +// What a practice server's connect hook should do with a joining client. The +// decision is taken before any engine state is touched, so it can be tested. +public enum ePracticeConnect +{ + // Known client: swap the password parameter for the server's own so the + // engine's check passes. + Authorized, + + // Unknown, but not provably wrong: leave the connect alone and let the + // engine check the password it was given. + PasswordCheck, + + // Blank the auth ticket so the connect fails. + Reject, +} diff --git a/shared/dotnet/FiveStack.Enums/eSolveOutcome.cs b/shared/dotnet/FiveStack.Enums/eSolveOutcome.cs new file mode 100644 index 00000000..e0a1de83 --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eSolveOutcome.cs @@ -0,0 +1,30 @@ +namespace FiveStack.Enums; + +// How a solve ended. Everything except Converged is a failure that has to be +// said out loud: a solver that quietly returns its best miss is a solver that +// saves lineups nobody can throw. +public enum eSolveOutcome +{ + Running, + + // A candidate landed inside the requested tolerance. + Converged, + + // A whole refinement pass moved the best landing less than it costs to + // keep going. The map geometry does not admit a throw to that point from + // there, or not one this search can find. + NoProgress, + + // Ran out of grenades before converging. + GrenadeCap, + + // Ran out of wall clock before converging. + TimedOut, + + // Nothing was worth throwing: no cleared strength, or the target is the + // throwing position. + NoCandidates, + + // Refused before a single grenade was emitted. + Refused, +} diff --git a/shared/dotnet/FiveStack.Enums/eThrowStrength.cs b/shared/dotnet/FiveStack.Enums/eThrowStrength.cs new file mode 100644 index 00000000..06b8a766 --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eThrowStrength.cs @@ -0,0 +1,10 @@ +namespace FiveStack.Enums; + +// Mirrors public.e_utility_throw_strengths. CS2 has exactly three release +// strengths: left click, both buttons, right click. +public enum eThrowStrength +{ + Full, + Half, + Drop, +} diff --git a/shared/dotnet/FiveStack.Enums/eThrowTechnique.cs b/shared/dotnet/FiveStack.Enums/eThrowTechnique.cs new file mode 100644 index 00000000..eab2335a --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eThrowTechnique.cs @@ -0,0 +1,16 @@ +namespace FiveStack.Enums; + +// Mirrors public.e_utility_techniques. Movement and stance are one value rather +// than two flags because that is how a player thinks about reproducing a +// lineup: "running jump throw" is a single instruction. +public enum eThrowTechnique +{ + Stationary, + Walking, + Running, + Crouch, + Jump, + RunJump, + WalkJump, + CrouchJump, +} diff --git a/shared/dotnet/FiveStack.Enums/eUtilityType.cs b/shared/dotnet/FiveStack.Enums/eUtilityType.cs new file mode 100644 index 00000000..377af13a --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eUtilityType.cs @@ -0,0 +1,13 @@ +namespace FiveStack.Enums; + +// Mirrors public.e_utility_types. The names are the API's spelling, not the +// engine's: the demo parser emits "HE" for HighExplosive and anything that +// forgets to map it silently drops every HE lineup. +public enum eUtilityType +{ + Decoy, + HighExplosive, + Flash, + Molotov, + Smoke, +} diff --git a/apps/swiftly/src/FiveStack.Utilities/ConnectAuth.cs b/shared/dotnet/FiveStack.Utilities/ConnectAuth.cs similarity index 100% rename from apps/swiftly/src/FiveStack.Utilities/ConnectAuth.cs rename to shared/dotnet/FiveStack.Utilities/ConnectAuth.cs diff --git a/shared/dotnet/FiveStack.Utilities/DrillUtility.cs b/shared/dotnet/FiveStack.Utilities/DrillUtility.cs new file mode 100644 index 00000000..e1b9f94f --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/DrillUtility.cs @@ -0,0 +1,285 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; + +namespace FiveStack.Utilities; + +// The choosing half of a drill: what can be drilled, in what order, and how +// many of them make a run. Pure, so the runner in each plugin is only the part +// that teleports people and prints. +public static class DrillUtility +{ + public const int DefaultCount = 10; + + // A run is meant to end. Nobody finishes two hundred throws in one sitting, + // and a queue that long is a way to leave a practice server teleporting + // somebody all afternoon. + public const int MaxCount = 50; + + // How long a throw has to come back scored before the run writes it off. + // The panel's own request timeout is ten seconds and a grenade can be in + // the air for five, so anything shorter calls a slow answer a lost one. + public const int ScoreWaitSeconds = 20; + + // Consecutive throws the panel did not answer before the run gives up. + // Scoring is the drill; standing somebody on lineup after lineup that never + // resolves is worse than telling them it cannot be scored right now. + public const int MaxUnscoredInARow = 3; + + // Consecutive lineups that could not be stood on before the run gives up. + public const int MaxUnloadableInARow = 3; + + // A lineup nobody has thrown yet sorts as though it were half landed: more + // worth drilling than one that never misses, less than one that keeps + // missing. + public const float UnattemptedPriority = 0.5f; + + // Above every rate, so a mastered lineup is always the last thing a + // worst-first run reaches for. + private const float MasteredPriority = 2f; + + // Scoring is what makes a throw a drill attempt, and the panel scores by + // lineup id, so a lineup it has never seen cannot be drilled. The two + // positions are the other half: a row that arrived without an origin or a + // landing point cannot be stood on or measured against. + public static bool IsDrillable(LineupRecord? lineup) + { + return lineup != null + && !string.IsNullOrEmpty(lineup.id) + && lineup.release.feet_position.Length() > 0f + && lineup.detonation_position.Length() > 0f; + } + + public static List Drillable(IEnumerable? lineups) + { + return lineups == null ? new List() : lineups.Where(IsDrillable).ToList(); + } + + public static string Name(LineupRecord lineup) + { + return string.IsNullOrWhiteSpace(lineup.name) ? lineup.utility_type : lineup.name; + } + + public static float Priority(DrillProgress? progress) + { + if (progress == null || progress.Attempts == 0) + { + return UnattemptedPriority; + } + + return progress.Mastered ? MasteredPriority : progress.Rate; + } + + // Worst first, and among equally bad ones the one with the most evidence + // behind it: 0/10 is a more certain problem than 0/1. + public static List WorstFirst( + IEnumerable lineups, + Func progressFor + ) + { + return lineups + .OrderBy(lineup => Priority(progressFor(lineup))) + .ThenByDescending(lineup => progressFor(lineup)?.Attempts ?? 0) + .ThenBy(lineup => Name(lineup), StringComparer.OrdinalIgnoreCase) + .ThenBy(lineup => lineup.client_id, StringComparer.Ordinal) + .ToList(); + } + + public static List Shuffled(IEnumerable lineups, Random random) + { + List shuffled = lineups.ToList(); + + for (int index = shuffled.Count - 1; index > 0; index--) + { + int swap = random.Next(index + 1); + (shuffled[index], shuffled[swap]) = (shuffled[swap], shuffled[index]); + } + + return shuffled; + } + + // The run, in the order it will be handed out. A book shorter than the run + // is drilled in whole passes rather than by picking each throw on its own, + // so nothing comes round twice before everything has come round once. + public static List Queue( + IEnumerable? library, + int count, + eDrillOrder order, + Func progressFor, + Random random + ) + { + List drillable = Drillable(library); + var queue = new List(); + + if (drillable.Count == 0) + { + return queue; + } + + count = Math.Clamp(count, 1, MaxCount); + + while (queue.Count < count) + { + List pass = + order == eDrillOrder.Worst + ? WorstFirst(drillable, progressFor) + : Shuffled(drillable, random); + + // The seam between two passes is the one place a shuffle can hand + // out the same lineup twice in a row. + if ( + queue.Count > 0 + && pass.Count > 1 + && string.Equals(pass[0].client_id, queue[^1].client_id, StringComparison.Ordinal) + ) + { + (pass[0], pass[1]) = (pass[1], pass[0]); + } + + foreach (LineupRecord lineup in pass) + { + if (queue.Count >= count) + { + break; + } + + queue.Add(lineup); + } + } + + return queue; + } + + // ".drill", ".drill 20", ".drill worst", ".drill 20 worst", ".drill stop". + // Order and count are read wherever they appear rather than by position: a + // player typing this into chat mid-round is not consulting a usage line. + public static DrillRequest Parse(string? argument) + { + var request = new DrillRequest(); + + string[] tokens = (argument ?? "") + .Trim() + .Trim('"') + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (string token in tokens) + { + if ( + token.Equals("stop", StringComparison.OrdinalIgnoreCase) + || token.Equals("end", StringComparison.OrdinalIgnoreCase) + ) + { + request.Stop = true; + continue; + } + + if (token.Equals("worst", StringComparison.OrdinalIgnoreCase)) + { + request.Order = eDrillOrder.Worst; + continue; + } + + if (token.Equals("random", StringComparison.OrdinalIgnoreCase)) + { + request.Order = eDrillOrder.Random; + continue; + } + + if (int.TryParse(token, out int count) && count > 0) + { + request.Count = Math.Min(count, MaxCount); + continue; + } + + request.Valid = false; + } + + return request; + } +} + +// What a player asked .drill for. +public class DrillRequest +{ + public bool Stop { get; set; } + public bool Valid { get; set; } = true; + public eDrillOrder Order { get; set; } = eDrillOrder.Random; + public int Count { get; set; } = DrillUtility.DefaultCount; +} + +// What the panel has said about one player and one lineup. Nothing here is +// counted locally: these are the panel's own totals, which is why a result +// replaces them rather than adding to them. +public class DrillProgress +{ + public int Attempts { get; set; } + public int Successes { get; set; } + public int BestStreak { get; set; } + public bool Mastered { get; set; } + + public float Rate => Attempts == 0 ? 0f : (float)Successes / Attempts; + + public static DrillProgress From(UtilityPracticeResult result) + { + return new DrillProgress + { + Attempts = result.attempts, + Successes = result.successes, + BestStreak = result.best_streak, + Mastered = result.mastered_at != null, + }; + } +} + +// Everything the panel has told this server about how people are doing, kept +// per steam id so two players drilling the same lineup never read each other's +// numbers. +public class DrillProgressBook +{ + private readonly Dictionary> _progress = + new Dictionary>(); + + public void Record(ulong steamId, string? lineupId, UtilityPracticeResult? result) + { + if (result == null || string.IsNullOrEmpty(lineupId)) + { + return; + } + + if (!_progress.TryGetValue(steamId, out Dictionary? lineups)) + { + lineups = new Dictionary(StringComparer.Ordinal); + _progress[steamId] = lineups; + } + + lineups[lineupId] = DrillProgress.From(result); + } + + public DrillProgress? For(ulong steamId, string? lineupId) + { + if ( + string.IsNullOrEmpty(lineupId) + || !_progress.TryGetValue(steamId, out Dictionary? lineups) + ) + { + return null; + } + + return lineups.TryGetValue(lineupId, out DrillProgress? progress) ? progress : null; + } + + public Func Lookup(ulong steamId) + { + return lineup => For(steamId, lineup.id); + } + + public void Forget(ulong steamId) + { + _progress.Remove(steamId); + } + + public void Clear() + { + _progress.Clear(); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PlaybookUtility.cs b/shared/dotnet/FiveStack.Utilities/PlaybookUtility.cs new file mode 100644 index 00000000..977f3cb5 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PlaybookUtility.cs @@ -0,0 +1,65 @@ +using FiveStack.Entities.Practice; + +namespace FiveStack.Utilities; + +// The scheduling half of an execute: which steps are due, who they belong to, +// and how long the whole thing runs. Pure, so the runner in each plugin is only +// the part that talks to players. +public static class PlaybookUtility +{ + // The panel caps a book at this too. A book that arrived longer than its + // own contract is a bug somewhere upstream, and running it anyway is how a + // practice server ends up teleporting people for ten minutes. + public const int MaxSteps = 32; + + // How long the execute stays live after its last throw, so a step that + // lands late is still part of the same run. + public const int TailMs = 3000; + + public static List Ordered(UtilityPlaybook? playbook) + { + if (playbook == null) + { + return new List(); + } + + return playbook + .steps.Where(step => step.lineup != null) + .OrderBy(step => step.step_order) + .ThenBy(step => step.offset_ms) + .Take(MaxSteps) + .ToList(); + } + + // Half open on purpose: the cursor a caller keeps is the last elapsed time + // it has already fired, so passing -1 first fires a step at offset zero + // exactly once. + public static List Due( + IReadOnlyList steps, + int afterMs, + int throughMs + ) + { + return steps + .Where(step => step.offset_ms > afterMs && step.offset_ms <= throughMs) + .ToList(); + } + + public static int DurationMs(IReadOnlyList steps) + { + return steps.Count == 0 ? 0 : steps.Max(step => step.offset_ms); + } + + public static bool IsAssigned(UtilityPlaybookStep step) + { + return !string.IsNullOrWhiteSpace(step.assigned_steam_id); + } + + // An unassigned step belongs to whoever is standing there, so it prompts + // everyone rather than nobody. + public static bool IsFor(UtilityPlaybookStep step, ulong steamId) + { + return !IsAssigned(step) + || step.assigned_steam_id!.Trim() == steamId.ToString(); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeCalibrationUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeCalibrationUtility.cs new file mode 100644 index 00000000..aba7d9dc --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeCalibrationUtility.cs @@ -0,0 +1,275 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; + +namespace FiveStack.Utilities; + +// The gate the whole solver stands on. +// +// A solve fires hundreds of real grenades and reports back an aim. Two things +// have to be true for that to be worth anything, and neither has ever been +// observed on a live server: +// +// 1. handing the engine a recorded throw's seed reproduces that throw, so a +// solved seed is a lineup somebody can replay; +// 2. PracticeLaunchUtility turns an aim into that seed the same way the game +// does, so the aim a solve reports is the aim that produces the throw. +// +// Both are checked here against throws the engine itself recorded, before a +// single solver grenade is emitted. Getting this wrong in the other direction +// -- assuming and proceeding -- produces a library full of lineups that land +// somewhere plausible and cannot be thrown, which is worse than no solver. +// +// Check 2 is arithmetic over throws already in hand and costs nothing. Check 1 +// needs one live grenade and is driven by the framework shell. +public static class PracticeCalibrationUtility +{ + // Only stationary throws are used. The launch model adds the thrower's own + // velocity, and for a moving thrower the snapshot and the engine's read of + // that velocity are a tick apart -- which shows up as model error that is + // really sampling error. A solve always throws from a standstill, so + // standing throws are also the exact regime being licensed. + public const int MaxSamples = 8; + + // The projectile spawns a fixed offset from the eye. Three units covers + // float noise on a 16 unit offset and nothing else; a wrong offset or a + // wrong eye height misses by far more. + public const float MaxPositionError = 3f; + + // The pitch remap is the part a player feels: aim reported a degree out is + // a lineup that misses by metres at range. A correct remap reproduces the + // direction to well under a tenth of a degree. + public const float MaxDirectionError = 1.5f; + + // Release speed is allowed to be systematically off, because a constant + // being slightly wrong is absorbed by carrying the measured ratio forward. + // Outside this band the formula is not slightly wrong, it is wrong. + public const float MinSpeedRatio = 0.5f; + public const float MaxSpeedRatio = 1.5f; + + // How far the re-emitted grenade may land from where the original one did. + // + // If the premise holds this should be near zero: same engine, same mesh, + // same seed. It is not set to zero because a landing point is read at a + // tick boundary from a projectile that is still settling, and because a + // grenade at rest still creeps a unit or two. Twelve units is comfortably + // above that noise and far below a wrong bounce, which throws a grenade + // hundreds of units away rather than tens. Nothing lands 12 units out by + // accident and 200 units out by the same cause. + public const float SeedReplayTolerance = 12f; + + // A throw the engine described in full: it wrote the seed, and the plugin + // caught the release edge, so both halves of the comparison are real. + public static bool IsUsableSample(LineupRecord lineup) + { + return lineup.HasPhysicsSeed() + && lineup.release.eye_position.Length() > 0f + && lineup.release.on_ground + && !lineup.release.jump_throw + && lineup.release.speed <= TrajectoryUtility.StationarySpeed + && lineup.detonation_position.Length() > 0f; + } + + public static List Samples( + IEnumerable lineups, + int maxSamples = MaxSamples + ) + { + var usable = new List(); + + // Newest first: a throw from this session is on the map and the build + // the solve is about to run against. + foreach (LineupRecord lineup in lineups.Reverse()) + { + if (!IsUsableSample(lineup)) + { + continue; + } + + usable.Add(lineup); + + if (usable.Count >= maxSamples) + { + break; + } + } + + return usable; + } + + public static LaunchCheck Check(LineupRecord sample) + { + LaunchSeed predicted = PracticeLaunchUtility.Predict(sample.release); + + float observedSpeed = sample.initial_velocity.Length(); + float speedRatio = predicted.speed <= float.Epsilon ? 0f : observedSpeed / predicted.speed; + + var check = new LaunchCheck + { + client_id = sample.client_id, + strength = TrajectoryUtility + .ClassifyStrength(sample.release.throw_strength_raw) + .ToString(), + pitch = sample.release.pitch, + position_error = (predicted.position - sample.initial_position).Length(), + direction_error = PracticeLaunchUtility.AngleBetween( + predicted.direction, + sample.initial_velocity + ), + speed_ratio = speedRatio, + }; + + check.passed = + check.position_error <= MaxPositionError + && check.direction_error <= MaxDirectionError + && check.speed_ratio >= MinSpeedRatio + && check.speed_ratio <= MaxSpeedRatio; + + return check; + } + + // Everything that can be decided without emitting anything. The result is + // never Ready: only a live seed replay can grant that. + public static CalibrationReport CheckLaunchModel( + string map, + IEnumerable lineups, + int maxSamples = MaxSamples + ) + { + var report = new CalibrationReport { map = map }; + List samples = Samples(lineups, maxSamples); + + if (samples.Count == 0) + { + report.status = nameof(eCalibrationStatus.NoSample); + report.message = + "no throw to calibrate against; stand still and throw one grenade, then try again"; + return report; + } + + var ratios = new Dictionary>(); + + foreach (LineupRecord sample in samples) + { + LaunchCheck check = Check(sample); + report.launch_checks.Add(check); + + if (!check.passed) + { + continue; + } + + if (!ratios.TryGetValue(check.strength, out List? bucket)) + { + bucket = new List(); + ratios[check.strength] = bucket; + } + + bucket.Add(check.speed_ratio); + } + + LaunchCheck? failed = report.launch_checks.FirstOrDefault(check => !check.passed); + + if (failed != null) + { + report.status = nameof(eCalibrationStatus.LaunchModelMismatch); + report.message = Explain(failed); + return report; + } + + foreach ((string strength, List bucket) in ratios) + { + report.speed_corrections[strength] = bucket.Average(); + } + + if (report.speed_corrections.Count == 0) + { + report.status = nameof(eCalibrationStatus.NoSample); + report.message = "no throw cleared the launch model"; + return report; + } + + report.message = "launch model agrees; seed replay not run yet"; + return report; + } + + // The throw to re-emit. Fewest bounces wins: a straight throw that lands in + // the open is the cleanest statement of "same seed, same landing", where a + // grenade that clipped three corners is testing the mesh as much as the + // premise. + public static LineupRecord? PickReplaySample(IEnumerable lineups) + { + return Samples(lineups, int.MaxValue) + .OrderBy(sample => sample.bounces) + .ThenByDescending(sample => sample.release.tick) + .FirstOrDefault(); + } + + public static CalibrationReport WithSeedReplay( + CalibrationReport report, + LineupRecord sample, + Vec3? observedLanding, + float tolerance = SeedReplayTolerance + ) + { + report.seed_replay_client_id = sample.client_id; + report.seed_replay_utility = sample.utility_type; + + if (observedLanding == null) + { + report.status = nameof(eCalibrationStatus.SeedReplayTimedOut); + report.message = + "the re-emitted grenade never reported a landing; the emit API did not produce a projectile this plugin can follow"; + return report; + } + + float error = (observedLanding.Value - sample.detonation_position).Length(); + report.seed_replay_error = error; + + if (error > tolerance) + { + report.status = nameof(eCalibrationStatus.SeedReplayMismatch); + report.message = + $"a re-emitted throw landed {error:0.0}u from where it originally did, over the {tolerance:0.0}u tolerance; the engine does not reproduce a seeded throw on this build, so no solve can be trusted"; + return report; + } + + report.status = nameof(eCalibrationStatus.Ready); + report.message = + $"seeded replay landed {error:0.0}u from the original; solving {string.Join(", ", report.SolvableStrengths())}"; + + return report; + } + + // CheckLaunchModel leaves a passing report undecided, because the verdict + // is not its to give: only the live seed replay can say Ready. + public static bool LaunchModelPassed(CalibrationReport report) + { + return report.status == nameof(eCalibrationStatus.Unknown) + && report.speed_corrections.Count > 0; + } + + public static CalibrationReport Unsupported(string map, string why) + { + return new CalibrationReport + { + map = map, + status = nameof(eCalibrationStatus.Unsupported), + message = why, + }; + } + + private static string Explain(LaunchCheck check) + { + if (check.position_error > MaxPositionError) + { + return $"the launch model puts the grenade {check.position_error:0.0}u from where the engine spawned it (limit {MaxPositionError:0.0}u); the eye offset or forward offset is wrong for this build"; + } + + if (check.direction_error > MaxDirectionError) + { + return $"the launch model throws {check.direction_error:0.00} degrees off the engine's own direction (limit {MaxDirectionError:0.00}); the pitch remap is wrong for this build, so any aim a solve reported would be wrong too"; + } + + return $"the launch model predicts a release speed {check.speed_ratio:0.000}x the engine's (allowed {MinSpeedRatio:0.0}-{MaxSpeedRatio:0.0}); the speed formula is wrong for this build"; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeConnectUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeConnectUtility.cs new file mode 100644 index 00000000..b7215624 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeConnectUtility.cs @@ -0,0 +1,123 @@ +using System.Security.Cryptography; +using System.Text; +using FiveStack.Entities.Practice; +using FiveStack.Enums; + +namespace FiveStack.Utilities; + +public class PracticeConnectDecision +{ + public ePracticeConnect action { get; set; } + public string? pending_role { get; set; } +} + +// The practice plugin never runs beside the match plugin, so it carries its own +// door policy. This is the whole of it, as a pure function of the cached +// session and the client's token, so both runtimes' hooks stay thin. +public static class PracticeConnectUtility +{ + public static PracticeConnectDecision Authorize( + PracticeSessionData? session, + ulong steamId, + string? token + ) + { + // Deny by default: an unloaded roster must not read as "everyone is + // welcome", so the engine's own password check stays the gate. + if (session == null) + { + return new PracticeConnectDecision { action = ePracticeConnect.PasswordCheck }; + } + + if (token == null) + { + return new PracticeConnectDecision { action = ePracticeConnect.Reject }; + } + + if (!string.IsNullOrEmpty(session.password) && token == session.password) + { + return new PracticeConnectDecision { action = ePracticeConnect.Authorized }; + } + + if (IsOnRoster(session, steamId)) + { + return new PracticeConnectDecision { action = ePracticeConnect.Authorized }; + } + + string[] parts = token.Split(':'); + + if (parts.Length != 3) + { + return new PracticeConnectDecision { action = ePracticeConnect.Reject }; + } + + string type = parts[0]; + string role = parts[1]; + + string expected = ConnectAuth.ComputeExpectedToken( + session.password, + type, + role, + steamId, + session.match_id + ); + + // Constant-time comparison so verifying the connect token does not leak + // the correct value through response timing. + bool matches = CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expected), + Encoding.UTF8.GetBytes(ConnectAuth.NormalizeClientToken(parts[2])) + ); + + if (!matches) + { + return new PracticeConnectDecision + { + action = type == "tv" ? ePracticeConnect.Reject : ePracticeConnect.PasswordCheck, + }; + } + + return new PracticeConnectDecision + { + action = ePracticeConnect.Authorized, + pending_role = PendingRole(type, role), + }; + } + + public static bool IsOnRoster(PracticeSessionData session, ulong steamId) + { + string id = steamId.ToString(); + + return session.allowed_steam_ids.Any(allowed => allowed.Trim() == id); + } + + private static string? PendingRole(string type, string role) + { + if (type != "game") + { + return null; + } + + ePlayerRoles playerRole = PlayerRoleUtility.PlayerRoleStringToEnum(role); + + if (playerRole == ePlayerRoles.Administrator) + { + return "admin"; + } + + if (playerRole == ePlayerRoles.Streamer) + { + return "streamer"; + } + + if ( + playerRole == ePlayerRoles.MatchOrganizer + || playerRole == ePlayerRoles.TournamentOrganizer + ) + { + return "organizer"; + } + + return null; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs b/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs new file mode 100644 index 00000000..d16ae7fa --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs @@ -0,0 +1,305 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; + +namespace FiveStack.Utilities; + +// One player's run: the queue, where they are in it, and what the panel said +// about each throw. +// +// A step is over when a throw has been scored, not when it has been thrown -- +// advancing on the throw itself would teleport somebody off a lineup before +// they were told they missed it. Everything that can leave a step unresolved +// has a named way out: the panel not answering, a lineup that cannot be stood +// on, and the player skipping it. +public class PracticeDrillRun +{ + private class Pending + { + public required string LineupId; + public required DateTime Deadline; + } + + private readonly List _queue; + private readonly List _missed = new List(); + private readonly List _skipped = new List(); + + private Pending? _pending; + private int _index = -1; + private int _unscoredInARow; + private int _unloadableInARow; + + // Reps are consecutive: a lineup is thrown until it is learned, then the + // run moves on. Interleaving them would make the drill a memory test of + // where the spots are rather than practice at hitting one. + public PracticeDrillRun(IReadOnlyList queue, int reps = 1) + { + _queue = queue.ToList(); + _reps = Math.Max(1, reps); + } + + private readonly int _reps; + private int _rep; + + public eDrillEnd Ending { get; private set; } = eDrillEnd.Running; + + public LineupRecord? Current { get; private set; } + + public int Length => _queue.Count; + + // One based, so it reads as "3/10" while a run is going. + public int Position => Math.Min(_index + 1, _queue.Count); + + // Which attempt at the current lineup this is, and how many it gets. + public int Rep => _rep + 1; + + public int Reps => _reps; + + public int Hits { get; private set; } + public int Misses { get; private set; } + public int Unscored { get; private set; } + public int Dropped { get; private set; } + public int Skipped => _skipped.Count; + public int Streak { get; private set; } + public int BestStreak { get; private set; } + + public bool Finished => Ending != eDrillEnd.Running; + + public bool Waiting => _pending != null; + + public int Attempts => Hits + Misses; + + // The next lineup to stand on, or null when there is nothing left. A step + // that was never resolved is abandoned here rather than carried forward: + // whatever the player does next belongs to the new step. + public LineupRecord? Next() + { + _pending = null; + + if (Finished) + { + return null; + } + + // Another go at the same lineup before moving on. + if (Current != null && _rep + 1 < _reps) + { + _rep++; + + return Current; + } + + _rep = 0; + _index++; + + if (_index >= _queue.Count) + { + Current = null; + Ending = eDrillEnd.Completed; + return null; + } + + Current = _queue[_index]; + + return Current; + } + + public void Loaded() + { + _unloadableInARow = 0; + } + + // The current lineup could not be stood on, so it is dropped rather than + // left as a step nobody can finish. A whole run of them is a broken library + // or a player who is no longer there, and either way the run is over. + public void CannotLoad() + { + Dropped++; + _unloadableInARow++; + + if (_unloadableInARow >= DrillUtility.MaxUnloadableInARow) + { + Ending = eDrillEnd.Unloadable; + } + } + + // A throw only becomes a drill attempt when it is the utility the current + // lineup asks for: throwing a flash while a smoke is loaded is a different + // throw, not a missed one. The second throw of a step is ignored as well, + // so a player who spams two smokes still owes the run one answer. + public bool Thrown(string utilityType, DateTime now) + { + if (Finished || Current == null || _pending != null) + { + return false; + } + + if ( + string.IsNullOrEmpty(Current.id) + || !string.Equals(Current.utility_type, utilityType, StringComparison.OrdinalIgnoreCase) + ) + { + return false; + } + + _pending = new Pending + { + LineupId = Current.id, + Deadline = now.AddSeconds(DrillUtility.ScoreWaitSeconds), + }; + + return true; + } + + // The panel has answered. A null result is "not scored", which is not the + // same as a miss and does not break a streak nobody has disproved. + public bool Score(string? lineupId, UtilityPracticeResult? result) + { + if ( + _pending == null + || !string.Equals(_pending.LineupId, lineupId, StringComparison.Ordinal) + ) + { + return false; + } + + _pending = null; + + if (result == null) + { + return Unscore(); + } + + _unscoredInARow = 0; + + if (result.success) + { + Hits++; + Streak++; + BestStreak = Math.Max(BestStreak, Streak); + + return true; + } + + Misses++; + Streak = 0; + + if (Current != null) + { + _missed.Add(DrillUtility.Name(Current)); + } + + return true; + } + + // Nothing came back for the throw. The run cannot wait on an answer that + // may never arrive, so the step resolves unscored. + public bool Expired(DateTime now) + { + if (_pending == null || now < _pending.Deadline) + { + return false; + } + + _pending = null; + + return Unscore(); + } + + // A skipped lineup is not a hit, so it breaks the run's streak; it is not a + // miss either, so it is counted apart from one. + public bool Skip() + { + if (Finished || Current == null) + { + return false; + } + + _pending = null; + _skipped.Add(DrillUtility.Name(Current)); + Streak = 0; + + return true; + } + + public void End(eDrillEnd ending) + { + if (!Finished) + { + Ending = ending; + } + } + + // What the run was for: the number at the end, and the lineups that put it + // there. + public List Summary() + { + var lines = new List + { + $"drill {Headline()} - {Hits}/{Attempts} hit, best streak {BestStreak}", + }; + + if (_missed.Count > 0) + { + lines.Add($"missed: {Grouped(_missed)}"); + } + + if (_skipped.Count > 0) + { + lines.Add($"skipped: {Grouped(_skipped)}"); + } + + if (Unscored > 0) + { + lines.Add( + $"{Unscored} {(Unscored == 1 ? "throw" : "throws")} could not be scored by the panel" + ); + } + + if (Dropped > 0) + { + lines.Add($"{Dropped} could not be loaded"); + } + + return lines; + } + + private string Headline() + { + switch (Ending) + { + case eDrillEnd.Stopped: + return "stopped"; + case eDrillEnd.Unscorable: + return "stopped, the panel is not scoring throws right now"; + case eDrillEnd.Unloadable: + return "stopped, those lineups could not be loaded"; + default: + return "over"; + } + } + + private bool Unscore() + { + Unscored++; + _unscoredInARow++; + + if (_unscoredInARow >= DrillUtility.MaxUnscoredInARow) + { + Ending = eDrillEnd.Unscorable; + } + + return true; + } + + private static string Grouped(IEnumerable names) + { + return string.Join( + ", ", + names + .GroupBy(name => name, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(group => group.Count()) + .ThenBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(group => group.Count() > 1 ? $"{group.Key} ({group.Count()})" : group.Key) + ); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeJson.cs b/shared/dotnet/FiveStack.Utilities/PracticeJson.cs new file mode 100644 index 00000000..3df016f4 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeJson.cs @@ -0,0 +1,37 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FiveStack.Utilities; + +// One set of options for everything the practice plugin exchanges with the +// panel. There is deliberately no custom converter here: the API owns the wire +// shapes, and the types in FiveStack.Entities.Practice that carry its spelling +// serialize as written. +public static class PracticeJson +{ + public static readonly JsonSerializerOptions Options = new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true, + }; + + // The trajectory artifact is stored gzipped and streamed back exactly as it + // is stored, so a response body is bytes and only sometimes text. + public static string Text(byte[] body) + { + if (body.Length < 2 || body[0] != 0x1F || body[1] != 0x8B) + { + return Encoding.UTF8.GetString(body); + } + + using var compressed = new MemoryStream(body); + using var gzip = new GZipStream(compressed, CompressionMode.Decompress); + using var plain = new MemoryStream(); + + gzip.CopyTo(plain); + + return Encoding.UTF8.GetString(plain.ToArray()); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLaunchUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLaunchUtility.cs new file mode 100644 index 00000000..f6dc886b --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeLaunchUtility.cs @@ -0,0 +1,189 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; + +namespace FiveStack.Utilities; + +// How an aim becomes a grenade. +// +// The solver models no physics at all -- a live practice server is the physics +// engine, and every candidate throw is a real grenade. This one mapping still +// has to exist, for two reasons the rest of the solver cannot avoid: +// +// * the emit API takes a velocity, and a solver candidate is an aim; +// * a solved lineup is worthless to a human unless the aim reported back is +// the aim that produces it. +// +// Every constant here is a claim about a CS2 build that nobody in this repo can +// check by reading it. So none of them are trusted: PracticeCalibrationUtility +// replays them against throws the engine itself recorded, and a solve does not +// start until they reproduce those throws. +public static class PracticeLaunchUtility +{ + // CS2 does not throw along the crosshair. It bends the aim down by ten + // degrees at the horizon and stretches the rest of the range to fit, which + // is why a grenade always leaves slightly above where you are looking. + public const float PitchRemapOffset = -10f; + public const float PitchRemapSlope = (90f + 10f) / 90f; + + // Release speed falls off linearly with the remapped pitch and saturates, + // so everything from roughly 22 degrees up throws at the same speed. + public const float SpeedPerDegree = 6f; + public const float MaxSpeed = 750f; + + // The projectile spawns ahead of the eye, not at it. + public const float ForwardOffset = 16f; + + // A moving player's own velocity is added to the release. The solver only + // ever throws from a standstill, so this term is exercised by calibration + // and not by a solve. + public const float PlayerVelocityScale = 1.25f; + + // A right click still throws, it just throws weakly. This is the shape of + // the curve between a right click and a left one; calibration measures the + // real value per strength bucket and the solver uses the measurement. + public const float MinStrengthScale = 0.2f; + + private const float DegreesToRadians = MathF.PI / 180f; + private const float RadiansToDegrees = 180f / MathF.PI; + + // Eye angles arrive normalized from both frameworks, but a lineup mined + // from a demo can carry the engine's unwrapped form. + public static float NormalizePitch(float pitch) + { + float wrapped = pitch % 360f; + + if (wrapped > 180f) + { + wrapped -= 360f; + } + else if (wrapped < -180f) + { + wrapped += 360f; + } + + return wrapped; + } + + public static float NormalizeYaw(float yaw) + { + return NormalizePitch(yaw); + } + + public static float RemapPitch(float pitch) + { + return PitchRemapOffset + (NormalizePitch(pitch) * PitchRemapSlope); + } + + // The inverse, for reading an aim back out of a seed the engine recorded. + public static float UnremapPitch(float remapped) + { + return (remapped - PitchRemapOffset) / PitchRemapSlope; + } + + // Source's convention: yaw counter-clockwise from +X, pitch positive when + // looking down. + public static Vec3 Forward(float pitch, float yaw) + { + float pitchRadians = pitch * DegreesToRadians; + float yawRadians = yaw * DegreesToRadians; + + float cosPitch = MathF.Cos(pitchRadians); + + return new Vec3( + cosPitch * MathF.Cos(yawRadians), + cosPitch * MathF.Sin(yawRadians), + -MathF.Sin(pitchRadians) + ); + } + + public static Vec3 ThrowDirection(float pitch, float yaw) + { + return Forward(RemapPitch(pitch), yaw); + } + + public static float BaseSpeed(float pitch) + { + return MathF.Min((90f - RemapPitch(pitch)) * SpeedPerDegree, MaxSpeed); + } + + public static float StrengthScale(float strength) + { + float clamped = Math.Clamp(strength, 0f, 1f); + + return MinStrengthScale + ((1f - MinStrengthScale) * clamped); + } + + // m_flThrowStrength for each of the three releases a player can actually + // make. The solver searches these and nothing between them: a value a human + // cannot produce is a lineup a human cannot throw. + public static float RawStrength(eThrowStrength strength) + { + switch (strength) + { + case eThrowStrength.Full: + return 1f; + case eThrowStrength.Half: + return 0.5f; + default: + return 0f; + } + } + + public static LaunchSeed Seed( + Vec3 eye, + float pitch, + float yaw, + float strength, + Vec3 playerVelocity, + float speedCorrection = 1f + ) + { + Vec3 direction = ThrowDirection(pitch, yaw); + float speed = BaseSpeed(pitch) * StrengthScale(strength) * speedCorrection; + + return new LaunchSeed + { + position = eye + (direction * ForwardOffset), + velocity = + (direction * speed) + (playerVelocity * PlayerVelocityScale), + direction = direction, + speed = speed, + }; + } + + // The seed a recorded throw should have had, so calibration can hold it up + // against the seed the engine actually wrote. + public static LaunchSeed Predict(ThrowSnapshot release, float speedCorrection = 1f) + { + return Seed( + release.eye_position, + release.pitch, + release.yaw, + release.throw_strength_raw, + release.velocity, + speedCorrection + ); + } + + public static float AngleBetween(Vec3 a, Vec3 b) + { + Vec3 first = a.Normalized(); + Vec3 second = b.Normalized(); + + if (first.Length() <= 0f || second.Length() <= 0f) + { + return 180f; + } + + return MathF.Acos(Math.Clamp(first.Dot(second), -1f, 1f)) * RadiansToDegrees; + } + + // Yaw from one point to another, which is where a solve's search window is + // centred. + public static float BearingTo(Vec3 from, Vec3 to) + { + Vec3 delta = to - from; + + return MathF.Atan2(delta.y, delta.x) * RadiansToDegrees; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs new file mode 100644 index 00000000..4952b877 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -0,0 +1,478 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; + +namespace FiveStack.Utilities; + +// Name tables and lookup helpers shared by both plugin runtimes. +public static class PracticeLineupUtility +{ + // designer name of the projectile entity -> e_utility_types value + private static readonly Dictionary ProjectileToUtility = + new() + { + { "smokegrenade_projectile", "Smoke" }, + { "flashbang_projectile", "Flash" }, + { "hegrenade_projectile", "HighExplosive" }, + { "molotov_projectile", "Molotov" }, + { "incendiarygrenade_projectile", "Molotov" }, + { "decoy_projectile", "Decoy" }, + }; + + // e_utility_types value -> the item a player is given to reproduce it + private static readonly Dictionary UtilityToWeapon = + new() + { + { "Smoke", "weapon_smokegrenade" }, + { "Flash", "weapon_flashbang" }, + { "HighExplosive", "weapon_hegrenade" }, + { "Molotov", "weapon_molotov" }, + { "Decoy", "weapon_decoy" }, + }; + + // e_utility_types value -> the world model shown floating over a lineup's + // stance, so a player can see WHAT to throw before walking to the spot. + private static readonly Dictionary UtilityToModel = + new() + { + { "Smoke", "weapons/models/grenade/smokegrenade/weapon_smokegrenade.vmdl" }, + { "Flash", "weapons/models/grenade/flashbang/weapon_flashbang.vmdl" }, + { "HighExplosive", "weapons/models/grenade/hegrenade/weapon_hegrenade.vmdl" }, + { "Molotov", "weapons/models/grenade/molotov/weapon_molotov.vmdl" }, + { "Decoy", "weapons/models/grenade/decoy/weapon_decoy.vmdl" }, + }; + + // Everything that has to be in the map's precache list. A model the server + // did not precache renders as the ERROR model, and precache only runs at + // map load -- so this list has to be handed over from the precache hook, + // not at the moment something wants to draw one. + public static IEnumerable AllUtilityModels() + { + return UtilityToModel.Values; + } + + // Learned from any projectile that actually flies, because the engine's own + // answer beats a guess: CS2 renamed these out of CS:GO's + // models/weapons/w_eq_* scheme, and a path wrong by one character renders + // as the ERROR model with no other complaint. The table above is what gets + // precached, so it is what a map starts with. + private static readonly Dictionary LearnedModels = new(); + + public static void LearnUtilityModel(string utilityType, string? model) + { + if (string.IsNullOrEmpty(model) || !model.EndsWith(".vmdl")) + { + return; + } + + LearnedModels[utilityType] = model; + } + + public static string? ModelForUtilityType(string utilityType) + { + if (LearnedModels.TryGetValue(utilityType, out string? learned)) + { + return learned; + } + + return UtilityToModel.TryGetValue(utilityType, out string? model) + ? model + : null; + } + + private static readonly HashSet GrenadeWeapons = + new() + { + "weapon_smokegrenade", + "weapon_flashbang", + "weapon_hegrenade", + "weapon_molotov", + "weapon_incgrenade", + "weapon_decoy", + }; + + public static string? UtilityTypeForProjectile(string designerName) + { + return ProjectileToUtility.TryGetValue(designerName, out string? type) ? type : null; + } + + // Shortest way round the circle between two angles. + public static float AngleGap(float a, float b) + { + float gap = Math.Abs(a - b) % 360f; + + return gap > 180f ? 360f - gap : gap; + } + + // How wrong the crosshair is for this throw, in degrees: the worse of the + // two axes, because being dead on the yaw does not help if the pitch is off. + public static float AimError(float eyeYaw, float eyePitch, float yaw, float pitch) + { + return Math.Max(AngleGap(eyeYaw, yaw), AngleGap(eyePitch, pitch)); + } + + // 0 when the crosshair is inside the lineup's tolerance, 1 when it is a + // long way outside, and a ramp between the two. What the reticle's colour + // is a picture of: green means throw it, red means keep looking. + public static float AimMiss(float error, float tolerance) + { + if (tolerance <= 0f) + { + tolerance = DefaultAimTolerance; + } + + if (error <= tolerance) + { + return 0f; + } + + // Red at the point where the crosshair is nowhere near, not at some + // multiple of a tolerance that may itself be tiny -- otherwise a 0.1 + // degree lineup would read fully red one degree off, which is where + // almost every attempt starts. + float span = Math.Max(AimMissSpanDegrees - tolerance, 0.01f); + + return Math.Clamp((error - tolerance) / span, 0f, 1f); + } + + // The same green-to-red idea as the aim, for where the player's feet are. + // Position and angle are the two halves of a lineup and a player has no way + // to tell which one they have wrong, so both say so the same way. + public static float StanceMiss(float distance) + { + if (distance <= StanceToleranceUnits) + { + return 0f; + } + + return Math.Clamp( + (distance - StanceToleranceUnits) / (StanceMissSpanUnits - StanceToleranceUnits), + 0f, + 1f + ); + } + + // Close enough to stand. Deliberately far tighter than SpotRadius, which + // asks "are these the same place" -- this asks "are you ON it". + public const float StanceToleranceUnits = 8f; + + // Fully red this far from the recorded spot. + public const float StanceMissSpanUnits = 48f; + + // Which of the colour steps a miss lands on. Step 0 is reserved for a miss + // of exactly zero -- inside tolerance -- so "the crosshair is green" and + // "LINED UP" can never disagree: a naive miss*steps split would show full + // green from over a degree out while the text still said no. Everything + // outside tolerance ramps across the remaining steps. + public static int MissBucket(float miss, int buckets) + { + if (miss <= 0f) + { + return 0; + } + + return 1 + Math.Clamp((int)(miss * (buckets - 1)), 0, buckets - 2); + } + + // How a player is told to move. Every value of eThrowTechnique must appear + // here: the switch this replaced matched "Run" and "Walk" while the enum + // says Running and Walking, and omitted WalkJump entirely -- so three of + // eight techniques quietly instructed the player to STAND STILL for a + // throw that only lands while moving. + public static string TechniqueLabel(string? technique) + { + return technique switch + { + nameof(eThrowTechnique.Stationary) => "STAND STILL", + nameof(eThrowTechnique.Walking) => "WALK AND THROW", + nameof(eThrowTechnique.Running) => "RUN AND THROW", + nameof(eThrowTechnique.Crouch) => "CROUCH THROW", + nameof(eThrowTechnique.Jump) => "JUMP THROW", + nameof(eThrowTechnique.RunJump) => "RUN + JUMP THROW", + nameof(eThrowTechnique.WalkJump) => "WALK + JUMP THROW", + nameof(eThrowTechnique.CrouchJump) => "CROUCH + JUMP THROW", + _ => "STAND STILL", + }; + } + + public static string StrengthLabel(string? strength) + { + return strength switch + { + nameof(eThrowStrength.Half) => "LEFT + RIGHT CLICK", + nameof(eThrowStrength.Drop) => "RIGHT CLICK", + _ => "LEFT CLICK", + }; + } + + // Title Case, for the two panels that are read as prose rather than barked + // as instructions. Shouting is reserved for the step line, which is the + // only one telling the player to DO something -- a name and a technique + // are just labels, and labels in block capitals are harder to read, not + // more important. Only whitespace separates words, so "write-up" keeps its + // lower half rather than becoming "Write-Up". + public static string TitleCase(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return ""; + } + + string[] words = text.Split(' '); + + for (int index = 0; index < words.Length; index += 1) + { + string word = words[index]; + + if (word.Length == 0) + { + continue; + } + + words[index] = char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant(); + } + + return string.Join(" ", words); + } + + // Captions are set in mono uppercase on wide tracking, and neither text + // channel has letter-spacing, so the spacing goes into the string. + public static string Tracked(string text) + { + return string.Join(" ", text.ToUpperInvariant().ToCharArray()); + } + + // The same tracking, for the HTML panel. Markup collapses runs of + // whitespace, so the three spaces separating two words become one and the + // words run together -- letter spacing survives and word spacing does not. + public static string TrackedHtml(string text) + { + return Tracked(text).Replace(" ", " "); + } + + // A lineup that never said how precise it is. + public const float DefaultAimTolerance = 0.35f; + + // Fully red this far off the recorded angle. + public const float AimMissSpanDegrees = 6f; + + // Groups stance positions that are close enough to be the same place to + // stand, and reports the distinct kinds of grenade thrown from each. What + // floats over a spot answers "what do I bring here" -- so two smokes from + // one position are one smoke, and a smoke plus a flash are two. + public static List<(float x, float y, float z, List types)> UtilityBySpot( + IEnumerable<(float x, float y, float z, string utilityType)> throws, + float radius, + float height + ) + { + var spots = new List<(float x, float y, float z, List types)>(); + + foreach ((float x, float y, float z, string utilityType) throwFrom in throws) + { + List? types = null; + + foreach ((float x, float y, float z, List types) spot in spots) + { + float dx = spot.x - throwFrom.x; + float dy = spot.y - throwFrom.y; + + if ( + Math.Sqrt((dx * dx) + (dy * dy)) <= radius + && Math.Abs(spot.z - throwFrom.z) <= height + ) + { + types = spot.types; + break; + } + } + + if (types == null) + { + types = new List(); + spots.Add((throwFrom.x, throwFrom.y, throwFrom.z, types)); + } + + if (!types.Contains(throwFrom.utilityType)) + { + types.Add(throwFrom.utilityType); + } + } + + return spots; + } + + public static string? WeaponForUtilityType(string utilityType) + { + return UtilityToWeapon.TryGetValue(utilityType, out string? weapon) ? weapon : null; + } + + public static bool IsGrenadeWeapon(string designerName) + { + return GrenadeWeapons.Contains(designerName); + } + + // What the grenade in a player's hand would record as, so a command that + // takes no utility argument can read the intent off the loadout instead of + // guessing at a smoke. weapon_incgrenade has no entry in the table above -- + // it is the CT molotov, and it is a Molotov lineup either way. + public static string? UtilityTypeForWeapon(string designerName) + { + if (designerName == "weapon_incgrenade") + { + return "Molotov"; + } + + foreach ((string utilityType, string weapon) in UtilityToWeapon) + { + if (weapon == designerName) + { + return utilityType; + } + } + + return null; + } + + // The API's spelling is the one that counts, and the mismatch that matters + // is "HE": the demo parser emits it, the panel's enum does not have it, and + // a wrong value here stores a lineup nobody can find rather than failing. + private static readonly Dictionary UtilityTypeAliases = + new(StringComparer.OrdinalIgnoreCase) + { + { "HE", "HighExplosive" }, + { "HEGrenade", "HighExplosive" }, + { "HighExplosive", "HighExplosive" }, + { "Smoke", "Smoke" }, + { "SmokeGrenade", "Smoke" }, + { "Flash", "Flash" }, + { "Flashbang", "Flash" }, + { "Molotov", "Molotov" }, + { "Incendiary", "Molotov" }, + { "Decoy", "Decoy" }, + }; + + public static string NormalizeUtilityType(string utilityType) + { + return UtilityTypeAliases.TryGetValue(utilityType, out string? normalized) + ? normalized + : utilityType; + } + + // Everything a query could have meant, nearest first, so .next and .prev + // walk the same set the player was thinking of. Resolve picks one out of + // this; it does not narrow it further. + /// + /// Exact lookup, for a load the panel asked for rather than one a player + /// typed. `.load` matches names loosely because a human is guessing at one; + /// a panel already knows exactly which lineup it means, and picking a + /// near-miss there would stand somebody on the wrong throw without saying + /// so. + /// + /// Both keys are checked: a saved lineup is addressed by its panel `id`, + /// and a scratch throw sent for a test has only the plugin-side + /// `client_id`. + /// + public static LineupRecord? ById(IEnumerable lineups, string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return null; + } + + return lineups.FirstOrDefault(lineup => + string.Equals(lineup.id, id, StringComparison.OrdinalIgnoreCase) + || string.Equals(lineup.client_id, id, StringComparison.OrdinalIgnoreCase) + ); + } + + public static List Filter( + IEnumerable lineups, + string query, + Vec3? near = null + ) + { + IEnumerable matches = lineups; + + if (!string.IsNullOrWhiteSpace(query)) + { + matches = matches.Where(lineup => + lineup.name.Contains(query, StringComparison.OrdinalIgnoreCase) + ); + } + + if (near == null) + { + return matches.ToList(); + } + + Vec3 from = near.Value; + + return matches + .OrderBy(lineup => (lineup.release.feet_position - from).Length()) + .ToList(); + } + + // Exact name, then unique prefix, then nearest to the player. Resolution + // order matters: a player who typed the whole name should never be handed + // something else because they happen to be standing next to another lineup. + public static LineupRecord? Resolve( + IEnumerable lineups, + string query, + Vec3? near = null + ) + { + var candidates = lineups.ToList(); + if (candidates.Count == 0) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(query)) + { + LineupRecord? exact = candidates.FirstOrDefault(lineup => + string.Equals(lineup.name, query, StringComparison.OrdinalIgnoreCase) + ); + if (exact != null) + { + return exact; + } + + var prefixed = candidates + .Where(lineup => + lineup.name.StartsWith(query, StringComparison.OrdinalIgnoreCase) + ) + .ToList(); + if (prefixed.Count == 1) + { + return prefixed[0]; + } + + var contained = candidates + .Where(lineup => + lineup.name.Contains(query, StringComparison.OrdinalIgnoreCase) + ) + .ToList(); + if (contained.Count == 1) + { + return contained[0]; + } + + candidates = prefixed.Count > 0 ? prefixed : contained; + } + + if (candidates.Count == 0) + { + return null; + } + + if (near == null) + { + return candidates[0]; + } + + Vec3 from = near.Value; + return candidates + .OrderBy(lineup => (lineup.release.feet_position - from).Length()) + .First(); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeSignalUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeSignalUtility.cs new file mode 100644 index 00000000..6637cba7 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeSignalUtility.cs @@ -0,0 +1,86 @@ +using System.Globalization; +using FiveStack.Entities.Practice; + +namespace FiveStack.Utilities; + +// What the plugin says to a machine. +// +// Everything else the practice plugin prints is for a person and can be +// reworded. These two things cannot: an external clip recorder greps the server +// console for the detonation line and issues the toggle below to get itself out +// of frame. Both are contracts. Changing the shape of the line breaks a regex +// in another repo, and reading "off" as "toggle" turns an idempotent command +// into a coin flip. +public static class PracticeSignalUtility +{ + public const string Prefix = "[utility-practice]"; + + // A grenade the plugin emitted has gone off. Deliberately not raised for a + // grenade a player threw: that one is observable from the demo, from the + // game events, and from watching the server. A plugin-emitted one is the + // case nothing outside the plugin can see. + public const string GhostDetonated = "ghost_detonated"; + + private const string Absent = "-"; + + // ghost_detonated utility= lineup= lineup_id= + // steam= x= y= z= + // + // One line, no colour, no punctuation inside a value, invariant decimals, + // and every field always present with "-" standing in for an absent one, so + // a reader can key on names rather than positions. + public static string GhostDetonatedLine( + string utilityType, + Vec3 at, + string? clientId, + string? lineupId, + ulong steamId + ) + { + return string.Create( + CultureInfo.InvariantCulture, + $"{Prefix} {GhostDetonated} utility={Text(utilityType)} lineup={Text(clientId)} lineup_id={Text(lineupId)} steam={steamId} x={at.x:0.00} y={at.y:0.00} z={at.z:0.00}" + ); + } + + // "on" / "off" set; anything empty toggles. An external caller must be able + // to say what it wants rather than ask for the opposite of a state it + // cannot see. + public static bool TryParseToggle(string? argument, bool current, out bool value) + { + string trimmed = (argument ?? "").Trim().Trim('"'); + + if (trimmed.Length == 0) + { + value = !current; + return true; + } + + switch (trimmed.ToLowerInvariant()) + { + case "on": + case "1": + case "true": + case "yes": + value = true; + return true; + case "off": + case "0": + case "false": + case "no": + value = false; + return true; + default: + value = current; + return false; + } + } + + // A value with a space in it would break the key=value reading, and every + // field that can carry one is an identifier that never does. Replacing + // rather than quoting keeps the line trivially splittable. + private static string Text(string? value) + { + return string.IsNullOrWhiteSpace(value) ? Absent : value.Trim().Replace(' ', '_'); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeSolverPlan.cs b/shared/dotnet/FiveStack.Utilities/PracticeSolverPlan.cs new file mode 100644 index 00000000..eee276c0 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeSolverPlan.cs @@ -0,0 +1,289 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; + +namespace FiveStack.Utilities; + +// The solve, minus the server. +// +// Hand it a request, ask it for a batch, tell it where those grenades landed, +// ask it for the next batch. It decides when there is nothing left worth +// throwing. Every stop is a named one -- there is no path where this runs out +// of ideas quietly and hands back its best miss as though it were an answer. +public class PracticeSolverPlan +{ + private readonly SolveRequest _request; + private readonly Queue _pending = new Queue(); + private readonly List _observations = new List(); + private readonly HashSet _tried = new HashSet(); + + private eSolveOutcome _stop = eSolveOutcome.Running; + private float _step = PracticeSolverUtility.RefineStartStep; + private float _bestAtPassStart = float.MaxValue; + private int _refinePass; + private int _stalledPasses; + private int _thrown; + private int _batches; + private string _phase = "sweep"; + + public PracticeSolverPlan(SolveRequest request) + { + _request = PracticeSolverUtility.Defaults(request); + + if (_request.strengths.Count == 0) + { + _stop = eSolveOutcome.NoCandidates; + return; + } + + List sweep = PracticeSolverUtility.CoarseSweep(_request); + + // The sweep is truncated rather than allowed to eat the refinement's + // budget: a grid fine enough to land on the answer by itself is not + // what a sweep is for. + int budget = Math.Max( + _request.batch_size, + (int)(_request.max_grenades * PracticeSolverUtility.CoarseShare) + ); + + foreach (SolveCandidate candidate in sweep.Take(budget)) + { + Enqueue(candidate); + } + + if (_pending.Count == 0) + { + _stop = eSolveOutcome.NoCandidates; + } + } + + public SolveRequest Request => _request; + + public IReadOnlyList Observations => _observations; + + public int Thrown => _thrown; + + public int Batches => _batches; + + public string Phase => _phase; + + public SolveObservation? Best => + _observations + .Where(observation => observation.landed) + .OrderBy(observation => observation.distance) + .FirstOrDefault(); + + public bool Converged() + { + SolveObservation? best = Best; + + return best != null && best.distance <= _request.tolerance; + } + + public bool Expired(float elapsedSeconds) + { + return elapsedSeconds >= _request.max_seconds; + } + + // Empty means there is nothing more to throw; Outcome says why. + public List NextBatch() + { + if (_stop != eSolveOutcome.Running) + { + return new List(); + } + + // Checked here and not only between phases: a throw inside tolerance + // during the sweep is the answer, and finishing the grid to confirm it + // would spend a hundred grenades on a question already settled. + if (Converged()) + { + _stop = eSolveOutcome.Converged; + return new List(); + } + + if (_pending.Count == 0 && !Advance()) + { + return new List(); + } + + int remaining = _request.max_grenades - _thrown; + + if (remaining <= 0) + { + _stop = eSolveOutcome.GrenadeCap; + return new List(); + } + + int take = Math.Min(Math.Min(_request.batch_size, remaining), _pending.Count); + var batch = new List(take); + + for (int index = 0; index < take; index++) + { + batch.Add(_pending.Dequeue()); + } + + _thrown += batch.Count; + _batches++; + + return batch; + } + + public void Observe(SolveObservation observation) + { + _observations.Add(observation); + } + + public SolveResult Finish(float elapsedSeconds) + { + SolveObservation? best = Best; + eSolveOutcome outcome; + + if (best != null && best.distance <= _request.tolerance) + { + outcome = eSolveOutcome.Converged; + } + else if (Expired(elapsedSeconds)) + { + outcome = eSolveOutcome.TimedOut; + } + else if (_stop != eSolveOutcome.Running) + { + outcome = _stop; + } + else + { + outcome = eSolveOutcome.GrenadeCap; + } + + return new SolveResult + { + outcome = outcome.ToString(), + message = Describe(outcome, best), + best = best, + thrown = _thrown, + batches = _batches, + elapsed_seconds = elapsedSeconds, + }; + } + + public string Progress() + { + SolveObservation? best = Best; + string closest = best == null ? "nothing landed yet" : $"closest {best.distance:0}u"; + + return $"{_phase}: {_thrown}/{_request.max_grenades} thrown, {closest}"; + } + + private bool Advance() + { + if (Converged()) + { + _stop = eSolveOutcome.Converged; + return false; + } + + if (_refinePass >= PracticeSolverUtility.RefinePasses) + { + _stop = eSolveOutcome.NoProgress; + return false; + } + + SolveObservation? best = Best; + + if (best == null) + { + _stop = eSolveOutcome.NoProgress; + return false; + } + + // Only counted once a refinement pass has actually run: the first pass + // is being compared against the sweep, which is a different question. + if (_refinePass > 0) + { + bool stalled = + _bestAtPassStart - best.distance < PracticeSolverUtility.MinProgress; + + _stalledPasses = stalled ? _stalledPasses + 1 : 0; + + if (_stalledPasses >= PracticeSolverUtility.MaxStallPasses) + { + _stop = eSolveOutcome.NoProgress; + return false; + } + } + + float separation = MathF.Max( + _step * 2f, + PracticeSolverUtility.MinSeparationDegrees + ); + + List seeds = PracticeSolverUtility.PickDistinct( + _observations, + PracticeSolverUtility.RefineSeeds, + separation + ); + + foreach (SolveObservation seed in seeds) + { + foreach ( + SolveCandidate neighbour in PracticeSolverUtility.Neighbours( + seed.candidate, + _step + ) + ) + { + Enqueue(neighbour); + } + } + + _bestAtPassStart = best.distance; + _refinePass++; + _phase = $"refine {_refinePass}"; + _step /= PracticeSolverUtility.RefineShrink; + + if (_pending.Count == 0) + { + _stop = eSolveOutcome.NoProgress; + return false; + } + + return true; + } + + // A refinement pass around neighbouring seeds proposes the same aim twice. + // Throwing it twice would spend the budget confirming what the engine + // already told us. + private void Enqueue(SolveCandidate candidate) + { + if (_tried.Add(PracticeSolverUtility.CandidateKey(candidate))) + { + _pending.Enqueue(candidate); + } + } + + private string Describe(eSolveOutcome outcome, SolveObservation? best) + { + switch (outcome) + { + case eSolveOutcome.Converged: + return $"landed {best!.distance:0.0}u from the target after {_thrown} grenades"; + case eSolveOutcome.NoCandidates: + return _request.strengths.Count == 0 + ? "no strength has been calibrated, so there is nothing safe to throw" + : "the target is too close to the throwing position to be a throw"; + case eSolveOutcome.TimedOut: + return $"gave up after {_request.max_seconds:0}s; {Closest(best)}"; + case eSolveOutcome.GrenadeCap: + return $"gave up after {_thrown} grenades; {Closest(best)}"; + default: + return $"refinement stopped improving; {Closest(best)}"; + } + } + + private static string Closest(SolveObservation? best) + { + return best == null + ? "no grenade reported a landing" + : $"the closest throw missed by {best.distance:0.0}u"; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeSolverUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeSolverUtility.cs new file mode 100644 index 00000000..86a29ad4 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeSolverUtility.cs @@ -0,0 +1,495 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; + +namespace FiveStack.Utilities; + +// Everything the solver decides, with no server attached: which throws to try, +// in what order, when to stop, and what a winning throw becomes. +// +// The search is a coarse sweep followed by local refinement around several +// separated near-misses, and it is that way because of what a grenade does. +// Landing point is not a smooth function of aim: a degree of pitch can be the +// difference between clearing a wall and bouncing off it, and either side of +// that edge the landing point jumps. Anything that follows a gradient walks +// straight into the nearest wall and stays there. A sweep finds the basins; the +// refinement finds the bottom of each one; keeping several means the basin that +// looked second-best after eight grenades can still win after eighty. +public static class PracticeSolverUtility +{ + public const float DefaultTolerance = 40f; + public const float MinTolerance = 8f; + public const float MaxTolerance = 256f; + + // A batch is grenades in flight at once. Twenty is a compromise measured in + // two directions: fewer and a solve is mostly waiting out flight times, + // more and the entity count plus the per-tick sampling start costing the + // server frames. + public const int DefaultBatchSize = 20; + public const int MaxBatchSize = 32; + + public const int DefaultMaxGrenades = 300; + public const int MaxGrenadeCap = 600; + + public const float DefaultMaxSeconds = 120f; + public const float MaxSecondsCap = 600f; + + // The sweep gets most of the budget and the refinement gets the rest. A + // sweep too small to find the right basin cannot be rescued by refining. + public const float CoarseShare = 0.6f; + + public static readonly float[] CoarseYawOffsets = + { + 0f, + -5f, + 5f, + -12f, + 12f, + -22f, + 22f, + -35f, + 35f, + }; + + public const float CoarsePitchFloor = -54f; + public const float CoarsePitchCeil = 18f; + public const float CoarsePitchStep = 9f; + + public const int RefineSeeds = 4; + public const int RefinePasses = 4; + public const float RefineStartStep = 4.5f; + public const float RefineShrink = 3f; + + // Two candidates closer than this are the same throw with noise on it, so + // refining both spends the budget twice on one basin. + public const float MinSeparationDegrees = 8f; + + // A refinement pass that moves the best landing less than this has learned + // nothing. + // + // One such pass is not a reason to stop: the neighbours of a seed exclude + // the seed itself, so a step that overshoots the answer can leave the best + // exactly where it was even though a smaller step is about to find it. Two + // in a row, with the step a third of the size the second time, is a search + // that has genuinely run out of room. + public const float MinProgress = 2f; + public const int MaxStallPasses = 2; + + // A target the thrower is standing on is not a throw. + public const float MinTargetDistance = 24f; + + private const float DuplicateEpsilon = 0.05f; + + public static SolveRequest Defaults(SolveRequest request) + { + request.tolerance = Math.Clamp( + request.tolerance <= 0f ? DefaultTolerance : request.tolerance, + MinTolerance, + MaxTolerance + ); + request.batch_size = Math.Clamp( + request.batch_size <= 0 ? DefaultBatchSize : request.batch_size, + 1, + MaxBatchSize + ); + request.max_grenades = Math.Clamp( + request.max_grenades <= 0 ? DefaultMaxGrenades : request.max_grenades, + request.batch_size, + MaxGrenadeCap + ); + request.max_seconds = Math.Clamp( + request.max_seconds <= 0f ? DefaultMaxSeconds : request.max_seconds, + 1f, + MaxSecondsCap + ); + + return request; + } + + // Ordered by how likely a throw is to be the answer, because the sweep is + // truncated to fit the budget and truncation should drop the least likely + // candidates rather than an arbitrary corner of the grid. + public static List CoarseSweep(SolveRequest request) + { + var candidates = new List(); + + if ((request.target - request.eye).Length() < MinTargetDistance) + { + return candidates; + } + + float bearing = PracticeLaunchUtility.BearingTo(request.eye, request.target); + float straight = StraightLinePitch(request.eye, request.target); + + foreach (string bucket in request.strengths) + { + if (!Enum.TryParse(bucket, out eThrowStrength strength)) + { + continue; + } + + float raw = PracticeLaunchUtility.RawStrength(strength); + + for (float pitch = CoarsePitchFloor; pitch <= CoarsePitchCeil; pitch += CoarsePitchStep) + { + foreach (float offset in CoarseYawOffsets) + { + candidates.Add( + new SolveCandidate + { + pitch = pitch, + yaw = PracticeLaunchUtility.NormalizeYaw(bearing + offset), + strength = raw, + strength_bucket = strength.ToString(), + } + ); + } + } + } + + return candidates + .OrderBy(candidate => + MathF.Abs( + PracticeLaunchUtility.NormalizeYaw(candidate.yaw - bearing) + ) + ) + .ThenBy(candidate => MathF.Abs(candidate.pitch - straight)) + .ThenByDescending(candidate => candidate.strength) + .ToList(); + } + + // Where the target sits relative to the eye, ignoring the arc. Only used to + // order the sweep: a grenade always has to be aimed above this, but how far + // above is what the sweep is for. + public static float StraightLinePitch(Vec3 eye, Vec3 target) + { + Vec3 delta = target - eye; + float flat = delta.LengthXY(); + + if (flat <= float.Epsilon) + { + return delta.z >= 0f ? -89f : 89f; + } + + return -MathF.Atan2(delta.z, flat) * (180f / MathF.PI); + } + + public static List Neighbours(SolveCandidate around, float step) + { + var neighbours = new List(); + + for (int pitchStep = -1; pitchStep <= 1; pitchStep++) + { + for (int yawStep = -1; yawStep <= 1; yawStep++) + { + if (pitchStep == 0 && yawStep == 0) + { + continue; + } + + neighbours.Add( + new SolveCandidate + { + pitch = Math.Clamp(around.pitch + (pitchStep * step), -89f, 89f), + yaw = PracticeLaunchUtility.NormalizeYaw(around.yaw + (yawStep * step)), + strength = around.strength, + strength_bucket = around.strength_bucket, + } + ); + } + } + + return neighbours; + } + + // Best first, but never two from the same basin. This is the whole reason + // the refinement survives a piecewise landing function. + public static List PickDistinct( + IEnumerable observations, + int count, + float separation + ) + { + var picked = new List(); + + foreach ( + SolveObservation observation in observations + .Where(observation => observation.landed) + .OrderBy(observation => observation.distance) + ) + { + if (picked.Count >= count) + { + break; + } + + bool crowded = picked.Any(chosen => + Separation(chosen.candidate, observation.candidate) < separation + ); + + if (!crowded) + { + picked.Add(observation); + } + } + + return picked; + } + + // Distance in aim space. Different strengths are never the same basin: the + // same aim thrown harder is a different throw, not a nearby one. + public static float Separation(SolveCandidate a, SolveCandidate b) + { + if (a.strength_bucket != b.strength_bucket) + { + return float.MaxValue; + } + + float yaw = PracticeLaunchUtility.NormalizeYaw(a.yaw - b.yaw); + + return MathF.Max(MathF.Abs(yaw), MathF.Abs(a.pitch - b.pitch)); + } + + public static string CandidateKey(SolveCandidate candidate) + { + return string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"{candidate.strength_bucket}:{MathF.Round(candidate.pitch / DuplicateEpsilon)}:{MathF.Round(PracticeLaunchUtility.NormalizeYaw(candidate.yaw) / DuplicateEpsilon)}" + ); + } + + // Whether a winning candidate, thrown once more with nothing else in the + // air, did the same thing again. + // + // A search grenade shares the sky with nineteen others and grenades bounce + // off each other, so a candidate deflected onto the target by a sibling + // looks exactly like the answer. Nothing downstream could tell the two + // apart: the lineup would simply not reproduce for whoever saved it. + public static bool Confirms(SolveObservation confirmation, SolveRequest request) + { + return confirmation.landed && confirmation.distance <= request.tolerance; + } + + public static LaunchSeed SeedFor( + SolveRequest request, + SolveCandidate candidate, + CalibrationReport calibration + ) + { + return PracticeLaunchUtility.Seed( + request.eye, + candidate.pitch, + candidate.yaw, + candidate.strength, + new Vec3(0f, 0f, 0f), + calibration.CorrectionFor(candidate.strength_bucket) + ); + } + + // The winning throw as a lineup. + // + // The seed is the point of it: an aim and a strength are what a human + // reproduces, but the seed is what makes the saved lineup replayable + // exactly, the same way a throw the plugin watched is. + public static LineupRecord ToLineup( + SolveRequest request, + SolveObservation best, + CalibrationReport calibration, + string pluginRuntime, + string pluginVersion + ) + { + LaunchSeed seed = SeedFor(request, best.candidate, calibration); + + var release = new ThrowSnapshot + { + feet_position = request.feet, + eye_position = request.eye, + pitch = best.candidate.pitch, + yaw = best.candidate.yaw, + velocity = new Vec3(0f, 0f, 0f), + speed = 0f, + on_ground = true, + ducked = false, + walking = false, + throw_strength_raw = best.candidate.strength, + jump_throw = false, + }; + + return new LineupRecord + { + client_id = Guid.NewGuid().ToString(), + map = request.map, + name = request.name, + utility_type = request.utility_type, + side = request.side, + visibility = nameof(eLineupVisibility.Private), + author_steam_id = request.requested_by, + release = release, + initial_position = seed.position, + initial_velocity = seed.velocity, + detonation_position = best.landing, + bounces = best.bounces, + technique = nameof(eThrowTechnique.Stationary), + strength = best.candidate.strength_bucket, + // The server threw this one and watched where it went, same as a + // player's own throw. Never sent: the panel stamps provenance. + confidence = LineupRecord.Exact, + recorded_tickrate = 64, + plugin_runtime = pluginRuntime, + plugin_version = pluginVersion, + }; + } + + // utility_solver_solve is driven over RCON, where a positional argument list is + // a silent misfire waiting to happen. Named arguments make a wrong call an + // error rather than a lineup for the wrong place. + public static bool TryParse( + IEnumerable args, + out SolveRequest request, + out string error + ) + { + request = new SolveRequest(); + error = ""; + + Vec3? target = null; + Vec3? from = null; + + foreach (string argument in args) + { + string trimmed = argument.Trim().Trim('"'); + + if (trimmed.Length == 0) + { + continue; + } + + int split = trimmed.IndexOf('='); + + if (split <= 0) + { + error = $"unexpected argument \"{trimmed}\"; every argument is key=value"; + return false; + } + + string key = trimmed.Substring(0, split).ToLowerInvariant(); + string value = trimmed.Substring(split + 1); + + switch (key) + { + case "target": + if (!TryVec3(value, out Vec3 parsedTarget)) + { + error = $"target must be x,y,z; got \"{value}\""; + return false; + } + target = parsedTarget; + break; + case "from": + if (!TryVec3(value, out Vec3 parsedFrom)) + { + error = $"from must be x,y,z; got \"{value}\""; + return false; + } + from = parsedFrom; + break; + case "utility": + request.utility_type = PracticeLineupUtility.NormalizeUtilityType(value); + break; + case "side": + request.side = value.ToUpperInvariant(); + break; + case "name": + request.name = value; + break; + case "steam": + request.requested_by = value; + break; + case "tolerance": + if (!TryFloat(value, out float tolerance)) + { + error = $"tolerance must be a number; got \"{value}\""; + return false; + } + request.tolerance = tolerance; + break; + case "grenades": + if (!int.TryParse(value, out int grenades)) + { + error = $"grenades must be a whole number; got \"{value}\""; + return false; + } + request.max_grenades = grenades; + break; + case "seconds": + if (!TryFloat(value, out float seconds)) + { + error = $"seconds must be a number; got \"{value}\""; + return false; + } + request.max_seconds = seconds; + break; + default: + error = $"unknown argument \"{key}\""; + return false; + } + } + + if (target == null) + { + error = "target=x,y,z is required"; + return false; + } + + request.target = target.Value; + + if (from != null) + { + request.feet = from.Value; + // A standing player's eyes; the caller gave a floor position. + request.eye = new Vec3(from.Value.x, from.Value.y, from.Value.z + StandingEyeHeight); + } + + Defaults(request); + + return true; + } + + // CS2's standing view offset. Only used when a caller supplies a throwing + // position by coordinate rather than by standing on it. + public const float StandingEyeHeight = 64f; + + private static bool TryVec3(string value, out Vec3 parsed) + { + parsed = new Vec3(0f, 0f, 0f); + + string[] parts = value.Split(',', StringSplitOptions.TrimEntries); + + if (parts.Length != 3) + { + return false; + } + + if ( + !TryFloat(parts[0], out float x) + || !TryFloat(parts[1], out float y) + || !TryFloat(parts[2], out float z) + ) + { + return false; + } + + parsed = new Vec3(x, y, z); + return true; + } + + private static bool TryFloat(string value, out float parsed) + { + return float.TryParse( + value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out parsed + ); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/SmokeVolumeUtility.cs b/shared/dotnet/FiveStack.Utilities/SmokeVolumeUtility.cs new file mode 100644 index 00000000..2a0e35f0 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/SmokeVolumeUtility.cs @@ -0,0 +1,488 @@ +using FiveStack.Entities.Practice; + +namespace FiveStack.Utilities; + +public class SmokeOutlineOptions +{ + // The hard entity budget. One beam per occupied voxel is thousands of + // entities for a single smoke, so the outline is contoured and decimated + // until it fits this rather than being allowed to grow to fit the shape. + public int MaxSegments { get; set; } = 48; + + // Horizontal contours through the bloom, low to high. Three reads as a + // shape; more reads as a scribble and costs the budget the rings need. + public int MaxLevels { get; set; } = 3; + + public int MinDensity { get; set; } = 1; + + // In grid cells. A voxel boundary is a staircase, and tracing it exactly + // costs one beam per step; a cell and a bit of slop turns a smoke's + // circumference into a dozen lines instead of a hundred. + public float Epsilon { get; set; } = 1.25f; + + public int MinLoopCells { get; set; } = 4; +} + +// Decoding and outlining of a measured smoke volume. Pure: both runtimes draw +// the result with their own entities, and neither the packing nor the contour +// tracing has anything to do with the game. +public static class SmokeVolumeUtility +{ + // A measured bloom is tens of thousands of cells. Anything past this is not + // a smoke, and allocating for it on the game thread is how a practice + // server stalls. + public const int MaxCells = 1 << 21; + + // den is two cells per byte, low nibble first, over dx*dy*dz cells in the + // order (k*dy + j)*dx + i. A den shorter than the grid is not an error: the + // cells it does not cover are clear. + public static byte[] Decode(SmokeVolume? volume) + { + if (volume == null || volume.dx <= 0 || volume.dy <= 0 || volume.dz <= 0) + { + return System.Array.Empty(); + } + + long count = (long)volume.dx * volume.dy * volume.dz; + + if (count > MaxCells) + { + return System.Array.Empty(); + } + + var density = new byte[count]; + + // No grid at all means the volume is only its bounding box, and the box + // is then the honest answer rather than an empty outline. + if (string.IsNullOrEmpty(volume.den)) + { + System.Array.Fill(density, (byte)15); + return density; + } + + byte[] packed; + + try + { + packed = Convert.FromBase64String(volume.den); + } + catch (FormatException) + { + return density; + } + + int cells = Math.Min((int)count, packed.Length * 2); + + for (int index = 0; index < cells; index++) + { + byte pair = packed[index >> 1]; + density[index] = (index & 1) == 0 ? (byte)(pair & 0x0F) : (byte)(pair >> 4); + } + + return density; + } + + public static byte Density(byte[] density, SmokeVolume volume, int i, int j, int k) + { + if ( + i < 0 + || j < 0 + || k < 0 + || i >= volume.dx + || j >= volume.dy + || k >= volume.dz + ) + { + return 0; + } + + int index = ((k * volume.dy) + j) * volume.dx + i; + + return index < density.Length ? density[index] : (byte)0; + } + + public static List Outline( + SmokeVolume? volume, + SmokeOutlineOptions? options = null + ) + { + var segments = new List(); + + if (volume == null) + { + return segments; + } + + options ??= new SmokeOutlineOptions(); + byte[] density = Decode(volume); + + if (density.Length == 0 || options.MaxSegments <= 0) + { + return segments; + } + + List levels = Levels(density, volume, options); + + if (levels.Count == 0) + { + return segments; + } + + var contours = new List>>(); + + foreach (int level in levels) + { + List> loops = Loops(density, volume, level, options); + + // Longest first, so a budget that only affords one ring per level + // spends it on the ring that describes the bloom. + loops.Sort((left, right) => right.Count.CompareTo(left.Count)); + contours.Add(loops); + } + + int deepest = contours.Max(loops => loops.Count); + + // Round robin rather than level by level: a budget spent entirely on the + // bottom contour would say nothing about how tall the smoke is. + for (int pass = 0; pass < deepest && segments.Count < options.MaxSegments; pass++) + { + for (int level = 0; level < contours.Count; level++) + { + if (pass >= contours[level].Count) + { + continue; + } + + List<(int x, int y)> loop = contours[level][pass]; + + if (segments.Count + loop.Count - 1 > options.MaxSegments) + { + continue; + } + + Draw(segments, loop, volume, levels[level]); + } + } + + return segments; + } + + private static void Draw( + List segments, + List<(int x, int y)> loop, + SmokeVolume volume, + int level + ) + { + float z = volume.oz + ((level + 0.5f) * volume.vs); + + for (int index = 0; index < loop.Count - 1; index++) + { + (int x, int y) from = loop[index]; + (int x, int y) to = loop[index + 1]; + + if (from == to) + { + continue; + } + + segments.Add( + new BloomSegment( + new Vec3(volume.ox + (from.x * volume.vs), volume.oy + (from.y * volume.vs), z), + new Vec3(volume.ox + (to.x * volume.vs), volume.oy + (to.y * volume.vs), z) + ) + ); + } + } + + // Evenly spaced through the occupied slices, offset by half a step so the + // lowest and highest rings are inside the bloom rather than on the one-cell + // caps at either end of it. + private static List Levels( + byte[] density, + SmokeVolume volume, + SmokeOutlineOptions options + ) + { + var levels = new List(); + int lowest = -1; + int highest = -1; + + for (int k = 0; k < volume.dz; k++) + { + if (!Occupied(density, volume, k, options.MinDensity)) + { + continue; + } + + if (lowest < 0) + { + lowest = k; + } + + highest = k; + } + + if (lowest < 0) + { + return levels; + } + + int span = highest - lowest + 1; + int count = Math.Clamp(options.MaxLevels, 1, span); + + for (int level = 0; level < count; level++) + { + int k = lowest + (int)((level + 0.5f) * span / count); + k = Math.Clamp(k, lowest, highest); + + if (!levels.Contains(k)) + { + levels.Add(k); + } + } + + return levels; + } + + private static bool Occupied( + byte[] density, + SmokeVolume volume, + int k, + int minDensity + ) + { + for (int j = 0; j < volume.dy; j++) + { + for (int i = 0; i < volume.dx; i++) + { + if (Density(density, volume, i, j, k) >= minDensity) + { + return true; + } + } + } + + return false; + } + + // Closed loops around the occupied cells of one slice, in grid corner + // coordinates, simplified. Every loop is returned with its first point + // repeated last, so a caller can draw it without closing it by hand. + private static List> Loops( + byte[] density, + SmokeVolume volume, + int k, + SmokeOutlineOptions options + ) + { + var edges = new List<((int x, int y) a, (int x, int y) b)>(); + + for (int j = 0; j < volume.dy; j++) + { + for (int i = 0; i < volume.dx; i++) + { + if (Density(density, volume, i, j, k) < options.MinDensity) + { + continue; + } + + // Counter-clockwise around the cell, so every loop keeps the + // smoke on its left and a hole inside the bloom comes out as + // its own loop rather than as a stray line. + if (Density(density, volume, i, j - 1, k) < options.MinDensity) + { + edges.Add(((i, j), (i + 1, j))); + } + + if (Density(density, volume, i + 1, j, k) < options.MinDensity) + { + edges.Add(((i + 1, j), (i + 1, j + 1))); + } + + if (Density(density, volume, i, j + 1, k) < options.MinDensity) + { + edges.Add(((i + 1, j + 1), (i, j + 1))); + } + + if (Density(density, volume, i - 1, j, k) < options.MinDensity) + { + edges.Add(((i, j + 1), (i, j))); + } + } + } + + return Trace(edges, options); + } + + private static List> Trace( + List<((int x, int y) a, (int x, int y) b)> edges, + SmokeOutlineOptions options + ) + { + var loops = new List>(); + var outgoing = new Dictionary<(int x, int y), List>(); + + for (int index = 0; index < edges.Count; index++) + { + if (!outgoing.TryGetValue(edges[index].a, out List? from)) + { + from = new List(); + outgoing[edges[index].a] = from; + } + + from.Add(index); + } + + var used = new bool[edges.Count]; + + for (int index = 0; index < edges.Count; index++) + { + if (used[index]) + { + continue; + } + + (int x, int y) start = edges[index].a; + var loop = new List<(int x, int y)> { start }; + int current = index; + + for (int guard = 0; guard <= edges.Count; guard++) + { + used[current] = true; + loop.Add(edges[current].b); + + if (edges[current].b == start) + { + break; + } + + int next = Next(edges, outgoing, used, current); + + if (next < 0) + { + break; + } + + current = next; + } + + List<(int x, int y)>? simplified = Simplify(loop, options); + + if (simplified != null) + { + loops.Add(simplified); + } + } + + return loops; + } + + // Straight ahead first, then the tightest right turn. At a corner where two + // cells only touch diagonally both continuations are legal, and turning + // splits the pinch into two loops instead of drawing a line through it. + private static int Next( + List<((int x, int y) a, (int x, int y) b)> edges, + Dictionary<(int x, int y), List> outgoing, + bool[] used, + int current + ) + { + if (!outgoing.TryGetValue(edges[current].b, out List? candidates)) + { + return -1; + } + + (int x, int y) direction = ( + edges[current].b.x - edges[current].a.x, + edges[current].b.y - edges[current].a.y + ); + (int x, int y) right = (direction.y, -direction.x); + + int straightMatch = -1; + int rightMatch = -1; + int any = -1; + + foreach (int candidate in candidates) + { + if (used[candidate]) + { + continue; + } + + (int x, int y) heading = ( + edges[candidate].b.x - edges[candidate].a.x, + edges[candidate].b.y - edges[candidate].a.y + ); + + if (heading == direction) + { + straightMatch = candidate; + } + else if (heading == right) + { + rightMatch = candidate; + } + else if (any < 0) + { + any = candidate; + } + } + + if (straightMatch >= 0) + { + return straightMatch; + } + + return rightMatch >= 0 ? rightMatch : any; + } + + // Null for a loop too small to be worth a beam: a single stray cell of + // density is measurement noise, not a shape a player can throw at. + private static List<(int x, int y)>? Simplify( + List<(int x, int y)> loop, + SmokeOutlineOptions options + ) + { + if (loop.Count < 4 || loop[0] != loop[^1]) + { + return null; + } + + if (Math.Abs(Area(loop)) < options.MinLoopCells) + { + return null; + } + + var points = loop.Select(point => new TrajectoryPoint + { + p = new Vec3(point.x, point.y, 0f), + }) + .ToList(); + + List simplified = TrajectoryUtility.Simplify(points, options.Epsilon); + + if (simplified.Count < 4) + { + return null; + } + + return simplified + .Select(point => ((int)MathF.Round(point.p.x), (int)MathF.Round(point.p.y))) + .ToList(); + } + + // Shoelace, in cells. The loop repeats its first point last, which the sum + // below relies on rather than closing the polygon itself. + private static float Area(List<(int x, int y)> loop) + { + float sum = 0f; + + for (int index = 0; index < loop.Count - 1; index++) + { + sum += (loop[index].x * loop[index + 1].y) - (loop[index + 1].x * loop[index].y); + } + + return sum / 2f; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/TrajectoryUtility.cs b/shared/dotnet/FiveStack.Utilities/TrajectoryUtility.cs new file mode 100644 index 00000000..ffcf0e8b --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/TrajectoryUtility.cs @@ -0,0 +1,203 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; + +namespace FiveStack.Utilities; + +// Pure maths shared by both plugin runtimes: classification of how a throw was +// made, and compaction of the sampled flight path. No engine types, so this is +// the part of the recorder that can actually be unit tested. +public static class TrajectoryUtility +{ + // CS2 release strengths are discrete. m_flThrowStrength reads ~1.0 for a + // left click, ~0.5 for both buttons and ~0.0 for a right click; the + // midpoints below are deliberately generous because the value is sampled a + // tick either side of release. + public const float FullStrengthFloor = 0.75f; + public const float HalfStrengthFloor = 0.25f; + + // Source movement speeds. Walking is capped at ~135 u/s on most weapons, + // and anything under a few units is standing still with float noise. + public const float StationarySpeed = 5f; + public const float WalkSpeed = 135f; + + // A hand-timed jump throw does not always set m_bJumpThrow, so an upward + // velocity at release counts too. + public const float JumpVelocityZ = 50f; + + public static eThrowStrength ClassifyStrength(float raw) + { + if (raw >= FullStrengthFloor) + { + return eThrowStrength.Full; + } + + if (raw >= HalfStrengthFloor) + { + return eThrowStrength.Half; + } + + return eThrowStrength.Drop; + } + + public static eThrowTechnique ClassifyTechnique(ThrowSnapshot release) + { + bool airborne = + release.jump_throw + || !release.on_ground + || release.velocity.z > JumpVelocityZ; + + if (airborne) + { + if (release.ducked) + { + return eThrowTechnique.CrouchJump; + } + + if (release.speed <= StationarySpeed) + { + return eThrowTechnique.Jump; + } + + return release.walking || release.speed <= WalkSpeed + ? eThrowTechnique.WalkJump + : eThrowTechnique.RunJump; + } + + if (release.ducked) + { + return eThrowTechnique.Crouch; + } + + if (release.speed <= StationarySpeed) + { + return eThrowTechnique.Stationary; + } + + return release.walking || release.speed <= WalkSpeed + ? eThrowTechnique.Walking + : eThrowTechnique.Running; + } + + // Yaw/pitch of a velocity vector, in the engine's convention: yaw measured + // counter-clockwise from +X, pitch negative when looking up. + public static (float pitch, float yaw) AnglesFromVelocity(Vec3 velocity) + { + float yaw = MathF.Atan2(velocity.y, velocity.x) * (180f / MathF.PI); + float length = velocity.Length(); + + if (length <= float.Epsilon) + { + return (0f, yaw); + } + + float pitch = -MathF.Asin(velocity.z / length) * (180f / MathF.PI); + return (pitch, yaw); + } + + // Ramer-Douglas-Peucker, with every bounce pinned as a vertex. A 20 second + // smoke samples ~640 points; this brings it to a few dozen without moving + // the line anywhere a viewer could see. + public static List Simplify( + List points, + float epsilon = 4f + ) + { + if (points.Count <= 2) + { + return new List(points); + } + + var keep = new bool[points.Count]; + keep[0] = true; + keep[points.Count - 1] = true; + + for (int i = 0; i < points.Count; i++) + { + if (points[i].bounce) + { + keep[i] = true; + } + } + + // Simplify each run between pinned vertices independently, so a bounce + // can never be smoothed away by a straight segment on either side. + var pinned = new List(); + for (int i = 0; i < points.Count; i++) + { + if (keep[i]) + { + pinned.Add(i); + } + } + + for (int segment = 0; segment < pinned.Count - 1; segment++) + { + SimplifySegment(points, pinned[segment], pinned[segment + 1], epsilon, keep); + } + + var result = new List(); + for (int i = 0; i < points.Count; i++) + { + if (keep[i]) + { + result.Add(points[i]); + } + } + + return result; + } + + private static void SimplifySegment( + List points, + int first, + int last, + float epsilon, + bool[] keep + ) + { + if (last <= first + 1) + { + return; + } + + float worst = 0f; + int worstIndex = -1; + + for (int i = first + 1; i < last; i++) + { + float distance = PerpendicularDistance(points[i].p, points[first].p, points[last].p); + if (distance > worst) + { + worst = distance; + worstIndex = i; + } + } + + if (worstIndex == -1 || worst <= epsilon) + { + return; + } + + keep[worstIndex] = true; + SimplifySegment(points, first, worstIndex, epsilon, keep); + SimplifySegment(points, worstIndex, last, epsilon, keep); + } + + private static float PerpendicularDistance(Vec3 point, Vec3 lineStart, Vec3 lineEnd) + { + Vec3 line = lineEnd - lineStart; + float lineLength = line.Length(); + + if (lineLength <= float.Epsilon) + { + return (point - lineStart).Length(); + } + + Vec3 toPoint = point - lineStart; + float cx = (toPoint.y * line.z) - (toPoint.z * line.y); + float cy = (toPoint.z * line.x) - (toPoint.x * line.z); + float cz = (toPoint.x * line.y) - (toPoint.y * line.x); + + return MathF.Sqrt((cx * cx) + (cy * cy) + (cz * cz)) / lineLength; + } +}