From b31f181a8130a18fce310e7ea2c77494bf2f5acf Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 16:48:24 -0400 Subject: [PATCH 001/107] feature: nade practice plugin --- .github/workflows/ci.yaml | 6 + .github/workflows/plugin-build.yaml | 14 +- apps/counterstrikesharp/Dockerfile | 30 +- apps/counterstrikesharp/Dockerfile.dev | 1 + apps/counterstrikesharp/scripts/setup.sh | 12 + apps/nades-css/src/Commands/Practice.cs | 844 +++++++++++++++++ apps/nades-css/src/Commands/Solver.cs | 54 ++ apps/nades-css/src/Events/PracticeConnect.cs | 119 +++ apps/nades-css/src/Events/PracticeGrenade.cs | 71 ++ apps/nades-css/src/Events/PracticePlayer.cs | 97 ++ apps/nades-css/src/NadePractice.csproj | 43 + apps/nades-css/src/NadePracticePlugin.cs | 239 +++++ .../src/NadePracticeServiceCollection.cs | 23 + apps/nades-css/src/Services/NadesApiClient.cs | 437 +++++++++ apps/nades-css/src/Services/NadesConfig.cs | 121 +++ apps/nades-css/src/Services/PracticeDrill.cs | 235 +++++ .../nades-css/src/Services/PracticeLibrary.cs | 132 +++ .../src/Services/PracticePlaybook.cs | 254 +++++ .../src/Services/PracticeRecorder.cs | 416 ++++++++ apps/nades-css/src/Services/PracticeReplay.cs | 455 +++++++++ apps/nades-css/src/Services/PracticeScore.cs | 160 ++++ .../nades-css/src/Services/PracticeSession.cs | 54 ++ apps/nades-css/src/Services/PracticeSystem.cs | 342 +++++++ apps/nades-css/test/DrillUtilityTests.cs | 432 +++++++++ apps/nades-css/test/FiveStack.Tests.csproj | 43 + apps/nades-css/test/NadeArtifactTests.cs | 338 +++++++ apps/nades-css/test/NadeWireTests.cs | 502 ++++++++++ apps/nades-css/test/PlaybookUtilityTests.cs | 233 +++++ .../test/PracticeCalibrationUtilityTests.cs | 349 +++++++ .../test/PracticeConnectUtilityTests.cs | 197 ++++ apps/nades-css/test/PracticeDrillRunTests.cs | 499 ++++++++++ apps/nades-css/test/PracticeJsonTests.cs | 62 ++ .../test/PracticeLaunchUtilityTests.cs | 196 ++++ .../test/PracticeLineupUtilityTests.cs | 140 +++ .../test/PracticeSignalUtilityTests.cs | 149 +++ .../nades-css/test/PracticeSolverPlanTests.cs | 374 ++++++++ .../test/PracticeSolverUtilityTests.cs | 519 ++++++++++ .../nades-css/test/SmokeVolumeUtilityTests.cs | 341 +++++++ apps/nades-css/test/TrajectoryUtilityTests.cs | 203 ++++ apps/nades-sw/src/Commands/Practice.cs | 888 ++++++++++++++++++ apps/nades-sw/src/Commands/Solver.cs | 473 ++++++++++ apps/nades-sw/src/Events/PracticeConnect.cs | 220 +++++ apps/nades-sw/src/Events/PracticeGrenade.cs | 95 ++ apps/nades-sw/src/Events/PracticePlayer.cs | 30 + apps/nades-sw/src/NadePractice.csproj | 51 + apps/nades-sw/src/NadePracticePlugin.cs | 346 +++++++ apps/nades-sw/src/Services/NadesApiClient.cs | 437 +++++++++ apps/nades-sw/src/Services/NadesConfig.cs | 124 +++ apps/nades-sw/src/Services/PracticeDrill.cs | 235 +++++ apps/nades-sw/src/Services/PracticeLibrary.cs | 138 +++ .../nades-sw/src/Services/PracticePlaybook.cs | 254 +++++ .../nades-sw/src/Services/PracticeRecorder.cs | 436 +++++++++ apps/nades-sw/src/Services/PracticeReplay.cs | 770 +++++++++++++++ apps/nades-sw/src/Services/PracticeScore.cs | 165 ++++ apps/nades-sw/src/Services/PracticeSession.cs | 54 ++ apps/nades-sw/src/Services/PracticeSolver.cs | 779 +++++++++++++++ apps/nades-sw/src/Services/PracticeSystem.cs | 401 ++++++++ apps/nades-sw/test/DrillUtilityTests.cs | 432 +++++++++ apps/nades-sw/test/FiveStack.Tests.csproj | 43 + apps/nades-sw/test/NadeArtifactTests.cs | 338 +++++++ apps/nades-sw/test/NadeWireTests.cs | 502 ++++++++++ apps/nades-sw/test/PlaybookUtilityTests.cs | 233 +++++ .../test/PracticeCalibrationUtilityTests.cs | 349 +++++++ .../test/PracticeConnectUtilityTests.cs | 197 ++++ apps/nades-sw/test/PracticeDrillRunTests.cs | 499 ++++++++++ apps/nades-sw/test/PracticeJsonTests.cs | 62 ++ .../test/PracticeLaunchUtilityTests.cs | 196 ++++ .../test/PracticeLineupUtilityTests.cs | 140 +++ .../test/PracticeSignalUtilityTests.cs | 149 +++ apps/nades-sw/test/PracticeSolverPlanTests.cs | 374 ++++++++ .../test/PracticeSolverUtilityTests.cs | 519 ++++++++++ apps/nades-sw/test/SmokeVolumeUtilityTests.cs | 341 +++++++ apps/nades-sw/test/TrajectoryUtilityTests.cs | 203 ++++ apps/swiftly/Dockerfile | 28 +- apps/swiftly/scripts/setup.sh | 25 + .../Practice/BloomSegment.cs | 15 + .../Practice/CalibrationReport.cs | 55 ++ .../Practice/LaunchCheck.cs | 21 + .../FiveStack.Entities/Practice/LaunchSeed.cs | 17 + .../Practice/LineupRecord.cs | 87 ++ .../Practice/NadeIngestPayload.cs | 92 ++ .../Practice/NadeLibraryRow.cs | 128 +++ .../Practice/NadePathPoint.cs | 12 + .../Practice/NadePlaybook.cs | 47 + .../Practice/NadePracticeResult.cs | 57 ++ .../Practice/NadeSessionRow.cs | 39 + .../Practice/NadeTrajectoryArtifact.cs | 168 ++++ .../Practice/PracticeSessionData.cs | 16 + .../Practice/SmokeVolume.cs | 23 + .../Practice/SolveCandidate.cs | 15 + .../Practice/SolveObservation.cs | 16 + .../Practice/SolveRequest.cs | 30 + .../Practice/SolveResult.cs | 21 + .../Practice/ThrowSnapshot.cs | 30 + .../Practice/TrajectoryPoint.cs | 11 + .../FiveStack.Entities/Practice/Vec3.cs | 55 ++ .../FiveStack.Enums/eCalibrationStatus.cs | 34 + shared/dotnet/FiveStack.Enums/eDrillEnd.cs | 24 + shared/dotnet/FiveStack.Enums/eDrillOrder.cs | 13 + shared/dotnet/FiveStack.Enums/eDrillStart.cs | 22 + .../FiveStack.Enums/eLineupVisibility.cs | 9 + .../FiveStack.Enums/ePracticeConnect.cs | 17 + .../dotnet/FiveStack.Enums/eSolveOutcome.cs | 30 + .../dotnet/FiveStack.Enums/eThrowStrength.cs | 10 + .../dotnet/FiveStack.Enums/eThrowTechnique.cs | 16 + shared/dotnet/FiveStack.Enums/eUtilityType.cs | 13 + .../FiveStack.Utilities/ConnectAuth.cs | 0 .../FiveStack.Utilities/DrillUtility.cs | 285 ++++++ .../FiveStack.Utilities/PlaybookUtility.cs | 65 ++ .../PracticeCalibrationUtility.cs | 275 ++++++ .../PracticeConnectUtility.cs | 123 +++ .../FiveStack.Utilities/PracticeDrillRun.cs | 284 ++++++ .../FiveStack.Utilities/PracticeJson.cs | 37 + .../PracticeLaunchUtility.cs | 189 ++++ .../PracticeLineupUtility.cs | 197 ++++ .../PracticeSignalUtility.cs | 86 ++ .../FiveStack.Utilities/PracticeSolverPlan.cs | 289 ++++++ .../PracticeSolverUtility.cs | 495 ++++++++++ .../FiveStack.Utilities/SmokeVolumeUtility.cs | 488 ++++++++++ .../FiveStack.Utilities/TrajectoryUtility.cs | 203 ++++ 120 files changed, 23418 insertions(+), 3 deletions(-) create mode 100644 apps/nades-css/src/Commands/Practice.cs create mode 100644 apps/nades-css/src/Commands/Solver.cs create mode 100644 apps/nades-css/src/Events/PracticeConnect.cs create mode 100644 apps/nades-css/src/Events/PracticeGrenade.cs create mode 100644 apps/nades-css/src/Events/PracticePlayer.cs create mode 100644 apps/nades-css/src/NadePractice.csproj create mode 100644 apps/nades-css/src/NadePracticePlugin.cs create mode 100644 apps/nades-css/src/NadePracticeServiceCollection.cs create mode 100644 apps/nades-css/src/Services/NadesApiClient.cs create mode 100644 apps/nades-css/src/Services/NadesConfig.cs create mode 100644 apps/nades-css/src/Services/PracticeDrill.cs create mode 100644 apps/nades-css/src/Services/PracticeLibrary.cs create mode 100644 apps/nades-css/src/Services/PracticePlaybook.cs create mode 100644 apps/nades-css/src/Services/PracticeRecorder.cs create mode 100644 apps/nades-css/src/Services/PracticeReplay.cs create mode 100644 apps/nades-css/src/Services/PracticeScore.cs create mode 100644 apps/nades-css/src/Services/PracticeSession.cs create mode 100644 apps/nades-css/src/Services/PracticeSystem.cs create mode 100644 apps/nades-css/test/DrillUtilityTests.cs create mode 100644 apps/nades-css/test/FiveStack.Tests.csproj create mode 100644 apps/nades-css/test/NadeArtifactTests.cs create mode 100644 apps/nades-css/test/NadeWireTests.cs create mode 100644 apps/nades-css/test/PlaybookUtilityTests.cs create mode 100644 apps/nades-css/test/PracticeCalibrationUtilityTests.cs create mode 100644 apps/nades-css/test/PracticeConnectUtilityTests.cs create mode 100644 apps/nades-css/test/PracticeDrillRunTests.cs create mode 100644 apps/nades-css/test/PracticeJsonTests.cs create mode 100644 apps/nades-css/test/PracticeLaunchUtilityTests.cs create mode 100644 apps/nades-css/test/PracticeLineupUtilityTests.cs create mode 100644 apps/nades-css/test/PracticeSignalUtilityTests.cs create mode 100644 apps/nades-css/test/PracticeSolverPlanTests.cs create mode 100644 apps/nades-css/test/PracticeSolverUtilityTests.cs create mode 100644 apps/nades-css/test/SmokeVolumeUtilityTests.cs create mode 100644 apps/nades-css/test/TrajectoryUtilityTests.cs create mode 100644 apps/nades-sw/src/Commands/Practice.cs create mode 100644 apps/nades-sw/src/Commands/Solver.cs create mode 100644 apps/nades-sw/src/Events/PracticeConnect.cs create mode 100644 apps/nades-sw/src/Events/PracticeGrenade.cs create mode 100644 apps/nades-sw/src/Events/PracticePlayer.cs create mode 100644 apps/nades-sw/src/NadePractice.csproj create mode 100644 apps/nades-sw/src/NadePracticePlugin.cs create mode 100644 apps/nades-sw/src/Services/NadesApiClient.cs create mode 100644 apps/nades-sw/src/Services/NadesConfig.cs create mode 100644 apps/nades-sw/src/Services/PracticeDrill.cs create mode 100644 apps/nades-sw/src/Services/PracticeLibrary.cs create mode 100644 apps/nades-sw/src/Services/PracticePlaybook.cs create mode 100644 apps/nades-sw/src/Services/PracticeRecorder.cs create mode 100644 apps/nades-sw/src/Services/PracticeReplay.cs create mode 100644 apps/nades-sw/src/Services/PracticeScore.cs create mode 100644 apps/nades-sw/src/Services/PracticeSession.cs create mode 100644 apps/nades-sw/src/Services/PracticeSolver.cs create mode 100644 apps/nades-sw/src/Services/PracticeSystem.cs create mode 100644 apps/nades-sw/test/DrillUtilityTests.cs create mode 100644 apps/nades-sw/test/FiveStack.Tests.csproj create mode 100644 apps/nades-sw/test/NadeArtifactTests.cs create mode 100644 apps/nades-sw/test/NadeWireTests.cs create mode 100644 apps/nades-sw/test/PlaybookUtilityTests.cs create mode 100644 apps/nades-sw/test/PracticeCalibrationUtilityTests.cs create mode 100644 apps/nades-sw/test/PracticeConnectUtilityTests.cs create mode 100644 apps/nades-sw/test/PracticeDrillRunTests.cs create mode 100644 apps/nades-sw/test/PracticeJsonTests.cs create mode 100644 apps/nades-sw/test/PracticeLaunchUtilityTests.cs create mode 100644 apps/nades-sw/test/PracticeLineupUtilityTests.cs create mode 100644 apps/nades-sw/test/PracticeSignalUtilityTests.cs create mode 100644 apps/nades-sw/test/PracticeSolverPlanTests.cs create mode 100644 apps/nades-sw/test/PracticeSolverUtilityTests.cs create mode 100644 apps/nades-sw/test/SmokeVolumeUtilityTests.cs create mode 100644 apps/nades-sw/test/TrajectoryUtilityTests.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/BloomSegment.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/CalibrationReport.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/LaunchCheck.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/LaunchSeed.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/NadeIngestPayload.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/NadeLibraryRow.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/NadePathPoint.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/NadePlaybook.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/NadePracticeResult.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/NadeSessionRow.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/NadeTrajectoryArtifact.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/SmokeVolume.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/SolveCandidate.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/SolveObservation.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/SolveRequest.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/SolveResult.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/ThrowSnapshot.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/TrajectoryPoint.cs create mode 100644 shared/dotnet/FiveStack.Entities/Practice/Vec3.cs create mode 100644 shared/dotnet/FiveStack.Enums/eCalibrationStatus.cs create mode 100644 shared/dotnet/FiveStack.Enums/eDrillEnd.cs create mode 100644 shared/dotnet/FiveStack.Enums/eDrillOrder.cs create mode 100644 shared/dotnet/FiveStack.Enums/eDrillStart.cs create mode 100644 shared/dotnet/FiveStack.Enums/eLineupVisibility.cs create mode 100644 shared/dotnet/FiveStack.Enums/ePracticeConnect.cs create mode 100644 shared/dotnet/FiveStack.Enums/eSolveOutcome.cs create mode 100644 shared/dotnet/FiveStack.Enums/eThrowStrength.cs create mode 100644 shared/dotnet/FiveStack.Enums/eThrowTechnique.cs create mode 100644 shared/dotnet/FiveStack.Enums/eUtilityType.cs rename {apps/swiftly/src => shared/dotnet}/FiveStack.Utilities/ConnectAuth.cs (100%) create mode 100644 shared/dotnet/FiveStack.Utilities/DrillUtility.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PlaybookUtility.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PracticeCalibrationUtility.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PracticeConnectUtility.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PracticeJson.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PracticeLaunchUtility.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PracticeSignalUtility.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PracticeSolverPlan.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PracticeSolverUtility.cs create mode 100644 shared/dotnet/FiveStack.Utilities/SmokeVolumeUtility.cs create mode 100644 shared/dotnet/FiveStack.Utilities/TrajectoryUtility.cs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e722da4b..b5f40348 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/nades-css/**' sw: - *shared - 'apps/swiftly/**' + - 'apps/nades-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/nades-css secrets: inherit swiftly: @@ -86,6 +91,7 @@ jobs: version_floor: 41 channel: ${{ needs.changes.outputs.channel }} run_tests: true + extra_test_dir: apps/nades-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..d8cd8563 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_NADE_PRACTICE_PLUGIN is set. +FROM dotnet-sdk AS nades-build + +WORKDIR /mod + +COPY Directory.Build.props ./ +COPY apps/nades-css/src/NadePractice.csproj apps/nades-css/src/ + +RUN dotnet restore apps/nades-css/src/NadePractice.csproj + +COPY shared shared +COPY apps/nades-css apps/nades-css + +ARG RELEASE_VERSION +ENV RELEASE_VERSION=${RELEASE_VERSION} + +RUN sed -i "s/__RELEASE_VERSION__/${RELEASE_VERSION}/" apps/nades-css/src/NadePracticePlugin.cs + +RUN dotnet build -c Release apps/nades-css/src/NadePractice.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_NADE_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=nades-build /mod/release /opt/nade-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..e76ad75a 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_NADE_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..5fe1eb41 100755 --- a/apps/counterstrikesharp/scripts/setup.sh +++ b/apps/counterstrikesharp/scripts/setup.sh @@ -129,6 +129,18 @@ if $INSTALL_5STACK_PLUGIN = true ; then fi fi +# A practice server runs this instead of the match plugin, never alongside it. +if $INSTALL_NADE_PRACTICE_PLUGIN = true ; then + echo "---Install Nade Practice---" + if [ "${DEV_SWAPPED}" == "1" ]; then + # css and sw dev builds share the dev volume; each uses its own subfolder + mkdir -p "/opt/dev/nades-css" + ln -s "/opt/dev/nades-css" "${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/plugins/NadePractice" + else + ln -s "/opt/nade-practice" "${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/plugins/NadePractice" + 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/nades-css/src/Commands/Practice.cs b/apps/nades-css/src/Commands/Practice.cs new file mode 100644 index 00000000..dced0160 --- /dev/null +++ b/apps/nades-css/src/Commands/Practice.cs @@ -0,0 +1,844 @@ +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 NadePractice; + +// 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 NadePracticePlugin +{ + [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, 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 and deliberately unprefixed, like the match plugin's + // get_match: the panel calls it when the roster or the library changes. + [ConsoleCommand("nade_practice_refresh", "Re-reads the practice session from the panel")] + [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)] + public void OnRefresh(CCSPlayerController? player, CommandInfo command) + { + RefreshEverything(); + } + + private static readonly string[] HelpLines = new[] + { + $" {ChatColors.Green}nade 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++) + { + NadePlaybookStep 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, + 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/nades-css/src/Commands/Solver.cs b/apps/nades-css/src/Commands/Solver.cs new file mode 100644 index 00000000..9f20723c --- /dev/null +++ b/apps/nades-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 NadePractice; + +// 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 NadePracticePlugin +{ + 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("nade_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("nade_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/nades-css/src/Events/PracticeConnect.cs b/apps/nades-css/src/Events/PracticeConnect.cs new file mode 100644 index 00000000..54772538 --- /dev/null +++ b/apps/nades-css/src/Events/PracticeConnect.cs @@ -0,0 +1,119 @@ +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; + +namespace NadePractice; + +// 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 NadePracticePlugin +{ + 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; + } + + 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/nades-css/src/Events/PracticeGrenade.cs b/apps/nades-css/src/Events/PracticeGrenade.cs new file mode 100644 index 00000000..d64791ef --- /dev/null +++ b/apps/nades-css/src/Events/PracticeGrenade.cs @@ -0,0 +1,71 @@ +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using FiveStack.Entities.Practice; + +namespace NadePractice; + +// 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 NadePracticePlugin +{ + [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/nades-css/src/Events/PracticePlayer.cs b/apps/nades-css/src/Events/PracticePlayer.cs new file mode 100644 index 00000000..f7373793 --- /dev/null +++ b/apps/nades-css/src/Events/PracticePlayer.cs @@ -0,0 +1,97 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Entities; + +namespace NadePractice; + +public partial class NadePracticePlugin +{ + // 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); + } + + private void OnClientDisconnect(int slot) + { + CCSPlayerController? player = Utilities.GetPlayerFromSlot(slot); + + if (player == null || !player.IsValid) + { + return; + } + + // 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; + } + + List<(uint index, ulong owner)> ghosts = _replay.GhostEntities().ToList(); + + 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/nades-css/src/NadePractice.csproj b/apps/nades-css/src/NadePractice.csproj new file mode 100644 index 00000000..e74a80cd --- /dev/null +++ b/apps/nades-css/src/NadePractice.csproj @@ -0,0 +1,43 @@ + + + true + NadePractice + NadePractice + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/nades-css/src/NadePracticePlugin.cs b/apps/nades-css/src/NadePracticePlugin.cs new file mode 100644 index 00000000..4a347b76 --- /dev/null +++ b/apps/nades-css/src/NadePracticePlugin.cs @@ -0,0 +1,239 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes; +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 NadePractice; + +// Standalone plugin, installed through the 5stack game-plugin registry and +// bound to the nade-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 NadePracticePlugin : BasePlugin +{ + private readonly NadesConfig _config; + private readonly NadesApiClient _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 => "NadePractice"; + 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 NadePracticePlugin( + NadesConfig config, + NadesApiClient 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); + RegisterListener(_recorder.OnProjectileCreated); + 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); + + _library.SetMap(Server.MapName); + RefreshEverything(); + + _logger.LogInformation( + "nade 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() + { + _system.Tick(); + _playbook.Second(); + _drill.Second(); + } + + 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); + + 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); + } + } + } + + private void OnSessionRefreshed(PracticeSessionData session) + { + if (!string.IsNullOrEmpty(session.password)) + { + SetPasswordBuffer(session.password); + } + } +} diff --git a/apps/nades-css/src/NadePracticeServiceCollection.cs b/apps/nades-css/src/NadePracticeServiceCollection.cs new file mode 100644 index 00000000..11cc0e39 --- /dev/null +++ b/apps/nades-css/src/NadePracticeServiceCollection.cs @@ -0,0 +1,23 @@ +using CounterStrikeSharp.API.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace NadePractice; + +// CounterStrikeSharp discovers this automatically and builds the plugin's +// container from it, mirroring the match plugin's FiveStackServiceCollection. +public class NadePracticeServiceCollection : 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/nades-css/src/Services/NadesApiClient.cs b/apps/nades-css/src/Services/NadesApiClient.cs new file mode 100644 index 00000000..6bca97a3 --- /dev/null +++ b/apps/nades-css/src/Services/NadesApiClient.cs @@ -0,0 +1,437 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace NadePractice; + +// 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 NadesApiClient +{ + // 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 NadesConfig _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 NadesApiClient(NadesConfig 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, + $"/nades/library?map={Uri.EscapeDataString(map)}&steam_id={steamId}", + null + ); + + if (body == null) + { + return null; + } + + try + { + List? rows = ReadList(body, "lineups", "nades"); + + 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) + { + byte[]? body = await Send( + HttpMethod.Get, + $"/nades/{Uri.EscapeDataString(id)}/trajectory", + null + ); + + if (body == null) + { + return null; + } + + try + { + return NadeTrajectoryArtifact.Parse(body); + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the trajectory for {id}", id); + return null; + } + } + + public async Task Session() + { + string? body = await SendText(HttpMethod.Get, "/nades/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(NadePracticeResultPayload payload) + { + NadePracticeResult? 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, $"/nades/{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) + { + NadePracticeResultPayload? 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(NadePracticeResultPayload 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( + NadeIngestPayload.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, "/nades/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(NadePracticeResultPayload 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, "/nades/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)); + request.Headers.Authorization = new AuthenticationHeaderValue( + "Bearer", + _config.NadesApiKey + ); + + 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) + { + _logger.LogWarning( + "{method} {path} returned {status}", + method.Method, + path, + (int)response.StatusCode + ); + return null; + } + + return await response.Content.ReadAsByteArrayAsync(); + } + catch (Exception error) + { + _logger.LogError(error, "{method} {path} failed", method.Method, path); + return null; + } + } + + // Every nade 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.NadesUrl}{path}"; + } + + string separator = path.Contains('?') ? "&" : "?"; + + return $"{_config.NadesUrl}{path}{separator}server_id={Uri.EscapeDataString(_config.ServerId)}"; + } +} diff --git a/apps/nades-css/src/Services/NadesConfig.cs b/apps/nades-css/src/Services/NadesConfig.cs new file mode 100644 index 00000000..c70d16b8 --- /dev/null +++ b/apps/nades-css/src/Services/NadesConfig.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace NadePractice; + +// Configuration arrives as addons/counterstrikesharp/configs/nade-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 NadesConfig +{ + public string NadesUrl { get; private set; } = ""; + public string NadesApiKey { get; private set; } = ""; + + // The plugin key alone buys nothing: every nade 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 InfiniteNades { 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 NadesConfig(ILogger logger) + { + _logger = logger; + } + + private class ConfigFile + { + public string? nades_url { get; set; } + public string? nades_apikey { 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_nades { 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/nade-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. + NadesUrl = Environment.GetEnvironmentVariable("NADES_URL") ?? ""; + NadesApiKey = Environment.GetEnvironmentVariable("NADES_API_KEY") ?? ""; + ServerId = Environment.GetEnvironmentVariable("SERVER_ID") ?? ""; + ServerApiPassword = Environment.GetEnvironmentVariable("SERVER_API_PASSWORD") ?? ""; + + string? path = configDirectories + .Where(directory => !string.IsNullOrEmpty(directory)) + .Select(directory => Path.Join(directory, "nade-practice.json")) + .FirstOrDefault(File.Exists); + + if (path != null) + { + try + { + ConfigFile? parsed = JsonSerializer.Deserialize( + File.ReadAllText(path) + ); + + if (parsed != null) + { + if (string.IsNullOrEmpty(NadesUrl)) + { + NadesUrl = parsed.nades_url ?? ""; + } + if (string.IsNullOrEmpty(NadesApiKey)) + { + NadesApiKey = parsed.nades_apikey ?? ""; + } + 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; + InfiniteNades = parsed.np_infinite_nades ?? InfiniteNades; + 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); + } + } + + NadesUrl = NadesUrl.TrimEnd('/'); + + if (string.IsNullOrEmpty(NadesUrl) || string.IsNullOrEmpty(NadesApiKey)) + { + // 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( + "nade practice is not connected to a panel; lineups cannot be saved or loaded" + ); + } + } + + public bool IsConnected() + { + return !string.IsNullOrEmpty(NadesUrl) && !string.IsNullOrEmpty(NadesApiKey); + } +} diff --git a/apps/nades-css/src/Services/PracticeDrill.cs b/apps/nades-css/src/Services/PracticeDrill.cs new file mode 100644 index 00000000..5f12d88f --- /dev/null +++ b/apps/nades-css/src/Services/PracticeDrill.cs @@ -0,0 +1,235 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; + +namespace NadePractice; + +// 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 NadesConfig _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(NadesConfig 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, NadePracticeResult? 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/nades-css/src/Services/PracticeLibrary.cs b/apps/nades-css/src/Services/PracticeLibrary.cs new file mode 100644 index 00000000..9d98b581 --- /dev/null +++ b/apps/nades-css/src/Services/PracticeLibrary.cs @@ -0,0 +1,132 @@ +using CounterStrikeSharp.API; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace NadePractice; + +// 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 NadesApiClient _api; + private readonly ILogger _logger; + + private readonly Dictionary> _lineups = new(); + private string _map = ""; + + public PracticeLibrary(NadesApiClient 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, Action ready) + { + if (lineup.trajectory.Count > 0 || string.IsNullOrEmpty(lineup.id)) + { + ready(lineup); + return; + } + + string id = lineup.id; + + _ = Task.Run(async () => + { + NadeTrajectoryArtifact? artifact = await _api.Trajectory(id); + + 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/nades-css/src/Services/PracticePlaybook.cs b/apps/nades-css/src/Services/PracticePlaybook.cs new file mode 100644 index 00000000..59e414ba --- /dev/null +++ b/apps/nades-css/src/Services/PracticePlaybook.cs @@ -0,0 +1,254 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; + +namespace NadePractice; + +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 NadePlaybook? Loaded => _session.Current?.playbook; + + public IReadOnlyList Steps => PlaybookUtility.Ordered(Loaded); + + public ePlaybookStart Start(string map) + { + if (Running) + { + return ePlaybookStart.AlreadyRunning; + } + + NadePlaybook? 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 (NadePlaybookStep 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(NadePlaybookStep 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(NadePlaybookStep step) + { + string id = step.nade_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/nades-css/src/Services/PracticeRecorder.cs b/apps/nades-css/src/Services/PracticeRecorder.cs new file mode 100644 index 00000000..7af47237 --- /dev/null +++ b/apps/nades-css/src/Services/PracticeRecorder.cs @@ -0,0 +1,416 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Utils; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace NadePractice; + +// 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(); + } + + private readonly Dictionary _armed = 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(); + _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; + CBasePlayerWeapon? active = pawn?.WeaponServices?.ActiveWeapon.Value; + + if (pawn == null || 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); + _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 ThrowSnapshot Snapshot( + CCSPlayerController player, + CCSPlayerPawn pawn, + CBaseCSGrenade grenade + ) + { + 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 + { + feet_position = 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 & (1 << 0)) != 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); + + if (utilityType == null || _tracked.Count >= MaxTrackedProjectiles) + { + 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/nades-css/src/Services/PracticeReplay.cs b/apps/nades-css/src/Services/PracticeReplay.cs new file mode 100644 index 00000000..c9828e17 --- /dev/null +++ b/apps/nades-css/src/Services/PracticeReplay.cs @@ -0,0 +1,455 @@ +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 NadePractice; + +// 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 NadesConfig _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(); + + public PracticeReplay(NadesConfig 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); + var angles = new QAngle(lineup.release.pitch, lineup.release.yaw, 0); + + pawn.Teleport(position, angles, new Vector(0, 0, 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, angles, 2); + + GiveUtility(player, lineup.utility_type); + + 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; + } + + _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. + _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); + } + } + + 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 ClearAll() + { + foreach (Ghost ghost in _ghosts) + { + Kill(ghost); + } + + _ghosts.Clear(); + } + + 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); + } + } + + 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. + public IEnumerable<(uint index, ulong owner)> GhostEntities() + { + foreach (Ghost ghost in _ghosts) + { + foreach (CEnvBeam beam in ghost.Beams) + { + if (beam.IsValid) + { + yield return (beam.Index, ghost.OwnerSteamId); + } + } + } + } + + 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 angles, 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, angles, new Vector(0, 0, 0)); + ReapplyAngles(player, angles, 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; + } + + 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/nades-css/src/Services/PracticeScore.cs b/apps/nades-css/src/Services/PracticeScore.cs new file mode 100644 index 00000000..06f87174 --- /dev/null +++ b/apps/nades-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 NadePractice; + +// 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 NadesConfig _config; + private readonly NadesApiClient _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( + NadesConfig config, + NadesApiClient 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 = NadePracticeResultPayload.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 () => + { + NadePracticeResult? 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, + NadePracticeResult? 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, + NadePracticeResult? 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/nades-css/src/Services/PracticeSession.cs b/apps/nades-css/src/Services/PracticeSession.cs new file mode 100644 index 00000000..4d4f918e --- /dev/null +++ b/apps/nades-css/src/Services/PracticeSession.cs @@ -0,0 +1,54 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace NadePractice; + +// 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 NadesApiClient _api; + private readonly ILogger _logger; + + private PracticeSessionData? _session; + + public PracticeSession(NadesApiClient 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/nades-css/src/Services/PracticeSystem.cs b/apps/nades-css/src/Services/PracticeSystem.cs new file mode 100644 index 00000000..00fbb256 --- /dev/null +++ b/apps/nades-css/src/Services/PracticeSystem.cs @@ -0,0 +1,342 @@ +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 NadePractice; + +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 NadesConfig _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( + NadesConfig 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.InfiniteNades) + { + 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); + } + } + + 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; + } + + pawn.Teleport( + new Vector( + position.feet_position.x, + position.feet_position.y, + position.feet_position.z + ), + new QAngle(position.pitch, position.yaw, 0), + new Vector(0, 0, 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/nades-css/test/DrillUtilityTests.cs b/apps/nades-css/test/DrillUtilityTests.cs new file mode 100644 index 00000000..a03fc58c --- /dev/null +++ b/apps/nades-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 NadePracticeResult Result( + bool success, + int attempts, + int successes, + bool mastered = false + ) + { + return new NadePracticeResult + { + 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/nades-css/test/FiveStack.Tests.csproj b/apps/nades-css/test/FiveStack.Tests.csproj new file mode 100644 index 00000000..79121e9f --- /dev/null +++ b/apps/nades-css/test/FiveStack.Tests.csproj @@ -0,0 +1,43 @@ + + + false + bin/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/nades-css/test/NadeArtifactTests.cs b/apps/nades-css/test/NadeArtifactTests.cs new file mode 100644 index 00000000..f0ee7532 --- /dev/null +++ b/apps/nades-css/test/NadeArtifactTests.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 NadeArtifactTests +{ + // 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() + { + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.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 = NadeTrajectoryArtifact.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); + } + + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.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() + { + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.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() + { + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.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() + { + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse( + """{"grenade_trajectories":[{"points":[]}],"smoke_volumes":[]}""" + ); + + Assert.Empty(artifact.path); + Assert.Null(artifact.smoke_volume); + } + + [Fact] + public void AVolumeWithNoExtentIsNotAVolume() + { + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.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() + { + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.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": [ + { + "nade_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", + "nade_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(); + + NadePlaybook? 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() + { + NadePracticeResultPayload payload = NadePracticeResultPayload.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.nade_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() + { + NadePracticeResultPayload payload = NadePracticeResultPayload.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("\"nade_lineup_id\"", json); + } + + [Fact] + public void AResultIsReadBackWithThePanelsRadius() + { + NadePracticeResult? 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() + { + NadePracticeResult? 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/nades-css/test/NadeWireTests.cs b/apps/nades-css/test/NadeWireTests.cs new file mode 100644 index 00000000..65a6c3ef --- /dev/null +++ b/apps/nades-css/test/NadeWireTests.cs @@ -0,0 +1,502 @@ +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 NadeWireTests +{ + 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() + { + NadeIngestPayload payload = NadeIngestPayload.From(Lineup()); + + Assert.Equal("76561198000000001", payload.author_steam_id); + Assert.Equal("Smoke", payload.nade_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() + { + NadeIngestPayload payload = NadeIngestPayload.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, NadeIngestPayload.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, NadeIngestPayload.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 TheNadeTypeIsSentInTheApisSpelling(string recorded, string expected) + { + LineupRecord lineup = Lineup(); + lineup.utility_type = recorded; + + Assert.Equal(expected, NadeIngestPayload.From(lineup).nade_type); + } + + [Fact] + public void ThePathIsSentAsObjectsNotPackedArrays() + { + string json = JsonSerializer.Serialize( + NadeIngestPayload.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("\"utility_type\"")] + [InlineData("\"release\"")] + [InlineData("\"trajectory\"")] + [InlineData("\"flight_time\":")] + [InlineData("\"confidence\"")] + public void FieldsTheApiDoesNotAcceptAreNotSent(string absent) + { + string json = JsonSerializer.Serialize( + NadeIngestPayload.From(Lineup()), + PracticeJson.Options + ); + + Assert.DoesNotContain(absent, json); + } + + private static NadeLibraryRow Row() + { + return new NadeLibraryRow + { + id = "panel-id", + name = "Window smoke", + map_name = "de_mirage", + nade_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() + { + NadeLibraryRow 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) + { + NadeLibraryRow 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() + { + NadeLibraryRow 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(); + NadeLibraryRow 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() + { + NadeLibraryRow 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 NadeLibraryRow Seeded(string? confidence = null) + { + NadeLibraryRow 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(); + NadeIngestPayload payload = NadeIngestPayload.From(original); + + var row = new NadeLibraryRow + { + id = "panel-id", + name = payload.name, + nade_type = payload.nade_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( + NadeIngestPayload.From(bare), + PracticeJson.Options + ); + + Assert.DoesNotContain("null", json); + Assert.DoesNotContain("\"description\"", json); + Assert.DoesNotContain("\"match_id\"", json); + } +} diff --git a/apps/nades-css/test/PlaybookUtilityTests.cs b/apps/nades-css/test/PlaybookUtilityTests.cs new file mode 100644 index 00000000..df849b67 --- /dev/null +++ b/apps/nades-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 NadePlaybookStep Step( + int order, + int offsetMs, + string? assigned = null, + bool withLineup = true, + bool seeded = false, + string? confidence = null + ) + { + return new NadePlaybookStep + { + nade_lineup_id = $"lineup-{order}", + step_order = order, + offset_ms = offsetMs, + assigned_steam_id = assigned, + note = $"step {order}", + lineup = withLineup + ? new NadeLibraryRow + { + id = $"row-{order}", + name = $"nade {order}", + nade_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 NadePlaybook Playbook(params NadePlaybookStep[] steps) + { + return new NadePlaybook + { + 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() + { + NadePlaybookStep 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() + { + NadePlaybookStep 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/nades-css/test/PracticeCalibrationUtilityTests.cs b/apps/nades-css/test/PracticeCalibrationUtilityTests.cs new file mode 100644 index 00000000..10aeeae4 --- /dev/null +++ b/apps/nades-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/nades-css/test/PracticeConnectUtilityTests.cs b/apps/nades-css/test/PracticeConnectUtilityTests.cs new file mode 100644 index 00000000..aa58969a --- /dev/null +++ b/apps/nades-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/nades-css/test/PracticeDrillRunTests.cs b/apps/nades-css/test/PracticeDrillRunTests.cs new file mode 100644 index 00000000..35aa1969 --- /dev/null +++ b/apps/nades-css/test/PracticeDrillRunTests.cs @@ -0,0 +1,499 @@ +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 NadePracticeResult Result(bool success) + { + return new NadePracticeResult + { + 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); + } +} diff --git a/apps/nades-css/test/PracticeJsonTests.cs b/apps/nades-css/test/PracticeJsonTests.cs new file mode 100644 index 00000000..8c9c0f6e --- /dev/null +++ b/apps/nades-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 + ); + + NadePathPoint 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\"}"; + + NadeLibraryRow? 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/nades-css/test/PracticeLaunchUtilityTests.cs b/apps/nades-css/test/PracticeLaunchUtilityTests.cs new file mode 100644 index 00000000..95736933 --- /dev/null +++ b/apps/nades-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/nades-css/test/PracticeLineupUtilityTests.cs b/apps/nades-css/test/PracticeLineupUtilityTests.cs new file mode 100644 index 00000000..f493c9a3 --- /dev/null +++ b/apps/nades-css/test/PracticeLineupUtilityTests.cs @@ -0,0 +1,140 @@ +using FiveStack.Entities.Practice; +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")); + } +} diff --git a/apps/nades-css/test/PracticeSignalUtilityTests.cs b/apps/nades-css/test/PracticeSignalUtilityTests.cs new file mode 100644 index 00000000..ccc3d187 --- /dev/null +++ b/apps/nades-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( + @"^\[nade-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/nades-css/test/PracticeSolverPlanTests.cs b/apps/nades-css/test/PracticeSolverPlanTests.cs new file mode 100644 index 00000000..30f3ea10 --- /dev/null +++ b/apps/nades-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/nades-css/test/PracticeSolverUtilityTests.cs b/apps/nades-css/test/PracticeSolverUtilityTests.cs new file mode 100644 index 00000000..720e1995 --- /dev/null +++ b/apps/nades-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/nades-css/test/SmokeVolumeUtilityTests.cs b/apps/nades-css/test/SmokeVolumeUtilityTests.cs new file mode 100644 index 00000000..4755a8ca --- /dev/null +++ b/apps/nades-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/nades-css/test/TrajectoryUtilityTests.cs b/apps/nades-css/test/TrajectoryUtilityTests.cs new file mode 100644 index 00000000..3fbe33ab --- /dev/null +++ b/apps/nades-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/nades-sw/src/Commands/Practice.cs b/apps/nades-sw/src/Commands/Practice.cs new file mode 100644 index 00000000..f82ff82e --- /dev/null +++ b/apps/nades-sw/src/Commands/Practice.cs @@ -0,0 +1,888 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Commands; +using SwiftlyS2.Shared.Players; +using static SwiftlyS2.Shared.Helper; + +namespace NadePractice; + +// 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 NadePracticePlugin +{ + [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); + + 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) + { + 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); + + 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, 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); + } + + [Command("drill", registerRaw: false, permission: "")] + public void OnDrill(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + DrillRequest request = DrillUtility.Parse(string.Join(" ", context.Args)); + + if (!request.Valid) + { + Reply(context, $" {ChatColors.Red}usage: .drill [count] [worst|random] / .drill stop"); + 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"); + } + } + + [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; + + _replay.ApplyVisibility(); + + 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)" + ); + } + + // 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. + [Command("ghosts", registerRaw: false, permission: "")] + public void OnGhosts(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + string argument = string.Join(" ", context.Args).Trim(); + + if (!PracticeSignalUtility.TryParseToggle(argument, state.Ghosts, out bool wanted)) + { + Reply(context, $" {ChatColors.Red}usage: .ghosts [on|off]"); + return; + } + + state.Ghosts = wanted; + + if (!wanted) + { + _replay.ClearGhosts(player.SteamID); + } + + _replay.ApplyVisibility(); + + Reply(context, $" {ChatColors.Green}ghost previews {Toggle(state.Ghosts)}"); + } + + [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..."); + + _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 and deliberately unprefixed, like the match plugin's + // get_match: the panel calls it when the roster or the library changes. + [Command("nade_practice_refresh", registerRaw: true, permission: "")] + public void OnRefresh(ICommandContext context) + { + if (context.IsSentByPlayer) + { + return; + } + + RefreshEverything(); + } + + private static readonly string[] HelpLines = new[] + { + $" {ChatColors.Green}nade 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 [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}.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(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++) + { + NadePlaybookStep 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.ShowGhost(player, lineup); + _replay.ThrowGhostProjectile(player, lineup); + WarnIfInexact(player, lineup); + + ulong steamId = player.SteamID; + + _library.EnsureTrajectory( + lineup, + 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) + { + _replay.ShowGhost(still, fetched); + } + + 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/nades-sw/src/Commands/Solver.cs b/apps/nades-sw/src/Commands/Solver.cs new file mode 100644 index 00000000..0ffab001 --- /dev/null +++ b/apps/nades-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 NadePractice; + +// 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 NadePracticePlugin +{ + [Command("nade_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("nade_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: nade_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/nades-sw/src/Events/PracticeConnect.cs b/apps/nades-sw/src/Events/PracticeConnect.cs new file mode 100644 index 00000000..8e878118 --- /dev/null +++ b/apps/nades-sw/src/Events/PracticeConnect.cs @@ -0,0 +1,220 @@ +using System.Runtime.InteropServices; +using System.Text; +using FiveStack.Enums; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared.Memory; + +namespace NadePractice; + +[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 NadePracticePlugin +{ + 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; + } + + _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; + } + + 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/nades-sw/src/Events/PracticeGrenade.cs b/apps/nades-sw/src/Events/PracticeGrenade.cs new file mode 100644 index 00000000..06498cee --- /dev/null +++ b/apps/nades-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 NadePractice; + +// 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 NadePracticePlugin +{ + [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/nades-sw/src/Events/PracticePlayer.cs b/apps/nades-sw/src/Events/PracticePlayer.cs new file mode 100644 index 00000000..69dc175d --- /dev/null +++ b/apps/nades-sw/src/Events/PracticePlayer.cs @@ -0,0 +1,30 @@ +using SwiftlyS2.Shared.GameEventDefinitions; +using SwiftlyS2.Shared.GameEvents; +using SwiftlyS2.Shared.Misc; +using SwiftlyS2.Shared.SchemaDefinitions; + +namespace NadePractice; + +public partial class NadePracticePlugin +{ + // Practising smokes through your own flash is nobody's idea of practice. + [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/nades-sw/src/NadePractice.csproj b/apps/nades-sw/src/NadePractice.csproj new file mode 100644 index 00000000..ebdb65fa --- /dev/null +++ b/apps/nades-sw/src/NadePractice.csproj @@ -0,0 +1,51 @@ + + + true + true + NadePractice + NadePractice + $(MSBuildThisFileDirectory)build/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/nades-sw/src/NadePracticePlugin.cs b/apps/nades-sw/src/NadePracticePlugin.cs new file mode 100644 index 00000000..4f71d6df --- /dev/null +++ b/apps/nades-sw/src/NadePracticePlugin.cs @@ -0,0 +1,346 @@ +using System.Reflection; +using FiveStack.Entities.Practice; +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; + +namespace NadePractice; + +// Standalone plugin, installed through the 5stack game-plugin registry and +// bound to the nade-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 = "NadePractice", + Version = "__RELEASE_VERSION__", + Name = "nade-practice", + Author = "5Stack.gg", + Description = "Records grenade lineups in game and replays saved lineups back to the thrower" +)] +public partial class NadePracticePlugin : BasePlugin +{ + private ILogger _logger = null!; + private IServiceProvider _serviceProvider = null!; + private NadesConfig _config = null!; + private NadesApiClient _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 EventDelegates.OnMapLoad? _mapLoadHandler; + private EventDelegates.OnClientDisconnected? _disconnectHandler; + private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; + + public NadePracticePlugin(ISwiftlyCore core) + : base(core) { } + + public string ModuleVersion => + typeof(NadePracticePlugin).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/NadePractice. + string pluginDirectory = + Path.GetDirectoryName(typeof(NadePracticePlugin).Assembly.Location) ?? ""; + _config.Load(Path.Join(pluginDirectory, "../../configs"), pluginDirectory); + + _replay.IsSolo = _system.IsSolo; + _replay.WantsGhosts = _system.WantsGhosts; + // 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; + _score.Scored += _drill.OnScored; + + WirePlaybook(); + WireDrill(); + + _tickHandler = OnGameTick; + Core.Event.OnTick += _tickHandler; + + _entityCreatedHandler = @event => _recorder.OnProjectileCreated(@event.Entity); + Core.Event.OnEntityCreated += _entityCreatedHandler; + + _mapLoadHandler = @event => OnMapLoad(@event.MapName); + Core.Event.OnMapLoad += _mapLoadHandler; + + _disconnectHandler = @event => ForPlayer(@event.PlayerId, OnPlayerGone); + Core.Event.OnClientDisconnected += _disconnectHandler; + + _authorizeHandler = @event => + ForPlayer(@event.PlayerId, steamId => _library.Refresh(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); + + _library.SetMap(Core.Engine.GlobalVars.MapName.ToString()); + RefreshEverything(); + + _logger.LogInformation( + "nade practice {version} loaded (connected: {connected})", + ModuleVersion, + _config.IsConnected() + ); + } + + public override void Unload() + { + _session.Refreshed -= OnSessionRefreshed; + _recorder.Thrown -= _system.OnThrown; + _recorder.Finalized -= _score.OnFinalized; + _recorder.Thrown -= _drill.OnThrown; + _score.Scored -= _drill.OnScored; + + 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 (_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(); + } + + private void OnSecond() + { + _system.Tick(); + _playbook.Second(); + _drill.Second(); + _solver.RefreshVisibility(); + } + + 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.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); + } + + // 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(); + _library.SetMap(mapName); + + 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 (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player != null && player.IsValid && !player.IsFakeClient) + { + _library.Refresh(player.SteamID); + } + } + } + + private void OnSessionRefreshed(PracticeSessionData session) + { + if (!string.IsNullOrEmpty(session.password)) + { + SetPasswordBuffer(session.password); + } + } +} diff --git a/apps/nades-sw/src/Services/NadesApiClient.cs b/apps/nades-sw/src/Services/NadesApiClient.cs new file mode 100644 index 00000000..6bca97a3 --- /dev/null +++ b/apps/nades-sw/src/Services/NadesApiClient.cs @@ -0,0 +1,437 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace NadePractice; + +// 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 NadesApiClient +{ + // 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 NadesConfig _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 NadesApiClient(NadesConfig 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, + $"/nades/library?map={Uri.EscapeDataString(map)}&steam_id={steamId}", + null + ); + + if (body == null) + { + return null; + } + + try + { + List? rows = ReadList(body, "lineups", "nades"); + + 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) + { + byte[]? body = await Send( + HttpMethod.Get, + $"/nades/{Uri.EscapeDataString(id)}/trajectory", + null + ); + + if (body == null) + { + return null; + } + + try + { + return NadeTrajectoryArtifact.Parse(body); + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the trajectory for {id}", id); + return null; + } + } + + public async Task Session() + { + string? body = await SendText(HttpMethod.Get, "/nades/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(NadePracticeResultPayload payload) + { + NadePracticeResult? 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, $"/nades/{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) + { + NadePracticeResultPayload? 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(NadePracticeResultPayload 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( + NadeIngestPayload.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, "/nades/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(NadePracticeResultPayload 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, "/nades/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)); + request.Headers.Authorization = new AuthenticationHeaderValue( + "Bearer", + _config.NadesApiKey + ); + + 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) + { + _logger.LogWarning( + "{method} {path} returned {status}", + method.Method, + path, + (int)response.StatusCode + ); + return null; + } + + return await response.Content.ReadAsByteArrayAsync(); + } + catch (Exception error) + { + _logger.LogError(error, "{method} {path} failed", method.Method, path); + return null; + } + } + + // Every nade 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.NadesUrl}{path}"; + } + + string separator = path.Contains('?') ? "&" : "?"; + + return $"{_config.NadesUrl}{path}{separator}server_id={Uri.EscapeDataString(_config.ServerId)}"; + } +} diff --git a/apps/nades-sw/src/Services/NadesConfig.cs b/apps/nades-sw/src/Services/NadesConfig.cs new file mode 100644 index 00000000..b07388ad --- /dev/null +++ b/apps/nades-sw/src/Services/NadesConfig.cs @@ -0,0 +1,124 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace NadePractice; + +// Configuration arrives as addons/counterstrikesharp/configs/nade-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 NadesConfig +{ + public string NadesUrl { get; private set; } = ""; + public string NadesApiKey { get; private set; } = ""; + + // The plugin key alone buys nothing: every nade 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 InfiniteNades { 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 NadesConfig(ILogger logger) + { + _logger = logger; + } + + private class ConfigFile + { + public string? nades_url { get; set; } + public string? nades_apikey { 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_nades { 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/nade-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. + NadesUrl = Environment.GetEnvironmentVariable("NADES_URL") ?? ""; + NadesApiKey = Environment.GetEnvironmentVariable("NADES_API_KEY") ?? ""; + ServerId = Environment.GetEnvironmentVariable("SERVER_ID") ?? ""; + ServerApiPassword = Environment.GetEnvironmentVariable("SERVER_API_PASSWORD") ?? ""; + + string? path = configDirectories + .Where(directory => !string.IsNullOrEmpty(directory)) + .Select(directory => Path.Join(directory, "nade-practice.json")) + .FirstOrDefault(File.Exists); + + if (path != null) + { + try + { + ConfigFile? parsed = JsonSerializer.Deserialize( + File.ReadAllText(path) + ); + + if (parsed != null) + { + if (string.IsNullOrEmpty(NadesUrl)) + { + NadesUrl = parsed.nades_url ?? ""; + } + if (string.IsNullOrEmpty(NadesApiKey)) + { + NadesApiKey = parsed.nades_apikey ?? ""; + } + 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; + InfiniteNades = parsed.np_infinite_nades ?? InfiniteNades; + 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); + } + } + + NadesUrl = NadesUrl.TrimEnd('/'); + + if (string.IsNullOrEmpty(NadesUrl) || string.IsNullOrEmpty(NadesApiKey)) + { + // 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( + "nade practice is not connected to a panel; lineups cannot be saved or loaded" + ); + } + } + + public bool IsConnected() + { + return !string.IsNullOrEmpty(NadesUrl) && !string.IsNullOrEmpty(NadesApiKey); + } +} diff --git a/apps/nades-sw/src/Services/PracticeDrill.cs b/apps/nades-sw/src/Services/PracticeDrill.cs new file mode 100644 index 00000000..5f12d88f --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeDrill.cs @@ -0,0 +1,235 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; + +namespace NadePractice; + +// 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 NadesConfig _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(NadesConfig 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, NadePracticeResult? 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/nades-sw/src/Services/PracticeLibrary.cs b/apps/nades-sw/src/Services/PracticeLibrary.cs new file mode 100644 index 00000000..9d6e1578 --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeLibrary.cs @@ -0,0 +1,138 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared; + +namespace NadePractice; + +// 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 NadesApiClient _api; + private readonly ILogger _logger; + + private readonly Dictionary> _lineups = new(); + private string _map = ""; + + public PracticeLibrary( + ISwiftlyCore core, + NadesApiClient 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, Action ready) + { + if (lineup.trajectory.Count > 0 || string.IsNullOrEmpty(lineup.id)) + { + ready(lineup); + return; + } + + string id = lineup.id; + + _ = Task.Run(async () => + { + NadeTrajectoryArtifact? artifact = await _api.Trajectory(id); + + _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/nades-sw/src/Services/PracticePlaybook.cs b/apps/nades-sw/src/Services/PracticePlaybook.cs new file mode 100644 index 00000000..59e414ba --- /dev/null +++ b/apps/nades-sw/src/Services/PracticePlaybook.cs @@ -0,0 +1,254 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; + +namespace NadePractice; + +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 NadePlaybook? Loaded => _session.Current?.playbook; + + public IReadOnlyList Steps => PlaybookUtility.Ordered(Loaded); + + public ePlaybookStart Start(string map) + { + if (Running) + { + return ePlaybookStart.AlreadyRunning; + } + + NadePlaybook? 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 (NadePlaybookStep 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(NadePlaybookStep 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(NadePlaybookStep step) + { + string id = step.nade_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/nades-sw/src/Services/PracticeRecorder.cs b/apps/nades-sw/src/Services/PracticeRecorder.cs new file mode 100644 index 00000000..89e66588 --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeRecorder.cs @@ -0,0 +1,436 @@ +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 NadePractice; + +// 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; + } + + 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 _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(); + _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; + CBasePlayerWeapon? active = pawn?.WeaponServices?.ActiveWeapon.Value; + + if (pawn == null || !pawn.IsValid || 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.Value > 0) + { + state.Released = true; + state.Frozen = Snapshot(pawn, grenade); + _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 ThrowSnapshot Snapshot(CCSPlayerPawn pawn, CBaseCSGrenade grenade) + { + 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 + { + feet_position = 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 & (1 << 0)) != 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); + + if (Emitting || utilityType == null || _tracked.Count >= MaxTrackedProjectiles) + { + 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) + { + 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/nades-sw/src/Services/PracticeReplay.cs b/apps/nades-sw/src/Services/PracticeReplay.cs new file mode 100644 index 00000000..2469284f --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeReplay.cs @@ -0,0 +1,770 @@ +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 NadePractice; + +// 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; + + // 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 NadesConfig _config; + private readonly PracticeRecorder _recorder; + 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(); + + // 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, + NadesConfig 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; + + // 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. + public Func WantsGhosts { get; set; } = _ => true; + + public void Load(IPlayer player, LineupRecord lineup) + { + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return; + } + + Vec3 feet = lineup.release.feet_position; + var position = new Vector(feet.x, feet.y, feet.z); + var angles = new QAngle(lineup.release.pitch, lineup.release.yaw, 0); + + player.Teleport(position, angles, new Vector(0, 0, 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, angles, 2); + + GiveUtility(player, lineup.utility_type); + + player.SendCenter(Describe(lineup)); + } + + // Tier 1 preview: the recorded line, drawn as beam segments with a marker + // where it lands. + public void ShowGhost(IPlayer 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; + } + + _ghosts.Add( + new Ghost + { + OwnerSteamId = player.SteamID, + Kind = GhostKind.Line, + ExpiresAt = DateTime.UtcNow.AddSeconds(GhostSeconds), + Beams = beams, + } + ); + + ApplyVisibility(); + } + + // 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) + { + 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. + _ghosts.Add( + new Ghost + { + OwnerSteamId = player.SteamID, + Kind = GhostKind.Bloom, + ExpiresAt = DateTime.MaxValue, + Beams = beams, + } + ); + + ApplyVisibility(); + + 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) + { + 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; + ApplyVisibility(); + + 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) + { + Unblock(smoke.Index); + 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 (!_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(); + + 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); + } + } + + // Swiftly's transmit blocking is a standing per-player list rather than a + // per-frame callback, so it is recomputed whenever the set of beams or of + // solo players changes. + public void ApplyVisibility() + { + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) + { + if (viewer == null || !viewer.IsValid || viewer.IsFakeClient) + { + continue; + } + + bool viewerIsSolo = IsSolo(viewer.SteamID); + bool viewerWantsGhosts = WantsGhosts(viewer.SteamID); + + foreach (Ghost ghost in _ghosts) + { + bool hidden = + !viewerWantsGhosts + || ( + ghost.OwnerSteamId != viewer.SteamID + && (viewerIsSolo || IsSolo(ghost.OwnerSteamId)) + ); + + foreach (CEnvBeam beam in ghost.Beams) + { + if (beam.IsValid) + { + viewer.ShouldBlockTransmitEntity((int)beam.Index, hidden); + } + } + } + + foreach ((ulong owner, CSmokeGrenadeProjectile smoke) in _bloomSmoke) + { + if (!smoke.IsValid) + { + continue; + } + + viewer.ShouldBlockTransmitEntity( + (int)smoke.Index, + !viewerWantsGhosts + || (owner != viewer.SteamID && (viewerIsSolo || IsSolo(owner))) + ); + } + } + } + + 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(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 angles, int frames) + { + if (frames <= 0) + { + return; + } + + _core.Scheduler.NextTick(() => + { + if (!player.IsValid) + { + return; + } + + player.Teleport(null, angles, new Vector(0, 0, 0)); + ReapplyAngles(player, angles, 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; + } + + private CEnvBeam? CreateBeam(Vec3 start, Vec3 end, Color color, float width) + { + 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(); + + 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; + } + + Unblock(beam.Index); + beam.Despawn(); + } + } + + // Entity indices are reused, so a standing block has to be lifted before + // the entity goes away or it will hide whatever lands on that index next. + private void Unblock(uint index) + { + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) + { + if (viewer != null && viewer.IsValid) + { + viewer.ShouldBlockTransmitEntity((int)index, false); + } + } + } + + 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/nades-sw/src/Services/PracticeScore.cs b/apps/nades-sw/src/Services/PracticeScore.cs new file mode 100644 index 00000000..ca0345c9 --- /dev/null +++ b/apps/nades-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 NadePractice; + +// 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 NadesConfig _config; + private readonly NadesApiClient _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, + NadesConfig config, + NadesApiClient 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 = NadePracticeResultPayload.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 () => + { + NadePracticeResult? 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, + NadePracticeResult? 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, + NadePracticeResult? 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/nades-sw/src/Services/PracticeSession.cs b/apps/nades-sw/src/Services/PracticeSession.cs new file mode 100644 index 00000000..4d4f918e --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeSession.cs @@ -0,0 +1,54 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace NadePractice; + +// 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 NadesApiClient _api; + private readonly ILogger _logger; + + private PracticeSessionData? _session; + + public PracticeSession(NadesApiClient 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/nades-sw/src/Services/PracticeSolver.cs b/apps/nades-sw/src/Services/PracticeSolver.cs new file mode 100644 index 00000000..01da5be9 --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeSolver.cs @@ -0,0 +1,779 @@ +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 NadePractice; + +// 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) + { + Block(index, true); + } + + private void Unhide(uint index) + { + Block(index, false); + } + + private void Block(uint index, bool hidden) + { + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) + { + if (viewer != null && viewer.IsValid) + { + viewer.ShouldBlockTransmitEntity((int)index, hidden); + } + } + } + + private CBaseCSGrenadeProjectile? TryProjectileAt(uint index) + { + try + { + return _core.EntitySystem.GetEntityByIndex(index); + } + catch + { + return null; + } + } +} diff --git a/apps/nades-sw/src/Services/PracticeSystem.cs b/apps/nades-sw/src/Services/PracticeSystem.cs new file mode 100644 index 00000000..3ab0c5ad --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeSystem.cs @@ -0,0 +1,401 @@ +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 NadePractice; + +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 ISwiftlyCore _core; + private readonly NadesConfig _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, + NadesConfig 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; + } + + // 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); + _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. + public void OnThrown(ulong steamId, string utilityType) + { + if (!_config.InfiniteNades) + { + 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); + } + } + + 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) + { + player.Teleport( + new Vector( + position.feet_position.x, + position.feet_position.y, + position.feet_position.z + ), + new QAngle(position.pitch, position.yaw, 0), + new Vector(0, 0, 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/nades-sw/test/DrillUtilityTests.cs b/apps/nades-sw/test/DrillUtilityTests.cs new file mode 100644 index 00000000..a03fc58c --- /dev/null +++ b/apps/nades-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 NadePracticeResult Result( + bool success, + int attempts, + int successes, + bool mastered = false + ) + { + return new NadePracticeResult + { + 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/nades-sw/test/FiveStack.Tests.csproj b/apps/nades-sw/test/FiveStack.Tests.csproj new file mode 100644 index 00000000..79121e9f --- /dev/null +++ b/apps/nades-sw/test/FiveStack.Tests.csproj @@ -0,0 +1,43 @@ + + + false + bin/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/nades-sw/test/NadeArtifactTests.cs b/apps/nades-sw/test/NadeArtifactTests.cs new file mode 100644 index 00000000..f0ee7532 --- /dev/null +++ b/apps/nades-sw/test/NadeArtifactTests.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 NadeArtifactTests +{ + // 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() + { + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.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 = NadeTrajectoryArtifact.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); + } + + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.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() + { + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.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() + { + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.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() + { + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse( + """{"grenade_trajectories":[{"points":[]}],"smoke_volumes":[]}""" + ); + + Assert.Empty(artifact.path); + Assert.Null(artifact.smoke_volume); + } + + [Fact] + public void AVolumeWithNoExtentIsNotAVolume() + { + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.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() + { + NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.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": [ + { + "nade_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", + "nade_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(); + + NadePlaybook? 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() + { + NadePracticeResultPayload payload = NadePracticeResultPayload.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.nade_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() + { + NadePracticeResultPayload payload = NadePracticeResultPayload.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("\"nade_lineup_id\"", json); + } + + [Fact] + public void AResultIsReadBackWithThePanelsRadius() + { + NadePracticeResult? 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() + { + NadePracticeResult? 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/nades-sw/test/NadeWireTests.cs b/apps/nades-sw/test/NadeWireTests.cs new file mode 100644 index 00000000..65a6c3ef --- /dev/null +++ b/apps/nades-sw/test/NadeWireTests.cs @@ -0,0 +1,502 @@ +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 NadeWireTests +{ + 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() + { + NadeIngestPayload payload = NadeIngestPayload.From(Lineup()); + + Assert.Equal("76561198000000001", payload.author_steam_id); + Assert.Equal("Smoke", payload.nade_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() + { + NadeIngestPayload payload = NadeIngestPayload.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, NadeIngestPayload.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, NadeIngestPayload.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 TheNadeTypeIsSentInTheApisSpelling(string recorded, string expected) + { + LineupRecord lineup = Lineup(); + lineup.utility_type = recorded; + + Assert.Equal(expected, NadeIngestPayload.From(lineup).nade_type); + } + + [Fact] + public void ThePathIsSentAsObjectsNotPackedArrays() + { + string json = JsonSerializer.Serialize( + NadeIngestPayload.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("\"utility_type\"")] + [InlineData("\"release\"")] + [InlineData("\"trajectory\"")] + [InlineData("\"flight_time\":")] + [InlineData("\"confidence\"")] + public void FieldsTheApiDoesNotAcceptAreNotSent(string absent) + { + string json = JsonSerializer.Serialize( + NadeIngestPayload.From(Lineup()), + PracticeJson.Options + ); + + Assert.DoesNotContain(absent, json); + } + + private static NadeLibraryRow Row() + { + return new NadeLibraryRow + { + id = "panel-id", + name = "Window smoke", + map_name = "de_mirage", + nade_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() + { + NadeLibraryRow 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) + { + NadeLibraryRow 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() + { + NadeLibraryRow 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(); + NadeLibraryRow 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() + { + NadeLibraryRow 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 NadeLibraryRow Seeded(string? confidence = null) + { + NadeLibraryRow 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(); + NadeIngestPayload payload = NadeIngestPayload.From(original); + + var row = new NadeLibraryRow + { + id = "panel-id", + name = payload.name, + nade_type = payload.nade_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( + NadeIngestPayload.From(bare), + PracticeJson.Options + ); + + Assert.DoesNotContain("null", json); + Assert.DoesNotContain("\"description\"", json); + Assert.DoesNotContain("\"match_id\"", json); + } +} diff --git a/apps/nades-sw/test/PlaybookUtilityTests.cs b/apps/nades-sw/test/PlaybookUtilityTests.cs new file mode 100644 index 00000000..df849b67 --- /dev/null +++ b/apps/nades-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 NadePlaybookStep Step( + int order, + int offsetMs, + string? assigned = null, + bool withLineup = true, + bool seeded = false, + string? confidence = null + ) + { + return new NadePlaybookStep + { + nade_lineup_id = $"lineup-{order}", + step_order = order, + offset_ms = offsetMs, + assigned_steam_id = assigned, + note = $"step {order}", + lineup = withLineup + ? new NadeLibraryRow + { + id = $"row-{order}", + name = $"nade {order}", + nade_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 NadePlaybook Playbook(params NadePlaybookStep[] steps) + { + return new NadePlaybook + { + 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() + { + NadePlaybookStep 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() + { + NadePlaybookStep 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/nades-sw/test/PracticeCalibrationUtilityTests.cs b/apps/nades-sw/test/PracticeCalibrationUtilityTests.cs new file mode 100644 index 00000000..10aeeae4 --- /dev/null +++ b/apps/nades-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/nades-sw/test/PracticeConnectUtilityTests.cs b/apps/nades-sw/test/PracticeConnectUtilityTests.cs new file mode 100644 index 00000000..aa58969a --- /dev/null +++ b/apps/nades-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/nades-sw/test/PracticeDrillRunTests.cs b/apps/nades-sw/test/PracticeDrillRunTests.cs new file mode 100644 index 00000000..35aa1969 --- /dev/null +++ b/apps/nades-sw/test/PracticeDrillRunTests.cs @@ -0,0 +1,499 @@ +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 NadePracticeResult Result(bool success) + { + return new NadePracticeResult + { + 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); + } +} diff --git a/apps/nades-sw/test/PracticeJsonTests.cs b/apps/nades-sw/test/PracticeJsonTests.cs new file mode 100644 index 00000000..8c9c0f6e --- /dev/null +++ b/apps/nades-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 + ); + + NadePathPoint 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\"}"; + + NadeLibraryRow? 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/nades-sw/test/PracticeLaunchUtilityTests.cs b/apps/nades-sw/test/PracticeLaunchUtilityTests.cs new file mode 100644 index 00000000..95736933 --- /dev/null +++ b/apps/nades-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/nades-sw/test/PracticeLineupUtilityTests.cs b/apps/nades-sw/test/PracticeLineupUtilityTests.cs new file mode 100644 index 00000000..f493c9a3 --- /dev/null +++ b/apps/nades-sw/test/PracticeLineupUtilityTests.cs @@ -0,0 +1,140 @@ +using FiveStack.Entities.Practice; +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")); + } +} diff --git a/apps/nades-sw/test/PracticeSignalUtilityTests.cs b/apps/nades-sw/test/PracticeSignalUtilityTests.cs new file mode 100644 index 00000000..ccc3d187 --- /dev/null +++ b/apps/nades-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( + @"^\[nade-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/nades-sw/test/PracticeSolverPlanTests.cs b/apps/nades-sw/test/PracticeSolverPlanTests.cs new file mode 100644 index 00000000..30f3ea10 --- /dev/null +++ b/apps/nades-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/nades-sw/test/PracticeSolverUtilityTests.cs b/apps/nades-sw/test/PracticeSolverUtilityTests.cs new file mode 100644 index 00000000..720e1995 --- /dev/null +++ b/apps/nades-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/nades-sw/test/SmokeVolumeUtilityTests.cs b/apps/nades-sw/test/SmokeVolumeUtilityTests.cs new file mode 100644 index 00000000..4755a8ca --- /dev/null +++ b/apps/nades-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/nades-sw/test/TrajectoryUtilityTests.cs b/apps/nades-sw/test/TrajectoryUtilityTests.cs new file mode 100644 index 00000000..3fbe33ab --- /dev/null +++ b/apps/nades-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/swiftly/Dockerfile b/apps/swiftly/Dockerfile index f6571e56..bd519915 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_NADE_PRACTICE_PLUGIN is set. +FROM dotnet-sdk AS nades-build + +WORKDIR /mod + +COPY Directory.Build.props ./ +COPY apps/nades-sw/src/NadePractice.csproj apps/nades-sw/src/ + +RUN dotnet restore apps/nades-sw/src/NadePractice.csproj + +COPY shared shared +COPY apps/nades-sw apps/nades-sw + +ARG RELEASE_VERSION +ENV RELEASE_VERSION=${RELEASE_VERSION} + +RUN sed -i "s/__RELEASE_VERSION__/${RELEASE_VERSION}/" apps/nades-sw/src/NadePracticePlugin.cs + +RUN dotnet publish -c Release apps/nades-sw/src/NadePractice.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_NADE_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=nades-build /mod/release /opt/nade-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..2959459c 100755 --- a/apps/swiftly/scripts/setup.sh +++ b/apps/swiftly/scripts/setup.sh @@ -178,6 +178,31 @@ if $INSTALL_5STACK_PLUGIN = true ; then fi fi +# A practice server runs this instead of the match plugin, never alongside it. +if $INSTALL_NADE_PRACTICE_PLUGIN = true ; then + echo "---Install Nade Practice---" + NADE_PRACTICE_PLUGIN_DIR="${INSTANCE_SERVER_DIR}/game/csgo/addons/swiftlys2/plugins/NadePractice" + 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 "$NADE_PRACTICE_PLUGIN_DIR" + if mountpoint -q "$NADE_PRACTICE_PLUGIN_DIR"; then + echo "---Nade 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 "$NADE_PRACTICE_PLUGIN_DIR" 2>/dev/null + mkdir -p "/opt/dev/nades-sw" + ln -s "/opt/dev/nades-sw" "$NADE_PRACTICE_PLUGIN_DIR" + echo "---Nade Practice dev: symlinked /opt/dev/nades-sw (hot reload OFF; use 'sw plugins reload NadePractice')---" + fi + elif [ ! -e "$NADE_PRACTICE_PLUGIN_DIR" ]; then + ln -s "/opt/nade-practice" "$NADE_PRACTICE_PLUGIN_DIR" + else + echo "---Nade Practice: plugin dir already present, skipping /opt/nade-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/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..f45808bd --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs @@ -0,0 +1,87 @@ +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; } + + // 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/NadeIngestPayload.cs b/shared/dotnet/FiveStack.Entities/Practice/NadeIngestPayload.cs new file mode 100644 index 00000000..e95049ac --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/NadeIngestPayload.cs @@ -0,0 +1,92 @@ +using FiveStack.Utilities; + +namespace FiveStack.Entities.Practice; + +// The body of POST /nades/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 NadeIngestPayload +{ + public string? match_id { get; set; } + public string? author_steam_id { get; set; } + public string? nade_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; } + + 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 NadeIngestPayload From(LineupRecord lineup) + { + return new NadeIngestPayload + { + author_steam_id = Text(lineup.author_steam_id), + nade_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, + + flight_time_ms = MillisecondsFromSeconds(lineup.flight_time), + name = Text(lineup.name), + tick_rate = lineup.recorded_tickrate, + + path = lineup + .trajectory.Select(point => new NadePathPoint + { + 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/NadeLibraryRow.cs b/shared/dotnet/FiveStack.Entities/Practice/NadeLibraryRow.cs new file mode 100644 index 00000000..76f739eb --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/NadeLibraryRow.cs @@ -0,0 +1,128 @@ +using FiveStack.Utilities; + +namespace FiveStack.Entities.Practice; + +// A row of GET /nades/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 /nades/{id}/trajectory. +public class NadeLibraryRow +{ + public string? id { get; set; } + public string? name { get; set; } + public string? map_name { get; set; } + public string? nade_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; } + + 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(nade_type ?? ""), + side = side ?? "TERRORIST", + technique = technique ?? "", + strength = throw_strength, + 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/NadePathPoint.cs b/shared/dotnet/FiveStack.Entities/Practice/NadePathPoint.cs new file mode 100644 index 00000000..5c2d6c58 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/NadePathPoint.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 NadePathPoint +{ + 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/NadePlaybook.cs b/shared/dotnet/FiveStack.Entities/Practice/NadePlaybook.cs new file mode 100644 index 00000000..02325501 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/NadePlaybook.cs @@ -0,0 +1,47 @@ +namespace FiveStack.Entities.Practice; + +// An execute, as GET /nades/session returns it when one is loaded. Steps arrive +// ordered by step_order. +public class NadePlaybook +{ + 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 NadePlaybookStep +{ + public string? nade_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 NadeLibraryRow? 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(nade_lineup_id)) + { + record.id = nade_lineup_id; + record.client_id = nade_lineup_id; + } + + return record; + } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/NadePracticeResult.cs b/shared/dotnet/FiveStack.Entities/Practice/NadePracticeResult.cs new file mode 100644 index 00000000..700ef3fc --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/NadePracticeResult.cs @@ -0,0 +1,57 @@ +namespace FiveStack.Entities.Practice; + +// The body of POST /nades/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 NadePracticeResultPayload +{ + public string? server_id { get; set; } + public string? session_id { get; set; } + public string? nade_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 NadePracticeResultPayload For( + string? serverId, + Guid sessionId, + string lineupId, + ulong steamId, + Vec3 landing, + bool? success + ) + { + return new NadePracticeResultPayload + { + server_id = string.IsNullOrEmpty(serverId) ? null : serverId, + session_id = sessionId == Guid.Empty ? null : sessionId.ToString(), + nade_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 NadePracticeResult +{ + 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/NadeSessionRow.cs b/shared/dotnet/FiveStack.Entities/Practice/NadeSessionRow.cs new file mode 100644 index 00000000..c2f3756a --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/NadeSessionRow.cs @@ -0,0 +1,39 @@ +namespace FiveStack.Entities.Practice; + +// The body of GET /nades/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 NadeSessionRow +{ + 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 NadePlaybook? 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/NadeTrajectoryArtifact.cs b/shared/dotnet/FiveStack.Entities/Practice/NadeTrajectoryArtifact.cs new file mode 100644 index 00000000..2ae1cca9 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/NadeTrajectoryArtifact.cs @@ -0,0 +1,168 @@ +using System.Text.Json; +using FiveStack.Utilities; + +namespace FiveStack.Entities.Practice; + +// What GET /nades/{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 NadeTrajectoryArtifact +{ + public List path { get; set; } = new List(); + public SmokeVolume? smoke_volume { get; set; } + + public static NadeTrajectoryArtifact Parse(byte[] body) + { + return Parse(PracticeJson.Text(body)); + } + + public static NadeTrajectoryArtifact Parse(string body) + { + var artifact = new NadeTrajectoryArtifact(); + + 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/PracticeSessionData.cs b/shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs new file mode 100644 index 00000000..4a531087 --- /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 NadePlaybook? 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/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..373c8b82 --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eLineupVisibility.cs @@ -0,0 +1,9 @@ +namespace FiveStack.Enums; + +// Mirrors public.e_nade_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..dd961f72 --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eThrowStrength.cs @@ -0,0 +1,10 @@ +namespace FiveStack.Enums; + +// Mirrors public.e_nade_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..e3c70a0f --- /dev/null +++ b/shared/dotnet/FiveStack.Enums/eThrowTechnique.cs @@ -0,0 +1,16 @@ +namespace FiveStack.Enums; + +// Mirrors public.e_nade_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..2aeed18c --- /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(NadePracticeResult 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, NadePracticeResult? 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..c8f14b1e --- /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(NadePlaybook? 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(NadePlaybookStep 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(NadePlaybookStep 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..cabcd789 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs @@ -0,0 +1,284 @@ +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; + + public PracticeDrillRun(IReadOnlyList queue) + { + _queue = queue.ToList(); + } + + 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); + + 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; + } + + _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, NadePracticeResult? 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..63ae35d5 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -0,0 +1,197 @@ +using FiveStack.Entities.Practice; + +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" }, + }; + + 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; + } + + 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. + 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..bbb6eda8 --- /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 = "[nade-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..cf946822 --- /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, + }; + } + + // nade_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; + } +} From 2fc59f87784e7d784902ebb734330b217ca33a0f Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 18:24:40 -0400 Subject: [PATCH 002/107] practice plugin dev hot-reload --- apps/nades-css/scripts/dev.sh | 34 ++++++++++++ apps/nades-sw/scripts/dev.sh | 99 +++++++++++++++++++++++++++++++++++ codepier.yaml | 2 + scripts/dev.sh | 2 + 4 files changed, 137 insertions(+) create mode 100755 apps/nades-css/scripts/dev.sh create mode 100755 apps/nades-sw/scripts/dev.sh diff --git a/apps/nades-css/scripts/dev.sh b/apps/nades-css/scripts/dev.sh new file mode 100755 index 00000000..a83ce21f --- /dev/null +++ b/apps/nades-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/nades-css/src + +dotnet watch build --project apps/nades-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/nades-css/src/bin/Debug/net10.0" + +# Matches the path apps/counterstrikesharp/scripts/setup.sh symlinks in. +DEV_DIR="/opt/dev/nades-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/nades-sw/scripts/dev.sh b/apps/nades-sw/scripts/dev.sh new file mode 100755 index 00000000..ef20f71d --- /dev/null +++ b/apps/nades-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 NadePractice.dll changes. +# +# Chain: dotnet watch build -> apps/nades-sw/src/build/net10.0 -> cp -> /opt/dev -> SwiftlyS2 reload + +log() { echo "[dev.sh $(date '+%H:%M:%S')] $*"; } + +PROJECT="/opt/5stack/apps/nades-sw/src/NadePractice.csproj" +BUILD_OUTPUT="/opt/5stack/apps/nades-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/NadePractice" +if mountpoint -q "$PLUGIN_MOUNT" 2>/dev/null; then + DEV_DIR="$PLUGIN_MOUNT" +else + DEV_DIR="/opt/dev/nades-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/NadePractice.dll" ]; then + log "synced -> $DEV_DIR (NadePractice.dll $(stat -c%s "$DEV_DIR/NadePractice.dll" 2>/dev/null || echo '?') bytes)" + else + log "WARNING: copied but $DEV_DIR/NadePractice.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/codepier.yaml b/codepier.yaml index dfdc92f7..0ee8538f 100644 --- a/codepier.yaml +++ b/codepier.yaml @@ -4,6 +4,8 @@ container: dev workload: - dev-game-server - dev-swiftly-game-server + - dev-nades-game-server + - dev-nades-swiftly-game-server supplemental: true workdir: /opt/5stack sync: diff --git a/scripts/dev.sh b/scripts/dev.sh index 24613b71..c70187aa 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-nades-game-server) app="nades-css" ;; + dev-nades-swiftly-game-server) app="nades-sw" ;; "") echo "not inside a codepier pod; run 'codepier up' from the repo root first" >&2 exit 1 From fca96456f679fe8caf5bb2c49e6ee229f36fbf9d Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 19:04:41 -0400 Subject: [PATCH 003/107] rename nades to utility; squash utility migrations --- .github/workflows/ci.yaml | 8 +-- apps/counterstrikesharp/Dockerfile | 18 +++--- apps/counterstrikesharp/Dockerfile.dev | 2 +- apps/counterstrikesharp/scripts/setup.sh | 10 ++-- apps/swiftly/Dockerfile | 18 +++--- apps/swiftly/scripts/setup.sh | 26 ++++---- .../{nades-css => utility-css}/scripts/dev.sh | 8 +-- .../src/Commands/Practice.cs | 12 ++-- .../src/Commands/Solver.cs | 8 +-- .../src/Events/PracticeConnect.cs | 4 +- .../src/Events/PracticeGrenade.cs | 4 +- .../src/Events/PracticePlayer.cs | 4 +- .../src/Services/PracticeDrill.cs | 8 +-- .../src/Services/PracticeLibrary.cs | 8 +-- .../src/Services/PracticePlaybook.cs | 24 ++++---- .../src/Services/PracticeRecorder.cs | 2 +- .../src/Services/PracticeReplay.cs | 6 +- .../src/Services/PracticeScore.cs | 20 +++---- .../src/Services/PracticeSession.cs | 6 +- .../src/Services/PracticeSystem.cs | 8 +-- .../src/Services/UtilityApiClient.cs} | 59 +++++++++---------- .../src/Services/UtilityConfig.cs} | 47 +++++++-------- .../src/UtilityPractice.csproj} | 4 +- .../src/UtilityPracticePlugin.cs} | 24 ++++---- .../src/UtilityPracticeServiceCollection.cs} | 8 +-- .../test/DrillUtilityTests.cs | 4 +- .../test/FiveStack.Tests.csproj | 0 .../test/PlaybookUtilityTests.cs | 22 +++---- .../test/PracticeCalibrationUtilityTests.cs | 0 .../test/PracticeConnectUtilityTests.cs | 0 .../test/PracticeDrillRunTests.cs | 4 +- .../test/PracticeJsonTests.cs | 6 +- .../test/PracticeLaunchUtilityTests.cs | 0 .../test/PracticeLineupUtilityTests.cs | 0 .../test/PracticeSignalUtilityTests.cs | 2 +- .../test/PracticeSolverPlanTests.cs | 0 .../test/PracticeSolverUtilityTests.cs | 0 .../test/SmokeVolumeUtilityTests.cs | 0 .../test/TrajectoryUtilityTests.cs | 0 .../test/UtilityArtifactTests.cs} | 42 ++++++------- .../test/UtilityWireTests.cs} | 48 +++++++-------- apps/{nades-sw => utility-sw}/scripts/dev.sh | 18 +++--- .../src/Commands/Practice.cs | 12 ++-- .../src/Commands/Solver.cs | 10 ++-- .../src/Events/PracticeConnect.cs | 4 +- .../src/Events/PracticeGrenade.cs | 4 +- .../src/Events/PracticePlayer.cs | 4 +- .../src/Services/PracticeDrill.cs | 8 +-- .../src/Services/PracticeLibrary.cs | 8 +-- .../src/Services/PracticePlaybook.cs | 24 ++++---- .../src/Services/PracticeRecorder.cs | 2 +- .../src/Services/PracticeReplay.cs | 6 +- .../src/Services/PracticeScore.cs | 20 +++---- .../src/Services/PracticeSession.cs | 6 +- .../src/Services/PracticeSolver.cs | 2 +- .../src/Services/PracticeSystem.cs | 8 +-- .../src/Services/UtilityApiClient.cs} | 59 +++++++++---------- .../src/Services/UtilityConfig.cs} | 47 +++++++-------- .../src/UtilityPractice.csproj} | 4 +- .../src/UtilityPracticePlugin.cs} | 36 +++++------ .../test/DrillUtilityTests.cs | 4 +- .../test/FiveStack.Tests.csproj | 0 .../test/PlaybookUtilityTests.cs | 22 +++---- .../test/PracticeCalibrationUtilityTests.cs | 0 .../test/PracticeConnectUtilityTests.cs | 0 .../test/PracticeDrillRunTests.cs | 4 +- .../test/PracticeJsonTests.cs | 6 +- .../test/PracticeLaunchUtilityTests.cs | 0 .../test/PracticeLineupUtilityTests.cs | 0 .../test/PracticeSignalUtilityTests.cs | 2 +- .../test/PracticeSolverPlanTests.cs | 0 .../test/PracticeSolverUtilityTests.cs | 0 .../test/SmokeVolumeUtilityTests.cs | 0 .../test/TrajectoryUtilityTests.cs | 0 .../test/UtilityArtifactTests.cs} | 42 ++++++------- .../test/UtilityWireTests.cs} | 48 +++++++-------- codepier.yaml | 4 +- scripts/dev.sh | 4 +- .../Practice/PracticeSessionData.cs | 2 +- ...gestPayload.cs => UtilityIngestPayload.cs} | 16 ++--- ...NadeLibraryRow.cs => UtilityLibraryRow.cs} | 10 ++-- .../{NadePathPoint.cs => UtilityPathPoint.cs} | 2 +- .../{NadePlaybook.cs => UtilityPlaybook.cs} | 18 +++--- ...ticeResult.cs => UtilityPracticeResult.cs} | 14 ++--- ...NadeSessionRow.cs => UtilitySessionRow.cs} | 6 +- ...tifact.cs => UtilityTrajectoryArtifact.cs} | 10 ++-- .../FiveStack.Enums/eLineupVisibility.cs | 2 +- .../dotnet/FiveStack.Enums/eThrowStrength.cs | 2 +- .../dotnet/FiveStack.Enums/eThrowTechnique.cs | 2 +- .../FiveStack.Utilities/DrillUtility.cs | 4 +- .../FiveStack.Utilities/PlaybookUtility.cs | 14 ++--- .../FiveStack.Utilities/PracticeDrillRun.cs | 2 +- .../PracticeSignalUtility.cs | 2 +- .../PracticeSolverUtility.cs | 2 +- 94 files changed, 488 insertions(+), 512 deletions(-) rename apps/{nades-css => utility-css}/scripts/dev.sh (78%) rename apps/{nades-css => utility-css}/src/Commands/Practice.cs (98%) rename apps/{nades-css => utility-css}/src/Commands/Solver.cs (88%) rename apps/{nades-css => utility-css}/src/Events/PracticeConnect.cs (98%) rename apps/{nades-css => utility-css}/src/Events/PracticeGrenade.cs (96%) rename apps/{nades-css => utility-css}/src/Events/PracticePlayer.cs (97%) rename apps/{nades-css => utility-css}/src/Services/PracticeDrill.cs (96%) rename apps/{nades-css => utility-css}/src/Services/PracticeLibrary.cs (94%) rename apps/{nades-sw => utility-css}/src/Services/PracticePlaybook.cs (89%) rename apps/{nades-css => utility-css}/src/Services/PracticeRecorder.cs (99%) rename apps/{nades-css => utility-css}/src/Services/PracticeReplay.cs (98%) rename apps/{nades-css => utility-css}/src/Services/PracticeScore.cs (91%) rename apps/{nades-css => utility-css}/src/Services/PracticeSession.cs (90%) rename apps/{nades-css => utility-css}/src/Services/PracticeSystem.cs (98%) rename apps/{nades-sw/src/Services/NadesApiClient.cs => utility-css/src/Services/UtilityApiClient.cs} (83%) rename apps/{nades-css/src/Services/NadesConfig.cs => utility-css/src/Services/UtilityConfig.cs} (67%) rename apps/{nades-css/src/NadePractice.csproj => utility-css/src/UtilityPractice.csproj} (97%) rename apps/{nades-css/src/NadePracticePlugin.cs => utility-css/src/UtilityPracticePlugin.cs} (92%) rename apps/{nades-css/src/NadePracticeServiceCollection.cs => utility-css/src/UtilityPracticeServiceCollection.cs} (76%) rename apps/{nades-sw => utility-css}/test/DrillUtilityTests.cs (99%) rename apps/{nades-css => utility-css}/test/FiveStack.Tests.csproj (100%) rename apps/{nades-css => utility-css}/test/PlaybookUtilityTests.cs (91%) rename apps/{nades-css => utility-css}/test/PracticeCalibrationUtilityTests.cs (100%) rename apps/{nades-css => utility-css}/test/PracticeConnectUtilityTests.cs (100%) rename apps/{nades-sw => utility-css}/test/PracticeDrillRunTests.cs (99%) rename apps/{nades-css => utility-css}/test/PracticeJsonTests.cs (88%) rename apps/{nades-css => utility-css}/test/PracticeLaunchUtilityTests.cs (100%) rename apps/{nades-css => utility-css}/test/PracticeLineupUtilityTests.cs (100%) rename apps/{nades-css => utility-css}/test/PracticeSignalUtilityTests.cs (95%) rename apps/{nades-css => utility-css}/test/PracticeSolverPlanTests.cs (100%) rename apps/{nades-css => utility-css}/test/PracticeSolverUtilityTests.cs (100%) rename apps/{nades-css => utility-css}/test/SmokeVolumeUtilityTests.cs (100%) rename apps/{nades-css => utility-css}/test/TrajectoryUtilityTests.cs (100%) rename apps/{nades-css/test/NadeArtifactTests.cs => utility-css/test/UtilityArtifactTests.cs} (87%) rename apps/{nades-css/test/NadeWireTests.cs => utility-css/test/UtilityWireTests.cs} (92%) rename apps/{nades-sw => utility-sw}/scripts/dev.sh (80%) rename apps/{nades-sw => utility-sw}/src/Commands/Practice.cs (98%) rename apps/{nades-sw => utility-sw}/src/Commands/Solver.cs (97%) rename apps/{nades-sw => utility-sw}/src/Events/PracticeConnect.cs (99%) rename apps/{nades-sw => utility-sw}/src/Events/PracticeGrenade.cs (98%) rename apps/{nades-sw => utility-sw}/src/Events/PracticePlayer.cs (90%) rename apps/{nades-sw => utility-sw}/src/Services/PracticeDrill.cs (96%) rename apps/{nades-sw => utility-sw}/src/Services/PracticeLibrary.cs (95%) rename apps/{nades-css => utility-sw}/src/Services/PracticePlaybook.cs (89%) rename apps/{nades-sw => utility-sw}/src/Services/PracticeRecorder.cs (99%) rename apps/{nades-sw => utility-sw}/src/Services/PracticeReplay.cs (99%) rename apps/{nades-sw => utility-sw}/src/Services/PracticeScore.cs (91%) rename apps/{nades-sw => utility-sw}/src/Services/PracticeSession.cs (90%) rename apps/{nades-sw => utility-sw}/src/Services/PracticeSolver.cs (99%) rename apps/{nades-sw => utility-sw}/src/Services/PracticeSystem.cs (98%) rename apps/{nades-css/src/Services/NadesApiClient.cs => utility-sw/src/Services/UtilityApiClient.cs} (83%) rename apps/{nades-sw/src/Services/NadesConfig.cs => utility-sw/src/Services/UtilityConfig.cs} (68%) rename apps/{nades-sw/src/NadePractice.csproj => utility-sw/src/UtilityPractice.csproj} (98%) rename apps/{nades-sw/src/NadePracticePlugin.cs => utility-sw/src/UtilityPracticePlugin.cs} (90%) rename apps/{nades-css => utility-sw}/test/DrillUtilityTests.cs (99%) rename apps/{nades-sw => utility-sw}/test/FiveStack.Tests.csproj (100%) rename apps/{nades-sw => utility-sw}/test/PlaybookUtilityTests.cs (91%) rename apps/{nades-sw => utility-sw}/test/PracticeCalibrationUtilityTests.cs (100%) rename apps/{nades-sw => utility-sw}/test/PracticeConnectUtilityTests.cs (100%) rename apps/{nades-css => utility-sw}/test/PracticeDrillRunTests.cs (99%) rename apps/{nades-sw => utility-sw}/test/PracticeJsonTests.cs (88%) rename apps/{nades-sw => utility-sw}/test/PracticeLaunchUtilityTests.cs (100%) rename apps/{nades-sw => utility-sw}/test/PracticeLineupUtilityTests.cs (100%) rename apps/{nades-sw => utility-sw}/test/PracticeSignalUtilityTests.cs (95%) rename apps/{nades-sw => utility-sw}/test/PracticeSolverPlanTests.cs (100%) rename apps/{nades-sw => utility-sw}/test/PracticeSolverUtilityTests.cs (100%) rename apps/{nades-sw => utility-sw}/test/SmokeVolumeUtilityTests.cs (100%) rename apps/{nades-sw => utility-sw}/test/TrajectoryUtilityTests.cs (100%) rename apps/{nades-sw/test/NadeArtifactTests.cs => utility-sw/test/UtilityArtifactTests.cs} (87%) rename apps/{nades-sw/test/NadeWireTests.cs => utility-sw/test/UtilityWireTests.cs} (92%) rename shared/dotnet/FiveStack.Entities/Practice/{NadeIngestPayload.cs => UtilityIngestPayload.cs} (86%) rename shared/dotnet/FiveStack.Entities/Practice/{NadeLibraryRow.cs => UtilityLibraryRow.cs} (94%) rename shared/dotnet/FiveStack.Entities/Practice/{NadePathPoint.cs => UtilityPathPoint.cs} (93%) rename shared/dotnet/FiveStack.Entities/Practice/{NadePlaybook.cs => UtilityPlaybook.cs} (66%) rename shared/dotnet/FiveStack.Entities/Practice/{NadePracticeResult.cs => UtilityPracticeResult.cs} (81%) rename shared/dotnet/FiveStack.Entities/Practice/{NadeSessionRow.cs => UtilitySessionRow.cs} (89%) rename shared/dotnet/FiveStack.Entities/Practice/{NadeTrajectoryArtifact.cs => UtilityTrajectoryArtifact.cs} (94%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b5f40348..6059cb57 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -51,11 +51,11 @@ jobs: css: - *shared - 'apps/counterstrikesharp/**' - - 'apps/nades-css/**' + - 'apps/utility-css/**' sw: - *shared - 'apps/swiftly/**' - - 'apps/nades-sw/**' + - 'apps/utility-sw/**' validator: - 'apps/gamedata-validator/**' - 'shared/gamedata/**' @@ -75,7 +75,7 @@ jobs: version_tag_regex: '^(?:css-)?v0\.0\.(\d+)$' channel: ${{ needs.changes.outputs.channel }} run_tests: true - extra_test_dir: apps/nades-css + extra_test_dir: apps/utility-css secrets: inherit swiftly: @@ -91,7 +91,7 @@ jobs: version_floor: 41 channel: ${{ needs.changes.outputs.channel }} run_tests: true - extra_test_dir: apps/nades-sw + extra_test_dir: apps/utility-sw secrets: inherit gamedata-validator: diff --git a/apps/counterstrikesharp/Dockerfile b/apps/counterstrikesharp/Dockerfile index d8cd8563..11d53274 100644 --- a/apps/counterstrikesharp/Dockerfile +++ b/apps/counterstrikesharp/Dockerfile @@ -39,25 +39,25 @@ 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_NADE_PRACTICE_PLUGIN is set. -FROM dotnet-sdk AS nades-build +# only when INSTALL_UTILITY_PRACTICE_PLUGIN is set. +FROM dotnet-sdk AS utility-build WORKDIR /mod COPY Directory.Build.props ./ -COPY apps/nades-css/src/NadePractice.csproj apps/nades-css/src/ +COPY apps/utility-css/src/UtilityPractice.csproj apps/utility-css/src/ -RUN dotnet restore apps/nades-css/src/NadePractice.csproj +RUN dotnet restore apps/utility-css/src/UtilityPractice.csproj COPY shared shared -COPY apps/nades-css apps/nades-css +COPY apps/utility-css apps/utility-css ARG RELEASE_VERSION ENV RELEASE_VERSION=${RELEASE_VERSION} -RUN sed -i "s/__RELEASE_VERSION__/${RELEASE_VERSION}/" apps/nades-css/src/NadePracticePlugin.cs +RUN sed -i "s/__RELEASE_VERSION__/${RELEASE_VERSION}/" apps/utility-css/src/UtilityPracticePlugin.cs -RUN dotnet build -c Release apps/nades-css/src/NadePractice.csproj -o release +RUN dotnet build -c Release apps/utility-css/src/UtilityPractice.csproj -o release RUN rm -f /mod/release/CounterStrikeSharp.API.dll @@ -86,7 +86,7 @@ ENV AUTOLOAD_PLUGINS=true ENV PLUGINS_DIR="/opt/custom-plugins" ENV INSTALL_5STACK_PLUGIN=true -ENV INSTALL_NADE_PRACTICE_PLUGIN=false +ENV INSTALL_UTILITY_PRACTICE_PLUGIN=false ENV GAME_ID="730" ENV GAME_PARAMS="" @@ -152,7 +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=nades-build /mod/release /opt/nade-practice +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 e76ad75a..404cb73d 100644 --- a/apps/counterstrikesharp/Dockerfile.dev +++ b/apps/counterstrikesharp/Dockerfile.dev @@ -11,7 +11,7 @@ ENV AUTOLOAD_PLUGINS=true ENV PLUGINS_DIR="/opt/custom-plugins" ENV INSTALL_5STACK_PLUGIN=true -ENV INSTALL_NADE_PRACTICE_PLUGIN=false +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 5fe1eb41..5149fa09 100755 --- a/apps/counterstrikesharp/scripts/setup.sh +++ b/apps/counterstrikesharp/scripts/setup.sh @@ -130,14 +130,14 @@ if $INSTALL_5STACK_PLUGIN = true ; then fi # A practice server runs this instead of the match plugin, never alongside it. -if $INSTALL_NADE_PRACTICE_PLUGIN = true ; then - echo "---Install Nade Practice---" +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/nades-css" - ln -s "/opt/dev/nades-css" "${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/plugins/NadePractice" + 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/nade-practice" "${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/plugins/NadePractice" + ln -s "/opt/utility-practice" "${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/plugins/UtilityPractice" fi fi diff --git a/apps/swiftly/Dockerfile b/apps/swiftly/Dockerfile index bd519915..dcd8a74a 100644 --- a/apps/swiftly/Dockerfile +++ b/apps/swiftly/Dockerfile @@ -34,25 +34,25 @@ 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_NADE_PRACTICE_PLUGIN is set. -FROM dotnet-sdk AS nades-build +# only when INSTALL_UTILITY_PRACTICE_PLUGIN is set. +FROM dotnet-sdk AS utility-build WORKDIR /mod COPY Directory.Build.props ./ -COPY apps/nades-sw/src/NadePractice.csproj apps/nades-sw/src/ +COPY apps/utility-sw/src/UtilityPractice.csproj apps/utility-sw/src/ -RUN dotnet restore apps/nades-sw/src/NadePractice.csproj +RUN dotnet restore apps/utility-sw/src/UtilityPractice.csproj COPY shared shared -COPY apps/nades-sw apps/nades-sw +COPY apps/utility-sw apps/utility-sw ARG RELEASE_VERSION ENV RELEASE_VERSION=${RELEASE_VERSION} -RUN sed -i "s/__RELEASE_VERSION__/${RELEASE_VERSION}/" apps/nades-sw/src/NadePracticePlugin.cs +RUN sed -i "s/__RELEASE_VERSION__/${RELEASE_VERSION}/" apps/utility-sw/src/UtilityPracticePlugin.cs -RUN dotnet publish -c Release apps/nades-sw/src/NadePractice.csproj -o /mod/release +RUN dotnet publish -c Release apps/utility-sw/src/UtilityPractice.csproj -o /mod/release FROM debian:bookworm-slim AS zip-creator @@ -78,7 +78,7 @@ ENV AUTOLOAD_PLUGINS=true ENV PLUGINS_DIR="/opt/custom-plugins" ENV INSTALL_5STACK_PLUGIN=true -ENV INSTALL_NADE_PRACTICE_PLUGIN=false +ENV INSTALL_UTILITY_PRACTICE_PLUGIN=false ENV GAME_ID="730" ENV GAME_PARAMS="" @@ -157,7 +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=nades-build /mod/release /opt/nade-practice +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 2959459c..20f9c7f3 100755 --- a/apps/swiftly/scripts/setup.sh +++ b/apps/swiftly/scripts/setup.sh @@ -179,27 +179,27 @@ if $INSTALL_5STACK_PLUGIN = true ; then fi # A practice server runs this instead of the match plugin, never alongside it. -if $INSTALL_NADE_PRACTICE_PLUGIN = true ; then - echo "---Install Nade Practice---" - NADE_PRACTICE_PLUGIN_DIR="${INSTANCE_SERVER_DIR}/game/csgo/addons/swiftlys2/plugins/NadePractice" +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 "$NADE_PRACTICE_PLUGIN_DIR" - if mountpoint -q "$NADE_PRACTICE_PLUGIN_DIR"; then - echo "---Nade Practice dev: plugin dir is a mounted volume (hot reload enabled)---" + 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 "$NADE_PRACTICE_PLUGIN_DIR" 2>/dev/null - mkdir -p "/opt/dev/nades-sw" - ln -s "/opt/dev/nades-sw" "$NADE_PRACTICE_PLUGIN_DIR" - echo "---Nade Practice dev: symlinked /opt/dev/nades-sw (hot reload OFF; use 'sw plugins reload NadePractice')---" + 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 "$NADE_PRACTICE_PLUGIN_DIR" ]; then - ln -s "/opt/nade-practice" "$NADE_PRACTICE_PLUGIN_DIR" + elif [ ! -e "$UTILITY_PRACTICE_PLUGIN_DIR" ]; then + ln -s "/opt/utility-practice" "$UTILITY_PRACTICE_PLUGIN_DIR" else - echo "---Nade Practice: plugin dir already present, skipping /opt/nade-practice symlink---" + echo "---Utility Practice: plugin dir already present, skipping /opt/utility-practice symlink---" fi fi diff --git a/apps/nades-css/scripts/dev.sh b/apps/utility-css/scripts/dev.sh similarity index 78% rename from apps/nades-css/scripts/dev.sh rename to apps/utility-css/scripts/dev.sh index a83ce21f..8aa38c55 100755 --- a/apps/nades-css/scripts/dev.sh +++ b/apps/utility-css/scripts/dev.sh @@ -13,18 +13,18 @@ kill_dotnet_watch() { exit fi } -dotnet build apps/nades-css/src +dotnet build apps/utility-css/src -dotnet watch build --project apps/nades-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/nades-css/src/bin/Debug/net10.0" +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/nades-css" +DEV_DIR="/opt/dev/utility-css" mkdir -p "$DEV_DIR" while true; do diff --git a/apps/nades-css/src/Commands/Practice.cs b/apps/utility-css/src/Commands/Practice.cs similarity index 98% rename from apps/nades-css/src/Commands/Practice.cs rename to apps/utility-css/src/Commands/Practice.cs index dced0160..10eff73e 100644 --- a/apps/nades-css/src/Commands/Practice.cs +++ b/apps/utility-css/src/Commands/Practice.cs @@ -7,13 +7,13 @@ using FiveStack.Enums; using FiveStack.Utilities; -namespace NadePractice; +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 NadePracticePlugin +public partial class UtilityPracticePlugin { [ConsoleCommand("css_save", "Saves your last throw as a named lineup")] [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] @@ -633,7 +633,7 @@ public void OnPracticeHelp(CCSPlayerController? player, CommandInfo command) // Server-only and deliberately unprefixed, like the match plugin's // get_match: the panel calls it when the roster or the library changes. - [ConsoleCommand("nade_practice_refresh", "Re-reads the practice session from the panel")] + [ConsoleCommand("utility_practice_refresh", "Re-reads the practice session from the panel")] [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)] public void OnRefresh(CCSPlayerController? player, CommandInfo command) { @@ -642,7 +642,7 @@ public void OnRefresh(CCSPlayerController? player, CommandInfo command) private static readonly string[] HelpLines = new[] { - $" {ChatColors.Green}nade practice", + $" {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", @@ -677,7 +677,7 @@ private void StartPlaybook(CCSPlayerController player, CommandInfo command) return; } - IReadOnlyList steps = _playbook.Steps; + IReadOnlyList steps = _playbook.Steps; Server.PrintToChatAll( $" {ChatColors.Green}{player.PlayerName} started {ChatColors.Default}{_playbook.Loaded?.name} {ChatColors.Grey}({steps.Count} steps)" @@ -685,7 +685,7 @@ private void StartPlaybook(CCSPlayerController player, CommandInfo command) for (int index = 0; index < steps.Count; index++) { - NadePlaybookStep step = steps[index]; + UtilityPlaybookStep step = steps[index]; string who = PlaybookUtility.IsAssigned(step) ? step.assigned_steam_id! : "anyone"; command.ReplyToCommand( diff --git a/apps/nades-css/src/Commands/Solver.cs b/apps/utility-css/src/Commands/Solver.cs similarity index 88% rename from apps/nades-css/src/Commands/Solver.cs rename to apps/utility-css/src/Commands/Solver.cs index 9f20723c..69016f06 100644 --- a/apps/nades-css/src/Commands/Solver.cs +++ b/apps/utility-css/src/Commands/Solver.cs @@ -5,7 +5,7 @@ using FiveStack.Entities.Practice; using FiveStack.Utilities; -namespace NadePractice; +namespace UtilityPractice; // The solver is a Swiftly-only feature, and these exist so it fails as an // answer rather than as silence. @@ -16,7 +16,7 @@ namespace NadePractice; // 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 NadePracticePlugin +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"; @@ -28,7 +28,7 @@ public void OnSolve(CCSPlayerController? player, CommandInfo command) command.ReplyToCommand($" {ChatColors.Red}{Unsupported}"); } - [ConsoleCommand("nade_solver_solve", "Solves a throw onto a point (SwiftlyS2 only)")] + [ConsoleCommand("utility_solver_solve", "Solves a throw onto a point (SwiftlyS2 only)")] [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)] public void OnSolverSolve(CCSPlayerController? player, CommandInfo command) { @@ -38,7 +38,7 @@ public void OnSolverSolve(CCSPlayerController? player, CommandInfo command) // 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("nade_solver_calibrate", "Checks the solver's premise (SwiftlyS2 only)")] + [ConsoleCommand("utility_solver_calibrate", "Checks the solver's premise (SwiftlyS2 only)")] [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)] public void OnSolverCalibrate(CCSPlayerController? player, CommandInfo command) { diff --git a/apps/nades-css/src/Events/PracticeConnect.cs b/apps/utility-css/src/Events/PracticeConnect.cs similarity index 98% rename from apps/nades-css/src/Events/PracticeConnect.cs rename to apps/utility-css/src/Events/PracticeConnect.cs index 54772538..0b6df1e6 100644 --- a/apps/nades-css/src/Events/PracticeConnect.cs +++ b/apps/utility-css/src/Events/PracticeConnect.cs @@ -6,12 +6,12 @@ using FiveStack.Enums; using FiveStack.Utilities; -namespace NadePractice; +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 NadePracticePlugin +public partial class UtilityPracticePlugin { private static int PasswordBufferLength = 86; public static nint PasswordBuffer { get; set; } = nint.Zero; diff --git a/apps/nades-css/src/Events/PracticeGrenade.cs b/apps/utility-css/src/Events/PracticeGrenade.cs similarity index 96% rename from apps/nades-css/src/Events/PracticeGrenade.cs rename to apps/utility-css/src/Events/PracticeGrenade.cs index d64791ef..968e3a83 100644 --- a/apps/nades-css/src/Events/PracticeGrenade.cs +++ b/apps/utility-css/src/Events/PracticeGrenade.cs @@ -2,7 +2,7 @@ using CounterStrikeSharp.API.Core.Attributes.Registration; using FiveStack.Entities.Practice; -namespace NadePractice; +namespace UtilityPractice; // Detonation is the other half of a recording: the projectile stops existing // and we finally know where the lineup lands. @@ -10,7 +10,7 @@ namespace NadePractice; // 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 NadePracticePlugin +public partial class UtilityPracticePlugin { [GameEventHandler] public HookResult OnSmokeDetonate(EventSmokegrenadeDetonate @event, GameEventInfo info) diff --git a/apps/nades-css/src/Events/PracticePlayer.cs b/apps/utility-css/src/Events/PracticePlayer.cs similarity index 97% rename from apps/nades-css/src/Events/PracticePlayer.cs rename to apps/utility-css/src/Events/PracticePlayer.cs index f7373793..62d214e4 100644 --- a/apps/nades-css/src/Events/PracticePlayer.cs +++ b/apps/utility-css/src/Events/PracticePlayer.cs @@ -3,9 +3,9 @@ using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Modules.Entities; -namespace NadePractice; +namespace UtilityPractice; -public partial class NadePracticePlugin +public partial class UtilityPracticePlugin { // Practising smokes through your own flash is nobody's idea of practice. [GameEventHandler] diff --git a/apps/nades-css/src/Services/PracticeDrill.cs b/apps/utility-css/src/Services/PracticeDrill.cs similarity index 96% rename from apps/nades-css/src/Services/PracticeDrill.cs rename to apps/utility-css/src/Services/PracticeDrill.cs index 5f12d88f..bdaf44de 100644 --- a/apps/nades-css/src/Services/PracticeDrill.cs +++ b/apps/utility-css/src/Services/PracticeDrill.cs @@ -2,7 +2,7 @@ using FiveStack.Enums; using FiveStack.Utilities; -namespace NadePractice; +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. @@ -15,7 +15,7 @@ namespace NadePractice; // server and none of them are told about each other's runs. public class PracticeDrill { - private readonly NadesConfig _config; + private readonly UtilityConfig _config; private readonly PracticeLibrary _library; private readonly Dictionary _runs = @@ -24,7 +24,7 @@ public class PracticeDrill private readonly DrillProgressBook _progress = new DrillProgressBook(); private readonly Random _random = new Random(); - public PracticeDrill(NadesConfig config, PracticeLibrary library) + public PracticeDrill(UtilityConfig config, PracticeLibrary library) { _config = config; _library = library; @@ -120,7 +120,7 @@ public void OnThrown(ulong steamId, string utilityType) } // The panel's answer, or the fact that there was not one. - public void OnScored(ulong steamId, string lineupId, NadePracticeResult? result) + 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 diff --git a/apps/nades-css/src/Services/PracticeLibrary.cs b/apps/utility-css/src/Services/PracticeLibrary.cs similarity index 94% rename from apps/nades-css/src/Services/PracticeLibrary.cs rename to apps/utility-css/src/Services/PracticeLibrary.cs index 9d98b581..2ce6e65b 100644 --- a/apps/nades-css/src/Services/PracticeLibrary.cs +++ b/apps/utility-css/src/Services/PracticeLibrary.cs @@ -3,20 +3,20 @@ using FiveStack.Utilities; using Microsoft.Extensions.Logging; -namespace NadePractice; +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 NadesApiClient _api; + private readonly UtilityApiClient _api; private readonly ILogger _logger; private readonly Dictionary> _lineups = new(); private string _map = ""; - public PracticeLibrary(NadesApiClient api, ILogger logger) + public PracticeLibrary(UtilityApiClient api, ILogger logger) { _api = api; _logger = logger; @@ -83,7 +83,7 @@ public void EnsureTrajectory(LineupRecord lineup, Action ready) _ = Task.Run(async () => { - NadeTrajectoryArtifact? artifact = await _api.Trajectory(id); + UtilityTrajectoryArtifact? artifact = await _api.Trajectory(id); Server.NextFrame(() => { diff --git a/apps/nades-sw/src/Services/PracticePlaybook.cs b/apps/utility-css/src/Services/PracticePlaybook.cs similarity index 89% rename from apps/nades-sw/src/Services/PracticePlaybook.cs rename to apps/utility-css/src/Services/PracticePlaybook.cs index 59e414ba..d32a979f 100644 --- a/apps/nades-sw/src/Services/PracticePlaybook.cs +++ b/apps/utility-css/src/Services/PracticePlaybook.cs @@ -1,7 +1,7 @@ using FiveStack.Entities.Practice; using FiveStack.Utilities; -namespace NadePractice; +namespace UtilityPractice; public enum ePlaybookStart { @@ -35,7 +35,7 @@ private enum Phase private Phase _phase = Phase.Idle; private string _name = ""; - private List _steps = new List(); + private List _steps = new List(); // When t=0 is, which is the end of the countdown rather than the moment // .playbook was typed. @@ -68,9 +68,9 @@ public PracticePlaybook(PracticeSession session, PracticeSystem system) public bool Running => _phase != Phase.Idle; - public NadePlaybook? Loaded => _session.Current?.playbook; + public UtilityPlaybook? Loaded => _session.Current?.playbook; - public IReadOnlyList Steps => PlaybookUtility.Ordered(Loaded); + public IReadOnlyList Steps => PlaybookUtility.Ordered(Loaded); public ePlaybookStart Start(string map) { @@ -79,7 +79,7 @@ public ePlaybookStart Start(string map) return ePlaybookStart.AlreadyRunning; } - NadePlaybook? playbook = Loaded; + UtilityPlaybook? playbook = Loaded; if (playbook == null) { @@ -95,7 +95,7 @@ public ePlaybookStart Start(string map) return ePlaybookStart.WrongMap; } - List steps = PlaybookUtility.Ordered(playbook); + List steps = PlaybookUtility.Ordered(playbook); if (steps.Count == 0) { @@ -120,7 +120,7 @@ public bool Stop() } _phase = Phase.Idle; - _steps = new List(); + _steps = new List(); return true; } @@ -129,7 +129,7 @@ public bool Stop() public void Reset() { _phase = Phase.Idle; - _steps = new List(); + _steps = new List(); _lineups.Clear(); } @@ -157,7 +157,7 @@ public void Tick() int elapsed = (int)(now - _startsAt).TotalMilliseconds; - foreach (NadePlaybookStep step in PlaybookUtility.Due(_steps, _elapsedMs, elapsed)) + foreach (UtilityPlaybookStep step in PlaybookUtility.Due(_steps, _elapsedMs, elapsed)) { Fire(step); } @@ -195,7 +195,7 @@ public void Second() } } - private void Fire(NadePlaybookStep step) + private void Fire(UtilityPlaybookStep step) { LineupRecord? lineup = LineupFor(step); @@ -233,9 +233,9 @@ private void Fire(NadePlaybookStep step) } } - private LineupRecord? LineupFor(NadePlaybookStep step) + private LineupRecord? LineupFor(UtilityPlaybookStep step) { - string id = step.nade_lineup_id ?? ""; + string id = step.utility_lineup_id ?? ""; if (!string.IsNullOrEmpty(id) && _lineups.TryGetValue(id, out LineupRecord? cached)) { diff --git a/apps/nades-css/src/Services/PracticeRecorder.cs b/apps/utility-css/src/Services/PracticeRecorder.cs similarity index 99% rename from apps/nades-css/src/Services/PracticeRecorder.cs rename to apps/utility-css/src/Services/PracticeRecorder.cs index 7af47237..62a003bd 100644 --- a/apps/nades-css/src/Services/PracticeRecorder.cs +++ b/apps/utility-css/src/Services/PracticeRecorder.cs @@ -5,7 +5,7 @@ using FiveStack.Utilities; using Microsoft.Extensions.Logging; -namespace NadePractice; +namespace UtilityPractice; // Turns a thrown grenade into a reproducible lineup. // diff --git a/apps/nades-css/src/Services/PracticeReplay.cs b/apps/utility-css/src/Services/PracticeReplay.cs similarity index 98% rename from apps/nades-css/src/Services/PracticeReplay.cs rename to apps/utility-css/src/Services/PracticeReplay.cs index c9828e17..39b389f2 100644 --- a/apps/nades-css/src/Services/PracticeReplay.cs +++ b/apps/utility-css/src/Services/PracticeReplay.cs @@ -6,7 +6,7 @@ using FiveStack.Utilities; using Microsoft.Extensions.Logging; -namespace NadePractice; +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. @@ -29,7 +29,7 @@ public class PracticeReplay private const int MaxBloomBeams = 48; private const int MaxBloomBeamsTotal = 240; - private readonly NadesConfig _config; + private readonly UtilityConfig _config; private readonly ILogger _logger; private enum GhostKind @@ -48,7 +48,7 @@ private class Ghost private readonly List _ghosts = new List(); - public PracticeReplay(NadesConfig config, ILogger logger) + public PracticeReplay(UtilityConfig config, ILogger logger) { _config = config; _logger = logger; diff --git a/apps/nades-css/src/Services/PracticeScore.cs b/apps/utility-css/src/Services/PracticeScore.cs similarity index 91% rename from apps/nades-css/src/Services/PracticeScore.cs rename to apps/utility-css/src/Services/PracticeScore.cs index 06f87174..5d7ca870 100644 --- a/apps/nades-css/src/Services/PracticeScore.cs +++ b/apps/utility-css/src/Services/PracticeScore.cs @@ -3,7 +3,7 @@ using CounterStrikeSharp.API.Modules.Utils; using FiveStack.Entities.Practice; -namespace NadePractice; +namespace UtilityPractice; // Scores a throw against the lineup the thrower had loaded. // @@ -13,8 +13,8 @@ namespace NadePractice; // and the streak a player is shown is always the panel's answer. public class PracticeScore { - private readonly NadesConfig _config; - private readonly NadesApiClient _api; + private readonly UtilityConfig _config; + private readonly UtilityApiClient _api; private readonly PracticeSession _session; private readonly PracticeSystem _system; @@ -25,8 +25,8 @@ public class PracticeScore private readonly HashSet _mastered = new HashSet(); public PracticeScore( - NadesConfig config, - NadesApiClient api, + UtilityConfig config, + UtilityApiClient api, PracticeSession session, PracticeSystem system ) @@ -40,7 +40,7 @@ PracticeSystem 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 event Action? Scored; public void Reset() { @@ -75,7 +75,7 @@ public void OnFinalized(LineupRecord thrown) Vec3 landing = thrown.detonation_position; float distance = (landing - loaded.detonation_position).Length(); - var payload = NadePracticeResultPayload.For( + var payload = UtilityPracticeResultPayload.For( _config.ServerId, _session.Current?.id ?? Guid.Empty, loaded.id, @@ -90,7 +90,7 @@ public void OnFinalized(LineupRecord thrown) _ = Task.Run(async () => { - NadePracticeResult? result = await _api.PracticeResult(payload); + UtilityPracticeResult? result = await _api.PracticeResult(payload); Server.NextFrame(() => Report(steamId, lineupId, key, name, result, distance)); }); @@ -101,7 +101,7 @@ private void Report( string lineupId, string key, string name, - NadePracticeResult? result, + UtilityPracticeResult? result, float measured ) { @@ -122,7 +122,7 @@ private void Announce( ulong steamId, string key, string name, - NadePracticeResult? result, + UtilityPracticeResult? result, float measured ) { diff --git a/apps/nades-css/src/Services/PracticeSession.cs b/apps/utility-css/src/Services/PracticeSession.cs similarity index 90% rename from apps/nades-css/src/Services/PracticeSession.cs rename to apps/utility-css/src/Services/PracticeSession.cs index 4d4f918e..36d45781 100644 --- a/apps/nades-css/src/Services/PracticeSession.cs +++ b/apps/utility-css/src/Services/PracticeSession.cs @@ -2,19 +2,19 @@ using FiveStack.Utilities; using Microsoft.Extensions.Logging; -namespace NadePractice; +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 NadesApiClient _api; + private readonly UtilityApiClient _api; private readonly ILogger _logger; private PracticeSessionData? _session; - public PracticeSession(NadesApiClient api, ILogger logger) + public PracticeSession(UtilityApiClient api, ILogger logger) { _api = api; _logger = logger; diff --git a/apps/nades-css/src/Services/PracticeSystem.cs b/apps/utility-css/src/Services/PracticeSystem.cs similarity index 98% rename from apps/nades-css/src/Services/PracticeSystem.cs rename to apps/utility-css/src/Services/PracticeSystem.cs index 00fbb256..d898fbfa 100644 --- a/apps/nades-css/src/Services/PracticeSystem.cs +++ b/apps/utility-css/src/Services/PracticeSystem.cs @@ -6,7 +6,7 @@ using FiveStack.Utilities; using Microsoft.Extensions.Logging; -namespace NadePractice; +namespace UtilityPractice; public class PracticeState { @@ -49,7 +49,7 @@ public class PracticeSystem { private const int MaxSavedPositions = 32; - private readonly NadesConfig _config; + private readonly UtilityConfig _config; private readonly PracticeReplay _replay; private readonly ILogger _logger; @@ -57,7 +57,7 @@ public class PracticeSystem private readonly List<(ulong steamId, string weapon)> _regive = new(); public PracticeSystem( - NadesConfig config, + UtilityConfig config, PracticeReplay replay, ILogger logger ) @@ -146,7 +146,7 @@ public void Tick() // again a tick or two later. public void OnThrown(ulong steamId, string utilityType) { - if (!_config.InfiniteNades) + if (!_config.InfiniteUtility) { return; } diff --git a/apps/nades-sw/src/Services/NadesApiClient.cs b/apps/utility-css/src/Services/UtilityApiClient.cs similarity index 83% rename from apps/nades-sw/src/Services/NadesApiClient.cs rename to apps/utility-css/src/Services/UtilityApiClient.cs index 6bca97a3..19af59e2 100644 --- a/apps/nades-sw/src/Services/NadesApiClient.cs +++ b/apps/utility-css/src/Services/UtilityApiClient.cs @@ -1,12 +1,11 @@ using System.Net.Http; -using System.Net.Http.Headers; using System.Text; using System.Text.Json; using FiveStack.Entities.Practice; using FiveStack.Utilities; using Microsoft.Extensions.Logging; -namespace NadePractice; +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 @@ -16,7 +15,7 @@ namespace NadePractice; // 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 NadesApiClient +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. @@ -24,16 +23,16 @@ public class NadesApiClient private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); - private readonly NadesConfig _config; - private readonly ILogger _logger; + 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 Queue _resultQueue = + new Queue(); private readonly SemaphoreSlim _draining = new SemaphoreSlim(1, 1); - public NadesApiClient(NadesConfig config, ILogger logger) + public UtilityApiClient(UtilityConfig config, ILogger logger) { _config = config; _logger = logger; @@ -75,7 +74,7 @@ public int QueuedCount { string? body = await SendText( HttpMethod.Get, - $"/nades/library?map={Uri.EscapeDataString(map)}&steam_id={steamId}", + $"/utility/library?map={Uri.EscapeDataString(map)}&steam_id={steamId}", null ); @@ -86,7 +85,7 @@ public int QueuedCount try { - List? rows = ReadList(body, "lineups", "nades"); + List? rows = ReadList(body, "lineups", "utility"); return rows == null ? new List() @@ -101,11 +100,11 @@ public int QueuedCount // A library row carries no flight path and no measured bloom, so both cost // one more call. - public async Task Trajectory(string id) + public async Task Trajectory(string id) { byte[]? body = await Send( HttpMethod.Get, - $"/nades/{Uri.EscapeDataString(id)}/trajectory", + $"/utility/{Uri.EscapeDataString(id)}/trajectory", null ); @@ -116,7 +115,7 @@ public int QueuedCount try { - return NadeTrajectoryArtifact.Parse(body); + return UtilityTrajectoryArtifact.Parse(body); } catch (Exception error) { @@ -127,7 +126,7 @@ public int QueuedCount public async Task Session() { - string? body = await SendText(HttpMethod.Get, "/nades/session", null); + string? body = await SendText(HttpMethod.Get, "/utility/session", null); if (body == null) { @@ -137,7 +136,7 @@ public int QueuedCount try { return JsonSerializer - .Deserialize(body, PracticeJson.Options) + .Deserialize(body, PracticeJson.Options) ?.ToSession(); } catch (Exception error) @@ -151,9 +150,9 @@ public int QueuedCount // 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(NadePracticeResultPayload payload) + public async Task PracticeResult(UtilityPracticeResultPayload payload) { - NadePracticeResult? result = await PostResult(payload); + UtilityPracticeResult? result = await PostResult(payload); if (result == null) { @@ -168,7 +167,7 @@ public int QueuedCount public async Task Delete(string id) { - return await SendText(HttpMethod.Delete, $"/nades/{Uri.EscapeDataString(id)}", null) + return await SendText(HttpMethod.Delete, $"/utility/{Uri.EscapeDataString(id)}", null) != null; } @@ -207,7 +206,7 @@ public async Task Drain() while (true) { - NadePracticeResultPayload? result; + UtilityPracticeResultPayload? result; lock (_queueLock) { if (!_resultQueue.TryPeek(out result)) @@ -246,7 +245,7 @@ private void Enqueue(LineupRecord record) } } - private void EnqueueResult(NadePracticeResultPayload payload) + private void EnqueueResult(UtilityPracticeResultPayload payload) { lock (_queueLock) { @@ -266,7 +265,7 @@ private void EnqueueResult(NadePracticeResultPayload payload) try { body = JsonSerializer.Serialize( - NadeIngestPayload.From(record), + UtilityIngestPayload.From(record), PracticeJson.Options ); } @@ -278,7 +277,7 @@ private void EnqueueResult(NadePracticeResultPayload payload) return null; } - string? response = await SendText(HttpMethod.Post, "/nades/ingest", body); + string? response = await SendText(HttpMethod.Post, "/utility/ingest", body); if (response == null) { @@ -296,7 +295,7 @@ private void EnqueueResult(NadePracticeResultPayload payload) } } - private async Task PostResult(NadePracticeResultPayload payload) + private async Task PostResult(UtilityPracticeResultPayload payload) { payload.server_id = string.IsNullOrEmpty(_config.ServerId) ? null : _config.ServerId; @@ -312,7 +311,7 @@ private void EnqueueResult(NadePracticeResultPayload payload) return null; } - string? response = await SendText(HttpMethod.Post, "/nades/practice-result", body); + string? response = await SendText(HttpMethod.Post, "/utility/practice-result", body); if (response == null) { @@ -321,7 +320,7 @@ private void EnqueueResult(NadePracticeResultPayload payload) try { - return JsonSerializer.Deserialize(response, PracticeJson.Options); + return JsonSerializer.Deserialize(response, PracticeJson.Options); } catch (Exception error) { @@ -377,10 +376,6 @@ private void EnqueueResult(NadePracticeResultPayload payload) try { using var request = new HttpRequestMessage(method, Url(path)); - request.Headers.Authorization = new AuthenticationHeaderValue( - "Bearer", - _config.NadesApiKey - ); if (!string.IsNullOrEmpty(_config.ServerApiPassword)) { @@ -421,17 +416,17 @@ private void EnqueueResult(NadePracticeResultPayload payload) } } - // Every nade endpoint resolves the session from the server rather than from + // 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.NadesUrl}{path}"; + return $"{_config.UtilityUrl}{path}"; } string separator = path.Contains('?') ? "&" : "?"; - return $"{_config.NadesUrl}{path}{separator}server_id={Uri.EscapeDataString(_config.ServerId)}"; + return $"{_config.UtilityUrl}{path}{separator}server_id={Uri.EscapeDataString(_config.ServerId)}"; } } diff --git a/apps/nades-css/src/Services/NadesConfig.cs b/apps/utility-css/src/Services/UtilityConfig.cs similarity index 67% rename from apps/nades-css/src/Services/NadesConfig.cs rename to apps/utility-css/src/Services/UtilityConfig.cs index c70d16b8..1b437f09 100644 --- a/apps/nades-css/src/Services/NadesConfig.cs +++ b/apps/utility-css/src/Services/UtilityConfig.cs @@ -1,64 +1,61 @@ using System.Text.Json; using Microsoft.Extensions.Logging; -namespace NadePractice; +namespace UtilityPractice; -// Configuration arrives as addons/counterstrikesharp/configs/nade-practice.json, +// 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 NadesConfig +public class UtilityConfig { - public string NadesUrl { get; private set; } = ""; - public string NadesApiKey { get; private set; } = ""; + public string UtilityUrl { get; private set; } = ""; - // The plugin key alone buys nothing: every nade endpoint also wants the + // 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 InfiniteNades { 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; + private readonly ILogger _logger; - public NadesConfig(ILogger logger) + public UtilityConfig(ILogger logger) { _logger = logger; } private class ConfigFile { - public string? nades_url { get; set; } - public string? nades_apikey { get; set; } + 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_nades { 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/nade-practice.json, but the two runtimes root + // 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. - NadesUrl = Environment.GetEnvironmentVariable("NADES_URL") ?? ""; - NadesApiKey = Environment.GetEnvironmentVariable("NADES_API_KEY") ?? ""; + 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, "nade-practice.json")) + .Select(directory => Path.Join(directory, "utility-practice.json")) .FirstOrDefault(File.Exists); if (path != null) @@ -71,13 +68,9 @@ public void Load(params string[] configDirectories) if (parsed != null) { - if (string.IsNullOrEmpty(NadesUrl)) + if (string.IsNullOrEmpty(UtilityUrl)) { - NadesUrl = parsed.nades_url ?? ""; - } - if (string.IsNullOrEmpty(NadesApiKey)) - { - NadesApiKey = parsed.nades_apikey ?? ""; + UtilityUrl = parsed.utility_url ?? ""; } if (string.IsNullOrEmpty(ServerId)) { @@ -89,7 +82,7 @@ public void Load(params string[] configDirectories) } RecordEnabled = parsed.np_record_enabled ?? RecordEnabled; ReplayEnabled = parsed.np_replay_enabled ?? ReplayEnabled; - InfiniteNades = parsed.np_infinite_nades ?? InfiniteNades; + InfiniteUtility = parsed.np_infinite_utility ?? InfiniteUtility; NoFlash = parsed.np_no_flash ?? NoFlash; GhostPreview = parsed.np_ghost_preview ?? GhostPreview; MaxSaved = parsed.np_max_saved ?? MaxSaved; @@ -101,21 +94,21 @@ public void Load(params string[] configDirectories) } } - NadesUrl = NadesUrl.TrimEnd('/'); + UtilityUrl = UtilityUrl.TrimEnd('/'); - if (string.IsNullOrEmpty(NadesUrl) || string.IsNullOrEmpty(NadesApiKey)) + 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( - "nade practice is not connected to a panel; lineups cannot be saved or loaded" + "utility practice is not connected to a panel; lineups cannot be saved or loaded" ); } } public bool IsConnected() { - return !string.IsNullOrEmpty(NadesUrl) && !string.IsNullOrEmpty(NadesApiKey); + return !string.IsNullOrEmpty(UtilityUrl) && !string.IsNullOrEmpty(ServerApiPassword); } } diff --git a/apps/nades-css/src/NadePractice.csproj b/apps/utility-css/src/UtilityPractice.csproj similarity index 97% rename from apps/nades-css/src/NadePractice.csproj rename to apps/utility-css/src/UtilityPractice.csproj index e74a80cd..6f9f6a26 100644 --- a/apps/nades-css/src/NadePractice.csproj +++ b/apps/utility-css/src/UtilityPractice.csproj @@ -1,8 +1,8 @@ true - NadePractice - NadePractice + UtilityPractice + UtilityPractice diff --git a/apps/nades-css/src/NadePracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs similarity index 92% rename from apps/nades-css/src/NadePracticePlugin.cs rename to apps/utility-css/src/UtilityPracticePlugin.cs index 4a347b76..80d1485b 100644 --- a/apps/nades-css/src/NadePracticePlugin.cs +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -8,17 +8,17 @@ using Microsoft.Extensions.Logging; using Timer = CounterStrikeSharp.API.Modules.Timers.Timer; -namespace NadePractice; +namespace UtilityPractice; // Standalone plugin, installed through the 5stack game-plugin registry and -// bound to the nade-practice game mode. It is only ever loaded on a practice +// 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 NadePracticePlugin : BasePlugin +public partial class UtilityPracticePlugin : BasePlugin { - private readonly NadesConfig _config; - private readonly NadesApiClient _api; + private readonly UtilityConfig _config; + private readonly UtilityApiClient _api; private readonly PracticeSession _session; private readonly PracticeRecorder _recorder; private readonly PracticeLibrary _library; @@ -27,20 +27,20 @@ public partial class NadePracticePlugin : BasePlugin private readonly PracticeScore _score; private readonly PracticePlaybook _playbook; private readonly PracticeDrill _drill; - private readonly ILogger _logger; + private readonly ILogger _logger; private Timer? _secondTimer; private Timer? _refillTimer; - public override string ModuleName => "NadePractice"; + 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 NadePracticePlugin( - NadesConfig config, - NadesApiClient api, + public UtilityPracticePlugin( + UtilityConfig config, + UtilityApiClient api, PracticeSession session, PracticeRecorder recorder, PracticeLibrary library, @@ -49,7 +49,7 @@ public NadePracticePlugin( PracticeScore score, PracticePlaybook playbook, PracticeDrill drill, - ILogger logger + ILogger logger ) { _config = config; @@ -97,7 +97,7 @@ public override void Load(bool hotReload) RefreshEverything(); _logger.LogInformation( - "nade practice {version} loaded (connected: {connected})", + "utility practice {version} loaded (connected: {connected})", ModuleVersion, _config.IsConnected() ); diff --git a/apps/nades-css/src/NadePracticeServiceCollection.cs b/apps/utility-css/src/UtilityPracticeServiceCollection.cs similarity index 76% rename from apps/nades-css/src/NadePracticeServiceCollection.cs rename to apps/utility-css/src/UtilityPracticeServiceCollection.cs index 11cc0e39..516f15f5 100644 --- a/apps/nades-css/src/NadePracticeServiceCollection.cs +++ b/apps/utility-css/src/UtilityPracticeServiceCollection.cs @@ -1,16 +1,16 @@ using CounterStrikeSharp.API.Core; using Microsoft.Extensions.DependencyInjection; -namespace NadePractice; +namespace UtilityPractice; // CounterStrikeSharp discovers this automatically and builds the plugin's // container from it, mirroring the match plugin's FiveStackServiceCollection. -public class NadePracticeServiceCollection : IPluginServiceCollection +public class UtilityPracticeServiceCollection : IPluginServiceCollection { public void ConfigureServices(IServiceCollection serviceCollection) { - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/apps/nades-sw/test/DrillUtilityTests.cs b/apps/utility-css/test/DrillUtilityTests.cs similarity index 99% rename from apps/nades-sw/test/DrillUtilityTests.cs rename to apps/utility-css/test/DrillUtilityTests.cs index a03fc58c..3db0ce91 100644 --- a/apps/nades-sw/test/DrillUtilityTests.cs +++ b/apps/utility-css/test/DrillUtilityTests.cs @@ -21,14 +21,14 @@ private static LineupRecord Lineup(string id, string? name = null, string utilit }; } - private static NadePracticeResult Result( + private static UtilityPracticeResult Result( bool success, int attempts, int successes, bool mastered = false ) { - return new NadePracticeResult + return new UtilityPracticeResult { success = success, distance = 40f, diff --git a/apps/nades-css/test/FiveStack.Tests.csproj b/apps/utility-css/test/FiveStack.Tests.csproj similarity index 100% rename from apps/nades-css/test/FiveStack.Tests.csproj rename to apps/utility-css/test/FiveStack.Tests.csproj diff --git a/apps/nades-css/test/PlaybookUtilityTests.cs b/apps/utility-css/test/PlaybookUtilityTests.cs similarity index 91% rename from apps/nades-css/test/PlaybookUtilityTests.cs rename to apps/utility-css/test/PlaybookUtilityTests.cs index df849b67..78d94cdd 100644 --- a/apps/nades-css/test/PlaybookUtilityTests.cs +++ b/apps/utility-css/test/PlaybookUtilityTests.cs @@ -6,7 +6,7 @@ // fires twice, and a step that never fires at all. public class PlaybookUtilityTests { - private static NadePlaybookStep Step( + private static UtilityPlaybookStep Step( int order, int offsetMs, string? assigned = null, @@ -15,19 +15,19 @@ private static NadePlaybookStep Step( string? confidence = null ) { - return new NadePlaybookStep + return new UtilityPlaybookStep { - nade_lineup_id = $"lineup-{order}", + utility_lineup_id = $"lineup-{order}", step_order = order, offset_ms = offsetMs, assigned_steam_id = assigned, note = $"step {order}", lineup = withLineup - ? new NadeLibraryRow + ? new UtilityLibraryRow { id = $"row-{order}", - name = $"nade {order}", - nade_type = "Smoke", + name = $"utility {order}", + utility_type = "Smoke", origin_x = 1f, origin_y = 2f, origin_z = 3f, @@ -46,9 +46,9 @@ private static NadePlaybookStep Step( }; } - private static NadePlaybook Playbook(params NadePlaybookStep[] steps) + private static UtilityPlaybook Playbook(params UtilityPlaybookStep[] steps) { - return new NadePlaybook + return new UtilityPlaybook { id = "book", name = "A execute", @@ -142,7 +142,7 @@ public void WalkingTheWindowFiresEveryStepOnce() [Fact] public void TheDurationIsTheLastOffset() { - Assert.Equal(0, PlaybookUtility.DurationMs(new List())); + Assert.Equal(0, PlaybookUtility.DurationMs(new List())); Assert.Equal( 2500, PlaybookUtility.DurationMs( @@ -154,7 +154,7 @@ public void TheDurationIsTheLastOffset() [Fact] public void AnUnassignedStepBelongsToEveryone() { - NadePlaybookStep step = Step(1, 0); + UtilityPlaybookStep step = Step(1, 0); Assert.False(PlaybookUtility.IsAssigned(step)); Assert.True(PlaybookUtility.IsFor(step, 76561198000000001)); @@ -164,7 +164,7 @@ public void AnUnassignedStepBelongsToEveryone() [Fact] public void AnAssignedStepBelongsToOnlyThatPlayer() { - NadePlaybookStep step = Step(1, 0, assigned: " 76561198000000001 "); + UtilityPlaybookStep step = Step(1, 0, assigned: " 76561198000000001 "); Assert.True(PlaybookUtility.IsAssigned(step)); Assert.True(PlaybookUtility.IsFor(step, 76561198000000001)); diff --git a/apps/nades-css/test/PracticeCalibrationUtilityTests.cs b/apps/utility-css/test/PracticeCalibrationUtilityTests.cs similarity index 100% rename from apps/nades-css/test/PracticeCalibrationUtilityTests.cs rename to apps/utility-css/test/PracticeCalibrationUtilityTests.cs diff --git a/apps/nades-css/test/PracticeConnectUtilityTests.cs b/apps/utility-css/test/PracticeConnectUtilityTests.cs similarity index 100% rename from apps/nades-css/test/PracticeConnectUtilityTests.cs rename to apps/utility-css/test/PracticeConnectUtilityTests.cs diff --git a/apps/nades-sw/test/PracticeDrillRunTests.cs b/apps/utility-css/test/PracticeDrillRunTests.cs similarity index 99% rename from apps/nades-sw/test/PracticeDrillRunTests.cs rename to apps/utility-css/test/PracticeDrillRunTests.cs index 35aa1969..e88e638a 100644 --- a/apps/nades-sw/test/PracticeDrillRunTests.cs +++ b/apps/utility-css/test/PracticeDrillRunTests.cs @@ -29,9 +29,9 @@ private static PracticeDrillRun Run(params string[] ids) return new PracticeDrillRun(ids.Select(id => Lineup(id)).ToList()); } - private static NadePracticeResult Result(bool success) + private static UtilityPracticeResult Result(bool success) { - return new NadePracticeResult + return new UtilityPracticeResult { success = success, distance = success ? 20f : 300f, diff --git a/apps/nades-css/test/PracticeJsonTests.cs b/apps/utility-css/test/PracticeJsonTests.cs similarity index 88% rename from apps/nades-css/test/PracticeJsonTests.cs rename to apps/utility-css/test/PracticeJsonTests.cs index 8c9c0f6e..eaa05d55 100644 --- a/apps/nades-css/test/PracticeJsonTests.cs +++ b/apps/utility-css/test/PracticeJsonTests.cs @@ -28,12 +28,12 @@ public void APathPointReadsBackFieldByField() { const string json = "[{\"tick\":7,\"x\":1.5,\"y\":-2.5,\"z\":3}]"; - List? path = JsonSerializer.Deserialize>( + List? path = JsonSerializer.Deserialize>( json, PracticeJson.Options ); - NadePathPoint point = Assert.Single(path!); + UtilityPathPoint point = Assert.Single(path!); Assert.Equal(7, point.tick); Assert.Equal(1.5f, point.x); Assert.Equal(-2.5f, point.y); @@ -47,7 +47,7 @@ public void AMissingFieldReadsAsAbsentRatherThanFailing() { const string json = "{\"id\":\"x\",\"name\":\"only a name\"}"; - NadeLibraryRow? row = JsonSerializer.Deserialize( + UtilityLibraryRow? row = JsonSerializer.Deserialize( json, PracticeJson.Options ); diff --git a/apps/nades-css/test/PracticeLaunchUtilityTests.cs b/apps/utility-css/test/PracticeLaunchUtilityTests.cs similarity index 100% rename from apps/nades-css/test/PracticeLaunchUtilityTests.cs rename to apps/utility-css/test/PracticeLaunchUtilityTests.cs diff --git a/apps/nades-css/test/PracticeLineupUtilityTests.cs b/apps/utility-css/test/PracticeLineupUtilityTests.cs similarity index 100% rename from apps/nades-css/test/PracticeLineupUtilityTests.cs rename to apps/utility-css/test/PracticeLineupUtilityTests.cs diff --git a/apps/nades-css/test/PracticeSignalUtilityTests.cs b/apps/utility-css/test/PracticeSignalUtilityTests.cs similarity index 95% rename from apps/nades-css/test/PracticeSignalUtilityTests.cs rename to apps/utility-css/test/PracticeSignalUtilityTests.cs index ccc3d187..cf8c7b39 100644 --- a/apps/nades-css/test/PracticeSignalUtilityTests.cs +++ b/apps/utility-css/test/PracticeSignalUtilityTests.cs @@ -11,7 +11,7 @@ public class PracticeSignalUtilityTests { // The shape a reader outside this repo matches on. private static readonly Regex Line = new Regex( - @"^\[nade-practice\] ghost_detonated utility=(?\S+) lineup=(?\S+) lineup_id=(?\S+) steam=(?\d+) x=(?-?\d+\.\d\d) y=(?-?\d+\.\d\d) z=(?-?\d+\.\d\d)$" + @"^\[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] diff --git a/apps/nades-css/test/PracticeSolverPlanTests.cs b/apps/utility-css/test/PracticeSolverPlanTests.cs similarity index 100% rename from apps/nades-css/test/PracticeSolverPlanTests.cs rename to apps/utility-css/test/PracticeSolverPlanTests.cs diff --git a/apps/nades-css/test/PracticeSolverUtilityTests.cs b/apps/utility-css/test/PracticeSolverUtilityTests.cs similarity index 100% rename from apps/nades-css/test/PracticeSolverUtilityTests.cs rename to apps/utility-css/test/PracticeSolverUtilityTests.cs diff --git a/apps/nades-css/test/SmokeVolumeUtilityTests.cs b/apps/utility-css/test/SmokeVolumeUtilityTests.cs similarity index 100% rename from apps/nades-css/test/SmokeVolumeUtilityTests.cs rename to apps/utility-css/test/SmokeVolumeUtilityTests.cs diff --git a/apps/nades-css/test/TrajectoryUtilityTests.cs b/apps/utility-css/test/TrajectoryUtilityTests.cs similarity index 100% rename from apps/nades-css/test/TrajectoryUtilityTests.cs rename to apps/utility-css/test/TrajectoryUtilityTests.cs diff --git a/apps/nades-css/test/NadeArtifactTests.cs b/apps/utility-css/test/UtilityArtifactTests.cs similarity index 87% rename from apps/nades-css/test/NadeArtifactTests.cs rename to apps/utility-css/test/UtilityArtifactTests.cs index f0ee7532..4d62ec34 100644 --- a/apps/nades-css/test/NadeArtifactTests.cs +++ b/apps/utility-css/test/UtilityArtifactTests.cs @@ -7,7 +7,7 @@ // 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 NadeArtifactTests +public class UtilityArtifactTests { // The artifact is the playback blob's shape, so the path is nested. private const string Artifact = """ @@ -41,7 +41,7 @@ public class NadeArtifactTests [Fact] public void ThePathIsReadOutOfTheNestedTrajectory() { - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse(Artifact); + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse(Artifact); Assert.Equal(2, artifact.path.Count); Assert.Equal(0, artifact.path[0].t); @@ -53,7 +53,7 @@ public void ThePathIsReadOutOfTheNestedTrajectory() [Fact] public void TheSmokeVolumeIsReadOutOfTheArtifact() { - SmokeVolume? volume = NadeTrajectoryArtifact.Parse(Artifact).smoke_volume; + SmokeVolume? volume = UtilityTrajectoryArtifact.Parse(Artifact).smoke_volume; Assert.NotNull(volume); Assert.Equal(-100f, volume!.ox); @@ -78,7 +78,7 @@ public void AGzippedArtifactIsUnpackedFirst() plain.CopyTo(gzip); } - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse(compressed.ToArray()); + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse(compressed.ToArray()); Assert.Equal(2, artifact.path.Count); Assert.NotNull(artifact.smoke_volume); @@ -93,7 +93,7 @@ public void APlainBodyIsReadAsItIs() [Fact] public void AFlatPathIsStillRead() { - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse( + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( """{"path":[{"tick":4,"x":1,"y":2,"z":3}]}""" ); @@ -104,7 +104,7 @@ public void AFlatPathIsStillRead() [Fact] public void ABareArrayIsStillRead() { - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse( + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( """[{"tick":4,"x":1,"y":2,"z":3}]""" ); @@ -115,7 +115,7 @@ public void ABareArrayIsStillRead() [Fact] public void AMissingSmokeVolumeIsNotAnError() { - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse( + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( """{"grenade_trajectories":[{"points":[]}],"smoke_volumes":[]}""" ); @@ -126,7 +126,7 @@ public void AMissingSmokeVolumeIsNotAnError() [Fact] public void AVolumeWithNoExtentIsNotAVolume() { - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse( + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( """{"smoke_volumes":[{"ox":0,"oy":0,"oz":0,"vs":0,"dx":0,"dy":0,"dz":0}]}""" ); @@ -136,7 +136,7 @@ public void AVolumeWithNoExtentIsNotAVolume() [Fact] public void AnArtifactWithNothingInItReadsAsEmpty() { - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse("{}"); + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse("{}"); Assert.Empty(artifact.path); Assert.Null(artifact.smoke_volume); @@ -146,7 +146,7 @@ public void AnArtifactWithNothingInItReadsAsEmpty() public void TheSessionIsReadInTheApiSpelling() { PracticeSessionData session = JsonSerializer - .Deserialize( + .Deserialize( """ { "session_id": "11111111-1111-1111-1111-111111111111", @@ -175,7 +175,7 @@ public void TheSessionIsReadInTheApiSpelling() public void TheOlderSessionSpellingStillFillsTheRoster() { PracticeSessionData session = JsonSerializer - .Deserialize( + .Deserialize( """ { "id": "11111111-1111-1111-1111-111111111111", @@ -198,7 +198,7 @@ public void TheOlderSessionSpellingStillFillsTheRoster() public void APlaybookOnTheSessionArrivesWithItsSteps() { PracticeSessionData session = JsonSerializer - .Deserialize( + .Deserialize( """ { "session_id": "11111111-1111-1111-1111-111111111111", @@ -213,7 +213,7 @@ public void APlaybookOnTheSessionArrivesWithItsSteps() "side": "TERRORIST", "steps": [ { - "nade_lineup_id": "44444444-4444-4444-4444-444444444444", + "utility_lineup_id": "44444444-4444-4444-4444-444444444444", "step_order": 1, "offset_ms": 0, "assigned_steam_id": "76561198000000001", @@ -222,7 +222,7 @@ public void APlaybookOnTheSessionArrivesWithItsSteps() "id": "44444444-4444-4444-4444-444444444444", "name": "jungle", "map_name": "de_mirage", - "nade_type": "Smoke", + "utility_type": "Smoke", "side": "TERRORIST", "origin_x": 1, "origin_y": 2, "origin_z": 3, "view_yaw": 90, "view_pitch": -20, @@ -237,7 +237,7 @@ public void APlaybookOnTheSessionArrivesWithItsSteps() )! .ToSession(); - NadePlaybook? playbook = session.playbook; + UtilityPlaybook? playbook = session.playbook; Assert.NotNull(playbook); Assert.Equal("A split", playbook!.name); @@ -258,7 +258,7 @@ public void APlaybookOnTheSessionArrivesWithItsSteps() [Fact] public void AResultNamesTheServerAndTheSessionItBelongsTo() { - NadePracticeResultPayload payload = NadePracticeResultPayload.For( + UtilityPracticeResultPayload payload = UtilityPracticeResultPayload.For( "55555555-5555-5555-5555-555555555555", Guid.Parse("11111111-1111-1111-1111-111111111111"), "44444444-4444-4444-4444-444444444444", @@ -269,7 +269,7 @@ public void AResultNamesTheServerAndTheSessionItBelongsTo() 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.nade_lineup_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); @@ -280,7 +280,7 @@ public void AResultNamesTheServerAndTheSessionItBelongsTo() [Fact] public void AnUnknownSessionIsLeftOutOfTheResult() { - NadePracticeResultPayload payload = NadePracticeResultPayload.For( + UtilityPracticeResultPayload payload = UtilityPracticeResultPayload.For( null, Guid.Empty, "44444444-4444-4444-4444-444444444444", @@ -294,13 +294,13 @@ public void AnUnknownSessionIsLeftOutOfTheResult() Assert.DoesNotContain("session_id", json); Assert.DoesNotContain("server_id", json); Assert.DoesNotContain("success", json); - Assert.Contains("\"nade_lineup_id\"", json); + Assert.Contains("\"utility_lineup_id\"", json); } [Fact] public void AResultIsReadBackWithThePanelsRadius() { - NadePracticeResult? result = JsonSerializer.Deserialize( + UtilityPracticeResult? result = JsonSerializer.Deserialize( """ { "success": true, @@ -327,7 +327,7 @@ public void AResultIsReadBackWithThePanelsRadius() [Fact] public void AResultThatHasNotBeenMasteredCarriesNoDate() { - NadePracticeResult? result = JsonSerializer.Deserialize( + 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 ); diff --git a/apps/nades-css/test/NadeWireTests.cs b/apps/utility-css/test/UtilityWireTests.cs similarity index 92% rename from apps/nades-css/test/NadeWireTests.cs rename to apps/utility-css/test/UtilityWireTests.cs index 65a6c3ef..30414d78 100644 --- a/apps/nades-css/test/NadeWireTests.cs +++ b/apps/utility-css/test/UtilityWireTests.cs @@ -7,7 +7,7 @@ // 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 NadeWireTests +public class UtilityWireTests { private static LineupRecord Lineup() { @@ -51,10 +51,10 @@ private static LineupRecord Lineup() [Fact] public void EveryFieldLandsInTheColumnTheApiNames() { - NadeIngestPayload payload = NadeIngestPayload.From(Lineup()); + UtilityIngestPayload payload = UtilityIngestPayload.From(Lineup()); Assert.Equal("76561198000000001", payload.author_steam_id); - Assert.Equal("Smoke", payload.nade_type); + Assert.Equal("Smoke", payload.utility_type); Assert.Equal("TERRORIST", payload.side); Assert.Equal("RunJump", payload.technique); Assert.Equal("Full", payload.throw_strength); @@ -81,7 +81,7 @@ public void EveryFieldLandsInTheColumnTheApiNames() [Fact] public void FlightTimeCrossesTheWireInMilliseconds() { - NadeIngestPayload payload = NadeIngestPayload.From(Lineup()); + UtilityIngestPayload payload = UtilityIngestPayload.From(Lineup()); Assert.Equal(1500, payload.flight_time_ms); } @@ -93,7 +93,7 @@ public void FlightTimeCrossesTheWireInMilliseconds() [InlineData(20f, 20000)] public void MillisecondsAreRoundedNotTruncated(float seconds, int expected) { - Assert.Equal(expected, NadeIngestPayload.MillisecondsFromSeconds(seconds)); + Assert.Equal(expected, UtilityIngestPayload.MillisecondsFromSeconds(seconds)); } // 62.5 is exactly representable, so this pins the midpoint rule rather @@ -101,7 +101,7 @@ public void MillisecondsAreRoundedNotTruncated(float seconds, int expected) [Fact] public void AMidpointRoundsAwayFromZero() { - Assert.Equal(63, NadeIngestPayload.MillisecondsFromSeconds(0.0625f)); + Assert.Equal(63, UtilityIngestPayload.MillisecondsFromSeconds(0.0625f)); } [Theory] @@ -112,19 +112,19 @@ public void AMidpointRoundsAwayFromZero() [InlineData("Incendiary", "Molotov")] [InlineData("Smoke", "Smoke")] [InlineData("Decoy", "Decoy")] - public void TheNadeTypeIsSentInTheApisSpelling(string recorded, string expected) + public void TheUtilityTypeIsSentInTheApisSpelling(string recorded, string expected) { LineupRecord lineup = Lineup(); lineup.utility_type = recorded; - Assert.Equal(expected, NadeIngestPayload.From(lineup).nade_type); + Assert.Equal(expected, UtilityIngestPayload.From(lineup).utility_type); } [Fact] public void ThePathIsSentAsObjectsNotPackedArrays() { string json = JsonSerializer.Serialize( - NadeIngestPayload.From(Lineup()), + UtilityIngestPayload.From(Lineup()), PracticeJson.Options ); @@ -152,21 +152,21 @@ public void ThePathIsSentAsObjectsNotPackedArrays() public void FieldsTheApiDoesNotAcceptAreNotSent(string absent) { string json = JsonSerializer.Serialize( - NadeIngestPayload.From(Lineup()), + UtilityIngestPayload.From(Lineup()), PracticeJson.Options ); Assert.DoesNotContain(absent, json); } - private static NadeLibraryRow Row() + private static UtilityLibraryRow Row() { - return new NadeLibraryRow + return new UtilityLibraryRow { id = "panel-id", name = "Window smoke", map_name = "de_mirage", - nade_type = "HE", + utility_type = "HE", side = "CT", technique = "Jump", throw_strength = "Half", @@ -257,7 +257,7 @@ public void ARowWithNoSeedIsNotReplayable() [Fact] public void ARowWithASeedIsReplayableExactly() { - NadeLibraryRow row = Seeded(); + UtilityLibraryRow row = Seeded(); LineupRecord lineup = row.ToLineup(); Assert.True(row.HasSeed()); @@ -282,7 +282,7 @@ public void ARowWithASeedIsReplayableExactly() [InlineData("initial_vel_z")] public void HalfASeedIsNoSeed(string missing) { - NadeLibraryRow row = Seeded(); + UtilityLibraryRow row = Seeded(); switch (missing) { @@ -313,7 +313,7 @@ public void HalfASeedIsNoSeed(string missing) [Fact] public void AZeroedSeedIsNoSeed() { - NadeLibraryRow row = Seeded(); + UtilityLibraryRow row = Seeded(); row.initial_vel_x = 0f; row.initial_vel_y = 0f; row.initial_vel_z = 0f; @@ -331,7 +331,7 @@ public void AZeroedSeedIsNoSeed() public void ASeedSurvivesTheRoundTripThroughBothShapes() { LineupRecord original = Lineup(); - NadeLibraryRow row = Seeded(); + UtilityLibraryRow row = Seeded(); row.initial_pos_x = original.initial_position.x; row.initial_pos_y = original.initial_position.y; @@ -401,7 +401,7 @@ public void AMissingConfidenceIsNotWarnedAbout() [Fact] public void ExactWithNoSeedIsStillNotReplayable() { - NadeLibraryRow row = Row(); + UtilityLibraryRow row = Row(); row.confidence = "exact"; LineupRecord lineup = row.ToLineup(); @@ -430,9 +430,9 @@ public void ALineupRecordedHereIsExactlyReplayable() Assert.True(recorded.IsExactlyReplayable()); } - private static NadeLibraryRow Seeded(string? confidence = null) + private static UtilityLibraryRow Seeded(string? confidence = null) { - NadeLibraryRow row = Row(); + UtilityLibraryRow row = Row(); row.initial_pos_x = 11f; row.initial_pos_y = 22f; @@ -449,13 +449,13 @@ private static NadeLibraryRow Seeded(string? confidence = null) public void APositionSurvivesTheRoundTripThroughBothShapes() { LineupRecord original = Lineup(); - NadeIngestPayload payload = NadeIngestPayload.From(original); + UtilityIngestPayload payload = UtilityIngestPayload.From(original); - var row = new NadeLibraryRow + var row = new UtilityLibraryRow { id = "panel-id", name = payload.name, - nade_type = payload.nade_type, + utility_type = payload.utility_type, side = payload.side, technique = payload.technique, throw_strength = payload.throw_strength, @@ -491,7 +491,7 @@ public void NullsAreOmittedRatherThanSentAsNull() var bare = new LineupRecord { utility_type = "Smoke" }; string json = JsonSerializer.Serialize( - NadeIngestPayload.From(bare), + UtilityIngestPayload.From(bare), PracticeJson.Options ); diff --git a/apps/nades-sw/scripts/dev.sh b/apps/utility-sw/scripts/dev.sh similarity index 80% rename from apps/nades-sw/scripts/dev.sh rename to apps/utility-sw/scripts/dev.sh index ef20f71d..fcba65ea 100755 --- a/apps/nades-sw/scripts/dev.sh +++ b/apps/utility-sw/scripts/dev.sh @@ -6,23 +6,23 @@ # 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 NadePractice.dll changes. +# reloads the plugin when UtilityPractice.dll changes. # -# Chain: dotnet watch build -> apps/nades-sw/src/build/net10.0 -> cp -> /opt/dev -> SwiftlyS2 reload +# 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/nades-sw/src/NadePractice.csproj" -BUILD_OUTPUT="/opt/5stack/apps/nades-sw/src/build/net10.0" +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/NadePractice" +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/nades-sw" + DEV_DIR="/opt/dev/utility-sw" mkdir -p "$DEV_DIR" fi @@ -48,10 +48,10 @@ sync_to_dev() { return 1 fi if cp -r "$BUILD_OUTPUT"/. "$DEV_DIR"/ 2>/tmp/dev-cp.err; then - if [ -f "$DEV_DIR/NadePractice.dll" ]; then - log "synced -> $DEV_DIR (NadePractice.dll $(stat -c%s "$DEV_DIR/NadePractice.dll" 2>/dev/null || echo '?') bytes)" + 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/NadePractice.dll is missing" + log "WARNING: copied but $DEV_DIR/UtilityPractice.dll is missing" fi else log "ERROR: copy to $DEV_DIR failed: $(cat /tmp/dev-cp.err)" diff --git a/apps/nades-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs similarity index 98% rename from apps/nades-sw/src/Commands/Practice.cs rename to apps/utility-sw/src/Commands/Practice.cs index f82ff82e..c5f54a82 100644 --- a/apps/nades-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -6,13 +6,13 @@ using SwiftlyS2.Shared.Players; using static SwiftlyS2.Shared.Helper; -namespace NadePractice; +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 NadePracticePlugin +public partial class UtilityPracticePlugin { [Command("save", registerRaw: false, permission: "")] public void OnSave(ICommandContext context) @@ -656,7 +656,7 @@ public void OnPracticeHelp(ICommandContext context) // Server-only and deliberately unprefixed, like the match plugin's // get_match: the panel calls it when the roster or the library changes. - [Command("nade_practice_refresh", registerRaw: true, permission: "")] + [Command("utility_practice_refresh", registerRaw: true, permission: "")] public void OnRefresh(ICommandContext context) { if (context.IsSentByPlayer) @@ -669,7 +669,7 @@ public void OnRefresh(ICommandContext context) private static readonly string[] HelpLines = new[] { - $" {ChatColors.Green}nade practice", + $" {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", @@ -704,7 +704,7 @@ private void StartPlaybook(IPlayer player, ICommandContext context) return; } - IReadOnlyList steps = _playbook.Steps; + IReadOnlyList steps = _playbook.Steps; Core.PlayerManager.SendChat( $" {ChatColors.Green}{player.Controller.PlayerName} started {ChatColors.Default}{_playbook.Loaded?.name} {ChatColors.Grey}({steps.Count} steps)".Colored() @@ -712,7 +712,7 @@ private void StartPlaybook(IPlayer player, ICommandContext context) for (int index = 0; index < steps.Count; index++) { - NadePlaybookStep step = steps[index]; + UtilityPlaybookStep step = steps[index]; string who = PlaybookUtility.IsAssigned(step) ? step.assigned_steam_id! : "anyone"; Reply( diff --git a/apps/nades-sw/src/Commands/Solver.cs b/apps/utility-sw/src/Commands/Solver.cs similarity index 97% rename from apps/nades-sw/src/Commands/Solver.cs rename to apps/utility-sw/src/Commands/Solver.cs index 0ffab001..2c757f4a 100644 --- a/apps/nades-sw/src/Commands/Solver.cs +++ b/apps/utility-sw/src/Commands/Solver.cs @@ -9,14 +9,14 @@ using SwiftlyS2.Shared.Trace; using static SwiftlyS2.Shared.Helper; -namespace NadePractice; +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 NadePracticePlugin +public partial class UtilityPracticePlugin { - [Command("nade_solver_calibrate", registerRaw: true, permission: "")] + [Command("utility_solver_calibrate", registerRaw: true, permission: "")] public void OnSolverCalibrate(ICommandContext context) { bool force = context.Args.Any(argument => @@ -47,7 +47,7 @@ public void OnSolverCalibrate(ICommandContext context) // Named arguments, because this one is called by a machine over RCON where // a positional list quietly solves for the wrong point. - [Command("nade_solver_solve", registerRaw: true, permission: "")] + [Command("utility_solver_solve", registerRaw: true, permission: "")] public void OnSolverSolve(ICommandContext context) { if ( @@ -69,7 +69,7 @@ public void OnSolverSolve(ICommandContext context) Reply(context, $" {ChatColors.Red}{error}"); Reply( context, - $" {ChatColors.Grey}usage: nade_solver_solve target=x,y,z [from=x,y,z] [utility=Smoke] [steam=id] [name=...] [tolerance=40] [grenades=300] [seconds=120]" + $" {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; } diff --git a/apps/nades-sw/src/Events/PracticeConnect.cs b/apps/utility-sw/src/Events/PracticeConnect.cs similarity index 99% rename from apps/nades-sw/src/Events/PracticeConnect.cs rename to apps/utility-sw/src/Events/PracticeConnect.cs index 8e878118..7d30c158 100644 --- a/apps/nades-sw/src/Events/PracticeConnect.cs +++ b/apps/utility-sw/src/Events/PracticeConnect.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging; using SwiftlyS2.Shared.Memory; -namespace NadePractice; +namespace UtilityPractice; [UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate nint ConnectClientDelegate( @@ -23,7 +23,7 @@ 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 NadePracticePlugin +public partial class UtilityPracticePlugin { private static int PasswordBufferLength = 86; public static nint PasswordBuffer { get; set; } = nint.Zero; diff --git a/apps/nades-sw/src/Events/PracticeGrenade.cs b/apps/utility-sw/src/Events/PracticeGrenade.cs similarity index 98% rename from apps/nades-sw/src/Events/PracticeGrenade.cs rename to apps/utility-sw/src/Events/PracticeGrenade.cs index 06498cee..fd8a1289 100644 --- a/apps/nades-sw/src/Events/PracticeGrenade.cs +++ b/apps/utility-sw/src/Events/PracticeGrenade.cs @@ -4,7 +4,7 @@ using SwiftlyS2.Shared.Misc; using SwiftlyS2.Shared.Players; -namespace NadePractice; +namespace UtilityPractice; // Detonation is the other half of a recording: the projectile stops existing // and we finally know where the lineup lands. @@ -12,7 +12,7 @@ namespace NadePractice; // 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 NadePracticePlugin +public partial class UtilityPracticePlugin { [GameEventHandler(HookMode.Post)] public HookResult OnSmokeDetonate(EventSmokegrenadeDetonate @event) diff --git a/apps/nades-sw/src/Events/PracticePlayer.cs b/apps/utility-sw/src/Events/PracticePlayer.cs similarity index 90% rename from apps/nades-sw/src/Events/PracticePlayer.cs rename to apps/utility-sw/src/Events/PracticePlayer.cs index 69dc175d..cac8138b 100644 --- a/apps/nades-sw/src/Events/PracticePlayer.cs +++ b/apps/utility-sw/src/Events/PracticePlayer.cs @@ -3,9 +3,9 @@ using SwiftlyS2.Shared.Misc; using SwiftlyS2.Shared.SchemaDefinitions; -namespace NadePractice; +namespace UtilityPractice; -public partial class NadePracticePlugin +public partial class UtilityPracticePlugin { // Practising smokes through your own flash is nobody's idea of practice. [GameEventHandler(HookMode.Post)] diff --git a/apps/nades-sw/src/Services/PracticeDrill.cs b/apps/utility-sw/src/Services/PracticeDrill.cs similarity index 96% rename from apps/nades-sw/src/Services/PracticeDrill.cs rename to apps/utility-sw/src/Services/PracticeDrill.cs index 5f12d88f..bdaf44de 100644 --- a/apps/nades-sw/src/Services/PracticeDrill.cs +++ b/apps/utility-sw/src/Services/PracticeDrill.cs @@ -2,7 +2,7 @@ using FiveStack.Enums; using FiveStack.Utilities; -namespace NadePractice; +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. @@ -15,7 +15,7 @@ namespace NadePractice; // server and none of them are told about each other's runs. public class PracticeDrill { - private readonly NadesConfig _config; + private readonly UtilityConfig _config; private readonly PracticeLibrary _library; private readonly Dictionary _runs = @@ -24,7 +24,7 @@ public class PracticeDrill private readonly DrillProgressBook _progress = new DrillProgressBook(); private readonly Random _random = new Random(); - public PracticeDrill(NadesConfig config, PracticeLibrary library) + public PracticeDrill(UtilityConfig config, PracticeLibrary library) { _config = config; _library = library; @@ -120,7 +120,7 @@ public void OnThrown(ulong steamId, string utilityType) } // The panel's answer, or the fact that there was not one. - public void OnScored(ulong steamId, string lineupId, NadePracticeResult? result) + 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 diff --git a/apps/nades-sw/src/Services/PracticeLibrary.cs b/apps/utility-sw/src/Services/PracticeLibrary.cs similarity index 95% rename from apps/nades-sw/src/Services/PracticeLibrary.cs rename to apps/utility-sw/src/Services/PracticeLibrary.cs index 9d6e1578..2f5fb476 100644 --- a/apps/nades-sw/src/Services/PracticeLibrary.cs +++ b/apps/utility-sw/src/Services/PracticeLibrary.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Logging; using SwiftlyS2.Shared; -namespace NadePractice; +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 @@ -11,7 +11,7 @@ namespace NadePractice; public class PracticeLibrary { private readonly ISwiftlyCore _core; - private readonly NadesApiClient _api; + private readonly UtilityApiClient _api; private readonly ILogger _logger; private readonly Dictionary> _lineups = new(); @@ -19,7 +19,7 @@ public class PracticeLibrary public PracticeLibrary( ISwiftlyCore core, - NadesApiClient api, + UtilityApiClient api, ILogger logger ) { @@ -89,7 +89,7 @@ public void EnsureTrajectory(LineupRecord lineup, Action ready) _ = Task.Run(async () => { - NadeTrajectoryArtifact? artifact = await _api.Trajectory(id); + UtilityTrajectoryArtifact? artifact = await _api.Trajectory(id); _core.Scheduler.NextTick(() => { diff --git a/apps/nades-css/src/Services/PracticePlaybook.cs b/apps/utility-sw/src/Services/PracticePlaybook.cs similarity index 89% rename from apps/nades-css/src/Services/PracticePlaybook.cs rename to apps/utility-sw/src/Services/PracticePlaybook.cs index 59e414ba..d32a979f 100644 --- a/apps/nades-css/src/Services/PracticePlaybook.cs +++ b/apps/utility-sw/src/Services/PracticePlaybook.cs @@ -1,7 +1,7 @@ using FiveStack.Entities.Practice; using FiveStack.Utilities; -namespace NadePractice; +namespace UtilityPractice; public enum ePlaybookStart { @@ -35,7 +35,7 @@ private enum Phase private Phase _phase = Phase.Idle; private string _name = ""; - private List _steps = new List(); + private List _steps = new List(); // When t=0 is, which is the end of the countdown rather than the moment // .playbook was typed. @@ -68,9 +68,9 @@ public PracticePlaybook(PracticeSession session, PracticeSystem system) public bool Running => _phase != Phase.Idle; - public NadePlaybook? Loaded => _session.Current?.playbook; + public UtilityPlaybook? Loaded => _session.Current?.playbook; - public IReadOnlyList Steps => PlaybookUtility.Ordered(Loaded); + public IReadOnlyList Steps => PlaybookUtility.Ordered(Loaded); public ePlaybookStart Start(string map) { @@ -79,7 +79,7 @@ public ePlaybookStart Start(string map) return ePlaybookStart.AlreadyRunning; } - NadePlaybook? playbook = Loaded; + UtilityPlaybook? playbook = Loaded; if (playbook == null) { @@ -95,7 +95,7 @@ public ePlaybookStart Start(string map) return ePlaybookStart.WrongMap; } - List steps = PlaybookUtility.Ordered(playbook); + List steps = PlaybookUtility.Ordered(playbook); if (steps.Count == 0) { @@ -120,7 +120,7 @@ public bool Stop() } _phase = Phase.Idle; - _steps = new List(); + _steps = new List(); return true; } @@ -129,7 +129,7 @@ public bool Stop() public void Reset() { _phase = Phase.Idle; - _steps = new List(); + _steps = new List(); _lineups.Clear(); } @@ -157,7 +157,7 @@ public void Tick() int elapsed = (int)(now - _startsAt).TotalMilliseconds; - foreach (NadePlaybookStep step in PlaybookUtility.Due(_steps, _elapsedMs, elapsed)) + foreach (UtilityPlaybookStep step in PlaybookUtility.Due(_steps, _elapsedMs, elapsed)) { Fire(step); } @@ -195,7 +195,7 @@ public void Second() } } - private void Fire(NadePlaybookStep step) + private void Fire(UtilityPlaybookStep step) { LineupRecord? lineup = LineupFor(step); @@ -233,9 +233,9 @@ private void Fire(NadePlaybookStep step) } } - private LineupRecord? LineupFor(NadePlaybookStep step) + private LineupRecord? LineupFor(UtilityPlaybookStep step) { - string id = step.nade_lineup_id ?? ""; + string id = step.utility_lineup_id ?? ""; if (!string.IsNullOrEmpty(id) && _lineups.TryGetValue(id, out LineupRecord? cached)) { diff --git a/apps/nades-sw/src/Services/PracticeRecorder.cs b/apps/utility-sw/src/Services/PracticeRecorder.cs similarity index 99% rename from apps/nades-sw/src/Services/PracticeRecorder.cs rename to apps/utility-sw/src/Services/PracticeRecorder.cs index 89e66588..43e71719 100644 --- a/apps/nades-sw/src/Services/PracticeRecorder.cs +++ b/apps/utility-sw/src/Services/PracticeRecorder.cs @@ -6,7 +6,7 @@ using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.SchemaDefinitions; -namespace NadePractice; +namespace UtilityPractice; // Turns a thrown grenade into a reproducible lineup. // diff --git a/apps/nades-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs similarity index 99% rename from apps/nades-sw/src/Services/PracticeReplay.cs rename to apps/utility-sw/src/Services/PracticeReplay.cs index 2469284f..52be7d7f 100644 --- a/apps/nades-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -6,7 +6,7 @@ using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.SchemaDefinitions; -namespace NadePractice; +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. @@ -35,7 +35,7 @@ public class PracticeReplay private const int MaxBloomBeamsTotal = 240; private readonly ISwiftlyCore _core; - private readonly NadesConfig _config; + private readonly UtilityConfig _config; private readonly PracticeRecorder _recorder; private readonly ILogger _logger; @@ -78,7 +78,7 @@ private class GhostThrow public PracticeReplay( ISwiftlyCore core, - NadesConfig config, + UtilityConfig config, PracticeRecorder recorder, ILogger logger ) diff --git a/apps/nades-sw/src/Services/PracticeScore.cs b/apps/utility-sw/src/Services/PracticeScore.cs similarity index 91% rename from apps/nades-sw/src/Services/PracticeScore.cs rename to apps/utility-sw/src/Services/PracticeScore.cs index ca0345c9..bd0d7e63 100644 --- a/apps/nades-sw/src/Services/PracticeScore.cs +++ b/apps/utility-sw/src/Services/PracticeScore.cs @@ -3,7 +3,7 @@ using SwiftlyS2.Shared.Players; using static SwiftlyS2.Shared.Helper; -namespace NadePractice; +namespace UtilityPractice; // Scores a throw against the lineup the thrower had loaded. // @@ -14,8 +14,8 @@ namespace NadePractice; public class PracticeScore { private readonly ISwiftlyCore _core; - private readonly NadesConfig _config; - private readonly NadesApiClient _api; + private readonly UtilityConfig _config; + private readonly UtilityApiClient _api; private readonly PracticeSession _session; private readonly PracticeSystem _system; @@ -27,8 +27,8 @@ public class PracticeScore public PracticeScore( ISwiftlyCore core, - NadesConfig config, - NadesApiClient api, + UtilityConfig config, + UtilityApiClient api, PracticeSession session, PracticeSystem system ) @@ -43,7 +43,7 @@ PracticeSystem 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 event Action? Scored; public void Reset() { @@ -78,7 +78,7 @@ public void OnFinalized(LineupRecord thrown) Vec3 landing = thrown.detonation_position; float distance = (landing - loaded.detonation_position).Length(); - var payload = NadePracticeResultPayload.For( + var payload = UtilityPracticeResultPayload.For( _config.ServerId, _session.Current?.id ?? Guid.Empty, loaded.id, @@ -93,7 +93,7 @@ public void OnFinalized(LineupRecord thrown) _ = Task.Run(async () => { - NadePracticeResult? result = await _api.PracticeResult(payload); + UtilityPracticeResult? result = await _api.PracticeResult(payload); _core.Scheduler.NextTick(() => Report(steamId, lineupId, key, name, result, distance)); }); @@ -104,7 +104,7 @@ private void Report( string lineupId, string key, string name, - NadePracticeResult? result, + UtilityPracticeResult? result, float measured ) { @@ -125,7 +125,7 @@ private void Announce( ulong steamId, string key, string name, - NadePracticeResult? result, + UtilityPracticeResult? result, float measured ) { diff --git a/apps/nades-sw/src/Services/PracticeSession.cs b/apps/utility-sw/src/Services/PracticeSession.cs similarity index 90% rename from apps/nades-sw/src/Services/PracticeSession.cs rename to apps/utility-sw/src/Services/PracticeSession.cs index 4d4f918e..36d45781 100644 --- a/apps/nades-sw/src/Services/PracticeSession.cs +++ b/apps/utility-sw/src/Services/PracticeSession.cs @@ -2,19 +2,19 @@ using FiveStack.Utilities; using Microsoft.Extensions.Logging; -namespace NadePractice; +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 NadesApiClient _api; + private readonly UtilityApiClient _api; private readonly ILogger _logger; private PracticeSessionData? _session; - public PracticeSession(NadesApiClient api, ILogger logger) + public PracticeSession(UtilityApiClient api, ILogger logger) { _api = api; _logger = logger; diff --git a/apps/nades-sw/src/Services/PracticeSolver.cs b/apps/utility-sw/src/Services/PracticeSolver.cs similarity index 99% rename from apps/nades-sw/src/Services/PracticeSolver.cs rename to apps/utility-sw/src/Services/PracticeSolver.cs index 01da5be9..64413577 100644 --- a/apps/nades-sw/src/Services/PracticeSolver.cs +++ b/apps/utility-sw/src/Services/PracticeSolver.cs @@ -7,7 +7,7 @@ using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.SchemaDefinitions; -namespace NadePractice; +namespace UtilityPractice; // Finds a throw that lands on a chosen point by throwing. // diff --git a/apps/nades-sw/src/Services/PracticeSystem.cs b/apps/utility-sw/src/Services/PracticeSystem.cs similarity index 98% rename from apps/nades-sw/src/Services/PracticeSystem.cs rename to apps/utility-sw/src/Services/PracticeSystem.cs index 3ab0c5ad..e14dce7a 100644 --- a/apps/nades-sw/src/Services/PracticeSystem.cs +++ b/apps/utility-sw/src/Services/PracticeSystem.cs @@ -6,7 +6,7 @@ using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.SchemaDefinitions; -namespace NadePractice; +namespace UtilityPractice; public class PracticeState { @@ -50,7 +50,7 @@ public class PracticeSystem private const int MaxSavedPositions = 32; private readonly ISwiftlyCore _core; - private readonly NadesConfig _config; + private readonly UtilityConfig _config; private readonly PracticeReplay _replay; private readonly ILogger _logger; @@ -63,7 +63,7 @@ public class PracticeSystem public PracticeSystem( ISwiftlyCore core, - NadesConfig config, + UtilityConfig config, PracticeReplay replay, ILogger logger ) @@ -163,7 +163,7 @@ public void Tick() // again a tick or two later. public void OnThrown(ulong steamId, string utilityType) { - if (!_config.InfiniteNades) + if (!_config.InfiniteUtility) { return; } diff --git a/apps/nades-css/src/Services/NadesApiClient.cs b/apps/utility-sw/src/Services/UtilityApiClient.cs similarity index 83% rename from apps/nades-css/src/Services/NadesApiClient.cs rename to apps/utility-sw/src/Services/UtilityApiClient.cs index 6bca97a3..19af59e2 100644 --- a/apps/nades-css/src/Services/NadesApiClient.cs +++ b/apps/utility-sw/src/Services/UtilityApiClient.cs @@ -1,12 +1,11 @@ using System.Net.Http; -using System.Net.Http.Headers; using System.Text; using System.Text.Json; using FiveStack.Entities.Practice; using FiveStack.Utilities; using Microsoft.Extensions.Logging; -namespace NadePractice; +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 @@ -16,7 +15,7 @@ namespace NadePractice; // 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 NadesApiClient +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. @@ -24,16 +23,16 @@ public class NadesApiClient private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); - private readonly NadesConfig _config; - private readonly ILogger _logger; + 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 Queue _resultQueue = + new Queue(); private readonly SemaphoreSlim _draining = new SemaphoreSlim(1, 1); - public NadesApiClient(NadesConfig config, ILogger logger) + public UtilityApiClient(UtilityConfig config, ILogger logger) { _config = config; _logger = logger; @@ -75,7 +74,7 @@ public int QueuedCount { string? body = await SendText( HttpMethod.Get, - $"/nades/library?map={Uri.EscapeDataString(map)}&steam_id={steamId}", + $"/utility/library?map={Uri.EscapeDataString(map)}&steam_id={steamId}", null ); @@ -86,7 +85,7 @@ public int QueuedCount try { - List? rows = ReadList(body, "lineups", "nades"); + List? rows = ReadList(body, "lineups", "utility"); return rows == null ? new List() @@ -101,11 +100,11 @@ public int QueuedCount // A library row carries no flight path and no measured bloom, so both cost // one more call. - public async Task Trajectory(string id) + public async Task Trajectory(string id) { byte[]? body = await Send( HttpMethod.Get, - $"/nades/{Uri.EscapeDataString(id)}/trajectory", + $"/utility/{Uri.EscapeDataString(id)}/trajectory", null ); @@ -116,7 +115,7 @@ public int QueuedCount try { - return NadeTrajectoryArtifact.Parse(body); + return UtilityTrajectoryArtifact.Parse(body); } catch (Exception error) { @@ -127,7 +126,7 @@ public int QueuedCount public async Task Session() { - string? body = await SendText(HttpMethod.Get, "/nades/session", null); + string? body = await SendText(HttpMethod.Get, "/utility/session", null); if (body == null) { @@ -137,7 +136,7 @@ public int QueuedCount try { return JsonSerializer - .Deserialize(body, PracticeJson.Options) + .Deserialize(body, PracticeJson.Options) ?.ToSession(); } catch (Exception error) @@ -151,9 +150,9 @@ public int QueuedCount // 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(NadePracticeResultPayload payload) + public async Task PracticeResult(UtilityPracticeResultPayload payload) { - NadePracticeResult? result = await PostResult(payload); + UtilityPracticeResult? result = await PostResult(payload); if (result == null) { @@ -168,7 +167,7 @@ public int QueuedCount public async Task Delete(string id) { - return await SendText(HttpMethod.Delete, $"/nades/{Uri.EscapeDataString(id)}", null) + return await SendText(HttpMethod.Delete, $"/utility/{Uri.EscapeDataString(id)}", null) != null; } @@ -207,7 +206,7 @@ public async Task Drain() while (true) { - NadePracticeResultPayload? result; + UtilityPracticeResultPayload? result; lock (_queueLock) { if (!_resultQueue.TryPeek(out result)) @@ -246,7 +245,7 @@ private void Enqueue(LineupRecord record) } } - private void EnqueueResult(NadePracticeResultPayload payload) + private void EnqueueResult(UtilityPracticeResultPayload payload) { lock (_queueLock) { @@ -266,7 +265,7 @@ private void EnqueueResult(NadePracticeResultPayload payload) try { body = JsonSerializer.Serialize( - NadeIngestPayload.From(record), + UtilityIngestPayload.From(record), PracticeJson.Options ); } @@ -278,7 +277,7 @@ private void EnqueueResult(NadePracticeResultPayload payload) return null; } - string? response = await SendText(HttpMethod.Post, "/nades/ingest", body); + string? response = await SendText(HttpMethod.Post, "/utility/ingest", body); if (response == null) { @@ -296,7 +295,7 @@ private void EnqueueResult(NadePracticeResultPayload payload) } } - private async Task PostResult(NadePracticeResultPayload payload) + private async Task PostResult(UtilityPracticeResultPayload payload) { payload.server_id = string.IsNullOrEmpty(_config.ServerId) ? null : _config.ServerId; @@ -312,7 +311,7 @@ private void EnqueueResult(NadePracticeResultPayload payload) return null; } - string? response = await SendText(HttpMethod.Post, "/nades/practice-result", body); + string? response = await SendText(HttpMethod.Post, "/utility/practice-result", body); if (response == null) { @@ -321,7 +320,7 @@ private void EnqueueResult(NadePracticeResultPayload payload) try { - return JsonSerializer.Deserialize(response, PracticeJson.Options); + return JsonSerializer.Deserialize(response, PracticeJson.Options); } catch (Exception error) { @@ -377,10 +376,6 @@ private void EnqueueResult(NadePracticeResultPayload payload) try { using var request = new HttpRequestMessage(method, Url(path)); - request.Headers.Authorization = new AuthenticationHeaderValue( - "Bearer", - _config.NadesApiKey - ); if (!string.IsNullOrEmpty(_config.ServerApiPassword)) { @@ -421,17 +416,17 @@ private void EnqueueResult(NadePracticeResultPayload payload) } } - // Every nade endpoint resolves the session from the server rather than from + // 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.NadesUrl}{path}"; + return $"{_config.UtilityUrl}{path}"; } string separator = path.Contains('?') ? "&" : "?"; - return $"{_config.NadesUrl}{path}{separator}server_id={Uri.EscapeDataString(_config.ServerId)}"; + return $"{_config.UtilityUrl}{path}{separator}server_id={Uri.EscapeDataString(_config.ServerId)}"; } } diff --git a/apps/nades-sw/src/Services/NadesConfig.cs b/apps/utility-sw/src/Services/UtilityConfig.cs similarity index 68% rename from apps/nades-sw/src/Services/NadesConfig.cs rename to apps/utility-sw/src/Services/UtilityConfig.cs index b07388ad..5bf24228 100644 --- a/apps/nades-sw/src/Services/NadesConfig.cs +++ b/apps/utility-sw/src/Services/UtilityConfig.cs @@ -1,44 +1,42 @@ using System.Text.Json; using Microsoft.Extensions.Logging; -namespace NadePractice; +namespace UtilityPractice; -// Configuration arrives as addons/counterstrikesharp/configs/nade-practice.json, +// 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 NadesConfig +public class UtilityConfig { - public string NadesUrl { get; private set; } = ""; - public string NadesApiKey { get; private set; } = ""; + public string UtilityUrl { get; private set; } = ""; - // The plugin key alone buys nothing: every nade endpoint also wants the + // 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 InfiniteNades { 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; + private readonly ILogger _logger; - public NadesConfig(ILogger logger) + public UtilityConfig(ILogger logger) { _logger = logger; } private class ConfigFile { - public string? nades_url { get; set; } - public string? nades_apikey { get; set; } + 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_nades { 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; } @@ -46,21 +44,20 @@ private class ConfigFile } // Candidates rather than one path: the registry writes - // addons/{runtime}/configs/nade-practice.json, but the two runtimes root + // 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. - NadesUrl = Environment.GetEnvironmentVariable("NADES_URL") ?? ""; - NadesApiKey = Environment.GetEnvironmentVariable("NADES_API_KEY") ?? ""; + 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, "nade-practice.json")) + .Select(directory => Path.Join(directory, "utility-practice.json")) .FirstOrDefault(File.Exists); if (path != null) @@ -73,13 +70,9 @@ public void Load(params string[] configDirectories) if (parsed != null) { - if (string.IsNullOrEmpty(NadesUrl)) + if (string.IsNullOrEmpty(UtilityUrl)) { - NadesUrl = parsed.nades_url ?? ""; - } - if (string.IsNullOrEmpty(NadesApiKey)) - { - NadesApiKey = parsed.nades_apikey ?? ""; + UtilityUrl = parsed.utility_url ?? ""; } if (string.IsNullOrEmpty(ServerId)) { @@ -91,7 +84,7 @@ public void Load(params string[] configDirectories) } RecordEnabled = parsed.np_record_enabled ?? RecordEnabled; ReplayEnabled = parsed.np_replay_enabled ?? ReplayEnabled; - InfiniteNades = parsed.np_infinite_nades ?? InfiniteNades; + InfiniteUtility = parsed.np_infinite_utility ?? InfiniteUtility; NoFlash = parsed.np_no_flash ?? NoFlash; GhostPreview = parsed.np_ghost_preview ?? GhostPreview; GhostProjectile = parsed.np_ghost_projectile ?? GhostProjectile; @@ -104,21 +97,21 @@ public void Load(params string[] configDirectories) } } - NadesUrl = NadesUrl.TrimEnd('/'); + UtilityUrl = UtilityUrl.TrimEnd('/'); - if (string.IsNullOrEmpty(NadesUrl) || string.IsNullOrEmpty(NadesApiKey)) + 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( - "nade practice is not connected to a panel; lineups cannot be saved or loaded" + "utility practice is not connected to a panel; lineups cannot be saved or loaded" ); } } public bool IsConnected() { - return !string.IsNullOrEmpty(NadesUrl) && !string.IsNullOrEmpty(NadesApiKey); + return !string.IsNullOrEmpty(UtilityUrl) && !string.IsNullOrEmpty(ServerApiPassword); } } diff --git a/apps/nades-sw/src/NadePractice.csproj b/apps/utility-sw/src/UtilityPractice.csproj similarity index 98% rename from apps/nades-sw/src/NadePractice.csproj rename to apps/utility-sw/src/UtilityPractice.csproj index ebdb65fa..4d29bbdd 100644 --- a/apps/nades-sw/src/NadePractice.csproj +++ b/apps/utility-sw/src/UtilityPractice.csproj @@ -2,8 +2,8 @@ true true - NadePractice - NadePractice + UtilityPractice + UtilityPractice $(MSBuildThisFileDirectory)build/ diff --git a/apps/nades-sw/src/NadePracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs similarity index 90% rename from apps/nades-sw/src/NadePracticePlugin.cs rename to apps/utility-sw/src/UtilityPracticePlugin.cs index 4f71d6df..b16f2b4e 100644 --- a/apps/nades-sw/src/NadePracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -8,25 +8,25 @@ using SwiftlyS2.Shared.Plugins; using static SwiftlyS2.Shared.Helper; -namespace NadePractice; +namespace UtilityPractice; // Standalone plugin, installed through the 5stack game-plugin registry and -// bound to the nade-practice game mode. It is only ever loaded on a practice +// 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 = "NadePractice", + Id = "UtilityPractice", Version = "__RELEASE_VERSION__", - Name = "nade-practice", + Name = "utility-practice", Author = "5Stack.gg", Description = "Records grenade lineups in game and replays saved lineups back to the thrower" )] -public partial class NadePracticePlugin : BasePlugin +public partial class UtilityPracticePlugin : BasePlugin { - private ILogger _logger = null!; + private ILogger _logger = null!; private IServiceProvider _serviceProvider = null!; - private NadesConfig _config = null!; - private NadesApiClient _api = null!; + private UtilityConfig _config = null!; + private UtilityApiClient _api = null!; private PracticeSession _session = null!; private PracticeRecorder _recorder = null!; private PracticeLibrary _library = null!; @@ -46,19 +46,19 @@ public partial class NadePracticePlugin : BasePlugin private EventDelegates.OnClientDisconnected? _disconnectHandler; private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; - public NadePracticePlugin(ISwiftlyCore core) + public UtilityPracticePlugin(ISwiftlyCore core) : base(core) { } public string ModuleVersion => - typeof(NadePracticePlugin).GetCustomAttribute()?.Version ?? "unknown"; + typeof(UtilityPracticePlugin).GetCustomAttribute()?.Version ?? "unknown"; public override void Load(bool hotReload) { ServiceCollection services = new(); services .AddSwiftly(Core) - .AddSingleton() - .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -70,9 +70,9 @@ public override void Load(bool hotReload) .AddSingleton(); _serviceProvider = services.BuildServiceProvider(); - _logger = _serviceProvider.GetRequiredService>(); - _config = _serviceProvider.GetRequiredService(); - _api = _serviceProvider.GetRequiredService(); + _logger = _serviceProvider.GetRequiredService>(); + _config = _serviceProvider.GetRequiredService(); + _api = _serviceProvider.GetRequiredService(); _session = _serviceProvider.GetRequiredService(); _recorder = _serviceProvider.GetRequiredService(); _library = _serviceProvider.GetRequiredService(); @@ -84,9 +84,9 @@ public override void Load(bool hotReload) _solver = _serviceProvider.GetRequiredService(); // addons/swiftlys2/configs is two levels up from - // addons/swiftlys2/plugins/NadePractice. + // addons/swiftlys2/plugins/UtilityPractice. string pluginDirectory = - Path.GetDirectoryName(typeof(NadePracticePlugin).Assembly.Location) ?? ""; + Path.GetDirectoryName(typeof(UtilityPracticePlugin).Assembly.Location) ?? ""; _config.Load(Path.Join(pluginDirectory, "../../configs"), pluginDirectory); _replay.IsSolo = _system.IsSolo; @@ -130,7 +130,7 @@ public override void Load(bool hotReload) RefreshEverything(); _logger.LogInformation( - "nade practice {version} loaded (connected: {connected})", + "utility practice {version} loaded (connected: {connected})", ModuleVersion, _config.IsConnected() ); diff --git a/apps/nades-css/test/DrillUtilityTests.cs b/apps/utility-sw/test/DrillUtilityTests.cs similarity index 99% rename from apps/nades-css/test/DrillUtilityTests.cs rename to apps/utility-sw/test/DrillUtilityTests.cs index a03fc58c..3db0ce91 100644 --- a/apps/nades-css/test/DrillUtilityTests.cs +++ b/apps/utility-sw/test/DrillUtilityTests.cs @@ -21,14 +21,14 @@ private static LineupRecord Lineup(string id, string? name = null, string utilit }; } - private static NadePracticeResult Result( + private static UtilityPracticeResult Result( bool success, int attempts, int successes, bool mastered = false ) { - return new NadePracticeResult + return new UtilityPracticeResult { success = success, distance = 40f, diff --git a/apps/nades-sw/test/FiveStack.Tests.csproj b/apps/utility-sw/test/FiveStack.Tests.csproj similarity index 100% rename from apps/nades-sw/test/FiveStack.Tests.csproj rename to apps/utility-sw/test/FiveStack.Tests.csproj diff --git a/apps/nades-sw/test/PlaybookUtilityTests.cs b/apps/utility-sw/test/PlaybookUtilityTests.cs similarity index 91% rename from apps/nades-sw/test/PlaybookUtilityTests.cs rename to apps/utility-sw/test/PlaybookUtilityTests.cs index df849b67..78d94cdd 100644 --- a/apps/nades-sw/test/PlaybookUtilityTests.cs +++ b/apps/utility-sw/test/PlaybookUtilityTests.cs @@ -6,7 +6,7 @@ // fires twice, and a step that never fires at all. public class PlaybookUtilityTests { - private static NadePlaybookStep Step( + private static UtilityPlaybookStep Step( int order, int offsetMs, string? assigned = null, @@ -15,19 +15,19 @@ private static NadePlaybookStep Step( string? confidence = null ) { - return new NadePlaybookStep + return new UtilityPlaybookStep { - nade_lineup_id = $"lineup-{order}", + utility_lineup_id = $"lineup-{order}", step_order = order, offset_ms = offsetMs, assigned_steam_id = assigned, note = $"step {order}", lineup = withLineup - ? new NadeLibraryRow + ? new UtilityLibraryRow { id = $"row-{order}", - name = $"nade {order}", - nade_type = "Smoke", + name = $"utility {order}", + utility_type = "Smoke", origin_x = 1f, origin_y = 2f, origin_z = 3f, @@ -46,9 +46,9 @@ private static NadePlaybookStep Step( }; } - private static NadePlaybook Playbook(params NadePlaybookStep[] steps) + private static UtilityPlaybook Playbook(params UtilityPlaybookStep[] steps) { - return new NadePlaybook + return new UtilityPlaybook { id = "book", name = "A execute", @@ -142,7 +142,7 @@ public void WalkingTheWindowFiresEveryStepOnce() [Fact] public void TheDurationIsTheLastOffset() { - Assert.Equal(0, PlaybookUtility.DurationMs(new List())); + Assert.Equal(0, PlaybookUtility.DurationMs(new List())); Assert.Equal( 2500, PlaybookUtility.DurationMs( @@ -154,7 +154,7 @@ public void TheDurationIsTheLastOffset() [Fact] public void AnUnassignedStepBelongsToEveryone() { - NadePlaybookStep step = Step(1, 0); + UtilityPlaybookStep step = Step(1, 0); Assert.False(PlaybookUtility.IsAssigned(step)); Assert.True(PlaybookUtility.IsFor(step, 76561198000000001)); @@ -164,7 +164,7 @@ public void AnUnassignedStepBelongsToEveryone() [Fact] public void AnAssignedStepBelongsToOnlyThatPlayer() { - NadePlaybookStep step = Step(1, 0, assigned: " 76561198000000001 "); + UtilityPlaybookStep step = Step(1, 0, assigned: " 76561198000000001 "); Assert.True(PlaybookUtility.IsAssigned(step)); Assert.True(PlaybookUtility.IsFor(step, 76561198000000001)); diff --git a/apps/nades-sw/test/PracticeCalibrationUtilityTests.cs b/apps/utility-sw/test/PracticeCalibrationUtilityTests.cs similarity index 100% rename from apps/nades-sw/test/PracticeCalibrationUtilityTests.cs rename to apps/utility-sw/test/PracticeCalibrationUtilityTests.cs diff --git a/apps/nades-sw/test/PracticeConnectUtilityTests.cs b/apps/utility-sw/test/PracticeConnectUtilityTests.cs similarity index 100% rename from apps/nades-sw/test/PracticeConnectUtilityTests.cs rename to apps/utility-sw/test/PracticeConnectUtilityTests.cs diff --git a/apps/nades-css/test/PracticeDrillRunTests.cs b/apps/utility-sw/test/PracticeDrillRunTests.cs similarity index 99% rename from apps/nades-css/test/PracticeDrillRunTests.cs rename to apps/utility-sw/test/PracticeDrillRunTests.cs index 35aa1969..e88e638a 100644 --- a/apps/nades-css/test/PracticeDrillRunTests.cs +++ b/apps/utility-sw/test/PracticeDrillRunTests.cs @@ -29,9 +29,9 @@ private static PracticeDrillRun Run(params string[] ids) return new PracticeDrillRun(ids.Select(id => Lineup(id)).ToList()); } - private static NadePracticeResult Result(bool success) + private static UtilityPracticeResult Result(bool success) { - return new NadePracticeResult + return new UtilityPracticeResult { success = success, distance = success ? 20f : 300f, diff --git a/apps/nades-sw/test/PracticeJsonTests.cs b/apps/utility-sw/test/PracticeJsonTests.cs similarity index 88% rename from apps/nades-sw/test/PracticeJsonTests.cs rename to apps/utility-sw/test/PracticeJsonTests.cs index 8c9c0f6e..eaa05d55 100644 --- a/apps/nades-sw/test/PracticeJsonTests.cs +++ b/apps/utility-sw/test/PracticeJsonTests.cs @@ -28,12 +28,12 @@ public void APathPointReadsBackFieldByField() { const string json = "[{\"tick\":7,\"x\":1.5,\"y\":-2.5,\"z\":3}]"; - List? path = JsonSerializer.Deserialize>( + List? path = JsonSerializer.Deserialize>( json, PracticeJson.Options ); - NadePathPoint point = Assert.Single(path!); + UtilityPathPoint point = Assert.Single(path!); Assert.Equal(7, point.tick); Assert.Equal(1.5f, point.x); Assert.Equal(-2.5f, point.y); @@ -47,7 +47,7 @@ public void AMissingFieldReadsAsAbsentRatherThanFailing() { const string json = "{\"id\":\"x\",\"name\":\"only a name\"}"; - NadeLibraryRow? row = JsonSerializer.Deserialize( + UtilityLibraryRow? row = JsonSerializer.Deserialize( json, PracticeJson.Options ); diff --git a/apps/nades-sw/test/PracticeLaunchUtilityTests.cs b/apps/utility-sw/test/PracticeLaunchUtilityTests.cs similarity index 100% rename from apps/nades-sw/test/PracticeLaunchUtilityTests.cs rename to apps/utility-sw/test/PracticeLaunchUtilityTests.cs diff --git a/apps/nades-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs similarity index 100% rename from apps/nades-sw/test/PracticeLineupUtilityTests.cs rename to apps/utility-sw/test/PracticeLineupUtilityTests.cs diff --git a/apps/nades-sw/test/PracticeSignalUtilityTests.cs b/apps/utility-sw/test/PracticeSignalUtilityTests.cs similarity index 95% rename from apps/nades-sw/test/PracticeSignalUtilityTests.cs rename to apps/utility-sw/test/PracticeSignalUtilityTests.cs index ccc3d187..cf8c7b39 100644 --- a/apps/nades-sw/test/PracticeSignalUtilityTests.cs +++ b/apps/utility-sw/test/PracticeSignalUtilityTests.cs @@ -11,7 +11,7 @@ public class PracticeSignalUtilityTests { // The shape a reader outside this repo matches on. private static readonly Regex Line = new Regex( - @"^\[nade-practice\] ghost_detonated utility=(?\S+) lineup=(?\S+) lineup_id=(?\S+) steam=(?\d+) x=(?-?\d+\.\d\d) y=(?-?\d+\.\d\d) z=(?-?\d+\.\d\d)$" + @"^\[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] diff --git a/apps/nades-sw/test/PracticeSolverPlanTests.cs b/apps/utility-sw/test/PracticeSolverPlanTests.cs similarity index 100% rename from apps/nades-sw/test/PracticeSolverPlanTests.cs rename to apps/utility-sw/test/PracticeSolverPlanTests.cs diff --git a/apps/nades-sw/test/PracticeSolverUtilityTests.cs b/apps/utility-sw/test/PracticeSolverUtilityTests.cs similarity index 100% rename from apps/nades-sw/test/PracticeSolverUtilityTests.cs rename to apps/utility-sw/test/PracticeSolverUtilityTests.cs diff --git a/apps/nades-sw/test/SmokeVolumeUtilityTests.cs b/apps/utility-sw/test/SmokeVolumeUtilityTests.cs similarity index 100% rename from apps/nades-sw/test/SmokeVolumeUtilityTests.cs rename to apps/utility-sw/test/SmokeVolumeUtilityTests.cs diff --git a/apps/nades-sw/test/TrajectoryUtilityTests.cs b/apps/utility-sw/test/TrajectoryUtilityTests.cs similarity index 100% rename from apps/nades-sw/test/TrajectoryUtilityTests.cs rename to apps/utility-sw/test/TrajectoryUtilityTests.cs diff --git a/apps/nades-sw/test/NadeArtifactTests.cs b/apps/utility-sw/test/UtilityArtifactTests.cs similarity index 87% rename from apps/nades-sw/test/NadeArtifactTests.cs rename to apps/utility-sw/test/UtilityArtifactTests.cs index f0ee7532..4d62ec34 100644 --- a/apps/nades-sw/test/NadeArtifactTests.cs +++ b/apps/utility-sw/test/UtilityArtifactTests.cs @@ -7,7 +7,7 @@ // 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 NadeArtifactTests +public class UtilityArtifactTests { // The artifact is the playback blob's shape, so the path is nested. private const string Artifact = """ @@ -41,7 +41,7 @@ public class NadeArtifactTests [Fact] public void ThePathIsReadOutOfTheNestedTrajectory() { - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse(Artifact); + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse(Artifact); Assert.Equal(2, artifact.path.Count); Assert.Equal(0, artifact.path[0].t); @@ -53,7 +53,7 @@ public void ThePathIsReadOutOfTheNestedTrajectory() [Fact] public void TheSmokeVolumeIsReadOutOfTheArtifact() { - SmokeVolume? volume = NadeTrajectoryArtifact.Parse(Artifact).smoke_volume; + SmokeVolume? volume = UtilityTrajectoryArtifact.Parse(Artifact).smoke_volume; Assert.NotNull(volume); Assert.Equal(-100f, volume!.ox); @@ -78,7 +78,7 @@ public void AGzippedArtifactIsUnpackedFirst() plain.CopyTo(gzip); } - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse(compressed.ToArray()); + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse(compressed.ToArray()); Assert.Equal(2, artifact.path.Count); Assert.NotNull(artifact.smoke_volume); @@ -93,7 +93,7 @@ public void APlainBodyIsReadAsItIs() [Fact] public void AFlatPathIsStillRead() { - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse( + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( """{"path":[{"tick":4,"x":1,"y":2,"z":3}]}""" ); @@ -104,7 +104,7 @@ public void AFlatPathIsStillRead() [Fact] public void ABareArrayIsStillRead() { - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse( + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( """[{"tick":4,"x":1,"y":2,"z":3}]""" ); @@ -115,7 +115,7 @@ public void ABareArrayIsStillRead() [Fact] public void AMissingSmokeVolumeIsNotAnError() { - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse( + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( """{"grenade_trajectories":[{"points":[]}],"smoke_volumes":[]}""" ); @@ -126,7 +126,7 @@ public void AMissingSmokeVolumeIsNotAnError() [Fact] public void AVolumeWithNoExtentIsNotAVolume() { - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse( + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse( """{"smoke_volumes":[{"ox":0,"oy":0,"oz":0,"vs":0,"dx":0,"dy":0,"dz":0}]}""" ); @@ -136,7 +136,7 @@ public void AVolumeWithNoExtentIsNotAVolume() [Fact] public void AnArtifactWithNothingInItReadsAsEmpty() { - NadeTrajectoryArtifact artifact = NadeTrajectoryArtifact.Parse("{}"); + UtilityTrajectoryArtifact artifact = UtilityTrajectoryArtifact.Parse("{}"); Assert.Empty(artifact.path); Assert.Null(artifact.smoke_volume); @@ -146,7 +146,7 @@ public void AnArtifactWithNothingInItReadsAsEmpty() public void TheSessionIsReadInTheApiSpelling() { PracticeSessionData session = JsonSerializer - .Deserialize( + .Deserialize( """ { "session_id": "11111111-1111-1111-1111-111111111111", @@ -175,7 +175,7 @@ public void TheSessionIsReadInTheApiSpelling() public void TheOlderSessionSpellingStillFillsTheRoster() { PracticeSessionData session = JsonSerializer - .Deserialize( + .Deserialize( """ { "id": "11111111-1111-1111-1111-111111111111", @@ -198,7 +198,7 @@ public void TheOlderSessionSpellingStillFillsTheRoster() public void APlaybookOnTheSessionArrivesWithItsSteps() { PracticeSessionData session = JsonSerializer - .Deserialize( + .Deserialize( """ { "session_id": "11111111-1111-1111-1111-111111111111", @@ -213,7 +213,7 @@ public void APlaybookOnTheSessionArrivesWithItsSteps() "side": "TERRORIST", "steps": [ { - "nade_lineup_id": "44444444-4444-4444-4444-444444444444", + "utility_lineup_id": "44444444-4444-4444-4444-444444444444", "step_order": 1, "offset_ms": 0, "assigned_steam_id": "76561198000000001", @@ -222,7 +222,7 @@ public void APlaybookOnTheSessionArrivesWithItsSteps() "id": "44444444-4444-4444-4444-444444444444", "name": "jungle", "map_name": "de_mirage", - "nade_type": "Smoke", + "utility_type": "Smoke", "side": "TERRORIST", "origin_x": 1, "origin_y": 2, "origin_z": 3, "view_yaw": 90, "view_pitch": -20, @@ -237,7 +237,7 @@ public void APlaybookOnTheSessionArrivesWithItsSteps() )! .ToSession(); - NadePlaybook? playbook = session.playbook; + UtilityPlaybook? playbook = session.playbook; Assert.NotNull(playbook); Assert.Equal("A split", playbook!.name); @@ -258,7 +258,7 @@ public void APlaybookOnTheSessionArrivesWithItsSteps() [Fact] public void AResultNamesTheServerAndTheSessionItBelongsTo() { - NadePracticeResultPayload payload = NadePracticeResultPayload.For( + UtilityPracticeResultPayload payload = UtilityPracticeResultPayload.For( "55555555-5555-5555-5555-555555555555", Guid.Parse("11111111-1111-1111-1111-111111111111"), "44444444-4444-4444-4444-444444444444", @@ -269,7 +269,7 @@ public void AResultNamesTheServerAndTheSessionItBelongsTo() 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.nade_lineup_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); @@ -280,7 +280,7 @@ public void AResultNamesTheServerAndTheSessionItBelongsTo() [Fact] public void AnUnknownSessionIsLeftOutOfTheResult() { - NadePracticeResultPayload payload = NadePracticeResultPayload.For( + UtilityPracticeResultPayload payload = UtilityPracticeResultPayload.For( null, Guid.Empty, "44444444-4444-4444-4444-444444444444", @@ -294,13 +294,13 @@ public void AnUnknownSessionIsLeftOutOfTheResult() Assert.DoesNotContain("session_id", json); Assert.DoesNotContain("server_id", json); Assert.DoesNotContain("success", json); - Assert.Contains("\"nade_lineup_id\"", json); + Assert.Contains("\"utility_lineup_id\"", json); } [Fact] public void AResultIsReadBackWithThePanelsRadius() { - NadePracticeResult? result = JsonSerializer.Deserialize( + UtilityPracticeResult? result = JsonSerializer.Deserialize( """ { "success": true, @@ -327,7 +327,7 @@ public void AResultIsReadBackWithThePanelsRadius() [Fact] public void AResultThatHasNotBeenMasteredCarriesNoDate() { - NadePracticeResult? result = JsonSerializer.Deserialize( + 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 ); diff --git a/apps/nades-sw/test/NadeWireTests.cs b/apps/utility-sw/test/UtilityWireTests.cs similarity index 92% rename from apps/nades-sw/test/NadeWireTests.cs rename to apps/utility-sw/test/UtilityWireTests.cs index 65a6c3ef..30414d78 100644 --- a/apps/nades-sw/test/NadeWireTests.cs +++ b/apps/utility-sw/test/UtilityWireTests.cs @@ -7,7 +7,7 @@ // 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 NadeWireTests +public class UtilityWireTests { private static LineupRecord Lineup() { @@ -51,10 +51,10 @@ private static LineupRecord Lineup() [Fact] public void EveryFieldLandsInTheColumnTheApiNames() { - NadeIngestPayload payload = NadeIngestPayload.From(Lineup()); + UtilityIngestPayload payload = UtilityIngestPayload.From(Lineup()); Assert.Equal("76561198000000001", payload.author_steam_id); - Assert.Equal("Smoke", payload.nade_type); + Assert.Equal("Smoke", payload.utility_type); Assert.Equal("TERRORIST", payload.side); Assert.Equal("RunJump", payload.technique); Assert.Equal("Full", payload.throw_strength); @@ -81,7 +81,7 @@ public void EveryFieldLandsInTheColumnTheApiNames() [Fact] public void FlightTimeCrossesTheWireInMilliseconds() { - NadeIngestPayload payload = NadeIngestPayload.From(Lineup()); + UtilityIngestPayload payload = UtilityIngestPayload.From(Lineup()); Assert.Equal(1500, payload.flight_time_ms); } @@ -93,7 +93,7 @@ public void FlightTimeCrossesTheWireInMilliseconds() [InlineData(20f, 20000)] public void MillisecondsAreRoundedNotTruncated(float seconds, int expected) { - Assert.Equal(expected, NadeIngestPayload.MillisecondsFromSeconds(seconds)); + Assert.Equal(expected, UtilityIngestPayload.MillisecondsFromSeconds(seconds)); } // 62.5 is exactly representable, so this pins the midpoint rule rather @@ -101,7 +101,7 @@ public void MillisecondsAreRoundedNotTruncated(float seconds, int expected) [Fact] public void AMidpointRoundsAwayFromZero() { - Assert.Equal(63, NadeIngestPayload.MillisecondsFromSeconds(0.0625f)); + Assert.Equal(63, UtilityIngestPayload.MillisecondsFromSeconds(0.0625f)); } [Theory] @@ -112,19 +112,19 @@ public void AMidpointRoundsAwayFromZero() [InlineData("Incendiary", "Molotov")] [InlineData("Smoke", "Smoke")] [InlineData("Decoy", "Decoy")] - public void TheNadeTypeIsSentInTheApisSpelling(string recorded, string expected) + public void TheUtilityTypeIsSentInTheApisSpelling(string recorded, string expected) { LineupRecord lineup = Lineup(); lineup.utility_type = recorded; - Assert.Equal(expected, NadeIngestPayload.From(lineup).nade_type); + Assert.Equal(expected, UtilityIngestPayload.From(lineup).utility_type); } [Fact] public void ThePathIsSentAsObjectsNotPackedArrays() { string json = JsonSerializer.Serialize( - NadeIngestPayload.From(Lineup()), + UtilityIngestPayload.From(Lineup()), PracticeJson.Options ); @@ -152,21 +152,21 @@ public void ThePathIsSentAsObjectsNotPackedArrays() public void FieldsTheApiDoesNotAcceptAreNotSent(string absent) { string json = JsonSerializer.Serialize( - NadeIngestPayload.From(Lineup()), + UtilityIngestPayload.From(Lineup()), PracticeJson.Options ); Assert.DoesNotContain(absent, json); } - private static NadeLibraryRow Row() + private static UtilityLibraryRow Row() { - return new NadeLibraryRow + return new UtilityLibraryRow { id = "panel-id", name = "Window smoke", map_name = "de_mirage", - nade_type = "HE", + utility_type = "HE", side = "CT", technique = "Jump", throw_strength = "Half", @@ -257,7 +257,7 @@ public void ARowWithNoSeedIsNotReplayable() [Fact] public void ARowWithASeedIsReplayableExactly() { - NadeLibraryRow row = Seeded(); + UtilityLibraryRow row = Seeded(); LineupRecord lineup = row.ToLineup(); Assert.True(row.HasSeed()); @@ -282,7 +282,7 @@ public void ARowWithASeedIsReplayableExactly() [InlineData("initial_vel_z")] public void HalfASeedIsNoSeed(string missing) { - NadeLibraryRow row = Seeded(); + UtilityLibraryRow row = Seeded(); switch (missing) { @@ -313,7 +313,7 @@ public void HalfASeedIsNoSeed(string missing) [Fact] public void AZeroedSeedIsNoSeed() { - NadeLibraryRow row = Seeded(); + UtilityLibraryRow row = Seeded(); row.initial_vel_x = 0f; row.initial_vel_y = 0f; row.initial_vel_z = 0f; @@ -331,7 +331,7 @@ public void AZeroedSeedIsNoSeed() public void ASeedSurvivesTheRoundTripThroughBothShapes() { LineupRecord original = Lineup(); - NadeLibraryRow row = Seeded(); + UtilityLibraryRow row = Seeded(); row.initial_pos_x = original.initial_position.x; row.initial_pos_y = original.initial_position.y; @@ -401,7 +401,7 @@ public void AMissingConfidenceIsNotWarnedAbout() [Fact] public void ExactWithNoSeedIsStillNotReplayable() { - NadeLibraryRow row = Row(); + UtilityLibraryRow row = Row(); row.confidence = "exact"; LineupRecord lineup = row.ToLineup(); @@ -430,9 +430,9 @@ public void ALineupRecordedHereIsExactlyReplayable() Assert.True(recorded.IsExactlyReplayable()); } - private static NadeLibraryRow Seeded(string? confidence = null) + private static UtilityLibraryRow Seeded(string? confidence = null) { - NadeLibraryRow row = Row(); + UtilityLibraryRow row = Row(); row.initial_pos_x = 11f; row.initial_pos_y = 22f; @@ -449,13 +449,13 @@ private static NadeLibraryRow Seeded(string? confidence = null) public void APositionSurvivesTheRoundTripThroughBothShapes() { LineupRecord original = Lineup(); - NadeIngestPayload payload = NadeIngestPayload.From(original); + UtilityIngestPayload payload = UtilityIngestPayload.From(original); - var row = new NadeLibraryRow + var row = new UtilityLibraryRow { id = "panel-id", name = payload.name, - nade_type = payload.nade_type, + utility_type = payload.utility_type, side = payload.side, technique = payload.technique, throw_strength = payload.throw_strength, @@ -491,7 +491,7 @@ public void NullsAreOmittedRatherThanSentAsNull() var bare = new LineupRecord { utility_type = "Smoke" }; string json = JsonSerializer.Serialize( - NadeIngestPayload.From(bare), + UtilityIngestPayload.From(bare), PracticeJson.Options ); diff --git a/codepier.yaml b/codepier.yaml index 0ee8538f..ddd9144e 100644 --- a/codepier.yaml +++ b/codepier.yaml @@ -4,8 +4,8 @@ container: dev workload: - dev-game-server - dev-swiftly-game-server - - dev-nades-game-server - - dev-nades-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 c70187aa..3eb23545 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -9,8 +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-nades-game-server) app="nades-css" ;; - dev-nades-swiftly-game-server) app="nades-sw" ;; + 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/shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs b/shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs index 4a531087..5f2ecbd1 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/PracticeSessionData.cs @@ -12,5 +12,5 @@ public class PracticeSessionData public List allowed_steam_ids { get; set; } = new List(); // Null unless the panel has loaded an execute onto this session. - public NadePlaybook? playbook { get; set; } + public UtilityPlaybook? playbook { get; set; } } diff --git a/shared/dotnet/FiveStack.Entities/Practice/NadeIngestPayload.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs similarity index 86% rename from shared/dotnet/FiveStack.Entities/Practice/NadeIngestPayload.cs rename to shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs index e95049ac..8f05e530 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/NadeIngestPayload.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs @@ -2,18 +2,18 @@ namespace FiveStack.Entities.Practice; -// The body of POST /nades/ingest, exactly as the API defines it. +// 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 NadeIngestPayload +public class UtilityIngestPayload { public string? match_id { get; set; } public string? author_steam_id { get; set; } - public string? nade_type { get; set; } + public string? utility_type { get; set; } public string? side { get; set; } public string? technique { get; set; } public string? throw_strength { get; set; } @@ -36,14 +36,14 @@ public class NadeIngestPayload public string? description { get; set; } public int? tick_rate { get; set; } - public List? path { get; set; } + public List? path { get; set; } - public static NadeIngestPayload From(LineupRecord lineup) + public static UtilityIngestPayload From(LineupRecord lineup) { - return new NadeIngestPayload + return new UtilityIngestPayload { author_steam_id = Text(lineup.author_steam_id), - nade_type = PracticeLineupUtility.NormalizeUtilityType(lineup.utility_type), + utility_type = PracticeLineupUtility.NormalizeUtilityType(lineup.utility_type), side = Text(lineup.side), technique = Text(lineup.technique), throw_strength = Text(lineup.strength), @@ -66,7 +66,7 @@ public static NadeIngestPayload From(LineupRecord lineup) tick_rate = lineup.recorded_tickrate, path = lineup - .trajectory.Select(point => new NadePathPoint + .trajectory.Select(point => new UtilityPathPoint { tick = point.t, x = point.p.x, diff --git a/shared/dotnet/FiveStack.Entities/Practice/NadeLibraryRow.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs similarity index 94% rename from shared/dotnet/FiveStack.Entities/Practice/NadeLibraryRow.cs rename to shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs index 76f739eb..d7665dfc 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/NadeLibraryRow.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs @@ -2,18 +2,18 @@ namespace FiveStack.Entities.Practice; -// A row of GET /nades/library, exactly as the API returns it. The steps of a +// 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 /nades/{id}/trajectory. -public class NadeLibraryRow +// 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? nade_type { get; set; } + public string? utility_type { get; set; } public string? side { get; set; } public string? technique { get; set; } public string? throw_strength { get; set; } @@ -60,7 +60,7 @@ public LineupRecord ToLineup() client_id = id ?? Guid.NewGuid().ToString(), name = name ?? "", map = map_name ?? "", - utility_type = PracticeLineupUtility.NormalizeUtilityType(nade_type ?? ""), + utility_type = PracticeLineupUtility.NormalizeUtilityType(utility_type ?? ""), side = side ?? "TERRORIST", technique = technique ?? "", strength = throw_strength, diff --git a/shared/dotnet/FiveStack.Entities/Practice/NadePathPoint.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityPathPoint.cs similarity index 93% rename from shared/dotnet/FiveStack.Entities/Practice/NadePathPoint.cs rename to shared/dotnet/FiveStack.Entities/Practice/UtilityPathPoint.cs index 5c2d6c58..cd966883 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/NadePathPoint.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityPathPoint.cs @@ -3,7 +3,7 @@ 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 NadePathPoint +public class UtilityPathPoint { public int? tick { get; set; } public float? x { get; set; } diff --git a/shared/dotnet/FiveStack.Entities/Practice/NadePlaybook.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityPlaybook.cs similarity index 66% rename from shared/dotnet/FiveStack.Entities/Practice/NadePlaybook.cs rename to shared/dotnet/FiveStack.Entities/Practice/UtilityPlaybook.cs index 02325501..07d34105 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/NadePlaybook.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityPlaybook.cs @@ -1,28 +1,28 @@ namespace FiveStack.Entities.Practice; -// An execute, as GET /nades/session returns it when one is loaded. Steps arrive +// An execute, as GET /utility/session returns it when one is loaded. Steps arrive // ordered by step_order. -public class NadePlaybook +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(); + 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 NadePlaybookStep +public class UtilityPlaybookStep { - public string? nade_lineup_id { get; set; } + 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 NadeLibraryRow? lineup { 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 @@ -36,10 +36,10 @@ public class NadePlaybookStep LineupRecord record = lineup.ToLineup(); - if (!string.IsNullOrEmpty(nade_lineup_id)) + if (!string.IsNullOrEmpty(utility_lineup_id)) { - record.id = nade_lineup_id; - record.client_id = nade_lineup_id; + record.id = utility_lineup_id; + record.client_id = utility_lineup_id; } return record; diff --git a/shared/dotnet/FiveStack.Entities/Practice/NadePracticeResult.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityPracticeResult.cs similarity index 81% rename from shared/dotnet/FiveStack.Entities/Practice/NadePracticeResult.cs rename to shared/dotnet/FiveStack.Entities/Practice/UtilityPracticeResult.cs index 700ef3fc..61a73bec 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/NadePracticeResult.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityPracticeResult.cs @@ -1,16 +1,16 @@ namespace FiveStack.Entities.Practice; -// The body of POST /nades/practice-result, exactly as the API defines it. +// 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 NadePracticeResultPayload +public class UtilityPracticeResultPayload { public string? server_id { get; set; } public string? session_id { get; set; } - public string? nade_lineup_id { get; set; } + public string? utility_lineup_id { get; set; } public string? steam_id { get; set; } public float? land_x { get; set; } @@ -19,7 +19,7 @@ public class NadePracticeResultPayload public bool? success { get; set; } - public static NadePracticeResultPayload For( + public static UtilityPracticeResultPayload For( string? serverId, Guid sessionId, string lineupId, @@ -28,11 +28,11 @@ public static NadePracticeResultPayload For( bool? success ) { - return new NadePracticeResultPayload + return new UtilityPracticeResultPayload { server_id = string.IsNullOrEmpty(serverId) ? null : serverId, session_id = sessionId == Guid.Empty ? null : sessionId.ToString(), - nade_lineup_id = lineupId, + utility_lineup_id = lineupId, steam_id = steamId.ToString(), land_x = landing.x, land_y = landing.y, @@ -44,7 +44,7 @@ public static NadePracticeResultPayload For( // 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 NadePracticeResult +public class UtilityPracticeResult { public bool success { get; set; } public float distance { get; set; } diff --git a/shared/dotnet/FiveStack.Entities/Practice/NadeSessionRow.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilitySessionRow.cs similarity index 89% rename from shared/dotnet/FiveStack.Entities/Practice/NadeSessionRow.cs rename to shared/dotnet/FiveStack.Entities/Practice/UtilitySessionRow.cs index c2f3756a..3d8564de 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/NadeSessionRow.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilitySessionRow.cs @@ -1,13 +1,13 @@ namespace FiveStack.Entities.Practice; -// The body of GET /nades/session, exactly as the API returns it. +// 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 NadeSessionRow +public class UtilitySessionRow { public string? id { get; set; } public string? session_id { get; set; } @@ -17,7 +17,7 @@ public class NadeSessionRow public string? map_name { get; set; } public List? steam_ids { get; set; } public List? allowed_steam_ids { get; set; } - public NadePlaybook? playbook { get; set; } + public UtilityPlaybook? playbook { get; set; } public PracticeSessionData ToSession() { diff --git a/shared/dotnet/FiveStack.Entities/Practice/NadeTrajectoryArtifact.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityTrajectoryArtifact.cs similarity index 94% rename from shared/dotnet/FiveStack.Entities/Practice/NadeTrajectoryArtifact.cs rename to shared/dotnet/FiveStack.Entities/Practice/UtilityTrajectoryArtifact.cs index 2ae1cca9..2b31b19e 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/NadeTrajectoryArtifact.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityTrajectoryArtifact.cs @@ -3,26 +3,26 @@ namespace FiveStack.Entities.Practice; -// What GET /nades/{id}/trajectory answers with. +// 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 NadeTrajectoryArtifact +public class UtilityTrajectoryArtifact { public List path { get; set; } = new List(); public SmokeVolume? smoke_volume { get; set; } - public static NadeTrajectoryArtifact Parse(byte[] body) + public static UtilityTrajectoryArtifact Parse(byte[] body) { return Parse(PracticeJson.Text(body)); } - public static NadeTrajectoryArtifact Parse(string body) + public static UtilityTrajectoryArtifact Parse(string body) { - var artifact = new NadeTrajectoryArtifact(); + var artifact = new UtilityTrajectoryArtifact(); using JsonDocument document = JsonDocument.Parse(body); JsonElement root = document.RootElement; diff --git a/shared/dotnet/FiveStack.Enums/eLineupVisibility.cs b/shared/dotnet/FiveStack.Enums/eLineupVisibility.cs index 373c8b82..c9adc85a 100644 --- a/shared/dotnet/FiveStack.Enums/eLineupVisibility.cs +++ b/shared/dotnet/FiveStack.Enums/eLineupVisibility.cs @@ -1,6 +1,6 @@ namespace FiveStack.Enums; -// Mirrors public.e_nade_visibility. +// Mirrors public.e_utility_visibility. public enum eLineupVisibility { Private, diff --git a/shared/dotnet/FiveStack.Enums/eThrowStrength.cs b/shared/dotnet/FiveStack.Enums/eThrowStrength.cs index dd961f72..06b8a766 100644 --- a/shared/dotnet/FiveStack.Enums/eThrowStrength.cs +++ b/shared/dotnet/FiveStack.Enums/eThrowStrength.cs @@ -1,6 +1,6 @@ namespace FiveStack.Enums; -// Mirrors public.e_nade_throw_strengths. CS2 has exactly three release +// Mirrors public.e_utility_throw_strengths. CS2 has exactly three release // strengths: left click, both buttons, right click. public enum eThrowStrength { diff --git a/shared/dotnet/FiveStack.Enums/eThrowTechnique.cs b/shared/dotnet/FiveStack.Enums/eThrowTechnique.cs index e3c70a0f..eab2335a 100644 --- a/shared/dotnet/FiveStack.Enums/eThrowTechnique.cs +++ b/shared/dotnet/FiveStack.Enums/eThrowTechnique.cs @@ -1,6 +1,6 @@ namespace FiveStack.Enums; -// Mirrors public.e_nade_techniques. Movement and stance are one value rather +// 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 diff --git a/shared/dotnet/FiveStack.Utilities/DrillUtility.cs b/shared/dotnet/FiveStack.Utilities/DrillUtility.cs index 2aeed18c..e1b9f94f 100644 --- a/shared/dotnet/FiveStack.Utilities/DrillUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/DrillUtility.cs @@ -219,7 +219,7 @@ public class DrillProgress public float Rate => Attempts == 0 ? 0f : (float)Successes / Attempts; - public static DrillProgress From(NadePracticeResult result) + public static DrillProgress From(UtilityPracticeResult result) { return new DrillProgress { @@ -239,7 +239,7 @@ public class DrillProgressBook private readonly Dictionary> _progress = new Dictionary>(); - public void Record(ulong steamId, string? lineupId, NadePracticeResult? result) + public void Record(ulong steamId, string? lineupId, UtilityPracticeResult? result) { if (result == null || string.IsNullOrEmpty(lineupId)) { diff --git a/shared/dotnet/FiveStack.Utilities/PlaybookUtility.cs b/shared/dotnet/FiveStack.Utilities/PlaybookUtility.cs index c8f14b1e..977f3cb5 100644 --- a/shared/dotnet/FiveStack.Utilities/PlaybookUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PlaybookUtility.cs @@ -16,11 +16,11 @@ public static class PlaybookUtility // lands late is still part of the same run. public const int TailMs = 3000; - public static List Ordered(NadePlaybook? playbook) + public static List Ordered(UtilityPlaybook? playbook) { if (playbook == null) { - return new List(); + return new List(); } return playbook @@ -34,8 +34,8 @@ public static List Ordered(NadePlaybook? playbook) // 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, + public static List Due( + IReadOnlyList steps, int afterMs, int throughMs ) @@ -45,19 +45,19 @@ int throughMs .ToList(); } - public static int DurationMs(IReadOnlyList steps) + public static int DurationMs(IReadOnlyList steps) { return steps.Count == 0 ? 0 : steps.Max(step => step.offset_ms); } - public static bool IsAssigned(NadePlaybookStep step) + 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(NadePlaybookStep step, ulong steamId) + 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/PracticeDrillRun.cs b/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs index cabcd789..6623ce19 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs @@ -131,7 +131,7 @@ public bool Thrown(string utilityType, DateTime now) // 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, NadePracticeResult? result) + public bool Score(string? lineupId, UtilityPracticeResult? result) { if ( _pending == null diff --git a/shared/dotnet/FiveStack.Utilities/PracticeSignalUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeSignalUtility.cs index bbb6eda8..6637cba7 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeSignalUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeSignalUtility.cs @@ -13,7 +13,7 @@ namespace FiveStack.Utilities; // into a coin flip. public static class PracticeSignalUtility { - public const string Prefix = "[nade-practice]"; + 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 diff --git a/shared/dotnet/FiveStack.Utilities/PracticeSolverUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeSolverUtility.cs index cf946822..86a29ad4 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeSolverUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeSolverUtility.cs @@ -340,7 +340,7 @@ string pluginVersion }; } - // nade_solver_solve is driven over RCON, where a positional argument list is + // 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( From 2b259181e9e91995903958aa474d76c897159ac7 Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 19:18:31 -0400 Subject: [PATCH 004/107] tail utility workloads --- apps/nades-sw/scripts/dev.sh | 99 ++ apps/nades-sw/src/Commands/Practice.cs | 888 ++++++++++++++++++ apps/nades-sw/src/Commands/Solver.cs | 473 ++++++++++ apps/nades-sw/src/Events/PracticeConnect.cs | 220 +++++ apps/nades-sw/src/Events/PracticeGrenade.cs | 95 ++ apps/nades-sw/src/Events/PracticePlayer.cs | 30 + apps/nades-sw/src/NadePractice.csproj | 51 + apps/nades-sw/src/NadePracticePlugin.cs | 346 +++++++ apps/nades-sw/src/Services/NadesApiClient.cs | 432 +++++++++ apps/nades-sw/src/Services/NadesConfig.cs | 117 +++ apps/nades-sw/src/Services/PracticeDrill.cs | 235 +++++ apps/nades-sw/src/Services/PracticeLibrary.cs | 138 +++ .../nades-sw/src/Services/PracticePlaybook.cs | 254 +++++ .../nades-sw/src/Services/PracticeRecorder.cs | 436 +++++++++ apps/nades-sw/src/Services/PracticeReplay.cs | 770 +++++++++++++++ apps/nades-sw/src/Services/PracticeScore.cs | 165 ++++ apps/nades-sw/src/Services/PracticeSession.cs | 54 ++ apps/nades-sw/src/Services/PracticeSolver.cs | 779 +++++++++++++++ apps/nades-sw/src/Services/PracticeSystem.cs | 401 ++++++++ apps/nades-sw/test/DrillUtilityTests.cs | 432 +++++++++ apps/nades-sw/test/FiveStack.Tests.csproj | 43 + apps/nades-sw/test/NadeArtifactTests.cs | 338 +++++++ apps/nades-sw/test/NadeWireTests.cs | 502 ++++++++++ apps/nades-sw/test/PlaybookUtilityTests.cs | 233 +++++ .../test/PracticeCalibrationUtilityTests.cs | 349 +++++++ .../test/PracticeConnectUtilityTests.cs | 197 ++++ apps/nades-sw/test/PracticeDrillRunTests.cs | 499 ++++++++++ apps/nades-sw/test/PracticeJsonTests.cs | 62 ++ .../test/PracticeLaunchUtilityTests.cs | 196 ++++ .../test/PracticeLineupUtilityTests.cs | 140 +++ .../test/PracticeSignalUtilityTests.cs | 149 +++ apps/nades-sw/test/PracticeSolverPlanTests.cs | 374 ++++++++ .../test/PracticeSolverUtilityTests.cs | 519 ++++++++++ apps/nades-sw/test/SmokeVolumeUtilityTests.cs | 341 +++++++ apps/nades-sw/test/TrajectoryUtilityTests.cs | 203 ++++ scripts/tail.sh | 9 +- 36 files changed, 10566 insertions(+), 3 deletions(-) create mode 100755 apps/nades-sw/scripts/dev.sh create mode 100644 apps/nades-sw/src/Commands/Practice.cs create mode 100644 apps/nades-sw/src/Commands/Solver.cs create mode 100644 apps/nades-sw/src/Events/PracticeConnect.cs create mode 100644 apps/nades-sw/src/Events/PracticeGrenade.cs create mode 100644 apps/nades-sw/src/Events/PracticePlayer.cs create mode 100644 apps/nades-sw/src/NadePractice.csproj create mode 100644 apps/nades-sw/src/NadePracticePlugin.cs create mode 100644 apps/nades-sw/src/Services/NadesApiClient.cs create mode 100644 apps/nades-sw/src/Services/NadesConfig.cs create mode 100644 apps/nades-sw/src/Services/PracticeDrill.cs create mode 100644 apps/nades-sw/src/Services/PracticeLibrary.cs create mode 100644 apps/nades-sw/src/Services/PracticePlaybook.cs create mode 100644 apps/nades-sw/src/Services/PracticeRecorder.cs create mode 100644 apps/nades-sw/src/Services/PracticeReplay.cs create mode 100644 apps/nades-sw/src/Services/PracticeScore.cs create mode 100644 apps/nades-sw/src/Services/PracticeSession.cs create mode 100644 apps/nades-sw/src/Services/PracticeSolver.cs create mode 100644 apps/nades-sw/src/Services/PracticeSystem.cs create mode 100644 apps/nades-sw/test/DrillUtilityTests.cs create mode 100644 apps/nades-sw/test/FiveStack.Tests.csproj create mode 100644 apps/nades-sw/test/NadeArtifactTests.cs create mode 100644 apps/nades-sw/test/NadeWireTests.cs create mode 100644 apps/nades-sw/test/PlaybookUtilityTests.cs create mode 100644 apps/nades-sw/test/PracticeCalibrationUtilityTests.cs create mode 100644 apps/nades-sw/test/PracticeConnectUtilityTests.cs create mode 100644 apps/nades-sw/test/PracticeDrillRunTests.cs create mode 100644 apps/nades-sw/test/PracticeJsonTests.cs create mode 100644 apps/nades-sw/test/PracticeLaunchUtilityTests.cs create mode 100644 apps/nades-sw/test/PracticeLineupUtilityTests.cs create mode 100644 apps/nades-sw/test/PracticeSignalUtilityTests.cs create mode 100644 apps/nades-sw/test/PracticeSolverPlanTests.cs create mode 100644 apps/nades-sw/test/PracticeSolverUtilityTests.cs create mode 100644 apps/nades-sw/test/SmokeVolumeUtilityTests.cs create mode 100644 apps/nades-sw/test/TrajectoryUtilityTests.cs diff --git a/apps/nades-sw/scripts/dev.sh b/apps/nades-sw/scripts/dev.sh new file mode 100755 index 00000000..ef20f71d --- /dev/null +++ b/apps/nades-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 NadePractice.dll changes. +# +# Chain: dotnet watch build -> apps/nades-sw/src/build/net10.0 -> cp -> /opt/dev -> SwiftlyS2 reload + +log() { echo "[dev.sh $(date '+%H:%M:%S')] $*"; } + +PROJECT="/opt/5stack/apps/nades-sw/src/NadePractice.csproj" +BUILD_OUTPUT="/opt/5stack/apps/nades-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/NadePractice" +if mountpoint -q "$PLUGIN_MOUNT" 2>/dev/null; then + DEV_DIR="$PLUGIN_MOUNT" +else + DEV_DIR="/opt/dev/nades-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/NadePractice.dll" ]; then + log "synced -> $DEV_DIR (NadePractice.dll $(stat -c%s "$DEV_DIR/NadePractice.dll" 2>/dev/null || echo '?') bytes)" + else + log "WARNING: copied but $DEV_DIR/NadePractice.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/nades-sw/src/Commands/Practice.cs b/apps/nades-sw/src/Commands/Practice.cs new file mode 100644 index 00000000..f82ff82e --- /dev/null +++ b/apps/nades-sw/src/Commands/Practice.cs @@ -0,0 +1,888 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Commands; +using SwiftlyS2.Shared.Players; +using static SwiftlyS2.Shared.Helper; + +namespace NadePractice; + +// 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 NadePracticePlugin +{ + [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); + + 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) + { + 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); + + 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, 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); + } + + [Command("drill", registerRaw: false, permission: "")] + public void OnDrill(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + DrillRequest request = DrillUtility.Parse(string.Join(" ", context.Args)); + + if (!request.Valid) + { + Reply(context, $" {ChatColors.Red}usage: .drill [count] [worst|random] / .drill stop"); + 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"); + } + } + + [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; + + _replay.ApplyVisibility(); + + 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)" + ); + } + + // 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. + [Command("ghosts", registerRaw: false, permission: "")] + public void OnGhosts(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + string argument = string.Join(" ", context.Args).Trim(); + + if (!PracticeSignalUtility.TryParseToggle(argument, state.Ghosts, out bool wanted)) + { + Reply(context, $" {ChatColors.Red}usage: .ghosts [on|off]"); + return; + } + + state.Ghosts = wanted; + + if (!wanted) + { + _replay.ClearGhosts(player.SteamID); + } + + _replay.ApplyVisibility(); + + Reply(context, $" {ChatColors.Green}ghost previews {Toggle(state.Ghosts)}"); + } + + [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..."); + + _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 and deliberately unprefixed, like the match plugin's + // get_match: the panel calls it when the roster or the library changes. + [Command("nade_practice_refresh", registerRaw: true, permission: "")] + public void OnRefresh(ICommandContext context) + { + if (context.IsSentByPlayer) + { + return; + } + + RefreshEverything(); + } + + private static readonly string[] HelpLines = new[] + { + $" {ChatColors.Green}nade 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 [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}.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(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++) + { + NadePlaybookStep 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.ShowGhost(player, lineup); + _replay.ThrowGhostProjectile(player, lineup); + WarnIfInexact(player, lineup); + + ulong steamId = player.SteamID; + + _library.EnsureTrajectory( + lineup, + 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) + { + _replay.ShowGhost(still, fetched); + } + + 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/nades-sw/src/Commands/Solver.cs b/apps/nades-sw/src/Commands/Solver.cs new file mode 100644 index 00000000..0ffab001 --- /dev/null +++ b/apps/nades-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 NadePractice; + +// 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 NadePracticePlugin +{ + [Command("nade_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("nade_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: nade_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/nades-sw/src/Events/PracticeConnect.cs b/apps/nades-sw/src/Events/PracticeConnect.cs new file mode 100644 index 00000000..8e878118 --- /dev/null +++ b/apps/nades-sw/src/Events/PracticeConnect.cs @@ -0,0 +1,220 @@ +using System.Runtime.InteropServices; +using System.Text; +using FiveStack.Enums; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared.Memory; + +namespace NadePractice; + +[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 NadePracticePlugin +{ + 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; + } + + _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; + } + + 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/nades-sw/src/Events/PracticeGrenade.cs b/apps/nades-sw/src/Events/PracticeGrenade.cs new file mode 100644 index 00000000..06498cee --- /dev/null +++ b/apps/nades-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 NadePractice; + +// 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 NadePracticePlugin +{ + [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/nades-sw/src/Events/PracticePlayer.cs b/apps/nades-sw/src/Events/PracticePlayer.cs new file mode 100644 index 00000000..69dc175d --- /dev/null +++ b/apps/nades-sw/src/Events/PracticePlayer.cs @@ -0,0 +1,30 @@ +using SwiftlyS2.Shared.GameEventDefinitions; +using SwiftlyS2.Shared.GameEvents; +using SwiftlyS2.Shared.Misc; +using SwiftlyS2.Shared.SchemaDefinitions; + +namespace NadePractice; + +public partial class NadePracticePlugin +{ + // Practising smokes through your own flash is nobody's idea of practice. + [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/nades-sw/src/NadePractice.csproj b/apps/nades-sw/src/NadePractice.csproj new file mode 100644 index 00000000..ebdb65fa --- /dev/null +++ b/apps/nades-sw/src/NadePractice.csproj @@ -0,0 +1,51 @@ + + + true + true + NadePractice + NadePractice + $(MSBuildThisFileDirectory)build/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/nades-sw/src/NadePracticePlugin.cs b/apps/nades-sw/src/NadePracticePlugin.cs new file mode 100644 index 00000000..4f71d6df --- /dev/null +++ b/apps/nades-sw/src/NadePracticePlugin.cs @@ -0,0 +1,346 @@ +using System.Reflection; +using FiveStack.Entities.Practice; +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; + +namespace NadePractice; + +// Standalone plugin, installed through the 5stack game-plugin registry and +// bound to the nade-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 = "NadePractice", + Version = "__RELEASE_VERSION__", + Name = "nade-practice", + Author = "5Stack.gg", + Description = "Records grenade lineups in game and replays saved lineups back to the thrower" +)] +public partial class NadePracticePlugin : BasePlugin +{ + private ILogger _logger = null!; + private IServiceProvider _serviceProvider = null!; + private NadesConfig _config = null!; + private NadesApiClient _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 EventDelegates.OnMapLoad? _mapLoadHandler; + private EventDelegates.OnClientDisconnected? _disconnectHandler; + private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; + + public NadePracticePlugin(ISwiftlyCore core) + : base(core) { } + + public string ModuleVersion => + typeof(NadePracticePlugin).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/NadePractice. + string pluginDirectory = + Path.GetDirectoryName(typeof(NadePracticePlugin).Assembly.Location) ?? ""; + _config.Load(Path.Join(pluginDirectory, "../../configs"), pluginDirectory); + + _replay.IsSolo = _system.IsSolo; + _replay.WantsGhosts = _system.WantsGhosts; + // 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; + _score.Scored += _drill.OnScored; + + WirePlaybook(); + WireDrill(); + + _tickHandler = OnGameTick; + Core.Event.OnTick += _tickHandler; + + _entityCreatedHandler = @event => _recorder.OnProjectileCreated(@event.Entity); + Core.Event.OnEntityCreated += _entityCreatedHandler; + + _mapLoadHandler = @event => OnMapLoad(@event.MapName); + Core.Event.OnMapLoad += _mapLoadHandler; + + _disconnectHandler = @event => ForPlayer(@event.PlayerId, OnPlayerGone); + Core.Event.OnClientDisconnected += _disconnectHandler; + + _authorizeHandler = @event => + ForPlayer(@event.PlayerId, steamId => _library.Refresh(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); + + _library.SetMap(Core.Engine.GlobalVars.MapName.ToString()); + RefreshEverything(); + + _logger.LogInformation( + "nade practice {version} loaded (connected: {connected})", + ModuleVersion, + _config.IsConnected() + ); + } + + public override void Unload() + { + _session.Refreshed -= OnSessionRefreshed; + _recorder.Thrown -= _system.OnThrown; + _recorder.Finalized -= _score.OnFinalized; + _recorder.Thrown -= _drill.OnThrown; + _score.Scored -= _drill.OnScored; + + 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 (_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(); + } + + private void OnSecond() + { + _system.Tick(); + _playbook.Second(); + _drill.Second(); + _solver.RefreshVisibility(); + } + + 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.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); + } + + // 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(); + _library.SetMap(mapName); + + 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 (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player != null && player.IsValid && !player.IsFakeClient) + { + _library.Refresh(player.SteamID); + } + } + } + + private void OnSessionRefreshed(PracticeSessionData session) + { + if (!string.IsNullOrEmpty(session.password)) + { + SetPasswordBuffer(session.password); + } + } +} diff --git a/apps/nades-sw/src/Services/NadesApiClient.cs b/apps/nades-sw/src/Services/NadesApiClient.cs new file mode 100644 index 00000000..2459baf2 --- /dev/null +++ b/apps/nades-sw/src/Services/NadesApiClient.cs @@ -0,0 +1,432 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace NadePractice; + +// 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 NadesApiClient +{ + // 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 NadesConfig _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 NadesApiClient(NadesConfig 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, + $"/nades/library?map={Uri.EscapeDataString(map)}&steam_id={steamId}", + null + ); + + if (body == null) + { + return null; + } + + try + { + List? rows = ReadList(body, "lineups", "nades"); + + 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) + { + byte[]? body = await Send( + HttpMethod.Get, + $"/nades/{Uri.EscapeDataString(id)}/trajectory", + null + ); + + if (body == null) + { + return null; + } + + try + { + return NadeTrajectoryArtifact.Parse(body); + } + catch (Exception error) + { + _logger.LogError(error, "unable to read the trajectory for {id}", id); + return null; + } + } + + public async Task Session() + { + string? body = await SendText(HttpMethod.Get, "/nades/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(NadePracticeResultPayload payload) + { + NadePracticeResult? 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, $"/nades/{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) + { + NadePracticeResultPayload? 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(NadePracticeResultPayload 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( + NadeIngestPayload.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, "/nades/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(NadePracticeResultPayload 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, "/nades/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) + { + _logger.LogWarning( + "{method} {path} returned {status}", + method.Method, + path, + (int)response.StatusCode + ); + return null; + } + + return await response.Content.ReadAsByteArrayAsync(); + } + catch (Exception error) + { + _logger.LogError(error, "{method} {path} failed", method.Method, path); + return null; + } + } + + // Every nade 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.NadesUrl}{path}"; + } + + string separator = path.Contains('?') ? "&" : "?"; + + return $"{_config.NadesUrl}{path}{separator}server_id={Uri.EscapeDataString(_config.ServerId)}"; + } +} diff --git a/apps/nades-sw/src/Services/NadesConfig.cs b/apps/nades-sw/src/Services/NadesConfig.cs new file mode 100644 index 00000000..03b928e1 --- /dev/null +++ b/apps/nades-sw/src/Services/NadesConfig.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace NadePractice; + +// Configuration arrives as addons/counterstrikesharp/configs/nade-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 NadesConfig +{ + public string NadesUrl { get; private set; } = ""; + + // The plugin key alone buys nothing: every nade 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 InfiniteNades { 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 NadesConfig(ILogger logger) + { + _logger = logger; + } + + private class ConfigFile + { + public string? nades_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_nades { 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/nade-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. + NadesUrl = Environment.GetEnvironmentVariable("NADES_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, "nade-practice.json")) + .FirstOrDefault(File.Exists); + + if (path != null) + { + try + { + ConfigFile? parsed = JsonSerializer.Deserialize( + File.ReadAllText(path) + ); + + if (parsed != null) + { + if (string.IsNullOrEmpty(NadesUrl)) + { + NadesUrl = parsed.nades_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; + InfiniteNades = parsed.np_infinite_nades ?? InfiniteNades; + 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); + } + } + + NadesUrl = NadesUrl.TrimEnd('/'); + + if (string.IsNullOrEmpty(NadesUrl) || 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( + "nade practice is not connected to a panel; lineups cannot be saved or loaded" + ); + } + } + + public bool IsConnected() + { + return !string.IsNullOrEmpty(NadesUrl) && !string.IsNullOrEmpty(ServerApiPassword); + } +} diff --git a/apps/nades-sw/src/Services/PracticeDrill.cs b/apps/nades-sw/src/Services/PracticeDrill.cs new file mode 100644 index 00000000..5f12d88f --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeDrill.cs @@ -0,0 +1,235 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; + +namespace NadePractice; + +// 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 NadesConfig _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(NadesConfig 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, NadePracticeResult? 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/nades-sw/src/Services/PracticeLibrary.cs b/apps/nades-sw/src/Services/PracticeLibrary.cs new file mode 100644 index 00000000..9d6e1578 --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeLibrary.cs @@ -0,0 +1,138 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared; + +namespace NadePractice; + +// 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 NadesApiClient _api; + private readonly ILogger _logger; + + private readonly Dictionary> _lineups = new(); + private string _map = ""; + + public PracticeLibrary( + ISwiftlyCore core, + NadesApiClient 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, Action ready) + { + if (lineup.trajectory.Count > 0 || string.IsNullOrEmpty(lineup.id)) + { + ready(lineup); + return; + } + + string id = lineup.id; + + _ = Task.Run(async () => + { + NadeTrajectoryArtifact? artifact = await _api.Trajectory(id); + + _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/nades-sw/src/Services/PracticePlaybook.cs b/apps/nades-sw/src/Services/PracticePlaybook.cs new file mode 100644 index 00000000..59e414ba --- /dev/null +++ b/apps/nades-sw/src/Services/PracticePlaybook.cs @@ -0,0 +1,254 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; + +namespace NadePractice; + +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 NadePlaybook? Loaded => _session.Current?.playbook; + + public IReadOnlyList Steps => PlaybookUtility.Ordered(Loaded); + + public ePlaybookStart Start(string map) + { + if (Running) + { + return ePlaybookStart.AlreadyRunning; + } + + NadePlaybook? 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 (NadePlaybookStep 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(NadePlaybookStep 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(NadePlaybookStep step) + { + string id = step.nade_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/nades-sw/src/Services/PracticeRecorder.cs b/apps/nades-sw/src/Services/PracticeRecorder.cs new file mode 100644 index 00000000..89e66588 --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeRecorder.cs @@ -0,0 +1,436 @@ +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 NadePractice; + +// 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; + } + + 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 _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(); + _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; + CBasePlayerWeapon? active = pawn?.WeaponServices?.ActiveWeapon.Value; + + if (pawn == null || !pawn.IsValid || 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.Value > 0) + { + state.Released = true; + state.Frozen = Snapshot(pawn, grenade); + _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 ThrowSnapshot Snapshot(CCSPlayerPawn pawn, CBaseCSGrenade grenade) + { + 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 + { + feet_position = 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 & (1 << 0)) != 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); + + if (Emitting || utilityType == null || _tracked.Count >= MaxTrackedProjectiles) + { + 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) + { + 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/nades-sw/src/Services/PracticeReplay.cs b/apps/nades-sw/src/Services/PracticeReplay.cs new file mode 100644 index 00000000..2469284f --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeReplay.cs @@ -0,0 +1,770 @@ +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 NadePractice; + +// 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; + + // 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 NadesConfig _config; + private readonly PracticeRecorder _recorder; + 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(); + + // 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, + NadesConfig 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; + + // 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. + public Func WantsGhosts { get; set; } = _ => true; + + public void Load(IPlayer player, LineupRecord lineup) + { + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return; + } + + Vec3 feet = lineup.release.feet_position; + var position = new Vector(feet.x, feet.y, feet.z); + var angles = new QAngle(lineup.release.pitch, lineup.release.yaw, 0); + + player.Teleport(position, angles, new Vector(0, 0, 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, angles, 2); + + GiveUtility(player, lineup.utility_type); + + player.SendCenter(Describe(lineup)); + } + + // Tier 1 preview: the recorded line, drawn as beam segments with a marker + // where it lands. + public void ShowGhost(IPlayer 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; + } + + _ghosts.Add( + new Ghost + { + OwnerSteamId = player.SteamID, + Kind = GhostKind.Line, + ExpiresAt = DateTime.UtcNow.AddSeconds(GhostSeconds), + Beams = beams, + } + ); + + ApplyVisibility(); + } + + // 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) + { + 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. + _ghosts.Add( + new Ghost + { + OwnerSteamId = player.SteamID, + Kind = GhostKind.Bloom, + ExpiresAt = DateTime.MaxValue, + Beams = beams, + } + ); + + ApplyVisibility(); + + 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) + { + 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; + ApplyVisibility(); + + 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) + { + Unblock(smoke.Index); + 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 (!_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(); + + 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); + } + } + + // Swiftly's transmit blocking is a standing per-player list rather than a + // per-frame callback, so it is recomputed whenever the set of beams or of + // solo players changes. + public void ApplyVisibility() + { + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) + { + if (viewer == null || !viewer.IsValid || viewer.IsFakeClient) + { + continue; + } + + bool viewerIsSolo = IsSolo(viewer.SteamID); + bool viewerWantsGhosts = WantsGhosts(viewer.SteamID); + + foreach (Ghost ghost in _ghosts) + { + bool hidden = + !viewerWantsGhosts + || ( + ghost.OwnerSteamId != viewer.SteamID + && (viewerIsSolo || IsSolo(ghost.OwnerSteamId)) + ); + + foreach (CEnvBeam beam in ghost.Beams) + { + if (beam.IsValid) + { + viewer.ShouldBlockTransmitEntity((int)beam.Index, hidden); + } + } + } + + foreach ((ulong owner, CSmokeGrenadeProjectile smoke) in _bloomSmoke) + { + if (!smoke.IsValid) + { + continue; + } + + viewer.ShouldBlockTransmitEntity( + (int)smoke.Index, + !viewerWantsGhosts + || (owner != viewer.SteamID && (viewerIsSolo || IsSolo(owner))) + ); + } + } + } + + 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(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 angles, int frames) + { + if (frames <= 0) + { + return; + } + + _core.Scheduler.NextTick(() => + { + if (!player.IsValid) + { + return; + } + + player.Teleport(null, angles, new Vector(0, 0, 0)); + ReapplyAngles(player, angles, 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; + } + + private CEnvBeam? CreateBeam(Vec3 start, Vec3 end, Color color, float width) + { + 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(); + + 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; + } + + Unblock(beam.Index); + beam.Despawn(); + } + } + + // Entity indices are reused, so a standing block has to be lifted before + // the entity goes away or it will hide whatever lands on that index next. + private void Unblock(uint index) + { + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) + { + if (viewer != null && viewer.IsValid) + { + viewer.ShouldBlockTransmitEntity((int)index, false); + } + } + } + + 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/nades-sw/src/Services/PracticeScore.cs b/apps/nades-sw/src/Services/PracticeScore.cs new file mode 100644 index 00000000..ca0345c9 --- /dev/null +++ b/apps/nades-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 NadePractice; + +// 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 NadesConfig _config; + private readonly NadesApiClient _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, + NadesConfig config, + NadesApiClient 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 = NadePracticeResultPayload.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 () => + { + NadePracticeResult? 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, + NadePracticeResult? 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, + NadePracticeResult? 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/nades-sw/src/Services/PracticeSession.cs b/apps/nades-sw/src/Services/PracticeSession.cs new file mode 100644 index 00000000..4d4f918e --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeSession.cs @@ -0,0 +1,54 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace NadePractice; + +// 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 NadesApiClient _api; + private readonly ILogger _logger; + + private PracticeSessionData? _session; + + public PracticeSession(NadesApiClient 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/nades-sw/src/Services/PracticeSolver.cs b/apps/nades-sw/src/Services/PracticeSolver.cs new file mode 100644 index 00000000..01da5be9 --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeSolver.cs @@ -0,0 +1,779 @@ +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 NadePractice; + +// 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) + { + Block(index, true); + } + + private void Unhide(uint index) + { + Block(index, false); + } + + private void Block(uint index, bool hidden) + { + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) + { + if (viewer != null && viewer.IsValid) + { + viewer.ShouldBlockTransmitEntity((int)index, hidden); + } + } + } + + private CBaseCSGrenadeProjectile? TryProjectileAt(uint index) + { + try + { + return _core.EntitySystem.GetEntityByIndex(index); + } + catch + { + return null; + } + } +} diff --git a/apps/nades-sw/src/Services/PracticeSystem.cs b/apps/nades-sw/src/Services/PracticeSystem.cs new file mode 100644 index 00000000..3ab0c5ad --- /dev/null +++ b/apps/nades-sw/src/Services/PracticeSystem.cs @@ -0,0 +1,401 @@ +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 NadePractice; + +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 ISwiftlyCore _core; + private readonly NadesConfig _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, + NadesConfig 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; + } + + // 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); + _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. + public void OnThrown(ulong steamId, string utilityType) + { + if (!_config.InfiniteNades) + { + 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); + } + } + + 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) + { + player.Teleport( + new Vector( + position.feet_position.x, + position.feet_position.y, + position.feet_position.z + ), + new QAngle(position.pitch, position.yaw, 0), + new Vector(0, 0, 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/nades-sw/test/DrillUtilityTests.cs b/apps/nades-sw/test/DrillUtilityTests.cs new file mode 100644 index 00000000..3db0ce91 --- /dev/null +++ b/apps/nades-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/nades-sw/test/FiveStack.Tests.csproj b/apps/nades-sw/test/FiveStack.Tests.csproj new file mode 100644 index 00000000..79121e9f --- /dev/null +++ b/apps/nades-sw/test/FiveStack.Tests.csproj @@ -0,0 +1,43 @@ + + + false + bin/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/nades-sw/test/NadeArtifactTests.cs b/apps/nades-sw/test/NadeArtifactTests.cs new file mode 100644 index 00000000..4d62ec34 --- /dev/null +++ b/apps/nades-sw/test/NadeArtifactTests.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/nades-sw/test/NadeWireTests.cs b/apps/nades-sw/test/NadeWireTests.cs new file mode 100644 index 00000000..30414d78 --- /dev/null +++ b/apps/nades-sw/test/NadeWireTests.cs @@ -0,0 +1,502 @@ +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("\"utility_type\"")] + [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); + } +} diff --git a/apps/nades-sw/test/PlaybookUtilityTests.cs b/apps/nades-sw/test/PlaybookUtilityTests.cs new file mode 100644 index 00000000..78d94cdd --- /dev/null +++ b/apps/nades-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/nades-sw/test/PracticeCalibrationUtilityTests.cs b/apps/nades-sw/test/PracticeCalibrationUtilityTests.cs new file mode 100644 index 00000000..10aeeae4 --- /dev/null +++ b/apps/nades-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/nades-sw/test/PracticeConnectUtilityTests.cs b/apps/nades-sw/test/PracticeConnectUtilityTests.cs new file mode 100644 index 00000000..aa58969a --- /dev/null +++ b/apps/nades-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/nades-sw/test/PracticeDrillRunTests.cs b/apps/nades-sw/test/PracticeDrillRunTests.cs new file mode 100644 index 00000000..e88e638a --- /dev/null +++ b/apps/nades-sw/test/PracticeDrillRunTests.cs @@ -0,0 +1,499 @@ +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); + } +} diff --git a/apps/nades-sw/test/PracticeJsonTests.cs b/apps/nades-sw/test/PracticeJsonTests.cs new file mode 100644 index 00000000..eaa05d55 --- /dev/null +++ b/apps/nades-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/nades-sw/test/PracticeLaunchUtilityTests.cs b/apps/nades-sw/test/PracticeLaunchUtilityTests.cs new file mode 100644 index 00000000..95736933 --- /dev/null +++ b/apps/nades-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/nades-sw/test/PracticeLineupUtilityTests.cs b/apps/nades-sw/test/PracticeLineupUtilityTests.cs new file mode 100644 index 00000000..f493c9a3 --- /dev/null +++ b/apps/nades-sw/test/PracticeLineupUtilityTests.cs @@ -0,0 +1,140 @@ +using FiveStack.Entities.Practice; +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")); + } +} diff --git a/apps/nades-sw/test/PracticeSignalUtilityTests.cs b/apps/nades-sw/test/PracticeSignalUtilityTests.cs new file mode 100644 index 00000000..cf8c7b39 --- /dev/null +++ b/apps/nades-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/nades-sw/test/PracticeSolverPlanTests.cs b/apps/nades-sw/test/PracticeSolverPlanTests.cs new file mode 100644 index 00000000..30f3ea10 --- /dev/null +++ b/apps/nades-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/nades-sw/test/PracticeSolverUtilityTests.cs b/apps/nades-sw/test/PracticeSolverUtilityTests.cs new file mode 100644 index 00000000..720e1995 --- /dev/null +++ b/apps/nades-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/nades-sw/test/SmokeVolumeUtilityTests.cs b/apps/nades-sw/test/SmokeVolumeUtilityTests.cs new file mode 100644 index 00000000..4755a8ca --- /dev/null +++ b/apps/nades-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/nades-sw/test/TrajectoryUtilityTests.cs b/apps/nades-sw/test/TrajectoryUtilityTests.cs new file mode 100644 index 00000000..3fbe33ab --- /dev/null +++ b/apps/nades-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/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 \ From b70bd7bc2c5011c68ac86669cb1fac67cf2f14d0 Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 20:51:11 -0400 Subject: [PATCH 005/107] drop the pre-rename plugin copy --- apps/nades-sw/scripts/dev.sh | 99 -- apps/nades-sw/src/Commands/Practice.cs | 888 ------------------ apps/nades-sw/src/Commands/Solver.cs | 473 ---------- apps/nades-sw/src/Events/PracticeConnect.cs | 220 ----- apps/nades-sw/src/Events/PracticeGrenade.cs | 95 -- apps/nades-sw/src/Events/PracticePlayer.cs | 30 - apps/nades-sw/src/NadePractice.csproj | 51 - apps/nades-sw/src/NadePracticePlugin.cs | 346 ------- apps/nades-sw/src/Services/NadesApiClient.cs | 432 --------- apps/nades-sw/src/Services/NadesConfig.cs | 117 --- apps/nades-sw/src/Services/PracticeDrill.cs | 235 ----- apps/nades-sw/src/Services/PracticeLibrary.cs | 138 --- .../nades-sw/src/Services/PracticePlaybook.cs | 254 ----- .../nades-sw/src/Services/PracticeRecorder.cs | 436 --------- apps/nades-sw/src/Services/PracticeReplay.cs | 770 --------------- apps/nades-sw/src/Services/PracticeScore.cs | 165 ---- apps/nades-sw/src/Services/PracticeSession.cs | 54 -- apps/nades-sw/src/Services/PracticeSolver.cs | 779 --------------- apps/nades-sw/src/Services/PracticeSystem.cs | 401 -------- apps/nades-sw/test/DrillUtilityTests.cs | 432 --------- apps/nades-sw/test/FiveStack.Tests.csproj | 43 - apps/nades-sw/test/NadeArtifactTests.cs | 338 ------- apps/nades-sw/test/NadeWireTests.cs | 502 ---------- apps/nades-sw/test/PlaybookUtilityTests.cs | 233 ----- .../test/PracticeCalibrationUtilityTests.cs | 349 ------- .../test/PracticeConnectUtilityTests.cs | 197 ---- apps/nades-sw/test/PracticeDrillRunTests.cs | 499 ---------- apps/nades-sw/test/PracticeJsonTests.cs | 62 -- .../test/PracticeLaunchUtilityTests.cs | 196 ---- .../test/PracticeLineupUtilityTests.cs | 140 --- .../test/PracticeSignalUtilityTests.cs | 149 --- apps/nades-sw/test/PracticeSolverPlanTests.cs | 374 -------- .../test/PracticeSolverUtilityTests.cs | 519 ---------- apps/nades-sw/test/SmokeVolumeUtilityTests.cs | 341 ------- apps/nades-sw/test/TrajectoryUtilityTests.cs | 203 ---- 35 files changed, 10560 deletions(-) delete mode 100755 apps/nades-sw/scripts/dev.sh delete mode 100644 apps/nades-sw/src/Commands/Practice.cs delete mode 100644 apps/nades-sw/src/Commands/Solver.cs delete mode 100644 apps/nades-sw/src/Events/PracticeConnect.cs delete mode 100644 apps/nades-sw/src/Events/PracticeGrenade.cs delete mode 100644 apps/nades-sw/src/Events/PracticePlayer.cs delete mode 100644 apps/nades-sw/src/NadePractice.csproj delete mode 100644 apps/nades-sw/src/NadePracticePlugin.cs delete mode 100644 apps/nades-sw/src/Services/NadesApiClient.cs delete mode 100644 apps/nades-sw/src/Services/NadesConfig.cs delete mode 100644 apps/nades-sw/src/Services/PracticeDrill.cs delete mode 100644 apps/nades-sw/src/Services/PracticeLibrary.cs delete mode 100644 apps/nades-sw/src/Services/PracticePlaybook.cs delete mode 100644 apps/nades-sw/src/Services/PracticeRecorder.cs delete mode 100644 apps/nades-sw/src/Services/PracticeReplay.cs delete mode 100644 apps/nades-sw/src/Services/PracticeScore.cs delete mode 100644 apps/nades-sw/src/Services/PracticeSession.cs delete mode 100644 apps/nades-sw/src/Services/PracticeSolver.cs delete mode 100644 apps/nades-sw/src/Services/PracticeSystem.cs delete mode 100644 apps/nades-sw/test/DrillUtilityTests.cs delete mode 100644 apps/nades-sw/test/FiveStack.Tests.csproj delete mode 100644 apps/nades-sw/test/NadeArtifactTests.cs delete mode 100644 apps/nades-sw/test/NadeWireTests.cs delete mode 100644 apps/nades-sw/test/PlaybookUtilityTests.cs delete mode 100644 apps/nades-sw/test/PracticeCalibrationUtilityTests.cs delete mode 100644 apps/nades-sw/test/PracticeConnectUtilityTests.cs delete mode 100644 apps/nades-sw/test/PracticeDrillRunTests.cs delete mode 100644 apps/nades-sw/test/PracticeJsonTests.cs delete mode 100644 apps/nades-sw/test/PracticeLaunchUtilityTests.cs delete mode 100644 apps/nades-sw/test/PracticeLineupUtilityTests.cs delete mode 100644 apps/nades-sw/test/PracticeSignalUtilityTests.cs delete mode 100644 apps/nades-sw/test/PracticeSolverPlanTests.cs delete mode 100644 apps/nades-sw/test/PracticeSolverUtilityTests.cs delete mode 100644 apps/nades-sw/test/SmokeVolumeUtilityTests.cs delete mode 100644 apps/nades-sw/test/TrajectoryUtilityTests.cs diff --git a/apps/nades-sw/scripts/dev.sh b/apps/nades-sw/scripts/dev.sh deleted file mode 100755 index ef20f71d..00000000 --- a/apps/nades-sw/scripts/dev.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/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 NadePractice.dll changes. -# -# Chain: dotnet watch build -> apps/nades-sw/src/build/net10.0 -> cp -> /opt/dev -> SwiftlyS2 reload - -log() { echo "[dev.sh $(date '+%H:%M:%S')] $*"; } - -PROJECT="/opt/5stack/apps/nades-sw/src/NadePractice.csproj" -BUILD_OUTPUT="/opt/5stack/apps/nades-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/NadePractice" -if mountpoint -q "$PLUGIN_MOUNT" 2>/dev/null; then - DEV_DIR="$PLUGIN_MOUNT" -else - DEV_DIR="/opt/dev/nades-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/NadePractice.dll" ]; then - log "synced -> $DEV_DIR (NadePractice.dll $(stat -c%s "$DEV_DIR/NadePractice.dll" 2>/dev/null || echo '?') bytes)" - else - log "WARNING: copied but $DEV_DIR/NadePractice.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/nades-sw/src/Commands/Practice.cs b/apps/nades-sw/src/Commands/Practice.cs deleted file mode 100644 index f82ff82e..00000000 --- a/apps/nades-sw/src/Commands/Practice.cs +++ /dev/null @@ -1,888 +0,0 @@ -using FiveStack.Entities.Practice; -using FiveStack.Enums; -using FiveStack.Utilities; -using SwiftlyS2.Shared; -using SwiftlyS2.Shared.Commands; -using SwiftlyS2.Shared.Players; -using static SwiftlyS2.Shared.Helper; - -namespace NadePractice; - -// 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 NadePracticePlugin -{ - [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); - - 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) - { - 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); - - 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, 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); - } - - [Command("drill", registerRaw: false, permission: "")] - public void OnDrill(ICommandContext context) - { - IPlayer? player = context.Sender; - - if (player == null || !player.IsValid) - { - return; - } - - DrillRequest request = DrillUtility.Parse(string.Join(" ", context.Args)); - - if (!request.Valid) - { - Reply(context, $" {ChatColors.Red}usage: .drill [count] [worst|random] / .drill stop"); - 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"); - } - } - - [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; - - _replay.ApplyVisibility(); - - 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)" - ); - } - - // 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. - [Command("ghosts", registerRaw: false, permission: "")] - public void OnGhosts(ICommandContext context) - { - IPlayer? player = context.Sender; - - if (player == null || !player.IsValid) - { - return; - } - - PracticeState state = _system.StateFor(player.SteamID); - string argument = string.Join(" ", context.Args).Trim(); - - if (!PracticeSignalUtility.TryParseToggle(argument, state.Ghosts, out bool wanted)) - { - Reply(context, $" {ChatColors.Red}usage: .ghosts [on|off]"); - return; - } - - state.Ghosts = wanted; - - if (!wanted) - { - _replay.ClearGhosts(player.SteamID); - } - - _replay.ApplyVisibility(); - - Reply(context, $" {ChatColors.Green}ghost previews {Toggle(state.Ghosts)}"); - } - - [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..."); - - _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 and deliberately unprefixed, like the match plugin's - // get_match: the panel calls it when the roster or the library changes. - [Command("nade_practice_refresh", registerRaw: true, permission: "")] - public void OnRefresh(ICommandContext context) - { - if (context.IsSentByPlayer) - { - return; - } - - RefreshEverything(); - } - - private static readonly string[] HelpLines = new[] - { - $" {ChatColors.Green}nade 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 [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}.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(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++) - { - NadePlaybookStep 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.ShowGhost(player, lineup); - _replay.ThrowGhostProjectile(player, lineup); - WarnIfInexact(player, lineup); - - ulong steamId = player.SteamID; - - _library.EnsureTrajectory( - lineup, - 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) - { - _replay.ShowGhost(still, fetched); - } - - 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/nades-sw/src/Commands/Solver.cs b/apps/nades-sw/src/Commands/Solver.cs deleted file mode 100644 index 0ffab001..00000000 --- a/apps/nades-sw/src/Commands/Solver.cs +++ /dev/null @@ -1,473 +0,0 @@ -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 NadePractice; - -// 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 NadePracticePlugin -{ - [Command("nade_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("nade_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: nade_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/nades-sw/src/Events/PracticeConnect.cs b/apps/nades-sw/src/Events/PracticeConnect.cs deleted file mode 100644 index 8e878118..00000000 --- a/apps/nades-sw/src/Events/PracticeConnect.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System.Runtime.InteropServices; -using System.Text; -using FiveStack.Enums; -using FiveStack.Utilities; -using Microsoft.Extensions.Logging; -using SwiftlyS2.Shared.Memory; - -namespace NadePractice; - -[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 NadePracticePlugin -{ - 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; - } - - _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; - } - - 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/nades-sw/src/Events/PracticeGrenade.cs b/apps/nades-sw/src/Events/PracticeGrenade.cs deleted file mode 100644 index 06498cee..00000000 --- a/apps/nades-sw/src/Events/PracticeGrenade.cs +++ /dev/null @@ -1,95 +0,0 @@ -using FiveStack.Entities.Practice; -using SwiftlyS2.Shared.GameEventDefinitions; -using SwiftlyS2.Shared.GameEvents; -using SwiftlyS2.Shared.Misc; -using SwiftlyS2.Shared.Players; - -namespace NadePractice; - -// 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 NadePracticePlugin -{ - [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/nades-sw/src/Events/PracticePlayer.cs b/apps/nades-sw/src/Events/PracticePlayer.cs deleted file mode 100644 index 69dc175d..00000000 --- a/apps/nades-sw/src/Events/PracticePlayer.cs +++ /dev/null @@ -1,30 +0,0 @@ -using SwiftlyS2.Shared.GameEventDefinitions; -using SwiftlyS2.Shared.GameEvents; -using SwiftlyS2.Shared.Misc; -using SwiftlyS2.Shared.SchemaDefinitions; - -namespace NadePractice; - -public partial class NadePracticePlugin -{ - // Practising smokes through your own flash is nobody's idea of practice. - [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/nades-sw/src/NadePractice.csproj b/apps/nades-sw/src/NadePractice.csproj deleted file mode 100644 index ebdb65fa..00000000 --- a/apps/nades-sw/src/NadePractice.csproj +++ /dev/null @@ -1,51 +0,0 @@ - - - true - true - NadePractice - NadePractice - $(MSBuildThisFileDirectory)build/ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/nades-sw/src/NadePracticePlugin.cs b/apps/nades-sw/src/NadePracticePlugin.cs deleted file mode 100644 index 4f71d6df..00000000 --- a/apps/nades-sw/src/NadePracticePlugin.cs +++ /dev/null @@ -1,346 +0,0 @@ -using System.Reflection; -using FiveStack.Entities.Practice; -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; - -namespace NadePractice; - -// Standalone plugin, installed through the 5stack game-plugin registry and -// bound to the nade-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 = "NadePractice", - Version = "__RELEASE_VERSION__", - Name = "nade-practice", - Author = "5Stack.gg", - Description = "Records grenade lineups in game and replays saved lineups back to the thrower" -)] -public partial class NadePracticePlugin : BasePlugin -{ - private ILogger _logger = null!; - private IServiceProvider _serviceProvider = null!; - private NadesConfig _config = null!; - private NadesApiClient _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 EventDelegates.OnMapLoad? _mapLoadHandler; - private EventDelegates.OnClientDisconnected? _disconnectHandler; - private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; - - public NadePracticePlugin(ISwiftlyCore core) - : base(core) { } - - public string ModuleVersion => - typeof(NadePracticePlugin).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/NadePractice. - string pluginDirectory = - Path.GetDirectoryName(typeof(NadePracticePlugin).Assembly.Location) ?? ""; - _config.Load(Path.Join(pluginDirectory, "../../configs"), pluginDirectory); - - _replay.IsSolo = _system.IsSolo; - _replay.WantsGhosts = _system.WantsGhosts; - // 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; - _score.Scored += _drill.OnScored; - - WirePlaybook(); - WireDrill(); - - _tickHandler = OnGameTick; - Core.Event.OnTick += _tickHandler; - - _entityCreatedHandler = @event => _recorder.OnProjectileCreated(@event.Entity); - Core.Event.OnEntityCreated += _entityCreatedHandler; - - _mapLoadHandler = @event => OnMapLoad(@event.MapName); - Core.Event.OnMapLoad += _mapLoadHandler; - - _disconnectHandler = @event => ForPlayer(@event.PlayerId, OnPlayerGone); - Core.Event.OnClientDisconnected += _disconnectHandler; - - _authorizeHandler = @event => - ForPlayer(@event.PlayerId, steamId => _library.Refresh(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); - - _library.SetMap(Core.Engine.GlobalVars.MapName.ToString()); - RefreshEverything(); - - _logger.LogInformation( - "nade practice {version} loaded (connected: {connected})", - ModuleVersion, - _config.IsConnected() - ); - } - - public override void Unload() - { - _session.Refreshed -= OnSessionRefreshed; - _recorder.Thrown -= _system.OnThrown; - _recorder.Finalized -= _score.OnFinalized; - _recorder.Thrown -= _drill.OnThrown; - _score.Scored -= _drill.OnScored; - - 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 (_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(); - } - - private void OnSecond() - { - _system.Tick(); - _playbook.Second(); - _drill.Second(); - _solver.RefreshVisibility(); - } - - 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.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); - } - - // 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(); - _library.SetMap(mapName); - - 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 (IPlayer player in Core.PlayerManager.GetAllPlayers()) - { - if (player != null && player.IsValid && !player.IsFakeClient) - { - _library.Refresh(player.SteamID); - } - } - } - - private void OnSessionRefreshed(PracticeSessionData session) - { - if (!string.IsNullOrEmpty(session.password)) - { - SetPasswordBuffer(session.password); - } - } -} diff --git a/apps/nades-sw/src/Services/NadesApiClient.cs b/apps/nades-sw/src/Services/NadesApiClient.cs deleted file mode 100644 index 2459baf2..00000000 --- a/apps/nades-sw/src/Services/NadesApiClient.cs +++ /dev/null @@ -1,432 +0,0 @@ -using System.Net.Http; -using System.Text; -using System.Text.Json; -using FiveStack.Entities.Practice; -using FiveStack.Utilities; -using Microsoft.Extensions.Logging; - -namespace NadePractice; - -// 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 NadesApiClient -{ - // 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 NadesConfig _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 NadesApiClient(NadesConfig 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, - $"/nades/library?map={Uri.EscapeDataString(map)}&steam_id={steamId}", - null - ); - - if (body == null) - { - return null; - } - - try - { - List? rows = ReadList(body, "lineups", "nades"); - - 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) - { - byte[]? body = await Send( - HttpMethod.Get, - $"/nades/{Uri.EscapeDataString(id)}/trajectory", - null - ); - - if (body == null) - { - return null; - } - - try - { - return NadeTrajectoryArtifact.Parse(body); - } - catch (Exception error) - { - _logger.LogError(error, "unable to read the trajectory for {id}", id); - return null; - } - } - - public async Task Session() - { - string? body = await SendText(HttpMethod.Get, "/nades/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(NadePracticeResultPayload payload) - { - NadePracticeResult? 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, $"/nades/{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) - { - NadePracticeResultPayload? 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(NadePracticeResultPayload 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( - NadeIngestPayload.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, "/nades/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(NadePracticeResultPayload 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, "/nades/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) - { - _logger.LogWarning( - "{method} {path} returned {status}", - method.Method, - path, - (int)response.StatusCode - ); - return null; - } - - return await response.Content.ReadAsByteArrayAsync(); - } - catch (Exception error) - { - _logger.LogError(error, "{method} {path} failed", method.Method, path); - return null; - } - } - - // Every nade 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.NadesUrl}{path}"; - } - - string separator = path.Contains('?') ? "&" : "?"; - - return $"{_config.NadesUrl}{path}{separator}server_id={Uri.EscapeDataString(_config.ServerId)}"; - } -} diff --git a/apps/nades-sw/src/Services/NadesConfig.cs b/apps/nades-sw/src/Services/NadesConfig.cs deleted file mode 100644 index 03b928e1..00000000 --- a/apps/nades-sw/src/Services/NadesConfig.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Logging; - -namespace NadePractice; - -// Configuration arrives as addons/counterstrikesharp/configs/nade-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 NadesConfig -{ - public string NadesUrl { get; private set; } = ""; - - // The plugin key alone buys nothing: every nade 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 InfiniteNades { 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 NadesConfig(ILogger logger) - { - _logger = logger; - } - - private class ConfigFile - { - public string? nades_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_nades { 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/nade-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. - NadesUrl = Environment.GetEnvironmentVariable("NADES_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, "nade-practice.json")) - .FirstOrDefault(File.Exists); - - if (path != null) - { - try - { - ConfigFile? parsed = JsonSerializer.Deserialize( - File.ReadAllText(path) - ); - - if (parsed != null) - { - if (string.IsNullOrEmpty(NadesUrl)) - { - NadesUrl = parsed.nades_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; - InfiniteNades = parsed.np_infinite_nades ?? InfiniteNades; - 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); - } - } - - NadesUrl = NadesUrl.TrimEnd('/'); - - if (string.IsNullOrEmpty(NadesUrl) || 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( - "nade practice is not connected to a panel; lineups cannot be saved or loaded" - ); - } - } - - public bool IsConnected() - { - return !string.IsNullOrEmpty(NadesUrl) && !string.IsNullOrEmpty(ServerApiPassword); - } -} diff --git a/apps/nades-sw/src/Services/PracticeDrill.cs b/apps/nades-sw/src/Services/PracticeDrill.cs deleted file mode 100644 index 5f12d88f..00000000 --- a/apps/nades-sw/src/Services/PracticeDrill.cs +++ /dev/null @@ -1,235 +0,0 @@ -using FiveStack.Entities.Practice; -using FiveStack.Enums; -using FiveStack.Utilities; - -namespace NadePractice; - -// 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 NadesConfig _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(NadesConfig 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, NadePracticeResult? 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/nades-sw/src/Services/PracticeLibrary.cs b/apps/nades-sw/src/Services/PracticeLibrary.cs deleted file mode 100644 index 9d6e1578..00000000 --- a/apps/nades-sw/src/Services/PracticeLibrary.cs +++ /dev/null @@ -1,138 +0,0 @@ -using FiveStack.Entities.Practice; -using FiveStack.Utilities; -using Microsoft.Extensions.Logging; -using SwiftlyS2.Shared; - -namespace NadePractice; - -// 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 NadesApiClient _api; - private readonly ILogger _logger; - - private readonly Dictionary> _lineups = new(); - private string _map = ""; - - public PracticeLibrary( - ISwiftlyCore core, - NadesApiClient 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, Action ready) - { - if (lineup.trajectory.Count > 0 || string.IsNullOrEmpty(lineup.id)) - { - ready(lineup); - return; - } - - string id = lineup.id; - - _ = Task.Run(async () => - { - NadeTrajectoryArtifact? artifact = await _api.Trajectory(id); - - _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/nades-sw/src/Services/PracticePlaybook.cs b/apps/nades-sw/src/Services/PracticePlaybook.cs deleted file mode 100644 index 59e414ba..00000000 --- a/apps/nades-sw/src/Services/PracticePlaybook.cs +++ /dev/null @@ -1,254 +0,0 @@ -using FiveStack.Entities.Practice; -using FiveStack.Utilities; - -namespace NadePractice; - -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 NadePlaybook? Loaded => _session.Current?.playbook; - - public IReadOnlyList Steps => PlaybookUtility.Ordered(Loaded); - - public ePlaybookStart Start(string map) - { - if (Running) - { - return ePlaybookStart.AlreadyRunning; - } - - NadePlaybook? 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 (NadePlaybookStep 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(NadePlaybookStep 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(NadePlaybookStep step) - { - string id = step.nade_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/nades-sw/src/Services/PracticeRecorder.cs b/apps/nades-sw/src/Services/PracticeRecorder.cs deleted file mode 100644 index 89e66588..00000000 --- a/apps/nades-sw/src/Services/PracticeRecorder.cs +++ /dev/null @@ -1,436 +0,0 @@ -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 NadePractice; - -// 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; - } - - 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 _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(); - _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; - CBasePlayerWeapon? active = pawn?.WeaponServices?.ActiveWeapon.Value; - - if (pawn == null || !pawn.IsValid || 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.Value > 0) - { - state.Released = true; - state.Frozen = Snapshot(pawn, grenade); - _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 ThrowSnapshot Snapshot(CCSPlayerPawn pawn, CBaseCSGrenade grenade) - { - 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 - { - feet_position = 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 & (1 << 0)) != 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); - - if (Emitting || utilityType == null || _tracked.Count >= MaxTrackedProjectiles) - { - 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) - { - 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/nades-sw/src/Services/PracticeReplay.cs b/apps/nades-sw/src/Services/PracticeReplay.cs deleted file mode 100644 index 2469284f..00000000 --- a/apps/nades-sw/src/Services/PracticeReplay.cs +++ /dev/null @@ -1,770 +0,0 @@ -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 NadePractice; - -// 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; - - // 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 NadesConfig _config; - private readonly PracticeRecorder _recorder; - 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(); - - // 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, - NadesConfig 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; - - // 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. - public Func WantsGhosts { get; set; } = _ => true; - - public void Load(IPlayer player, LineupRecord lineup) - { - CCSPlayerPawn? pawn = player.PlayerPawn; - - if (pawn == null || !pawn.IsValid) - { - return; - } - - Vec3 feet = lineup.release.feet_position; - var position = new Vector(feet.x, feet.y, feet.z); - var angles = new QAngle(lineup.release.pitch, lineup.release.yaw, 0); - - player.Teleport(position, angles, new Vector(0, 0, 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, angles, 2); - - GiveUtility(player, lineup.utility_type); - - player.SendCenter(Describe(lineup)); - } - - // Tier 1 preview: the recorded line, drawn as beam segments with a marker - // where it lands. - public void ShowGhost(IPlayer 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; - } - - _ghosts.Add( - new Ghost - { - OwnerSteamId = player.SteamID, - Kind = GhostKind.Line, - ExpiresAt = DateTime.UtcNow.AddSeconds(GhostSeconds), - Beams = beams, - } - ); - - ApplyVisibility(); - } - - // 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) - { - 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. - _ghosts.Add( - new Ghost - { - OwnerSteamId = player.SteamID, - Kind = GhostKind.Bloom, - ExpiresAt = DateTime.MaxValue, - Beams = beams, - } - ); - - ApplyVisibility(); - - 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) - { - 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; - ApplyVisibility(); - - 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) - { - Unblock(smoke.Index); - 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 (!_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(); - - 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); - } - } - - // Swiftly's transmit blocking is a standing per-player list rather than a - // per-frame callback, so it is recomputed whenever the set of beams or of - // solo players changes. - public void ApplyVisibility() - { - foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) - { - if (viewer == null || !viewer.IsValid || viewer.IsFakeClient) - { - continue; - } - - bool viewerIsSolo = IsSolo(viewer.SteamID); - bool viewerWantsGhosts = WantsGhosts(viewer.SteamID); - - foreach (Ghost ghost in _ghosts) - { - bool hidden = - !viewerWantsGhosts - || ( - ghost.OwnerSteamId != viewer.SteamID - && (viewerIsSolo || IsSolo(ghost.OwnerSteamId)) - ); - - foreach (CEnvBeam beam in ghost.Beams) - { - if (beam.IsValid) - { - viewer.ShouldBlockTransmitEntity((int)beam.Index, hidden); - } - } - } - - foreach ((ulong owner, CSmokeGrenadeProjectile smoke) in _bloomSmoke) - { - if (!smoke.IsValid) - { - continue; - } - - viewer.ShouldBlockTransmitEntity( - (int)smoke.Index, - !viewerWantsGhosts - || (owner != viewer.SteamID && (viewerIsSolo || IsSolo(owner))) - ); - } - } - } - - 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(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 angles, int frames) - { - if (frames <= 0) - { - return; - } - - _core.Scheduler.NextTick(() => - { - if (!player.IsValid) - { - return; - } - - player.Teleport(null, angles, new Vector(0, 0, 0)); - ReapplyAngles(player, angles, 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; - } - - private CEnvBeam? CreateBeam(Vec3 start, Vec3 end, Color color, float width) - { - 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(); - - 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; - } - - Unblock(beam.Index); - beam.Despawn(); - } - } - - // Entity indices are reused, so a standing block has to be lifted before - // the entity goes away or it will hide whatever lands on that index next. - private void Unblock(uint index) - { - foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) - { - if (viewer != null && viewer.IsValid) - { - viewer.ShouldBlockTransmitEntity((int)index, false); - } - } - } - - 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/nades-sw/src/Services/PracticeScore.cs b/apps/nades-sw/src/Services/PracticeScore.cs deleted file mode 100644 index ca0345c9..00000000 --- a/apps/nades-sw/src/Services/PracticeScore.cs +++ /dev/null @@ -1,165 +0,0 @@ -using FiveStack.Entities.Practice; -using SwiftlyS2.Shared; -using SwiftlyS2.Shared.Players; -using static SwiftlyS2.Shared.Helper; - -namespace NadePractice; - -// 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 NadesConfig _config; - private readonly NadesApiClient _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, - NadesConfig config, - NadesApiClient 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 = NadePracticeResultPayload.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 () => - { - NadePracticeResult? 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, - NadePracticeResult? 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, - NadePracticeResult? 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/nades-sw/src/Services/PracticeSession.cs b/apps/nades-sw/src/Services/PracticeSession.cs deleted file mode 100644 index 4d4f918e..00000000 --- a/apps/nades-sw/src/Services/PracticeSession.cs +++ /dev/null @@ -1,54 +0,0 @@ -using FiveStack.Entities.Practice; -using FiveStack.Utilities; -using Microsoft.Extensions.Logging; - -namespace NadePractice; - -// 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 NadesApiClient _api; - private readonly ILogger _logger; - - private PracticeSessionData? _session; - - public PracticeSession(NadesApiClient 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/nades-sw/src/Services/PracticeSolver.cs b/apps/nades-sw/src/Services/PracticeSolver.cs deleted file mode 100644 index 01da5be9..00000000 --- a/apps/nades-sw/src/Services/PracticeSolver.cs +++ /dev/null @@ -1,779 +0,0 @@ -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 NadePractice; - -// 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) - { - Block(index, true); - } - - private void Unhide(uint index) - { - Block(index, false); - } - - private void Block(uint index, bool hidden) - { - foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) - { - if (viewer != null && viewer.IsValid) - { - viewer.ShouldBlockTransmitEntity((int)index, hidden); - } - } - } - - private CBaseCSGrenadeProjectile? TryProjectileAt(uint index) - { - try - { - return _core.EntitySystem.GetEntityByIndex(index); - } - catch - { - return null; - } - } -} diff --git a/apps/nades-sw/src/Services/PracticeSystem.cs b/apps/nades-sw/src/Services/PracticeSystem.cs deleted file mode 100644 index 3ab0c5ad..00000000 --- a/apps/nades-sw/src/Services/PracticeSystem.cs +++ /dev/null @@ -1,401 +0,0 @@ -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 NadePractice; - -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 ISwiftlyCore _core; - private readonly NadesConfig _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, - NadesConfig 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; - } - - // 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); - _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. - public void OnThrown(ulong steamId, string utilityType) - { - if (!_config.InfiniteNades) - { - 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); - } - } - - 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) - { - player.Teleport( - new Vector( - position.feet_position.x, - position.feet_position.y, - position.feet_position.z - ), - new QAngle(position.pitch, position.yaw, 0), - new Vector(0, 0, 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/nades-sw/test/DrillUtilityTests.cs b/apps/nades-sw/test/DrillUtilityTests.cs deleted file mode 100644 index 3db0ce91..00000000 --- a/apps/nades-sw/test/DrillUtilityTests.cs +++ /dev/null @@ -1,432 +0,0 @@ -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/nades-sw/test/FiveStack.Tests.csproj b/apps/nades-sw/test/FiveStack.Tests.csproj deleted file mode 100644 index 79121e9f..00000000 --- a/apps/nades-sw/test/FiveStack.Tests.csproj +++ /dev/null @@ -1,43 +0,0 @@ - - - false - bin/ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/nades-sw/test/NadeArtifactTests.cs b/apps/nades-sw/test/NadeArtifactTests.cs deleted file mode 100644 index 4d62ec34..00000000 --- a/apps/nades-sw/test/NadeArtifactTests.cs +++ /dev/null @@ -1,338 +0,0 @@ -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/nades-sw/test/NadeWireTests.cs b/apps/nades-sw/test/NadeWireTests.cs deleted file mode 100644 index 30414d78..00000000 --- a/apps/nades-sw/test/NadeWireTests.cs +++ /dev/null @@ -1,502 +0,0 @@ -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("\"utility_type\"")] - [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); - } -} diff --git a/apps/nades-sw/test/PlaybookUtilityTests.cs b/apps/nades-sw/test/PlaybookUtilityTests.cs deleted file mode 100644 index 78d94cdd..00000000 --- a/apps/nades-sw/test/PlaybookUtilityTests.cs +++ /dev/null @@ -1,233 +0,0 @@ -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/nades-sw/test/PracticeCalibrationUtilityTests.cs b/apps/nades-sw/test/PracticeCalibrationUtilityTests.cs deleted file mode 100644 index 10aeeae4..00000000 --- a/apps/nades-sw/test/PracticeCalibrationUtilityTests.cs +++ /dev/null @@ -1,349 +0,0 @@ -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/nades-sw/test/PracticeConnectUtilityTests.cs b/apps/nades-sw/test/PracticeConnectUtilityTests.cs deleted file mode 100644 index aa58969a..00000000 --- a/apps/nades-sw/test/PracticeConnectUtilityTests.cs +++ /dev/null @@ -1,197 +0,0 @@ -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/nades-sw/test/PracticeDrillRunTests.cs b/apps/nades-sw/test/PracticeDrillRunTests.cs deleted file mode 100644 index e88e638a..00000000 --- a/apps/nades-sw/test/PracticeDrillRunTests.cs +++ /dev/null @@ -1,499 +0,0 @@ -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); - } -} diff --git a/apps/nades-sw/test/PracticeJsonTests.cs b/apps/nades-sw/test/PracticeJsonTests.cs deleted file mode 100644 index eaa05d55..00000000 --- a/apps/nades-sw/test/PracticeJsonTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -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/nades-sw/test/PracticeLaunchUtilityTests.cs b/apps/nades-sw/test/PracticeLaunchUtilityTests.cs deleted file mode 100644 index 95736933..00000000 --- a/apps/nades-sw/test/PracticeLaunchUtilityTests.cs +++ /dev/null @@ -1,196 +0,0 @@ -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/nades-sw/test/PracticeLineupUtilityTests.cs b/apps/nades-sw/test/PracticeLineupUtilityTests.cs deleted file mode 100644 index f493c9a3..00000000 --- a/apps/nades-sw/test/PracticeLineupUtilityTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using FiveStack.Entities.Practice; -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")); - } -} diff --git a/apps/nades-sw/test/PracticeSignalUtilityTests.cs b/apps/nades-sw/test/PracticeSignalUtilityTests.cs deleted file mode 100644 index cf8c7b39..00000000 --- a/apps/nades-sw/test/PracticeSignalUtilityTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -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/nades-sw/test/PracticeSolverPlanTests.cs b/apps/nades-sw/test/PracticeSolverPlanTests.cs deleted file mode 100644 index 30f3ea10..00000000 --- a/apps/nades-sw/test/PracticeSolverPlanTests.cs +++ /dev/null @@ -1,374 +0,0 @@ -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/nades-sw/test/PracticeSolverUtilityTests.cs b/apps/nades-sw/test/PracticeSolverUtilityTests.cs deleted file mode 100644 index 720e1995..00000000 --- a/apps/nades-sw/test/PracticeSolverUtilityTests.cs +++ /dev/null @@ -1,519 +0,0 @@ -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/nades-sw/test/SmokeVolumeUtilityTests.cs b/apps/nades-sw/test/SmokeVolumeUtilityTests.cs deleted file mode 100644 index 4755a8ca..00000000 --- a/apps/nades-sw/test/SmokeVolumeUtilityTests.cs +++ /dev/null @@ -1,341 +0,0 @@ -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/nades-sw/test/TrajectoryUtilityTests.cs b/apps/nades-sw/test/TrajectoryUtilityTests.cs deleted file mode 100644 index 3fbe33ab..00000000 --- a/apps/nades-sw/test/TrajectoryUtilityTests.cs +++ /dev/null @@ -1,203 +0,0 @@ -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())); - } -} From b5c8e138d55bec73f371ee0f7faa49d81e1cab1d Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 21:02:13 -0400 Subject: [PATCH 006/107] set sv_password from the practice session; load at cold boot; log connect decisions --- .../utility-css/src/Events/PracticeConnect.cs | 13 ++++ apps/utility-css/src/UtilityPracticePlugin.cs | 41 +++++++++++-- apps/utility-css/test/UtilityWireTests.cs | 1 - apps/utility-sw/src/Events/PracticeConnect.cs | 15 +++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 59 +++++++++++++++++-- apps/utility-sw/test/UtilityWireTests.cs | 1 - 6 files changed, 120 insertions(+), 10 deletions(-) diff --git a/apps/utility-css/src/Events/PracticeConnect.cs b/apps/utility-css/src/Events/PracticeConnect.cs index 0b6df1e6..87b810b9 100644 --- a/apps/utility-css/src/Events/PracticeConnect.cs +++ b/apps/utility-css/src/Events/PracticeConnect.cs @@ -5,6 +5,7 @@ using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions; using FiveStack.Enums; using FiveStack.Utilities; +using Microsoft.Extensions.Logging; namespace UtilityPractice; @@ -65,6 +66,18 @@ private HookResult ConnectClientHook(DynamicHook hook) 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: diff --git a/apps/utility-css/src/UtilityPracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs index 80d1485b..13a1b6ff 100644 --- a/apps/utility-css/src/UtilityPracticePlugin.cs +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -1,6 +1,7 @@ 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; @@ -93,8 +94,13 @@ public override void Load(bool hotReload) _secondTimer = AddTimer(1f, OnSecond, TimerFlags.REPEAT); _refillTimer = AddTimer(0.1f, OnFastTick, TimerFlags.REPEAT); - _library.SetMap(Server.MapName); - RefreshEverything(); + // 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); + RefreshEverything(); + } _logger.LogInformation( "utility practice {version} loaded (connected: {connected})", @@ -231,9 +237,36 @@ private void RefreshEverything() private void OnSessionRefreshed(PracticeSessionData session) { - if (!string.IsNullOrEmpty(session.password)) + 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) { - SetPasswordBuffer(session.password); + _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/test/UtilityWireTests.cs b/apps/utility-css/test/UtilityWireTests.cs index 30414d78..0f5b0ebc 100644 --- a/apps/utility-css/test/UtilityWireTests.cs +++ b/apps/utility-css/test/UtilityWireTests.cs @@ -144,7 +144,6 @@ public void ThePathIsSentAsObjectsNotPackedArrays() [InlineData("\"plugin_runtime\"")] [InlineData("\"plugin_version\"")] [InlineData("\"workshop_map_id\"")] - [InlineData("\"utility_type\"")] [InlineData("\"release\"")] [InlineData("\"trajectory\"")] [InlineData("\"flight_time\":")] diff --git a/apps/utility-sw/src/Events/PracticeConnect.cs b/apps/utility-sw/src/Events/PracticeConnect.cs index 7d30c158..aa61f10e 100644 --- a/apps/utility-sw/src/Events/PracticeConnect.cs +++ b/apps/utility-sw/src/Events/PracticeConnect.cs @@ -81,6 +81,8 @@ private void InitializeConnectClientHook() return; } + _logger.LogInformation("ConnectClient hook installed"); + _connectClientHookId = _connectClientFunc.AddHook( (next) => { @@ -119,6 +121,19 @@ bool param9 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 diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index b16f2b4e..91d7c16d 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -126,8 +126,15 @@ public override void Load(bool hotReload) _secondTimer = Core.Scheduler.RepeatBySeconds(1, OnSecond); _refillTimer = Core.Scheduler.RepeatBySeconds(0.1f, OnFastTick); - _library.SetMap(Core.Engine.GlobalVars.MapName.ToString()); - RefreshEverything(); + // 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. + if (hotReload) + { + _library.SetMap(Core.Engine.GlobalVars.MapName.ToString()); + RefreshEverything(); + } _logger.LogInformation( "utility practice {version} loaded (connected: {connected})", @@ -338,9 +345,53 @@ private void RefreshEverything() private void OnSessionRefreshed(PracticeSessionData session) { - if (!string.IsNullOrEmpty(session.password)) + 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 + ); + } + + 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) { - SetPasswordBuffer(session.password); + _logger.LogWarning(error, "failed setting convar {Name}", name); + return false; } } } diff --git a/apps/utility-sw/test/UtilityWireTests.cs b/apps/utility-sw/test/UtilityWireTests.cs index 30414d78..0f5b0ebc 100644 --- a/apps/utility-sw/test/UtilityWireTests.cs +++ b/apps/utility-sw/test/UtilityWireTests.cs @@ -144,7 +144,6 @@ public void ThePathIsSentAsObjectsNotPackedArrays() [InlineData("\"plugin_runtime\"")] [InlineData("\"plugin_version\"")] [InlineData("\"workshop_map_id\"")] - [InlineData("\"utility_type\"")] [InlineData("\"release\"")] [InlineData("\"trajectory\"")] [InlineData("\"flight_time\":")] From 277dab7792bdaf2e927dcbee2b49d97c4d235adc Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 21:41:31 -0400 Subject: [PATCH 007/107] put practice servers into a practice game state --- apps/counterstrikesharp/scripts/setup.sh | 5 ++- apps/swiftly/scripts/setup.sh | 5 ++- apps/utility-css/src/UtilityPracticePlugin.cs | 39 ++++++++++++++++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 41 +++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/apps/counterstrikesharp/scripts/setup.sh b/apps/counterstrikesharp/scripts/setup.sh index 5149fa09..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 diff --git a/apps/swiftly/scripts/setup.sh b/apps/swiftly/scripts/setup.sh index 20f9c7f3..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 diff --git a/apps/utility-css/src/UtilityPracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs index 13a1b6ff..e8d20878 100644 --- a/apps/utility-css/src/UtilityPracticePlugin.cs +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -99,6 +99,7 @@ public override void Load(bool hotReload) if (hotReload) { _library.SetMap(Server.MapName); + ApplyPracticeCfg(); RefreshEverything(); } @@ -213,6 +214,8 @@ private void OnMapStart(string mapName) _system.Reset(); _library.SetMap(mapName); + ApplyPracticeCfg(); + RefreshEverything(); } @@ -235,6 +238,42 @@ private void RefreshEverything() } } + // 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 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_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", + "sv_infinite_ammo 1", + "ammo_grenade_limit_total 5", + "sv_full_alltalk 1", + "tv_enable 0", + }; + + private void ApplyPracticeCfg() + { + Server.NextFrame(() => Server.ExecuteCommand(string.Join(";", PracticeCfg))); + } + private void OnSessionRefreshed(PracticeSessionData session) { if (string.IsNullOrEmpty(session.password)) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 91d7c16d..fb38bce2 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -133,6 +133,7 @@ public override void Load(bool hotReload) if (hotReload) { _library.SetMap(Core.Engine.GlobalVars.MapName.ToString()); + ApplyPracticeCfg(); RefreshEverything(); } @@ -321,6 +322,8 @@ private void OnMapLoad(string mapName) _solver.Reset(); _library.SetMap(mapName); + ApplyPracticeCfg(); + RefreshEverything(); } @@ -374,6 +377,44 @@ private void OnSessionRefreshed(PracticeSessionData session) ); } + // 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 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_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", + "sv_infinite_ammo 1", + "ammo_grenade_limit_total 5", + "sv_full_alltalk 1", + "tv_enable 0", + }; + + private void ApplyPracticeCfg() + { + Core.Scheduler.NextTick( + () => Core.Engine.ExecuteCommand(string.Join(";", PracticeCfg)) + ); + } + private bool TrySetConVar(string name, string value) { try From 067ba916cc7e278a7d41f78c3d5e428b39e1438c Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 21:46:20 -0400 Subject: [PATCH 008/107] record throws reliably; survive a map change --- .../src/Services/PracticeRecorder.cs | 16 +++++++- apps/utility-css/src/UtilityPracticePlugin.cs | 38 +++++++++++++++++- .../src/Services/PracticeRecorder.cs | 25 +++++++++++- apps/utility-sw/src/UtilityPracticePlugin.cs | 40 +++++++++++++++++-- 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/apps/utility-css/src/Services/PracticeRecorder.cs b/apps/utility-css/src/Services/PracticeRecorder.cs index 62a003bd..2cbd26b0 100644 --- a/apps/utility-css/src/Services/PracticeRecorder.cs +++ b/apps/utility-css/src/Services/PracticeRecorder.cs @@ -217,11 +217,25 @@ public void OnProjectileCreated(CEntityInstance entity) string designer = entity.DesignerName ?? ""; string? utilityType = PracticeLineupUtility.UtilityTypeForProjectile(designer); - if (utilityType == null || _tracked.Count >= MaxTrackedProjectiles) + // 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 { diff --git a/apps/utility-css/src/UtilityPracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs index e8d20878..d36039eb 100644 --- a/apps/utility-css/src/UtilityPracticePlugin.cs +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -81,7 +81,18 @@ public override void Load(bool hotReload) WireDrill(); RegisterListener(_recorder.OnTick); - RegisterListener(_recorder.OnProjectileCreated); + // 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); @@ -242,6 +253,8 @@ private void RefreshEverything() // 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 float CfgReapplySeconds = 3f; + private static readonly string[] PracticeCfg = new[] { "sv_cheats 1", @@ -271,7 +284,28 @@ private void RefreshEverything() private void ApplyPracticeCfg() { - Server.NextFrame(() => Server.ExecuteCommand(string.Join(";", PracticeCfg))); + // 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) diff --git a/apps/utility-sw/src/Services/PracticeRecorder.cs b/apps/utility-sw/src/Services/PracticeRecorder.cs index 43e71719..9a4001aa 100644 --- a/apps/utility-sw/src/Services/PracticeRecorder.cs +++ b/apps/utility-sw/src/Services/PracticeRecorder.cs @@ -226,11 +226,30 @@ public void OnProjectileCreated(CEntityInstance entity) string designer = entity.DesignerName ?? ""; string? utilityType = PracticeLineupUtility.UtilityTypeForProjectile(designer); - if (Emitting || utilityType == null || _tracked.Count >= MaxTrackedProjectiles) + // 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 (Emitting) + { + return; + } + + if (_tracked.Count >= MaxTrackedProjectiles) + { + _logger.LogWarning( + "dropped a {type}: already tracking {count} projectiles", + utilityType, + _tracked.Count + ); + return; + } + CBaseCSGrenadeProjectile projectile; try { @@ -247,6 +266,10 @@ public void OnProjectileCreated(CEntityInstance entity) if (player == null || !player.IsValid) { + _logger.LogWarning( + "dropped a {type}: no thrower on the projectile yet", + utilityType + ); return; } diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index fb38bce2..f78a9b8f 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -7,6 +7,7 @@ using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.Plugins; using static SwiftlyS2.Shared.Helper; +using SwiftlyS2.Shared.SchemaDefinitions; namespace UtilityPractice; @@ -105,7 +106,20 @@ public override void Load(bool hotReload) _tickHandler = OnGameTick; Core.Event.OnTick += _tickHandler; - _entityCreatedHandler = @event => _recorder.OnProjectileCreated(@event.Entity); + // 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); @@ -381,6 +395,8 @@ private void OnSessionRefreshed(PracticeSessionData session) // 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 float CfgReapplySeconds = 3f; + private static readonly string[] PracticeCfg = new[] { "sv_cheats 1", @@ -410,9 +426,25 @@ private void OnSessionRefreshed(PracticeSessionData session) private void ApplyPracticeCfg() { - Core.Scheduler.NextTick( - () => Core.Engine.ExecuteCommand(string.Join(";", PracticeCfg)) - ); + // 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) From 82049f7fe6b47d2783d441f5dab661d15ba1125c Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 21:51:33 -0400 Subject: [PATCH 009/107] greet players with the commands when they join a team --- apps/utility-css/src/Commands/Practice.cs | 13 +++++++ apps/utility-css/src/Events/PracticePlayer.cs | 38 +++++++++++++++++++ apps/utility-css/src/UtilityPracticePlugin.cs | 2 + apps/utility-sw/src/Commands/Practice.cs | 14 +++++++ apps/utility-sw/src/Events/PracticePlayer.cs | 37 ++++++++++++++++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 11 +++++- 6 files changed, 114 insertions(+), 1 deletion(-) diff --git a/apps/utility-css/src/Commands/Practice.cs b/apps/utility-css/src/Commands/Practice.cs index 10eff73e..2112b7f7 100644 --- a/apps/utility-css/src/Commands/Practice.cs +++ b/apps/utility-css/src/Commands/Practice.cs @@ -640,6 +640,19 @@ 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", diff --git a/apps/utility-css/src/Events/PracticePlayer.cs b/apps/utility-css/src/Events/PracticePlayer.cs index 62d214e4..19ce980f 100644 --- a/apps/utility-css/src/Events/PracticePlayer.cs +++ b/apps/utility-css/src/Events/PracticePlayer.cs @@ -33,6 +33,42 @@ 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); @@ -42,6 +78,8 @@ private void OnClientDisconnect(int slot) 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); diff --git a/apps/utility-css/src/UtilityPracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs index d36039eb..d7d4726e 100644 --- a/apps/utility-css/src/UtilityPracticePlugin.cs +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -253,6 +253,8 @@ private void RefreshEverything() // 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 float CfgReapplySeconds = 3f; private static readonly string[] PracticeCfg = new[] diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index c5f54a82..c5e428ac 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -667,6 +667,20 @@ public void OnRefresh(ICommandContext context) 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", diff --git a/apps/utility-sw/src/Events/PracticePlayer.cs b/apps/utility-sw/src/Events/PracticePlayer.cs index cac8138b..094e4fb3 100644 --- a/apps/utility-sw/src/Events/PracticePlayer.cs +++ b/apps/utility-sw/src/Events/PracticePlayer.cs @@ -1,6 +1,7 @@ using SwiftlyS2.Shared.GameEventDefinitions; using SwiftlyS2.Shared.GameEvents; using SwiftlyS2.Shared.Misc; +using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.SchemaDefinitions; namespace UtilityPractice; @@ -8,6 +9,42 @@ 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) { diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index f78a9b8f..1419ee10 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -43,6 +43,7 @@ public partial class UtilityPracticePlugin : BasePlugin private EventDelegates.OnTick? _tickHandler; private EventDelegates.OnEntityCreated? _entityCreatedHandler; + private readonly HashSet _welcomed = new(); private EventDelegates.OnMapLoad? _mapLoadHandler; private EventDelegates.OnClientDisconnected? _disconnectHandler; private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; @@ -125,7 +126,15 @@ public override void Load(bool hotReload) _mapLoadHandler = @event => OnMapLoad(@event.MapName); Core.Event.OnMapLoad += _mapLoadHandler; - _disconnectHandler = @event => ForPlayer(@event.PlayerId, OnPlayerGone); + _disconnectHandler = @event => + ForPlayer( + @event.PlayerId, + steamId => + { + _welcomed.Remove(steamId); + OnPlayerGone(steamId); + } + ); Core.Event.OnClientDisconnected += _disconnectHandler; _authorizeHandler = @event => From 90caa50301440516338bca5f113060df60c69cdf Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 22:03:00 -0400 Subject: [PATCH 010/107] keep practice players alive and stocked --- .../src/Services/PracticeSystem.cs | 44 ++++++++++++++++++ apps/utility-css/src/UtilityPracticePlugin.cs | 37 +++++++++++++++ .../utility-sw/src/Services/PracticeSystem.cs | 46 +++++++++++++++++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 37 +++++++++++++++ 4 files changed, 164 insertions(+) diff --git a/apps/utility-css/src/Services/PracticeSystem.cs b/apps/utility-css/src/Services/PracticeSystem.cs index d898fbfa..ff58ad41 100644 --- a/apps/utility-css/src/Services/PracticeSystem.cs +++ b/apps/utility-css/src/Services/PracticeSystem.cs @@ -192,6 +192,50 @@ public void RefillUtility() } } + // 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 diff --git a/apps/utility-css/src/UtilityPracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs index d7d4726e..ef58bdba 100644 --- a/apps/utility-css/src/UtilityPracticePlugin.cs +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -143,11 +143,48 @@ public override void Unload(bool hotReload) private void OnSecond() { + RespawnTheDead(); + KeepEveryoneStocked(); _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 void KeepEveryoneStocked() + { + foreach (CCSPlayerController player in Utilities.GetPlayers()) + { + if (player == null || !player.IsValid || player.IsBot) + { + continue; + } + + _system.GiveUtility(player); + } + } + private void OnFastTick() { _system.RefillUtility(); diff --git a/apps/utility-sw/src/Services/PracticeSystem.cs b/apps/utility-sw/src/Services/PracticeSystem.cs index e14dce7a..af136bbe 100644 --- a/apps/utility-sw/src/Services/PracticeSystem.cs +++ b/apps/utility-sw/src/Services/PracticeSystem.cs @@ -210,6 +210,52 @@ public void RefillUtility() } } + // 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()) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 1419ee10..4b0fc720 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -236,12 +236,49 @@ private void OnGameTick() private void OnSecond() { + RespawnTheDead(); + KeepEveryoneStocked(); _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 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(); From e13d1dab2ded3ef94cdb335de385eb15ef2c28e7 Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 22:12:49 -0400 Subject: [PATCH 011/107] report who is on a practice server; save the stance not the release point --- .../src/Services/UtilityApiClient.cs | 14 +++++++++ apps/utility-css/src/UtilityPracticePlugin.cs | 29 +++++++++++++++++++ .../src/Services/PracticeRecorder.cs | 29 ++++++++++++++++--- .../src/Services/UtilityApiClient.cs | 14 +++++++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 29 +++++++++++++++++++ 5 files changed, 111 insertions(+), 4 deletions(-) diff --git a/apps/utility-css/src/Services/UtilityApiClient.cs b/apps/utility-css/src/Services/UtilityApiClient.cs index 19af59e2..c68c731d 100644 --- a/apps/utility-css/src/Services/UtilityApiClient.cs +++ b/apps/utility-css/src/Services/UtilityApiClient.cs @@ -124,6 +124,20 @@ public int QueuedCount } } + // 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); diff --git a/apps/utility-css/src/UtilityPracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs index ef58bdba..5c0fd0e0 100644 --- a/apps/utility-css/src/UtilityPracticePlugin.cs +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -145,6 +145,7 @@ private void OnSecond() { RespawnTheDead(); KeepEveryoneStocked(); + ReportOccupancy(); _system.Tick(); _playbook.Second(); _drill.Second(); @@ -172,6 +173,32 @@ private void RespawnTheDead() // 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 void KeepEveryoneStocked() { foreach (CCSPlayerController player in Utilities.GetPlayers()) @@ -292,6 +319,8 @@ private void RefreshEverything() // warmup with no money and no utility. private readonly HashSet _welcomed = new(); + private const int OccupancySeconds = 15; + private const float CfgReapplySeconds = 3f; private static readonly string[] PracticeCfg = new[] diff --git a/apps/utility-sw/src/Services/PracticeRecorder.cs b/apps/utility-sw/src/Services/PracticeRecorder.cs index 9a4001aa..0777c68b 100644 --- a/apps/utility-sw/src/Services/PracticeRecorder.cs +++ b/apps/utility-sw/src/Services/PracticeRecorder.cs @@ -37,8 +37,17 @@ 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; + private class TrackedProjectile { public required ulong ThrowerSteamId; @@ -150,13 +159,23 @@ private void WatchArmedGrenades() 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; - state.Frozen = Snapshot(pawn, grenade); + state.Frozen = Snapshot(pawn, grenade, state.Stance); _pending[player.SteamID] = state.Frozen; } } @@ -180,7 +199,7 @@ private void WatchArmedGrenades() } } - private ThrowSnapshot Snapshot(CCSPlayerPawn pawn, CBaseCSGrenade grenade) + private ThrowSnapshot Snapshot(CCSPlayerPawn pawn, CBaseCSGrenade grenade, Vec3? stance) { Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); Vector velocity = pawn.AbsVelocity; @@ -202,13 +221,15 @@ private ThrowSnapshot Snapshot(CCSPlayerPawn pawn, CBaseCSGrenade grenade) return new ThrowSnapshot { - feet_position = new Vec3(origin.X, origin.Y, origin.Z), + // 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 & (1 << 0)) != 0, + on_ground = (pawn.Flags & FlOnGround) != 0, ducked = ducked, walking = walking, throw_strength_raw = grenade.ThrowStrength, diff --git a/apps/utility-sw/src/Services/UtilityApiClient.cs b/apps/utility-sw/src/Services/UtilityApiClient.cs index 19af59e2..c68c731d 100644 --- a/apps/utility-sw/src/Services/UtilityApiClient.cs +++ b/apps/utility-sw/src/Services/UtilityApiClient.cs @@ -124,6 +124,20 @@ public int QueuedCount } } + // 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); diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 4b0fc720..2578b1d5 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -238,6 +238,7 @@ private void OnSecond() { RespawnTheDead(); KeepEveryoneStocked(); + ReportOccupancy(); _system.Tick(); _playbook.Second(); _drill.Second(); @@ -266,6 +267,32 @@ private void RespawnTheDead() // 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 void KeepEveryoneStocked() { foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) @@ -441,6 +468,8 @@ private void OnSessionRefreshed(PracticeSessionData session) // 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 float CfgReapplySeconds = 3f; private static readonly string[] PracticeCfg = new[] From 3d0b16f72096e048b8446f251b61b181d97f1daa Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 22:20:29 -0400 Subject: [PATCH 012/107] never sit in warmup on a practice server --- apps/utility-css/src/UtilityPracticePlugin.cs | 37 +++++++++++++++++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 37 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/apps/utility-css/src/UtilityPracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs index 5c0fd0e0..a18a82db 100644 --- a/apps/utility-css/src/UtilityPracticePlugin.cs +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -143,6 +143,7 @@ public override void Unload(bool hotReload) private void OnSecond() { + EndWarmup(); RespawnTheDead(); KeepEveryoneStocked(); ReportOccupancy(); @@ -199,6 +200,35 @@ private void ReportOccupancy() _ = 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()) @@ -320,6 +350,7 @@ private void RefreshEverything() private readonly HashSet _welcomed = new(); private const int OccupancySeconds = 15; + private const int WarmupRetrySeconds = 3; private const float CfgReapplySeconds = 3f; @@ -329,6 +360,12 @@ private void RefreshEverything() // 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", diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 2578b1d5..9aae76a5 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -236,6 +236,7 @@ private void OnGameTick() private void OnSecond() { + EndWarmup(); RespawnTheDead(); KeepEveryoneStocked(); ReportOccupancy(); @@ -293,6 +294,35 @@ private void ReportOccupancy() _ = 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()) @@ -469,6 +499,7 @@ private void OnSessionRefreshed(PracticeSessionData session) // 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; @@ -478,6 +509,12 @@ private void OnSessionRefreshed(PracticeSessionData session) // 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", From 58a34cad832a5ba4a77e6e09b18f97649792e4c2 Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 22:24:58 -0400 Subject: [PATCH 013/107] aim goes on the eyes, not the body --- apps/utility-css/src/Services/PracticeSystem.cs | 9 ++++++++- apps/utility-sw/src/Services/PracticeSystem.cs | 12 +++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/utility-css/src/Services/PracticeSystem.cs b/apps/utility-css/src/Services/PracticeSystem.cs index ff58ad41..beda5efd 100644 --- a/apps/utility-css/src/Services/PracticeSystem.cs +++ b/apps/utility-css/src/Services/PracticeSystem.cs @@ -295,15 +295,22 @@ public static void TeleportTo(CCSPlayerController player, ThrowSnapshot position 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(position.pitch, position.yaw, 0), + 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() diff --git a/apps/utility-sw/src/Services/PracticeSystem.cs b/apps/utility-sw/src/Services/PracticeSystem.cs index af136bbe..bd51356f 100644 --- a/apps/utility-sw/src/Services/PracticeSystem.cs +++ b/apps/utility-sw/src/Services/PracticeSystem.cs @@ -321,15 +321,25 @@ public bool SavePosition(IPlayer player, string name) 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(position.pitch, position.yaw, 0), + 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() From 3eafd9bb88d4b9e1dda08e0f45ba7b133608e2ce Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 22:33:23 -0400 Subject: [PATCH 014/107] stop rebuilding the transmit filter every frame --- apps/utility-css/src/Events/PracticePlayer.cs | 2 +- .../src/Services/PracticeReplay.cs | 33 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/utility-css/src/Events/PracticePlayer.cs b/apps/utility-css/src/Events/PracticePlayer.cs index 19ce980f..cd05acf9 100644 --- a/apps/utility-css/src/Events/PracticePlayer.cs +++ b/apps/utility-css/src/Events/PracticePlayer.cs @@ -97,7 +97,7 @@ private void OnCheckTransmit(CCheckTransmitInfoList infoList) return; } - List<(uint index, ulong owner)> ghosts = _replay.GhostEntities().ToList(); + IReadOnlyList<(uint index, ulong owner)> ghosts = _replay.GhostEntities(); foreach ((CCheckTransmitInfo info, CCSPlayerController? viewer) in infoList) { diff --git a/apps/utility-css/src/Services/PracticeReplay.cs b/apps/utility-css/src/Services/PracticeReplay.cs index 39b389f2..d7750ce4 100644 --- a/apps/utility-css/src/Services/PracticeReplay.cs +++ b/apps/utility-css/src/Services/PracticeReplay.cs @@ -129,6 +129,7 @@ public void ShowGhost(CCSPlayerController player, LineupRecord lineup) return; } + GhostsChanged(); _ghosts.Add( new Ghost { @@ -189,6 +190,7 @@ public int ShowBloom(CCSPlayerController player, LineupRecord lineup) // 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 { @@ -218,6 +220,7 @@ public void ClearGhosts(ulong steamId) Kill(_ghosts[index]); _ghosts.RemoveAt(index); + GhostsChanged(); } } @@ -232,6 +235,7 @@ private void ClearKind(ulong steamId, GhostKind kind) Kill(_ghosts[index]); _ghosts.RemoveAt(index); + GhostsChanged(); } } @@ -250,6 +254,7 @@ public void ClearAll() } _ghosts.Clear(); + GhostsChanged(); } public void Sweep() @@ -265,6 +270,7 @@ public void Sweep() Kill(_ghosts[index]); _ghosts.RemoveAt(index); + GhostsChanged(); } } @@ -272,18 +278,41 @@ public void Sweep() // Beams are the only entities this plugin spawns, so a transmit filter // built from this list can never hide anything else by accident. - public IEnumerable<(uint index, ulong owner)> GhostEntities() + // + // 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) { - yield return (beam.Index, ghost.OwnerSteamId); + 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) From abd92124c04ae9df1a730fa8d9f30dc23fa17bba Mon Sep 17 00:00:00 2001 From: lukepolo Date: Wed, 19 Aug 2026 23:22:11 -0400 Subject: [PATCH 015/107] the load path puts the aim on the eyes too --- .../src/Services/PracticeReplay.cs | 28 +++++++++++++++---- .../utility-sw/src/Services/PracticeReplay.cs | 26 +++++++++++++---- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/apps/utility-css/src/Services/PracticeReplay.cs b/apps/utility-css/src/Services/PracticeReplay.cs index d7750ce4..dd402fff 100644 --- a/apps/utility-css/src/Services/PracticeReplay.cs +++ b/apps/utility-css/src/Services/PracticeReplay.cs @@ -69,13 +69,20 @@ public void Load(CCSPlayerController player, LineupRecord lineup) Vec3 feet = lineup.release.feet_position; var position = new Vector(feet.x, feet.y, feet.z); - var angles = new QAngle(lineup.release.pitch, lineup.release.yaw, 0); + // 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, angles, new Vector(0, 0, 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, angles, 2); + ReapplyAngles(player, facing, aim, 2); GiveUtility(player, lineup.utility_type); @@ -362,7 +369,12 @@ private static bool HasWeapon(CCSPlayerController player, string designerName) return false; } - private static void ReapplyAngles(CCSPlayerController player, QAngle angles, int frames) + private static void ReapplyAngles( + CCSPlayerController player, + QAngle facing, + QAngle aim, + int frames + ) { if (frames <= 0) { @@ -378,8 +390,12 @@ private static void ReapplyAngles(CCSPlayerController player, QAngle angles, int return; } - pawn.Teleport(null, angles, new Vector(0, 0, 0)); - ReapplyAngles(player, angles, frames - 1); + 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); }); } diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 52be7d7f..ae44f9af 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -108,13 +108,19 @@ public void Load(IPlayer player, LineupRecord lineup) Vec3 feet = lineup.release.feet_position; var position = new Vector(feet.x, feet.y, feet.z); - var angles = new QAngle(lineup.release.pitch, lineup.release.yaw, 0); - player.Teleport(position, angles, new Vector(0, 0, 0)); + // 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, angles, 2); + ReapplyAngles(player, facing, aim, 2); GiveUtility(player, lineup.utility_type); @@ -634,7 +640,7 @@ private static bool HasWeapon(CCSPlayerPawn pawn, string designerName) return false; } - private void ReapplyAngles(IPlayer player, QAngle angles, int frames) + private void ReapplyAngles(IPlayer player, QAngle facing, QAngle aim, int frames) { if (frames <= 0) { @@ -648,8 +654,16 @@ private void ReapplyAngles(IPlayer player, QAngle angles, int frames) return; } - player.Teleport(null, angles, new Vector(0, 0, 0)); - ReapplyAngles(player, angles, frames - 1); + 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); }); } From dffa8193e224190a7b1b4b33b194966e2826a4c5 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 08:49:03 -0400 Subject: [PATCH 016/107] record the standstill a throw is set up from, not the takeoff point --- .../src/Services/PracticeRecorder.cs | 102 ++++++++++++++++-- .../src/Services/PracticeRecorder.cs | 90 +++++++++++++++- 2 files changed, 183 insertions(+), 9 deletions(-) diff --git a/apps/utility-css/src/Services/PracticeRecorder.cs b/apps/utility-css/src/Services/PracticeRecorder.cs index 2cbd26b0..3270d36c 100644 --- a/apps/utility-css/src/Services/PracticeRecorder.cs +++ b/apps/utility-css/src/Services/PracticeRecorder.cs @@ -49,7 +49,34 @@ private class TrackedProjectile 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(); @@ -87,6 +114,8 @@ public IReadOnlyList HistoryFor(ulong steamId) public void Reset() { _armed.Clear(); + _stationary.Clear(); + _settling.Clear(); _pending.Clear(); _tracked.Clear(); } @@ -111,9 +140,18 @@ private void WatchArmedGrenades() } CCSPlayerPawn? pawn = player.PlayerPawn.Value; - CBasePlayerWeapon? active = pawn?.WeaponServices?.ActiveWeapon.Value; - if (pawn == null || active == null || !active.IsValid) + 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; @@ -143,7 +181,7 @@ private void WatchArmedGrenades() if (state.PinPulled && !state.Released && grenade.ThrowTime > 0) { state.Released = true; - state.Frozen = Snapshot(player, pawn, grenade); + state.Frozen = Snapshot(player, pawn, grenade, StanceFor(player.SteamID)); _pending[player.SteamID] = state.Frozen; } } @@ -167,10 +205,60 @@ private void WatchArmedGrenades() } } + 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 + CBaseCSGrenade grenade, + Vec3? stance ) { Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); @@ -193,13 +281,15 @@ CBaseCSGrenade grenade return new ThrowSnapshot { - feet_position = new Vec3(origin.X, origin.Y, origin.Z), + // 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 & (1 << 0)) != 0, + on_ground = (pawn.Flags & FlOnGround) != 0, ducked = ducked, walking = walking, throw_strength_raw = grenade.ThrowStrength, diff --git a/apps/utility-sw/src/Services/PracticeRecorder.cs b/apps/utility-sw/src/Services/PracticeRecorder.cs index 0777c68b..e5904363 100644 --- a/apps/utility-sw/src/Services/PracticeRecorder.cs +++ b/apps/utility-sw/src/Services/PracticeRecorder.cs @@ -48,6 +48,28 @@ private class ArmedState // 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; @@ -61,6 +83,8 @@ private class TrackedProjectile } 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(); @@ -109,6 +133,8 @@ public void Forget(uint entityIndex) public void Reset() { _armed.Clear(); + _stationary.Clear(); + _settling.Clear(); _pending.Clear(); _tracked.Clear(); } @@ -133,9 +159,18 @@ private void WatchArmedGrenades() } CCSPlayerPawn? pawn = player.PlayerPawn; - CBasePlayerWeapon? active = pawn?.WeaponServices?.ActiveWeapon.Value; - if (pawn == null || !pawn.IsValid || active == null || !active.IsValid) + 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; @@ -175,7 +210,7 @@ private void WatchArmedGrenades() if (state.PinPulled && !state.Released && grenade.ThrowTime.Value > 0) { state.Released = true; - state.Frozen = Snapshot(pawn, grenade, state.Stance); + state.Frozen = Snapshot(pawn, grenade, StanceFor(player.SteamID, state)); _pending[player.SteamID] = state.Frozen; } } @@ -199,6 +234,55 @@ private void WatchArmedGrenades() } } + 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) + { + if ( + _stationary.TryGetValue(steamId, out StationaryAnchor anchor) + && _tick - anchor.Tick <= StationaryMaxAgeTicks + ) + { + return anchor.Position; + } + + return state.Stance; + } + private ThrowSnapshot Snapshot(CCSPlayerPawn pawn, CBaseCSGrenade grenade, Vec3? stance) { Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); From b1a4affb340b831b29f2d85598d75173aed40d9d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 11:57:44 -0400 Subject: [PATCH 017/107] let practice servers hand players valve's annotation editor --- apps/utility-css/src/UtilityPracticePlugin.cs | 5 +++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/apps/utility-css/src/UtilityPracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs index a18a82db..38a45526 100644 --- a/apps/utility-css/src/UtilityPracticePlugin.cs +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -381,6 +381,11 @@ private void RefreshEverything() "mp_solid_teammates 0", "mp_teammates_are_enemies 0", "sv_grenade_trajectory_prac_pipreview 1", + // 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", diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 9aae76a5..2949c436 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -530,6 +530,11 @@ private void OnSessionRefreshed(PracticeSessionData session) "mp_solid_teammates 0", "mp_teammates_are_enemies 0", "sv_grenade_trajectory_prac_pipreview 1", + // 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", From 0e4b28bc6e6f5b4d6b995d6e4d6b750265266aba Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 12:23:32 -0400 Subject: [PATCH 018/107] mark the stance, the aim and the landing in world --- .../src/Services/PracticeReplay.cs | 162 +++++++++++++++++ .../utility-sw/src/Services/PracticeReplay.cs | 164 ++++++++++++++++++ 2 files changed, 326 insertions(+) diff --git a/apps/utility-css/src/Services/PracticeReplay.cs b/apps/utility-css/src/Services/PracticeReplay.cs index dd402fff..0d5b412d 100644 --- a/apps/utility-css/src/Services/PracticeReplay.cs +++ b/apps/utility-css/src/Services/PracticeReplay.cs @@ -48,6 +48,12 @@ private class Ghost 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; @@ -86,6 +92,8 @@ public void Load(CCSPlayerController player, LineupRecord lineup) GiveUtility(player, lineup.utility_type); + ShowMarkers(lineup); + player.PrintToCenter(Describe(lineup)); } @@ -262,6 +270,8 @@ public void ClearAll() _ghosts.Clear(); GhostsChanged(); + + ClearMarkers(); } public void Sweep() @@ -434,6 +444,158 @@ private static List GhostPoints(LineupRecord lineup) 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 diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index ae44f9af..ce846173 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -55,6 +55,12 @@ private class Ghost 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(); + // 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(); @@ -124,6 +130,8 @@ public void Load(IPlayer player, LineupRecord lineup) GiveUtility(player, lineup.utility_type); + ShowMarkers(lineup); + player.SendCenter(Describe(lineup)); } @@ -483,6 +491,8 @@ public void ClearAll() _ghosts.Clear(); _ghostThrows.Clear(); + ClearMarkers(); + foreach (ulong steamId in _bloomSmoke.Keys.ToList()) { ClearBloomSmoke(steamId); @@ -705,6 +715,160 @@ private static List GhostPoints(LineupRecord lineup) 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. 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. + 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 = + _core.EntitySystem.CreateEntityByDesignerName( + "point_worldtext" + ); + + if (!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.Despawn(); + } + } + + foreach (CPointWorldText label in _markerTexts) + { + if (label.IsValid) + { + label.Despawn(); + } + } + + _markerBeams.Clear(); + _markerTexts.Clear(); + } + private CEnvBeam? CreateBeam(Vec3 start, Vec3 end, Color color, float width) { try From 3eaeeca6e7c3dcd5d7a499bb12a24e998b4e1f5c Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 13:02:06 -0400 Subject: [PATCH 019/107] say why the library is empty instead of blaming the query --- .../src/Services/UtilityApiClient.cs | 19 ++++++++++-- apps/utility-sw/src/Commands/Practice.cs | 29 +++++++++++++++++++ .../src/Services/UtilityApiClient.cs | 19 ++++++++++-- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/apps/utility-css/src/Services/UtilityApiClient.cs b/apps/utility-css/src/Services/UtilityApiClient.cs index c68c731d..804fbb93 100644 --- a/apps/utility-css/src/Services/UtilityApiClient.cs +++ b/apps/utility-css/src/Services/UtilityApiClient.cs @@ -412,11 +412,26 @@ private void EnqueueResult(UtilityPracticeResultPayload payload) 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}", + "{method} {path} returned {status}: {reason}", method.Method, path, - (int)response.StatusCode + (int)response.StatusCode, + reason.Length > 500 ? reason.Substring(0, 500) : reason ); return null; } diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index c5e428ac..f6e586df 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -96,6 +96,35 @@ public void OnLoad(ICommandContext context) 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; } diff --git a/apps/utility-sw/src/Services/UtilityApiClient.cs b/apps/utility-sw/src/Services/UtilityApiClient.cs index c68c731d..804fbb93 100644 --- a/apps/utility-sw/src/Services/UtilityApiClient.cs +++ b/apps/utility-sw/src/Services/UtilityApiClient.cs @@ -412,11 +412,26 @@ private void EnqueueResult(UtilityPracticeResultPayload payload) 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}", + "{method} {path} returned {status}: {reason}", method.Method, path, - (int)response.StatusCode + (int)response.StatusCode, + reason.Length > 500 ? reason.Substring(0, 500) : reason ); return null; } From c7d90676115a0ed5d301fb81152d172c60ab0e99 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 13:24:50 -0400 Subject: [PATCH 020/107] aim at a reticle on the wall and say how to throw once you are on it --- .../utility-sw/src/Services/PracticeReplay.cs | 186 ++++++++++++++++-- apps/utility-sw/src/UtilityPracticePlugin.cs | 60 ++++++ 2 files changed, 229 insertions(+), 17 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index ce846173..92012ca8 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -58,6 +58,13 @@ private class Ghost // 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. + // Long enough to cross any competitive map, short enough to stop. + private const float AimTraceRange = 8192f; + + // 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); + private readonly List _markerBeams = new(); private readonly List _markerTexts = new(); @@ -604,6 +611,34 @@ public void ApplyVisibility() } } + // 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 movement = lineup.technique switch + { + "Jump" => "JUMP THROW", + "Run" => "RUN AND THROW", + "RunJump" => "RUN + JUMP THROW", + "Crouch" => "CROUCH THROW", + "CrouchJump" => "CROUCH + JUMP THROW", + "Walk" => "WALK AND THROW", + _ => "STAND STILL", + }; + + string click = lineup.strength switch + { + "Half" => "LEFT + RIGHT CLICK", + "Drop" => "RIGHT CLICK", + "Full" => "LEFT CLICK", + _ => "LEFT CLICK", + }; + + string bind = lineup.release.jump_throw ? " (jump-throw bind)" : ""; + + return $"LINED UP\n{movement} - {click}{bind}"; + } + public static string Describe(LineupRecord lineup) { string name = string.IsNullOrEmpty(lineup.name) ? "unnamed" : lineup.name; @@ -733,44 +768,161 @@ private void ShowMarkers(LineupRecord lineup) Vec3 stance = lineup.release.feet_position; Vec3 landing = lineup.detonation_position; - Ring(stance, 18f, color, 1.5f); + Ring(stance, 32f, color, 3f); Label( new Vec3(stance.x, stance.y, stance.z + 12f), $"STAND\n{lineup.name}", color ); - Ring(landing, 26f, color, 2f); + Ring(landing, 40f, color, 3f); 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. + AimReticle(lineup); + } + + // Where to point. The aim ray is traced until it hits something, so the + // reticle lands ON the surface being aimed at rather than hanging in the + // air short of it -- for an arcing smoke the crosshair sits well above the + // landing spot, so distance-to-landing was never the right answer. + private void AimReticle(LineupRecord lineup) + { 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); + + // 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 + ); + + Vec3 hit; + + try + { + var trace = _core.Trace.TraceShapeLine(from, to, null); + + hit = trace.DidHit + ? new Vec3(trace.EndPos.X, trace.EndPos.Y, trace.EndPos.Z) + : new Vec3(to.X, to.Y, to.Z); + } + catch (Exception error) { - double yaw = lineup.release.yaw * Math.PI / 180.0; - double pitch = lineup.release.pitch * Math.PI / 180.0; - float flat = (float)Math.Cos(pitch); + _logger.LogError(error, "unable to trace a lineup's aim"); + hit = new Vec3(to.X, to.Y, to.Z); + } - 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 + // 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. + float size = Math.Clamp(away * 0.05f, 14f, 90f); + + // 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. + Reticle(center, dir, size, AimColor); + } + + // 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) + { + 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 + ); + + Vec3 topLeft = Corner(-size, size); + Vec3 topRight = Corner(size, size); + Vec3 bottomRight = Corner(size, -size); + Vec3 bottomLeft = Corner(-size, -size); + + AddMarkerBeam(topLeft, topRight, color, 2f); + AddMarkerBeam(topRight, bottomRight, color, 2f); + AddMarkerBeam(bottomRight, bottomLeft, color, 2f); + AddMarkerBeam(bottomLeft, topLeft, color, 2f); + + const int Segments = 12; + float radius = size * 0.45f; + + for (int index = 0; index < Segments; index++) + { + double a = index * 2 * Math.PI / Segments; + double b = (index + 1) * 2 * Math.PI / Segments; + + AddMarkerBeam( + Corner((float)Math.Cos(a) * radius, (float)Math.Sin(a) * radius), + Corner((float)Math.Cos(b) * radius, (float)Math.Sin(b) * radius), + color, + 2f ); + } + } - Label(aim, "AIM", color); - Ring(aim, 10f, color, 1f); + private void AddMarkerBeam(Vec3 start, Vec3 end, Color color, float width) + { + CEnvBeam? beam = CreateBeam(start, end, color, width); + + if (beam != null) + { + _markerBeams.Add(beam); } } + private 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); + } + private void Ring(Vec3 center, float radius, Color color, float width) { const int Segments = 10; diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 2949c436..ce90e5d9 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -7,6 +7,7 @@ using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.Plugins; using static SwiftlyS2.Shared.Helper; +using SwiftlyS2.Shared.Natives; using SwiftlyS2.Shared.SchemaDefinitions; namespace UtilityPractice; @@ -232,6 +233,65 @@ private void OnGameTick() { _recorder.OnTick(); _solver.OnTick(); + AimFeedback(); + } + + // How close the crosshair has to be to the recorded aim before the throw + // instructions appear. Two degrees is tight enough that it means "you are + // on it" and loose enough to hold still at. + private const float AimToleranceDegrees = 2f; + + // Centre text has to be re-sent to stay on screen, but not sixty-four + // times a second: eight is smooth and costs a fraction of the messages. + private const int AimFeedbackEveryTicks = 8; + + 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. + private void AimFeedback() + { + if (++_aimTick % AimFeedbackEveryTicks != 0) + { + return; + } + + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || player.IsFakeClient) + { + continue; + } + + LineupRecord? lineup = _system.StateFor(player.SteamID).Loaded; + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (lineup == null || pawn == null || !pawn.IsValid) + { + continue; + } + + QAngle eyes = pawn.EyeAngles; + + if ( + AngleGap(eyes.Y, lineup.release.yaw) > AimToleranceDegrees + || AngleGap(eyes.X, lineup.release.pitch) > AimToleranceDegrees + ) + { + continue; + } + + player.SendCenter(PracticeReplay.ThrowHint(lineup)); + } + } + + // Shortest way round the circle, so 359 and 1 are two degrees apart. + private static float AngleGap(float a, float b) + { + float gap = Math.Abs(a - b) % 360f; + + return gap > 180f ? 360f - gap : gap; } private void OnSecond() From b309cd6bf1ab78de93c88ab31b25a6e8ec58ce4f Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 13:30:28 -0400 Subject: [PATCH 021/107] draw the markers as outlines instead of glowing slabs --- .../utility-sw/src/Services/PracticeReplay.cs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 92012ca8..e37822d6 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -61,6 +61,11 @@ private class Ghost // Long enough to cross any competitive map, short enough to stop. private const float AimTraceRange = 8192f; + // 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; + // 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); @@ -768,14 +773,14 @@ private void ShowMarkers(LineupRecord lineup) Vec3 stance = lineup.release.feet_position; Vec3 landing = lineup.detonation_position; - Ring(stance, 32f, color, 3f); + Ring(stance, 24f, color, MarkerWidth); Label( new Vec3(stance.x, stance.y, stance.z + 12f), $"STAND\n{lineup.name}", color ); - Ring(landing, 40f, color, 3f); + Ring(landing, 34f, color, MarkerWidth); Label( new Vec3(landing.x, landing.y, landing.z + 16f), lineup.utility_type.ToUpperInvariant(), @@ -875,12 +880,12 @@ Vec3 Corner(float x, float y) => Vec3 bottomRight = Corner(size, -size); Vec3 bottomLeft = Corner(-size, -size); - AddMarkerBeam(topLeft, topRight, color, 2f); - AddMarkerBeam(topRight, bottomRight, color, 2f); - AddMarkerBeam(bottomRight, bottomLeft, color, 2f); - AddMarkerBeam(bottomLeft, topLeft, color, 2f); + AddMarkerBeam(topLeft, topRight, color, MarkerWidth); + AddMarkerBeam(topRight, bottomRight, color, MarkerWidth); + AddMarkerBeam(bottomRight, bottomLeft, color, MarkerWidth); + AddMarkerBeam(bottomLeft, topLeft, color, MarkerWidth); - const int Segments = 12; + const int Segments = 20; float radius = size * 0.45f; for (int index = 0; index < Segments; index++) @@ -892,7 +897,7 @@ Vec3 Corner(float x, float y) => Corner((float)Math.Cos(a) * radius, (float)Math.Sin(a) * radius), Corner((float)Math.Cos(b) * radius, (float)Math.Sin(b) * radius), color, - 2f + MarkerWidth ); } } @@ -925,7 +930,7 @@ private static Vec3 Normalize(Vec3 v) private void Ring(Vec3 center, float radius, Color color, float width) { - const int Segments = 10; + const int Segments = 20; for (int index = 0; index < Segments; index++) { From 112d7fcebe652b1356dfee1222aaaa1ef245d4f2 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 13:31:43 -0400 Subject: [PATCH 022/107] keep the reticle visible at range --- .../utility-sw/src/Services/PracticeReplay.cs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index e37822d6..ac16a6fa 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -849,12 +849,17 @@ private void AimReticle(LineupRecord lineup) // 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. - Reticle(center, dir, size, AimColor); + // 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); + + Reticle(center, dir, size, AimColor, weight); } // 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) + private void Reticle(Vec3 center, Vec3 forward, float size, Color color, float width) { Vec3 right = Cross(forward, new Vec3(0, 0, 1)); @@ -880,10 +885,10 @@ Vec3 Corner(float x, float y) => Vec3 bottomRight = Corner(size, -size); Vec3 bottomLeft = Corner(-size, -size); - AddMarkerBeam(topLeft, topRight, color, MarkerWidth); - AddMarkerBeam(topRight, bottomRight, color, MarkerWidth); - AddMarkerBeam(bottomRight, bottomLeft, color, MarkerWidth); - AddMarkerBeam(bottomLeft, topLeft, color, MarkerWidth); + AddMarkerBeam(topLeft, topRight, color, width); + AddMarkerBeam(topRight, bottomRight, color, width); + AddMarkerBeam(bottomRight, bottomLeft, color, width); + AddMarkerBeam(bottomLeft, topLeft, color, width); const int Segments = 20; float radius = size * 0.45f; @@ -897,7 +902,7 @@ Vec3 Corner(float x, float y) => Corner((float)Math.Cos(a) * radius, (float)Math.Sin(a) * radius), Corner((float)Math.Cos(b) * radius, (float)Math.Sin(b) * radius), color, - MarkerWidth + width ); } } From e88644546882357ccef09bad6649dff866edbabc Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 13:38:09 -0400 Subject: [PATCH 023/107] trace the aim from where the player stands, not from where the grenade left --- apps/utility-sw/src/Services/PracticeReplay.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index ac16a6fa..88dcaf37 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -796,7 +796,19 @@ private void ShowMarkers(LineupRecord lineup) // landing spot, so distance-to-landing was never the right answer. private void AimReticle(LineupRecord lineup) { - Vec3 eye = lineup.release.eye_position; + // From where the player's eye WILL BE after .load, not from where the + // grenade left the hand. Those stopped being the same point when the + // recorder started storing the standstill as feet_position: a run- or + // jump-throw releases a whole run-up away from where it is set up, and + // tracing from the release point puts the reticle that far off from + // what the player sees standing on the marker. + Vec3 stance = lineup.release.feet_position; + + var eye = new Vec3( + stance.x, + stance.y, + stance.z + PracticeSolverUtility.StandingEyeHeight + ); double yaw = lineup.release.yaw * Math.PI / 180.0; double pitch = lineup.release.pitch * Math.PI / 180.0; From 3ada30960b6694c4527b7296e50dcd29c3809577 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 13:42:28 -0400 Subject: [PATCH 024/107] hang the reticle in front of the stance instead of on a distant ceiling --- apps/utility-sw/src/Services/PracticeReplay.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 88dcaf37..3674e48f 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -58,8 +58,13 @@ private class Ghost // 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. - // Long enough to cross any competitive map, short enough to stop. - private const float AimTraceRange = 8192f; + // 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. + private const float AimTraceRange = 420f; // 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 @@ -856,7 +861,7 @@ private void AimReticle(LineupRecord lineup) // Sized by distance so it looks the same from the stance whether the // wall is ten units away or two thousand. - float size = Math.Clamp(away * 0.05f, 14f, 90f); + float size = Math.Clamp(away * 0.09f, 18f, 60f); // 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 From 2a2299f66853952114b689a564a0157a69be8bd6 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 13:47:03 -0400 Subject: [PATCH 025/107] put the stance marker on the floor a player could stand on --- .../utility-sw/src/Services/PracticeReplay.cs | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 3674e48f..925a59fd 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -66,6 +66,10 @@ private class Ghost // in which case it lands on the wall. private const float AimTraceRange = 420f; + // 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. @@ -129,7 +133,9 @@ public void Load(IPlayer player, LineupRecord lineup) return; } - Vec3 feet = lineup.release.feet_position; + // 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 @@ -775,7 +781,7 @@ private void ShowMarkers(LineupRecord lineup) } Color color = ColorFor(lineup.utility_type); - Vec3 stance = lineup.release.feet_position; + Vec3 stance = Grounded(lineup.release.feet_position); Vec3 landing = lineup.detonation_position; Ring(stance, 24f, color, MarkerWidth); @@ -807,7 +813,7 @@ private void AimReticle(LineupRecord lineup) // jump-throw releases a whole run-up away from where it is set up, and // tracing from the release point puts the reticle that far off from // what the player sees standing on the marker. - Vec3 stance = lineup.release.feet_position; + Vec3 stance = Grounded(lineup.release.feet_position); var eye = new Vec3( stance.x, @@ -924,6 +930,35 @@ Vec3 Corner(float x, float y) => } } + // 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 + { + var from = new Vector(position.x, position.y, position.z); + var to = new Vector( + position.x, + position.y, + position.z - GroundSnapRange + ); + + var trace = _core.Trace.TraceShapeLine(from, to, null); + + // Already standing on it: the trace hits at once and changes nothing. + return trace.DidHit + ? new Vec3(position.x, position.y, trace.EndPos.Z) + : position; + } + catch (Exception error) + { + _logger.LogError(error, "unable to find the floor under a lineup"); + return position; + } + } + private void AddMarkerBeam(Vec3 start, Vec3 end, Color color, float width) { CEnvBeam? beam = CreateBeam(start, end, color, width); From 21411103e2dee82c8693903a6edafb58c7616f51 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 13:50:32 -0400 Subject: [PATCH 026/107] start the ground trace above the point so it can actually hit --- .../utility-sw/src/Services/PracticeReplay.cs | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 925a59fd..46347b89 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -938,7 +938,10 @@ private Vec3 Grounded(Vec3 position) { try { - var from = new Vector(position.x, position.y, position.z); + // 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, @@ -947,10 +950,29 @@ private Vec3 Grounded(Vec3 position) var trace = _core.Trace.TraceShapeLine(from, to, null); - // Already standing on it: the trace hits at once and changes nothing. - return trace.DidHit - ? new Vec3(position.x, position.y, trace.EndPos.Z) - : position; + 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) { From 2196993d7e07024de2cc7e4cf13a73916534e64a Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 14:32:21 -0400 Subject: [PATCH 027/107] ground the markers before the player is standing on the spot --- apps/utility-sw/src/Services/PracticeReplay.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 46347b89..5cdc9624 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -153,7 +153,7 @@ public void Load(IPlayer player, LineupRecord lineup) GiveUtility(player, lineup.utility_type); - ShowMarkers(lineup); + ShowMarkers(lineup, feet); player.SendCenter(Describe(lineup)); } @@ -771,7 +771,11 @@ private static List GhostPoints(LineupRecord lineup) // 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. - private void ShowMarkers(LineupRecord lineup) + // 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. + private void ShowMarkers(LineupRecord lineup, Vec3 stance) { ClearMarkers(); @@ -781,7 +785,6 @@ private void ShowMarkers(LineupRecord lineup) } Color color = ColorFor(lineup.utility_type); - Vec3 stance = Grounded(lineup.release.feet_position); Vec3 landing = lineup.detonation_position; Ring(stance, 24f, color, MarkerWidth); @@ -798,14 +801,14 @@ private void ShowMarkers(LineupRecord lineup) color ); - AimReticle(lineup); + AimReticle(lineup, stance); } // Where to point. The aim ray is traced until it hits something, so the // reticle lands ON the surface being aimed at rather than hanging in the // air short of it -- for an arcing smoke the crosshair sits well above the // landing spot, so distance-to-landing was never the right answer. - private void AimReticle(LineupRecord lineup) + private void AimReticle(LineupRecord lineup, Vec3 stance) { // From where the player's eye WILL BE after .load, not from where the // grenade left the hand. Those stopped being the same point when the @@ -813,8 +816,6 @@ private void AimReticle(LineupRecord lineup) // jump-throw releases a whole run-up away from where it is set up, and // tracing from the release point puts the reticle that far off from // what the player sees standing on the marker. - Vec3 stance = Grounded(lineup.release.feet_position); - var eye = new Vec3( stance.x, stance.y, From 0dc715da0ccf0505d8e5f00f27e9d885478adb11 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 14:39:28 -0400 Subject: [PATCH 028/107] draw the stance where the player actually lands --- .../utility-sw/src/Services/PracticeReplay.cs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 5cdc9624..ff6438b1 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -153,7 +153,29 @@ public void Load(IPlayer player, LineupRecord lineup) GiveUtility(player, lineup.utility_type); - ShowMarkers(lineup, feet); + // 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); + } + + ShowMarkers(lineup, standing); + }); player.SendCenter(Describe(lineup)); } From dc48d5db15bfafec5f33eb24fbb2e7107b0a8ca8 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 15:14:47 -0400 Subject: [PATCH 029/107] print where you are against what the lineup says --- apps/utility-sw/src/Commands/Practice.cs | 72 ++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index f6e586df..fd3c54f8 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -3,7 +3,9 @@ using FiveStack.Utilities; 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; @@ -389,6 +391,75 @@ public void OnSkip(ICommandContext context) } } + // Prints where the player actually is next to what the loaded lineup says, + // because every disagreement so far has been between those two numbers and + // no amount of looking at the marker tells you which one is wrong. + [Command("where", registerRaw: false, permission: "")] + public void OnWhere(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return; + } + + Vector feet = pawn.AbsOrigin ?? new Vector(0, 0, 0); + float viewOffset = pawn.ViewOffset.Z.Value; + QAngle eyes = pawn.EyeAngles; + bool grounded = (pawn.Flags & (1 << 0)) != 0; + Vector velocity = pawn.AbsVelocity; + float speed = new Vec3(velocity.X, velocity.Y, 0f).LengthXY(); + + Reply( + context, + $" {ChatColors.Gold}YOU {ChatColors.Default}feet " + + $"{feet.X:0.##} {feet.Y:0.##} {ChatColors.Green}{feet.Z:0.##}" + + $"{ChatColors.Default} eye {(feet.Z + viewOffset):0.##}" + + $" aim {eyes.Y:0.##} / {eyes.X:0.##}" + + $" {(grounded ? "on ground" : $"{ChatColors.Red}AIRBORNE{ChatColors.Default}")}" + + $" speed {speed:0.#}" + ); + + LineupRecord? lineup = _system.StateFor(player.SteamID).Loaded; + + if (lineup == null) + { + Reply(context, $" {ChatColors.Grey}no lineup loaded to compare against"); + return; + } + + Vec3 storedFeet = lineup.release.feet_position; + Vec3 storedEye = lineup.release.eye_position; + + Reply( + context, + $" {ChatColors.Gold}LINEUP {ChatColors.Default}feet " + + $"{storedFeet.x:0.##} {storedFeet.y:0.##} {ChatColors.Green}{storedFeet.z:0.##}" + + $"{ChatColors.Default} eye {storedEye.z:0.##}" + + $" aim {lineup.release.yaw:0.##} / {lineup.release.pitch:0.##}" + + $" {(lineup.release.on_ground ? "on ground" : $"{ChatColors.Red}AIRBORNE{ChatColors.Default}")}" + ); + + // The gap is the whole point: a lineup that teleports you correctly + // reads zero here, and any drift says which axis is wrong. + Reply( + context, + $" {ChatColors.Gold}GAP {ChatColors.Default}" + + $"dx {(feet.X - storedFeet.x):0.##} " + + $"dy {(feet.Y - storedFeet.y):0.##} " + + $"{ChatColors.Green}dz {(feet.Z - storedFeet.z):0.##}{ChatColors.Default} " + + $"eye-over-feet: you {(viewOffset):0.##} / stored {(storedEye.z - storedFeet.z):0.##}" + ); + } + [Command("pos", registerRaw: false, permission: "")] public void OnPos(ICommandContext context) { @@ -720,6 +791,7 @@ public void OnRefresh(ICommandContext context) $" {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}.where {ChatColors.Grey}where you are vs what the loaded lineup says", $" {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", From b06edf7c5721caa6b9ee615e2fc2cf104532cb6c Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 15:24:49 -0400 Subject: [PATCH 030/107] say which position a recorded throw chose and why --- .../src/Services/PracticeRecorder.cs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/utility-sw/src/Services/PracticeRecorder.cs b/apps/utility-sw/src/Services/PracticeRecorder.cs index e5904363..4fd98bba 100644 --- a/apps/utility-sw/src/Services/PracticeRecorder.cs +++ b/apps/utility-sw/src/Services/PracticeRecorder.cs @@ -210,7 +210,31 @@ private void WatchArmedGrenades() if (state.PinPulled && !state.Released && grenade.ThrowTime.Value > 0) { state.Released = true; - state.Frozen = Snapshot(pawn, grenade, StanceFor(player.SteamID, state)); + + 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; } } From 588288b9be3035c1865764e334c7a2925d34914d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 15:26:57 -0400 Subject: [PATCH 031/107] prefer the throw's own takeoff over a standstill walked away from --- .../utility-sw/src/Services/PracticeRecorder.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/utility-sw/src/Services/PracticeRecorder.cs b/apps/utility-sw/src/Services/PracticeRecorder.cs index 4fd98bba..ec83250b 100644 --- a/apps/utility-sw/src/Services/PracticeRecorder.cs +++ b/apps/utility-sw/src/Services/PracticeRecorder.cs @@ -296,6 +296,21 @@ private void TrackStationary(ulong steamId, CCSPlayerPawn pawn) // 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 @@ -304,7 +319,7 @@ private void TrackStationary(ulong steamId, CCSPlayerPawn pawn) return anchor.Position; } - return state.Stance; + return null; } private ThrowSnapshot Snapshot(CCSPlayerPawn pawn, CBaseCSGrenade grenade, Vec3? stance) From 4e8fe0941b8f4af9b0b67e49a147b2d9db461745 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 15:35:12 -0400 Subject: [PATCH 032/107] show a lineup the moment it is saved --- apps/utility-sw/src/Commands/Practice.cs | 17 +++++++++++++++++ apps/utility-sw/src/Services/PracticeReplay.cs | 16 ++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index fd3c54f8..3284e88e 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -59,6 +59,23 @@ public void OnSave(ICommandContext context) _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; diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index ff6438b1..4a1cf1a0 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -797,6 +797,22 @@ private static List GhostPoints(LineupRecord lineup) // 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. + // 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) + { + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return; + } + + Vector feet = pawn.AbsOrigin ?? new Vector(0, 0, 0); + + ShowMarkers(lineup, new Vec3(feet.X, feet.Y, feet.Z)); + } + private void ShowMarkers(LineupRecord lineup, Vec3 stance) { ClearMarkers(); From d739a585a67f20cbac60368413facfb36531a26c Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 15:39:32 -0400 Subject: [PATCH 033/107] keep the lineup on screen and make the aim exact --- apps/utility-sw/src/Commands/Practice.cs | 11 ++++ .../utility-sw/src/Services/PracticeReplay.cs | 30 ++++++--- apps/utility-sw/src/UtilityPracticePlugin.cs | 61 +++++++++++++++---- 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 3284e88e..72270267 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -254,6 +254,7 @@ public void OnClear(ICommandContext context) state.Bloom = false; _replay.ClearGhosts(player.SteamID); + _replay.ClearMarkers(); Reply(context, $" {ChatColors.Green}cleared"); } @@ -743,6 +744,16 @@ public void OnReload(ICommandContext context) 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 => diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 4a1cf1a0..83e1a22f 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -64,7 +64,15 @@ private class Ghost // 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. - private const float AimTraceRange = 420f; + // 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; + + // 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. + private const float AimFallbackRange = 420f; // A jump tops out around 54 units, so anything further below a recorded // position is a different floor and must not be snapped to. @@ -817,11 +825,9 @@ private void ShowMarkers(LineupRecord lineup, Vec3 stance) { ClearMarkers(); - if (!_config.GhostPreview) - { - return; - } - + // 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; @@ -886,12 +892,20 @@ private void AimReticle(LineupRecord lineup, Vec3 stance) hit = trace.DidHit ? new Vec3(trace.EndPos.X, trace.EndPos.Y, trace.EndPos.Z) - : new Vec3(to.X, to.Y, to.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(to.X, to.Y, to.Z); + hit = new Vec3( + eye.x + dir.x * AimFallbackRange, + eye.y + dir.y * AimFallbackRange, + eye.z + dir.z * AimFallbackRange + ); } // Pulled back off the surface so the reticle does not z-fight with the diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index ce90e5d9..0d6faa72 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -236,14 +236,20 @@ private void OnGameTick() AimFeedback(); } - // How close the crosshair has to be to the recorded aim before the throw - // instructions appear. Two degrees is tight enough that it means "you are - // on it" and loose enough to hold still at. - private const float AimToleranceDegrees = 2f; + // Dead on, not nearly. A tenth of a degree is inside one mouse count at + // normal sensitivity, so this only reads LINED UP when the crosshair is + // genuinely on the recorded angle rather than somewhere near it. + private const float AimToleranceDegrees = 0.1f; - // Centre text has to be re-sent to stay on screen, but not sixty-four - // times a second: eight is smooth and costs a fraction of the messages. - private const int AimFeedbackEveryTicks = 8; + // Wide enough to be worth coaching towards. Between this and the tolerance + // the player gets the live error instead of silence, which is what lets + // them close the last fraction of a degree. + private const float AimGuideDegrees = 8f; + + // 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; private int _aimTick; @@ -274,18 +280,49 @@ private void AimFeedback() QAngle eyes = pawn.EyeAngles; - if ( - AngleGap(eyes.Y, lineup.release.yaw) > AimToleranceDegrees - || AngleGap(eyes.X, lineup.release.pitch) > AimToleranceDegrees - ) + float yawOff = AngleGap(eyes.Y, lineup.release.yaw); + float pitchOff = AngleGap(eyes.X, lineup.release.pitch); + + if (yawOff <= AimToleranceDegrees && pitchOff <= AimToleranceDegrees) { + player.SendCenter(PracticeReplay.ThrowHint(lineup)); continue; } - player.SendCenter(PracticeReplay.ThrowHint(lineup)); + if (yawOff > AimGuideDegrees || pitchOff > AimGuideDegrees) + { + continue; + } + + // Signed, and named by the direction the crosshair has to travel, + // because "0.4 degrees off" does not tell anyone which way to move. + float yawDelta = Signed(lineup.release.yaw - eyes.Y); + float pitchDelta = Signed(lineup.release.pitch - eyes.X); + + player.SendCenter( + $"{(yawDelta >= 0 ? "LEFT" : "RIGHT")} {Math.Abs(yawDelta):0.00}°" + + $" {(pitchDelta >= 0 ? "DOWN" : "UP")} {Math.Abs(pitchDelta):0.00}°" + ); } } + // Shortest signed way round the circle: -180..180. + private static float Signed(float degrees) + { + float wrapped = degrees % 360f; + + if (wrapped > 180f) + { + wrapped -= 360f; + } + else if (wrapped < -180f) + { + wrapped += 360f; + } + + return wrapped; + } + // Shortest way round the circle, so 359 and 1 are two degrees apart. private static float AngleGap(float a, float b) { From be1e7d569ccce041dcf1a901ad2d72f8ec9ec04d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 15:44:43 -0400 Subject: [PATCH 034/107] load every lineup on the map at once --- .../utility-sw/src/Services/PracticeReplay.cs | 75 +++++++++++++++++-- apps/utility-sw/src/UtilityPracticePlugin.cs | 1 + 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 83e1a22f..6e8977f4 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -83,6 +83,10 @@ private class Ghost // anything heavier. private const float MarkerWidth = 0.6f; + // Coarser circles for the lineups that are not the focused one: at a dozen + // on screen the difference is invisible and the entity count is not. + private const int QuietSegments = 10; + // 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); @@ -128,6 +132,11 @@ ILogger logger // 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. public Func WantsGhosts { get; set; } = _ => true; @@ -182,7 +191,14 @@ public void Load(IPlayer player, LineupRecord lineup) standing = new Vec3(landed.X, landed.Y, landed.Z); } - ShowMarkers(lineup, standing); + // Everything on the map, with this one in focus. + IReadOnlyList everything = All(player.SteamID); + + ShowAllMarkers( + everything.Count > 0 ? everything : new[] { lineup }, + lineup, + standing + ); }); player.SendCenter(Describe(lineup)); @@ -817,14 +833,57 @@ public void ShowMarkersFor(IPlayer player, LineupRecord lineup) } Vector feet = pawn.AbsOrigin ?? new Vector(0, 0, 0); + IReadOnlyList everything = All(player.SteamID); - ShowMarkers(lineup, new Vec3(feet.X, feet.Y, feet.Z)); + ShowAllMarkers( + everything.Count > 0 ? everything : new[] { lineup }, + lineup, + new Vec3(feet.X, feet.Y, feet.Z) + ); } - private void ShowMarkers(LineupRecord lineup, Vec3 stance) + // 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. + public void ShowAllMarkers( + IEnumerable lineups, + LineupRecord? focused, + Vec3 focusedStance + ) { ClearMarkers(); + foreach (LineupRecord lineup in lineups) + { + if (focused != null && lineup.client_id == focused.client_id) + { + continue; + } + + Color quiet = ColorFor(lineup.utility_type); + Vec3 feet = Grounded(lineup.release.feet_position); + + Ring(feet, 18f, quiet, MarkerWidth, QuietSegments); + Ring(lineup.detonation_position, 26f, quiet, MarkerWidth, QuietSegments); + AddMarkerBeam(feet, lineup.detonation_position, quiet, 0.3f); + Label(new Vec3(feet.x, feet.y, feet.z + 10f), lineup.name, quiet); + } + + if (focused != null) + { + ShowMarkers(focused, focusedStance, false); + } + } + + private void ShowMarkers(LineupRecord lineup, Vec3 stance, bool clear = true) + { + if (clear) + { + ClearMarkers(); + } + // 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. @@ -1060,9 +1119,15 @@ private static Vec3 Normalize(Vec3 v) return length < 0.0001f ? v : new Vec3(v.x / length, v.y / length, v.z / length); } - private void Ring(Vec3 center, float radius, Color color, float width) + private void Ring( + Vec3 center, + float radius, + Color color, + float width, + int segments = 20 + ) { - const int Segments = 20; + int Segments = segments; for (int index = 0; index < Segments; index++) { diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 0d6faa72..a9216aac 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -94,6 +94,7 @@ public override void Load(bool hotReload) _replay.IsSolo = _system.IsSolo; _replay.WantsGhosts = _system.WantsGhosts; + _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; From 2e85557d5d4c08996e56438f422c514bb19a7f8d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 15:46:52 -0400 Subject: [PATCH 035/107] mark the exact aim point with a crosshair --- .../utility-sw/src/Services/PracticeReplay.cs | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 6e8977f4..52b8e0b2 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -979,7 +979,9 @@ private void AimReticle(LineupRecord lineup, Vec3 stance) // Sized by distance so it looks the same from the stance whether the // wall is ten units away or two thousand. - float size = Math.Clamp(away * 0.09f, 18f, 60f); + // 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, 40f); // 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 @@ -1015,18 +1017,29 @@ Vec3 Corner(float x, float y) => center.z + right.z * x + up.z * y ); - Vec3 topLeft = Corner(-size, size); - Vec3 topRight = Corner(size, size); - Vec3 bottomRight = Corner(size, -size); - Vec3 bottomLeft = Corner(-size, -size); + // 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(topLeft, topRight, color, width); - AddMarkerBeam(topRight, bottomRight, color, width); - AddMarkerBeam(bottomRight, bottomLeft, color, width); - AddMarkerBeam(bottomLeft, topLeft, color, width); + 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); - const int Segments = 20; - float radius = size * 0.45f; + // 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); + + // A faint outer ring so the thing can be FOUND from across the map. + // Deliberately thin and wide: it draws the eye without competing with + // the centre for it. + const int Segments = 16; + float radius = size; for (int index = 0; index < Segments; index++) { @@ -1037,7 +1050,7 @@ Vec3 Corner(float x, float y) => Corner((float)Math.Cos(a) * radius, (float)Math.Sin(a) * radius), Corner((float)Math.Cos(b) * radius, (float)Math.Sin(b) * radius), color, - width + width * 0.5f ); } } From ec5539e8db747eb6a1061d59432c3aec22b05152 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 15:52:12 -0400 Subject: [PATCH 036/107] light up every throw from the spot you step into --- .../utility-sw/src/Services/PracticeReplay.cs | 104 ++++++++++++++---- apps/utility-sw/src/UtilityPracticePlugin.cs | 71 ++++++++++++ 2 files changed, 155 insertions(+), 20 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 52b8e0b2..8974a7ae 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -72,7 +72,11 @@ private class Ghost // 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. - private const float AimFallbackRange = 420f; + // 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. @@ -194,11 +198,18 @@ public void Load(IPlayer player, LineupRecord lineup) // Everything on the map, with this one in focus. IReadOnlyList everything = All(player.SteamID); - ShowAllMarkers( - everything.Count > 0 ? everything : new[] { lineup }, - lineup, - standing - ); + 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); + } + + ShowAllMarkers(library, here, standing); }); player.SendCenter(Describe(lineup)); @@ -835,11 +846,18 @@ public void ShowMarkersFor(IPlayer player, LineupRecord lineup) Vector feet = pawn.AbsOrigin ?? new Vector(0, 0, 0); IReadOnlyList everything = All(player.SteamID); - ShowAllMarkers( - everything.Count > 0 ? everything : new[] { lineup }, - lineup, - new Vec3(feet.X, feet.Y, feet.Z) - ); + 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); + } + + ShowAllMarkers(library, here, standing); } // Every lineup on the map at once. Loading one and cycling with .next hides @@ -847,17 +865,27 @@ public void ShowMarkersFor(IPlayer player, LineupRecord lineup) // 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; + + // 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, - LineupRecord? focused, - Vec3 focusedStance + IReadOnlyCollection active, + Vec3 stance ) { ClearMarkers(); + var live = new HashSet(active.Select(entry => entry.client_id)); + foreach (LineupRecord lineup in lineups) { - if (focused != null && lineup.client_id == focused.client_id) + if (live.Contains(lineup.client_id)) { continue; } @@ -871,12 +899,31 @@ Vec3 focusedStance Label(new Vec3(feet.x, feet.y, feet.z + 10f), lineup.name, quiet); } - if (focused != null) + foreach (LineupRecord lineup in active) { - ShowMarkers(focused, focusedStance, false); + ShowMarkers(lineup, stance, false); } } + // 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) <= 72f; + }) + .ToList(); + } + private void ShowMarkers(LineupRecord lineup, Vec3 stance, bool clear = true) { if (clear) @@ -904,14 +951,14 @@ private void ShowMarkers(LineupRecord lineup, Vec3 stance, bool clear = true) color ); - AimReticle(lineup, stance); + AimReticle(lineup, stance, 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, Vec3 stance) + private void AimReticle(LineupRecord lineup, Vec3 stance, string label) { // From where the player's eye WILL BE after .load, not from where the // grenade left the hand. Those stopped being the same point when the @@ -981,7 +1028,7 @@ private void AimReticle(LineupRecord lineup, Vec3 stance) // 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, 40f); + 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 @@ -991,7 +1038,15 @@ private void AimReticle(LineupRecord lineup, Vec3 stance) // this: you are always standing on them. float weight = Math.Clamp(away * 0.0018f, MarkerWidth, 2.2f); - Reticle(center, dir, size, AimColor, weight); + Reticle(center, dir, size, ColorFor(lineup.utility_type), weight); + + // Named at the crosshair itself: several throws off one spot are only + // useful if you can tell which crosshair belongs to which. + Label( + new Vec3(center.x, center.y, center.z + size + 8f), + label, + ColorFor(lineup.utility_type) + ); } // A box with a ring inside it, drawn in the plane facing back down the aim @@ -1214,6 +1269,15 @@ private void Label(Vec3 at, string text, Color color) } } + // 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() + { + _markerBeams.Clear(); + _markerTexts.Clear(); + } + public void ClearMarkers() { foreach (CEnvBeam beam in _markerBeams) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index a9216aac..9e8eccff 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -235,6 +235,13 @@ 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(); + } } // Dead on, not nearly. A tenth of a degree is inside one mouse count at @@ -252,11 +259,68 @@ private void OnGameTick() // 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; + 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); + + string key = string.Join(",", here.Select(entry => entry.client_id).OrderBy(id => id)); + + if (_standingIn.TryGetValue(player.SteamID, out string? was) && was == key) + { + continue; + } + + _standingIn[player.SteamID] = key; + + if (here.Count == 0) + { + continue; + } + + // Standing on a spot is the ask: show every throw off it at once, + // each crosshair named so they can be told apart. + _replay.ShowAllMarkers(library, here, at); + + player.SendCenter( + here.Count == 1 + ? here[0].name + : $"{here.Count} throws from here" + ); + } + } + private void AimFeedback() { if (++_aimTick % AimFeedbackEveryTicks != 0) @@ -535,6 +599,13 @@ private void OnMapLoad(string mapName) // A calibration is a statement about one map's collision mesh, so it // does not survive the mesh being replaced. _solver.Reset(); + + // Beams and world text do not survive a map change as anything useful: + // the handles go stale while the tracked lists still hold them, so + // nothing ever clears them and the next load draws on top of a list + // that can never be emptied. + _replay.ForgetMarkers(); + _library.SetMap(mapName); ApplyPracticeCfg(); From e23a5c35f41ebeb0e9a23a88a866bc829c09147f Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 15:55:43 -0400 Subject: [PATCH 037/107] carry the aim trace past the player to the wall it points at --- .../utility-sw/src/Services/PracticeReplay.cs | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 8974a7ae..33e0f6f5 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -69,6 +69,10 @@ private class Ghost // aim ray actually lands. private const float AimTraceRange = 4096f; + // A standing hull is 32 wide and 72 tall, so anything past ~48 units along + // the aim is outside it whichever way the player is facing. + private const float AimTraceStartOffset = 48f; + // 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. @@ -983,7 +987,15 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) (float)(-Math.Sin(pitch)) ); - var from = new Vector(eye.x, eye.y, eye.z); + // Started clear of the player's own hull. Markers are drawn while + // somebody is standing on the spot, and a trace beginning inside a + // player stops on that player -- which is why the crosshair landed a + // stride away instead of on the wall it is aimed at. + var from = new Vector( + eye.x + dir.x * AimTraceStartOffset, + eye.y + dir.y * AimTraceStartOffset, + eye.z + dir.z * AimTraceStartOffset + ); var to = new Vector( eye.x + dir.x * AimTraceRange, eye.y + dir.y * AimTraceRange, @@ -996,6 +1008,20 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) { var trace = _core.Trace.TraceShapeLine(from, to, null); + _logger.LogInformation( + "aim trace for {name}: hit={hit} at {x},{y},{z} ({dist} units out)", + lineup.name, + trace.DidHit, + trace.EndPos.X.ToString("0.#"), + trace.EndPos.Y.ToString("0.#"), + trace.EndPos.Z.ToString("0.#"), + new Vec3( + trace.EndPos.X - eye.x, + trace.EndPos.Y - eye.y, + trace.EndPos.Z - eye.z + ).Length().ToString("0.#") + ); + hit = trace.DidHit ? new Vec3(trace.EndPos.X, trace.EndPos.Y, trace.EndPos.Z) : new Vec3( From bf1185ed3fb236a810be644be352a861bce1f314 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 16:08:41 -0400 Subject: [PATCH 038/107] let the player point at the ring they mean --- apps/utility-sw/src/UtilityPracticePlugin.cs | 92 +++++++++++++++++--- 1 file changed, 82 insertions(+), 10 deletions(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 9e8eccff..fb1fd3c8 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -262,6 +262,11 @@ private void OnGameTick() // 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; + private int _aimTick; // Lining a crosshair up is the part of a lineup that cannot be shown by @@ -295,7 +300,26 @@ private void SpotWatch() IReadOnlyList library = _library.For(player.SteamID); List here = PracticeReplay.SpotAt(library, at); - string key = string.Join(",", here.Select(entry => entry.client_id).OrderBy(id => id)); + // 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 = LookingAtRing(pawn, at, library); + + // One throw off this spot needs no choosing. Several do, and until + // one is chosen nothing draws an aim point at all: a crosshair for + // the wrong throw is worse than none. + List show = + aimedAt != null + ? new List { aimedAt } + : here.Count == 1 + ? here + : new List(); + + string key = string.Join( + ",", + show.Select(entry => entry.client_id).OrderBy(id => id) + ); if (_standingIn.TryGetValue(player.SteamID, out string? was) && was == key) { @@ -304,21 +328,69 @@ private void SpotWatch() _standingIn[player.SteamID] = key; - if (here.Count == 0) + _replay.ShowAllMarkers(library, show, at); + + if (show.Count == 1) + { + player.SendCenter(show[0].name); + } + else if (here.Count > 1) + { + player.SendCenter($"{here.Count} throws here - look at a ring"); + } + } + } + + // 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. + private static LineupRecord? LookingAtRing( + CCSPlayerPawn pawn, + Vec3 at, + IReadOnlyList library + ) + { + QAngle eyes = pawn.EyeAngles; + + 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; + var toRing = new Vec3(feet.x - eye.x, feet.y - eye.y, feet.z - eye.z); + float length = toRing.Length(); + + if (length < 1f) { continue; } - // Standing on a spot is the ask: show every throw off it at once, - // each crosshair named so they can be told apart. - _replay.ShowAllMarkers(library, here, at); + double dot = + (view.x * toRing.x + view.y * toRing.y + view.z * toRing.z) / length; - player.SendCenter( - here.Count == 1 - ? here[0].name - : $"{here.Count} throws from here" - ); + 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() From 1c757d969da71a60a0f75aeec29c5589c07d1f6e Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 16:28:39 -0400 Subject: [PATCH 039/107] name what you are looking at and float the grenade over the spot --- .../utility-sw/src/Services/PracticeReplay.cs | 68 +++++++++++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 96 +++++++++++-------- .../PracticeLineupUtility.cs | 19 ++++ 3 files changed, 143 insertions(+), 40 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 33e0f6f5..14aecf6f 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -95,12 +95,17 @@ private class Ghost // on screen the difference is invisible and the entity count is not. private const int QuietSegments = 10; + // 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. + private const float UtilityModelHeight = 52f; + // 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); private readonly List _markerBeams = new(); private readonly List _markerTexts = new(); + private readonly List _markerProps = new(); // A real smoke emitted at the landing point: the only preview with perfect // fidelity, because it is the same cloud the throw would make. @@ -901,6 +906,7 @@ Vec3 stance Ring(lineup.detonation_position, 26f, quiet, MarkerWidth, QuietSegments); AddMarkerBeam(feet, lineup.detonation_position, quiet, 0.3f); Label(new Vec3(feet.x, feet.y, feet.z + 10f), lineup.name, quiet); + UtilityModel(lineup, feet); } foreach (LineupRecord lineup in active) @@ -947,6 +953,7 @@ private void ShowMarkers(LineupRecord lineup, Vec3 stance, bool clear = true) $"STAND\n{lineup.name}", color ); + UtilityModel(lineup, stance); Ring(landing, 34f, color, MarkerWidth); Label( @@ -1187,6 +1194,57 @@ private Vec3 Grounded(Vec3 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. + private void UtilityModel(LineupRecord lineup, Vec3 stance) + { + string? model = PracticeLineupUtility.ModelForUtilityType(lineup.utility_type); + + if (model == null) + { + return; + } + + try + { + CDynamicProp prop = + _core.EntitySystem.CreateEntityByDesignerName( + "prop_dynamic" + ); + + if (!prop.IsValid) + { + return; + } + + prop.Teleport( + new Vector(stance.x, stance.y, stance.z + UtilityModelHeight), + new QAngle(0, 0, 0), + new Vector(0, 0, 0) + ); + + prop.DispatchSpawn(); + + _markerProps.Add(prop); + + // Deferred a tick, the same way the match plugin sets a player + // model: a model assigned in the same frame as the spawn does not + // take. + _core.Scheduler.NextTick(() => + { + if (prop.IsValid) + { + prop.SetModel(model); + } + }); + } + 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); @@ -1302,6 +1360,7 @@ public void ForgetMarkers() { _markerBeams.Clear(); _markerTexts.Clear(); + _markerProps.Clear(); } public void ClearMarkers() @@ -1322,8 +1381,17 @@ public void ClearMarkers() } } + foreach (CDynamicProp prop in _markerProps) + { + if (prop.IsValid) + { + prop.Despawn(); + } + } + _markerBeams.Clear(); _markerTexts.Clear(); + _markerProps.Clear(); } private CEnvBeam? CreateBeam(Vec3 start, Vec3 end, Color color, float width) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index fb1fd3c8..353628a7 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -249,11 +249,6 @@ private void OnGameTick() // genuinely on the recorded angle rather than somewhere near it. private const float AimToleranceDegrees = 0.1f; - // Wide enough to be worth coaching towards. Between this and the tolerance - // the player gets the live error instead of silence, which is what lets - // them close the last fraction of a degree. - private const float AimGuideDegrees = 8f; - // 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. @@ -330,13 +325,13 @@ private void SpotWatch() _replay.ShowAllMarkers(library, show, at); - if (show.Count == 1) - { - player.SendCenter(show[0].name); - } - else if (here.Count > 1) + // 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) { - player.SendCenter($"{here.Count} throws here - look at a ring"); + _system.StateFor(player.SteamID).Loaded = aimedAt; } } } @@ -393,6 +388,10 @@ IReadOnlyList library return best; } + // One owner for centre text, resolved fresh every pass and re-sent so it + // persists -- centre text decays, and the old split let SpotWatch write + // "what is here" once and lose it a second later while AimFeedback kept + // re-sending angles over the top. private void AimFeedback() { if (++_aimTick % AimFeedbackEveryTicks != 0) @@ -407,57 +406,74 @@ private void AimFeedback() continue; } - LineupRecord? lineup = _system.StateFor(player.SteamID).Loaded; CCSPlayerPawn? pawn = player.PlayerPawn; - if (lineup == null || pawn == null || !pawn.IsValid) + if (pawn == null || !pawn.IsValid) { continue; } - QAngle eyes = pawn.EyeAngles; - - float yawOff = AngleGap(eyes.Y, lineup.release.yaw); - float pitchOff = AngleGap(eyes.X, lineup.release.pitch); + string? message = CentreText(player, pawn); - if (yawOff <= AimToleranceDegrees && pitchOff <= AimToleranceDegrees) + if (message != null) { - player.SendCenter(PracticeReplay.ThrowHint(lineup)); - continue; + player.SendCenter(message); } + } + } + + // 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. + private string? CentreText(IPlayer player, CCSPlayerPawn pawn) + { + LineupRecord? lineup = _system.StateFor(player.SteamID).Loaded; + + if (lineup != null) + { + QAngle eyes = pawn.EyeAngles; - if (yawOff > AimGuideDegrees || pitchOff > AimGuideDegrees) + if ( + AngleGap(eyes.Y, lineup.release.yaw) <= AimToleranceDegrees + && AngleGap(eyes.X, lineup.release.pitch) <= AimToleranceDegrees + ) { - continue; + return PracticeReplay.ThrowHint(lineup); } + } - // Signed, and named by the direction the crosshair has to travel, - // because "0.4 degrees off" does not tell anyone which way to move. - float yawDelta = Signed(lineup.release.yaw - eyes.Y); - float pitchDelta = Signed(lineup.release.pitch - eyes.X); + Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); + var at = new Vec3(origin.X, origin.Y, origin.Z); - player.SendCenter( - $"{(yawDelta >= 0 ? "LEFT" : "RIGHT")} {Math.Abs(yawDelta):0.00}°" - + $" {(pitchDelta >= 0 ? "DOWN" : "UP")} {Math.Abs(pitchDelta):0.00}°" - ); + IReadOnlyList library = _library.For(player.SteamID); + LineupRecord? aimedAt = LookingAtRing(pawn, at, library); + + if (aimedAt != null) + { + return Describe(aimedAt); } - } - // Shortest signed way round the circle: -180..180. - private static float Signed(float degrees) - { - float wrapped = degrees % 360f; + List here = PracticeReplay.SpotAt(library, at); - if (wrapped > 180f) + if (here.Count == 1) { - wrapped -= 360f; + return Describe(here[0]); } - else if (wrapped < -180f) + + // Several throws off one spot: name them all rather than a count, so the + // player knows what is on offer before choosing one. + if (here.Count > 1) { - wrapped += 360f; + return string.Join("\n", here.Select(entry => entry.name)); } - return wrapped; + return null; + } + + private static string Describe(LineupRecord lineup) + { + return $"{lineup.name}\n{lineup.utility_type.ToUpperInvariant()} - " + + lineup.technique.ToUpperInvariant(); } // Shortest way round the circle, so 359 and 1 are two degrees apart. diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index 63ae35d5..df3e1193 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -28,6 +28,25 @@ public static class PracticeLineupUtility { "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", "models/weapons/w_eq_smokegrenade_dropped.vmdl" }, + { "Flash", "models/weapons/w_eq_flashbang_dropped.vmdl" }, + { "HighExplosive", "models/weapons/w_eq_fraggrenade_dropped.vmdl" }, + { "Molotov", "models/weapons/w_eq_molotov_dropped.vmdl" }, + { "Decoy", "models/weapons/w_eq_decoy_dropped.vmdl" }, + }; + + public static string? ModelForUtilityType(string utilityType) + { + return UtilityToModel.TryGetValue(utilityType, out string? model) + ? model + : null; + } + private static readonly HashSet GrenadeWeapons = new() { From 9e3c9e62864854f9034f0c34c21bff935041ec71 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 16:33:57 -0400 Subject: [PATCH 040/107] give each player their own selection markers --- .../utility-sw/src/Services/PracticeReplay.cs | 249 +++++++++++++++++- apps/utility-sw/src/UtilityPracticePlugin.cs | 9 +- 2 files changed, 243 insertions(+), 15 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 14aecf6f..bdcaea67 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -103,10 +103,28 @@ private class Ghost // 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(); + } + + private readonly Dictionary _selections = new(); + + // Which list the drawing helpers append to. Null means the shared layer. + private Selection? _drawingInto; + // 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(); @@ -218,7 +236,8 @@ public void Load(IPlayer player, LineupRecord lineup) here.Add(lineup); } - ShowAllMarkers(library, here, standing); + ShowLibrary(library); + ShowSelection(player, here, standing); }); player.SendCenter(Describe(lineup)); @@ -866,7 +885,8 @@ public void ShowMarkersFor(IPlayer player, LineupRecord lineup) here.Add(lineup); } - ShowAllMarkers(library, here, standing); + ShowLibrary(library); + ShowSelection(player, here, standing); } // Every lineup on the map at once. Loading one and cycling with .next hides @@ -888,17 +908,21 @@ public void ShowAllMarkers( Vec3 stance ) { - ClearMarkers(); + ShowLibrary(lineups); + ShowSelection(null, active, stance); + } - var live = new HashSet(active.Select(entry => entry.client_id)); + // 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. + public void ShowLibrary(IEnumerable lineups) + { + ClearSharedMarkers(); + + _drawingInto = null; foreach (LineupRecord lineup in lineups) { - if (live.Contains(lineup.client_id)) - { - continue; - } - Color quiet = ColorFor(lineup.utility_type); Vec3 feet = Grounded(lineup.release.feet_position); @@ -908,11 +932,145 @@ Vec3 stance Label(new Vec3(feet.x, feet.y, feet.z + 10f), lineup.name, quiet); UtilityModel(lineup, feet); } + } + + // 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 + ) + { + if (owner == null) + { + _drawingInto = null; + + foreach (LineupRecord lineup in active) + { + ShowMarkers(lineup, stance, false); + } + + return; + } + + ClearSelection(owner.SteamID); + + if (active.Count == 0) + { + return; + } + + var selection = new Selection(); + + _selections[owner.SteamID] = selection; + _drawingInto = selection; + + try + { + foreach (LineupRecord lineup in active) + { + ShowMarkers(lineup, stance, false); + } + } + finally + { + _drawingInto = null; + } + + ApplySelectionVisibility(); + } + + // A selection belongs to one viewer, so it is blocked for everybody else. + private void ApplySelectionVisibility() + { + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) + { + if (viewer == null || !viewer.IsValid || viewer.IsFakeClient) + { + continue; + } + + foreach ((ulong owner, Selection selection) in _selections) + { + bool hidden = owner != viewer.SteamID; + + foreach (CEnvBeam beam in selection.Beams) + { + if (beam.IsValid) + { + viewer.ShouldBlockTransmitEntity((int)beam.Index, hidden); + } + } + + foreach (CPointWorldText text in selection.Texts) + { + if (text.IsValid) + { + viewer.ShouldBlockTransmitEntity((int)text.Index, hidden); + } + } + } + } + } + + public void ClearSelectionFor(ulong steamId) + { + ClearSelection(steamId); + } + + private void ClearSelection(ulong steamId) + { + if (!_selections.TryGetValue(steamId, out Selection? selection)) + { + return; + } + + // Entity indices are reused, so a standing block has to be lifted + // BEFORE the entity goes away or it lands on whatever takes its slot. + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) + { + if (viewer == null || !viewer.IsValid || viewer.IsFakeClient) + { + continue; + } + + foreach (CEnvBeam beam in selection.Beams) + { + if (beam.IsValid) + { + viewer.ShouldBlockTransmitEntity((int)beam.Index, false); + } + } + + foreach (CPointWorldText text in selection.Texts) + { + if (text.IsValid) + { + viewer.ShouldBlockTransmitEntity((int)text.Index, false); + } + } + } - foreach (LineupRecord lineup in active) + foreach (CEnvBeam beam in selection.Beams) { - ShowMarkers(lineup, stance, false); + 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 @@ -1249,7 +1407,16 @@ private void AddMarkerBeam(Vec3 start, Vec3 end, Color color, float width) { CEnvBeam? beam = CreateBeam(start, end, color, width); - if (beam != null) + if (beam == null) + { + return; + } + + if (_drawingInto != null) + { + _drawingInto.Beams.Add(beam); + } + else { _markerBeams.Add(beam); } @@ -1303,7 +1470,14 @@ private void Ring( if (beam != null) { - _markerBeams.Add(beam); + if (_drawingInto != null) + { + _drawingInto.Beams.Add(beam); + } + else + { + _markerBeams.Add(beam); + } } } } @@ -1345,7 +1519,14 @@ private void Label(Vec3 at, string text, Color color) label.DispatchSpawn(); - _markerTexts.Add(label); + if (_drawingInto != null) + { + _drawingInto.Texts.Add(label); + } + else + { + _markerTexts.Add(label); + } } catch (Exception error) { @@ -1361,10 +1542,50 @@ public void ForgetMarkers() _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 (CDynamicProp 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) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 353628a7..8bc0fe3c 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -323,7 +323,8 @@ private void SpotWatch() _standingIn[player.SteamID] = key; - _replay.ShowAllMarkers(library, show, at); + // The library is already drawn; only this player's selection moves. + _replay.ShowSelection(player, show, at); // Looking at a ring IS choosing it: the crosshair, the name and the // angular guidance have to describe one throw, and they read the @@ -662,6 +663,12 @@ 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); } // Swiftly's client events carry a slot, not a steam id. From 4850f3ea6e4ee8583ac5cd6db4f9bd5b248332b9 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 16:36:35 -0400 Subject: [PATCH 041/107] add a picker for the throws on one spot --- apps/utility-sw/src/Commands/Practice.cs | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 72270267..edea2753 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -2,6 +2,7 @@ using FiveStack.Enums; using FiveStack.Utilities; using SwiftlyS2.Shared; +using SwiftlyS2.Core.Menus.OptionsBase; using SwiftlyS2.Shared.Commands; using SwiftlyS2.Shared.Natives; using SwiftlyS2.Shared.Players; @@ -412,6 +413,70 @@ public void OnSkip(ICommandContext context) // Prints where the player actually is next to what the loaded lineup says, // because every disagreement so far has been between those two numbers and // no amount of looking at the marker tells you which one is wrong. + // A list you choose from, for the case looking at a ring cannot settle: + // several throws off one spot, stacked closely enough that pointing at one + // is fiddly. Deliberately a command rather than something that opens itself + // -- the menu binds shift and e, which are walk and use, and a menu that + // appeared when you stepped onto a spot would eat walk-throws. + [Command("pick", registerRaw: false, permission: "")] + public void OnPick(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return; + } + + 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); + + if (here.Count == 0) + { + Reply(context, $" {ChatColors.Red}nothing to pick from where you are standing"); + return; + } + + var builder = Core.MenusAPI.CreateBuilder(); + + builder.Design.SetMenuTitle($"{here.Count} throws from here"); + // Never frozen: this is a practice server and the player may well want + // to step off the spot to dismiss it. + builder.SetPlayerFrozen(false); + builder.EnableExit(); + + foreach (LineupRecord lineup in here) + { + LineupRecord chosen = lineup; + + var option = new ButtonMenuOption(lineup.name) + { + Comment = $"{lineup.utility_type} - {lineup.technique}", + CloseAfterClick = true, + }; + + option.Click += (_, _) => + { + Apply(player, chosen); + return ValueTask.CompletedTask; + }; + + builder.AddOption(option); + } + + Core.MenusAPI.OpenMenuForPlayer(player, builder.Build()); + } + [Command("where", registerRaw: false, permission: "")] public void OnWhere(ICommandContext context) { @@ -819,6 +884,7 @@ public void OnRefresh(ICommandContext context) $" {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}.pick {ChatColors.Grey}choose between the throws on this spot", $" {ChatColors.Default}.where {ChatColors.Grey}where you are vs what the loaded lineup says", $" {ChatColors.Default}.spawn {ChatColors.Grey}teleports to a spawn point", $" {ChatColors.Default}.bloom {ChatColors.Grey}outlines where the loaded smoke lands", From ef5b4d81f6afb294531db648dadd22e2d71ea738 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 16:40:34 -0400 Subject: [PATCH 042/107] precache the grenade models so they are not error models --- apps/utility-sw/src/UtilityPracticePlugin.cs | 20 +++++++++++++++++++ .../PracticeLineupUtility.cs | 9 +++++++++ 2 files changed, 29 insertions(+) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 8bc0fe3c..bf61856b 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -1,5 +1,6 @@ using System.Reflection; using FiveStack.Entities.Practice; +using FiveStack.Utilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SwiftlyS2.Shared; @@ -47,6 +48,7 @@ public partial class UtilityPracticePlugin : BasePlugin 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) @@ -128,6 +130,19 @@ public override void Load(bool hotReload) _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, @@ -195,6 +210,11 @@ public override void Unload() if (_disconnectHandler != null) { Core.Event.OnClientDisconnected -= _disconnectHandler; + + if (_precacheHandler != null) + { + Core.Event.OnPrecacheResource -= _precacheHandler; + } } if (_authorizeHandler != null) diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index df3e1193..2acbea8a 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -40,6 +40,15 @@ public static class PracticeLineupUtility { "Decoy", "models/weapons/w_eq_decoy_dropped.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; + } + public static string? ModelForUtilityType(string utilityType) { return UtilityToModel.TryGetValue(utilityType, out string? model) From d3f37aee868dca5354236a1caeccdf61b86d4e3e Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 16:44:15 -0400 Subject: [PATCH 043/107] pick the throw you are looking toward and hang its label the right way up --- .../utility-sw/src/Services/PracticeReplay.cs | 38 ++++++++-- apps/utility-sw/src/UtilityPracticePlugin.cs | 70 ++++++++++++++++--- 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index bdcaea67..abbc34ed 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -1236,7 +1236,8 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) Label( new Vec3(center.x, center.y, center.z + size + 8f), label, - ColorFor(lineup.utility_type) + ColorFor(lineup.utility_type), + eye ); } @@ -1482,7 +1483,10 @@ private void Ring( } } - private void Label(Vec3 at, string text, Color color) + // 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 void Label(Vec3 at, string text, Color color, Vec3? facing = null) { try { @@ -1507,13 +1511,35 @@ private void Label(Vec3 at, string text, Color color) .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; + var angle = new QAngle(0, 0, 0); + + if (facing == null) + { + // Spins to face whoever is reading it. Fine on the floor. + label.ReorientMode = PointWorldTextReorientMode_t + .POINT_WORLD_TEXT_REORIENT_AROUND_UP; + } + else + { + // Aimed by hand instead. Auto-reorient was leaving wall labels + // rolled onto their side -- text running bottom to top -- so + // the angle is set explicitly and the roll pinned flat. + float dx = facing.Value.x - at.x; + float dy = facing.Value.y - at.y; + + float yaw = (float)(Math.Atan2(dy, dx) * 180.0 / Math.PI); + + // point_worldtext reads along its own +X, so it has to be + // turned to face the reader rather than away from them. + angle = new QAngle(0, yaw + 180f, 0); + + label.ReorientMode = PointWorldTextReorientMode_t + .POINT_WORLD_TEXT_REORIENT_NONE; + } label.Teleport( new Vector(at.x, at.y, at.z), - new QAngle(0, 0, 0), + angle, new Vector(0, 0, 0) ); diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index bf61856b..c3eefc03 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -282,6 +282,15 @@ private void OnGameTick() // 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 @@ -319,7 +328,7 @@ private void SpotWatch() // 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 = LookingAtRing(pawn, at, library); + LineupRecord? aimedAt = LookingAt(pawn, at, library, here); // One throw off this spot needs no choosing. Several do, and until // one is chosen nothing draws an aim point at all: a crosshair for @@ -360,14 +369,56 @@ private void SpotWatch() // 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. - private static LineupRecord? LookingAtRing( + // 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 library, + IReadOnlyList here ) { QAngle eyes = pawn.EyeAngles; + if (here.Count > 0) + { + LineupRecord? bestAim = null; + float bestOff = AimPickDegrees; + + foreach (LineupRecord lineup in here) + { + float off = Math.Max( + AngleGap(eyes.Y, lineup.release.yaw), + AngleGap(eyes.X, 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); @@ -389,7 +440,9 @@ IReadOnlyList library var toRing = new Vec3(feet.x - eye.x, feet.y - eye.y, feet.z - eye.z); float length = toRing.Length(); - if (length < 1f) + // A ring you are standing on is straight down from the eye, which + // is never what "looking at" means. + if (length < RingHoverMinDistance) { continue; } @@ -409,10 +462,6 @@ IReadOnlyList library return best; } - // One owner for centre text, resolved fresh every pass and re-sent so it - // persists -- centre text decays, and the old split let SpotWatch write - // "what is here" once and lose it a second later while AimFeedback kept - // re-sending angles over the top. private void AimFeedback() { if (++_aimTick % AimFeedbackEveryTicks != 0) @@ -467,15 +516,14 @@ private void AimFeedback() var at = new Vec3(origin.X, origin.Y, origin.Z); IReadOnlyList library = _library.For(player.SteamID); - LineupRecord? aimedAt = LookingAtRing(pawn, at, library); + List here = PracticeReplay.SpotAt(library, at); + LineupRecord? aimedAt = LookingAt(pawn, at, library, here); if (aimedAt != null) { return Describe(aimedAt); } - List here = PracticeReplay.SpotAt(library, at); - if (here.Count == 1) { return Describe(here[0]); From cceb24cff8d57428579d14fb1f04c394029e1997 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 16:53:48 -0400 Subject: [PATCH 044/107] set the model before spawning and learn the real path from a thrown grenade --- .../src/Services/PracticeRecorder.cs | 6 +++++ .../utility-sw/src/Services/PracticeReplay.cs | 24 +++++++++---------- .../PracticeLineupUtility.cs | 21 ++++++++++++++++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeRecorder.cs b/apps/utility-sw/src/Services/PracticeRecorder.cs index ec83250b..5bbd3942 100644 --- a/apps/utility-sw/src/Services/PracticeRecorder.cs +++ b/apps/utility-sw/src/Services/PracticeRecorder.cs @@ -379,6 +379,12 @@ public void OnProjectileCreated(CEntityInstance entity) 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; diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index abbc34ed..fd7d3f82 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -1367,9 +1367,12 @@ private void UtilityModel(LineupRecord lineup, Vec3 stance) try { + // prop_dynamic_override, and the model set BEFORE DispatchSpawn. + // Both matter: a prop spawned first is networked with no model and + // stays the ERROR model however late the real one is assigned. CDynamicProp prop = _core.EntitySystem.CreateEntityByDesignerName( - "prop_dynamic" + "prop_dynamic_override" ); if (!prop.IsValid) @@ -1377,6 +1380,14 @@ private void UtilityModel(LineupRecord lineup, Vec3 stance) return; } + prop.SetModel(model); + + _logger.LogInformation( + "utility marker model for {type}: {model}", + lineup.utility_type, + model + ); + prop.Teleport( new Vector(stance.x, stance.y, stance.z + UtilityModelHeight), new QAngle(0, 0, 0), @@ -1386,17 +1397,6 @@ private void UtilityModel(LineupRecord lineup, Vec3 stance) prop.DispatchSpawn(); _markerProps.Add(prop); - - // Deferred a tick, the same way the match plugin sets a player - // model: a model assigned in the same frame as the spawn does not - // take. - _core.Scheduler.NextTick(() => - { - if (prop.IsValid) - { - prop.SetModel(model); - } - }); } catch (Exception error) { diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index 2acbea8a..7299ae2d 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -49,8 +49,29 @@ public static IEnumerable AllUtilityModels() return UtilityToModel.Values; } + // Learned at runtime from a real projectile. The hard-coded table below is + // a starting guess carried over from CS:GO's naming, and a wrong path shows + // as the ERROR model -- so the first time anybody actually throws a smoke, + // the engine's own answer replaces the guess for good. + 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; From 0079090505cf59e05e25750eefd0af5caa59d66d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 17:01:47 -0400 Subject: [PATCH 045/107] keep the practice trajectory trail up long enough to walk back to it --- apps/utility-css/src/UtilityPracticePlugin.cs | 3 +++ apps/utility-sw/src/UtilityPracticePlugin.cs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/utility-css/src/UtilityPracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs index 38a45526..5fe32494 100644 --- a/apps/utility-css/src/UtilityPracticePlugin.cs +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -381,6 +381,9 @@ private void RefreshEverything() "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. diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index c3eefc03..79904a09 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -862,6 +862,9 @@ private void OnSessionRefreshed(PracticeSessionData session) "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. From c2c9ae04c2027fc4be9a1e9aa0cdcde000e46c59 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 17:07:29 -0400 Subject: [PATCH 046/107] show every throw from a spot and weight the one you are looking toward --- .../utility-sw/src/Services/PracticeReplay.cs | 56 +++++++++++++------ apps/utility-sw/src/UtilityPracticePlugin.cs | 21 ++++--- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index fd7d3f82..fe4758bf 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -108,7 +108,7 @@ private class Ghost // where the lineups are. private readonly List _markerBeams = new(); private readonly List _markerTexts = new(); - private readonly List _markerProps = 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 @@ -125,6 +125,11 @@ private class Selection // 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(); @@ -941,9 +946,12 @@ public void ShowLibrary(IEnumerable lineups) public void ShowSelection( IPlayer? owner, IReadOnlyCollection active, - Vec3 stance + Vec3 stance, + LineupRecord? focused = null ) { + _focused = focused; + if (owner == null) { _drawingInto = null; @@ -1229,6 +1237,18 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) // this: you are always standing on them. float weight = Math.Clamp(away * 0.0018f, MarkerWidth, 2.2f); + bool isFocused = + _focused == null || _focused.client_id == lineup.client_id; + + // A sibling throw is drawn, but quietly: half the weight and two thirds + // the size, so the one being looked toward reads as the answer without + // the others disappearing. + if (!isFocused) + { + Reticle(center, dir, size * 0.66f, ColorFor(lineup.utility_type), weight * 0.5f); + return; + } + Reticle(center, dir, size, ColorFor(lineup.utility_type), weight); // Named at the crosshair itself: several throws off one spot are only @@ -1367,12 +1387,18 @@ private void UtilityModel(LineupRecord lineup, Vec3 stance) try { - // prop_dynamic_override, and the model set BEFORE DispatchSpawn. - // Both matter: a prop spawned first is networked with no model and - // stays the ERROR model however late the real one is assigned. - CDynamicProp prop = - _core.EntitySystem.CreateEntityByDesignerName( - "prop_dynamic_override" + // 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. + // + // The model is set BEFORE DispatchSpawn: a prop spawned first is + // networked with no model and stays the ERROR model however late + // the real one arrives. + CPhysicsProp prop = + _core.EntitySystem.CreateEntityByDesignerName( + "prop_physics_override" ); if (!prop.IsValid) @@ -1382,12 +1408,6 @@ private void UtilityModel(LineupRecord lineup, Vec3 stance) prop.SetModel(model); - _logger.LogInformation( - "utility marker model for {type}: {model}", - lineup.utility_type, - model - ); - prop.Teleport( new Vector(stance.x, stance.y, stance.z + UtilityModelHeight), new QAngle(0, 0, 0), @@ -1396,6 +1416,10 @@ private void UtilityModel(LineupRecord lineup, Vec3 stance) prop.DispatchSpawn(); + // 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); + _markerProps.Add(prop); } catch (Exception error) @@ -1592,7 +1616,7 @@ private void ClearSharedMarkers() } } - foreach (CDynamicProp prop in _markerProps) + foreach (CPhysicsProp prop in _markerProps) { if (prop.IsValid) { @@ -1628,7 +1652,7 @@ public void ClearMarkers() } } - foreach (CDynamicProp prop in _markerProps) + foreach (CPhysicsProp prop in _markerProps) { if (prop.IsValid) { diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 79904a09..4979f0f3 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -330,19 +330,22 @@ private void SpotWatch() // side you stepped in from. Pointing at a ring is unambiguous. LineupRecord? aimedAt = LookingAt(pawn, at, library, here); - // One throw off this spot needs no choosing. Several do, and until - // one is chosen nothing draws an aim point at all: a crosshair for - // the wrong throw is worse than none. + // 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 = - aimedAt != null - ? new List { aimedAt } - : here.Count == 1 - ? here + here.Count > 0 + ? here + : aimedAt != null + ? new List { aimedAt } : new List(); string key = string.Join( ",", - show.Select(entry => entry.client_id).OrderBy(id => id) + show.Select(entry => entry.client_id) + .OrderBy(id => id) + .Append(aimedAt?.client_id ?? "-") ); if (_standingIn.TryGetValue(player.SteamID, out string? was) && was == key) @@ -353,7 +356,7 @@ private void SpotWatch() _standingIn[player.SteamID] = key; // The library is already drawn; only this player's selection moves. - _replay.ShowSelection(player, show, at); + _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 From 6a5f35bc1cbdb2e42d67199306ccfc0119987781 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 17:17:48 -0400 Subject: [PATCH 047/107] spawn the lineup grenade model through keyvalues --- .../utility-sw/src/Services/PracticeReplay.cs | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index fe4758bf..e8f162b8 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -1,7 +1,9 @@ +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; @@ -1392,10 +1394,6 @@ private void UtilityModel(LineupRecord lineup, Vec3 stance) // 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. - // - // The model is set BEFORE DispatchSpawn: a prop spawned first is - // networked with no model and stays the ERROR model however late - // the real one arrives. CPhysicsProp prop = _core.EntitySystem.CreateEntityByDesignerName( "prop_physics_override" @@ -1406,15 +1404,26 @@ private void UtilityModel(LineupRecord lineup, Vec3 stance) return; } - prop.SetModel(model); - - prop.Teleport( - new Vector(stance.x, stance.y, stance.z + UtilityModelHeight), - new QAngle(0, 0, 0), - new Vector(0, 0, 0) + // 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(stance.x, stance.y, stance.z + UtilityModelHeight); + + 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(); + 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. From b3d34caed421636d8783e44cf2070fa91bbe5d82 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 17:21:13 -0400 Subject: [PATCH 048/107] ask for a trajectory as the player who wants it --- apps/utility-css/src/Commands/Practice.cs | 3 ++- apps/utility-css/src/Services/PracticeLibrary.cs | 4 ++-- apps/utility-css/src/Services/UtilityApiClient.cs | 4 ++-- apps/utility-sw/src/Commands/Practice.cs | 3 ++- apps/utility-sw/src/Services/PracticeLibrary.cs | 4 ++-- apps/utility-sw/src/Services/UtilityApiClient.cs | 4 ++-- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/utility-css/src/Commands/Practice.cs b/apps/utility-css/src/Commands/Practice.cs index 2112b7f7..f8c0d3d3 100644 --- a/apps/utility-css/src/Commands/Practice.cs +++ b/apps/utility-css/src/Commands/Practice.cs @@ -246,7 +246,7 @@ public void OnBloom(CCSPlayerController? player, CommandInfo command) ulong steamId = player.SteamID; // The same fetch .load already made, and free once it has landed. - _library.EnsureTrajectory(loaded, fetched => DrawBloom(steamId, fetched)); + _library.EnsureTrajectory(loaded, steamId, fetched => DrawBloom(steamId, fetched)); } [ConsoleCommand("css_playbook", "Runs the loaded execute: .playbook / .playbook stop")] @@ -816,6 +816,7 @@ private void Apply(CCSPlayerController player, LineupRecord lineup) _library.EnsureTrajectory( lineup, + steamId, fetched => { // The player may have loaded something else while the path was diff --git a/apps/utility-css/src/Services/PracticeLibrary.cs b/apps/utility-css/src/Services/PracticeLibrary.cs index 2ce6e65b..8712ec4e 100644 --- a/apps/utility-css/src/Services/PracticeLibrary.cs +++ b/apps/utility-css/src/Services/PracticeLibrary.cs @@ -71,7 +71,7 @@ public void Remove(ulong steamId, LineupRecord lineup) // 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, Action ready) + public void EnsureTrajectory(LineupRecord lineup, ulong steamId, Action ready) { if (lineup.trajectory.Count > 0 || string.IsNullOrEmpty(lineup.id)) { @@ -83,7 +83,7 @@ public void EnsureTrajectory(LineupRecord lineup, Action ready) _ = Task.Run(async () => { - UtilityTrajectoryArtifact? artifact = await _api.Trajectory(id); + UtilityTrajectoryArtifact? artifact = await _api.Trajectory(id, steamId); Server.NextFrame(() => { diff --git a/apps/utility-css/src/Services/UtilityApiClient.cs b/apps/utility-css/src/Services/UtilityApiClient.cs index 804fbb93..7b12428f 100644 --- a/apps/utility-css/src/Services/UtilityApiClient.cs +++ b/apps/utility-css/src/Services/UtilityApiClient.cs @@ -100,11 +100,11 @@ public int QueuedCount // A library row carries no flight path and no measured bloom, so both cost // one more call. - public async Task Trajectory(string id) + public async Task Trajectory(string id, ulong steamId) { byte[]? body = await Send( HttpMethod.Get, - $"/utility/{Uri.EscapeDataString(id)}/trajectory", + $"/utility/{Uri.EscapeDataString(id)}/trajectory?steam_id={steamId}", null ); diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index edea2753..d7a42011 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -299,7 +299,7 @@ public void OnBloom(ICommandContext context) ulong steamId = player.SteamID; // The same fetch .load already made, and free once it has landed. - _library.EnsureTrajectory(loaded, fetched => DrawBloom(steamId, fetched)); + _library.EnsureTrajectory(loaded, steamId, fetched => DrawBloom(steamId, fetched)); } [Command("playbook", registerRaw: false, permission: "")] @@ -1051,6 +1051,7 @@ private void Apply(IPlayer player, LineupRecord lineup) _library.EnsureTrajectory( lineup, + steamId, fetched => { // The player may have loaded something else while the path was diff --git a/apps/utility-sw/src/Services/PracticeLibrary.cs b/apps/utility-sw/src/Services/PracticeLibrary.cs index 2f5fb476..754e31a3 100644 --- a/apps/utility-sw/src/Services/PracticeLibrary.cs +++ b/apps/utility-sw/src/Services/PracticeLibrary.cs @@ -77,7 +77,7 @@ public void Remove(ulong steamId, LineupRecord lineup) // 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, Action ready) + public void EnsureTrajectory(LineupRecord lineup, ulong steamId, Action ready) { if (lineup.trajectory.Count > 0 || string.IsNullOrEmpty(lineup.id)) { @@ -89,7 +89,7 @@ public void EnsureTrajectory(LineupRecord lineup, Action ready) _ = Task.Run(async () => { - UtilityTrajectoryArtifact? artifact = await _api.Trajectory(id); + UtilityTrajectoryArtifact? artifact = await _api.Trajectory(id, steamId); _core.Scheduler.NextTick(() => { diff --git a/apps/utility-sw/src/Services/UtilityApiClient.cs b/apps/utility-sw/src/Services/UtilityApiClient.cs index 804fbb93..7b12428f 100644 --- a/apps/utility-sw/src/Services/UtilityApiClient.cs +++ b/apps/utility-sw/src/Services/UtilityApiClient.cs @@ -100,11 +100,11 @@ public int QueuedCount // A library row carries no flight path and no measured bloom, so both cost // one more call. - public async Task Trajectory(string id) + public async Task Trajectory(string id, ulong steamId) { byte[]? body = await Send( HttpMethod.Get, - $"/utility/{Uri.EscapeDataString(id)}/trajectory", + $"/utility/{Uri.EscapeDataString(id)}/trajectory?steam_id={steamId}", null ); From 6dfa6c0693b5bb430bd021dda14fcc8a7d031270 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 17:30:40 -0400 Subject: [PATCH 049/107] learn grenade models at map load and stop them blocking the spot --- .../utility-sw/src/Services/PracticeReplay.cs | 62 ++++++++++++++++++- apps/utility-sw/src/UtilityPracticePlugin.cs | 5 ++ .../PracticeLineupUtility.cs | 28 ++++++--- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index e8f162b8..ad7c41a4 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -99,7 +99,9 @@ private class Ghost // 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. - private const float UtilityModelHeight = 52f; + // Above standing eye height (64) on purpose. .load stands the player ON + // the ring, and at chest height the grenade is inside their camera. + private 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. @@ -1378,6 +1380,52 @@ private Vec3 Grounded(Vec3 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. + // FSOLID_NOT_SOLID. + private const byte NotSolid = 4; + + // Asks the engine what each grenade actually looks like, by spawning one of + // each weapon under the world for a single tick and reading the model back + // off it. Two problems in one: the path is right by construction rather + // than by guesswork, and it is precached by construction too -- the engine + // precaches every weapon at level init, which our own precache list, built + // from paths we invented, cannot promise. Without this nothing rendered + // until somebody threw the grenade, which is what taught it the path. + public void LearnUtilityModels() + { + foreach (KeyValuePair pair in PracticeLineupUtility.UtilityWeapons()) + { + try + { + CBaseModelEntity probe = + _core.EntitySystem.CreateEntityByDesignerName(pair.Value); + + if (!probe.IsValid) + { + continue; + } + + var keys = new CEntityKeyValues(); + + keys.SetString("origin", "0 0 -16384"); + keys.SetString("solid", "0"); + + probe.DispatchSpawn(keys); + + PracticeLineupUtility.LearnUtilityModel(pair.Key, probe.GetModel()); + + probe.Despawn(); + } + catch (Exception error) + { + _logger.LogWarning( + error, + "unable to read the world model for {weapon}", + pair.Value + ); + } + } + } + private void UtilityModel(LineupRecord lineup, Vec3 stance) { string? model = PracticeLineupUtility.ModelForUtilityType(lineup.utility_type); @@ -1429,6 +1477,18 @@ private void UtilityModel(LineupRecord lineup, Vec3 stance) // 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(); + _markerProps.Add(prop); } catch (Exception error) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 4979f0f3..111172ea 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -174,6 +174,7 @@ public override void Load(bool hotReload) { _library.SetMap(Core.Engine.GlobalVars.MapName.ToString()); ApplyPracticeCfg(); + _replay.LearnUtilityModels(); RefreshEverything(); } @@ -776,6 +777,10 @@ private void OnMapLoad(string mapName) ApplyPracticeCfg(); + // Next tick, not now: the entity system has nothing to spawn into + // while the map is still loading. + Core.Scheduler.NextTick(_replay.LearnUtilityModels); + RefreshEverything(); } diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index 7299ae2d..7db9765d 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -33,11 +33,11 @@ public static class PracticeLineupUtility private static readonly Dictionary UtilityToModel = new() { - { "Smoke", "models/weapons/w_eq_smokegrenade_dropped.vmdl" }, - { "Flash", "models/weapons/w_eq_flashbang_dropped.vmdl" }, - { "HighExplosive", "models/weapons/w_eq_fraggrenade_dropped.vmdl" }, - { "Molotov", "models/weapons/w_eq_molotov_dropped.vmdl" }, - { "Decoy", "models/weapons/w_eq_decoy_dropped.vmdl" }, + { "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 @@ -49,10 +49,11 @@ public static IEnumerable AllUtilityModels() return UtilityToModel.Values; } - // Learned at runtime from a real projectile. The hard-coded table below is - // a starting guess carried over from CS:GO's naming, and a wrong path shows - // as the ERROR model -- so the first time anybody actually throws a smoke, - // the engine's own answer replaces the guess for good. + // The engine's own answer, harvested at map load from a throwaway weapon + // entity of each type and topped up from any projectile that actually + // flies. The table above is only a fallback: CS2 renamed these out of + // CS:GO's models/weapons/w_eq_* scheme, and a path that is wrong by one + // character renders as the ERROR model with no other complaint. private static readonly Dictionary LearnedModels = new(); public static void LearnUtilityModel(string utilityType, string? model) @@ -93,6 +94,15 @@ public static void LearnUtilityModel(string utilityType, string? model) return ProjectileToUtility.TryGetValue(designerName, out string? type) ? type : null; } + // Type paired with the weapon that carries its model, for the map-load + // harvest. Spawning the real weapon is the only way to get a path that is + // certainly correct AND certainly precached -- our own precache list can + // only ever contain paths we guessed. + public static IEnumerable> UtilityWeapons() + { + return UtilityToWeapon; + } + public static string? WeaponForUtilityType(string utilityType) { return UtilityToWeapon.TryGetValue(utilityType, out string? weapon) ? weapon : null; From 25c0b3e7056d107eae261da2892da81560b430e9 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 17:35:01 -0400 Subject: [PATCH 050/107] one grenade model per spot, not per lineup --- .../test/PracticeLineupUtilityTests.cs | 60 ++++++++++++++++++ .../utility-sw/src/Services/PracticeReplay.cs | 61 ++++++++++++++----- .../test/PracticeLineupUtilityTests.cs | 60 ++++++++++++++++++ .../PracticeLineupUtility.cs | 46 ++++++++++++++ 4 files changed, 213 insertions(+), 14 deletions(-) diff --git a/apps/utility-css/test/PracticeLineupUtilityTests.cs b/apps/utility-css/test/PracticeLineupUtilityTests.cs index f493c9a3..a78eba2b 100644 --- a/apps/utility-css/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-css/test/PracticeLineupUtilityTests.cs @@ -138,3 +138,63 @@ 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)); + } +} diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index ad7c41a4..23717a64 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -99,6 +99,9 @@ private class Ghost // 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. private const float UtilityModelHeight = 80f; @@ -907,6 +910,9 @@ public void ShowMarkersFor(IPlayer player, LineupRecord lineup) // 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 @@ -939,7 +945,40 @@ public void ShowLibrary(IEnumerable lineups) Ring(lineup.detonation_position, 26f, quiet, MarkerWidth, QuietSegments); AddMarkerBeam(feet, lineup.detonation_position, quiet, 0.3f); Label(new Vec3(feet.x, feet.y, feet.z + 10f), lineup.name, quiet); - UtilityModel(lineup, feet); + } + + 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)); + } } } @@ -962,7 +1001,7 @@ public void ShowSelection( foreach (LineupRecord lineup in active) { - ShowMarkers(lineup, stance, false); + ShowMarkers(lineup, stance); } return; @@ -984,7 +1023,7 @@ public void ShowSelection( { foreach (LineupRecord lineup in active) { - ShowMarkers(lineup, stance, false); + ShowMarkers(lineup, stance); } } finally @@ -1099,18 +1138,13 @@ Vec3 at return new Vec3(feet.x - at.x, feet.y - at.y, 0f).LengthXY() <= SpotRadius - && Math.Abs(feet.z - at.z) <= 72f; + && Math.Abs(feet.z - at.z) <= SpotHeight; }) .ToList(); } - private void ShowMarkers(LineupRecord lineup, Vec3 stance, bool clear = true) + private void ShowMarkers(LineupRecord lineup, Vec3 stance) { - if (clear) - { - ClearMarkers(); - } - // 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. @@ -1123,7 +1157,6 @@ private void ShowMarkers(LineupRecord lineup, Vec3 stance, bool clear = true) $"STAND\n{lineup.name}", color ); - UtilityModel(lineup, stance); Ring(landing, 34f, color, MarkerWidth); Label( @@ -1426,9 +1459,9 @@ public void LearnUtilityModels() } } - private void UtilityModel(LineupRecord lineup, Vec3 stance) + private void UtilityModel(string utilityType, Vec3 at) { - string? model = PracticeLineupUtility.ModelForUtilityType(lineup.utility_type); + string? model = PracticeLineupUtility.ModelForUtilityType(utilityType); if (model == null) { @@ -1458,7 +1491,7 @@ private void UtilityModel(LineupRecord lineup, Vec3 stance) // 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(stance.x, stance.y, stance.z + UtilityModelHeight); + var origin = new Vector(at.x, at.y, at.z + UtilityModelHeight); keys.SetString("model", model); keys.SetString("solid", "0"); diff --git a/apps/utility-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs index f493c9a3..a78eba2b 100644 --- a/apps/utility-sw/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-sw/test/PracticeLineupUtilityTests.cs @@ -138,3 +138,63 @@ 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)); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index 7db9765d..c3b9dd5a 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -94,6 +94,52 @@ public static void LearnUtilityModel(string utilityType, string? model) return ProjectileToUtility.TryGetValue(designerName, out string? type) ? type : null; } + // 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; + } + // Type paired with the weapon that carries its model, for the map-load // harvest. Spawning the real weapon is the only way to get a path that is // certainly correct AND certainly precached -- our own precache list can From 04dd10f88e7e19eacd6051afa48fbf5e7203940b Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 17:46:42 -0400 Subject: [PATCH 051/107] colour the crosshair by how far off the aim is --- .../test/PracticeLineupUtilityTests.cs | 66 ++++++++++ .../utility-sw/src/Services/PracticeReplay.cs | 118 +++++++++++++++--- apps/utility-sw/src/UtilityPracticePlugin.cs | 38 +++--- .../test/PracticeLineupUtilityTests.cs | 66 ++++++++++ .../Practice/LineupRecord.cs | 6 + .../Practice/UtilityLibraryRow.cs | 3 + .../PracticeLineupUtility.cs | 45 +++++++ 7 files changed, 313 insertions(+), 29 deletions(-) diff --git a/apps/utility-css/test/PracticeLineupUtilityTests.cs b/apps/utility-css/test/PracticeLineupUtilityTests.cs index a78eba2b..8d64c7b9 100644 --- a/apps/utility-css/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-css/test/PracticeLineupUtilityTests.cs @@ -198,3 +198,69 @@ 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); + } + } +} diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 23717a64..979693c0 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -125,8 +125,24 @@ 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(); } + private class Aim + { + public LineupRecord Lineup = null!; + public readonly List Beams = new(); + public CPointWorldText? Label; + public float Miss = -1f; + } + + // The reticle currently being drawn, so its beams can be collected apart + // from the rest of the selection. + private Aim? _aimInto; + private readonly Dictionary _selections = new(); // Which list the drawing helpers append to. Null means the shared layer. @@ -1274,28 +1290,96 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) // this: you are always standing on them. float weight = Math.Clamp(away * 0.0018f, MarkerWidth, 2.2f); - bool isFocused = - _focused == null || _focused.client_id == lineup.client_id; + // 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; - // A sibling throw is drawn, but quietly: half the weight and two thirds - // the size, so the one being looked toward reads as the answer without - // the others disappearing. - if (!isFocused) + try { - Reticle(center, dir, size * 0.66f, ColorFor(lineup.utility_type), weight * 0.5f); - return; + Reticle(center, dir, size, MissColor(1f), weight); + } + finally + { + _aimInto = null; } - - Reticle(center, dir, size, ColorFor(lineup.utility_type), weight); // Named at the crosshair itself: several throws off one spot are only // useful if you can tell which crosshair belongs to which. - Label( + aim.Label = Label( new Vec3(center.x, center.y, center.z + size + 8f), label, - ColorFor(lineup.utility_type), + MissColor(1f), eye ); + + _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 crosshairs + // are already drawn, and all that changes is how wrong each one is. + public void TintAim(IPlayer player, float eyeYaw, float eyePitch) + { + if (!_selections.TryGetValue(player.SteamID, out Selection? selection)) + { + return; + } + + foreach (Aim aim in selection.Aims) + { + float miss = PracticeLineupUtility.AimMiss( + PracticeLineupUtility.AimError( + eyeYaw, + eyePitch, + aim.Lineup.release.yaw, + aim.Lineup.release.pitch + ), + aim.Lineup.aim_tolerance + ); + + // Beams are networked on change, so only send one when the colour + // would actually differ. At sixteen updates a second across every + // throw on a spot, repainting unconditionally is real traffic. + if (Math.Abs(miss - aim.Miss) < 0.02f) + { + continue; + } + + aim.Miss = miss; + + Color color = MissColor(miss); + + foreach (CEnvBeam beam in aim.Beams) + { + if (beam.IsValid) + { + beam.Render = color; + } + } + + if (aim.Label != null && aim.Label.IsValid) + { + aim.Label.Color = color; + } + } } // A box with a ring inside it, drawn in the plane facing back down the aim @@ -1539,6 +1623,8 @@ private void AddMarkerBeam(Vec3 start, Vec3 end, Color color, float width) return; } + _aimInto?.Beams.Add(beam); + if (_drawingInto != null) { _drawingInto.Beams.Add(beam); @@ -1612,7 +1698,7 @@ private void Ring( // 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 void Label(Vec3 at, string text, Color color, Vec3? facing = null) + private CPointWorldText? Label(Vec3 at, string text, Color color, Vec3? facing = null) { try { @@ -1623,7 +1709,7 @@ private void Label(Vec3 at, string text, Color color, Vec3? facing = null) if (!label.IsValid) { - return; + return null; } label.MessageText = text; @@ -1679,10 +1765,14 @@ private void Label(Vec3 at, string text, Color color, Vec3? facing = null) { _markerTexts.Add(label); } + + return label; } catch (Exception error) { _logger.LogError(error, "unable to place a lineup marker"); + + return null; } } diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 111172ea..21a0d57b 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -265,10 +265,14 @@ private void OnGameTick() } } - // Dead on, not nearly. A tenth of a degree is inside one mouse count at - // normal sensitivity, so this only reads LINED UP when the crosshair is - // genuinely on the recorded angle rather than somewhere near it. - private const float AimToleranceDegrees = 0.1f; + // 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 @@ -398,9 +402,11 @@ IReadOnlyList here foreach (LineupRecord lineup in here) { - float off = Math.Max( - AngleGap(eyes.Y, lineup.release.yaw), - AngleGap(eyes.X, lineup.release.pitch) + float off = PracticeLineupUtility.AimError( + eyes.Y, + eyes.X, + lineup.release.yaw, + lineup.release.pitch ); if (off < bestOff) @@ -487,6 +493,10 @@ private void AimFeedback() continue; } + QAngle eyes = pawn.EyeAngles; + + _replay.TintAim(player, eyes.Y, eyes.X); + string? message = CentreText(player, pawn); if (message != null) @@ -508,8 +518,12 @@ private void AimFeedback() QAngle eyes = pawn.EyeAngles; if ( - AngleGap(eyes.Y, lineup.release.yaw) <= AimToleranceDegrees - && AngleGap(eyes.X, lineup.release.pitch) <= AimToleranceDegrees + PracticeLineupUtility.AimError( + eyes.Y, + eyes.X, + lineup.release.yaw, + lineup.release.pitch + ) <= ToleranceFor(lineup) ) { return PracticeReplay.ThrowHint(lineup); @@ -550,12 +564,6 @@ private static string Describe(LineupRecord lineup) } // Shortest way round the circle, so 359 and 1 are two degrees apart. - private static float AngleGap(float a, float b) - { - float gap = Math.Abs(a - b) % 360f; - - return gap > 180f ? 360f - gap : gap; - } private void OnSecond() { diff --git a/apps/utility-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs index a78eba2b..8d64c7b9 100644 --- a/apps/utility-sw/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-sw/test/PracticeLineupUtilityTests.cs @@ -198,3 +198,69 @@ 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); + } + } +} diff --git a/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs b/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs index f45808bd..ccd0a2cd 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs @@ -35,6 +35,12 @@ public class LineupRecord 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 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. diff --git a/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs index d7665dfc..0be525a9 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs @@ -19,6 +19,8 @@ public class UtilityLibraryRow public string? throw_strength { get; set; } public bool? jump_throw_bind { get; set; } + public double? aim_tolerance { get; set; } + public float? origin_x { get; set; } public float? origin_y { get; set; } public float? origin_z { get; set; } @@ -64,6 +66,7 @@ public LineupRecord ToLineup() side = side ?? "TERRORIST", technique = technique ?? "", strength = throw_strength, + aim_tolerance = (float)(aim_tolerance ?? 0d), visibility = visibility ?? "Private", confidence = confidence, author_steam_id = author_steam_id ?? "", diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index c3b9dd5a..a7c36cdf 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -94,6 +94,51 @@ public static void LearnUtilityModel(string utilityType, string? model) 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); + } + + // 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 From 133892fd8ac048cc90e72f5783bc796865ce68c8 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 20:27:32 -0400 Subject: [PATCH 052/107] send the physics seed when saving a lineup --- apps/utility-css/src/Commands/Practice.cs | 60 +++++++++++++++++ apps/utility-css/test/UtilityWireTests.cs | 67 +++++++++++++++++++ apps/utility-sw/test/UtilityWireTests.cs | 67 +++++++++++++++++++ .../Practice/UtilityIngestPayload.cs | 25 +++++++ .../PracticeLineupUtility.cs | 24 +++++++ 5 files changed, 243 insertions(+) diff --git a/apps/utility-css/src/Commands/Practice.cs b/apps/utility-css/src/Commands/Practice.cs index f8c0d3d3..89f958d5 100644 --- a/apps/utility-css/src/Commands/Practice.cs +++ b/apps/utility-css/src/Commands/Practice.cs @@ -631,6 +631,66 @@ public void OnPracticeHelp(CCSPlayerController? player, CommandInfo command) } } + // 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")] diff --git a/apps/utility-css/test/UtilityWireTests.cs b/apps/utility-css/test/UtilityWireTests.cs index 0f5b0ebc..d769b089 100644 --- a/apps/utility-css/test/UtilityWireTests.cs +++ b/apps/utility-css/test/UtilityWireTests.cs @@ -499,3 +499,70 @@ public void NullsAreOmittedRatherThanSentAsNull() 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/test/UtilityWireTests.cs b/apps/utility-sw/test/UtilityWireTests.cs index 0f5b0ebc..d769b089 100644 --- a/apps/utility-sw/test/UtilityWireTests.cs +++ b/apps/utility-sw/test/UtilityWireTests.cs @@ -499,3 +499,70 @@ public void NullsAreOmittedRatherThanSentAsNull() 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/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs index 8f05e530..490043f9 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityIngestPayload.cs @@ -27,6 +27,18 @@ public class UtilityIngestPayload 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; } @@ -40,6 +52,11 @@ public class UtilityIngestPayload 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), @@ -61,6 +78,14 @@ public static UtilityIngestPayload From(LineupRecord lineup) 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, diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index a7c36cdf..cd4b9b13 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -254,6 +254,30 @@ public static string NormalizeUtilityType(string 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, From bf79b9b8262424144fdf39fdf2c50e482c141220 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 20:35:25 -0400 Subject: [PATCH 053/107] draw lineups the 5stack way --- apps/utility-sw/src/Commands/Practice.cs | 62 +++++ .../utility-sw/src/Services/PracticeReplay.cs | 254 +++++++++++++----- 2 files changed, 243 insertions(+), 73 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index d7a42011..7c011dfa 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -847,6 +847,68 @@ public void OnPracticeHelp(ICommandContext context) } } + // 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); + } + + 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: "")] diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 979693c0..acd720b3 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -93,9 +93,6 @@ private class Ghost // anything heavier. private const float MarkerWidth = 0.6f; - // Coarser circles for the lineups that are not the focused one: at a dozen - // on screen the difference is invisible and the entity count is not. - private const int QuietSegments = 10; // 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. @@ -954,13 +951,25 @@ public void ShowLibrary(IEnumerable lineups) foreach (LineupRecord lineup in lineups) { - Color quiet = ColorFor(lineup.utility_type); + Color type = ColorFor(lineup.utility_type); Vec3 feet = Grounded(lineup.release.feet_position); - Ring(feet, 18f, quiet, MarkerWidth, QuietSegments); - Ring(lineup.detonation_position, 26f, quiet, MarkerWidth, QuietSegments); - AddMarkerBeam(feet, lineup.detonation_position, quiet, 0.3f); - Label(new Vec3(feet.x, feet.y, feet.z + 10f), lineup.name, quiet); + // 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. + Chevron(feet, lineup.release.yaw, 13f, AmberDim, MarkerWidth); + AddMarkerBeam( + new Vec3(feet.x, feet.y, feet.z + 2f), + new Vec3(feet.x, feet.y, feet.z + 9f), + AmberDim, + MarkerWidth + ); + + Diamond(lineup.detonation_position, 22f, type, MarkerWidth); + + Label(new Vec3(feet.x, feet.y, feet.z + 14f), lineup.name, Amber); } ShowSpotUtility(lineups); @@ -1167,17 +1176,38 @@ private void ShowMarkers(LineupRecord lineup, Vec3 stance) Color color = ColorFor(lineup.utility_type); Vec3 landing = lineup.detonation_position; - Ring(stance, 24f, color, MarkerWidth); + // The stance is a GATE turned to face the throw: step into it, look out + // of the open end. Amber, because it is about the player rather than + // the grenade, and oriented, because "stand in this circle" leaves you + // to guess the one thing that is hardest to guess. + Gate(stance, lineup.release.yaw, 26f, Amber, MarkerWidth); + Chevron(stance, lineup.release.yaw, 15f, Amber, MarkerWidth); + + // Ties the grenade floating overhead to the ground it belongs to, so a + // spot with a model reads as one object rather than two. + Dashed( + new Vec3(stance.x, stance.y, stance.z + 4f), + new Vec3(stance.x, stance.y, stance.z + UtilityModelHeight - 6f), + AmberDim, + MarkerWidth, + 4 + ); + Label( - new Vec3(stance.x, stance.y, stance.z + 12f), - $"STAND\n{lineup.name}", - color + new Vec3(stance.x, stance.y, stance.z + 14f), + $"{lineup.name}\n{Tracked("stand")}", + Amber ); - Ring(landing, 34f, color, MarkerWidth); + // The flight is a plan, so it is dashed, and it only appears for the + // throw you are actually on -- every lineup drawing its own line is + // what made a spot with three throws unreadable. + Dashed(new Vec3(stance.x, stance.y, stance.z + 40f), landing, color, 0.3f, 7); + + Diamond(landing, 30f, color, MarkerWidth); Label( new Vec3(landing.x, landing.y, landing.z + 16f), - lineup.utility_type.ToUpperInvariant(), + Tracked(lineup.utility_type), color ); @@ -1423,23 +1453,32 @@ Vec3 Corner(float x, float y) => AddMarkerBeam(Corner(-dot, 0), Corner(dot, 0), color, width * 1.6f); AddMarkerBeam(Corner(0, -dot), Corner(0, dot), color, width * 1.6f); - // A faint outer ring so the thing can be FOUND from across the map. - // Deliberately thin and wide: it draws the eye without competing with - // the centre for it. - const int Segments = 16; - float radius = size; + // 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; - for (int index = 0; index < Segments; index++) + foreach (int horizontal in new[] { 1, -1 }) { - double a = index * 2 * Math.PI / Segments; - double b = (index + 1) * 2 * Math.PI / Segments; - - AddMarkerBeam( - Corner((float)Math.Cos(a) * radius, (float)Math.Sin(a) * radius), - Corner((float)Math.Cos(b) * radius, (float)Math.Sin(b) * radius), - color, - width * 0.5f - ); + 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 + ); + } } } @@ -1497,9 +1536,122 @@ private Vec3 Grounded(Vec3 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); + // 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)); + } + + private static Vec3 Offset(Vec3 at, Vec3 forward, float along, Vec3 right, float across) + { + return new Vec3( + at.x + (forward.x * along) + (right.x * across), + at.y + (forward.y * along) + (right.y * across), + at.z + ); + } + + // A caret on the floor pointing down the throw. Two beams against a ring's + // ten, and it says the thing a ring cannot: which way to face once you are + // standing on it. + private void Chevron(Vec3 at, float yaw, float size, Color color, float width) + { + (Vec3 forward, Vec3 right) = Bearing(yaw); + + Vec3 tip = Offset(at, forward, size, right, 0f); + + AddMarkerBeam(Offset(at, forward, -size * 0.4f, right, -size * 0.8f), tip, color, width); + AddMarkerBeam(Offset(at, forward, -size * 0.4f, right, size * 0.8f), 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); + } + + // Corner brackets on a square turned to face the throw, with the two + // corners on the throwing side cut back so it reads as a gate you step + // into and shoot out of, rather than a box you are stood in. + private void Gate(Vec3 at, float yaw, float half, Color color, float width) + { + (Vec3 forward, Vec3 right) = Bearing(yaw); + + foreach (int alongSign in new[] { 1, -1 }) + { + foreach (int acrossSign in new[] { 1, -1 }) + { + Vec3 corner = Offset(at, forward, half * alongSign, right, half * acrossSign); + float reach = half * (alongSign > 0 ? 0.28f : 0.46f); + + AddMarkerBeam( + corner, + Offset(corner, forward, -reach * alongSign, right, 0f), + color, + width + ); + AddMarkerBeam( + corner, + Offset(corner, forward, 0f, right, -reach * acrossSign), + color, + width + ); + } + } + } + + // A flight is a plan, not an object, so it is drawn as one. + private void Dashed(Vec3 from, Vec3 to, Color color, float width, int dashes) + { + for (int index = 0; index < dashes; index += 1) + { + float start = index / (float)dashes; + float end = start + (0.55f / dashes); + + AddMarkerBeam(Lerp(from, to, start), Lerp(from, to, end), color, width); + } + } + + private static Vec3 Lerp(Vec3 from, Vec3 to, float t) + { + return new Vec3( + from.x + ((to.x - from.x) * t), + from.y + ((to.y - from.y) * t), + from.z + ((to.z - from.z) * t) + ); + } + + // The panel sets every caption in mono uppercase on wide tracking, and + // point_worldtext has no letter-spacing, so the spacing goes in the string. + private static string Tracked(string text) + { + return string.Join(" ", text.ToUpperInvariant().ToCharArray()); + } + // Asks the engine what each grenade actually looks like, by spawning one of // each weapon under the world for a single tick and reading the model back // off it. Two problems in one: the path is right by construction rather @@ -1651,50 +1803,6 @@ private static Vec3 Normalize(Vec3 v) return length < 0.0001f ? v : new Vec3(v.x / length, v.y / length, v.z / length); } - private void Ring( - Vec3 center, - float radius, - Color color, - float width, - int segments = 20 - ) - { - int Segments = segments; - - 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) - { - if (_drawingInto != null) - { - _drawingInto.Beams.Add(beam); - } - else - { - _markerBeams.Add(beam); - } - } - } - } - // 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. From 3af086595eb37595996755a2d680b81fc7a294d0 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 20:41:42 -0400 Subject: [PATCH 054/107] say which half of a lineup is wrong --- .../test/PracticeLineupUtilityTests.cs | 35 ++++++ .../utility-sw/src/Services/PracticeReplay.cs | 103 +++++++++++++++--- apps/utility-sw/src/UtilityPracticePlugin.cs | 32 +++++- .../test/PracticeLineupUtilityTests.cs | 35 ++++++ .../PracticeLineupUtility.cs | 24 ++++ 5 files changed, 207 insertions(+), 22 deletions(-) diff --git a/apps/utility-css/test/PracticeLineupUtilityTests.cs b/apps/utility-css/test/PracticeLineupUtilityTests.cs index 8d64c7b9..c3448536 100644 --- a/apps/utility-css/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-css/test/PracticeLineupUtilityTests.cs @@ -264,3 +264,38 @@ public void MissNeverLeavesTheZeroToOneRange() } } } + +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); + } +} diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index acd720b3..08a0db44 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -126,6 +126,13 @@ private class Selection // 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 float Miss = -1f; } private class Aim @@ -140,6 +147,9 @@ private class Aim // from the rest of the selection. private Aim? _aimInto; + // 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. @@ -1024,6 +1034,11 @@ public void ShowSelection( { _drawingInto = null; + if (active.Count > 0) + { + ShowStance(stance, active.Count); + } + foreach (LineupRecord lineup in active) { ShowMarkers(lineup, stance); @@ -1044,8 +1059,21 @@ public void ShowSelection( _selections[owner.SteamID] = selection; _drawingInto = selection; + selection.At = stance; + try { + _stanceInto = selection.Stance; + + try + { + ShowStance(stance, active.Count); + } + finally + { + _stanceInto = null; + } + foreach (LineupRecord lineup in active) { ShowMarkers(lineup, stance); @@ -1092,6 +1120,33 @@ private void ApplySelectionVisibility() } } + // 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) + { + float miss = PracticeLineupUtility.StanceMiss( + new Vec3(selection.At.x - feet.x, selection.At.y - feet.y, 0f).LengthXY() + ); + + if (Math.Abs(miss - selection.Miss) < 0.02f) + { + return; + } + + selection.Miss = miss; + + Color color = MissColor(miss); + + foreach (CEnvBeam beam in selection.Stance) + { + if (beam.IsValid) + { + beam.Render = color; + } + } + } + public void ClearSelectionFor(ulong steamId) { ClearSelection(steamId); @@ -1168,20 +1223,12 @@ Vec3 at .ToList(); } - private void ShowMarkers(LineupRecord lineup, Vec3 stance) + // 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. + private void ShowStance(Vec3 stance, int throws) { - // 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; - - // The stance is a GATE turned to face the throw: step into it, look out - // of the open end. Amber, because it is about the player rather than - // the grenade, and oriented, because "stand in this circle" leaves you - // to guess the one thing that is hardest to guess. - Gate(stance, lineup.release.yaw, 26f, Amber, MarkerWidth); - Chevron(stance, lineup.release.yaw, 15f, Amber, MarkerWidth); + Gate(stance, 0f, 26f, Amber, MarkerWidth); // Ties the grenade floating overhead to the ground it belongs to, so a // spot with a model reads as one object rather than two. @@ -1195,9 +1242,26 @@ private void ShowMarkers(LineupRecord lineup, Vec3 stance) Label( new Vec3(stance.x, stance.y, stance.z + 14f), - $"{lineup.name}\n{Tracked("stand")}", + throws > 1 + ? $"{Tracked("stand")}\n{throws} throws" + : Tracked("stand"), Amber ); + } + + private void ShowMarkers(LineupRecord lineup, Vec3 stance) + { + // 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; + + // One chevron per throw, all fanning out of the one gate: a spot with + // three lineups shows three directions leaving it, which is the whole + // question a player has when they walk onto it. The gate itself is + // drawn once by ShowStance -- it belongs to the ground, not the throw. + Chevron(stance, lineup.release.yaw, 15f, Amber, MarkerWidth); // The flight is a plan, so it is dashed, and it only appears for the // throw you are actually on -- every lineup drawing its own line is @@ -1364,15 +1428,17 @@ private static Color MissColor(float miss) ); } - // Called as the player moves, not as they walk onto a spot: the crosshairs - // are already drawn, and all that changes is how wrong each one is. - public void TintAim(IPlayer player, float eyeYaw, float eyePitch) + // 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 (!_selections.TryGetValue(player.SteamID, out Selection? selection)) { return; } + TintStance(selection, feet); + foreach (Aim aim in selection.Aims) { float miss = PracticeLineupUtility.AimMiss( @@ -1647,7 +1713,7 @@ private static Vec3 Lerp(Vec3 from, Vec3 to, float t) // The panel sets every caption in mono uppercase on wide tracking, and // point_worldtext has no letter-spacing, so the spacing goes in the string. - private static string Tracked(string text) + public static string Tracked(string text) { return string.Join(" ", text.ToUpperInvariant().ToCharArray()); } @@ -1776,6 +1842,7 @@ private void AddMarkerBeam(Vec3 start, Vec3 end, Color color, float width) } _aimInto?.Beams.Add(beam); + _stanceInto?.Add(beam); if (_drawingInto != null) { diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 21a0d57b..8cb9b8cf 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -495,7 +495,14 @@ private void AimFeedback() QAngle eyes = pawn.EyeAngles; - _replay.TintAim(player, eyes.Y, eyes.X); + Vector standing = pawn.AbsOrigin ?? new Vector(0, 0, 0); + + _replay.TintAim( + player, + eyes.Y, + eyes.X, + new Vec3(standing.X, standing.Y, standing.Z) + ); string? message = CentreText(player, pawn); @@ -516,18 +523,35 @@ private void AimFeedback() if (lineup != null) { QAngle eyes = pawn.EyeAngles; + Vector standing = pawn.AbsOrigin ?? new Vector(0, 0, 0); + Vec3 spot = lineup.release.feet_position; - if ( + bool onAngle = PracticeLineupUtility.AimError( eyes.Y, eyes.X, lineup.release.yaw, lineup.release.pitch - ) <= ToleranceFor(lineup) - ) + ) <= ToleranceFor(lineup); + + // Both halves, not just the angle. Saying LINED UP to somebody + // holding the right angle from the wrong place is worse than saying + // nothing -- they throw it, it misses, and the lineup gets blamed. + bool onSpot = + PracticeLineupUtility.StanceMiss( + new Vec3(spot.x - standing.X, spot.y - standing.Y, 0f).LengthXY() + ) == 0f; + + if (onAngle && onSpot) { return PracticeReplay.ThrowHint(lineup); } + + if (onAngle) + { + return $"{PracticeReplay.Tracked("on the angle")}\n" + + PracticeReplay.Tracked("wrong spot"); + } } Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); diff --git a/apps/utility-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs index 8d64c7b9..c3448536 100644 --- a/apps/utility-sw/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-sw/test/PracticeLineupUtilityTests.cs @@ -264,3 +264,38 @@ public void MissNeverLeavesTheZeroToOneRange() } } } + +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); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index cd4b9b13..fa7e3c41 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -133,6 +133,30 @@ public static float AimMiss(float error, float tolerance) 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; + // A lineup that never said how precise it is. public const float DefaultAimTolerance = 0.35f; From a121d30f0dcd6de669ea18974df739cd79823009 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 20:58:33 -0400 Subject: [PATCH 055/107] guard entity placement and anchor the gate to the lineup --- .../utility-sw/src/Services/PracticeReplay.cs | 73 ++++++++++++++----- 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 08a0db44..b817a077 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -93,7 +93,6 @@ private class Ghost // anything heavier. private const float MarkerWidth = 0.6f; - // 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. @@ -1036,7 +1035,7 @@ public void ShowSelection( if (active.Count > 0) { - ShowStance(stance, active.Count); + ShowStance(Grounded(active.First().release.feet_position), active.Count); } foreach (LineupRecord lineup in active) @@ -1059,7 +1058,10 @@ public void ShowSelection( _selections[owner.SteamID] = selection; _drawingInto = selection; - selection.At = stance; + // 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 { @@ -1067,7 +1069,7 @@ public void ShowSelection( try { - ShowStance(stance, active.Count); + ShowStance(selection.At, active.Count); } finally { @@ -1328,20 +1330,6 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) { var trace = _core.Trace.TraceShapeLine(from, to, null); - _logger.LogInformation( - "aim trace for {name}: hit={hit} at {x},{y},{z} ({dist} units out)", - lineup.name, - trace.DidHit, - trace.EndPos.X.ToString("0.#"), - trace.EndPos.Y.ToString("0.#"), - trace.EndPos.Z.ToString("0.#"), - new Vec3( - trace.EndPos.X - eye.x, - trace.EndPos.Y - eye.y, - trace.EndPos.Z - eye.z - ).Length().ToString("0.#") - ); - hit = trace.DidHit ? new Vec3(trace.EndPos.X, trace.EndPos.Y, trace.EndPos.Z) : new Vec3( @@ -1741,14 +1729,26 @@ public void LearnUtilityModels() var keys = new CEntityKeyValues(); - keys.SetString("origin", "0 0 -16384"); + // Inside the world. -16384 is the coordinate limit itself, and + // an entity spawned exactly on it trips engine bounds checks. + keys.SetString("origin", "0 0 -8192"); keys.SetString("solid", "0"); probe.DispatchSpawn(keys); PracticeLineupUtility.LearnUtilityModel(pair.Key, probe.GetModel()); - probe.Despawn(); + // Next tick, not this one. A weapon entity is still finishing + // its own spawn when DispatchSpawn returns, and tearing it down + // underneath that is how you crash the server rather than + // borrow a model path from it. + _core.Scheduler.NextTick(() => + { + if (probe.IsValid) + { + probe.Despawn(); + } + }); } catch (Exception error) { @@ -1763,6 +1763,11 @@ public void LearnUtilityModels() private void UtilityModel(string utilityType, Vec3 at) { + if (!Sane(at)) + { + return; + } + string? model = PracticeLineupUtility.ModelForUtilityType(utilityType); if (model == null) @@ -1875,6 +1880,11 @@ private static Vec3 Normalize(Vec3 v) // label lying on the floor and wrong for one on a wall. private CPointWorldText? Label(Vec3 at, string text, Color color, Vec3? facing = null) { + if (!Sane(at)) + { + return null; + } + try { CPointWorldText label = @@ -2032,8 +2042,31 @@ public void ClearMarkers() _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"); From 1036d1eda01b91e40ffec423d232e5fd3eebb048 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:07:56 -0400 Subject: [PATCH 056/107] revert the in-world redraw back to the working build --- .../test/PracticeLineupUtilityTests.cs | 68 ++- .../utility-sw/src/Services/PracticeReplay.cs | 398 +++++------------- apps/utility-sw/src/UtilityPracticePlugin.cs | 32 +- .../test/PracticeLineupUtilityTests.cs | 68 ++- .../PracticeLineupUtility.cs | 24 -- 5 files changed, 199 insertions(+), 391 deletions(-) diff --git a/apps/utility-css/test/PracticeLineupUtilityTests.cs b/apps/utility-css/test/PracticeLineupUtilityTests.cs index c3448536..52d1cc54 100644 --- a/apps/utility-css/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-css/test/PracticeLineupUtilityTests.cs @@ -263,39 +263,71 @@ public void MissNeverLeavesTheZeroToOneRange() Assert.InRange(miss, 0f, 1f); } } -} -public class StanceMissTests -{ + private static LineupRecord Addressable(string? id, string clientId) + { + return new LineupRecord + { + id = id, + client_id = clientId, + name = "addressable", + release = new ThrowSnapshot { feet_position = new Vec3(0f, 0f, 0f) }, + }; + } + + // A saved lineup is addressed by the panel's id. + [Fact] + public void FindsALineupByItsPanelId() + { + LineupRecord wanted = Addressable("11111111-2222-3333-4444-555555555555", "local-1"); + var lineups = new[] { Addressable("00000000-0000-0000-0000-000000000000", "local-0"), wanted }; + + Assert.Same( + wanted, + PracticeLineupUtility.ById(lineups, "11111111-2222-3333-4444-555555555555") + ); + } + + // A scratch throw sent for a test has no panel id at all, only the local + // one, so both keys have to be addressable or "try it" cannot be loaded. [Fact] - public void StandingOnTheSpotIsFullyOn() + public void FindsAScratchLineupByItsClientId() { - Assert.Equal(0f, PracticeLineupUtility.StanceMiss(0f)); - Assert.Equal(0f, PracticeLineupUtility.StanceMiss(8f)); + LineupRecord scratch = Addressable(null, "scratch-abc"); + var lineups = new[] { Addressable("11111111-2222-3333-4444-555555555555", "local-1"), scratch }; + + Assert.Same(scratch, PracticeLineupUtility.ById(lineups, "scratch-abc")); } [Fact] - public void DriftingOffRampsUp() + public void MatchesAnIdRegardlessOfCase() { - float near = PracticeLineupUtility.StanceMiss(12f); - float far = PracticeLineupUtility.StanceMiss(30f); + LineupRecord wanted = Addressable("AABBCCDD-1111-2222-3333-444455556666", "local-1"); - Assert.True(near > 0f); - Assert.True(far > near); - Assert.True(far < 1f); + Assert.Same( + wanted, + PracticeLineupUtility.ById(new[] { wanted }, "aabbccdd-1111-2222-3333-444455556666") + ); } + // The important one. An empty argument must find nothing rather than the + // first lineup whose own id happens to be unset -- a remote load that + // arrived without an id would otherwise stand somebody on an arbitrary + // throw and report success. [Fact] - public void WellOffTheSpotIsFullyRed() + public void RefusesAnEmptyId() { - Assert.Equal(1f, PracticeLineupUtility.StanceMiss(48f)); - Assert.Equal(1f, PracticeLineupUtility.StanceMiss(500f)); + var lineups = new[] { Addressable(null, ""), Addressable("", "local-1") }; + + Assert.Null(PracticeLineupUtility.ById(lineups, "")); + Assert.Null(PracticeLineupUtility.ById(lineups, " ")); } [Fact] - public void StanceToleranceIsTighterThanTheSpotItself() + public void ReturnsNullWhenNothingMatches() { - // SpotRadius asks "is this the same place"; this asks "are you on it". - Assert.True(PracticeLineupUtility.StanceToleranceUnits < 40f); + var lineups = new[] { Addressable("11111111-2222-3333-4444-555555555555", "local-1") }; + + Assert.Null(PracticeLineupUtility.ById(lineups, "not-a-lineup")); } } diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index b817a077..979693c0 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -93,6 +93,10 @@ private class Ghost // anything heavier. private const float MarkerWidth = 0.6f; + // Coarser circles for the lineups that are not the focused one: at a dozen + // on screen the difference is invisible and the entity count is not. + private const int QuietSegments = 10; + // 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. @@ -125,13 +129,6 @@ private class Selection // 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 float Miss = -1f; } private class Aim @@ -146,9 +143,6 @@ private class Aim // from the rest of the selection. private Aim? _aimInto; - // 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. @@ -960,25 +954,13 @@ public void ShowLibrary(IEnumerable lineups) foreach (LineupRecord lineup in lineups) { - Color type = ColorFor(lineup.utility_type); + Color quiet = 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. - Chevron(feet, lineup.release.yaw, 13f, AmberDim, MarkerWidth); - AddMarkerBeam( - new Vec3(feet.x, feet.y, feet.z + 2f), - new Vec3(feet.x, feet.y, feet.z + 9f), - AmberDim, - MarkerWidth - ); - - Diamond(lineup.detonation_position, 22f, type, MarkerWidth); - - Label(new Vec3(feet.x, feet.y, feet.z + 14f), lineup.name, Amber); + Ring(feet, 18f, quiet, MarkerWidth, QuietSegments); + Ring(lineup.detonation_position, 26f, quiet, MarkerWidth, QuietSegments); + AddMarkerBeam(feet, lineup.detonation_position, quiet, 0.3f); + Label(new Vec3(feet.x, feet.y, feet.z + 10f), lineup.name, quiet); } ShowSpotUtility(lineups); @@ -1033,11 +1015,6 @@ public void ShowSelection( { _drawingInto = null; - if (active.Count > 0) - { - ShowStance(Grounded(active.First().release.feet_position), active.Count); - } - foreach (LineupRecord lineup in active) { ShowMarkers(lineup, stance); @@ -1058,24 +1035,8 @@ public void ShowSelection( _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, active.Count); - } - finally - { - _stanceInto = null; - } - foreach (LineupRecord lineup in active) { ShowMarkers(lineup, stance); @@ -1122,33 +1083,6 @@ private void ApplySelectionVisibility() } } - // 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) - { - float miss = PracticeLineupUtility.StanceMiss( - new Vec3(selection.At.x - feet.x, selection.At.y - feet.y, 0f).LengthXY() - ); - - if (Math.Abs(miss - selection.Miss) < 0.02f) - { - return; - } - - selection.Miss = miss; - - Color color = MissColor(miss); - - foreach (CEnvBeam beam in selection.Stance) - { - if (beam.IsValid) - { - beam.Render = color; - } - } - } - public void ClearSelectionFor(ulong steamId) { ClearSelection(steamId); @@ -1225,32 +1159,6 @@ Vec3 at .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. - private void ShowStance(Vec3 stance, int throws) - { - Gate(stance, 0f, 26f, Amber, MarkerWidth); - - // Ties the grenade floating overhead to the ground it belongs to, so a - // spot with a model reads as one object rather than two. - Dashed( - new Vec3(stance.x, stance.y, stance.z + 4f), - new Vec3(stance.x, stance.y, stance.z + UtilityModelHeight - 6f), - AmberDim, - MarkerWidth, - 4 - ); - - Label( - new Vec3(stance.x, stance.y, stance.z + 14f), - throws > 1 - ? $"{Tracked("stand")}\n{throws} throws" - : Tracked("stand"), - Amber - ); - } - private void ShowMarkers(LineupRecord lineup, Vec3 stance) { // Deliberately not gated behind the ghost preview: a preview is an @@ -1259,21 +1167,17 @@ private void ShowMarkers(LineupRecord lineup, Vec3 stance) Color color = ColorFor(lineup.utility_type); Vec3 landing = lineup.detonation_position; - // One chevron per throw, all fanning out of the one gate: a spot with - // three lineups shows three directions leaving it, which is the whole - // question a player has when they walk onto it. The gate itself is - // drawn once by ShowStance -- it belongs to the ground, not the throw. - Chevron(stance, lineup.release.yaw, 15f, Amber, MarkerWidth); - - // The flight is a plan, so it is dashed, and it only appears for the - // throw you are actually on -- every lineup drawing its own line is - // what made a spot with three throws unreadable. - Dashed(new Vec3(stance.x, stance.y, stance.z + 40f), landing, color, 0.3f, 7); + Ring(stance, 24f, color, MarkerWidth); + Label( + new Vec3(stance.x, stance.y, stance.z + 12f), + $"STAND\n{lineup.name}", + color + ); - Diamond(landing, 30f, color, MarkerWidth); + Ring(landing, 34f, color, MarkerWidth); Label( new Vec3(landing.x, landing.y, landing.z + 16f), - Tracked(lineup.utility_type), + lineup.utility_type.ToUpperInvariant(), color ); @@ -1330,6 +1234,20 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) { var trace = _core.Trace.TraceShapeLine(from, to, null); + _logger.LogInformation( + "aim trace for {name}: hit={hit} at {x},{y},{z} ({dist} units out)", + lineup.name, + trace.DidHit, + trace.EndPos.X.ToString("0.#"), + trace.EndPos.Y.ToString("0.#"), + trace.EndPos.Z.ToString("0.#"), + new Vec3( + trace.EndPos.X - eye.x, + trace.EndPos.Y - eye.y, + trace.EndPos.Z - eye.z + ).Length().ToString("0.#") + ); + hit = trace.DidHit ? new Vec3(trace.EndPos.X, trace.EndPos.Y, trace.EndPos.Z) : new Vec3( @@ -1416,17 +1334,15 @@ private static Color MissColor(float miss) ); } - // 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) + // Called as the player moves, not as they walk onto a spot: the crosshairs + // are already drawn, and all that changes is how wrong each one is. + public void TintAim(IPlayer player, float eyeYaw, float eyePitch) { if (!_selections.TryGetValue(player.SteamID, out Selection? selection)) { return; } - TintStance(selection, feet); - foreach (Aim aim in selection.Aims) { float miss = PracticeLineupUtility.AimMiss( @@ -1507,32 +1423,23 @@ Vec3 Corner(float x, float y) => 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; + // A faint outer ring so the thing can be FOUND from across the map. + // Deliberately thin and wide: it draws the eye without competing with + // the centre for it. + const int Segments = 16; + float radius = size; - foreach (int horizontal in new[] { 1, -1 }) + for (int index = 0; index < Segments; index++) { - 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 - ); - } + double a = index * 2 * Math.PI / Segments; + double b = (index + 1) * 2 * Math.PI / Segments; + + AddMarkerBeam( + Corner((float)Math.Cos(a) * radius, (float)Math.Sin(a) * radius), + Corner((float)Math.Cos(b) * radius, (float)Math.Sin(b) * radius), + color, + width * 0.5f + ); } } @@ -1590,122 +1497,9 @@ private Vec3 Grounded(Vec3 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); - // 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)); - } - - private static Vec3 Offset(Vec3 at, Vec3 forward, float along, Vec3 right, float across) - { - return new Vec3( - at.x + (forward.x * along) + (right.x * across), - at.y + (forward.y * along) + (right.y * across), - at.z - ); - } - - // A caret on the floor pointing down the throw. Two beams against a ring's - // ten, and it says the thing a ring cannot: which way to face once you are - // standing on it. - private void Chevron(Vec3 at, float yaw, float size, Color color, float width) - { - (Vec3 forward, Vec3 right) = Bearing(yaw); - - Vec3 tip = Offset(at, forward, size, right, 0f); - - AddMarkerBeam(Offset(at, forward, -size * 0.4f, right, -size * 0.8f), tip, color, width); - AddMarkerBeam(Offset(at, forward, -size * 0.4f, right, size * 0.8f), 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); - } - - // Corner brackets on a square turned to face the throw, with the two - // corners on the throwing side cut back so it reads as a gate you step - // into and shoot out of, rather than a box you are stood in. - private void Gate(Vec3 at, float yaw, float half, Color color, float width) - { - (Vec3 forward, Vec3 right) = Bearing(yaw); - - foreach (int alongSign in new[] { 1, -1 }) - { - foreach (int acrossSign in new[] { 1, -1 }) - { - Vec3 corner = Offset(at, forward, half * alongSign, right, half * acrossSign); - float reach = half * (alongSign > 0 ? 0.28f : 0.46f); - - AddMarkerBeam( - corner, - Offset(corner, forward, -reach * alongSign, right, 0f), - color, - width - ); - AddMarkerBeam( - corner, - Offset(corner, forward, 0f, right, -reach * acrossSign), - color, - width - ); - } - } - } - - // A flight is a plan, not an object, so it is drawn as one. - private void Dashed(Vec3 from, Vec3 to, Color color, float width, int dashes) - { - for (int index = 0; index < dashes; index += 1) - { - float start = index / (float)dashes; - float end = start + (0.55f / dashes); - - AddMarkerBeam(Lerp(from, to, start), Lerp(from, to, end), color, width); - } - } - - private static Vec3 Lerp(Vec3 from, Vec3 to, float t) - { - return new Vec3( - from.x + ((to.x - from.x) * t), - from.y + ((to.y - from.y) * t), - from.z + ((to.z - from.z) * t) - ); - } - - // The panel sets every caption in mono uppercase on wide tracking, and - // point_worldtext has no letter-spacing, so the spacing goes in the string. - public static string Tracked(string text) - { - return string.Join(" ", text.ToUpperInvariant().ToCharArray()); - } - // Asks the engine what each grenade actually looks like, by spawning one of // each weapon under the world for a single tick and reading the model back // off it. Two problems in one: the path is right by construction rather @@ -1729,26 +1523,14 @@ public void LearnUtilityModels() var keys = new CEntityKeyValues(); - // Inside the world. -16384 is the coordinate limit itself, and - // an entity spawned exactly on it trips engine bounds checks. - keys.SetString("origin", "0 0 -8192"); + keys.SetString("origin", "0 0 -16384"); keys.SetString("solid", "0"); probe.DispatchSpawn(keys); PracticeLineupUtility.LearnUtilityModel(pair.Key, probe.GetModel()); - // Next tick, not this one. A weapon entity is still finishing - // its own spawn when DispatchSpawn returns, and tearing it down - // underneath that is how you crash the server rather than - // borrow a model path from it. - _core.Scheduler.NextTick(() => - { - if (probe.IsValid) - { - probe.Despawn(); - } - }); + probe.Despawn(); } catch (Exception error) { @@ -1763,11 +1545,6 @@ public void LearnUtilityModels() private void UtilityModel(string utilityType, Vec3 at) { - if (!Sane(at)) - { - return; - } - string? model = PracticeLineupUtility.ModelForUtilityType(utilityType); if (model == null) @@ -1847,7 +1624,6 @@ private void AddMarkerBeam(Vec3 start, Vec3 end, Color color, float width) } _aimInto?.Beams.Add(beam); - _stanceInto?.Add(beam); if (_drawingInto != null) { @@ -1875,16 +1651,55 @@ private static Vec3 Normalize(Vec3 v) return length < 0.0001f ? v : new Vec3(v.x / length, v.y / length, v.z / length); } + private void Ring( + Vec3 center, + float radius, + Color color, + float width, + int segments = 20 + ) + { + int Segments = segments; + + 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) + { + if (_drawingInto != null) + { + _drawingInto.Beams.Add(beam); + } + else + { + _markerBeams.Add(beam); + } + } + } + } + // 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, Vec3? facing = null) { - if (!Sane(at)) - { - return null; - } - try { CPointWorldText label = @@ -2042,31 +1857,8 @@ public void ClearMarkers() _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"); diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 8cb9b8cf..21a0d57b 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -495,14 +495,7 @@ private void AimFeedback() 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) - ); + _replay.TintAim(player, eyes.Y, eyes.X); string? message = CentreText(player, pawn); @@ -523,35 +516,18 @@ private void AimFeedback() if (lineup != null) { QAngle eyes = pawn.EyeAngles; - Vector standing = pawn.AbsOrigin ?? new Vector(0, 0, 0); - Vec3 spot = lineup.release.feet_position; - bool onAngle = + if ( PracticeLineupUtility.AimError( eyes.Y, eyes.X, lineup.release.yaw, lineup.release.pitch - ) <= ToleranceFor(lineup); - - // Both halves, not just the angle. Saying LINED UP to somebody - // holding the right angle from the wrong place is worse than saying - // nothing -- they throw it, it misses, and the lineup gets blamed. - bool onSpot = - PracticeLineupUtility.StanceMiss( - new Vec3(spot.x - standing.X, spot.y - standing.Y, 0f).LengthXY() - ) == 0f; - - if (onAngle && onSpot) + ) <= ToleranceFor(lineup) + ) { return PracticeReplay.ThrowHint(lineup); } - - if (onAngle) - { - return $"{PracticeReplay.Tracked("on the angle")}\n" - + PracticeReplay.Tracked("wrong spot"); - } } Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); diff --git a/apps/utility-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs index c3448536..52d1cc54 100644 --- a/apps/utility-sw/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-sw/test/PracticeLineupUtilityTests.cs @@ -263,39 +263,71 @@ public void MissNeverLeavesTheZeroToOneRange() Assert.InRange(miss, 0f, 1f); } } -} -public class StanceMissTests -{ + private static LineupRecord Addressable(string? id, string clientId) + { + return new LineupRecord + { + id = id, + client_id = clientId, + name = "addressable", + release = new ThrowSnapshot { feet_position = new Vec3(0f, 0f, 0f) }, + }; + } + + // A saved lineup is addressed by the panel's id. + [Fact] + public void FindsALineupByItsPanelId() + { + LineupRecord wanted = Addressable("11111111-2222-3333-4444-555555555555", "local-1"); + var lineups = new[] { Addressable("00000000-0000-0000-0000-000000000000", "local-0"), wanted }; + + Assert.Same( + wanted, + PracticeLineupUtility.ById(lineups, "11111111-2222-3333-4444-555555555555") + ); + } + + // A scratch throw sent for a test has no panel id at all, only the local + // one, so both keys have to be addressable or "try it" cannot be loaded. [Fact] - public void StandingOnTheSpotIsFullyOn() + public void FindsAScratchLineupByItsClientId() { - Assert.Equal(0f, PracticeLineupUtility.StanceMiss(0f)); - Assert.Equal(0f, PracticeLineupUtility.StanceMiss(8f)); + LineupRecord scratch = Addressable(null, "scratch-abc"); + var lineups = new[] { Addressable("11111111-2222-3333-4444-555555555555", "local-1"), scratch }; + + Assert.Same(scratch, PracticeLineupUtility.ById(lineups, "scratch-abc")); } [Fact] - public void DriftingOffRampsUp() + public void MatchesAnIdRegardlessOfCase() { - float near = PracticeLineupUtility.StanceMiss(12f); - float far = PracticeLineupUtility.StanceMiss(30f); + LineupRecord wanted = Addressable("AABBCCDD-1111-2222-3333-444455556666", "local-1"); - Assert.True(near > 0f); - Assert.True(far > near); - Assert.True(far < 1f); + Assert.Same( + wanted, + PracticeLineupUtility.ById(new[] { wanted }, "aabbccdd-1111-2222-3333-444455556666") + ); } + // The important one. An empty argument must find nothing rather than the + // first lineup whose own id happens to be unset -- a remote load that + // arrived without an id would otherwise stand somebody on an arbitrary + // throw and report success. [Fact] - public void WellOffTheSpotIsFullyRed() + public void RefusesAnEmptyId() { - Assert.Equal(1f, PracticeLineupUtility.StanceMiss(48f)); - Assert.Equal(1f, PracticeLineupUtility.StanceMiss(500f)); + var lineups = new[] { Addressable(null, ""), Addressable("", "local-1") }; + + Assert.Null(PracticeLineupUtility.ById(lineups, "")); + Assert.Null(PracticeLineupUtility.ById(lineups, " ")); } [Fact] - public void StanceToleranceIsTighterThanTheSpotItself() + public void ReturnsNullWhenNothingMatches() { - // SpotRadius asks "is this the same place"; this asks "are you on it". - Assert.True(PracticeLineupUtility.StanceToleranceUnits < 40f); + var lineups = new[] { Addressable("11111111-2222-3333-4444-555555555555", "local-1") }; + + Assert.Null(PracticeLineupUtility.ById(lineups, "not-a-lineup")); } } diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index fa7e3c41..cd4b9b13 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -133,30 +133,6 @@ public static float AimMiss(float error, float tolerance) 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; - // A lineup that never said how precise it is. public const float DefaultAimTolerance = 0.35f; From 5adea5b94126dc665090a9630a1541f44fd5ccb6 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:15:48 -0400 Subject: [PATCH 057/107] stop calling CheckTransmit on swiftly 1.4.4 --- .../utility-sw/src/Services/PracticeReplay.cs | 34 +++++++++++++++---- .../utility-sw/src/Services/PracticeSolver.cs | 8 +++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 979693c0..83475b79 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -719,7 +719,7 @@ public void ApplyVisibility() { if (beam.IsValid) { - viewer.ShouldBlockTransmitEntity((int)beam.Index, hidden); + BlockTransmit(viewer, (int)beam.Index, hidden); } } } @@ -731,7 +731,8 @@ public void ApplyVisibility() continue; } - viewer.ShouldBlockTransmitEntity( + BlockTransmit( + viewer, (int)smoke.Index, !viewerWantsGhosts || (owner != viewer.SteamID && (viewerIsSolo || IsSolo(owner))) @@ -1068,7 +1069,7 @@ private void ApplySelectionVisibility() { if (beam.IsValid) { - viewer.ShouldBlockTransmitEntity((int)beam.Index, hidden); + BlockTransmit(viewer, (int)beam.Index, hidden); } } @@ -1076,7 +1077,7 @@ private void ApplySelectionVisibility() { if (text.IsValid) { - viewer.ShouldBlockTransmitEntity((int)text.Index, hidden); + BlockTransmit(viewer, (int)text.Index, hidden); } } } @@ -1108,7 +1109,7 @@ private void ClearSelection(ulong steamId) { if (beam.IsValid) { - viewer.ShouldBlockTransmitEntity((int)beam.Index, false); + BlockTransmit(viewer, (int)beam.Index, false); } } @@ -1116,7 +1117,7 @@ private void ClearSelection(ulong steamId) { if (text.IsValid) { - viewer.ShouldBlockTransmitEntity((int)text.Index, false); + BlockTransmit(viewer, (int)text.Index, false); } } } @@ -1497,6 +1498,25 @@ private Vec3 Grounded(Vec3 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. + // SwiftlyS2 1.4.4 has a known crash in its CheckTransmit implementation + // ("fix(checktransmit): Client crashes", fixed after v1.4.4 upstream), and + // this plugin is the only one in the repo that calls it -- the match plugin + // never does, which is why practice servers fall over and match servers do + // not. Blocking transmit is only ever a nicety: without it everyone sees + // everyone's ghosts and crosshairs instead of just their own. Flip this back + // on once the runtime is past 1.4.4. + public const bool TransmitBlocking = false; + + private static void BlockTransmit(IPlayer viewer, int index, bool hidden) + { + if (!TransmitBlocking) + { + return; + } + + viewer.ShouldBlockTransmitEntity(index, hidden); + } + // FSOLID_NOT_SOLID. private const byte NotSolid = 4; @@ -1912,7 +1932,7 @@ private void Unblock(uint index) { if (viewer != null && viewer.IsValid) { - viewer.ShouldBlockTransmitEntity((int)index, false); + BlockTransmit(viewer, (int)index, false); } } } diff --git a/apps/utility-sw/src/Services/PracticeSolver.cs b/apps/utility-sw/src/Services/PracticeSolver.cs index 64413577..986d81ad 100644 --- a/apps/utility-sw/src/Services/PracticeSolver.cs +++ b/apps/utility-sw/src/Services/PracticeSolver.cs @@ -754,8 +754,16 @@ private void Unhide(uint index) Block(index, false); } + // Off for the same reason PracticeReplay.TransmitBlocking is: SwiftlyS2 + // 1.4.4 crashes in CheckTransmit. A solver probe being visible for the + // moment it exists is cosmetic; the server going down is not. private void Block(uint index, bool hidden) { + if (!PracticeReplay.TransmitBlocking) + { + return; + } + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) { if (viewer != null && viewer.IsValid) From 146bbeef3fd4ff3884bc7ec96e2fdf80afde4b99 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:20:19 -0400 Subject: [PATCH 058/107] reset the practice plugin to the last working build --- .../test/PracticeLineupUtilityTests.cs | 67 ------------------- apps/utility-sw/src/Commands/Practice.cs | 62 ----------------- .../utility-sw/src/Services/PracticeReplay.cs | 34 ++-------- .../utility-sw/src/Services/PracticeSolver.cs | 8 --- .../test/PracticeLineupUtilityTests.cs | 67 ------------------- 5 files changed, 7 insertions(+), 231 deletions(-) diff --git a/apps/utility-css/test/PracticeLineupUtilityTests.cs b/apps/utility-css/test/PracticeLineupUtilityTests.cs index 52d1cc54..8d64c7b9 100644 --- a/apps/utility-css/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-css/test/PracticeLineupUtilityTests.cs @@ -263,71 +263,4 @@ public void MissNeverLeavesTheZeroToOneRange() Assert.InRange(miss, 0f, 1f); } } - - private static LineupRecord Addressable(string? id, string clientId) - { - return new LineupRecord - { - id = id, - client_id = clientId, - name = "addressable", - release = new ThrowSnapshot { feet_position = new Vec3(0f, 0f, 0f) }, - }; - } - - // A saved lineup is addressed by the panel's id. - [Fact] - public void FindsALineupByItsPanelId() - { - LineupRecord wanted = Addressable("11111111-2222-3333-4444-555555555555", "local-1"); - var lineups = new[] { Addressable("00000000-0000-0000-0000-000000000000", "local-0"), wanted }; - - Assert.Same( - wanted, - PracticeLineupUtility.ById(lineups, "11111111-2222-3333-4444-555555555555") - ); - } - - // A scratch throw sent for a test has no panel id at all, only the local - // one, so both keys have to be addressable or "try it" cannot be loaded. - [Fact] - public void FindsAScratchLineupByItsClientId() - { - LineupRecord scratch = Addressable(null, "scratch-abc"); - var lineups = new[] { Addressable("11111111-2222-3333-4444-555555555555", "local-1"), scratch }; - - Assert.Same(scratch, PracticeLineupUtility.ById(lineups, "scratch-abc")); - } - - [Fact] - public void MatchesAnIdRegardlessOfCase() - { - LineupRecord wanted = Addressable("AABBCCDD-1111-2222-3333-444455556666", "local-1"); - - Assert.Same( - wanted, - PracticeLineupUtility.ById(new[] { wanted }, "aabbccdd-1111-2222-3333-444455556666") - ); - } - - // The important one. An empty argument must find nothing rather than the - // first lineup whose own id happens to be unset -- a remote load that - // arrived without an id would otherwise stand somebody on an arbitrary - // throw and report success. - [Fact] - public void RefusesAnEmptyId() - { - var lineups = new[] { Addressable(null, ""), Addressable("", "local-1") }; - - Assert.Null(PracticeLineupUtility.ById(lineups, "")); - Assert.Null(PracticeLineupUtility.ById(lineups, " ")); - } - - [Fact] - public void ReturnsNullWhenNothingMatches() - { - var lineups = new[] { Addressable("11111111-2222-3333-4444-555555555555", "local-1") }; - - Assert.Null(PracticeLineupUtility.ById(lineups, "not-a-lineup")); - } } diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 7c011dfa..d7a42011 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -847,68 +847,6 @@ public void OnPracticeHelp(ICommandContext context) } } - // 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); - } - - 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: "")] diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 83475b79..979693c0 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -719,7 +719,7 @@ public void ApplyVisibility() { if (beam.IsValid) { - BlockTransmit(viewer, (int)beam.Index, hidden); + viewer.ShouldBlockTransmitEntity((int)beam.Index, hidden); } } } @@ -731,8 +731,7 @@ public void ApplyVisibility() continue; } - BlockTransmit( - viewer, + viewer.ShouldBlockTransmitEntity( (int)smoke.Index, !viewerWantsGhosts || (owner != viewer.SteamID && (viewerIsSolo || IsSolo(owner))) @@ -1069,7 +1068,7 @@ private void ApplySelectionVisibility() { if (beam.IsValid) { - BlockTransmit(viewer, (int)beam.Index, hidden); + viewer.ShouldBlockTransmitEntity((int)beam.Index, hidden); } } @@ -1077,7 +1076,7 @@ private void ApplySelectionVisibility() { if (text.IsValid) { - BlockTransmit(viewer, (int)text.Index, hidden); + viewer.ShouldBlockTransmitEntity((int)text.Index, hidden); } } } @@ -1109,7 +1108,7 @@ private void ClearSelection(ulong steamId) { if (beam.IsValid) { - BlockTransmit(viewer, (int)beam.Index, false); + viewer.ShouldBlockTransmitEntity((int)beam.Index, false); } } @@ -1117,7 +1116,7 @@ private void ClearSelection(ulong steamId) { if (text.IsValid) { - BlockTransmit(viewer, (int)text.Index, false); + viewer.ShouldBlockTransmitEntity((int)text.Index, false); } } } @@ -1498,25 +1497,6 @@ private Vec3 Grounded(Vec3 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. - // SwiftlyS2 1.4.4 has a known crash in its CheckTransmit implementation - // ("fix(checktransmit): Client crashes", fixed after v1.4.4 upstream), and - // this plugin is the only one in the repo that calls it -- the match plugin - // never does, which is why practice servers fall over and match servers do - // not. Blocking transmit is only ever a nicety: without it everyone sees - // everyone's ghosts and crosshairs instead of just their own. Flip this back - // on once the runtime is past 1.4.4. - public const bool TransmitBlocking = false; - - private static void BlockTransmit(IPlayer viewer, int index, bool hidden) - { - if (!TransmitBlocking) - { - return; - } - - viewer.ShouldBlockTransmitEntity(index, hidden); - } - // FSOLID_NOT_SOLID. private const byte NotSolid = 4; @@ -1932,7 +1912,7 @@ private void Unblock(uint index) { if (viewer != null && viewer.IsValid) { - BlockTransmit(viewer, (int)index, false); + viewer.ShouldBlockTransmitEntity((int)index, false); } } } diff --git a/apps/utility-sw/src/Services/PracticeSolver.cs b/apps/utility-sw/src/Services/PracticeSolver.cs index 986d81ad..64413577 100644 --- a/apps/utility-sw/src/Services/PracticeSolver.cs +++ b/apps/utility-sw/src/Services/PracticeSolver.cs @@ -754,16 +754,8 @@ private void Unhide(uint index) Block(index, false); } - // Off for the same reason PracticeReplay.TransmitBlocking is: SwiftlyS2 - // 1.4.4 crashes in CheckTransmit. A solver probe being visible for the - // moment it exists is cosmetic; the server going down is not. private void Block(uint index, bool hidden) { - if (!PracticeReplay.TransmitBlocking) - { - return; - } - foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) { if (viewer != null && viewer.IsValid) diff --git a/apps/utility-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs index 52d1cc54..8d64c7b9 100644 --- a/apps/utility-sw/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-sw/test/PracticeLineupUtilityTests.cs @@ -263,71 +263,4 @@ public void MissNeverLeavesTheZeroToOneRange() Assert.InRange(miss, 0f, 1f); } } - - private static LineupRecord Addressable(string? id, string clientId) - { - return new LineupRecord - { - id = id, - client_id = clientId, - name = "addressable", - release = new ThrowSnapshot { feet_position = new Vec3(0f, 0f, 0f) }, - }; - } - - // A saved lineup is addressed by the panel's id. - [Fact] - public void FindsALineupByItsPanelId() - { - LineupRecord wanted = Addressable("11111111-2222-3333-4444-555555555555", "local-1"); - var lineups = new[] { Addressable("00000000-0000-0000-0000-000000000000", "local-0"), wanted }; - - Assert.Same( - wanted, - PracticeLineupUtility.ById(lineups, "11111111-2222-3333-4444-555555555555") - ); - } - - // A scratch throw sent for a test has no panel id at all, only the local - // one, so both keys have to be addressable or "try it" cannot be loaded. - [Fact] - public void FindsAScratchLineupByItsClientId() - { - LineupRecord scratch = Addressable(null, "scratch-abc"); - var lineups = new[] { Addressable("11111111-2222-3333-4444-555555555555", "local-1"), scratch }; - - Assert.Same(scratch, PracticeLineupUtility.ById(lineups, "scratch-abc")); - } - - [Fact] - public void MatchesAnIdRegardlessOfCase() - { - LineupRecord wanted = Addressable("AABBCCDD-1111-2222-3333-444455556666", "local-1"); - - Assert.Same( - wanted, - PracticeLineupUtility.ById(new[] { wanted }, "aabbccdd-1111-2222-3333-444455556666") - ); - } - - // The important one. An empty argument must find nothing rather than the - // first lineup whose own id happens to be unset -- a remote load that - // arrived without an id would otherwise stand somebody on an arbitrary - // throw and report success. - [Fact] - public void RefusesAnEmptyId() - { - var lineups = new[] { Addressable(null, ""), Addressable("", "local-1") }; - - Assert.Null(PracticeLineupUtility.ById(lineups, "")); - Assert.Null(PracticeLineupUtility.ById(lineups, " ")); - } - - [Fact] - public void ReturnsNullWhenNothingMatches() - { - var lineups = new[] { Addressable("11111111-2222-3333-4444-555555555555", "local-1") }; - - Assert.Null(PracticeLineupUtility.ById(lineups, "not-a-lineup")); - } } From 09eb91149085e30e98e17a86791ef267c0de5e6d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:32:41 -0400 Subject: [PATCH 059/107] put every engine-touching feature behind a bisect switch --- .../test/PracticeLineupUtilityTests.cs | 35 ++ apps/utility-sw/src/Commands/Practice.cs | 62 +++ .../utility-sw/src/Services/PracticeReplay.cs | 494 ++++++++++++++---- .../utility-sw/src/Services/PracticeSolver.cs | 5 + apps/utility-sw/src/UtilityPracticePlugin.cs | 32 +- .../test/PracticeLineupUtilityTests.cs | 35 ++ .../PracticeLineupUtility.cs | 24 + 7 files changed, 588 insertions(+), 99 deletions(-) diff --git a/apps/utility-css/test/PracticeLineupUtilityTests.cs b/apps/utility-css/test/PracticeLineupUtilityTests.cs index 8d64c7b9..c3448536 100644 --- a/apps/utility-css/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-css/test/PracticeLineupUtilityTests.cs @@ -264,3 +264,38 @@ public void MissNeverLeavesTheZeroToOneRange() } } } + +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); + } +} diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index d7a42011..7c011dfa 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -847,6 +847,68 @@ public void OnPracticeHelp(ICommandContext context) } } + // 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); + } + + 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: "")] diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 979693c0..bf31d8d3 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -93,10 +93,6 @@ private class Ghost // anything heavier. private const float MarkerWidth = 0.6f; - // Coarser circles for the lineups that are not the focused one: at a dozen - // on screen the difference is invisible and the entity count is not. - private const int QuietSegments = 10; - // 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. @@ -129,6 +125,13 @@ private class Selection // 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 float Miss = -1f; } private class Aim @@ -143,6 +146,9 @@ private class Aim // from the rest of the selection. private Aim? _aimInto; + // 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. @@ -200,6 +206,37 @@ ILogger logger // drawing of it: beams in frame are our overlay filmed instead of the map. public Func WantsGhosts { get; set; } = _ => true; + // --------------------------------------------------------------------- + // 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 = false; + + // prop_physics_override grenade models floating over a spot, and the + // collision clearing that follows them. + public const bool DrawModels = false; + + // Spawning a weapon entity of each type at map load to read its model path. + public const bool HarvestModels = false; + + // Ghost trails and the measured bloom outline. + public const bool DrawGhosts = false; + + // REAL projectiles: EmitSmokeGrenade / EmitFlashbang / EmitHEGrenade / + // EmitMolotov, plus the bloom's live smoke. + public const bool EmitGrenades = false; + + // ShouldBlockTransmitEntity. SwiftlyS2 1.4.4 has a known CheckTransmit + // crash upstream, and this is the only plugin in the repo that calls it. + public const bool TransmitBlocking = false; + public void Load(IPlayer player, LineupRecord lineup) { CCSPlayerPawn? pawn = player.PlayerPawn; @@ -275,6 +312,11 @@ public void Load(IPlayer player, LineupRecord lineup) // where it lands. public void ShowGhost(IPlayer player, LineupRecord lineup) { + if (!DrawGhosts) + { + return; + } + if (!_config.GhostPreview || !WantsGhosts(player.SteamID)) { return; @@ -336,6 +378,11 @@ public void ShowGhost(IPlayer player, LineupRecord lineup) // lineup, which is a normal answer and not a failure. public int ShowBloom(IPlayer player, LineupRecord lineup) { + if (!DrawGhosts) + { + return 0; + } + ClearKind(player.SteamID, GhostKind.Bloom); if (!_config.GhostPreview || !WantsGhosts(player.SteamID)) @@ -399,6 +446,11 @@ public int ShowBloom(IPlayer player, LineupRecord lineup) // subject. Only Swiftly can emit one, so only Swiftly offers it. public bool ShowBloomSmoke(IPlayer player, LineupRecord lineup) { + if (!EmitGrenades) + { + return false; + } + ClearBloomSmoke(player.SteamID); if (lineup.utility_type != "Smoke") @@ -471,6 +523,11 @@ public void ClearBloomSmoke(ulong steamId) // where the recorded one did instead of near it. public void ThrowGhostProjectile(IPlayer player, LineupRecord lineup) { + if (!EmitGrenades) + { + return; + } + if (!_config.GhostProjectile) { return; @@ -696,6 +753,11 @@ public void Sweep() // solo players changes. public void ApplyVisibility() { + if (!TransmitBlocking) + { + return; + } + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) { if (viewer == null || !viewer.IsValid || viewer.IsFakeClient) @@ -892,6 +954,11 @@ private static List GhostPoints(LineupRecord lineup) // 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) @@ -948,19 +1015,36 @@ Vec3 stance // to everyone else. public void ShowLibrary(IEnumerable lineups) { + if (!DrawMarkers) + { + return; + } + ClearSharedMarkers(); _drawingInto = null; foreach (LineupRecord lineup in lineups) { - Color quiet = ColorFor(lineup.utility_type); + Color type = ColorFor(lineup.utility_type); Vec3 feet = Grounded(lineup.release.feet_position); - Ring(feet, 18f, quiet, MarkerWidth, QuietSegments); - Ring(lineup.detonation_position, 26f, quiet, MarkerWidth, QuietSegments); - AddMarkerBeam(feet, lineup.detonation_position, quiet, 0.3f); - Label(new Vec3(feet.x, feet.y, feet.z + 10f), lineup.name, quiet); + // 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. + Chevron(feet, lineup.release.yaw, 13f, AmberDim, MarkerWidth); + AddMarkerBeam( + new Vec3(feet.x, feet.y, feet.z + 2f), + new Vec3(feet.x, feet.y, feet.z + 9f), + AmberDim, + MarkerWidth + ); + + Diamond(lineup.detonation_position, 22f, type, MarkerWidth); + + Label(new Vec3(feet.x, feet.y, feet.z + 14f), lineup.name, Amber); } ShowSpotUtility(lineups); @@ -1009,12 +1093,22 @@ public void ShowSelection( LineupRecord? focused = null ) { + if (!DrawMarkers) + { + return; + } + _focused = focused; if (owner == null) { _drawingInto = null; + if (active.Count > 0) + { + ShowStance(Grounded(active.First().release.feet_position), active.Count); + } + foreach (LineupRecord lineup in active) { ShowMarkers(lineup, stance); @@ -1035,8 +1129,24 @@ public void ShowSelection( _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, active.Count); + } + finally + { + _stanceInto = null; + } + foreach (LineupRecord lineup in active) { ShowMarkers(lineup, stance); @@ -1053,6 +1163,11 @@ public void ShowSelection( // A selection belongs to one viewer, so it is blocked for everybody else. private void ApplySelectionVisibility() { + if (!TransmitBlocking) + { + return; + } + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) { if (viewer == null || !viewer.IsValid || viewer.IsFakeClient) @@ -1083,6 +1198,33 @@ private void ApplySelectionVisibility() } } + // 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) + { + float miss = PracticeLineupUtility.StanceMiss( + new Vec3(selection.At.x - feet.x, selection.At.y - feet.y, 0f).LengthXY() + ); + + if (Math.Abs(miss - selection.Miss) < 0.02f) + { + return; + } + + selection.Miss = miss; + + Color color = MissColor(miss); + + foreach (CEnvBeam beam in selection.Stance) + { + if (beam.IsValid) + { + beam.Render = color; + } + } + } + public void ClearSelectionFor(ulong steamId) { ClearSelection(steamId); @@ -1159,6 +1301,32 @@ Vec3 at .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. + private void ShowStance(Vec3 stance, int throws) + { + Gate(stance, 0f, 26f, Amber, MarkerWidth); + + // Ties the grenade floating overhead to the ground it belongs to, so a + // spot with a model reads as one object rather than two. + Dashed( + new Vec3(stance.x, stance.y, stance.z + 4f), + new Vec3(stance.x, stance.y, stance.z + UtilityModelHeight - 6f), + AmberDim, + MarkerWidth, + 4 + ); + + Label( + new Vec3(stance.x, stance.y, stance.z + 14f), + throws > 1 + ? $"{Tracked("stand")}\n{throws} throws" + : Tracked("stand"), + Amber + ); + } + private void ShowMarkers(LineupRecord lineup, Vec3 stance) { // Deliberately not gated behind the ghost preview: a preview is an @@ -1167,17 +1335,21 @@ private void ShowMarkers(LineupRecord lineup, Vec3 stance) Color color = ColorFor(lineup.utility_type); Vec3 landing = lineup.detonation_position; - Ring(stance, 24f, color, MarkerWidth); - Label( - new Vec3(stance.x, stance.y, stance.z + 12f), - $"STAND\n{lineup.name}", - color - ); + // One chevron per throw, all fanning out of the one gate: a spot with + // three lineups shows three directions leaving it, which is the whole + // question a player has when they walk onto it. The gate itself is + // drawn once by ShowStance -- it belongs to the ground, not the throw. + Chevron(stance, lineup.release.yaw, 15f, Amber, MarkerWidth); - Ring(landing, 34f, color, MarkerWidth); + // The flight is a plan, so it is dashed, and it only appears for the + // throw you are actually on -- every lineup drawing its own line is + // what made a spot with three throws unreadable. + Dashed(new Vec3(stance.x, stance.y, stance.z + 40f), landing, color, 0.3f, 7); + + Diamond(landing, 30f, color, MarkerWidth); Label( new Vec3(landing.x, landing.y, landing.z + 16f), - lineup.utility_type.ToUpperInvariant(), + Tracked(lineup.utility_type), color ); @@ -1234,20 +1406,6 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) { var trace = _core.Trace.TraceShapeLine(from, to, null); - _logger.LogInformation( - "aim trace for {name}: hit={hit} at {x},{y},{z} ({dist} units out)", - lineup.name, - trace.DidHit, - trace.EndPos.X.ToString("0.#"), - trace.EndPos.Y.ToString("0.#"), - trace.EndPos.Z.ToString("0.#"), - new Vec3( - trace.EndPos.X - eye.x, - trace.EndPos.Y - eye.y, - trace.EndPos.Z - eye.z - ).Length().ToString("0.#") - ); - hit = trace.DidHit ? new Vec3(trace.EndPos.X, trace.EndPos.Y, trace.EndPos.Z) : new Vec3( @@ -1334,15 +1492,22 @@ private static Color MissColor(float miss) ); } - // Called as the player moves, not as they walk onto a spot: the crosshairs - // are already drawn, and all that changes is how wrong each one is. - public void TintAim(IPlayer player, float eyeYaw, float eyePitch) + // 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) { float miss = PracticeLineupUtility.AimMiss( @@ -1423,23 +1588,32 @@ Vec3 Corner(float x, float y) => AddMarkerBeam(Corner(-dot, 0), Corner(dot, 0), color, width * 1.6f); AddMarkerBeam(Corner(0, -dot), Corner(0, dot), color, width * 1.6f); - // A faint outer ring so the thing can be FOUND from across the map. - // Deliberately thin and wide: it draws the eye without competing with - // the centre for it. - const int Segments = 16; - float radius = size; + // 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; - for (int index = 0; index < Segments; index++) + foreach (int horizontal in new[] { 1, -1 }) { - double a = index * 2 * Math.PI / Segments; - double b = (index + 1) * 2 * Math.PI / Segments; - - AddMarkerBeam( - Corner((float)Math.Cos(a) * radius, (float)Math.Sin(a) * radius), - Corner((float)Math.Cos(b) * radius, (float)Math.Sin(b) * radius), - color, - width * 0.5f - ); + 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 + ); + } } } @@ -1497,9 +1671,122 @@ private Vec3 Grounded(Vec3 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); + // 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)); + } + + private static Vec3 Offset(Vec3 at, Vec3 forward, float along, Vec3 right, float across) + { + return new Vec3( + at.x + (forward.x * along) + (right.x * across), + at.y + (forward.y * along) + (right.y * across), + at.z + ); + } + + // A caret on the floor pointing down the throw. Two beams against a ring's + // ten, and it says the thing a ring cannot: which way to face once you are + // standing on it. + private void Chevron(Vec3 at, float yaw, float size, Color color, float width) + { + (Vec3 forward, Vec3 right) = Bearing(yaw); + + Vec3 tip = Offset(at, forward, size, right, 0f); + + AddMarkerBeam(Offset(at, forward, -size * 0.4f, right, -size * 0.8f), tip, color, width); + AddMarkerBeam(Offset(at, forward, -size * 0.4f, right, size * 0.8f), 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); + } + + // Corner brackets on a square turned to face the throw, with the two + // corners on the throwing side cut back so it reads as a gate you step + // into and shoot out of, rather than a box you are stood in. + private void Gate(Vec3 at, float yaw, float half, Color color, float width) + { + (Vec3 forward, Vec3 right) = Bearing(yaw); + + foreach (int alongSign in new[] { 1, -1 }) + { + foreach (int acrossSign in new[] { 1, -1 }) + { + Vec3 corner = Offset(at, forward, half * alongSign, right, half * acrossSign); + float reach = half * (alongSign > 0 ? 0.28f : 0.46f); + + AddMarkerBeam( + corner, + Offset(corner, forward, -reach * alongSign, right, 0f), + color, + width + ); + AddMarkerBeam( + corner, + Offset(corner, forward, 0f, right, -reach * acrossSign), + color, + width + ); + } + } + } + + // A flight is a plan, not an object, so it is drawn as one. + private void Dashed(Vec3 from, Vec3 to, Color color, float width, int dashes) + { + for (int index = 0; index < dashes; index += 1) + { + float start = index / (float)dashes; + float end = start + (0.55f / dashes); + + AddMarkerBeam(Lerp(from, to, start), Lerp(from, to, end), color, width); + } + } + + private static Vec3 Lerp(Vec3 from, Vec3 to, float t) + { + return new Vec3( + from.x + ((to.x - from.x) * t), + from.y + ((to.y - from.y) * t), + from.z + ((to.z - from.z) * t) + ); + } + + // The panel sets every caption in mono uppercase on wide tracking, and + // point_worldtext has no letter-spacing, so the spacing goes in the string. + public static string Tracked(string text) + { + return string.Join(" ", text.ToUpperInvariant().ToCharArray()); + } + // Asks the engine what each grenade actually looks like, by spawning one of // each weapon under the world for a single tick and reading the model back // off it. Two problems in one: the path is right by construction rather @@ -1509,6 +1796,11 @@ private Vec3 Grounded(Vec3 position) // until somebody threw the grenade, which is what taught it the path. public void LearnUtilityModels() { + if (!HarvestModels) + { + return; + } + foreach (KeyValuePair pair in PracticeLineupUtility.UtilityWeapons()) { try @@ -1523,14 +1815,26 @@ public void LearnUtilityModels() var keys = new CEntityKeyValues(); - keys.SetString("origin", "0 0 -16384"); + // Inside the world. -16384 is the coordinate limit itself, and + // an entity spawned exactly on it trips engine bounds checks. + keys.SetString("origin", "0 0 -8192"); keys.SetString("solid", "0"); probe.DispatchSpawn(keys); PracticeLineupUtility.LearnUtilityModel(pair.Key, probe.GetModel()); - probe.Despawn(); + // Next tick, not this one. A weapon entity is still finishing + // its own spawn when DispatchSpawn returns, and tearing it down + // underneath that is how you crash the server rather than + // borrow a model path from it. + _core.Scheduler.NextTick(() => + { + if (probe.IsValid) + { + probe.Despawn(); + } + }); } catch (Exception error) { @@ -1545,6 +1849,16 @@ public void LearnUtilityModels() private void UtilityModel(string utilityType, Vec3 at) { + if (!DrawModels) + { + return; + } + + if (!Sane(at)) + { + return; + } + string? model = PracticeLineupUtility.ModelForUtilityType(utilityType); if (model == null) @@ -1624,6 +1938,7 @@ private void AddMarkerBeam(Vec3 start, Vec3 end, Color color, float width) } _aimInto?.Beams.Add(beam); + _stanceInto?.Add(beam); if (_drawingInto != null) { @@ -1651,55 +1966,16 @@ private static Vec3 Normalize(Vec3 v) return length < 0.0001f ? v : new Vec3(v.x / length, v.y / length, v.z / length); } - private void Ring( - Vec3 center, - float radius, - Color color, - float width, - int segments = 20 - ) - { - int Segments = segments; - - 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) - { - if (_drawingInto != null) - { - _drawingInto.Beams.Add(beam); - } - else - { - _markerBeams.Add(beam); - } - } - } - } - // 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, Vec3? facing = null) { + if (!Sane(at)) + { + return null; + } + try { CPointWorldText label = @@ -1857,8 +2133,31 @@ public void ClearMarkers() _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"); @@ -1908,6 +2207,11 @@ private void Kill(Ghost ghost) // the entity goes away or it will hide whatever lands on that index next. private void Unblock(uint index) { + if (!TransmitBlocking) + { + return; + } + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) { if (viewer != null && viewer.IsValid) diff --git a/apps/utility-sw/src/Services/PracticeSolver.cs b/apps/utility-sw/src/Services/PracticeSolver.cs index 64413577..6e3b0ab5 100644 --- a/apps/utility-sw/src/Services/PracticeSolver.cs +++ b/apps/utility-sw/src/Services/PracticeSolver.cs @@ -756,6 +756,11 @@ private void Unhide(uint index) private void Block(uint index, bool hidden) { + if (!PracticeReplay.TransmitBlocking) + { + return; + } + foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) { if (viewer != null && viewer.IsValid) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 21a0d57b..8cb9b8cf 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -495,7 +495,14 @@ private void AimFeedback() QAngle eyes = pawn.EyeAngles; - _replay.TintAim(player, eyes.Y, eyes.X); + Vector standing = pawn.AbsOrigin ?? new Vector(0, 0, 0); + + _replay.TintAim( + player, + eyes.Y, + eyes.X, + new Vec3(standing.X, standing.Y, standing.Z) + ); string? message = CentreText(player, pawn); @@ -516,18 +523,35 @@ private void AimFeedback() if (lineup != null) { QAngle eyes = pawn.EyeAngles; + Vector standing = pawn.AbsOrigin ?? new Vector(0, 0, 0); + Vec3 spot = lineup.release.feet_position; - if ( + bool onAngle = PracticeLineupUtility.AimError( eyes.Y, eyes.X, lineup.release.yaw, lineup.release.pitch - ) <= ToleranceFor(lineup) - ) + ) <= ToleranceFor(lineup); + + // Both halves, not just the angle. Saying LINED UP to somebody + // holding the right angle from the wrong place is worse than saying + // nothing -- they throw it, it misses, and the lineup gets blamed. + bool onSpot = + PracticeLineupUtility.StanceMiss( + new Vec3(spot.x - standing.X, spot.y - standing.Y, 0f).LengthXY() + ) == 0f; + + if (onAngle && onSpot) { return PracticeReplay.ThrowHint(lineup); } + + if (onAngle) + { + return $"{PracticeReplay.Tracked("on the angle")}\n" + + PracticeReplay.Tracked("wrong spot"); + } } Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); diff --git a/apps/utility-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs index 8d64c7b9..c3448536 100644 --- a/apps/utility-sw/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-sw/test/PracticeLineupUtilityTests.cs @@ -264,3 +264,38 @@ public void MissNeverLeavesTheZeroToOneRange() } } } + +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); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index cd4b9b13..fa7e3c41 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -133,6 +133,30 @@ public static float AimMiss(float error, float tolerance) 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; + // A lineup that never said how precise it is. public const float DefaultAimTolerance = 0.35f; From f6d47dfd03f596fa1957248096e55e4e7450447d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:35:27 -0400 Subject: [PATCH 060/107] bisect: markers on --- apps/utility-sw/src/Services/PracticeReplay.cs | 10 +++++++++- apps/utility-sw/src/UtilityPracticePlugin.cs | 5 +++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index bf31d8d3..1e0db814 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -217,7 +217,7 @@ ILogger logger // --------------------------------------------------------------------- // Beams and world text: rings/gates, labels, connectors, the flight line. - public const bool DrawMarkers = false; + public const bool DrawMarkers = true; // prop_physics_override grenade models floating over a spot, and the // collision clearing that follows them. @@ -237,6 +237,14 @@ ILogger logger // crash upstream, and this is the only plugin in the repo that calls it. public const bool TransmitBlocking = false; + // 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} harvest={HarvestModels} " + + $"ghosts={DrawGhosts} grenades={EmitGrenades} transmit={TransmitBlocking}"; + } + public void Load(IPlayer player, LineupRecord lineup) { CCSPlayerPawn? pawn = player.PlayerPawn; diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 8cb9b8cf..906b2469 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -179,9 +179,10 @@ public override void Load(bool hotReload) } _logger.LogInformation( - "utility practice {version} loaded (connected: {connected})", + "utility practice {version} loaded (connected: {connected}) [{switches}]", ModuleVersion, - _config.IsConnected() + _config.IsConnected(), + PracticeReplay.SwitchState() ); } From 875c589069ea0c9d0f760b4f7457ab0aee673f3a Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:36:31 -0400 Subject: [PATCH 061/107] bisect: grenade models on --- apps/utility-sw/src/Services/PracticeReplay.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 1e0db814..36d4f1f7 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -221,10 +221,10 @@ ILogger logger // prop_physics_override grenade models floating over a spot, and the // collision clearing that follows them. - public const bool DrawModels = false; + public const bool DrawModels = true; // Spawning a weapon entity of each type at map load to read its model path. - public const bool HarvestModels = false; + public const bool HarvestModels = true; // Ghost trails and the measured bloom outline. public const bool DrawGhosts = false; From d661629567b7195714e77f1c95de99a53813ea63 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:39:25 -0400 Subject: [PATCH 062/107] spawn the model probes on the main thread --- apps/utility-sw/src/UtilityPracticePlugin.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 906b2469..25b568ed 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -174,7 +174,12 @@ public override void Load(bool hotReload) { _library.SetMap(Core.Engine.GlobalVars.MapName.ToString()); ApplyPracticeCfg(); - _replay.LearnUtilityModels(); + + // Load() runs on the plugin manager's thread, and spawning an + // entity off the main thread throws. OnMapLoad already schedules + // this for the same reason; the hot-reload path forgot to. + Core.Scheduler.NextTick(_replay.LearnUtilityModels); + RefreshEverything(); } From 0340fb7705a31e925b978f0aff0477b75775df41 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:42:24 -0400 Subject: [PATCH 063/107] bisect: harvest off, props on --- apps/utility-sw/src/Services/PracticeReplay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 36d4f1f7..a3d9047a 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -224,7 +224,7 @@ ILogger logger public const bool DrawModels = true; // Spawning a weapon entity of each type at map load to read its model path. - public const bool HarvestModels = true; + public const bool HarvestModels = false; // Ghost trails and the measured bloom outline. public const bool DrawGhosts = false; From 1fc70b2e17c6d0eb9fe7a50e49f2d5621a2be746 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:44:56 -0400 Subject: [PATCH 064/107] drop the weapon-probe model harvest --- .../utility-sw/src/Services/PracticeReplay.cs | 64 +------------------ apps/utility-sw/src/UtilityPracticePlugin.cs | 9 --- .../PracticeLineupUtility.cs | 19 ++---- 3 files changed, 6 insertions(+), 86 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index a3d9047a..6721c000 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -223,8 +223,6 @@ ILogger logger // collision clearing that follows them. public const bool DrawModels = true; - // Spawning a weapon entity of each type at map load to read its model path. - public const bool HarvestModels = false; // Ghost trails and the measured bloom outline. public const bool DrawGhosts = false; @@ -241,7 +239,7 @@ ILogger logger // 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} harvest={HarvestModels} " + return $"markers={DrawMarkers} models={DrawModels} " + $"ghosts={DrawGhosts} grenades={EmitGrenades} transmit={TransmitBlocking}"; } @@ -1795,66 +1793,6 @@ public static string Tracked(string text) return string.Join(" ", text.ToUpperInvariant().ToCharArray()); } - // Asks the engine what each grenade actually looks like, by spawning one of - // each weapon under the world for a single tick and reading the model back - // off it. Two problems in one: the path is right by construction rather - // than by guesswork, and it is precached by construction too -- the engine - // precaches every weapon at level init, which our own precache list, built - // from paths we invented, cannot promise. Without this nothing rendered - // until somebody threw the grenade, which is what taught it the path. - public void LearnUtilityModels() - { - if (!HarvestModels) - { - return; - } - - foreach (KeyValuePair pair in PracticeLineupUtility.UtilityWeapons()) - { - try - { - CBaseModelEntity probe = - _core.EntitySystem.CreateEntityByDesignerName(pair.Value); - - if (!probe.IsValid) - { - continue; - } - - var keys = new CEntityKeyValues(); - - // Inside the world. -16384 is the coordinate limit itself, and - // an entity spawned exactly on it trips engine bounds checks. - keys.SetString("origin", "0 0 -8192"); - keys.SetString("solid", "0"); - - probe.DispatchSpawn(keys); - - PracticeLineupUtility.LearnUtilityModel(pair.Key, probe.GetModel()); - - // Next tick, not this one. A weapon entity is still finishing - // its own spawn when DispatchSpawn returns, and tearing it down - // underneath that is how you crash the server rather than - // borrow a model path from it. - _core.Scheduler.NextTick(() => - { - if (probe.IsValid) - { - probe.Despawn(); - } - }); - } - catch (Exception error) - { - _logger.LogWarning( - error, - "unable to read the world model for {weapon}", - pair.Value - ); - } - } - } - private void UtilityModel(string utilityType, Vec3 at) { if (!DrawModels) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 25b568ed..412808c2 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -174,12 +174,6 @@ public override void Load(bool hotReload) { _library.SetMap(Core.Engine.GlobalVars.MapName.ToString()); ApplyPracticeCfg(); - - // Load() runs on the plugin manager's thread, and spawning an - // entity off the main thread throws. OnMapLoad already schedules - // this for the same reason; the hot-reload path forgot to. - Core.Scheduler.NextTick(_replay.LearnUtilityModels); - RefreshEverything(); } @@ -815,9 +809,6 @@ private void OnMapLoad(string mapName) ApplyPracticeCfg(); - // Next tick, not now: the entity system has nothing to spawn into - // while the map is still loading. - Core.Scheduler.NextTick(_replay.LearnUtilityModels); RefreshEverything(); } diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index fa7e3c41..4bfabe53 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -49,11 +49,11 @@ public static IEnumerable AllUtilityModels() return UtilityToModel.Values; } - // The engine's own answer, harvested at map load from a throwaway weapon - // entity of each type and topped up from any projectile that actually - // flies. The table above is only a fallback: CS2 renamed these out of - // CS:GO's models/weapons/w_eq_* scheme, and a path that is wrong by one - // character renders as the ERROR model with no other complaint. + // 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) @@ -209,15 +209,6 @@ float height return spots; } - // Type paired with the weapon that carries its model, for the map-load - // harvest. Spawning the real weapon is the only way to get a path that is - // certainly correct AND certainly precached -- our own precache list can - // only ever contain paths we guessed. - public static IEnumerable> UtilityWeapons() - { - return UtilityToWeapon; - } - public static string? WeaponForUtilityType(string utilityType) { return UtilityToWeapon.TryGetValue(utilityType, out string? weapon) ? weapon : null; From efac1da0bb763d8014068cd549bf95b4a8bbf6d1 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:48:26 -0400 Subject: [PATCH 065/107] bisect: ghosts on --- apps/utility-sw/src/Services/PracticeReplay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 6721c000..0e151040 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -225,7 +225,7 @@ ILogger logger // Ghost trails and the measured bloom outline. - public const bool DrawGhosts = false; + public const bool DrawGhosts = true; // REAL projectiles: EmitSmokeGrenade / EmitFlashbang / EmitHEGrenade / // EmitMolotov, plus the bloom's live smoke. From 54326631e542e88aa67f9eca8efae896d7c58a40 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:50:59 -0400 Subject: [PATCH 066/107] bisect: real projectiles on --- apps/utility-sw/src/Services/PracticeReplay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 0e151040..655d3395 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -229,7 +229,7 @@ ILogger logger // REAL projectiles: EmitSmokeGrenade / EmitFlashbang / EmitHEGrenade / // EmitMolotov, plus the bloom's live smoke. - public const bool EmitGrenades = false; + public const bool EmitGrenades = true; // ShouldBlockTransmitEntity. SwiftlyS2 1.4.4 has a known CheckTransmit // crash upstream, and this is the only plugin in the repo that calls it. From a18548feee918bdc13f4d8bdcd4a0360c889ef2e Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 21:57:22 -0400 Subject: [PATCH 067/107] make labels face the reader and drop transmit blocking --- apps/utility-sw/src/Commands/Practice.cs | 4 - .../utility-sw/src/Services/PracticeReplay.cs | 204 +++--------------- .../utility-sw/src/Services/PracticeSolver.cs | 18 -- 3 files changed, 25 insertions(+), 201 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 7c011dfa..98a4a3de 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -701,8 +701,6 @@ public void OnSolo(ICommandContext context) PracticeState state = _system.StateFor(player.SteamID); state.Solo = !state.Solo; - _replay.ApplyVisibility(); - Reply( context, state.Solo @@ -741,8 +739,6 @@ public void OnGhosts(ICommandContext context) _replay.ClearGhosts(player.SteamID); } - _replay.ApplyVisibility(); - Reply(context, $" {ChatColors.Green}ghost previews {Toggle(state.Ghosts)}"); } diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 655d3395..20a861a6 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -223,7 +223,6 @@ ILogger logger // collision clearing that follows them. public const bool DrawModels = true; - // Ghost trails and the measured bloom outline. public const bool DrawGhosts = true; @@ -231,16 +230,12 @@ ILogger logger // EmitMolotov, plus the bloom's live smoke. public const bool EmitGrenades = true; - // ShouldBlockTransmitEntity. SwiftlyS2 1.4.4 has a known CheckTransmit - // crash upstream, and this is the only plugin in the repo that calls it. - public const bool TransmitBlocking = false; - // 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} " - + $"ghosts={DrawGhosts} grenades={EmitGrenades} transmit={TransmitBlocking}"; + + $"ghosts={DrawGhosts} grenades={EmitGrenades}"; } public void Load(IPlayer player, LineupRecord lineup) @@ -376,7 +371,6 @@ public void ShowGhost(IPlayer player, LineupRecord lineup) } ); - ApplyVisibility(); } // The measured bloom, outlined where it would actually sit. Answers how @@ -443,8 +437,6 @@ public int ShowBloom(IPlayer player, LineupRecord lineup) } ); - ApplyVisibility(); - return beams.Count; } @@ -495,7 +487,6 @@ public bool ShowBloomSmoke(IPlayer player, LineupRecord lineup) _recorder.Forget(smoke.Index); _bloomSmoke[player.SteamID] = smoke; - ApplyVisibility(); return true; } @@ -519,7 +510,6 @@ public void ClearBloomSmoke(ulong steamId) if (smoke.IsValid) { - Unblock(smoke.Index); smoke.Despawn(); } } @@ -754,60 +744,6 @@ public void Sweep() } } - // Swiftly's transmit blocking is a standing per-player list rather than a - // per-frame callback, so it is recomputed whenever the set of beams or of - // solo players changes. - public void ApplyVisibility() - { - if (!TransmitBlocking) - { - return; - } - - foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) - { - if (viewer == null || !viewer.IsValid || viewer.IsFakeClient) - { - continue; - } - - bool viewerIsSolo = IsSolo(viewer.SteamID); - bool viewerWantsGhosts = WantsGhosts(viewer.SteamID); - - foreach (Ghost ghost in _ghosts) - { - bool hidden = - !viewerWantsGhosts - || ( - ghost.OwnerSteamId != viewer.SteamID - && (viewerIsSolo || IsSolo(ghost.OwnerSteamId)) - ); - - foreach (CEnvBeam beam in ghost.Beams) - { - if (beam.IsValid) - { - viewer.ShouldBlockTransmitEntity((int)beam.Index, hidden); - } - } - } - - foreach ((ulong owner, CSmokeGrenadeProjectile smoke) in _bloomSmoke) - { - if (!smoke.IsValid) - { - continue; - } - - viewer.ShouldBlockTransmitEntity( - (int)smoke.Index, - !viewerWantsGhosts - || (owner != viewer.SteamID && (viewerIsSolo || IsSolo(owner))) - ); - } - } - } - // 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) @@ -1163,45 +1099,6 @@ public void ShowSelection( _drawingInto = null; } - ApplySelectionVisibility(); - } - - // A selection belongs to one viewer, so it is blocked for everybody else. - private void ApplySelectionVisibility() - { - if (!TransmitBlocking) - { - return; - } - - foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) - { - if (viewer == null || !viewer.IsValid || viewer.IsFakeClient) - { - continue; - } - - foreach ((ulong owner, Selection selection) in _selections) - { - bool hidden = owner != viewer.SteamID; - - foreach (CEnvBeam beam in selection.Beams) - { - if (beam.IsValid) - { - viewer.ShouldBlockTransmitEntity((int)beam.Index, hidden); - } - } - - foreach (CPointWorldText text in selection.Texts) - { - if (text.IsValid) - { - viewer.ShouldBlockTransmitEntity((int)text.Index, hidden); - } - } - } - } } // The gate answers the other half of the question. A player who is off the @@ -1243,32 +1140,6 @@ private void ClearSelection(ulong steamId) return; } - // Entity indices are reused, so a standing block has to be lifted - // BEFORE the entity goes away or it lands on whatever takes its slot. - foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) - { - if (viewer == null || !viewer.IsValid || viewer.IsFakeClient) - { - continue; - } - - foreach (CEnvBeam beam in selection.Beams) - { - if (beam.IsValid) - { - viewer.ShouldBlockTransmitEntity((int)beam.Index, false); - } - } - - foreach (CPointWorldText text in selection.Texts) - { - if (text.IsValid) - { - viewer.ShouldBlockTransmitEntity((int)text.Index, false); - } - } - } - foreach (CEnvBeam beam in selection.Beams) { if (beam.IsValid) @@ -1327,8 +1198,8 @@ private void ShowStance(Vec3 stance, int throws) Label( new Vec3(stance.x, stance.y, stance.z + 14f), throws > 1 - ? $"{Tracked("stand")}\n{throws} throws" - : Tracked("stand"), + ? $"{Tracked("stand here")}\n{throws} throws from this spot" + : Tracked("stand here"), Amber ); } @@ -1476,8 +1347,7 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) aim.Label = Label( new Vec3(center.x, center.y, center.z + size + 8f), label, - MissColor(1f), - eye + MissColor(1f) ); _drawingInto?.Aims.Add(aim); @@ -1685,6 +1555,11 @@ private Vec3 Grounded(Vec3 position) private static readonly Color Amber = new Color(249, 158, 47, 255); private static readonly Color AmberDim = new Color(203, 117, 11, 255); + // 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; + // FSOLID_NOT_SOLID. private const byte NotSolid = 4; @@ -1915,7 +1790,7 @@ private static Vec3 Normalize(Vec3 v) // 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, Vec3? facing = null) + private CPointWorldText? Label(Vec3 at, string text, Color color) { if (!Sane(at)) { @@ -1936,40 +1811,30 @@ private static Vec3 Normalize(Vec3 v) 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; - var angle = new QAngle(0, 0, 0); - if (facing == null) - { - // Spins to face whoever is reading it. Fine on the floor. - label.ReorientMode = PointWorldTextReorientMode_t - .POINT_WORLD_TEXT_REORIENT_AROUND_UP; - } - else - { - // Aimed by hand instead. Auto-reorient was leaving wall labels - // rolled onto their side -- text running bottom to top -- so - // the angle is set explicitly and the roll pinned flat. - float dx = facing.Value.x - at.x; - float dy = facing.Value.y - at.y; - - float yaw = (float)(Math.Atan2(dy, dx) * 180.0 / Math.PI); - - // point_worldtext reads along its own +X, so it has to be - // turned to face the reader rather than away from them. - angle = new QAngle(0, yaw + 180f, 0); + // 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; - label.ReorientMode = PointWorldTextReorientMode_t - .POINT_WORLD_TEXT_REORIENT_NONE; - } + var angle = new QAngle(0, 0, 0); label.Teleport( new Vector(at.x, at.y, at.z), @@ -2144,29 +2009,10 @@ private void Kill(Ghost ghost) continue; } - Unblock(beam.Index); beam.Despawn(); } } - // Entity indices are reused, so a standing block has to be lifted before - // the entity goes away or it will hide whatever lands on that index next. - private void Unblock(uint index) - { - if (!TransmitBlocking) - { - return; - } - - foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) - { - if (viewer != null && viewer.IsValid) - { - viewer.ShouldBlockTransmitEntity((int)index, false); - } - } - } - private static Color ColorFor(string utilityType) { switch (utilityType) diff --git a/apps/utility-sw/src/Services/PracticeSolver.cs b/apps/utility-sw/src/Services/PracticeSolver.cs index 6e3b0ab5..3716d90c 100644 --- a/apps/utility-sw/src/Services/PracticeSolver.cs +++ b/apps/utility-sw/src/Services/PracticeSolver.cs @@ -746,28 +746,10 @@ private void Retire(uint index) private void Hide(uint index) { - Block(index, true); } private void Unhide(uint index) { - Block(index, false); - } - - private void Block(uint index, bool hidden) - { - if (!PracticeReplay.TransmitBlocking) - { - return; - } - - foreach (IPlayer viewer in _core.PlayerManager.GetAllPlayers()) - { - if (viewer != null && viewer.IsValid) - { - viewer.ShouldBlockTransmitEntity((int)index, hidden); - } - } } private CBaseCSGrenadeProjectile? TryProjectileAt(uint index) From 634476144c286aa2f3bcd96a6cbed98694615ee5 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 20 Aug 2026 22:44:32 -0400 Subject: [PATCH 068/107] rebuild markers to change colour and add a stance beacon --- .../utility-sw/src/Services/PracticeReplay.cs | 144 ++++++++++++++---- 1 file changed, 117 insertions(+), 27 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 20a861a6..2391162b 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -131,7 +131,7 @@ private class Selection public readonly List Stance = new(); public Vec3 At; - public float Miss = -1f; + public int Bucket = -1; } private class Aim @@ -139,7 +139,30 @@ private class Aim public LineupRecord Lineup = null!; public readonly List Beams = new(); public CPointWorldText? Label; - public float Miss = -1f; + public int Bucket = -1; + + // Kept so the crosshair can be REBUILT in a new colour. A spawned + // env_beam does not reliably re-network m_clrRender, so assigning + // Render on a live beam moves it on the server and nowhere else -- + // which is why lining up never turned anything green. + public Vec3 Center; + public Vec3 Dir; + public float Size; + public float Weight; + } + + // 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 Math.Clamp((int)(miss * MissBuckets), 0, MissBuckets - 1); + } + + private static Color ColorForBucket(int bucket) + { + return MissColor(bucket / (float)(MissBuckets - 1)); } // The reticle currently being drawn, so its beams can be collected apart @@ -1106,26 +1129,47 @@ public void ShowSelection( // colours, on the marker that is actually wrong. private void TintStance(Selection selection, Vec3 feet) { - float miss = PracticeLineupUtility.StanceMiss( - new Vec3(selection.At.x - feet.x, selection.At.y - feet.y, 0f).LengthXY() + int bucket = BucketFor( + PracticeLineupUtility.StanceMiss( + new Vec3(selection.At.x - feet.x, selection.At.y - feet.y, 0f).LengthXY() + ) ); - if (Math.Abs(miss - selection.Miss) < 0.02f) + if (bucket == selection.Bucket) { return; } - selection.Miss = miss; + selection.Bucket = bucket; - Color color = MissColor(miss); + Color color = ColorForBucket(bucket); foreach (CEnvBeam beam in selection.Stance) { if (beam.IsValid) { - beam.Render = color; + selection.Beams.Remove(beam); + beam.Despawn(); } } + + selection.Stance.Clear(); + + Selection? was = _drawingInto; + + _drawingInto = selection; + _stanceInto = selection.Stance; + + try + { + Gate(selection.At, 0f, StanceGateHalf, color, StanceWidth); + Beacon(selection.At, color); + } + finally + { + _stanceInto = null; + _drawingInto = was; + } } public void ClearSelectionFor(ulong steamId) @@ -1183,7 +1227,8 @@ Vec3 at // and, worse, piled the labels into an unreadable smear. private void ShowStance(Vec3 stance, int throws) { - Gate(stance, 0f, 26f, Amber, MarkerWidth); + Gate(stance, 0f, StanceGateHalf, ColorForBucket(MissBuckets - 1), StanceWidth); + Beacon(stance, ColorForBucket(MissBuckets - 1)); // Ties the grenade floating overhead to the ground it belongs to, so a // spot with a model reads as one object rather than two. @@ -1329,13 +1374,20 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) // 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 }; + var aim = new Aim + { + Lineup = lineup, + Center = center, + Dir = dir, + Size = size, + Weight = weight, + }; _aimInto = aim; try { - Reticle(center, dir, size, MissColor(1f), weight); + Reticle(center, dir, size, ColorForBucket(MissBuckets - 1), weight); } finally { @@ -1347,7 +1399,7 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) aim.Label = Label( new Vec3(center.x, center.y, center.z + size + 8f), label, - MissColor(1f) + ColorForBucket(MissBuckets - 1) ); _drawingInto?.Aims.Add(aim); @@ -1386,36 +1438,54 @@ public void TintAim(IPlayer player, float eyeYaw, float eyePitch, Vec3 feet) foreach (Aim aim in selection.Aims) { - float miss = PracticeLineupUtility.AimMiss( - PracticeLineupUtility.AimError( - eyeYaw, - eyePitch, - aim.Lineup.release.yaw, - aim.Lineup.release.pitch - ), - aim.Lineup.aim_tolerance + int bucket = BucketFor( + PracticeLineupUtility.AimMiss( + PracticeLineupUtility.AimError( + eyeYaw, + eyePitch, + aim.Lineup.release.yaw, + aim.Lineup.release.pitch + ), + aim.Lineup.aim_tolerance + ) ); - // Beams are networked on change, so only send one when the colour - // would actually differ. At sixteen updates a second across every - // throw on a spot, repainting unconditionally is real traffic. - if (Math.Abs(miss - aim.Miss) < 0.02f) + if (bucket == aim.Bucket) { continue; } - aim.Miss = miss; + aim.Bucket = bucket; - Color color = MissColor(miss); + Color color = ColorForBucket(bucket); + // Rebuilt, not recoloured, for the reason on Aim above. foreach (CEnvBeam beam in aim.Beams) { if (beam.IsValid) { - beam.Render = color; + selection.Beams.Remove(beam); + beam.Despawn(); } } + aim.Beams.Clear(); + + Selection? previous = _drawingInto; + + _drawingInto = selection; + _aimInto = aim; + + try + { + Reticle(aim.Center, aim.Dir, aim.Size, color, aim.Weight); + } + finally + { + _aimInto = null; + _drawingInto = previous; + } + if (aim.Label != null && aim.Label.IsValid) { aim.Label.Color = color; @@ -1555,6 +1625,12 @@ private Vec3 Grounded(Vec3 position) private static readonly Color Amber = new Color(249, 158, 47, 255); private static readonly Color AmberDim = new Color(203, 117, 11, 255); + // The gate has to read as a place to stand from across the room, not only + // from on top of it. + private const float StanceGateHalf = 30f; + private const float StanceWidth = 1.6f; + private const float BeaconHeight = 96f; + // 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; @@ -1582,6 +1658,20 @@ private static Vec3 Offset(Vec3 at, Vec3 forward, float along, Vec3 right, float ); } + // A pillar out of the middle of the gate. The gate is flat on the floor, so + // at any distance it is edge-on and nearly invisible; this is the part you + // spot first, and it carries the same colour, so "am I on the spot" is + // answerable without looking down. + private void Beacon(Vec3 at, Color color) + { + AddMarkerBeam( + new Vec3(at.x, at.y, at.z + 2f), + new Vec3(at.x, at.y, at.z + BeaconHeight), + color, + StanceWidth + ); + } + // A caret on the floor pointing down the throw. Two beams against a ring's // ten, and it says the thing a ring cannot: which way to face once you are // standing on it. From 2c5c490b76dd097e5dd0d6d933fad96c3312675e Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 09:03:07 -0400 Subject: [PATCH 069/107] reserve green for inside tolerance and cap the library draw --- .../test/PracticeLineupUtilityTests.cs | 30 +++++++++++++++++++ .../utility-sw/src/Services/PracticeReplay.cs | 24 ++++++++++++++- .../test/PracticeLineupUtilityTests.cs | 30 +++++++++++++++++++ .../PracticeLineupUtility.cs | 15 ++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/apps/utility-css/test/PracticeLineupUtilityTests.cs b/apps/utility-css/test/PracticeLineupUtilityTests.cs index c3448536..f7063ff7 100644 --- a/apps/utility-css/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-css/test/PracticeLineupUtilityTests.cs @@ -299,3 +299,33 @@ public void StanceToleranceIsTighterThanTheSpotItself() 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); + } + } +} diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 2391162b..7abd480a 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -157,7 +157,7 @@ private class Aim private static int BucketFor(float miss) { - return Math.Clamp((int)(miss * MissBuckets), 0, MissBuckets - 1); + return PracticeLineupUtility.MissBucket(miss, MissBuckets); } private static Color ColorForBucket(int bucket) @@ -978,6 +978,13 @@ Vec3 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) @@ -989,6 +996,21 @@ public void ShowLibrary(IEnumerable lineups) _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); diff --git a/apps/utility-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs index c3448536..f7063ff7 100644 --- a/apps/utility-sw/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-sw/test/PracticeLineupUtilityTests.cs @@ -299,3 +299,33 @@ public void StanceToleranceIsTighterThanTheSpotItself() 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); + } + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index 4bfabe53..0767fc66 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -157,6 +157,21 @@ public static float StanceMiss(float distance) // 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); + } + // A lineup that never said how precise it is. public const float DefaultAimTolerance = 0.35f; From 08427e085aab6efc5f41fe252eea72f4767aed32 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 09:11:22 -0400 Subject: [PATCH 070/107] glow the utility models and probe live beam recolouring --- apps/utility-sw/src/Commands/Practice.cs | 20 +++++ .../utility-sw/src/Services/PracticeReplay.cs | 89 +++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 98a4a3de..27a708e6 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -477,6 +477,26 @@ public void OnPick(ICommandContext context) Core.MenusAPI.OpenMenuForPlayer(player, builder.Build()); } + // TEMPORARY: the runtime half of the AcceptInput("Color") question -- see + // PracticeReplay.TintProbe for what an answer buys. + [Command("tintest", registerRaw: false, permission: "")] + public void OnTintTest(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + Reply( + context, + _replay.TintProbe(player) + ? $" {ChatColors.Grey}watch the post ahead of you: does it flip red/green each second?" + : $" {ChatColors.Red}could not spawn the test beam" + ); + } + [Command("where", registerRaw: false, permission: "")] public void OnWhere(ICommandContext context) { diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 7abd480a..acd10523 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -93,6 +93,10 @@ private class Ghost // 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. @@ -1853,6 +1857,19 @@ private void UtilityModel(string utilityType, Vec3 at) 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; + prop.Glow.Glowing = true; + _markerProps.Add(prop); } catch (Exception error) @@ -1975,6 +1992,78 @@ private static Vec3 Normalize(Vec3 v) } } + // TEMPORARY EXPERIMENT -- delete together with .tintest once answered. + // Settles at runtime whether AcceptInput("Color") on a live env_beam + // reaches clients. Spawns a post in front of the player that tries to flip + // red/green once a second for six seconds, then removes itself. If the + // post visibly flips, the rebuild-on-bucket machinery can collapse into a + // single input call per colour change; if it stays red, rebuilds are the + // only way and this whole probe just gets deleted. + public bool TintProbe(IPlayer player) + { + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return false; + } + + Vector feet = pawn.AbsOrigin ?? new Vector(0, 0, 0); + double yaw = pawn.EyeAngles.Y * Math.PI / 180.0; + var at = new Vec3( + feet.X + (float)(Math.Cos(yaw) * 80), + feet.Y + (float)(Math.Sin(yaw) * 80), + feet.Z + ); + + CEnvBeam? beam = CreateBeam( + new Vec3(at.x, at.y, at.z + 8f), + new Vec3(at.x, at.y, at.z + 72f), + MissColor(1f), + 2f + ); + + if (beam == null) + { + return false; + } + + for (int second = 1; second <= 6; second += 1) + { + bool green = second % 2 == 1; + + _core.Scheduler.DelayBySeconds( + second, + () => + { + if (beam.IsValid) + { + beam.AcceptInput( + "Color", + green ? "60 230 90" : "255 60 0", + null, + null, + 0 + ); + } + } + ); + } + + _core.Scheduler.DelayBySeconds( + 7, + () => + { + if (beam.IsValid) + { + beam.Despawn(); + } + } + ); + + return true; + } + // 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. From ed291036f67a318bc25d109e403b1481a94a39f9 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 09:23:37 -0400 Subject: [PATCH 071/107] recolour beams by input, skip players in marker traces, keep glow on screen --- apps/utility-sw/src/Commands/Practice.cs | 20 -- .../utility-sw/src/Services/PracticeReplay.cs | 223 ++++-------------- 2 files changed, 52 insertions(+), 191 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 27a708e6..98a4a3de 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -477,26 +477,6 @@ public void OnPick(ICommandContext context) Core.MenusAPI.OpenMenuForPlayer(player, builder.Build()); } - // TEMPORARY: the runtime half of the AcceptInput("Color") question -- see - // PracticeReplay.TintProbe for what an answer buys. - [Command("tintest", registerRaw: false, permission: "")] - public void OnTintTest(ICommandContext context) - { - IPlayer? player = context.Sender; - - if (player == null || !player.IsValid) - { - return; - } - - Reply( - context, - _replay.TintProbe(player) - ? $" {ChatColors.Grey}watch the post ahead of you: does it flip red/green each second?" - : $" {ChatColors.Red}could not spawn the test beam" - ); - } - [Command("where", registerRaw: false, permission: "")] public void OnWhere(ICommandContext context) { diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index acd10523..fde309c0 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -7,6 +7,7 @@ using SwiftlyS2.Shared.Natives; using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.SchemaDefinitions; +using SwiftlyS2.Shared.Trace; namespace UtilityPractice; @@ -71,9 +72,22 @@ private class Ghost // aim ray actually lands. private const float AimTraceRange = 4096f; - // A standing hull is 32 wide and 72 tall, so anything past ~48 units along - // the aim is outside it whichever way the player is facing. - private const float AimTraceStartOffset = 48f; + // 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. + private static TraceParams SkipPlayers() + { + var parameters = new TraceParams(); + + parameters.IterateEntities = true; + parameters.ShouldHitEntity = entity => entity.DesignerName != "player"; + + 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 @@ -142,17 +156,28 @@ private class Aim { public LineupRecord Lineup = null!; public readonly List Beams = new(); - public CPointWorldText? Label; public int Bucket = -1; + } - // Kept so the crosshair can be REBUILT in a new colour. A spawned - // env_beam does not reliably re-network m_clrRender, so assigning - // Render on a live beam moves it on the server and nowhere else -- - // which is why lining up never turned anything green. - public Vec3 Center; - public Vec3 Dir; - public float Size; - public float Weight; + // 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 @@ -1167,35 +1192,7 @@ private void TintStance(Selection selection, Vec3 feet) } selection.Bucket = bucket; - - Color color = ColorForBucket(bucket); - - foreach (CEnvBeam beam in selection.Stance) - { - if (beam.IsValid) - { - selection.Beams.Remove(beam); - beam.Despawn(); - } - } - - selection.Stance.Clear(); - - Selection? was = _drawingInto; - - _drawingInto = selection; - _stanceInto = selection.Stance; - - try - { - Gate(selection.At, 0f, StanceGateHalf, color, StanceWidth); - Beacon(selection.At, color); - } - finally - { - _stanceInto = null; - _drawingInto = was; - } + Recolour(selection.Stance, ColorForBucket(bucket)); } public void ClearSelectionFor(ulong steamId) @@ -1333,15 +1330,7 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) (float)(-Math.Sin(pitch)) ); - // Started clear of the player's own hull. Markers are drawn while - // somebody is standing on the spot, and a trace beginning inside a - // player stops on that player -- which is why the crosshair landed a - // stride away instead of on the wall it is aimed at. - var from = new Vector( - eye.x + dir.x * AimTraceStartOffset, - eye.y + dir.y * AimTraceStartOffset, - eye.z + dir.z * AimTraceStartOffset - ); + var from = new Vector(eye.x, eye.y, eye.z); var to = new Vector( eye.x + dir.x * AimTraceRange, eye.y + dir.y * AimTraceRange, @@ -1352,7 +1341,7 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) try { - var trace = _core.Trace.TraceShapeLine(from, to, null); + var trace = _core.Trace.TraceShapeLine(from, to, SkipPlayers()); hit = trace.DidHit ? new Vec3(trace.EndPos.X, trace.EndPos.Y, trace.EndPos.Z) @@ -1400,14 +1389,7 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) // 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, - Center = center, - Dir = dir, - Size = size, - Weight = weight, - }; + var aim = new Aim { Lineup = lineup }; _aimInto = aim; @@ -1422,11 +1404,10 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) // Named at the crosshair itself: several throws off one spot are only // useful if you can tell which crosshair belongs to which. - aim.Label = Label( - new Vec3(center.x, center.y, center.z + size + 8f), - label, - ColorForBucket(MissBuckets - 1) - ); + // 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); } @@ -1482,40 +1463,7 @@ public void TintAim(IPlayer player, float eyeYaw, float eyePitch, Vec3 feet) } aim.Bucket = bucket; - - Color color = ColorForBucket(bucket); - - // Rebuilt, not recoloured, for the reason on Aim above. - foreach (CEnvBeam beam in aim.Beams) - { - if (beam.IsValid) - { - selection.Beams.Remove(beam); - beam.Despawn(); - } - } - - aim.Beams.Clear(); - - Selection? previous = _drawingInto; - - _drawingInto = selection; - _aimInto = aim; - - try - { - Reticle(aim.Center, aim.Dir, aim.Size, color, aim.Weight); - } - finally - { - _aimInto = null; - _drawingInto = previous; - } - - if (aim.Label != null && aim.Label.IsValid) - { - aim.Label.Color = color; - } + Recolour(aim.Beams, ColorForBucket(bucket)); } } @@ -1607,7 +1555,7 @@ private Vec3 Grounded(Vec3 position) position.z - GroundSnapRange ); - var trace = _core.Trace.TraceShapeLine(from, to, null); + var trace = _core.Trace.TraceShapeLine(from, to, SkipPlayers()); if (!trace.DidHit) { @@ -1868,6 +1816,11 @@ private void UtilityModel(string utilityType, Vec3 at) 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); @@ -1992,78 +1945,6 @@ private static Vec3 Normalize(Vec3 v) } } - // TEMPORARY EXPERIMENT -- delete together with .tintest once answered. - // Settles at runtime whether AcceptInput("Color") on a live env_beam - // reaches clients. Spawns a post in front of the player that tries to flip - // red/green once a second for six seconds, then removes itself. If the - // post visibly flips, the rebuild-on-bucket machinery can collapse into a - // single input call per colour change; if it stays red, rebuilds are the - // only way and this whole probe just gets deleted. - public bool TintProbe(IPlayer player) - { - CCSPlayerPawn? pawn = player.PlayerPawn; - - if (pawn == null || !pawn.IsValid) - { - return false; - } - - Vector feet = pawn.AbsOrigin ?? new Vector(0, 0, 0); - double yaw = pawn.EyeAngles.Y * Math.PI / 180.0; - var at = new Vec3( - feet.X + (float)(Math.Cos(yaw) * 80), - feet.Y + (float)(Math.Sin(yaw) * 80), - feet.Z - ); - - CEnvBeam? beam = CreateBeam( - new Vec3(at.x, at.y, at.z + 8f), - new Vec3(at.x, at.y, at.z + 72f), - MissColor(1f), - 2f - ); - - if (beam == null) - { - return false; - } - - for (int second = 1; second <= 6; second += 1) - { - bool green = second % 2 == 1; - - _core.Scheduler.DelayBySeconds( - second, - () => - { - if (beam.IsValid) - { - beam.AcceptInput( - "Color", - green ? "60 230 90" : "255 60 0", - null, - null, - 0 - ); - } - } - ); - } - - _core.Scheduler.DelayBySeconds( - 7, - () => - { - if (beam.IsValid) - { - beam.Despawn(); - } - } - ); - - return true; - } - // 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. From d89e56c03111595776b4c0734e803c404b255fd1 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 09:30:52 -0400 Subject: [PATCH 072/107] ground reticle for the stance, marker-proof aim trace, flight line stays --- .../utility-sw/src/Services/PracticeReplay.cs | 128 ++++++++++-------- 1 file changed, 72 insertions(+), 56 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index fde309c0..626766c7 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -15,7 +15,6 @@ namespace UtilityPractice; // 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 @@ -78,12 +77,26 @@ private class Ghost // 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. - private static TraceParams SkipPlayers() + // 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 => entity.DesignerName != "player"; + parameters.ShouldHitEntity = entity => + !TraceInvisible.Contains(entity.DesignerName ?? ""); return parameters; } @@ -418,7 +431,10 @@ public void ShowGhost(IPlayer player, LineupRecord lineup) { OwnerSteamId = player.SteamID, Kind = GhostKind.Line, - ExpiresAt = DateTime.UtcNow.AddSeconds(GhostSeconds), + // Held, not timed out: "the line where the nade goes" vanishing + // after a few seconds read as a bug, not a preview. The next + // .load or .clear replaces it. + ExpiresAt = DateTime.MaxValue, Beams = beams, } ); @@ -1248,10 +1264,54 @@ Vec3 at // 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 + ); + } + + float arm = StanceRingRadius * 0.75f; + float gap = StanceRingRadius * 0.25f; + + 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, + StanceWidth + ); + } + } + private void ShowStance(Vec3 stance, int throws) { - Gate(stance, 0f, StanceGateHalf, ColorForBucket(MissBuckets - 1), StanceWidth); - Beacon(stance, ColorForBucket(MissBuckets - 1)); + GroundReticle(stance, ColorForBucket(MissBuckets - 1)); // Ties the grenade floating overhead to the ground it belongs to, so a // spot with a model reads as one object rather than two. @@ -1341,7 +1401,7 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) try { - var trace = _core.Trace.TraceShapeLine(from, to, SkipPlayers()); + var trace = _core.Trace.TraceShapeLine(from, to, SkipMarkers()); hit = trace.DidHit ? new Vec3(trace.EndPos.X, trace.EndPos.Y, trace.EndPos.Z) @@ -1555,7 +1615,7 @@ private Vec3 Grounded(Vec3 position) position.z - GroundSnapRange ); - var trace = _core.Trace.TraceShapeLine(from, to, SkipPlayers()); + var trace = _core.Trace.TraceShapeLine(from, to, SkipMarkers()); if (!trace.DidHit) { @@ -1599,11 +1659,11 @@ private Vec3 Grounded(Vec3 position) private static readonly Color Amber = new Color(249, 158, 47, 255); private static readonly Color AmberDim = new Color(203, 117, 11, 255); - // The gate has to read as a place to stand from across the room, not only - // from on top of it. - private const float StanceGateHalf = 30f; private const float StanceWidth = 1.6f; - private const float BeaconHeight = 96f; + + // 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. @@ -1632,20 +1692,6 @@ private static Vec3 Offset(Vec3 at, Vec3 forward, float along, Vec3 right, float ); } - // A pillar out of the middle of the gate. The gate is flat on the floor, so - // at any distance it is edge-on and nearly invisible; this is the part you - // spot first, and it carries the same colour, so "am I on the spot" is - // answerable without looking down. - private void Beacon(Vec3 at, Color color) - { - AddMarkerBeam( - new Vec3(at.x, at.y, at.z + 2f), - new Vec3(at.x, at.y, at.z + BeaconHeight), - color, - StanceWidth - ); - } - // A caret on the floor pointing down the throw. Two beams against a ring's // ten, and it says the thing a ring cannot: which way to face once you are // standing on it. @@ -1674,36 +1720,6 @@ private void Diamond(Vec3 at, float radius, Color color, float width) AddMarkerBeam(west, north, color, width); } - // Corner brackets on a square turned to face the throw, with the two - // corners on the throwing side cut back so it reads as a gate you step - // into and shoot out of, rather than a box you are stood in. - private void Gate(Vec3 at, float yaw, float half, Color color, float width) - { - (Vec3 forward, Vec3 right) = Bearing(yaw); - - foreach (int alongSign in new[] { 1, -1 }) - { - foreach (int acrossSign in new[] { 1, -1 }) - { - Vec3 corner = Offset(at, forward, half * alongSign, right, half * acrossSign); - float reach = half * (alongSign > 0 ? 0.28f : 0.46f); - - AddMarkerBeam( - corner, - Offset(corner, forward, -reach * alongSign, right, 0f), - color, - width - ); - AddMarkerBeam( - corner, - Offset(corner, forward, 0f, right, -reach * acrossSign), - color, - width - ); - } - } - } - // A flight is a plan, not an object, so it is drawn as one. private void Dashed(Vec3 from, Vec3 to, Color color, float width, int dashes) { From eee2df2f478a58b5d015078ef97d8e7cd084d017 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 09:38:43 -0400 Subject: [PATCH 073/107] name on point, quieter ground, reticles that never move --- .../utility-sw/src/Services/PracticeReplay.cs | 81 ++++++++++--------- apps/utility-sw/src/UtilityPracticePlugin.cs | 44 ++++++---- 2 files changed, 73 insertions(+), 52 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 626766c7..8774f6a2 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -96,7 +96,15 @@ private static TraceParams SkipMarkers() parameters.IterateEntities = true; parameters.ShouldHitEntity = entity => - !TraceInvisible.Contains(entity.DesignerName ?? ""); + { + 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; } @@ -131,7 +139,7 @@ private static TraceParams SkipMarkers() // Above standing eye height (64) on purpose. .load stands the player ON // the ring, and at chest height the grenade is inside their camera. - private const float UtilityModelHeight = 80f; + 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. @@ -211,6 +219,10 @@ private static Color ColorForBucket(int bucket) // 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; @@ -1066,17 +1078,11 @@ public void ShowLibrary(IEnumerable lineups) // 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. Chevron(feet, lineup.release.yaw, 13f, AmberDim, MarkerWidth); - AddMarkerBeam( - new Vec3(feet.x, feet.y, feet.z + 2f), - new Vec3(feet.x, feet.y, feet.z + 9f), - AmberDim, - MarkerWidth - ); - Diamond(lineup.detonation_position, 22f, type, MarkerWidth); - - Label(new Vec3(feet.x, feet.y, feet.z + 14f), lineup.name, Amber); } ShowSpotUtility(lineups); @@ -1313,16 +1319,6 @@ private void ShowStance(Vec3 stance, int throws) { GroundReticle(stance, ColorForBucket(MissBuckets - 1)); - // Ties the grenade floating overhead to the ground it belongs to, so a - // spot with a model reads as one object rather than two. - Dashed( - new Vec3(stance.x, stance.y, stance.z + 4f), - new Vec3(stance.x, stance.y, stance.z + UtilityModelHeight - 6f), - AmberDim, - MarkerWidth, - 4 - ); - Label( new Vec3(stance.x, stance.y, stance.z + 14f), throws > 1 @@ -1397,28 +1393,36 @@ private void AimReticle(LineupRecord lineup, Vec3 stance, string label) eye.z + dir.z * AimTraceRange ); - Vec3 hit; - - try + // 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)) { - var trace = _core.Trace.TraceShapeLine(from, to, SkipMarkers()); - - hit = trace.DidHit - ? new Vec3(trace.EndPos.X, trace.EndPos.Y, trace.EndPos.Z) - : new Vec3( + 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 ); - } - 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 @@ -1966,6 +1970,7 @@ private static Vec3 Normalize(Vec3 v) // makes despawning it actively harmful. Drop the references instead. public void ForgetMarkers() { + _aimHits.Clear(); _markerBeams.Clear(); _markerTexts.Clear(); _markerProps.Clear(); diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 412808c2..601f5be4 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -447,25 +447,41 @@ IReadOnlyList here foreach (LineupRecord lineup in library) { Vec3 feet = lineup.release.feet_position; - var toRing = new Vec3(feet.x - eye.x, feet.y - eye.y, feet.z - 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) + // 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 } + ) { - continue; - } + var toRing = new Vec3( + feet.x - eye.x, + feet.y - eye.y, + feet.z + lift - eye.z + ); + float length = toRing.Length(); - double dot = - (view.x * toRing.x + view.y * toRing.y + view.z * toRing.z) / 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 angle = Math.Acos(Math.Clamp(dot, -1.0, 1.0)) * 180.0 / Math.PI; + double dot = + (view.x * toRing.x + view.y * toRing.y + view.z * toRing.z) + / length; - if (angle < bestAngle) - { - bestAngle = angle; - best = lineup; + double angle = + Math.Acos(Math.Clamp(dot, -1.0, 1.0)) * 180.0 / Math.PI; + + if (angle < bestAngle) + { + bestAngle = angle; + best = lineup; + } } } From 071c10c595ef2366c99dbd5910dfaca34bb1a12b Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 09:41:31 -0400 Subject: [PATCH 074/107] flight lines are opt-in --- apps/utility-sw/src/Services/PracticeReplay.cs | 5 ----- apps/utility-sw/src/Services/PracticeSystem.cs | 8 ++++---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 8774f6a2..9ce0d484 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -1342,11 +1342,6 @@ private void ShowMarkers(LineupRecord lineup, Vec3 stance) // drawn once by ShowStance -- it belongs to the ground, not the throw. Chevron(stance, lineup.release.yaw, 15f, Amber, MarkerWidth); - // The flight is a plan, so it is dashed, and it only appears for the - // throw you are actually on -- every lineup drawing its own line is - // what made a spot with three throws unreadable. - Dashed(new Vec3(stance.x, stance.y, stance.z + 40f), landing, color, 0.3f, 7); - Diamond(landing, 30f, color, MarkerWidth); Label( new Vec3(landing.x, landing.y, landing.z + 16f), diff --git a/apps/utility-sw/src/Services/PracticeSystem.cs b/apps/utility-sw/src/Services/PracticeSystem.cs index bd51356f..42f0247c 100644 --- a/apps/utility-sw/src/Services/PracticeSystem.cs +++ b/apps/utility-sw/src/Services/PracticeSystem.cs @@ -33,7 +33,9 @@ public class PracticeState // 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; + // Off until asked for: the flight line held on screen read as clutter -- + // "weird lines coming out of the spot" -- not guidance. .ghosts opts in. + public bool Ghosts { get; set; } = false; // 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. @@ -97,11 +99,9 @@ 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; + return _states.TryGetValue(steamId, out PracticeState? state) && state.Ghosts; } public void Forget(ulong steamId) From 28653c6966ae7dd7f72c95bfda155bba15a66a40 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 09:41:59 -0400 Subject: [PATCH 075/107] drop the unused dashed-line helpers --- .../utility-sw/src/Services/PracticeReplay.cs | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 9ce0d484..502047e2 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -1719,27 +1719,6 @@ private void Diamond(Vec3 at, float radius, Color color, float width) AddMarkerBeam(west, north, color, width); } - // A flight is a plan, not an object, so it is drawn as one. - private void Dashed(Vec3 from, Vec3 to, Color color, float width, int dashes) - { - for (int index = 0; index < dashes; index += 1) - { - float start = index / (float)dashes; - float end = start + (0.55f / dashes); - - AddMarkerBeam(Lerp(from, to, start), Lerp(from, to, end), color, width); - } - } - - private static Vec3 Lerp(Vec3 from, Vec3 to, float t) - { - return new Vec3( - from.x + ((to.x - from.x) * t), - from.y + ((to.y - from.y) * t), - from.z + ((to.z - from.z) * t) - ); - } - // The panel sets every caption in mono uppercase on wide tracking, and // point_worldtext has no letter-spacing, so the spacing goes in the string. public static string Tracked(string text) From f4297413443c77997615964389079cfbad0f5bdf Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 09:46:25 -0400 Subject: [PATCH 076/107] one precise needle per throw and nothing to read off the floor --- .../utility-sw/src/Services/PracticeReplay.cs | 58 ++++++++----------- 1 file changed, 24 insertions(+), 34 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 502047e2..1fd3734e 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -1081,7 +1081,7 @@ public void ShowLibrary(IEnumerable lineups) // No name on the ground and no post: the chevron says which way, // the glowing model says what and where, and the name arrives in // centre text when the player points at the grenade. - Chevron(feet, lineup.release.yaw, 13f, AmberDim, MarkerWidth); + Needle(feet, lineup.release.yaw, 13f, AmberDim, MarkerWidth); Diamond(lineup.detonation_position, 22f, type, MarkerWidth); } @@ -1144,7 +1144,7 @@ public void ShowSelection( if (active.Count > 0) { - ShowStance(Grounded(active.First().release.feet_position), active.Count); + ShowStance(Grounded(active.First().release.feet_position)); } foreach (LineupRecord lineup in active) @@ -1178,7 +1178,7 @@ public void ShowSelection( try { - ShowStance(selection.At, active.Count); + ShowStance(selection.At); } finally { @@ -1315,17 +1315,9 @@ private void GroundReticle(Vec3 at, Color color) } } - private void ShowStance(Vec3 stance, int throws) + private void ShowStance(Vec3 stance) { GroundReticle(stance, ColorForBucket(MissBuckets - 1)); - - Label( - new Vec3(stance.x, stance.y, stance.z + 14f), - throws > 1 - ? $"{Tracked("stand here")}\n{throws} throws from this spot" - : Tracked("stand here"), - Amber - ); } private void ShowMarkers(LineupRecord lineup, Vec3 stance) @@ -1336,12 +1328,6 @@ private void ShowMarkers(LineupRecord lineup, Vec3 stance) Color color = ColorFor(lineup.utility_type); Vec3 landing = lineup.detonation_position; - // One chevron per throw, all fanning out of the one gate: a spot with - // three lineups shows three directions leaving it, which is the whole - // question a player has when they walk onto it. The gate itself is - // drawn once by ShowStance -- it belongs to the ground, not the throw. - Chevron(stance, lineup.release.yaw, 15f, Amber, MarkerWidth); - Diamond(landing, 30f, color, MarkerWidth); Label( new Vec3(landing.x, landing.y, landing.z + 16f), @@ -1682,26 +1668,30 @@ private static (Vec3 forward, Vec3 right) Bearing(float yaw) return (forward, new Vec3(forward.y, -forward.x, 0f)); } - private static Vec3 Offset(Vec3 at, Vec3 forward, float along, Vec3 right, float across) - { - return new Vec3( - at.x + (forward.x * along) + (right.x * across), - at.y + (forward.y * along) + (right.y * across), - at.z - ); - } - - // A caret on the floor pointing down the throw. Two beams against a ring's - // ten, and it says the thing a ring cannot: which way to face once you are - // standing on it. - private void Chevron(Vec3 at, float yaw, float size, Color color, float width) + // 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 = Offset(at, forward, size, right, 0f); + Vec3 tip = At(size * 2.2f, 0f); - AddMarkerBeam(Offset(at, forward, -size * 0.4f, right, -size * 0.8f), tip, color, width); - AddMarkerBeam(Offset(at, forward, -size * 0.4f, right, size * 0.8f), tip, color, width); + 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 From 4c0a0c870bea3f06c04ccb51f379ea6b98333b02 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 09:48:07 -0400 Subject: [PATCH 077/107] fade the crosshair once the player is on it --- apps/utility-sw/src/Services/PracticeReplay.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 1fd3734e..a1da7614 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -180,6 +180,10 @@ private class Aim 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 @@ -1508,7 +1512,15 @@ public void TintAim(IPlayer player, float eyeYaw, float eyePitch, Vec3 feet) } aim.Bucket = bucket; - Recolour(aim.Beams, ColorForBucket(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) + ); } } From e14064b3522f014f613469b17b3cef8c80110004 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 09:50:34 -0400 Subject: [PATCH 078/107] give the ground circle the sky reticle's crosshair --- .../utility-sw/src/Services/PracticeReplay.cs | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index a1da7614..a497828e 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -1305,8 +1305,13 @@ private void GroundReticle(Vec3 at, Color color) ); } - float arm = StanceRingRadius * 0.75f; - float gap = StanceRingRadius * 0.25f; + // 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) }) { @@ -1314,9 +1319,24 @@ private void GroundReticle(Vec3 at, Color color) new Vec3(at.x + (x * gap), at.y + (y * gap), z), new Vec3(at.x + (x * arm), at.y + (y * arm), z), color, - StanceWidth + 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) @@ -1658,6 +1678,8 @@ private Vec3 Grounded(Vec3 position) 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; From 03302cc3b5090988bc724d7ecc8c9bdd80796813 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 09:57:19 -0400 Subject: [PATCH 079/107] trace the aim from the lineup's spot, not the player's --- .../utility-sw/src/Services/PracticeReplay.cs | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index a497828e..fdc0ca80 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -1153,7 +1153,7 @@ public void ShowSelection( foreach (LineupRecord lineup in active) { - ShowMarkers(lineup, stance); + ShowMarkers(lineup); } return; @@ -1191,7 +1191,7 @@ public void ShowSelection( foreach (LineupRecord lineup in active) { - ShowMarkers(lineup, stance); + ShowMarkers(lineup); } } finally @@ -1344,7 +1344,7 @@ private void ShowStance(Vec3 stance) GroundReticle(stance, ColorForBucket(MissBuckets - 1)); } - private void ShowMarkers(LineupRecord lineup, Vec3 stance) + 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. @@ -1359,25 +1359,31 @@ private void ShowMarkers(LineupRecord lineup, Vec3 stance) color ); - AimReticle(lineup, stance, lineup.name); + 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, Vec3 stance, string label) - { - // From where the player's eye WILL BE after .load, not from where the - // grenade left the hand. Those stopped being the same point when the - // recorder started storing the standstill as feet_position: a run- or - // jump-throw releases a whole run-up away from where it is set up, and - // tracing from the release point puts the reticle that far off from - // what the player sees standing on the marker. + 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( - stance.x, - stance.y, - stance.z + PracticeSolverUtility.StandingEyeHeight + spot.x, + spot.y, + spot.z + PracticeSolverUtility.StandingEyeHeight ); double yaw = lineup.release.yaw * Math.PI / 180.0; From c7674da95848294274ebab6b08d2cf32a378cf56 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 10:04:18 -0400 Subject: [PATCH 080/107] tag every marker and sweep the world on load, map change, unload and clear --- apps/utility-sw/src/Commands/Practice.cs | 9 ++- .../utility-sw/src/Services/PracticeReplay.cs | 65 ++++++++++++++++++- apps/utility-sw/src/UtilityPracticePlugin.cs | 23 +++++-- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 98a4a3de..af7c6341 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -255,7 +255,14 @@ public void OnClear(ICommandContext context) state.Bloom = false; _replay.ClearGhosts(player.SteamID); - _replay.ClearMarkers(); + + // 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"); } diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index fdc0ca80..e20b4593 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -1695,6 +1695,66 @@ private Vec3 Grounded(Vec3 position) 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; @@ -1800,6 +1860,7 @@ private void UtilityModel(string utilityType, Vec3 at) 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( @@ -1948,7 +2009,7 @@ private static Vec3 Normalize(Vec3 v) new Vector(0, 0, 0) ); - label.DispatchSpawn(); + label.DispatchSpawn(Tagged()); if (_drawingInto != null) { @@ -2096,7 +2157,7 @@ private static bool Sane(Vec3 point) beam.EndPos = new Vector(end.x, end.y, end.z); - beam.DispatchSpawn(); + beam.DispatchSpawn(Tagged()); return beam; } diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 601f5be4..f9114e25 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -170,6 +170,15 @@ public override void Load(bool hotReload) // 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()); @@ -187,6 +196,11 @@ public override void Load(bool hotReload) 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; @@ -815,11 +829,10 @@ private void OnMapLoad(string mapName) // does not survive the mesh being replaced. _solver.Reset(); - // Beams and world text do not survive a map change as anything useful: - // the handles go stale while the tracked lists still hold them, so - // nothing ever clears them and the next load draws on top of a list - // that can never be emptied. - _replay.ForgetMarkers(); + // 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); From fb1a39d8fb798b5fff6f8bc2c656c7569227f85f Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 10:26:05 -0400 Subject: [PATCH 081/107] three-row lineup window that always names the throw --- .../utility-sw/src/Services/PracticeReplay.cs | 7 ++-- apps/utility-sw/src/UtilityPracticePlugin.cs | 35 +++++++++++-------- .../Practice/LineupRecord.cs | 4 +++ .../Practice/UtilityLibraryRow.cs | 3 ++ 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index e20b4593..91bb26b8 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -851,9 +851,12 @@ public static string ThrowHint(LineupRecord lineup) _ => "LEFT CLICK", }; - string bind = lineup.release.jump_throw ? " (jump-throw bind)" : ""; + string bind = lineup.release.jump_throw ? " + JUMP-THROW BIND" : ""; - return $"LINED UP\n{movement} - {click}{bind}"; + // No LINED UP banner: the crosshair fading to nothing already says it, + // and this line has a standing job as the middle row rather than + // appearing only at the moment of success. + return $"{lineup.utility_type.ToUpperInvariant()} - {movement} - {click}{bind}"; } public static string Describe(LineupRecord lineup) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index f9114e25..88ce7533 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -572,16 +572,7 @@ private void AimFeedback() new Vec3(spot.x - standing.X, spot.y - standing.Y, 0f).LengthXY() ) == 0f; - if (onAngle && onSpot) - { - return PracticeReplay.ThrowHint(lineup); - } - - if (onAngle) - { - return $"{PracticeReplay.Tracked("on the angle")}\n" - + PracticeReplay.Tracked("wrong spot"); - } + return Describe(lineup, onSpot, onAngle); } Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); @@ -593,12 +584,12 @@ private void AimFeedback() if (aimedAt != null) { - return Describe(aimedAt); + return Describe(aimedAt, here.Contains(aimedAt)); } if (here.Count == 1) { - return Describe(here[0]); + return Describe(here[0], true); } // Several throws off one spot: name them all rather than a count, so the @@ -611,10 +602,24 @@ private void AimFeedback() return null; } - private static string Describe(LineupRecord lineup) + // Three rows, always the same three, so the window never reflows under the + // player's eyes: WHAT it is called, HOW it is thrown, WHAT TO DO NEXT. + // The name stays put throughout -- losing it the moment you lined up was + // the thing that made this window feel like it was flickering at you. + private static string Describe( + LineupRecord lineup, + bool onSpot = false, + bool onAngle = false + ) { - return $"{lineup.name}\n{lineup.utility_type.ToUpperInvariant()} - " - + lineup.technique.ToUpperInvariant(); + string next = + !onSpot ? PracticeReplay.Tracked("stand in the circle") + : !onAngle ? PracticeReplay.Tracked("match the crosshair") + : string.IsNullOrWhiteSpace(lineup.description) + ? PracticeReplay.Tracked("throw it") + : PracticeReplay.Tracked("write-up on the web"); + + return $"{lineup.name}\n{PracticeReplay.ThrowHint(lineup)}\n{next}"; } // Shortest way round the circle, so 359 and 1 are two degrees apart. diff --git a/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs b/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs index ccd0a2cd..656e27ce 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/LineupRecord.cs @@ -41,6 +41,10 @@ public class LineupRecord // 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. diff --git a/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs b/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs index 0be525a9..46a325b2 100644 --- a/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs +++ b/shared/dotnet/FiveStack.Entities/Practice/UtilityLibraryRow.cs @@ -21,6 +21,8 @@ public class UtilityLibraryRow 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; } @@ -67,6 +69,7 @@ public LineupRecord ToLineup() technique = technique ?? "", strength = throw_strength, aim_tolerance = (float)(aim_tolerance ?? 0d), + description = description, visibility = visibility ?? "Private", confidence = confidence, author_steam_id = author_steam_id ?? "", From 157f9c2c85ac8e6ca546edea42a60a1fd8c9829d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 10:58:59 -0400 Subject: [PATCH 082/107] split the lineup hud into a card and a steps line --- apps/utility-sw/src/UtilityPracticePlugin.cs | 108 +++++++++++++------ 1 file changed, 74 insertions(+), 34 deletions(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 88ce7533..a18eb428 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -534,19 +534,45 @@ private void AimFeedback() new Vec3(standing.X, standing.Y, standing.Z) ); - string? message = CentreText(player, pawn); - - if (message != null) - { - player.SendCenter(message); - } + 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. - private string? CentreText(IPlayer player, CCSPlayerPawn pawn) + // 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. + private void Panels(IPlayer player, CCSPlayerPawn pawn) + { + (LineupRecord? lineup, bool onSpot, bool onAngle) = Focused(player, pawn); + + if (lineup == null) + { + return; + } + + // Held a little past the resend interval so it never blinks between + // frames, and re-sent because centre HTML expires on its own. + player.SendCenterHTML(Card(lineup), CardHoldMilliseconds); + + string? steps = Steps(onSpot, onAngle); + + if (steps != null) + { + player.SendCenter(steps); + } + } + + // Slightly longer than the resend interval, so the card is continuous. + private const int CardHoldMilliseconds = 400; + + private (LineupRecord? lineup, bool onSpot, bool onAngle) Focused( + IPlayer player, + CCSPlayerPawn pawn + ) { LineupRecord? lineup = _system.StateFor(player.SteamID).Loaded; @@ -572,7 +598,7 @@ private void AimFeedback() new Vec3(spot.x - standing.X, spot.y - standing.Y, 0f).LengthXY() ) == 0f; - return Describe(lineup, onSpot, onAngle); + return (lineup, onSpot, onAngle); } Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); @@ -582,44 +608,58 @@ private void AimFeedback() List here = PracticeReplay.SpotAt(library, at); LineupRecord? aimedAt = LookingAt(pawn, at, library, here); + // Nothing loaded, so nothing is "done" yet -- the panels describe what + // the player is pointing at or standing on, and the steps line tells + // them the first thing to do about it. if (aimedAt != null) { - return Describe(aimedAt, here.Contains(aimedAt)); + return (aimedAt, here.Contains(aimedAt), false); } if (here.Count == 1) { - return Describe(here[0], true); + return (here[0], true, false); } - // Several throws off one spot: name them all rather than a count, so the - // player knows what is on offer before choosing one. - if (here.Count > 1) - { - return string.Join("\n", here.Select(entry => entry.name)); - } + return (null, false, false); + } - return null; + // 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. + private static string Card(LineupRecord lineup) + { + string details = string.IsNullOrWhiteSpace(lineup.description) + ? "" + : "
write-up on the web"; + + return $"{Escape(lineup.name)}" + + $"
{PracticeReplay.ThrowHint(lineup)}" + + details; } - // Three rows, always the same three, so the window never reflows under the - // player's eyes: WHAT it is called, HOW it is thrown, WHAT TO DO NEXT. - // The name stays put throughout -- losing it the moment you lined up was - // the thing that made this window feel like it was flickering at you. - private static string Describe( - LineupRecord lineup, - bool onSpot = false, - bool onAngle = false - ) + // Centre HTML is markup, and a lineup name is whatever somebody typed. + private static string Escape(string text) { - string next = - !onSpot ? PracticeReplay.Tracked("stand in the circle") - : !onAngle ? PracticeReplay.Tracked("match the crosshair") - : string.IsNullOrWhiteSpace(lineup.description) - ? PracticeReplay.Tracked("throw it") - : PracticeReplay.Tracked("write-up on the web"); - - return $"{lineup.name}\n{PracticeReplay.ThrowHint(lineup)}\n{next}"; + return text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); + } + + // The other panel: the one step that is not done yet. It closes the moment + // the player is lined up -- silence IS the success signal, alongside the + // crosshair fading out, so nothing is left shouting over the shot. + private static string? Steps(bool onSpot, bool onAngle) + { + if (!onSpot) + { + return PracticeReplay.Tracked("stand in the circle"); + } + + if (!onAngle) + { + return PracticeReplay.Tracked("match the crosshair"); + } + + return null; } // Shortest way round the circle, so 359 and 1 are two degrees apart. From 804998773384625725dee6735c42db5992f90160 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 11:14:04 -0400 Subject: [PATCH 083/107] stop the hud strobing and teach every technique properly --- .../test/PracticeLineupUtilityTests.cs | 44 +++++++++++++ .../utility-sw/src/Services/PracticeReplay.cs | 27 ++------ apps/utility-sw/src/UtilityPracticePlugin.cs | 65 ++++++++++++++++--- .../test/PracticeLineupUtilityTests.cs | 44 +++++++++++++ .../PracticeLineupUtility.cs | 32 +++++++++ 5 files changed, 180 insertions(+), 32 deletions(-) diff --git a/apps/utility-css/test/PracticeLineupUtilityTests.cs b/apps/utility-css/test/PracticeLineupUtilityTests.cs index f7063ff7..fbd995a8 100644 --- a/apps/utility-css/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-css/test/PracticeLineupUtilityTests.cs @@ -1,4 +1,5 @@ using FiveStack.Entities.Practice; +using FiveStack.Enums; using FiveStack.Utilities; using Xunit; @@ -329,3 +330,46 @@ public void EveryMissLandsInsideTheStepRange() } } } + +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)); + } +} diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 91bb26b8..283a130c 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -832,31 +832,14 @@ public void Sweep() // lineup knows about the throw, in the order a player performs it. public static string ThrowHint(LineupRecord lineup) { - string movement = lineup.technique switch - { - "Jump" => "JUMP THROW", - "Run" => "RUN AND THROW", - "RunJump" => "RUN + JUMP THROW", - "Crouch" => "CROUCH THROW", - "CrouchJump" => "CROUCH + JUMP THROW", - "Walk" => "WALK AND THROW", - _ => "STAND STILL", - }; - - string click = lineup.strength switch - { - "Half" => "LEFT + RIGHT CLICK", - "Drop" => "RIGHT CLICK", - "Full" => "LEFT CLICK", - _ => "LEFT CLICK", - }; - 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 as the middle row rather than - // appearing only at the moment of success. - return $"{lineup.utility_type.ToUpperInvariant()} - {movement} - {click}{bind}"; + // 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) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index a18eb428..2440efa8 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -549,26 +549,69 @@ private void Panels(IPlayer player, CCSPlayerPawn pawn) { (LineupRecord? lineup, bool onSpot, bool onAngle) = Focused(player, pawn); - if (lineup == null) + Send(player, PanelKind.Card, lineup == null ? null : Card(lineup)); + Send(player, PanelKind.Steps, lineup == null ? null : Steps(onSpot, onAngle)); + } + + private enum PanelKind + { + 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(); + + // Long enough that a keepalive never races the expiry, short enough that a + // player who disconnects mid-throw does not leave a panel hanging. + private const int PanelHoldMilliseconds = 8000; + + // A keepalive well inside the hold, so the panel is continuous without + // being rewritten on every pass. + private const int PanelKeepAliveTicks = 128; + + 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); + Write(player, kind, ""); + } + return; } - // Held a little past the resend interval so it never blinks between - // frames, and re-sent because centre HTML expires on its own. - player.SendCenterHTML(Card(lineup), CardHoldMilliseconds); + if (had && showing == content && _aimTick % PanelKeepAliveTicks != 0) + { + return; + } - string? steps = Steps(onSpot, onAngle); + _showing[key] = content; + Write(player, kind, content); + } - if (steps != null) + private static void Write(IPlayer player, PanelKind kind, string content) + { + if (kind == PanelKind.Card) { - player.SendCenter(steps); + player.SendCenterHTML(content, PanelHoldMilliseconds); + } + else + { + player.SendCenter(content); } } - // Slightly longer than the resend interval, so the card is continuous. - private const int CardHoldMilliseconds = 400; - private (LineupRecord? lineup, bool onSpot, bool onAngle) Focused( IPlayer player, CCSPlayerPawn pawn @@ -848,6 +891,8 @@ private void OnPlayerGone(ulong steamId) // hand to something else. _replay.ClearSelectionFor(steamId); _standingIn.Remove(steamId); + _showing.Remove((steamId, PanelKind.Card)); + _showing.Remove((steamId, PanelKind.Steps)); } // Swiftly's client events carry a slot, not a steam id. diff --git a/apps/utility-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs index f7063ff7..fbd995a8 100644 --- a/apps/utility-sw/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-sw/test/PracticeLineupUtilityTests.cs @@ -1,4 +1,5 @@ using FiveStack.Entities.Practice; +using FiveStack.Enums; using FiveStack.Utilities; using Xunit; @@ -329,3 +330,46 @@ public void EveryMissLandsInsideTheStepRange() } } } + +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)); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index 0767fc66..46a6b8a2 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -1,4 +1,5 @@ using FiveStack.Entities.Practice; +using FiveStack.Enums; namespace FiveStack.Utilities; @@ -172,6 +173,37 @@ public static int MissBucket(float miss, int buckets) 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", + }; + } + // A lineup that never said how precise it is. public const float DefaultAimTolerance = 0.35f; From 4116d84abbf8d324d14d863b8066b51ed7fedde0 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 11:16:51 -0400 Subject: [PATCH 084/107] quieten the panel keepalive and clear with a space --- apps/utility-sw/src/UtilityPracticePlugin.cs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 2440efa8..ed44de90 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -565,13 +565,15 @@ private enum PanelKind // written only when what it says actually changes. private readonly Dictionary<(ulong, PanelKind), string> _showing = new(); - // Long enough that a keepalive never races the expiry, short enough that a - // player who disconnects mid-throw does not leave a panel hanging. - private const int PanelHoldMilliseconds = 8000; + // EVERY write restarts the panel's fade-in, so a keepalive is a visible + // pulse no matter how rarely it runs -- the only cure is to make it rare. + // A minute of hold, refreshed at half that, turns a flash every two seconds + // into one every thirty, and the panel is explicitly cleared the moment it + // stops being true rather than relying on this to expire. + private const int PanelHoldMilliseconds = 60000; - // A keepalive well inside the hold, so the panel is continuous without - // being rewritten on every pass. - private const int PanelKeepAliveTicks = 128; + // Derived so the two can never drift into a gap: half the hold, in ticks. + private const int PanelKeepAliveTicks = (PanelHoldMilliseconds / 1000 / 2) * 64; private void Send(IPlayer player, PanelKind kind, string? content) { @@ -585,7 +587,10 @@ private void Send(IPlayer player, PanelKind kind, string? content) if (had) { _showing.Remove(key); - Write(player, kind, ""); + + // A space, not an empty string: an empty centre message draws a + // stray glyph rather than nothing at all. + Write(player, kind, " "); } return; From 3f5472b7a1056e2dc62b83a64027d84d52d07762 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 11:17:33 -0400 Subject: [PATCH 085/107] drop the panels when the player leaves the spot --- apps/utility-sw/src/UtilityPracticePlugin.cs | 44 ++++++++++---------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index ed44de90..9001cc87 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -622,42 +622,44 @@ private static void Write(IPlayer player, PanelKind kind, string content) CCSPlayerPawn pawn ) { - LineupRecord? lineup = _system.StateFor(player.SteamID).Loaded; + 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; - if (lineup != null) + // 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 && (here.Contains(loaded) || aimedAt == loaded)) { QAngle eyes = pawn.EyeAngles; - Vector standing = pawn.AbsOrigin ?? new Vector(0, 0, 0); - Vec3 spot = lineup.release.feet_position; + Vec3 spot = loaded.release.feet_position; bool onAngle = PracticeLineupUtility.AimError( eyes.Y, eyes.X, - lineup.release.yaw, - lineup.release.pitch - ) <= ToleranceFor(lineup); + loaded.release.yaw, + loaded.release.pitch + ) <= ToleranceFor(loaded); - // Both halves, not just the angle. Saying LINED UP to somebody - // holding the right angle from the wrong place is worse than saying - // nothing -- they throw it, it misses, and the lineup gets blamed. + // 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 - standing.X, spot.y - standing.Y, 0f).LengthXY() + new Vec3(spot.x - origin.X, spot.y - origin.Y, 0f).LengthXY() ) == 0f; - return (lineup, onSpot, onAngle); + return (loaded, onSpot, onAngle); } - 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); - - // Nothing loaded, so nothing is "done" yet -- the panels describe what - // the player is pointing at or standing on, and the steps line tells + // 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) { From 1f45a7eba857932a5bf39517a030f6f369b24100 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 11:18:43 -0400 Subject: [PATCH 086/107] clear panels instantly instead of blanking them for a minute --- apps/utility-sw/src/UtilityPracticePlugin.cs | 24 ++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 9001cc87..3fe5a12e 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -587,10 +587,7 @@ private void Send(IPlayer player, PanelKind kind, string? content) if (had) { _showing.Remove(key); - - // A space, not an empty string: an empty centre message draws a - // stray glyph rather than nothing at all. - Write(player, kind, " "); + Clear(player, kind); } return; @@ -617,6 +614,25 @@ private static void Write(IPlayer player, PanelKind kind, string 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. The card blanks to nothing + // at all; the steps line blanks to a space, because an empty centre message + // draws a stray glyph instead of drawing nothing. + private const int PanelClearMilliseconds = 1; + + private static void Clear(IPlayer player, PanelKind kind) + { + if (kind == PanelKind.Card) + { + player.SendCenterHTML("", PanelClearMilliseconds); + } + else + { + player.SendCenter(" "); + } + } + private (LineupRecord? lineup, bool onSpot, bool onAngle) Focused( IPlayer player, CCSPlayerPawn pawn From d1d8e0f4487e343c39cae0d6a608240b8981036a Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 11:25:02 -0400 Subject: [PATCH 087/107] let the steps line lapse rather than blanking it --- apps/utility-sw/src/UtilityPracticePlugin.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 3fe5a12e..e37b23ab 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -616,9 +616,7 @@ private static void Write(IPlayer player, PanelKind kind, string 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. The card blanks to nothing - // at all; the steps line blanks to a space, because an empty centre message - // draws a stray glyph instead of drawing nothing. + // an empty panel sitting on screen for a minute. private const int PanelClearMilliseconds = 1; private static void Clear(IPlayer player, PanelKind kind) @@ -626,11 +624,15 @@ private static void Clear(IPlayer player, PanelKind kind) if (kind == PanelKind.Card) { player.SendCenterHTML("", PanelClearMilliseconds); + + return; } - else - { - player.SendCenter(" "); - } + + // The steps line is NOT written blank. SendCenter takes no duration, so + // a blank write just starts another full-length message that happens to + // have nothing in it -- which is slower to disappear than the real line + // it replaced. Writing nothing lets the last line lapse on the game's + // own schedule instead. } private (LineupRecord? lineup, bool onSpot, bool onAngle) Focused( From ea7b0371098c305e2e9215c9814dbb71013e4238 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 11:28:15 -0400 Subject: [PATCH 088/107] swap the panels so the flashing one carries the changing message --- apps/utility-sw/src/UtilityPracticePlugin.cs | 70 +++++++++++--------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index e37b23ab..5bad42df 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -565,15 +565,18 @@ private enum PanelKind // written only when what it says actually changes. private readonly Dictionary<(ulong, PanelKind), string> _showing = new(); - // EVERY write restarts the panel's fade-in, so a keepalive is a visible - // pulse no matter how rarely it runs -- the only cure is to make it rare. - // A minute of hold, refreshed at half that, turns a flash every two seconds - // into one every thirty, and the panel is explicitly cleared the moment it - // stops being true rather than relying on this to expire. + // 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; - // Derived so the two can never drift into a gap: half the hold, in ticks. - private const int PanelKeepAliveTicks = (PanelHoldMilliseconds / 1000 / 2) * 64; + // 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) { @@ -593,7 +596,9 @@ private void Send(IPlayer player, PanelKind kind, string? content) return; } - if (had && showing == content && _aimTick % PanelKeepAliveTicks != 0) + int keepAlive = kind == PanelKind.Steps ? StepsKeepAliveTicks : CardKeepAliveTicks; + + if (had && showing == content && _aimTick % keepAlive != 0) { return; } @@ -604,7 +609,11 @@ private void Send(IPlayer player, PanelKind kind, string? content) private static void Write(IPlayer player, PanelKind kind, string content) { - if (kind == PanelKind.Card) + // 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); } @@ -621,18 +630,17 @@ private static void Write(IPlayer player, PanelKind kind, string content) private static void Clear(IPlayer player, PanelKind kind) { - if (kind == PanelKind.Card) + if (kind == PanelKind.Steps) { player.SendCenterHTML("", PanelClearMilliseconds); return; } - // The steps line is NOT written blank. SendCenter takes no duration, so - // a blank write just starts another full-length message that happens to - // have nothing in it -- which is slower to disappear than the real line - // it replaced. Writing nothing lets the last line lapse on the game's - // own schedule instead. + // The card is NOT written blank. SendCenter takes no duration, so a + // blank write just starts another full-length message that happens to + // have nothing in it -- slower to disappear than the line it replaced. + // Writing nothing lets it lapse on the game's own schedule instead. } private (LineupRecord? lineup, bool onSpot, bool onAngle) Focused( @@ -695,41 +703,43 @@ CCSPlayerPawn pawn // 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. private static string Card(LineupRecord lineup) { string details = string.IsNullOrWhiteSpace(lineup.description) ? "" - : "
write-up on the web"; - - return $"{Escape(lineup.name)}" - + $"
{PracticeReplay.ThrowHint(lineup)}" - + details; - } + : $"\n{PracticeReplay.Tracked("write-up on the web")}"; - // Centre HTML is markup, and a lineup name is whatever somebody typed. - private static string Escape(string text) - { - return text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); + return $"{lineup.name}\n{PracticeReplay.ThrowHint(lineup)}{details}"; } - // The other panel: the one step that is not done yet. It closes the moment - // the player is lined up -- silence IS the success signal, alongside the - // crosshair fading out, so nothing is left shouting over the shot. + // 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. private static string? Steps(bool onSpot, bool onAngle) { if (!onSpot) { - return PracticeReplay.Tracked("stand in the circle"); + return Instruction("stand in the circle", "#f99e2f"); } if (!onAngle) { - return PracticeReplay.Tracked("match the crosshair"); + return Instruction("match the crosshair", "#f99e2f"); } return null; } + private static string Instruction(string text, string color) + { + return $"" + + $"{PracticeReplay.Tracked(text)}"; + } + // Shortest way round the circle, so 359 and 1 are two degrees apart. private void OnSecond() From 9f8e88c854b1de71694a275bd951d64a39d8e516 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 11:30:59 -0400 Subject: [PATCH 089/107] keep word gaps in the html instruction --- .../test/PracticeLineupUtilityTests.cs | 20 +++++++++++++++++++ .../utility-sw/src/Services/PracticeReplay.cs | 9 +-------- apps/utility-sw/src/UtilityPracticePlugin.cs | 4 ++-- .../test/PracticeLineupUtilityTests.cs | 20 +++++++++++++++++++ .../PracticeLineupUtility.cs | 15 ++++++++++++++ 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/apps/utility-css/test/PracticeLineupUtilityTests.cs b/apps/utility-css/test/PracticeLineupUtilityTests.cs index fbd995a8..93ea000f 100644 --- a/apps/utility-css/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-css/test/PracticeLineupUtilityTests.cs @@ -373,3 +373,23 @@ 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")); + } +} diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 283a130c..e6210ca6 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -1341,7 +1341,7 @@ private void ShowMarkers(LineupRecord lineup) Diamond(landing, 30f, color, MarkerWidth); Label( new Vec3(landing.x, landing.y, landing.z + 16f), - Tracked(lineup.utility_type), + PracticeLineupUtility.Tracked(lineup.utility_type), color ); @@ -1795,13 +1795,6 @@ private void Diamond(Vec3 at, float radius, Color color, float width) AddMarkerBeam(west, north, color, width); } - // The panel sets every caption in mono uppercase on wide tracking, and - // point_worldtext has no letter-spacing, so the spacing goes in the string. - public static string Tracked(string text) - { - return string.Join(" ", text.ToUpperInvariant().ToCharArray()); - } - private void UtilityModel(string utilityType, Vec3 at) { if (!DrawModels) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 5bad42df..8cd531a5 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -710,7 +710,7 @@ private static string Card(LineupRecord lineup) { string details = string.IsNullOrWhiteSpace(lineup.description) ? "" - : $"\n{PracticeReplay.Tracked("write-up on the web")}"; + : $"\n{PracticeLineupUtility.Tracked("write-up on the web")}"; return $"{lineup.name}\n{PracticeReplay.ThrowHint(lineup)}{details}"; } @@ -737,7 +737,7 @@ private static string Card(LineupRecord lineup) private static string Instruction(string text, string color) { return $"" - + $"{PracticeReplay.Tracked(text)}"; + + $"{PracticeLineupUtility.TrackedHtml(text)}"; } // Shortest way round the circle, so 359 and 1 are two degrees apart. diff --git a/apps/utility-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs index fbd995a8..93ea000f 100644 --- a/apps/utility-sw/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-sw/test/PracticeLineupUtilityTests.cs @@ -373,3 +373,23 @@ 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")); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index 46a6b8a2..b09fd60c 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -204,6 +204,21 @@ public static string StrengthLabel(string? strength) }; } + // 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; From c604ec851ba3b1b37d5452e2f327fcded8846770 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 11:38:04 -0400 Subject: [PATCH 090/107] give the lineup name its own hud channel --- apps/utility-sw/src/UtilityPracticePlugin.cs | 45 +++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 8cd531a5..0783d955 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -9,6 +9,7 @@ using SwiftlyS2.Shared.Plugins; using static SwiftlyS2.Shared.Helper; using SwiftlyS2.Shared.Natives; +using SwiftlyS2.Shared.ProtobufDefinitions; using SwiftlyS2.Shared.SchemaDefinitions; namespace UtilityPractice; @@ -549,12 +550,14 @@ private void Panels(IPlayer player, CCSPlayerPawn pawn) { (LineupRecord? lineup, bool onSpot, bool onAngle) = Focused(player, pawn); + Send(player, PanelKind.Title, lineup?.name); Send(player, PanelKind.Card, lineup == null ? null : Card(lineup)); Send(player, PanelKind.Steps, lineup == null ? null : Steps(onSpot, onAngle)); } private enum PanelKind { + Title, Card, Steps, } @@ -607,6 +610,33 @@ private void Send(IPlayer player, PanelKind kind, string? content) Write(player, kind, content); } + // A positioned HUD message on its own channel -- a third element entirely, + // independent of the two text channels, so the name can sit above the + // throw details instead of sharing a line with them. Channel and position + // are ours to pick; nothing else in the plugin writes this channel. + private const int TitleChannel = 4; + + private void WriteTitle(IPlayer player, string content) + { + try + { + using var message = Core.NetMessage.Create(); + + message.Channel = TitleChannel; + + // -1 centres an axis. Just above the middle, clear of the crosshair. + message.X = -1f; + message.Y = 0.34f; + message.Message = content; + + message.SendToPlayer((int)player.Slot); + } + catch (Exception error) + { + _logger.LogWarning(error, "unable to write the lineup title"); + } + } + private static void Write(IPlayer player, PanelKind kind, string content) { // Steps take the animating HTML panel, the card takes the quiet one. @@ -628,8 +658,16 @@ private static void Write(IPlayer player, PanelKind kind, string content) // an empty panel sitting on screen for a minute. private const int PanelClearMilliseconds = 1; - private static void Clear(IPlayer player, PanelKind kind) + private void Clear(IPlayer player, PanelKind kind) { + // An empty HUD message retires the channel outright. + if (kind == PanelKind.Title) + { + WriteTitle(player, ""); + + return; + } + if (kind == PanelKind.Steps) { player.SendCenterHTML("", PanelClearMilliseconds); @@ -706,13 +744,15 @@ CCSPlayerPawn pawn // 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. + // Throw details only. The name lives in its own HUD channel now, so this + // stays purely "how do I throw it" and never reflows when the name changes. private static string Card(LineupRecord lineup) { string details = string.IsNullOrWhiteSpace(lineup.description) ? "" : $"\n{PracticeLineupUtility.Tracked("write-up on the web")}"; - return $"{lineup.name}\n{PracticeReplay.ThrowHint(lineup)}{details}"; + return $"{PracticeReplay.ThrowHint(lineup)}{details}"; } // The one step that is not done yet, on the animating channel -- where a @@ -926,6 +966,7 @@ private void OnPlayerGone(ulong steamId) // hand to something else. _replay.ClearSelectionFor(steamId); _standingIn.Remove(steamId); + _showing.Remove((steamId, PanelKind.Title)); _showing.Remove((steamId, PanelKind.Card)); _showing.Remove((steamId, PanelKind.Steps)); } From 294b371ba21032c47a44a0ab893f2c3db92217da Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 11:54:46 -0400 Subject: [PATCH 091/107] name on the panel that renders, and draw the library on join --- apps/utility-sw/src/UtilityPracticePlugin.cs | 86 ++++++++++---------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 0783d955..68a9ee4d 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -9,7 +9,6 @@ using SwiftlyS2.Shared.Plugins; using static SwiftlyS2.Shared.Helper; using SwiftlyS2.Shared.Natives; -using SwiftlyS2.Shared.ProtobufDefinitions; using SwiftlyS2.Shared.SchemaDefinitions; namespace UtilityPractice; @@ -155,8 +154,10 @@ public override void Load(bool hotReload) ); 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 => _library.Refresh(steamId)); + ForPlayer(@event.PlayerId, steamId => RefreshAndShow(steamId)); Core.Event.OnClientSteamAuthorize += _authorizeHandler; InitializeConnectClientHook(); @@ -550,14 +551,16 @@ private void Panels(IPlayer player, CCSPlayerPawn pawn) { (LineupRecord? lineup, bool onSpot, bool onAngle) = Focused(player, pawn); - Send(player, PanelKind.Title, lineup?.name); Send(player, PanelKind.Card, lineup == null ? null : Card(lineup)); - Send(player, PanelKind.Steps, lineup == null ? null : Steps(onSpot, onAngle)); + Send( + player, + PanelKind.Steps, + lineup == null ? null : Headline(lineup, onSpot, onAngle) + ); } private enum PanelKind { - Title, Card, Steps, } @@ -610,33 +613,6 @@ private void Send(IPlayer player, PanelKind kind, string? content) Write(player, kind, content); } - // A positioned HUD message on its own channel -- a third element entirely, - // independent of the two text channels, so the name can sit above the - // throw details instead of sharing a line with them. Channel and position - // are ours to pick; nothing else in the plugin writes this channel. - private const int TitleChannel = 4; - - private void WriteTitle(IPlayer player, string content) - { - try - { - using var message = Core.NetMessage.Create(); - - message.Channel = TitleChannel; - - // -1 centres an axis. Just above the middle, clear of the crosshair. - message.X = -1f; - message.Y = 0.34f; - message.Message = content; - - message.SendToPlayer((int)player.Slot); - } - catch (Exception error) - { - _logger.LogWarning(error, "unable to write the lineup title"); - } - } - private static void Write(IPlayer player, PanelKind kind, string content) { // Steps take the animating HTML panel, the card takes the quiet one. @@ -660,14 +636,6 @@ private static void Write(IPlayer player, PanelKind kind, string content) private void Clear(IPlayer player, PanelKind kind) { - // An empty HUD message retires the channel outright. - if (kind == PanelKind.Title) - { - WriteTitle(player, ""); - - return; - } - if (kind == PanelKind.Steps) { player.SendCenterHTML("", PanelClearMilliseconds); @@ -759,6 +727,27 @@ private static string Card(LineupRecord lineup) // 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. + private static string Headline(LineupRecord lineup, bool onSpot, bool onAngle) + { + string name = + $"{Escape(lineup.name)}"; + + string? step = Steps(onSpot, onAngle); + + return step == null ? name : $"{name}
{step}"; + } + + // Centre HTML is markup and a lineup name is whatever somebody typed. + private static string Escape(string text) + { + return text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); + } + private static string? Steps(bool onSpot, bool onAngle) { if (!onSpot) @@ -966,7 +955,6 @@ private void OnPlayerGone(ulong steamId) // hand to something else. _replay.ClearSelectionFor(steamId); _standingIn.Remove(steamId); - _showing.Remove((steamId, PanelKind.Title)); _showing.Remove((steamId, PanelKind.Card)); _showing.Remove((steamId, PanelKind.Steps)); } @@ -1010,6 +998,20 @@ private void OnMapLoad(string mapName) // 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) + { + _replay.ShowLibrary(_library.For(steamId)); + } + } + ); + } + private void RefreshEverything() { _ = Task.Run(async () => @@ -1022,7 +1024,7 @@ private void RefreshEverything() { if (player != null && player.IsValid && !player.IsFakeClient) { - _library.Refresh(player.SteamID); + RefreshAndShow(player.SteamID); } } } From 507b0ae099bd85226f8148baffc518827475993e Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 11:56:43 -0400 Subject: [PATCH 092/107] title to the quiet panel, details to the flashing one --- apps/utility-sw/src/UtilityPracticePlugin.cs | 33 ++++++++++---------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 68a9ee4d..6008c47b 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -712,15 +712,11 @@ CCSPlayerPawn pawn // 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. - // Throw details only. The name lives in its own HUD channel now, so this - // stays purely "how do I throw it" and never reflows when the name changes. + // Just the name, on the channel that does not animate. A title has nothing + // to say that changes, so it belongs where nothing moves. private static string Card(LineupRecord lineup) { - string details = string.IsNullOrWhiteSpace(lineup.description) - ? "" - : $"\n{PracticeLineupUtility.Tracked("write-up on the web")}"; - - return $"{PracticeReplay.ThrowHint(lineup)}{details}"; + return lineup.name; } // The one step that is not done yet, on the animating channel -- where a @@ -732,20 +728,25 @@ private static string Card(LineupRecord lineup) // 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. private static string Headline(LineupRecord lineup, bool onSpot, bool onAngle) { - string name = - $"{Escape(lineup.name)}"; + string how = + "" + + $"{PracticeReplay.ThrowHint(lineup)}"; - string? step = Steps(onSpot, onAngle); + if (!string.IsNullOrWhiteSpace(lineup.description)) + { + how += + "
" + + $"{PracticeLineupUtility.TrackedHtml("write-up on the web")}"; + } - return step == null ? name : $"{name}
{step}"; - } + string? step = Steps(onSpot, onAngle); - // Centre HTML is markup and a lineup name is whatever somebody typed. - private static string Escape(string text) - { - return text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); + return step == null ? how : $"{how}
{step}"; } private static string? Steps(bool onSpot, bool onAngle) From 72f401cfb43ef860bd3a2d347e0b4688a36f6365 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:00:39 -0400 Subject: [PATCH 093/107] put the lineup on the quiet panel and add an alerts sampler --- apps/utility-sw/src/Commands/Practice.cs | 47 ++++++++++++++++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 52 +++++++++++++------- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index af7c6341..a2cf0a7e 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -1,6 +1,7 @@ using FiveStack.Entities.Practice; using FiveStack.Enums; using FiveStack.Utilities; +using Microsoft.Extensions.Logging; using SwiftlyS2.Shared; using SwiftlyS2.Core.Menus.OptionsBase; using SwiftlyS2.Shared.Commands; @@ -484,6 +485,51 @@ public void OnPick(ICommandContext context) Core.MenusAPI.OpenMenuForPlayer(player, builder.Build()); } + // Every message channel CS2 exposes, one sample each, labelled with the + // name to use in code. There is no documentation for where these land on + // screen -- the only way to choose between them is to look at all of them + // at once. + [Command("alerts", registerRaw: false, permission: "")] + public void OnAlerts(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + // Twelve seconds of quiet, so the lineup panels do not overwrite the + // two centre samples a moment after they appear. + SuppressPanels(player.SteamID, 12 * 64); + + foreach (MessageType type in Enum.GetValues()) + { + try + { + player.SendMessage(type, $"[{type}] the quick brown fox - 1234567890"); + } + catch (Exception error) + { + _logger.LogWarning(error, "channel {type} refused a message", type); + } + } + + // Sent separately because it is the only one that takes a duration, and + // without one it would vanish before the others are read. + player.SendCenterHTML( + "[CenterHTML] large amber" + + "
medium grey" + + "
small dim", + 12000 + ); + + Reply( + context, + $" {ChatColors.Grey}sent one sample down every channel - panels paused for 12s" + ); + } + [Command("where", registerRaw: false, permission: "")] public void OnWhere(ICommandContext context) { @@ -957,6 +1003,7 @@ public void OnRefresh(ICommandContext context) $" {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}.alerts {ChatColors.Grey}one sample down every message channel", $" {ChatColors.Default}.noclip / .god / .timer / .solo / .clear", }; diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 6008c47b..68b614ed 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -547,8 +547,28 @@ private void AimFeedback() // 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. + // While .alerts is showing samples, the panels would overwrite the centre + // ones within a second and the demo would be pointless. + private readonly Dictionary _demoUntil = new(); + + public void SuppressPanels(ulong steamId, int ticks) + { + _demoUntil[steamId] = _aimTick + ticks; + } + private void Panels(IPlayer player, CCSPlayerPawn pawn) { + if (_demoUntil.TryGetValue(player.SteamID, out int until)) + { + if (_aimTick < until) + { + return; + } + + _demoUntil.Remove(player.SteamID); + } + + (LineupRecord? lineup, bool onSpot, bool onAngle) = Focused(player, pawn); Send(player, PanelKind.Card, lineup == null ? null : Card(lineup)); @@ -712,11 +732,17 @@ CCSPlayerPawn pawn // 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. - // Just the name, on the channel that does not animate. A title has nothing - // to say that changes, so it belongs where nothing moves. + // Everything about the lineup ITSELF -- what it is called, how it is + // thrown, whether there is more to read -- on the channel that does not + // animate. None of it changes while a lineup is in focus, so none of it + // belongs on a panel that redraws. private static string Card(LineupRecord lineup) { - return lineup.name; + string details = string.IsNullOrWhiteSpace(lineup.description) + ? "" + : $"\n{PracticeLineupUtility.Tracked("write-up on the web")}"; + + return $"{lineup.name}\n{PracticeReplay.ThrowHint(lineup)}{details}"; } // The one step that is not done yet, on the animating channel -- where a @@ -731,22 +757,11 @@ private static string Card(LineupRecord lineup) // 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. - private static string Headline(LineupRecord lineup, bool onSpot, bool onAngle) + // 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) { - string how = - "" - + $"{PracticeReplay.ThrowHint(lineup)}"; - - if (!string.IsNullOrWhiteSpace(lineup.description)) - { - how += - "
" - + $"{PracticeLineupUtility.TrackedHtml("write-up on the web")}"; - } - - string? step = Steps(onSpot, onAngle); - - return step == null ? how : $"{how}
{step}"; + return Steps(onSpot, onAngle); } private static string? Steps(bool onSpot, bool onAngle) @@ -956,6 +971,7 @@ private void OnPlayerGone(ulong steamId) // hand to something else. _replay.ClearSelectionFor(steamId); _standingIn.Remove(steamId); + _demoUntil.Remove(steamId); _showing.Remove((steamId, PanelKind.Card)); _showing.Remove((steamId, PanelKind.Steps)); } From d038f9e4d26746c99f80d238968d6ca72594c535 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:11:35 -0400 Subject: [PATCH 094/107] put the lineup name on the alert channel --- apps/utility-sw/src/UtilityPracticePlugin.cs | 34 +++++++++++++------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 68b614ed..a05504f2 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -571,6 +571,7 @@ private void Panels(IPlayer player, CCSPlayerPawn pawn) (LineupRecord? lineup, bool onSpot, bool onAngle) = Focused(player, pawn); + Send(player, PanelKind.Title, lineup?.name); Send(player, PanelKind.Card, lineup == null ? null : Card(lineup)); Send( player, @@ -581,6 +582,7 @@ private void Panels(IPlayer player, CCSPlayerPawn pawn) private enum PanelKind { + Title, Card, Steps, } @@ -642,11 +644,21 @@ private static void Write(IPlayer player, PanelKind kind, string content) if (kind == PanelKind.Steps) { player.SendCenterHTML(content, PanelHoldMilliseconds); + + return; } - else + + // 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.SendCenter(content); + player.SendAlert(content); + + return; } + + player.SendCenter(content); } // Clearing is a WRITE with the shortest possible life, not a write with the @@ -663,10 +675,10 @@ private void Clear(IPlayer player, PanelKind kind) return; } - // The card is NOT written blank. SendCenter takes no duration, so a - // blank write just starts another full-length message that happens to - // have nothing in it -- slower to disappear than the line it replaced. - // Writing nothing lets it lapse on the game's own schedule instead. + // 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( @@ -732,17 +744,16 @@ CCSPlayerPawn pawn // 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. - // Everything about the lineup ITSELF -- what it is called, how it is - // thrown, whether there is more to read -- on the channel that does not - // animate. None of it changes while a lineup is in focus, so none of it - // belongs on a panel that redraws. + // 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 details = string.IsNullOrWhiteSpace(lineup.description) ? "" : $"\n{PracticeLineupUtility.Tracked("write-up on the web")}"; - return $"{lineup.name}\n{PracticeReplay.ThrowHint(lineup)}{details}"; + return $"{PracticeReplay.ThrowHint(lineup)}{details}"; } // The one step that is not done yet, on the animating channel -- where a @@ -972,6 +983,7 @@ private void OnPlayerGone(ulong steamId) _replay.ClearSelectionFor(steamId); _standingIn.Remove(steamId); _demoUntil.Remove(steamId); + _showing.Remove((steamId, PanelKind.Title)); _showing.Remove((steamId, PanelKind.Card)); _showing.Remove((steamId, PanelKind.Steps)); } From bd5d2c8ab27bda46ea3ded682f5b3cede0e8d4d2 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:14:04 -0400 Subject: [PATCH 095/107] title case the name and the throw details --- .../test/PracticeLineupUtilityTests.cs | 33 +++++++++++++++++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 12 +++++-- .../test/PracticeLineupUtilityTests.cs | 33 +++++++++++++++++++ .../PracticeLineupUtility.cs | 30 +++++++++++++++++ 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/apps/utility-css/test/PracticeLineupUtilityTests.cs b/apps/utility-css/test/PracticeLineupUtilityTests.cs index 93ea000f..fab368ee 100644 --- a/apps/utility-css/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-css/test/PracticeLineupUtilityTests.cs @@ -393,3 +393,36 @@ 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/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index a05504f2..441221ee 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -571,7 +571,13 @@ private void Panels(IPlayer player, CCSPlayerPawn pawn) (LineupRecord? lineup, bool onSpot, bool onAngle) = Focused(player, pawn); - Send(player, PanelKind.Title, lineup?.name); + // 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) + ); Send(player, PanelKind.Card, lineup == null ? null : Card(lineup)); Send( player, @@ -751,9 +757,9 @@ private static string Card(LineupRecord lineup) { string details = string.IsNullOrWhiteSpace(lineup.description) ? "" - : $"\n{PracticeLineupUtility.Tracked("write-up on the web")}"; + : "\nWrite-up on the web"; - return $"{PracticeReplay.ThrowHint(lineup)}{details}"; + return $"{PracticeLineupUtility.TitleCase(PracticeReplay.ThrowHint(lineup))}{details}"; } // The one step that is not done yet, on the animating channel -- where a diff --git a/apps/utility-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs index 93ea000f..fab368ee 100644 --- a/apps/utility-sw/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-sw/test/PracticeLineupUtilityTests.cs @@ -393,3 +393,36 @@ 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/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index b09fd60c..4952b877 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -204,6 +204,36 @@ public static string StrengthLabel(string? strength) }; } + // 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) From 6d0a218d94c32303a7f445f61be648daf04c79f5 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:23:03 -0400 Subject: [PATCH 096/107] tell a player about next and prev once --- apps/utility-sw/src/UtilityPracticePlugin.cs | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 441221ee..16cf7357 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -547,6 +547,31 @@ private void AimFeedback() // 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. + // Told once, the first time a player has a lineup in front of them. + // + // 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 something + // said once and never repeated. On a panel it would either sit there + // forever or fight the three that are already earning their place. And it + // is guarded per player rather than shown on a timer, because the second + // time you read the same tip it is noise. + private readonly HashSet _hinted = new(); + + private void Hint(IPlayer player) + { + if (!_hinted.Add(player.SteamID)) + { + return; + } + + Tell( + player.SteamID, + $" {ChatColors.Grey}tip: {ChatColors.Default}.next{ChatColors.Grey} and " + + $"{ChatColors.Default}.prev{ChatColors.Grey} walk through the lineups, " + + $"{ChatColors.Default}.clear{ChatColors.Grey} puts them away" + ); + } + // While .alerts is showing samples, the panels would overwrite the centre // ones within a second and the demo would be pointless. private readonly Dictionary _demoUntil = new(); @@ -578,6 +603,11 @@ private void Panels(IPlayer player, CCSPlayerPawn pawn) PanelKind.Title, lineup == null ? null : PracticeLineupUtility.TitleCase(lineup.name) ); + if (lineup != null) + { + Hint(player); + } + Send(player, PanelKind.Card, lineup == null ? null : Card(lineup)); Send( player, @@ -988,6 +1018,7 @@ private void OnPlayerGone(ulong steamId) // hand to something else. _replay.ClearSelectionFor(steamId); _standingIn.Remove(steamId); + _hinted.Remove(steamId); _demoUntil.Remove(steamId); _showing.Remove((steamId, PanelKind.Title)); _showing.Remove((steamId, PanelKind.Card)); From 59efc0351618c2470a299d4f5494d29700928df4 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:33:41 -0400 Subject: [PATCH 097/107] repeat the tip on a landed throw and drop the alerts helper --- apps/utility-sw/src/Commands/Practice.cs | 46 ------------- apps/utility-sw/src/UtilityPracticePlugin.cs | 72 +++++++++++--------- 2 files changed, 40 insertions(+), 78 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index a2cf0a7e..b3e27335 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -485,51 +485,6 @@ public void OnPick(ICommandContext context) Core.MenusAPI.OpenMenuForPlayer(player, builder.Build()); } - // Every message channel CS2 exposes, one sample each, labelled with the - // name to use in code. There is no documentation for where these land on - // screen -- the only way to choose between them is to look at all of them - // at once. - [Command("alerts", registerRaw: false, permission: "")] - public void OnAlerts(ICommandContext context) - { - IPlayer? player = context.Sender; - - if (player == null || !player.IsValid) - { - return; - } - - // Twelve seconds of quiet, so the lineup panels do not overwrite the - // two centre samples a moment after they appear. - SuppressPanels(player.SteamID, 12 * 64); - - foreach (MessageType type in Enum.GetValues()) - { - try - { - player.SendMessage(type, $"[{type}] the quick brown fox - 1234567890"); - } - catch (Exception error) - { - _logger.LogWarning(error, "channel {type} refused a message", type); - } - } - - // Sent separately because it is the only one that takes a duration, and - // without one it would vanish before the others are read. - player.SendCenterHTML( - "[CenterHTML] large amber" - + "
medium grey" - + "
small dim", - 12000 - ); - - Reply( - context, - $" {ChatColors.Grey}sent one sample down every channel - panels paused for 12s" - ); - } - [Command("where", registerRaw: false, permission: "")] public void OnWhere(ICommandContext context) { @@ -1003,7 +958,6 @@ public void OnRefresh(ICommandContext context) $" {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}.alerts {ChatColors.Grey}one sample down every message channel", $" {ChatColors.Default}.noclip / .god / .timer / .solo / .clear", }; diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 16cf7357..eba225f0 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -104,6 +104,7 @@ public override void Load(bool hotReload) _recorder.Finalized += _score.OnFinalized; _recorder.Thrown += _drill.OnThrown; _score.Scored += _drill.OnScored; + _score.Scored += OnScoredHint; WirePlaybook(); WireDrill(); @@ -208,6 +209,7 @@ public override void Unload() _recorder.Finalized -= _score.OnFinalized; _recorder.Thrown -= _drill.OnThrown; _score.Scored -= _drill.OnScored; + _score.Scored -= OnScoredHint; if (_tickHandler != null) { @@ -547,52 +549,59 @@ private void AimFeedback() // 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. - // Told once, the first time a player has a lineup in front of them. - // // 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 something - // said once and never repeated. On a panel it would either sit there - // forever or fight the three that are already earning their place. And it - // is guarded per player rather than shown on a timer, because the second - // time you read the same tip it is noise. - private readonly HashSet _hinted = new(); - - private void Hint(IPlayer player) + // 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 (!_hinted.Add(player.SteamID)) + 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, " - + $"{ChatColors.Default}.clear{ChatColors.Grey} puts them away" + + $"{ChatColors.Default}.prev{ChatColors.Grey} walk through the lineups" ); } - // While .alerts is showing samples, the panels would overwrite the centre - // ones within a second and the demo would be pointless. - private readonly Dictionary _demoUntil = new(); - - public void SuppressPanels(ulong steamId, int ticks) - { - _demoUntil[steamId] = _aimTick + ticks; - } - - private void Panels(IPlayer player, CCSPlayerPawn pawn) + // 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 (_demoUntil.TryGetValue(player.SteamID, out int until)) + if (result?.success != true) { - if (_aimTick < until) - { - return; - } + return; + } + + IPlayer? player = _system.Find(steamId); - _demoUntil.Remove(player.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); @@ -605,7 +614,7 @@ private void Panels(IPlayer player, CCSPlayerPawn pawn) ); if (lineup != null) { - Hint(player); + Hint(player, HintCooldownTicks); } Send(player, PanelKind.Card, lineup == null ? null : Card(lineup)); @@ -1018,8 +1027,7 @@ private void OnPlayerGone(ulong steamId) // hand to something else. _replay.ClearSelectionFor(steamId); _standingIn.Remove(steamId); - _hinted.Remove(steamId); - _demoUntil.Remove(steamId); + _hintedAt.Remove(steamId); _showing.Remove((steamId, PanelKind.Title)); _showing.Remove((steamId, PanelKind.Card)); _showing.Remove((steamId, PanelKind.Steps)); From 2ad1b872799c55270e8ba368cfbd3fe2011686a4 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:35:07 -0400 Subject: [PATCH 098/107] remove the pick and where commands --- apps/utility-sw/src/Commands/Practice.cs | 136 ----------------------- 1 file changed, 136 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index b3e27335..7cf7c482 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -3,7 +3,6 @@ using FiveStack.Utilities; using Microsoft.Extensions.Logging; using SwiftlyS2.Shared; -using SwiftlyS2.Core.Menus.OptionsBase; using SwiftlyS2.Shared.Commands; using SwiftlyS2.Shared.Natives; using SwiftlyS2.Shared.Players; @@ -418,139 +417,6 @@ public void OnSkip(ICommandContext context) } } - // Prints where the player actually is next to what the loaded lineup says, - // because every disagreement so far has been between those two numbers and - // no amount of looking at the marker tells you which one is wrong. - // A list you choose from, for the case looking at a ring cannot settle: - // several throws off one spot, stacked closely enough that pointing at one - // is fiddly. Deliberately a command rather than something that opens itself - // -- the menu binds shift and e, which are walk and use, and a menu that - // appeared when you stepped onto a spot would eat walk-throws. - [Command("pick", registerRaw: false, permission: "")] - public void OnPick(ICommandContext context) - { - IPlayer? player = context.Sender; - - if (player == null || !player.IsValid) - { - return; - } - - CCSPlayerPawn? pawn = player.PlayerPawn; - - if (pawn == null || !pawn.IsValid) - { - return; - } - - 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); - - if (here.Count == 0) - { - Reply(context, $" {ChatColors.Red}nothing to pick from where you are standing"); - return; - } - - var builder = Core.MenusAPI.CreateBuilder(); - - builder.Design.SetMenuTitle($"{here.Count} throws from here"); - // Never frozen: this is a practice server and the player may well want - // to step off the spot to dismiss it. - builder.SetPlayerFrozen(false); - builder.EnableExit(); - - foreach (LineupRecord lineup in here) - { - LineupRecord chosen = lineup; - - var option = new ButtonMenuOption(lineup.name) - { - Comment = $"{lineup.utility_type} - {lineup.technique}", - CloseAfterClick = true, - }; - - option.Click += (_, _) => - { - Apply(player, chosen); - return ValueTask.CompletedTask; - }; - - builder.AddOption(option); - } - - Core.MenusAPI.OpenMenuForPlayer(player, builder.Build()); - } - - [Command("where", registerRaw: false, permission: "")] - public void OnWhere(ICommandContext context) - { - IPlayer? player = context.Sender; - - if (player == null || !player.IsValid) - { - return; - } - - CCSPlayerPawn? pawn = player.PlayerPawn; - - if (pawn == null || !pawn.IsValid) - { - return; - } - - Vector feet = pawn.AbsOrigin ?? new Vector(0, 0, 0); - float viewOffset = pawn.ViewOffset.Z.Value; - QAngle eyes = pawn.EyeAngles; - bool grounded = (pawn.Flags & (1 << 0)) != 0; - Vector velocity = pawn.AbsVelocity; - float speed = new Vec3(velocity.X, velocity.Y, 0f).LengthXY(); - - Reply( - context, - $" {ChatColors.Gold}YOU {ChatColors.Default}feet " - + $"{feet.X:0.##} {feet.Y:0.##} {ChatColors.Green}{feet.Z:0.##}" - + $"{ChatColors.Default} eye {(feet.Z + viewOffset):0.##}" - + $" aim {eyes.Y:0.##} / {eyes.X:0.##}" - + $" {(grounded ? "on ground" : $"{ChatColors.Red}AIRBORNE{ChatColors.Default}")}" - + $" speed {speed:0.#}" - ); - - LineupRecord? lineup = _system.StateFor(player.SteamID).Loaded; - - if (lineup == null) - { - Reply(context, $" {ChatColors.Grey}no lineup loaded to compare against"); - return; - } - - Vec3 storedFeet = lineup.release.feet_position; - Vec3 storedEye = lineup.release.eye_position; - - Reply( - context, - $" {ChatColors.Gold}LINEUP {ChatColors.Default}feet " - + $"{storedFeet.x:0.##} {storedFeet.y:0.##} {ChatColors.Green}{storedFeet.z:0.##}" - + $"{ChatColors.Default} eye {storedEye.z:0.##}" - + $" aim {lineup.release.yaw:0.##} / {lineup.release.pitch:0.##}" - + $" {(lineup.release.on_ground ? "on ground" : $"{ChatColors.Red}AIRBORNE{ChatColors.Default}")}" - ); - - // The gap is the whole point: a lineup that teleports you correctly - // reads zero here, and any drift says which axis is wrong. - Reply( - context, - $" {ChatColors.Gold}GAP {ChatColors.Default}" - + $"dx {(feet.X - storedFeet.x):0.##} " - + $"dy {(feet.Y - storedFeet.y):0.##} " - + $"{ChatColors.Green}dz {(feet.Z - storedFeet.z):0.##}{ChatColors.Default} " - + $"eye-over-feet: you {(viewOffset):0.##} / stored {(storedEye.z - storedFeet.z):0.##}" - ); - } - [Command("pos", registerRaw: false, permission: "")] public void OnPos(ICommandContext context) { @@ -950,8 +816,6 @@ public void OnRefresh(ICommandContext context) $" {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}.pick {ChatColors.Grey}choose between the throws on this spot", - $" {ChatColors.Default}.where {ChatColors.Grey}where you are vs what the loaded lineup says", $" {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", From 00eed4f82b3bb8b233fdcf151c15091497f858ba Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:37:13 -0400 Subject: [PATCH 099/107] add jump and make refreshed lineups walkable --- apps/utility-sw/src/Commands/Practice.cs | 35 +++++++++++++++++++ .../utility-sw/src/Services/PracticeReplay.cs | 35 +++++++++++++++++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 21 +++++++++-- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 7cf7c482..6e180543 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -417,6 +417,40 @@ public void OnSkip(ICommandContext context) } } + // 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) { @@ -812,6 +846,7 @@ public void OnRefresh(ICommandContext context) $" {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", diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index e6210ca6..09dfe536 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -962,6 +962,41 @@ private static List GhostPoints(LineupRecord lineup) // 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) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index eba225f0..4c178087 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -1078,10 +1078,27 @@ private void RefreshAndShow(ulong steamId) steamId, count => { - if (count > 0) + if (count <= 0) { - _replay.ShowLibrary(_library.For(steamId)); + 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; } ); } From 50e3efa6b64260d206538b290330f6e98cdd6633 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:40:01 -0400 Subject: [PATCH 100/107] remove the ghost trail --- apps/utility-sw/src/Commands/Practice.cs | 36 ------ .../utility-sw/src/Services/PracticeReplay.cs | 119 +----------------- .../utility-sw/src/Services/PracticeSystem.cs | 11 -- apps/utility-sw/src/UtilityPracticePlugin.cs | 1 - 4 files changed, 5 insertions(+), 162 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 6e180543..21840810 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -617,39 +617,6 @@ public void OnSolo(ICommandContext context) ); } - // 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. - [Command("ghosts", registerRaw: false, permission: "")] - public void OnGhosts(ICommandContext context) - { - IPlayer? player = context.Sender; - - if (player == null || !player.IsValid) - { - return; - } - - PracticeState state = _system.StateFor(player.SteamID); - string argument = string.Join(" ", context.Args).Trim(); - - if (!PracticeSignalUtility.TryParseToggle(argument, state.Ghosts, out bool wanted)) - { - Reply(context, $" {ChatColors.Red}usage: .ghosts [on|off]"); - return; - } - - state.Ghosts = wanted; - - if (!wanted) - { - _replay.ClearGhosts(player.SteamID); - } - - Reply(context, $" {ChatColors.Green}ghost previews {Toggle(state.Ghosts)}"); - } - [Command("delete", registerRaw: false, permission: "")] public void OnDelete(ICommandContext context) { @@ -856,7 +823,6 @@ public void OnRefresh(ICommandContext context) $" {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}.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", }; @@ -1008,7 +974,6 @@ private void Apply(IPlayer player, LineupRecord 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); _replay.ThrowGhostProjectile(player, lineup); WarnIfInexact(player, lineup); @@ -1031,7 +996,6 @@ private void Apply(IPlayer player, LineupRecord lineup) if (still != null && still.IsValid) { - _replay.ShowGhost(still, fetched); } DrawBloom(steamId, fetched); diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 09dfe536..c7d75ef3 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -15,11 +15,9 @@ namespace UtilityPractice; // grenade took so they can see the throw before they make it. public class PracticeReplay { - 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; @@ -43,7 +41,6 @@ public class PracticeReplay private enum GhostKind { - Line, Bloom, } @@ -285,7 +282,6 @@ ILogger logger // 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. - public Func WantsGhosts { get; set; } = _ => true; // --------------------------------------------------------------------- // CRASH BISECT. Every one of these makes the plugin touch the engine for @@ -304,8 +300,8 @@ ILogger logger // collision clearing that follows them. public const bool DrawModels = true; - // Ghost trails and the measured bloom outline. - public const bool DrawGhosts = true; + // The measured bloom outline. + public const bool DrawBloom = true; // REAL projectiles: EmitSmokeGrenade / EmitFlashbang / EmitHEGrenade / // EmitMolotov, plus the bloom's live smoke. @@ -316,7 +312,7 @@ ILogger logger public static string SwitchState() { return $"markers={DrawMarkers} models={DrawModels} " - + $"ghosts={DrawGhosts} grenades={EmitGrenades}"; + + $"bloom={DrawBloom} grenades={EmitGrenades}"; } public void Load(IPlayer player, LineupRecord lineup) @@ -390,86 +386,19 @@ public void Load(IPlayer player, LineupRecord lineup) player.SendCenter(Describe(lineup)); } - // Tier 1 preview: the recorded line, drawn as beam segments with a marker - // where it lands. - public void ShowGhost(IPlayer player, LineupRecord lineup) - { - if (!DrawGhosts) - { - return; - } - - 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; - } - - _ghosts.Add( - new Ghost - { - OwnerSteamId = player.SteamID, - Kind = GhostKind.Line, - // Held, not timed out: "the line where the nade goes" vanishing - // after a few seconds read as a bug, not a preview. The next - // .load or .clear replaces it. - ExpiresAt = DateTime.MaxValue, - 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(IPlayer player, LineupRecord lineup) { - if (!DrawGhosts) + if (!DrawBloom) { return 0; } ClearKind(player.SteamID, GhostKind.Bloom); - if (!_config.GhostPreview || !WantsGhosts(player.SteamID)) + if (!_config.GhostPreview) { return 0; } @@ -915,44 +844,6 @@ private void ReapplyAngles(IPlayer player, QAngle facing, QAngle aim, int frames }); } - // 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. It is drawn from entities the server owns rather than diff --git a/apps/utility-sw/src/Services/PracticeSystem.cs b/apps/utility-sw/src/Services/PracticeSystem.cs index 42f0247c..9495b1bc 100644 --- a/apps/utility-sw/src/Services/PracticeSystem.cs +++ b/apps/utility-sw/src/Services/PracticeSystem.cs @@ -30,12 +30,6 @@ public class PracticeState // 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. - // Off until asked for: the flight line held on screen read as clutter -- - // "weird lines coming out of the spot" -- not guidance. .ghosts opts in. - public bool Ghosts { get; set; } = false; // 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. @@ -99,11 +93,6 @@ public bool IsSolo(ulong steamId) return !_states.TryGetValue(steamId, out PracticeState? state) || state.Solo; } - public bool WantsGhosts(ulong steamId) - { - return _states.TryGetValue(steamId, out PracticeState? state) && state.Ghosts; - } - public void Forget(ulong steamId) { _states.Remove(steamId); diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 4c178087..47a44c15 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -95,7 +95,6 @@ public override void Load(bool hotReload) _config.Load(Path.Join(pluginDirectory, "../../configs"), pluginDirectory); _replay.IsSolo = _system.IsSolo; - _replay.WantsGhosts = _system.WantsGhosts; _replay.All = steamId => _library.For(steamId); // A solve rains live HE and molotovs on a map people are standing in. _system.SolveRunning = () => _solver.IsBusy; From 2ac8823acc455a337ba2a2a6aa40f571fe8b326d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:43:34 -0400 Subject: [PATCH 101/107] drill in reps of three and show where you are in it --- .../utility-css/test/PracticeDrillRunTests.cs | 61 +++++++++++++++++++ apps/utility-sw/src/Services/PracticeDrill.cs | 35 ++++++++++- .../utility-sw/src/Services/PracticeSystem.cs | 11 ++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 17 +++++- apps/utility-sw/test/PracticeDrillRunTests.cs | 61 +++++++++++++++++++ .../FiveStack.Utilities/PracticeDrillRun.cs | 23 ++++++- 6 files changed, 201 insertions(+), 7 deletions(-) diff --git a/apps/utility-css/test/PracticeDrillRunTests.cs b/apps/utility-css/test/PracticeDrillRunTests.cs index e88e638a..b7d559f2 100644 --- a/apps/utility-css/test/PracticeDrillRunTests.cs +++ b/apps/utility-css/test/PracticeDrillRunTests.cs @@ -497,3 +497,64 @@ public void TwoRunsKeepTheirOwnCounts() 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/src/Services/PracticeDrill.cs b/apps/utility-sw/src/Services/PracticeDrill.cs index bdaf44de..4e904e10 100644 --- a/apps/utility-sw/src/Services/PracticeDrill.cs +++ b/apps/utility-sw/src/Services/PracticeDrill.cs @@ -70,12 +70,13 @@ public eDrillStart Start(ulong steamId, eDrillOrder order, int count) return eDrillStart.NothingToDrill; } - var run = new PracticeDrillRun(queue); + var run = new PracticeDrillRun(queue, DrillReps); _runs[steamId] = run; Tell?.Invoke( steamId, - $"drill started - {queue.Count} throws, {Ordering(order)} (.skip to pass, .drill stop to end)" + $"drill started - {queue.Count} lineups x{DrillReps}, {Ordering(order)}" + + " (.skip to pass, .drill stop to end)" ); Advance(steamId, run); @@ -168,6 +169,33 @@ public void Second() // 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; + + // 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); @@ -199,7 +227,8 @@ private void Advance(ulong steamId, PracticeDrillRun run) Note?.Invoke( steamId, - $"{run.Position}/{run.Length} {DrillUtility.Name(next)} - {Tally(run)}" + $"{run.Position}/{run.Length} rep {run.Rep}/{run.Reps} " + + $"{DrillUtility.Name(next)} - {Tally(run)}" ); return; diff --git a/apps/utility-sw/src/Services/PracticeSystem.cs b/apps/utility-sw/src/Services/PracticeSystem.cs index 9495b1bc..d8cbe0b0 100644 --- a/apps/utility-sw/src/Services/PracticeSystem.cs +++ b/apps/utility-sw/src/Services/PracticeSystem.cs @@ -150,6 +150,12 @@ public void Tick() // 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) @@ -157,6 +163,11 @@ public void OnThrown(ulong steamId, string utilityType) return; } + if (HoldUtility?.Invoke(steamId) == true) + { + return; + } + string? weapon = PracticeLineupUtility.WeaponForUtilityType(utilityType); if (weapon == null) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 47a44c15..f6072874 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -102,6 +102,7 @@ public override void Load(bool hotReload) _recorder.Thrown += _system.OnThrown; _recorder.Finalized += _score.OnFinalized; _recorder.Thrown += _drill.OnThrown; + _system.HoldUtility = _drill.Waiting; _score.Scored += _drill.OnScored; _score.Scored += OnScoredHint; @@ -616,7 +617,11 @@ private void Panels(IPlayer player, CCSPlayerPawn pawn) Hint(player, HintCooldownTicks); } - Send(player, PanelKind.Card, lineup == null ? null : Card(lineup)); + Send( + player, + PanelKind.Card, + lineup == null ? null : Card(lineup, _drill.Progress(player.SteamID)) + ); Send( player, PanelKind.Steps, @@ -791,13 +796,19 @@ CCSPlayerPawn pawn // 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) + private static string Card(LineupRecord lineup, string? drill) { string details = string.IsNullOrWhiteSpace(lineup.description) ? "" : "\nWrite-up on the web"; - return $"{PracticeLineupUtility.TitleCase(PracticeReplay.ThrowHint(lineup))}{details}"; + // 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 diff --git a/apps/utility-sw/test/PracticeDrillRunTests.cs b/apps/utility-sw/test/PracticeDrillRunTests.cs index e88e638a..b7d559f2 100644 --- a/apps/utility-sw/test/PracticeDrillRunTests.cs +++ b/apps/utility-sw/test/PracticeDrillRunTests.cs @@ -497,3 +497,64 @@ public void TwoRunsKeepTheirOwnCounts() 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/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs b/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs index 6623ce19..d16ae7fa 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs @@ -28,11 +28,18 @@ private class Pending private int _unscoredInARow; private int _unloadableInARow; - public PracticeDrillRun(IReadOnlyList queue) + // 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; } @@ -42,6 +49,11 @@ public PracticeDrillRun(IReadOnlyList queue) // 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; } @@ -68,6 +80,15 @@ public PracticeDrillRun(IReadOnlyList queue) 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) From 12ca25d236cfcf74acf4499266830f6abff54068 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:47:12 -0400 Subject: [PATCH 102/107] leave the player where they are between drill reps --- apps/utility-sw/src/Services/PracticeDrill.cs | 21 +++++++++++++++++++ .../utility-sw/src/Services/PracticeReplay.cs | 5 ++++- apps/utility-sw/src/UtilityPracticePlugin.cs | 10 +++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/utility-sw/src/Services/PracticeDrill.cs b/apps/utility-sw/src/Services/PracticeDrill.cs index 4e904e10..f02b12d8 100644 --- a/apps/utility-sw/src/Services/PracticeDrill.cs +++ b/apps/utility-sw/src/Services/PracticeDrill.cs @@ -34,6 +34,10 @@ public PracticeDrill(UtilityConfig config, PracticeLibrary library) // 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; } @@ -221,6 +225,23 @@ private void Advance(ulong steamId, PracticeDrillRun 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(); diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index c7d75ef3..b0af58da 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -779,7 +779,10 @@ public static string Describe(LineupRecord lineup) return $"{name}\n{lineup.utility_type} - {lineup.technique}{strength}"; } - private void GiveUtility(IPlayer player, string utilityType) + // 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; diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index f6072874..c5118153 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -1010,6 +1010,16 @@ private void WireDrill() 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}"); From 08eae4fbb91e369b58266fbb4a830ac5e8311e46 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:48:05 -0400 Subject: [PATCH 103/107] draw only the lineup a drill is asking for --- apps/utility-sw/src/Services/PracticeDrill.cs | 8 ++++++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/apps/utility-sw/src/Services/PracticeDrill.cs b/apps/utility-sw/src/Services/PracticeDrill.cs index f02b12d8..720c72ae 100644 --- a/apps/utility-sw/src/Services/PracticeDrill.cs +++ b/apps/utility-sw/src/Services/PracticeDrill.cs @@ -178,6 +178,14 @@ public void Second() // 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 diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index c5118153..54a4b95a 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -364,6 +364,18 @@ private void SpotWatch() ? 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) From 0faa9559d4f8cd89df695ab51e2b410779e7163d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:48:47 -0400 Subject: [PATCH 104/107] keep the panels on the drilled lineup --- apps/utility-sw/src/UtilityPracticePlugin.cs | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 54a4b95a..842f234c 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -755,11 +755,23 @@ CCSPlayerPawn pawn LineupRecord? aimedAt = LookingAt(pawn, at, library, here); LineupRecord? loaded = _system.StateFor(player.SteamID).Loaded; - // 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 && (here.Contains(loaded) || aimedAt == 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; From 370c9caec8a4c1e4d4957e49a162e8be94ff85db Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:55:30 -0400 Subject: [PATCH 105/107] let drill toggle off and add cancel --- apps/utility-sw/src/Commands/Practice.cs | 38 +++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 21840810..693f5342 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -351,6 +351,25 @@ public void OnRun(ICommandContext context) 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) { @@ -361,11 +380,27 @@ public void OnDrill(ICommandContext context) 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 stop"); + Reply( + context, + $" {ChatColors.Red}usage: .drill [count] [worst|random] / .drill / .cancel" + ); return; } @@ -822,6 +857,7 @@ public void OnRefresh(ICommandContext context) $" {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", }; From 6208c29b4f941933b4a032a64a0a83897f7d881b Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 13:18:24 -0400 Subject: [PATCH 106/107] let the panel drill a chosen set --- apps/utility-sw/src/Commands/Practice.cs | 81 +++++++++++++++++++ apps/utility-sw/src/Services/PracticeDrill.cs | 31 ++++++- .../src/Services/PracticeSession.cs | 40 +++++++++ apps/utility-sw/src/UtilityPracticePlugin.cs | 3 + 4 files changed, 153 insertions(+), 2 deletions(-) diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 693f5342..fbde364c 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -783,6 +783,87 @@ public void OnRemoteLoad(ICommandContext context) 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); diff --git a/apps/utility-sw/src/Services/PracticeDrill.cs b/apps/utility-sw/src/Services/PracticeDrill.cs index 720c72ae..2f255a1d 100644 --- a/apps/utility-sw/src/Services/PracticeDrill.cs +++ b/apps/utility-sw/src/Services/PracticeDrill.cs @@ -69,6 +69,33 @@ public eDrillStart Start(ulong steamId, eDrillOrder order, int count) _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; @@ -79,8 +106,8 @@ public eDrillStart Start(ulong steamId, eDrillOrder order, int count) Tell?.Invoke( steamId, - $"drill started - {queue.Count} lineups x{DrillReps}, {Ordering(order)}" - + " (.skip to pass, .drill stop to end)" + $"drill started - {queue.Count} lineups x{DrillReps}, {ordering}" + + " (.skip to pass, .cancel to end)" ); Advance(steamId, run); diff --git a/apps/utility-sw/src/Services/PracticeSession.cs b/apps/utility-sw/src/Services/PracticeSession.cs index 36d45781..ec6ee0a9 100644 --- a/apps/utility-sw/src/Services/PracticeSession.cs +++ b/apps/utility-sw/src/Services/PracticeSession.cs @@ -24,6 +24,46 @@ public PracticeSession(UtilityApiClient api, ILogger logger) 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(); diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 842f234c..3feb122b 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -879,6 +879,9 @@ private static string Instruction(string text, string color) 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(); From 85757beafe0b61e6748f80b094b922a4e0dae895 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 16:41:22 -0400 Subject: [PATCH 107/107] wip --- apps/utility-sw/src/Services/UtilityConfig.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/utility-sw/src/Services/UtilityConfig.cs b/apps/utility-sw/src/Services/UtilityConfig.cs index 5bf24228..d4be690e 100644 --- a/apps/utility-sw/src/Services/UtilityConfig.cs +++ b/apps/utility-sw/src/Services/UtilityConfig.cs @@ -99,6 +99,20 @@ public void Load(params string[] configDirectories) 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