diff --git a/.dockerignore b/.dockerignore index c80fa1df..94d6a0ed 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,3 +14,6 @@ **/*.pyc apps/gamedata-validator/gamedata/ccs.gamedata.json +**/.compiler/ +**/.tools/ +apps/utility-sw/hud/dist/ diff --git a/.github/workflows/ccs-update.yaml b/.github/workflows/ccs-update.yaml index 9b58302f..b36cd80a 100644 --- a/.github/workflows/ccs-update.yaml +++ b/.github/workflows/ccs-update.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Extract current URL from Dockerfile run: | @@ -45,15 +45,41 @@ jobs: echo "Current URL: ${{ env.CURRENT_URL }}" echo "Latest URL: ${{ env.LATEST_URL }}" - - name: Update Dockerfile / csproj + # The runtime and EVERY plugin's nuget must move together or the ABI + # breaks. The csproj list is discovered rather than written down: this + # step used to name apps/counterstrikesharp alone, so when the practice + # plugin arrived with its own pin it silently stayed a version behind + # the runtime it gets loaded into. + - name: Update Dockerfile / csprojs if: env.CURRENT_URL != env.LATEST_URL run: | + set -euo pipefail VERSION_NUMBER=$(echo ${{ env.LATEST_URL }} | grep -oP 'v\K\d+\.\d+\.\d+') + sed -i 's|ENV COUNTER_STRIKE_SHARP_URL=.*|ENV COUNTER_STRIKE_SHARP_URL=${{ env.LATEST_URL }}|' apps/counterstrikesharp/Dockerfile - sed -i "s|> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + PUBLISHED=no + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + PUBLISHED=yes + echo "::notice::$KEY is already on the Workshop." + fi + echo "published=$PUBLISHED" >> "$GITHUB_OUTPUT" + + # A dry run still builds -- verifying an unchanged addon is the point of + # one. Only the upload is suppressed by an unchanged key. + if [ "$PUBLISHED" = no ] || [ "${{ inputs.force }}" = true ] || [ "${{ inputs.dry_run }}" = true ]; then + echo "proceed=yes" >> "$GITHUB_OUTPUT" + else + echo "proceed=no" >> "$GITHUB_OUTPUT" + fi - - uses: actions/setup-dotnet@v4 + - name: Nothing to publish + if: steps.changed.outputs.proceed != 'yes' + run: | + echo "::notice::The addon is byte-identical to the last publish, so nothing" + echo "::notice::was sent to Steam. Re-run with force: true to publish anyway" + echo "::notice::(a title or description edit needs that)." + + - uses: actions/setup-dotnet@v6 + if: steps.changed.outputs.proceed == 'yes' with: dotnet-version: "10.0.x" @@ -31,17 +84,20 @@ jobs: # variable, and a broken layout fails silently in game rather than at # build time. Never publish an addon whose contract has not been checked. - name: Verify the slot contract + if: steps.changed.outputs.proceed == 'yes' run: dotnet test apps/utility-sw/test/FiveStack.Tests.csproj --nologo # PanoramaCompiler needs only the .NET SDK and build.sh fetches vpkeditcli # into .tools on Linux, so nothing here needs Windows or a CS2 install. - name: Build the addon + if: steps.changed.outputs.proceed == 'yes' run: ./apps/utility-sw/hud/build.sh # An addon that mounts and resolves nothing is the failure mode with no # symptom: the layouts must sit under panorama/, and there must be five of # them plus the stylesheet. - name: Verify what was packed + if: steps.changed.outputs.proceed == 'yes' run: | set -euo pipefail VPK=apps/utility-sw/hud/build/upload/5stack_utility_hud.vpk @@ -63,12 +119,36 @@ jobs: echo "addon: $(du -h "$VPK" | cut -f1)" + # Checked here rather than in the publish step so that a dry run fails + # on a missing preview instead of reporting green and leaving the real + # run to discover it after a 90s SteamCMD install. + HUD=apps/utility-sw/hud + PREVIEW="" + for candidate in "$HUD/preview.jpg" "$HUD/preview.png"; do + [ -f "$candidate" ] && { PREVIEW="$candidate"; break; } + done + [ -n "$PREVIEW" ] || { + echo "::error::no preview image at $HUD/preview.{jpg,png}" + exit 1 + } + + # Steam reports an oversized preview as a generic upload failure that + # never mentions the image. + BYTES=$(wc -c < "$PREVIEW") + if [ "$BYTES" -gt 1000000 ]; then + echo "::error::preview is $((BYTES / 1024)) KB; Steam's limit is 1 MB" + exit 1 + fi + + echo "preview: $PREVIEW ($((BYTES / 1024)) KB)" + echo "PREVIEW=$GITHUB_WORKSPACE/$PREVIEW" >> "$GITHUB_ENV" + - name: Stop here - if: inputs.dry_run + if: inputs.dry_run && steps.changed.outputs.proceed == 'yes' run: echo "::notice::dry run - built and verified, nothing published" - name: Install SteamCMD - if: ${{ !inputs.dry_run }} + if: ${{ !inputs.dry_run && steps.changed.outputs.proceed == 'yes' }} run: | sudo add-apt-repository -y multiverse sudo dpkg --add-architecture i386 @@ -78,17 +158,64 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq steamcmd - name: Publish - if: ${{ !inputs.dry_run }} + if: ${{ !inputs.dry_run && steps.changed.outputs.proceed == 'yes' }} env: STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }} STEAM_PASSWORD: ${{ secrets.STEAM_PASSWORD }} + STEAM_CONFIG_VDF: ${{ secrets.STEAM_CONFIG_VDF }} ITEM_ID: ${{ inputs.item_id }} run: | set -euo pipefail HUD="$GITHUB_WORKSPACE/apps/utility-sw/hud" - if [ -z "${STEAM_USERNAME:-}" ] || [ -z "${STEAM_PASSWORD:-}" ]; then - echo "::error::STEAM_USERNAME and STEAM_PASSWORD secrets are required" + if [ -z "${STEAM_USERNAME:-}" ]; then + echo "::error::the STEAM_USERNAME secret is required" + exit 1 + fi + + # Secrets pasted through a UI or piped in pick up trailing newlines, + # and Steam reports the resulting credential as Invalid Password. + RAW_U=${#STEAM_USERNAME}; RAW_P=${#STEAM_PASSWORD} + STEAM_USERNAME=$(printf '%s' "$STEAM_USERNAME" | tr -d '[:space:]') + STEAM_PASSWORD=$(printf '%s' "${STEAM_PASSWORD:-}" | tr -d '\r\n') + + # Whether trimming changed anything, never by how much: this log is + # public and a password length is not something to publish. + [ "${#STEAM_USERNAME}" -ne "$RAW_U" ] && echo "::warning::STEAM_USERNAME had surrounding whitespace; trimmed" + [ "${#STEAM_PASSWORD}" -ne "$RAW_P" ] && echo "::warning::STEAM_PASSWORD had a trailing newline; trimmed" + true + + # An array, not a string: a password containing a space or a glob + # character must reach steamcmd as one argument, and an unquoted + # expansion would split or expand it into something else entirely. + if [ -n "${STEAM_CONFIG_VDF:-}" ]; then + # macOS base64 wraps at 76 columns; strip whitespace before decoding + # so a secret created there is not rejected on a Linux runner. + printf '%s' "$STEAM_CONFIG_VDF" | tr -d '[:space:]' | base64 -d > /tmp/config.vdf + if ! grep -q ConnectCache /tmp/config.vdf; then + echo "::error::the restored config.vdf has no ConnectCache - it holds no session." + echo "::error::Re-run the local steamcmd login and re-copy the file." + exit 1 + fi + + # Which root SteamCMD reads depends on how it was installed: the + # Ubuntu package uses ~/.local/share/Steam. Write all three rather + # than guess -- guessing wrong reports "Cached credentials not + # found" and silently falls through to a password prompt. + for root in "$HOME/.local/share/Steam" "$HOME/Steam" "$HOME/.steam/steam"; do + mkdir -p "$root/config" + cp /tmp/config.vdf "$root/config/config.vdf" + chmod 600 "$root/config/config.vdf" + done + rm -f /tmp/config.vdf + echo "auth: restored session into $HOME/.local/share/Steam and 2 fallbacks" + + LOGIN=("$STEAM_USERNAME") + elif [ -n "$STEAM_PASSWORD" ]; then + LOGIN=("$STEAM_USERNAME" "$STEAM_PASSWORD") + echo "auth: password login" + else + echo "::error::set STEAM_CONFIG_VDF (guarded account) or STEAM_PASSWORD" exit 1 fi @@ -103,18 +230,13 @@ jobs: ;; esac - [ -f "$HUD/preview.png" ] || { - echo "::error::apps/utility-sw/hud/preview.png is missing; Steam requires a preview image" - exit 1 - } - cat > "$HUD/build/workshop.vdf" < "d"). @@ -115,16 +115,16 @@ jobs: // the package name already namespaces the image, so its version tag stays bare core.setOutput('tags', [repoTag('latest'), repoTag(sha), repoTag(`v${version}`)].join('\n')); - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build plugin zip - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ${{ inputs.app_dir }}/Dockerfile @@ -143,7 +143,7 @@ jobs: docker rm temp - name: Build and Push server Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ${{ inputs.app_dir }}/Dockerfile @@ -155,7 +155,7 @@ jobs: cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ inputs.image }}:buildcache,mode=max - name: Create Release and Upload Asset - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -170,7 +170,7 @@ jobs: # against its v0.0.N image tags - name: Prune old beta images if: inputs.channel == 'beta' - uses: actions/github-script@v7 + uses: actions/github-script@v9 env: IMAGE: ${{ inputs.image }} with: diff --git a/.github/workflows/swiftly-update.yaml b/.github/workflows/swiftly-update.yaml index ce0f0dd7..70f10881 100644 --- a/.github/workflows/swiftly-update.yaml +++ b/.github/workflows/swiftly-update.yaml @@ -13,7 +13,7 @@ jobs: contents: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Resolve current and latest SwiftlyS2 versions env: @@ -30,15 +30,55 @@ jobs: echo "LATEST=$LATEST" >> "$GITHUB_ENV" echo "current=$CURRENT latest=$LATEST" - # the runtime zip and the nuget must move together or the ABI breaks. - - name: Update Dockerfile and csproj - if: env.LATEST != '' && env.LATEST != 'null' && env.CURRENT != env.LATEST + # A job that goes green every week while doing nothing is how the csproj + # pins drifted behind the runtime in the first place, so a deliberate skip + # says so in the run log. + - name: Report a deliberate beta pin + if: contains(env.CURRENT, '-beta') run: | + echo "::notice::pinned to $CURRENT (prerelease); leaving it alone. Latest stable is $LATEST." + + # The runtime zip and EVERY plugin's nuget must move together or the ABI + # breaks. The csproj list is discovered rather than written down: this step + # used to name apps/swiftly alone, so when the practice plugin arrived with + # its own pin it silently stayed a version behind the runtime it gets + # loaded into. + # + # A deliberate beta pin outranks "latest stable": this only ever picks a + # non-prerelease, so left alone it walks a beta back to the release before + # it and takes whatever the beta was pinned for with it. + - name: Update Dockerfile and csprojs + if: >- + env.LATEST != '' && env.LATEST != 'null' && env.CURRENT != env.LATEST + && !contains(env.CURRENT, '-beta') + run: | + set -euo pipefail NUGET_VERSION="${LATEST#v}" + sed -i "s|SWIFTLYS2_VERSION=\"[^\"]*\"|SWIFTLYS2_VERSION=\"${LATEST}\"|" apps/swiftly/Dockerfile - sed -i "s|> "$GITHUB_ENV" - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and Push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: apps/gamedata-validator/Dockerfile @@ -43,6 +43,8 @@ jobs: cache-to: | type=registry,ref=ghcr.io/${{ github.repository_owner }}/gamedata-validator:buildcache,mode=max + # Still node20 and still warns. v5 is the newest release there is, so + # there is nothing to bump to; leave it until upstream ships a node24 major. - name: Delete Package Versions uses: actions/delete-package-versions@v5 with: diff --git a/.gitignore b/.gitignore index 1bed6c8a..ce35e2e8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,13 @@ __pycache__/ *.pyc apps/gamedata-validator/gamedata/ccs.gamedata.json + +# panorama build artifacts +apps/utility-sw/hud/build/ +apps/utility-sw/hud/.compiler/ + +# panorama tool cache +apps/utility-sw/hud/.tools/ + +# packed addon; synced back from the pod, not committed +apps/utility-sw/hud/dist/ diff --git a/apps/swiftly/Dockerfile b/apps/swiftly/Dockerfile index 80070aff..c6930365 100644 --- a/apps/swiftly/Dockerfile +++ b/apps/swiftly/Dockerfile @@ -96,7 +96,7 @@ ENV STEAM_RELAY="false" ENV SERVER_TYPE="Ranked" -ENV SWIFTLYS2_VERSION="v1.4.5" +ENV SWIFTLYS2_VERSION="v1.4.7" ENV SWIFTLYS2_URL=https://github.com/swiftly-solution/swiftlys2/releases/download/${SWIFTLYS2_VERSION}/swiftlys2-linux-${SWIFTLYS2_VERSION}-with-runtimes.zip ENV ENABLE_CSS_COMPAT=false diff --git a/apps/swiftly/src/FiveStack.csproj b/apps/swiftly/src/FiveStack.csproj index 804cf25a..e98b2952 100644 --- a/apps/swiftly/src/FiveStack.csproj +++ b/apps/swiftly/src/FiveStack.csproj @@ -8,7 +8,10 @@ - + + diff --git a/apps/utility-css/src/Commands/Practice.cs b/apps/utility-css/src/Commands/Practice.cs index b1b78839..576ed7d4 100644 --- a/apps/utility-css/src/Commands/Practice.cs +++ b/apps/utility-css/src/Commands/Practice.cs @@ -24,14 +24,11 @@ public void OnSave(CCSPlayerController? player, CommandInfo command) return; } + // An empty name is allowed: the panel names the throw from the map's + // own callouts -- where it lands and where it was thrown from -- which + // is a better name than most people type anyway. 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) @@ -56,7 +53,11 @@ public void OnSave(CCSPlayerController? player, CommandInfo command) _library.Add(player.SteamID, thrown); - command.ReplyToCommand($" {ChatColors.Green}saved {ChatColors.Default}{name}"); + command.ReplyToCommand( + name.Length > 0 + ? $" {ChatColors.Green}saved {ChatColors.Default}{name}" + : $" {ChatColors.Green}saved {ChatColors.Default}(named from the map)" + ); ulong steamId = player.SteamID; @@ -72,11 +73,52 @@ public void OnSave(CCSPlayerController? player, CommandInfo command) return; } - Tell(steamId, $" {ChatColors.Red}{name} could not reach the panel; it will retry"); + Tell( + steamId, + $" {ChatColors.Red}that throw could not reach the panel; it will retry" + ); }); }); } + // A read-only dump of what the level says its areas are called. This is the + // check to run before trusting a map's callouts: compare it against the + // published extract for the same map, or just against the names you know. + [ConsoleCommand("css_callouts", "Lists the callouts this map defines")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnCallouts(CCSPlayerController? player, CommandInfo command) + { + if (player == null || !player.IsValid) + { + return; + } + + List callouts = _callouts.Collect(); + + if (callouts.Count == 0) + { + command.ReplyToCommand($" {ChatColors.Red}this map defines no callouts"); + return; + } + + command.ReplyToCommand( + $" {ChatColors.Green}{callouts.Count} {ChatColors.Default}callouts on {_library.Map}" + ); + + foreach (MapCalloutPayload callout in callouts.OrderBy(c => c.name)) + { + MapCalloutBox box = callout.boxes[0]; + + command.ReplyToCommand( + $" {ChatColors.Default}{callout.name} {ChatColors.Grey}" + + $"x {box.min[0]:F0}..{box.max[0]:F0} " + + $"y {box.min[1]:F0}..{box.max[1]:F0} " + + $"z {box.min[2]:F0}..{box.max[2]:F0}" + + (callout.boxes.Count > 1 ? $" (+{callout.boxes.Count - 1})" : string.Empty) + ); + } + } + [ConsoleCommand("css_load", "Teleports you to a saved lineup")] [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] public void OnLoad(CCSPlayerController? player, CommandInfo command) @@ -104,6 +146,14 @@ public void OnLoad(CCSPlayerController? player, CommandInfo command) ); state.Index = state.Results.FindIndex(match => match.client_id == lineup.client_id); + // Resolve and Filter are different matchers, so what was loaded is not + // always inside the walk that was just built. + if (state.Index < 0) + { + state.Results.Insert(0, lineup); + state.Index = 0; + } + Apply(player, lineup); } @@ -668,27 +718,28 @@ private void RemoteLoad(ulong steamId, string lineupId, bool refreshed) return; } - LineupRecord? lineup = PracticeLineupUtility.ById(_library.For(steamId), lineupId); - - if (lineup != null) + // Always re-read before resolving. The panel pushing a load IS the + // signal that something changed: a draft tested from the website keeps + // the same client id on purpose so it replaces itself, so answering out + // of the cache stood the player on the first version of the throw every + // time afterwards. + if (!refreshed) { - Apply(player, lineup); + _library.Refresh(steamId, _ => RemoteLoad(steamId, lineupId, refreshed: true)); 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) + LineupRecord? lineup = PracticeLineupUtility.ById(_library.For(steamId), lineupId); + + if (lineup != null) { - Tell(steamId, $" {ChatColors.Red}that lineup is not available on this server"); + Apply(player, lineup); return; } - _library.Refresh(steamId, _ => RemoteLoad(steamId, lineupId, refreshed: true)); + // One refresh, then give up: retrying past that would hammer the panel + // every time somebody sends a lineup that really is gone. + Tell(steamId, $" {ChatColors.Red}that lineup is not available on this server"); } // Server-only, like the load above. Everything on this server goes through a @@ -934,9 +985,15 @@ private void Step(CCSPlayerController? player, CommandInfo command, int directio return; } + // Index is -1 until something has been loaded, which is "before the + // start" rather than a position. Feeding that through the modulo made + // the first .prev land on the second-to-last lineup and skip the last + // one entirely. state.Index = - ((state.Index + direction) % state.Results.Count + state.Results.Count) - % state.Results.Count; + state.Index < 0 + ? (direction > 0 ? 0 : state.Results.Count - 1) + : ((state.Index + direction) % state.Results.Count + state.Results.Count) + % state.Results.Count; Apply(player, state.Results[state.Index]); } diff --git a/apps/utility-css/src/Services/MapCalloutsReporter.cs b/apps/utility-css/src/Services/MapCalloutsReporter.cs new file mode 100644 index 00000000..8fbc0207 --- /dev/null +++ b/apps/utility-css/src/Services/MapCalloutsReporter.cs @@ -0,0 +1,185 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Memory; +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging; + +namespace UtilityPractice; + +// The map's own names for its areas, read off the entities the engine resolves +// them from. This is what fills `player_kills.attacker_location` in a match, and +// with the boxes attached the panel can draw them on the radar and name a throw +// by where it lands and where it was thrown from. +// +// Only ever a fallback. The published extract covers the official pool and wins +// on the API side; this is how a workshop map -- which nothing offline has ever +// opened -- gets any callouts at all. +public class MapCalloutsReporter +{ + // A place volume thinner than this on either horizontal axis is a trigger + // somebody tied to the class by accident, not an area anyone calls out. + private const float MinExtent = 8f; + + private readonly UtilityApiClient _api; + private readonly ILogger _logger; + + // env_cs_place entities are not guaranteed to have spawned by the time the + // map-load handler runs, so an empty walk means "not yet", not "this map + // has none". Report is asked again on the per-second tick until one of them + // answers or the attempts run out -- without it a map that was slow to + // spawn stays unnamed for the whole session, silently: no HUD kicker, no + // auto-named save, and nothing ever POSTed for the workshop maps this + // endpoint exists for. + private const int MaxAttempts = 10; + + private string _reported = string.Empty; + private int _attempts; + private List _cached = new List(); + + public MapCalloutsReporter(UtilityApiClient api, ILogger logger) + { + _api = api; + _logger = logger; + } + + public void Reset() + { + _reported = string.Empty; + _attempts = 0; + _cached = new List(); + } + + /// + /// The level's callouts, walked once per map and held. Anything naming a + /// point on screen reads this rather than calling -- + /// resolving a marker is a per-frame question and walking the entity list + /// is not a per-frame answer. + /// + public IReadOnlyList Callouts => _cached; + + /// + /// What the map calls this point, already humanised. Empty when the map has + /// no callouts or nothing is near enough to name. + /// + public string Label(Vec3 point) + { + return CalloutLookup.ResolveLabel(point, _cached); + } + + /// + /// Reads every env_cs_place in the level. Safe to call from a map-load + /// listener; it only touches entities the game already made. + /// + public List Collect() + { + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach ( + CBaseEntity entity in Utilities.FindAllEntitiesByDesignerName( + "env_cs_place" + ) + ) + { + if (!entity.IsValid) + { + continue; + } + + string name; + + try + { + // CCSPlace carries exactly one field, m_name, and it is the + // string the HUD shows. Read through the schema rather than a + // generated wrapper so a CounterStrikeSharp version that has + // not generated the class still compiles. + name = Schema.GetString(entity.Handle, "CCSPlace", "m_name") ?? string.Empty; + } + catch (Exception error) + { + _logger.LogWarning(error, "unable to read a place name"); + continue; + } + + name = name.Trim(); + + if (name.Length == 0) + { + continue; + } + + // The volume is a model, so its extents are relative to the entity + // and have to be lifted into world space before anything can ask + // whether a grenade is inside one. + var origin = entity.AbsOrigin; + var mins = entity.Collision?.Mins; + var maxs = entity.Collision?.Maxs; + + if (origin == null || mins == null || maxs == null) + { + continue; + } + + var box = new MapCalloutBox + { + min = new[] { mins.X + origin.X, mins.Y + origin.Y, mins.Z + origin.Z }, + max = new[] { maxs.X + origin.X, maxs.Y + origin.Y, maxs.Z + origin.Z }, + }; + + if ( + box.max[0] - box.min[0] < MinExtent + || box.max[1] - box.min[1] < MinExtent + ) + { + continue; + } + + if (!byName.TryGetValue(name, out MapCalloutPayload? callout)) + { + callout = new MapCalloutPayload { name = name }; + byName[name] = callout; + } + + callout.boxes.Add(box); + } + + return byName.Values.ToList(); + } + + /// + /// Reports the level's callouts once per map. Safe to call every tick: it + /// stops the moment it has an answer, and gives up after MaxAttempts so a + /// map that genuinely has no places is not walked for ever. + /// + public void Report(string? mapName) + { + if (string.IsNullOrEmpty(mapName) || _reported == mapName || _attempts >= MaxAttempts) + { + return; + } + + _attempts++; + + List callouts; + + try + { + callouts = Collect(); + } + catch (Exception error) + { + _logger.LogWarning(error, "unable to collect the map's callouts"); + return; + } + + if (callouts.Count == 0) + { + return; + } + + _cached = callouts; + _reported = mapName; + _ = Task.Run(async () => await _api.Callouts(mapName, callouts)); + } +} diff --git a/apps/utility-css/src/Services/PracticeScore.cs b/apps/utility-css/src/Services/PracticeScore.cs index 5d7ca870..19d185a4 100644 --- a/apps/utility-css/src/Services/PracticeScore.cs +++ b/apps/utility-css/src/Services/PracticeScore.cs @@ -2,6 +2,7 @@ using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Utils; using FiveStack.Entities.Practice; +using FiveStack.Utilities; namespace UtilityPractice; @@ -75,6 +76,21 @@ public void OnFinalized(LineupRecord thrown) Vec3 landing = thrown.detonation_position; float distance = (landing - loaded.detonation_position).Length(); + string name = string.IsNullOrEmpty(loaded.name) ? "that lineup" : loaded.name; + + // A scratch throw -- a meta spot, or a draft being tested from the + // panel before it is saved -- has no row behind it, and the panel + // rejects a result whose lineup id is not a uuid. Asking anyway got a + // 400 back and told the player "the panel did not answer", which reads + // as a broken panel when everything needed to judge the throw is + // already here. There is nothing to persist it against, which is why it + // carries no streak and no tally. + if (!PracticeLineupUtility.IsPanelId(loaded.id)) + { + ScoreLocally(steamId, loaded.client_id, name, distance); + return; + } + var payload = UtilityPracticeResultPayload.For( _config.ServerId, _session.Current?.id ?? Guid.Empty, @@ -86,7 +102,6 @@ public void OnFinalized(LineupRecord thrown) string lineupId = loaded.id; string key = $"{lineupId}:{steamId}"; - string name = string.IsNullOrEmpty(loaded.name) ? "that lineup" : loaded.name; _ = Task.Run(async () => { @@ -96,6 +111,40 @@ public void OnFinalized(LineupRecord thrown) }); } + // Judged here rather than by the panel, for a throw the panel has no row + // for. The radius is still the panel's whenever it has said one this + // session; the fallback only stands in before it ever has. + private void ScoreLocally(ulong steamId, string lineupId, string name, float distance) + { + float radius = _radius ?? PracticeLineupUtility.FallbackSuccessRadius; + bool success = distance <= radius; + + CCSPlayerController? player = Utilities.GetPlayerFromSteamId(steamId); + + if (player != null && player.IsValid) + { + player.PrintToChat( + success + ? $" {ChatColors.Green}hit {ChatColors.Default}{name} {ChatColors.Grey}{PracticeLineupUtility.Metres(distance)} off - not saved, so it is not counted" + : $" {ChatColors.Red}miss {ChatColors.Default}{name} {ChatColors.Grey}{PracticeLineupUtility.Metres(distance)} off, needs {PracticeLineupUtility.Metres(radius)}" + ); + } + + // A drill counts on being able to tell a miss from an unanswered throw, + // and this is an answer -- so it carries one, with the tallies left at + // zero because there is nothing behind them. + Scored?.Invoke( + steamId, + lineupId, + new UtilityPracticeResult + { + success = success, + distance = distance, + radius = radius, + } + ); + } + private void Report( ulong steamId, string lineupId, @@ -136,15 +185,15 @@ float measured if (result == null) { player.PrintToChat( - $" {ChatColors.Grey}{measured:0}u from {name} {ChatColors.Default}(not scored; the panel did not answer)" + $" {ChatColors.Grey}{PracticeLineupUtility.Metres(measured)} 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}" + ? $" {ChatColors.Green}hit {ChatColors.Default}{name} {ChatColors.Grey}{PracticeLineupUtility.Metres(result.distance)} off - streak {result.current_streak} (best {result.best_streak})" + : $" {ChatColors.Red}miss {ChatColors.Default}{name} {ChatColors.Grey}{PracticeLineupUtility.Metres(result.distance)} off, needs {PracticeLineupUtility.Metres(result.radius)} - {result.successes}/{result.attempts}" ); if (result.mastered_at == null || !_mastered.Add(key)) diff --git a/apps/utility-css/src/Services/UtilityApiClient.cs b/apps/utility-css/src/Services/UtilityApiClient.cs index d8f00762..a540df4b 100644 --- a/apps/utility-css/src/Services/UtilityApiClient.cs +++ b/apps/utility-css/src/Services/UtilityApiClient.cs @@ -138,6 +138,23 @@ public async Task Occupancy(IReadOnlyCollection steamIds) await SendText(HttpMethod.Post, "/utility/occupancy", body); } + // Fire and forget on map load. A failure is not retried: the next map load + // reports again, and a map nobody ever loads again does not need callouts. + public async Task Callouts(string map, IReadOnlyCollection callouts) + { + if (string.IsNullOrEmpty(map) || callouts.Count == 0) + { + return; + } + + string body = JsonSerializer.Serialize( + new MapCalloutsPayload { map = map, callouts = callouts.ToList() }, + PracticeJson.Options + ); + + await SendText(HttpMethod.Post, "/utility/callouts", body); + } + public async Task Session(string? map = null) { string route = string.IsNullOrEmpty(map) @@ -170,11 +187,18 @@ public async Task Occupancy(IReadOnlyCollection steamIds) // then, which is why only the live attempt answers. public async Task PracticeResult(UtilityPracticeResultPayload payload) { - UtilityPracticeResult? result = await PostResult(payload); + var outcome = new SendOutcome(); + UtilityPracticeResult? result = await PostResult(payload, outcome); if (result == null) { - EnqueueResult(payload); + // Only worth keeping if asking again could go differently. A + // refusal queued here is one the queue would replay at the head + // forever, and everything behind it would never be delivered. + if (!outcome.Rejected) + { + EnqueueResult(payload); + } return null; } @@ -233,11 +257,15 @@ public async Task Drain() } } - if (await PostResult(result) == null) + var outcome = new SendOutcome(); + + if (await PostResult(result, outcome) == null && !outcome.Rejected) { return; } + // Delivered, or refused outright -- either way it leaves the + // queue, so one bad payload cannot hold up the ones behind it. lock (_queueLock) { _resultQueue.TryDequeue(out _); @@ -313,7 +341,10 @@ private void EnqueueResult(UtilityPracticeResultPayload payload) } } - private async Task PostResult(UtilityPracticeResultPayload payload) + private async Task PostResult( + UtilityPracticeResultPayload payload, + SendOutcome? outcome = null + ) { payload.server_id = string.IsNullOrEmpty(_config.ServerId) ? null : _config.ServerId; @@ -329,7 +360,12 @@ private void EnqueueResult(UtilityPracticeResultPayload payload) return null; } - string? response = await SendText(HttpMethod.Post, "/utility/practice-result", body); + string? response = await SendText( + HttpMethod.Post, + "/utility/practice-result", + body, + outcome + ); if (response == null) { @@ -377,14 +413,33 @@ private void EnqueueResult(UtilityPracticeResultPayload payload) return null; } - private async Task SendText(HttpMethod method, string path, string? body) + // Why a request failed, for the two callers that queue what they could not + // deliver. A 4xx is the panel saying this request is wrong and will stay + // wrong; retrying one forever is how a single rejected throw stopped every + // later one from ever being scored. + private sealed class SendOutcome { - byte[]? response = await Send(method, path, body); + public bool Rejected { get; set; } + } + + private async Task SendText( + HttpMethod method, + string path, + string? body, + SendOutcome? outcome = null + ) + { + byte[]? response = await Send(method, path, body, outcome); return response == null ? null : PracticeJson.Text(response); } - private async Task Send(HttpMethod method, string path, string? body) + private async Task Send( + HttpMethod method, + string path, + string? body, + SendOutcome? outcome = null + ) { if (!_config.IsConnected()) { @@ -437,6 +492,27 @@ private void EnqueueResult(UtilityPracticeResultPayload payload) (int)response.StatusCode, reason.Length > 500 ? reason.Substring(0, 500) : reason ); + + if (outcome != null) + { + int status = (int)response.StatusCode; + // A refusal is the panel saying this payload is wrong, and + // it will still be wrong next time -- so it is dropped + // rather than queued. 401 and 403 are NOT that: a rotated + // plugin key, or a pod that came up before the panel + // authorised it, refuses every request until the credential + // is right and then accepts them all. Treating those as + // refusals threw away a player's whole session of scored + // attempts, and took the already-queued ones with it. + outcome.Rejected = + status >= 400 + && status < 500 + && status != 401 + && status != 403 + && status != 408 + && status != 429; + } + return null; } diff --git a/apps/utility-css/src/UtilityPractice.csproj b/apps/utility-css/src/UtilityPractice.csproj index b56eafb6..f29771f2 100644 --- a/apps/utility-css/src/UtilityPractice.csproj +++ b/apps/utility-css/src/UtilityPractice.csproj @@ -5,7 +5,7 @@ UtilityPractice - + + @@ -32,7 +35,9 @@ + + @@ -48,5 +53,23 @@ + + + + + + + + + + + + + + + diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 6f4872bc..3d858444 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.CompilerServices; using FiveStack.Entities.Practice; using FiveStack.Utilities; using Microsoft.Extensions.DependencyInjection; @@ -8,6 +9,8 @@ using SwiftlyS2.Shared.Players; using SwiftlyS2.Shared.Plugins; using static SwiftlyS2.Shared.Helper; +using SwiftlyS2.Shared.GameEventDefinitions; +using SwiftlyS2.Shared.Misc; using SwiftlyS2.Shared.Natives; using SwiftlyS2.Shared.SchemaDefinitions; @@ -36,8 +39,10 @@ public partial class UtilityPracticePlugin : BasePlugin private PracticeReplay _replay = null!; private PracticeSystem _system = null!; private PracticeScore _score = null!; + private PracticeRelay _relay = null!; private PracticePlaybook _playbook = null!; private PracticeDrill _drill = null!; + private MapCalloutsReporter _callouts = null!; private PracticeSolver _solver = null!; private CancellationTokenSource? _secondTimer; @@ -55,6 +60,7 @@ public partial class UtilityPracticePlugin : BasePlugin private EventDelegates.OnClientDisconnected? _disconnectHandler; private EventDelegates.OnPrecacheResource? _precacheHandler; private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; + private EventDelegates.OnCustomHudClicked? _hudClickHandler; public UtilityPracticePlugin(ISwiftlyCore core) : base(core) { } @@ -75,9 +81,13 @@ public override void Load(bool hotReload) .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); _serviceProvider = services.BuildServiceProvider(); _logger = _serviceProvider.GetRequiredService>(); @@ -89,17 +99,27 @@ public override void Load(bool hotReload) _replay = _serviceProvider.GetRequiredService(); _system = _serviceProvider.GetRequiredService(); _score = _serviceProvider.GetRequiredService(); + _relay = _serviceProvider.GetRequiredService(); _playbook = _serviceProvider.GetRequiredService(); _drill = _serviceProvider.GetRequiredService(); + _callouts = _serviceProvider.GetRequiredService(); _solver = _serviceProvider.GetRequiredService(); + _hud = ResolveHud(); + _prompt = _serviceProvider.GetRequiredService(); + _prompt.Start(); // addons/swiftlys2/configs is two levels up from // addons/swiftlys2/plugins/UtilityPractice. - string pluginDirectory = - Path.GetDirectoryName(typeof(UtilityPracticePlugin).Assembly.Location) ?? ""; + // + // Core.PluginPath rather than Assembly.Location: plugins are loaded from + // bytes so hot reload can replace the file on disk, which leaves Location + // empty and resolves both of these against the server's working + // directory instead. Env vars were masking it here. + string pluginDirectory = Core.PluginPath; _config.Load(Path.Join(pluginDirectory, "../../configs"), pluginDirectory); _replay.IsSolo = _system.IsSolo; + _replay.AnnouncesLoad = steamId => !UseHud(steamId); _replay.All = steamId => _library.For(steamId); // A solve rains live HE and molotovs on a map people are standing in. _system.SolveRunning = () => _solver.IsBusy; @@ -107,6 +127,29 @@ public override void Load(bool hotReload) _recorder.Thrown += _system.OnThrown; _recorder.Finalized += _score.OnFinalized; _recorder.Thrown += _drill.OnThrown; + + // The flight, in the colour that throw was promised. The engine's own + // practice trail is coloured by TEAM, so on a server where everybody is + // on the same side every arc looks the same. + _recorder.Sampled += (steamId, at) => + { + if (_system.StateFor(steamId).Colors) + { + _replay.TrailPoint(steamId, InFlightColor(steamId), at); + } + }; + _recorder.Ended += _replay.TrailEnded; + + // The promised colour is claimed by the throw and the cursor moves on, + // so what the player was shown before pulling the pin is what the arc + // and the smoke actually come out in. + _recorder.Thrown += (steamId, _) => + { + PracticeState state = _system.StateFor(steamId); + + state.InFlightColorIndex = state.ThrowColorIndex; + state.ThrowColorIndex++; + }; _system.HoldUtility = _drill.Waiting; _score.Scored += _drill.OnScored; _score.Scored += OnScoredHint; @@ -117,6 +160,12 @@ public override void Load(bool hotReload) _tickHandler = OnGameTick; Core.Event.OnTick += _tickHandler; + if (_hud != null) + { + _hud.Clicked += OnHudClicked; + WireHudClicks(); + } + // 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. @@ -128,6 +177,7 @@ public override void Load(bool hotReload) if (entity.IsValid) { _recorder.OnProjectileCreated(entity); + TintSmoke(entity); } }); }; @@ -136,6 +186,13 @@ public override void Load(bool hotReload) _mapLoadHandler = @event => OnMapLoad(@event.MapName); Core.Event.OnMapLoad += _mapLoadHandler; + Core.GameEvent.HookPre(_ => + { + KeepRoundsMoving(); + + return HookResult.Continue; + }); + // 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 @@ -154,12 +211,14 @@ public override void Load(bool hotReload) // Outside ForPlayer: somebody left either way, and a player we // cannot resolve is exactly when the roster most needs re-reading. _occupancyDirty = true; + _prompt?.Cancel(@event.PlayerId); ForPlayer( @event.PlayerId, steamId => { _welcomed.Remove(steamId); + ForgetHud(steamId, @event.PlayerId); OnPlayerGone(steamId); } ); @@ -224,9 +283,22 @@ public override void Unload() _session.Refreshed -= OnSessionRefreshed; _recorder.Thrown -= _system.OnThrown; _recorder.Finalized -= _score.OnFinalized; + _recorder.Ended -= _replay.TrailEnded; _recorder.Thrown -= _drill.OnThrown; _score.Scored -= _drill.OnScored; _score.Scored -= OnScoredHint; + if (_hud != null) + { + _hud.Clicked -= OnHudClicked; + UnwireHudClicks(); + + // Before anything else tears down: a panel left on screen with the + // cursor still captured survives the reload and needs a map change + // to clear. + _hud.Shutdown(); + } + + _prompt?.Stop(); if (_tickHandler != null) { @@ -291,6 +363,7 @@ private void OnGameTick() _recorder.OnTick(); _solver.OnTick(); AimFeedback(); + UseWatch(); // 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. @@ -340,6 +413,230 @@ private static float ToleranceFor(LineupRecord lineup) // light up everything throwable from it without redrawing every tick. private readonly Dictionary _standingIn = new(); + // IN_USE. Read every tick rather than on the 4Hz spot sweep because a tap + // is shorter than a quarter of a second and a walk-up that does nothing is + // worse than not offering it. + private const uint InUse = 1 << 5; + + // How far from a stance the offer still stands. Near enough that pressing + // use plainly means "put me on that", far enough to be worth doing -- and + // bounded, because use is also how a player picks a weapon up and being + // teleported across a room for that would be a bug rather than a feature. + private const float UseReachUnits = 220f; + + private readonly HashSet _useHeld = new(); + + // Bots that have been placed, and where they were asked to stand. Kept so + // a map change or a cfg re-run can put them back rather than leaving the + // player with a bot standing in a spawn. + private readonly List _bots = new(); + + /// + /// Put a bot where the caller is standing, facing the way they face. + /// + /// The point is something to flash and to blow up that does not move, so + /// it is frozen where it lands rather than allowed to play the round. The + /// quota is raised first: bot_add on its own is refused once the quota is + /// full, and the quota starts at zero on a practice server. + /// + public bool AddBot(IPlayer player) + { + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + return false; + } + + Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); + QAngle angles = pawn.EyeAngles; + + var spot = new ThrowSnapshot + { + feet_position = new Vec3(origin.X, origin.Y, origin.Z), + yaw = angles.Y, + }; + + _bots.Add(spot); + + Core.Engine.ExecuteCommand(string.Join(";", BotsCfg)); + Core.Engine.ExecuteCommand($"bot_quota {_bots.Count}"); + Core.Engine.ExecuteCommand( + pawn.TeamNum == 3 ? "bot_add_ct" : "bot_add_t" + ); + + // The bot does not exist on the tick it is asked for, and it spawns + // wherever the map puts it. Placing it is a second step. + Core.Scheduler.DelayBySeconds(BotPlaceDelaySeconds, PlaceBots); + + return true; + } + + public int ClearBots() + { + int had = _bots.Count; + + _bots.Clear(); + Core.Engine.ExecuteCommand(string.Join(";", NoBotsCfg)); + + return had; + } + + // Walks the bots that exist and stands each one on the spot it was asked + // for, in the order they were asked for. Bots have no identity worth + // tracking across a respawn, so position is assigned by order rather than + // by remembering which bot was which. + private void PlaceBots() + { + if (_bots.Count == 0) + { + return; + } + + int index = 0; + + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || !player.IsFakeClient) + { + continue; + } + + if (index >= _bots.Count) + { + break; + } + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) + { + continue; + } + + ThrowSnapshot spot = _bots[index++]; + Vec3 feet = spot.feet_position; + + player.Teleport( + new Vector(feet.x, feet.y, feet.z), + new QAngle(0, spot.yaw, 0), + new Vector(0, 0, 0) + ); + } + } + + private const float BotPlaceDelaySeconds = 0.5f; + + private void UseWatch() + { + 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; + } + + uint buttons = 0; + + try + { + buttons = (uint)(pawn.MovementServices?.Buttons.ButtonStates[0] ?? 0); + } + catch + { + continue; + } + + bool down = (buttons & InUse) != 0; + + // The edge, not the state: holding use must not teleport once a + // tick. + if (!down) + { + _useHeld.Remove(player.SteamID); + continue; + } + + if (!_useHeld.Add(player.SteamID)) + { + continue; + } + + StandOnNearest(player, pawn); + } + } + + private void StandOnNearest(IPlayer player, CCSPlayerPawn pawn) + { + Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); + var at = new Vec3(origin.X, origin.Y, origin.Z); + + IReadOnlyList library = _library.For(player.SteamID); + + if (library.Count == 0) + { + return; + } + + // What they are pointing at wins over what they are near: with two + // stances in reach, the crosshair is the only thing that says which. + LineupRecord? target = + LookingAt(pawn, at, library, PracticeReplay.SpotAt(library, at)) + ?? Nearest(library, at); + + if (target == null) + { + return; + } + + Vec3 feet = target.release.feet_position; + float away = new Vec3(feet.x - at.x, feet.y - at.y, 0f).LengthXY(); + + if (away > UseReachUnits) + { + return; + } + + // Already on it. Teleporting somebody onto the spot they are standing + // on reads as the key doing nothing, and costs them their run-up. + if (away <= PracticeLineupUtility.StanceToleranceUnits) + { + return; + } + + if (_replay.StandOn(player, target)) + { + _system.StateFor(player.SteamID).Loaded = target; + } + } + + private static LineupRecord? Nearest(IReadOnlyList library, Vec3 at) + { + LineupRecord? best = null; + float bestAway = float.MaxValue; + + foreach (LineupRecord lineup in library) + { + Vec3 feet = lineup.release.feet_position; + float away = new Vec3(feet.x - at.x, feet.y - at.y, 0f).LengthXY(); + + if (away < bestAway) + { + bestAway = away; + best = lineup; + } + } + + return best; + } + // 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() @@ -605,6 +902,19 @@ private void Hint(IPlayer player, int cooldown) _hintedAt[player.SteamID] = _aimTick; + // Where the panel is up, the thing worth teaching is the panel that + // replaces the typing, not two more commands to type. + if (UseHud(player.SteamID) && _hud!.Available(HudSlots.List)) + { + Tell( + player.SteamID, + $" {ChatColors.Grey}tip: {ChatColors.Default}.menu{ChatColors.Grey} " + + "picks a lineup off the screen" + ); + + return; + } + Tell( player.SteamID, $" {ChatColors.Grey}tip: {ChatColors.Default}.next{ChatColors.Grey} and " @@ -634,23 +944,40 @@ private void Panels(IPlayer player, CCSPlayerPawn pawn) (LineupRecord? lineup, bool onSpot, bool onAngle) = Focused(player, pawn); + if (HudPanels(player, pawn, lineup, onSpot, onAngle)) + { + if (lineup != null) + { + Hint(player, HintCooldownTicks); + } + + return; + } + // 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.Title, lineup == null ? null : Title(player, lineup)); if (lineup != null) { Hint(player, HintCooldownTicks); } - Send( - player, - PanelKind.Card, - lineup == null ? null : Card(lineup, _drill.Progress(player.SteamID)) - ); + // The colour is only worth saying while there is a grenade in hand: it + // answers "which arc is about to be mine", and that is not a question + // anybody is asking while walking around with a rifle out. Said before + // the throw on purpose -- afterwards it is just a label on something + // already in the air. + string? colour = + HoldingUtility(pawn) && _system.StateFor(player.SteamID).Colors + ? $"NEXT: {ThrowColor(player.SteamID).Name.ToUpperInvariant()}" + : null; + + string? card = lineup == null + ? colour + : Card(lineup, _drill.Progress(player.SteamID)) + + (colour == null ? "" : $"\n{colour}"); + + Send(player, PanelKind.Card, card); Send( player, PanelKind.Steps, @@ -658,6 +985,160 @@ private void Panels(IPlayer player, CCSPlayerPawn pawn) ); } + // "[3/24] Shorta". Where you are in the walk is the one thing .next and + // .prev cannot tell you themselves -- without it there is no way to know + // whether you have seen everything on the map or how far round you are. + // Only shown while a walk is actually loaded and the focused lineup is the + // one it is pointing at; drifting onto a neighbour's spot must not label it + // with somebody else's position. + // A grenade in hand, which is the only time the next colour matters. Read + // the same way the recorder reads it, so the panel and the recording never + // disagree about whether somebody is holding one. + private static bool HoldingUtility(CCSPlayerPawn pawn) + { + try + { + CBasePlayerWeapon? active = pawn.WeaponServices?.ActiveWeapon.Value; + + if (active == null || !active.IsValid) + { + return false; + } + + string designer = active.Entity?.DesignerName ?? ""; + + return designer.StartsWith("weapon_") + && ( + designer.Contains("grenade") + || designer.Contains("flashbang") + || designer.Contains("molotov") + || designer.Contains("incgrenade") + || designer.Contains("decoy") + ); + } + catch + { + return false; + } + } + + /// + /// The colour the next grenade off this player will wear. + /// + /// In a running execute the step owns the colour -- it is a fact about the + /// throw everybody is rehearsing, and it has to stay the same across + /// attempts. Everywhere else the player's own cycle owns it, so ten smokes + /// in a row come out as ten different arcs instead of ten identical ones. + /// + /// + /// The colour the NEXT grenade off this player will wear. Public because + /// anything showing a player their own colour -- centre text, a HUD panel, + /// chat -- has to get it from one place: the step-beats-cycle rule below is + /// the sort of thing that silently drifts once it exists twice. + /// + public PracticeStepColors.StepColor ThrowColor(ulong steamId) + { + return ColorFor(steamId, _system.StateFor(steamId).ThrowColorIndex); + } + + // Done after the recorder has seen the projectile, because that is what + // claims the colour for this throw. Before it, the cursor still points at + // the colour the NEXT grenade will be. + private void TintSmoke(CEntityInstance entity) + { + if ((entity.DesignerName ?? "") != "smokegrenade_projectile") + { + return; + } + + try + { + CBaseEntity? thrower = entity.As().Thrower.Value; + + if (thrower == null || !thrower.IsValid) + { + return; + } + + IPlayer? player = Core.PlayerManager.GetPlayerFromPawn( + thrower.As() + ); + + if (player == null || !player.IsValid) + { + return; + } + + if (_system.StateFor(player.SteamID).Colors) + { + _replay.TintSmoke(entity, InFlightColor(player.SteamID)); + } + } + catch + { + // A projectile whose thrower cannot be read is one the recorder + // has already dropped; a grey smoke is not worth a log line. + } + } + + /// The colour of the throw already in the air. + public PracticeStepColors.StepColor InFlightColor(ulong steamId) + { + return ColorFor(steamId, _system.StateFor(steamId).InFlightColorIndex); + } + + /// + /// A running execute owns the colour, because it is a fact about the throw + /// everybody is rehearsing and has to mean the same thing across attempts + /// and across players. Everywhere else the player's own cycle owns it. + /// + private PracticeStepColors.StepColor ColorFor(ulong steamId, int cycle) + { + LineupRecord? loaded = _system.StateFor(steamId).Loaded; + + if (loaded != null) + { + PracticeStepColors.StepColor? step = _replay.StepColorFor(loaded.client_id); + + if (step != null) + { + return step.Value; + } + } + + return PracticeStepColors.For(cycle); + } + + private string Title(IPlayer player, LineupRecord lineup) + { + string name = PracticeLineupUtility.TitleCase(lineup.name); + + // In an execute the colour beats the position in the library walk. + // Several grenades are up at once and they all look the same in the + // air, so "you are throwing the cyan one" is the thing that lets a + // player find their own smoke on the ground afterwards. + PracticeStepColors.StepColor? step = _replay.StepColorFor(lineup.client_id); + + if (step != null) + { + return $"{step.Value.Name.ToUpperInvariant()} - {name}"; + } + + PracticeState state = _system.StateFor(player.SteamID); + + if (state.Results.Count < 2 || state.Index < 0 || state.Index >= state.Results.Count) + { + return name; + } + + if (state.Results[state.Index].client_id != lineup.client_id) + { + return name; + } + + return $"[{state.Index + 1}/{state.Results.Count}] {name}"; + } + private enum PanelKind { Title, @@ -907,6 +1388,8 @@ private void OnSecond() _playbook.Second(); _drill.Second(); _solver.RefreshVisibility(); + // A no-op once the map has answered; see MapCalloutsReporter.Report. + _callouts.Report(_session.Map); DrainPendingMapLoad(); } @@ -966,7 +1449,9 @@ private void ReportOccupancy() } } - _ = Task.Run(() => _api.Occupancy(present)); + string? relay = _relay.AccountId(); + + _ = Task.Run(() => _api.Occupancy(present, relay)); } private int _warmupTicks; @@ -1032,6 +1517,13 @@ private void WirePlaybook() } }; + _playbook.Restrict = only => _replay.LibraryRestriction = only; + + // Off means off, and a drill fades it out across its reps so the last + // one is thrown off what the player has actually learned. + _replay.AimVisibility = steamId => + _system.StateFor(steamId).Crosshair ? _drill.Assist(steamId) : 0f; + _playbook.Chat = message => Core.PlayerManager.SendChat($" {ChatColors.Green}{message}".Colored()); @@ -1109,6 +1601,53 @@ private void OnPlayerGone(ulong steamId) _showing.Remove((steamId, PanelKind.Steps)); } + // Isolated and never inlined so the JIT resolves OnCustomHudClicked only + // here: on a SwiftlyS2 older than 1.4.6-beta.9 the type does not exist, and + // touching it anywhere inside Load would take the whole plugin down instead + // of just the HUD. Losing the panel and keeping centre text is the point of + // having both. + // Isolated so the JIT resolves CCSCustomHudLayout only here. + [MethodImpl(MethodImplOptions.NoInlining)] + private HudKit? ResolveHud() + { + try + { + return _serviceProvider.GetRequiredService(); + } + catch (Exception exception) + { + _logger.LogWarning( + "custom hud unavailable on this SwiftlyS2 build ({Reason}); panels stay on centre text", + exception.Message + ); + + return null; + } + } + + // Only ever called when _hud resolved, which is the same thing as the custom + // hud types existing. The guard has to sit at the CALL: naming + // OnCustomHudClicked anywhere in here means the JIT resolves it as it + // compiles this method, so a try/catch inside would never get to run. + [MethodImpl(MethodImplOptions.NoInlining)] + private void WireHudClicks() + { + _hudClickHandler = _hud!.OnClicked; + Core.Event.OnCustomHudClicked += _hudClickHandler; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void UnwireHudClicks() + { + if (_hudClickHandler == null) + { + return; + } + + Core.Event.OnCustomHudClicked -= _hudClickHandler; + _hudClickHandler = null; + } + // Swiftly's client events carry a slot, not a steam id. private void ForPlayer(int playerId, Action action) { @@ -1124,6 +1663,8 @@ private void ForPlayer(int playerId, Action action) private void OnMapLoad(string mapName) { + _hud?.Reset(); + _menus.Clear(); _recorder.Reset(); _playbook.Reset(); _drill.Reset(); @@ -1140,6 +1681,8 @@ private void OnMapLoad(string mapName) _library.SetMap(mapName); _session.Map = mapName; + _callouts.Reset(); + _callouts.Report(mapName); ApplyPracticeCfg(); @@ -1247,10 +1790,6 @@ private void OnSessionRefreshed(PracticeSessionData session) // the duel cfg uses for the same continuous-respawn reason. "mp_autokick 0", "mp_disconnect_kills_players 0", - // Nobody is here but the thrower. Bots add competitive round noise and - // a team-select screen the render has to sit through. - "bot_quota 0", - "bot_kick", // Nothing ends the round: a kill or an expired timer would reset // everyone mid-lineup. "mp_ignore_round_win_conditions 1", @@ -1275,9 +1814,14 @@ 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", + // This is what keeps the practice camera up after the grenade lands, + // which is the only way to watch a smoke actually bloom -- the pip + // above turns the camera on, this decides how long it and the trail + // survive it. Setting it to 0 to suppress the engine's team-coloured + // trail took the bloom view with it. Longer than the ten it was + // before: a smoke detonates and then takes a couple of seconds to + // fill, and the point is to see the end of that, not the start. + "sv_grenade_trajectory_prac_trailtime " + EngineTrailSeconds, // 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. @@ -1298,10 +1842,49 @@ private void ApplyPracticeCfg() Core.Scheduler.DelayBySeconds(CfgReapplySeconds, () => RunPracticeCfg()); } + // Freeze time is the one cvar a restart can beat us to. The cfg above lands + // a tick after the map loads and again three seconds later, and a restart + // inside that window begins its countdown with whatever the map's own cfg + // left behind -- so it is asserted again as each round begins, where nothing + // can exec over it afterwards. + private void KeepRoundsMoving() + { + Core.Engine.ExecuteCommand("mp_freezetime 0;mp_warmup_pausetimer 0;mp_warmup_end"); + } + + // Nobody is here but the thrower unless somebody has asked for a bot to + // throw at. Kept out of PracticeCfg because that list is re-run on every + // map change and twice on load, and a bot placed to practise against must + // not be swept away by housekeeping a second later. + private const int EngineTrailSeconds = 20; + + private static readonly string[] NoBotsCfg = new[] { "bot_quota 0", "bot_kick" }; + + // What a bot is for here: something to flash and to blow up, that stays + // where it was put. bot_zombie stops them walking off the spot, and + // bot_join_after_player stops the quota filling itself the moment somebody + // connects. + private static readonly string[] BotsCfg = new[] + { + "bot_quota_mode normal", + "bot_join_after_player 0", + "bot_zombie 1", + "bot_stop 1", + "bot_freeze 1", + "bot_chatter off", + "mp_limitteams 0", + "mp_autoteambalance 0", + }; + private void RunPracticeCfg() { Core.Engine.ExecuteCommand(string.Join(";", PracticeCfg)); + if (_bots.Count == 0) + { + Core.Engine.ExecuteCommand(string.Join(";", NoBotsCfg)); + } + // 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; diff --git a/apps/utility-sw/test/CalloutLookupTests.cs b/apps/utility-sw/test/CalloutLookupTests.cs new file mode 100644 index 00000000..44e32558 --- /dev/null +++ b/apps/utility-sw/test/CalloutLookupTests.cs @@ -0,0 +1,124 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +namespace FiveStack.Tests; + +// The same cases the panel's resolver is tested against +// (api/src/utility/utility-callouts.service.spec.ts). They are duplicated +// deliberately: a name the HUD gives and a name the website gives for the same +// throw must never disagree, and only matching tests keep that true. +public class CalloutLookupTests +{ + private static MapCalloutBox Box(float[] min, float[] max) + { + return new MapCalloutBox { min = min, max = max }; + } + + private static MapCalloutPayload Place(string name, params MapCalloutBox[] boxes) + { + return new MapCalloutPayload { name = name, boxes = boxes.ToList() }; + } + + private static readonly List Map = new() + { + Place("BombsiteA", Box(new[] { 0f, 0f, 0f }, new[] { 1000f, 1000f, 200f })), + Place("Goose", Box(new[] { 100f, 100f, 0f }, new[] { 300f, 300f, 200f })), + Place("Ramp", Box(new[] { 2000f, 0f, 0f }, new[] { 2400f, 400f, 200f })), + }; + + private static readonly List Stacked = new() + { + Place("Upper", Box(new[] { 0f, 0f, 100f }, new[] { 500f, 500f, 300f })), + Place("Lower", Box(new[] { 0f, 0f, -400f }, new[] { 500f, 500f, -100f })), + }; + + [Fact] + public void NamesThePlaceAPointStandsIn() + { + Assert.Equal("BombsiteA", CalloutLookup.Resolve(new Vec3(800, 800, 50), Map)); + } + + // The specific name is the one a player would say. + [Fact] + public void PrefersTheSmallerOfTwoNestedVolumes() + { + Assert.Equal("Goose", CalloutLookup.Resolve(new Vec3(200, 200, 50), Map)); + } + + [Fact] + public void NamesThePlaceBeneathAPointRestingAboveIt() + { + Assert.Equal("BombsiteA", CalloutLookup.Resolve(new Vec3(800, 800, 900), Map)); + } + + // Two places at the same XY on different levels is the Nuke/Vertigo case. + [Theory] + [InlineData(200f, "Upper")] + [InlineData(-200f, "Lower")] + public void UsesZToSeparateStackedPlaces(float z, string expected) + { + Assert.Equal(expected, CalloutLookup.Resolve(new Vec3(250, 250, z), Stacked)); + } + + [Fact] + public void SnapsToANearbyPlaceOutsideEveryVolume() + { + Assert.Equal("BombsiteA", CalloutLookup.Resolve(new Vec3(1100, 500, 50), Map)); + } + + [Fact] + public void SaysNothingWhenTheNearestPlaceIsTooFarToMeanAnything() + { + Assert.Null(CalloutLookup.Resolve(new Vec3(9000, 9000, 50), Map)); + } + + [Fact] + public void SaysNothingWhenTheMapHasNoCallouts() + { + Assert.Null(CalloutLookup.Resolve(new Vec3(0, 0, 0), new List())); + Assert.Null(CalloutLookup.Resolve(new Vec3(0, 0, 0), null)); + } + + [Theory] + [InlineData("BombsiteA", "A Site")] + [InlineData("BombsiteB", "B Site")] + [InlineData("TSpawn", "T Spawn")] + [InlineData("CTSpawn", "CT Spawn")] + [InlineData("Catwalk", "Catwalk")] + [InlineData("LongDoors", "Long Doors")] + [InlineData("back_alley", "back alley")] + [InlineData("TopofMid", "Top of Mid")] + [InlineData("BackofA", "Back of A")] + [InlineData("Roof", "Roof")] + [InlineData("", "")] + public void HumanisesAPlaceName(string raw, string expected) + { + Assert.Equal(expected, CalloutLookup.Humanize(raw)); + } + + [Fact] + public void ResolvesAndHumanisesInOneStep() + { + Assert.Equal("A Site", CalloutLookup.ResolveLabel(new Vec3(800, 800, 50), Map)); + Assert.Equal(string.Empty, CalloutLookup.ResolveLabel(new Vec3(9000, 9000, 50), Map)); + } + + // A place is legitimately several disjoint volumes, and either half of it + // has to answer with the same name. + [Fact] + public void ReadsEveryVolumeOfAMultiBoxPlace() + { + var banana = new List + { + Place( + "Banana", + Box(new[] { 0f, 0f, 0f }, new[] { 200f, 200f, 100f }), + Box(new[] { 900f, 900f, 0f }, new[] { 1100f, 1100f, 100f }) + ), + }; + + Assert.Equal("Banana", CalloutLookup.Resolve(new Vec3(100, 100, 50), banana)); + Assert.Equal("Banana", CalloutLookup.Resolve(new Vec3(1000, 1000, 50), banana)); + } +} diff --git a/apps/utility-sw/test/FiveStack.Tests.csproj b/apps/utility-sw/test/FiveStack.Tests.csproj index bbdf986c..e06a3e9d 100644 --- a/apps/utility-sw/test/FiveStack.Tests.csproj +++ b/apps/utility-sw/test/FiveStack.Tests.csproj @@ -25,7 +25,9 @@ + + @@ -40,5 +42,13 @@ + + + + + + + + diff --git a/apps/utility-sw/test/HudAimGridTests.cs b/apps/utility-sw/test/HudAimGridTests.cs new file mode 100644 index 00000000..8da47967 --- /dev/null +++ b/apps/utility-sw/test/HudAimGridTests.cs @@ -0,0 +1,153 @@ +using FiveStack.Utilities; +using Xunit; + +namespace FiveStack.Tests; + +public class HudAimGridTests +{ + private const float Tolerance = 0.35f; + private const int Columns = 25; + private const int Rows = 17; + private const int CentreColumn = 12; + private const int CentreRow = 8; + + [Fact] + public void OnTheAngleSitsInTheCentreCell() + { + Assert.Equal(CentreColumn, HudAimGrid.Column(90f, 90f, Tolerance, Columns)); + Assert.Equal(CentreRow, HudAimGrid.Row(-12f, -12f, Tolerance, Rows)); + } + + [Fact] + public void TargetToTheRightPutsTheDotLeft() + { + Assert.True(HudAimGrid.Column(90f, 89f, Tolerance, Columns) < CentreColumn); + Assert.True(HudAimGrid.Column(90f, 91f, Tolerance, Columns) > CentreColumn); + } + + [Fact] + public void LookingBelowTheTargetPutsTheDotLow() + { + Assert.True(HudAimGrid.Row(5f, 0f, Tolerance, Rows) > CentreRow); + Assert.True(HudAimGrid.Row(-5f, 0f, Tolerance, Rows) < CentreRow); + } + + // The whole point of the rework: the drawn box is one tolerance, so exactly + // at tolerance the dot sits on its edge whatever the lineup's tolerance is. + [Theory] + [InlineData(0.1f)] + [InlineData(0.35f)] + [InlineData(2.0f)] + public void OneToleranceOutLandsOnTheBoxEdge(float tolerance) + { + Assert.Equal(HudAimGrid.ToleranceExtent, HudAimGrid.Offset(tolerance, tolerance), 4); + Assert.Equal(-HudAimGrid.ToleranceExtent, HudAimGrid.Offset(-tolerance, tolerance), 4); + } + + [Theory] + [InlineData(0.1f)] + [InlineData(0.35f)] + [InlineData(2.0f)] + public void InsideToleranceIsAlwaysInsideTheBox(float tolerance) + { + for (float fraction = 0f; fraction < 1f; fraction += 0.05f) + { + float offset = Math.Abs(HudAimGrid.Offset(tolerance * fraction, tolerance)); + + Assert.True(offset < HudAimGrid.ToleranceExtent + 0.0001f); + } + } + + [Fact] + public void OutsideToleranceIsAlwaysOutsideTheBox() + { + foreach (float multiple in new[] { 1.2f, 2f, 5f, 40f }) + { + float offset = Math.Abs(HudAimGrid.Offset(Tolerance * multiple, Tolerance)); + + Assert.True(offset > HudAimGrid.ToleranceExtent); + } + } + + // Linear inside the tolerance: half the tolerance is half the travel, so + // the range the player is working in gets every cell it can. + [Fact] + public void TravelIsLinearInsideTheTolerance() + { + Assert.Equal(HudAimGrid.ToleranceExtent / 2f, HudAimGrid.Offset(Tolerance / 2f, Tolerance), 4); + Assert.Equal(HudAimGrid.ToleranceExtent / 4f, HudAimGrid.Offset(Tolerance / 4f, Tolerance), 4); + } + + [Fact] + public void FarOutPinsToTheEdgeWithoutOvershooting() + { + Assert.Equal(Columns - 1, HudAimGrid.Column(90f, 90f + 400f * Tolerance, Tolerance, Columns)); + Assert.Equal(0, HudAimGrid.Column(90f, 90f - 400f * Tolerance, Tolerance, Columns)); + Assert.True(Math.Abs(HudAimGrid.Offset(9999f, Tolerance)) <= 1f); + } + + [Fact] + public void ZeroToleranceFallsBackToTheDefault() + { + Assert.Equal( + HudAimGrid.Offset(PracticeLineupUtility.DefaultAimTolerance, PracticeLineupUtility.DefaultAimTolerance), + HudAimGrid.Offset(PracticeLineupUtility.DefaultAimTolerance, 0f), + 4 + ); + } + + [Fact] + public void WrapsTheShortWayRound() + { + Assert.Equal(0f, HudAimGrid.Delta(359f, 359f)); + Assert.Equal(2f, HudAimGrid.Delta(1f, 359f)); + Assert.Equal(-2f, HudAimGrid.Delta(359f, 1f)); + + Assert.Equal( + HudAimGrid.Column(1f, 359f, Tolerance, Columns), + HudAimGrid.Column(11f, 9f, Tolerance, Columns) + ); + } + + [Fact] + public void EveryCellIsInRange() + { + for (float degrees = -30f; degrees <= 30f; degrees += 0.05f) + { + Assert.InRange(HudAimGrid.Column(90f + degrees, 90f, Tolerance, Columns), 0, Columns - 1); + Assert.InRange(HudAimGrid.Row(degrees, 0f, Tolerance, Rows), 0, Rows - 1); + } + } + + // The words and the dot must never disagree: the dot sits where the player + // is aiming, so a target to the right puts the dot LEFT and the text has to + // say LOOK RIGHT. Source yaw increases anticlockwise, so a target BELOW the + // player's yaw is the one on their right. + [Fact] + public void DirectionAgreesWithTheDot() + { + Assert.True(HudAimGrid.Column(90f, 89f, Tolerance, Columns) < CentreColumn); + Assert.Equal("LOOK RIGHT", HudAimGrid.Direction(90f, 0f, 89f, 0f, Tolerance)); + + Assert.True(HudAimGrid.Column(90f, 91f, Tolerance, Columns) > CentreColumn); + Assert.Equal("LOOK LEFT", HudAimGrid.Direction(90f, 0f, 91f, 0f, Tolerance)); + + Assert.True(HudAimGrid.Row(5f, 0f, Tolerance, Rows) > CentreRow); + Assert.Equal("LOOK UP", HudAimGrid.Direction(90f, 5f, 90f, 0f, Tolerance)); + + Assert.True(HudAimGrid.Row(-5f, 0f, Tolerance, Rows) < CentreRow); + Assert.Equal("LOOK DOWN", HudAimGrid.Direction(90f, -5f, 90f, 0f, Tolerance)); + } + + [Fact] + public void DirectionNamesBothAxesWhenBothAreOut() + { + Assert.Equal("LOOK RIGHT AND DOWN", HudAimGrid.Direction(90f, -5f, 89f, 0f, Tolerance)); + } + + [Fact] + public void OnTheAngleSaysSo() + { + Assert.Equal("LINED UP - THROW IT", HudAimGrid.Direction(90f, -12f, 90f, -12f, Tolerance)); + } +} diff --git a/apps/utility-sw/test/HudLayoutContractTests.cs b/apps/utility-sw/test/HudLayoutContractTests.cs new file mode 100644 index 00000000..1a05d1d5 --- /dev/null +++ b/apps/utility-sw/test/HudLayoutContractTests.cs @@ -0,0 +1,416 @@ +using FiveStack.Utilities; +using Xunit; + +namespace FiveStack.Tests; + +// Every failure mode of custom_hud_layout is silent: a variable the layout does +// not declare renders nothing and logs nothing. These are the only thing that +// catches a rename. +public class HudLayoutContractTests +{ + private static string HudRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory != null) + { + string candidate = Path.Combine(directory.FullName, "apps", "utility-sw", "hud"); + + if (Directory.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("apps/utility-sw/hud not found above the test output"); + } + + private static string Layout(string name) + { + return File.ReadAllText( + Path.Combine(HudRoot(), "panorama", "layout", "custom_game", $"{name}.xml") + ); + } + + private static string Styles() + { + return File.ReadAllText( + Path.Combine(HudRoot(), "panorama", "styles", "custom_game", "nadehud.css") + ); + } + + public static TheoryData Layouts() + { + var data = new TheoryData(); + + foreach (HudLayoutSlots slots in HudSlots.All) + { + data.Add(slots.Layout); + } + + return data; + } + + // A malformed layout still gets most of the way through the panorama + // compiler before failing with a position, not a reason. Catching it here + // costs nothing and names the file. + [Theory] + [MemberData(nameof(Layouts))] + public void LayoutIsWellFormedXml(string layout) + { + System.Xml.Linq.XDocument.Parse(Layout(layout)); + } + + [Theory] + [MemberData(nameof(Layouts))] + public void LayoutAndCodeNameTheSameSlots(string layout) + { + HudLayoutSlots slots = HudSlots.All.Single(candidate => candidate.Layout == layout); + + Assert.Empty(HudLayoutContract.Verify(slots, Layout(layout))); + } + + [Fact] + public void EveryElementTheHudTogglesClassesOnExists() + { + string[] toggled = + { + HudSlots.Hud.RootId, + "spot", + "aim", + "aimerr", + "noterow", + "drillrow", + "drillbar", + }; + + Assert.Empty(HudLayoutContract.VerifyElements(HudSlots.Hud, Layout(HudSlots.NadeHud), toggled)); + } + + [Fact] + public void EveryElementTheListTogglesClassesOnExists() + { + var toggled = new List { HudSlots.List.RootId, "pager" }; + + for (int row = 1; row <= HudSlots.ListRows; row++) + { + toggled.Add($"row{row}"); + } + + foreach (string side in HudSlots.ListSides) + { + toggled.Add($"side_{side}"); + } + + foreach (string scope in HudSlots.ListScopes) + { + toggled.Add($"scope_{scope}"); + } + + foreach (string type in HudSlots.ListTypes) + { + toggled.Add($"type_{type}"); + } + + toggled.Add("body"); + + Assert.Empty( + HudLayoutContract.VerifyElements(HudSlots.List, Layout(HudSlots.NadeList), toggled) + ); + } + + + [Fact] + public void EveryClassTheHudTogglesIsStyled() + { + var toggles = new List<(string, string)> + { + (HudSlots.Hud.RootId, HudSlots.Shown), + (HudSlots.Hud.RootId, "docked"), + ("spot", "on"), + ("aim", "on"), + ("aimerr", "on"), + ("steer", "on"), + ("throwcolor", "hidden"), + ("noterow", HudSlots.Shown), + ("drillrow", HudSlots.Shown), + }; + + Assert.Empty( + HudLayoutContract.VerifyToggles(HudSlots.Hud, Layout(HudSlots.NadeHud), Styles(), toggles) + ); + } + + [Fact] + public void EveryClassTheListTogglesIsStyled() + { + var toggles = new List<(string, string)> + { + (HudSlots.List.RootId, HudSlots.Shown), + ("pager", "hidden"), + }; + + for (int row = 1; row <= HudSlots.ListRows; row++) + { + toggles.Add(($"row{row}", "hidden")); + toggles.Add(($"row{row}", "selected")); + toggles.Add(($"row{row}", "disabled")); + } + + foreach (string side in HudSlots.ListSides) + { + toggles.Add(($"side_{side}", "on")); + } + + foreach (string scope in HudSlots.ListScopes) + { + toggles.Add(($"scope_{scope}", "on")); + } + + foreach (string type in HudSlots.ListTypes) + { + toggles.Add(($"type_{type}", "on")); + } + + + Assert.Empty( + HudLayoutContract.VerifyToggles( + HudSlots.List, + Layout(HudSlots.NadeList), + Styles(), + toggles + ) + ); + } + + [Fact] + public void EveryElementTheMapTogglesClassesOnExists() + { + var toggled = new List { HudSlots.Map.RootId, "radar", "detail", "list" }; + + for (int marker = 1; marker <= HudSlots.MapMarkers; marker++) + { + toggled.Add($"m{marker}"); + toggled.Add($"n{marker}"); + } + + Assert.Empty( + HudLayoutContract.VerifyElements(HudSlots.Map, Layout(HudSlots.NadeMap), toggled) + ); + } + + [Fact] + public void EveryClassTheMapTogglesIsStyled() + { + var toggles = new List<(string, string)> + { + (HudSlots.Map.RootId, HudSlots.Shown), + ("detail", HudSlots.Shown), + ("list", "hidden"), + }; + + foreach (string map in RadarMaps.All) + { + toggles.Add(("radar", map)); + } + + for (int marker = 1; marker <= HudSlots.MapMarkers; marker++) + { + toggles.Add(($"m{marker}", "hidden")); + toggles.Add(($"m{marker}", "reachable")); + toggles.Add(($"m{marker}", "loaded")); + toggles.Add(($"m{marker}", "selected")); + + foreach (string type in RadarMaps.Types) + { + toggles.Add(($"m{marker}", type)); + } + } + + Assert.Empty( + HudLayoutContract.VerifyToggles( + HudSlots.Map, + Layout(HudSlots.NadeMap), + Styles(), + toggles + ) + ); + } + + // The marker grid is shared across every marker (.nh-mx / .nh-my) rather + // than scoped per id, so it is checked by class not by element. + [Theory] + [InlineData("nh-marker", "x")] + [InlineData("nh-marker", "y")] + public void MarkerGridIsDeclaredForEveryCell(string carrier, string prefix) + { + SortedSet declared = HudLayoutContract.ClassStates(Styles(), carrier, prefix, byClass: true); + var expected = new SortedSet( + Enumerable.Range(0, HudSlots.MapGrid).Select(step => $"{prefix}{step}"), + StringComparer.Ordinal + ); + + Assert.Equal(expected, declared); + } + + // A map with an image but no calibration (or vice versa) draws markers in + // the wrong place, which is worse than drawing none. + // The chip's classes come straight from PracticeStepColors, so a colour + // added there without a rule here would render an unstyled pill. + [Fact] + public void EveryStepColourIsStyled() + { + string styles = Styles(); + var toggles = new List<(string, string)>(); + + for (int index = 0; index < PracticeStepColors.Count; index++) + { + toggles.Add(("throwcolor", PracticeStepColors.For(index).Name)); + } + + Assert.Empty( + HudLayoutContract.VerifyToggles(HudSlots.Hud, Layout(HudSlots.NadeHud), styles, toggles) + ); + } + + // Green and red are the reticle's ramp. A step colour wearing either would + // read as aim feedback; PracticeStepColors asserts the palette, this asserts + // the HUD never styles one in. + [Fact] + public void NoStepColourCollidesWithTheReticle() + { + for (int index = 0; index < PracticeStepColors.Count; index++) + { + string name = PracticeStepColors.For(index).Name; + + Assert.NotEqual("green", name); + Assert.NotEqual("red", name); + } + } + + [Fact] + public void EveryRadarMapHasAnImageAStyleAndCalibration() + { + string styles = Styles(); + var metadata = System.Text.Json.JsonDocument.Parse( + File.ReadAllText(Path.Combine(HudRoot(), "radars", "metadata.json")) + ); + + foreach (string map in RadarMaps.All) + { + Assert.Contains($"#radar.{map}", styles); + Assert.True( + metadata.RootElement.TryGetProperty(map, out _), + $"{map}: no calibration in metadata.json" + ); + } + } + + [Fact] + public void EveryElementTheRunTogglesClassesOnExists() + { + var toggled = new List { HudSlots.Run.RootId }; + + for (int step = 1; step <= HudSlots.RunSteps; step++) + { + toggled.Add($"s{step}"); + toggled.Add($"sw{step}"); + } + + Assert.Empty( + HudLayoutContract.VerifyElements(HudSlots.Run, Layout(HudSlots.NadeRun), toggled) + ); + } + + [Fact] + public void EveryClassTheRunTogglesIsStyled() + { + var toggles = new List<(string, string)> { (HudSlots.Run.RootId, HudSlots.Shown) }; + + for (int step = 1; step <= HudSlots.RunSteps; step++) + { + foreach (string state in new[] { "hidden", "mine", "theirs", "done", "now" }) + { + toggles.Add(($"s{step}", state)); + } + + for (int colour = 0; colour < PracticeStepColors.Count; colour++) + { + toggles.Add(($"sw{step}", PracticeStepColors.For(colour).Name)); + } + } + + Assert.Empty( + HudLayoutContract.VerifyToggles(HudSlots.Run, Layout(HudSlots.NadeRun), Styles(), toggles) + ); + } + + [Fact] + public void EveryElementTheEditTogglesClassesOnExists() + { + var toggled = new List { HudSlots.Edit.RootId, "name", "desc", "fdesc", "hint" }; + + foreach (string visibility in HudSlots.Visibilities) + { + toggled.Add($"vis_{visibility}"); + } + + Assert.Empty( + HudLayoutContract.VerifyElements(HudSlots.Edit, Layout(HudSlots.NadeEdit), toggled) + ); + } + + [Fact] + public void EveryClassTheEditTogglesIsStyled() + { + var toggles = new List<(string, string)> + { + (HudSlots.Edit.RootId, HudSlots.Shown), + ("name", "asking"), + ("desc", "asking"), + ("fdesc", "empty"), + ("hint", "warn"), + ("hint", "bad"), + ("hint", "good"), + }; + + foreach (string visibility in HudSlots.Visibilities) + { + toggles.Add(($"vis_{visibility}", "on")); + } + + Assert.Empty( + HudLayoutContract.VerifyToggles( + HudSlots.Edit, + Layout(HudSlots.NadeEdit), + Styles(), + toggles + ) + ); + } + + // Geometry must not be editable: a lineup that moves keeps its id, so every + // scored attempt against it silently becomes a measurement of a different + // throw. + [Fact] + public void TheEditPanelCannotTouchGeometry() + { + string layout = Layout(HudSlots.NadeEdit); + + foreach (string forbidden in new[] { "origin", "yaw", "pitch", "land", "velocity", "throw" }) + { + Assert.DoesNotContain(forbidden, layout, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void RootsAreCollapsedUntilAPlayerIsOptedIn() + { + string styles = Styles(); + + Assert.Contains(".nh-root.shown", styles); + Assert.Contains(".nh-list.shown", styles); + } +} diff --git a/apps/utility-sw/test/HudSteadyTests.cs b/apps/utility-sw/test/HudSteadyTests.cs new file mode 100644 index 00000000..f2b0be06 --- /dev/null +++ b/apps/utility-sw/test/HudSteadyTests.cs @@ -0,0 +1,90 @@ +using FiveStack.Utilities; +using Xunit; + +namespace FiveStack.Tests; + +public class HudSteadyTests +{ + private const int Hold = 22; + + [Fact] + public void HoldsTheStartingValue() + { + Assert.False(HudSteady.Start(false).Committed); + Assert.True(HudSteady.Start(true).Committed); + } + + [Fact] + public void DoesNotFollowUntilTheReadingHasHeld() + { + HudSteady steady = HudSteady.Start(false); + + steady = steady.Read(true, 100, Hold); + Assert.False(steady.Committed); + + steady = steady.Read(true, 100 + Hold - 1, Hold); + Assert.False(steady.Committed); + + steady = steady.Read(true, 100 + Hold, Hold); + Assert.True(steady.Committed); + } + + // The whole point: standing on the edge of the circle flips the raw test + // many times a second, and the panel must not move for any of them. + [Fact] + public void FlickerNeverCommits() + { + HudSteady steady = HudSteady.Start(false); + + for (int tick = 0; tick < 2000; tick++) + { + steady = steady.Read(tick % 2 == 0, tick, Hold); + + Assert.False(steady.Committed); + } + } + + // A reading that goes back to the committed value drops the pending move + // entirely, rather than letting near-misses accumulate towards one. + [Fact] + public void GoingBackResetsTheTimer() + { + HudSteady steady = HudSteady.Start(false); + + steady = steady.Read(true, 0, Hold); + steady = steady.Read(false, Hold - 1, Hold); + steady = steady.Read(true, Hold, Hold); + + Assert.False(steady.Committed); + + steady = steady.Read(true, Hold * 2, Hold); + + Assert.True(steady.Committed); + } + + [Fact] + public void CommitsInBothDirections() + { + HudSteady steady = HudSteady.Start(true); + + for (int tick = 0; tick <= Hold; tick++) + { + steady = steady.Read(false, tick, Hold); + } + + Assert.False(steady.Committed); + } + + [Fact] + public void StayingPutNeverChanges() + { + HudSteady steady = HudSteady.Start(true); + + for (int tick = 0; tick < 500; tick++) + { + steady = steady.Read(true, tick, Hold); + + Assert.True(steady.Committed); + } + } +} diff --git a/apps/utility-sw/test/LineupNamingTests.cs b/apps/utility-sw/test/LineupNamingTests.cs new file mode 100644 index 00000000..e5728f4f --- /dev/null +++ b/apps/utility-sw/test/LineupNamingTests.cs @@ -0,0 +1,168 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +namespace FiveStack.Tests; + +// Pinned against UtilityCalloutsService.autoName in the api. The name this +// produces in game and the name the website shows for the same throw are the +// same string or the feature is worse than no name at all. +public class LineupNamingTests +{ + private static MapCalloutPayload Place(string name, float x, float y) + { + return new MapCalloutPayload + { + name = name, + boxes = new List + { + new MapCalloutBox + { + min = new[] { x - 100f, y - 100f, -100f }, + max = new[] { x + 100f, y + 100f, 100f }, + }, + }, + }; + } + + private static List Mirage() + { + return new List + { + Place("Window", 0f, 0f), + Place("TSpawn", 2000f, 0f), + Place("BombsiteA", -2000f, 0f), + }; + } + + [Fact] + public void NamesBothEnds() + { + Assert.Equal( + "Window Smoke from T Spawn", + LineupNaming.Auto( + nameof(eUtilityType.Smoke), + new Vec3(2000f, 0f, 0f), + new Vec3(0f, 0f, 0f), + Mirage() + ) + ); + } + + // Thrown from the place it lands in: "Window Smoke from Window" says nothing. + [Fact] + public void CollapsesWhenBothEndsAreTheSamePlace() + { + Assert.Equal( + "Window Smoke", + LineupNaming.Auto( + nameof(eUtilityType.Smoke), + new Vec3(0f, 0f, 0f), + new Vec3(20f, 20f, 0f), + Mirage() + ) + ); + } + + [Fact] + public void NamesTheDestinationAloneWhenTheOriginIsNowhere() + { + Assert.Equal( + "Window Smoke", + LineupNaming.Auto( + nameof(eUtilityType.Smoke), + new Vec3(50000f, 50000f, 0f), + new Vec3(0f, 0f, 0f), + Mirage() + ) + ); + } + + [Fact] + public void NamesTheOriginAloneWhenTheDestinationIsNowhere() + { + Assert.Equal( + "Smoke from T Spawn", + LineupNaming.Auto( + nameof(eUtilityType.Smoke), + new Vec3(2000f, 0f, 0f), + new Vec3(50000f, 50000f, 0f), + Mirage() + ) + ); + } + + // Empty rather than a bad guess: the caller's own fallback is better than a + // name that says nothing. + [Fact] + public void EmptyWhenTheMapCannotAnswer() + { + Assert.Equal( + "", + LineupNaming.Auto( + nameof(eUtilityType.Smoke), + new Vec3(50000f, 50000f, 0f), + new Vec3(60000f, 60000f, 0f), + Mirage() + ) + ); + + Assert.Equal( + "", + LineupNaming.Auto(nameof(eUtilityType.Smoke), new Vec3(), new Vec3(), null) + ); + + Assert.Equal( + "", + LineupNaming.Auto( + nameof(eUtilityType.Smoke), + new Vec3(), + new Vec3(), + new List() + ) + ); + } + + // The api's TYPE_LABELS: only HighExplosive differs from its enum name. + [Theory] + [InlineData(nameof(eUtilityType.Smoke), "Smoke")] + [InlineData(nameof(eUtilityType.Flash), "Flash")] + [InlineData(nameof(eUtilityType.Molotov), "Molotov")] + [InlineData(nameof(eUtilityType.Decoy), "Decoy")] + [InlineData(nameof(eUtilityType.HighExplosive), "HE")] + public void UsesThePanelsTypeLabels(string utilityType, string expected) + { + Assert.Equal(expected, LineupNaming.TypeLabel(utilityType)); + } + + [Fact] + public void HighExplosiveReadsAsHeInAFullName() + { + Assert.Equal( + "Window HE from T Spawn", + LineupNaming.Auto( + nameof(eUtilityType.HighExplosive), + new Vec3(2000f, 0f, 0f), + new Vec3(0f, 0f, 0f), + Mirage() + ) + ); + } + + // Humanised through the shared lookup, so "BombsiteA" is "A Site" here and + // on the website both. + [Fact] + public void PlaceNamesAreHumanised() + { + Assert.Equal( + "A Site Smoke from T Spawn", + LineupNaming.Auto( + nameof(eUtilityType.Smoke), + new Vec3(2000f, 0f, 0f), + new Vec3(-2000f, 0f, 0f), + Mirage() + ) + ); + } +} diff --git a/apps/utility-sw/test/PracticeDrillRunTests.cs b/apps/utility-sw/test/PracticeDrillRunTests.cs index b7d559f2..2d41d79b 100644 --- a/apps/utility-sw/test/PracticeDrillRunTests.cs +++ b/apps/utility-sw/test/PracticeDrillRunTests.cs @@ -498,6 +498,148 @@ public void TwoRunsKeepTheirOwnCounts() } } +// Fading the crosshair across a run is what makes the last rep worth +// anything: with it up the whole way, a drill measures how well somebody reads +// a crosshair. +public class PracticeDrillAssistTests +{ + private static readonly DateTime Now = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + private static LineupRecord Lineup(string id) + { + return new LineupRecord { id = id, client_id = id, utility_type = "Smoke" }; + } + + private static UtilityPracticeResult Result(bool success) + { + return new UtilityPracticeResult { success = success, radius = 80f }; + } + + 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 ARunStartsWithFullHelp() + { + Assert.Equal(1f, new PracticeDrillRun(new[] { Lineup("a") }, 3).Assist, 3); + } + + [Fact] + public void ASingleRepRunIsPracticeNotATest() + { + var run = new PracticeDrillRun(new[] { Lineup("a") }, 1); + + Throws(run, hit: true); + + Assert.Equal(1f, run.Assist, 3); + } + + // The point of the whole mechanism: help is EARNED away by landing it, so + // the last rep is thrown off what the player actually learned. + [Fact] + public void LandingItTakesTheCrosshairAway() + { + var run = new PracticeDrillRun(new[] { Lineup("a") }, 3); + + Throws(run, hit: true); + Assert.Equal(0.5f, run.Assist, 3); + + Throws(run, hit: true); + Assert.Equal(0f, run.Assist, 3); + } + + // And the reason it is not just the rep number: somebody missing every + // throw is exactly who still needs the crosshair. + [Fact] + public void MissingEveryThrowNeverTakesTheCrosshairAway() + { + var run = new PracticeDrillRun(new[] { Lineup("a") }, 3); + + Throws(run, hit: false); + Throws(run, hit: false); + + Assert.Equal(1f, run.Assist, 3); + } + + // A miss gives a step back rather than resetting: one bad throw at the end + // of a good run is a bad throw, not evidence they never knew it. + [Fact] + public void AMissGivesOneStepBackRatherThanAllOfIt() + { + var run = new PracticeDrillRun(new[] { Lineup("a") }, 5); + + Throws(run, hit: true); + Throws(run, hit: true); + Throws(run, hit: true); + Assert.Equal(0.25f, run.Assist, 3); + + Throws(run, hit: false); + Assert.Equal(0.5f, run.Assist, 3); + } + + // A throw the panel never scored says nothing about whether they know it, + // so it must not move the help in either direction. + [Fact] + public void AnUnscoredThrowLeavesTheHelpAlone() + { + var run = new PracticeDrillRun(new[] { Lineup("a") }, 3); + + Throws(run, hit: true); + float earned = run.Assist; + + LineupRecord? lineup = run.Next(); + Assert.NotNull(lineup); + Assert.True(run.Thrown(lineup!.utility_type, Now)); + run.Score(lineup.id, null); + + Assert.Equal(earned, run.Assist, 3); + } + + // A different throw is a different thing to have learned. + [Fact] + public void MovingToTheNextLineupStartsFromFullHelpAgain() + { + // Note the queue runs a,a,b,b at two reps -- both goes at "a" come + // before "b" starts. + var run = new PracticeDrillRun(new[] { Lineup("a"), Lineup("b") }, 2); + + Throws(run, hit: true); + Assert.Equal(0f, run.Assist, 3); + + Throws(run, hit: true); + Assert.Equal(0f, run.Assist, 3); + + // Checked as the run ARRIVES at "b", before that throw is scored -- + // scoring it would earn the help straight back off and hide whether it + // was ever restored. + Assert.Equal("b", run.Next()!.id); + Assert.Equal(1f, run.Assist, 3); + } + + [Fact] + public void AssistNeverLeavesTheZeroToOneRange() + { + foreach (int reps in new[] { 1, 2, 3, 5, 10 }) + { + var run = new PracticeDrillRun(new[] { Lineup("a") }, reps); + + // Exactly the run's length: past the last rep Next() hands back + // null and there is nothing left to score. + for (int index = 0; index < reps; index++) + { + Throws(run, hit: index % 3 != 0); + Assert.InRange(run.Assist, 0f, 1f); + } + } + } +} + public class PracticeDrillRunRepTests { private static LineupRecord Lineup(string id) diff --git a/apps/utility-sw/test/PracticeLineupUtilityTests.cs b/apps/utility-sw/test/PracticeLineupUtilityTests.cs index fab368ee..4e6afa8d 100644 --- a/apps/utility-sw/test/PracticeLineupUtilityTests.cs +++ b/apps/utility-sw/test/PracticeLineupUtilityTests.cs @@ -425,4 +425,54 @@ public void RunsOfSpacesDoNotCrash() { Assert.Equal("Two Gaps", PracticeLineupUtility.TitleCase("two gaps")); } + + // The panel rejects a practice-result whose lineup id is not a uuid, so + // this is what decides whether a throw is worth reporting at all. Getting + // it wrong in either direction is a real failure: too strict and saved + // lineups stop being scored, too loose and every scratch throw goes back to + // telling the player the panel did not answer. + [Theory] + [InlineData("2ba3c04c-715d-4c4e-bce8-860b97ca6fc3")] + [InlineData("2BA3C04C-715D-4C4E-BCE8-860B97CA6FC3")] + public void PanelIdsAreUuids(string id) + { + Assert.True(PracticeLineupUtility.IsPanelId(id)); + } + + // "2171u, needs 96u" is two numbers in a unit nobody thinks in. The line + // exists to say whether a throw was close or nowhere near, and units answer + // neither. + [Fact] + public void DistancesAreSaidInMetres() + { + Assert.Equal("41m", PracticeLineupUtility.Metres(2171f)); + Assert.Equal("1.8m", PracticeLineupUtility.Metres(96f)); + } + + // Under ten metres the decimal is the whole point: half a metre off is a + // good smoke and three metres off is not, and both round to the same whole + // number. + [Fact] + public void ShortDistancesKeepTheirDecimal() + { + Assert.Contains(".", PracticeLineupUtility.Metres(30f)); + Assert.DoesNotContain(".", PracticeLineupUtility.Metres(2171f)); + } + + [Fact] + public void ADistanceIsNeverNegative() + { + Assert.Equal(PracticeLineupUtility.Metres(96f), PracticeLineupUtility.Metres(-96f)); + } + + [Theory] + [InlineData("scratch-draft")] + [InlineData("scratch-de_mirage:Smoke:-29,14:-8,5")] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void ScratchIdsAreNotPanelIds(string? id) + { + Assert.False(PracticeLineupUtility.IsPanelId(id)); + } } diff --git a/apps/utility-sw/test/PracticeStepColorsTests.cs b/apps/utility-sw/test/PracticeStepColorsTests.cs new file mode 100644 index 00000000..08fc1a2f --- /dev/null +++ b/apps/utility-sw/test/PracticeStepColorsTests.cs @@ -0,0 +1,112 @@ +using FiveStack.Utilities; +using Xunit; + +public class PracticeStepColorsTests +{ + // The whole point is telling four smokes apart, so neighbouring steps must + // not share a colour. + [Fact] + public void EveryColorInThePaletteIsDistinct() + { + var seen = new HashSet<(byte, byte, byte)>(); + + for (int index = 0; index < PracticeStepColors.Count; index++) + { + PracticeStepColors.StepColor color = PracticeStepColors.For(index); + + Assert.True( + seen.Add((color.R, color.G, color.B)), + $"step {index} ({color.Name}) repeats a colour" + ); + } + } + + [Fact] + public void EveryColorIsSayable() + { + for (int index = 0; index < PracticeStepColors.Count; index++) + { + Assert.False(string.IsNullOrWhiteSpace(PracticeStepColors.For(index).Name)); + } + } + + // Green and red are the aim reticle's ramp: green means inside tolerance + // and red means far off it. A step wearing either would answer a question + // it was not asked. + [Fact] + public void NothingIsMistakableForTheAimRamp() + { + for (int index = 0; index < PracticeStepColors.Count; index++) + { + PracticeStepColors.StepColor color = PracticeStepColors.For(index); + + bool green = color.G > 150 && color.R < 120 && color.B < 120; + bool red = color.R > 150 && color.G < 120 && color.B < 120; + + Assert.False(green, $"{color.Name} reads as the lined-up green"); + Assert.False(red, $"{color.Name} reads as the missed red"); + } + } + + // An execute longer than the palette repeats rather than running out: a + // repeated colour is worse than a distinct one and far better than an + // unlit marker. + [Fact] + public void PositionsPastThePaletteWrap() + { + Assert.Equal( + PracticeStepColors.For(0).Name, + PracticeStepColors.For(PracticeStepColors.Count).Name + ); + } + + // The same execute has to come up the same way twice, or the colour is one + // more thing to relearn every run. + [Fact] + public void TheSamePositionIsAlwaysTheSameColor() + { + Assert.Equal(PracticeStepColors.For(3).Name, PracticeStepColors.For(3).Name); + Assert.Equal(PracticeStepColors.For(3).R, PracticeStepColors.For(3).R); + } + + [Fact] + public void ANegativePositionDoesNotThrow() + { + Assert.Equal(PracticeStepColors.For(0).Name, PracticeStepColors.For(-1).Name); + } + + // The per-throw cycle: rehearsing one lineup ten times has to produce ten + // arcs a player can tell apart, which is the only reason to colour a trail + // at all. Consecutive throws must never share a colour. + [Fact] + public void ConsecutiveThrowsNeverShareAColor() + { + for (int throwIndex = 0; throwIndex < 32; throwIndex++) + { + Assert.NotEqual( + PracticeStepColors.For(throwIndex).Name, + PracticeStepColors.For(throwIndex + 1).Name + ); + } + } + + // Ten in a row is the case that was asked for, and the palette is eight + // long -- so the tenth repeats the second. That is intended, but a colour + // must not come back sooner than the palette allows. + [Fact] + public void AColorDoesNotComeBackBeforeTheWholePaletteHasBeenUsed() + { + for (int start = 0; start < 16; start++) + { + var window = new HashSet(); + + for (int step = 0; step < PracticeStepColors.Count; step++) + { + Assert.True( + window.Add(PracticeStepColors.For(start + step).Name), + $"a colour repeated within {PracticeStepColors.Count} throws of {start}" + ); + } + } + } +} diff --git a/apps/utility-sw/test/RadarProjectionTests.cs b/apps/utility-sw/test/RadarProjectionTests.cs new file mode 100644 index 00000000..100be236 --- /dev/null +++ b/apps/utility-sw/test/RadarProjectionTests.cs @@ -0,0 +1,177 @@ +using FiveStack.Entities.Practice; +using FiveStack.Utilities; +using Xunit; + +namespace FiveStack.Tests; + +// Pinned against web/composables/useRadarProjection.ts. If these move, the panel +// and the HUD have stopped agreeing about where a lineup lands. +public class RadarProjectionTests +{ + private static RadarCalibration Mirage() + { + return new RadarCalibration + { + Resolution = 5.02f, + OffsetX = 3240f, + OffsetY = 3410f, + }; + } + + private static RadarCalibration Nuke() + { + return new RadarCalibration + { + Resolution = 6.98f, + OffsetX = 3290f, + OffsetY = 5990f, + Splits = new List + { + new RadarSplit + { + BoundsTop = -482f, + BoundsBottom = -2500f, + OffsetX = 0f, + OffsetY = -46f, + }, + }, + }; + } + + [Theory] + [InlineData("de_mirage", "de_mirage")] + [InlineData("DE_MIRAGE", "de_mirage")] + [InlineData(" de_inferno ", "de_inferno")] + [InlineData("de_ancient_night", "de_ancient")] + [InlineData(null, "")] + public void MapNamesNormaliseLikeTheWeb(string? input, string expected) + { + Assert.Equal(expected, RadarProjection.NormalizeMapName(input)); + } + + // x = (world.x + offsetX) / resolution + // y = 1024 - (world.y + offsetY) / resolution + [Fact] + public void ProjectsWithTheSameArithmeticAsTheWeb() + { + (float x, float y) = RadarProjection.Project(new Vec3(0f, 0f, 0f), Mirage()); + + Assert.Equal(3240f / 5.02f, x, 2); + Assert.Equal(1024f - 3410f / 5.02f, y, 2); + } + + // The y flip is the easiest thing to get backwards, and getting it backwards + // mirrors every marker about the middle of the map. + [Fact] + public void NorthIsUp() + { + RadarCalibration mirage = Mirage(); + (_, float low) = RadarProjection.Project(new Vec3(0f, -1000f, 0f), mirage); + (_, float high) = RadarProjection.Project(new Vec3(0f, 1000f, 0f), mirage); + + Assert.True(high < low); + } + + [Fact] + public void EastIsRight() + { + RadarCalibration mirage = Mirage(); + (float left, _) = RadarProjection.Project(new Vec3(-1000f, 0f, 0f), mirage); + (float right, _) = RadarProjection.Project(new Vec3(1000f, 0f, 0f), mirage); + + Assert.True(right > left); + } + + [Fact] + public void NukeLowerLevelShiftsOntoItsOwnHalf() + { + RadarCalibration nuke = Nuke(); + var upper = new Vec3(0f, 0f, -100f); + var lower = new Vec3(0f, 0f, -1000f); + + (_, float upperY) = RadarProjection.Project(upper, nuke); + (_, float lowerY) = RadarProjection.Project(lower, nuke); + + // -46% of the image on a y that is then flipped, so the lower level ends + // up BELOW the upper one on screen. + Assert.Equal(upperY + 0.46f * RadarProjection.Pixels, lowerY, 2); + } + + [Fact] + public void HeightOutsideTheSplitBandIsUnshifted() + { + RadarCalibration nuke = Nuke(); + + Assert.Equal((0f, 0f), RadarProjection.Split(-100f, nuke.Splits)); + Assert.Equal((0f, -46f), RadarProjection.Split(-1000f, nuke.Splits)); + Assert.Equal((0f, 0f), RadarProjection.Split(-3000f, nuke.Splits)); + } + + [Fact] + public void NormalisedStaysOnTheImage() + { + RadarCalibration mirage = Mirage(); + + foreach (float world in new[] { -99999f, -2000f, 0f, 2000f, 99999f }) + { + (float x, float y) = RadarProjection.Normalized(new Vec3(world, world, 0f), mirage); + + Assert.InRange(x, 0f, 1f); + Assert.InRange(y, 0f, 1f); + } + } + + // The shipped file, parsed by the shipped loader. A calibration that fails + // to parse means the minimap silently has no radar for that map. + [Fact] + public void TheShippedCalibrationParses() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + string? path = null; + + while (directory != null && path == null) + { + string candidate = Path.Combine( + directory.FullName, "apps", "utility-sw", "hud", "radars", "metadata.json" + ); + + if (File.Exists(candidate)) + { + path = candidate; + } + + directory = directory.Parent; + } + + Assert.NotNull(path); + + Dictionary loaded = RadarMaps.Load(path!); + + foreach (string map in RadarMaps.All) + { + Assert.True(loaded.ContainsKey(map), $"{map}: did not parse out of metadata.json"); + Assert.True(loaded[map].Resolution > 0f, $"{map}: resolution is zero"); + } + + Assert.Equal(2, loaded["de_nuke"].Splits.Count + loaded["de_vertigo"].Splits.Count); + } + + [Fact] + public void CellsLandInsideTheGrid() + { + RadarCalibration mirage = Mirage(); + + for (float world = -4000f; world <= 4000f; world += 250f) + { + (int column, int row) = RadarProjection.Cell( + new Vec3(world, world, 0f), + mirage, + 64, + 64 + ); + + Assert.InRange(column, 0, 63); + Assert.InRange(row, 0, 63); + } + } +} diff --git a/codepier.yaml b/codepier.yaml index ddd9144e..0edfefbc 100644 --- a/codepier.yaml +++ b/codepier.yaml @@ -14,6 +14,8 @@ ignore: - "**/bin/" - "**/obj/" - "**/build/" + - "**/.compiler/" + - "**/.tools/" - "**/__pycache__/" - "Folder.DotSettings.user" - "*.generated.sln" diff --git a/k8s/dev-swiftly-game-server.yaml b/k8s/dev-swiftly-game-server.yaml index 644c9554..b5da69ea 100644 --- a/k8s/dev-swiftly-game-server.yaml +++ b/k8s/dev-swiftly-game-server.yaml @@ -52,7 +52,9 @@ spec: - name: TV_PORT value: '27021' - name: EXTRA_GAME_PARAMS - value: '-maxplayers 13 +map de_overpass' + # -insecure turns VAC off. Dev only: clients have to launch with + # -insecure too, and a VAC-secured client will refuse to connect. + value: '-maxplayers 13 +map de_overpass -insecure' - name: ALLOW_BOTS value: 'true' - name: STEAM_RELAY diff --git a/scripts/sync-radars.sh b/scripts/sync-radars.sh new file mode 100755 index 00000000..ee54fce6 --- /dev/null +++ b/scripts/sync-radars.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Re-mirrors the radar CALIBRATION from the web panel. +# +# Not the images. The HUD draws on CS2's own overheadmaps +# (s2r://panorama/images/overheadmaps/_radar.psd) because a texture +# compiled by PanoramaCompiler's experimental image path never loaded in game. +# The web's radars are SimpleRadar, a drop-in replacement for Valve's, so the +# same calibration projects correctly onto either -- which is the only reason +# dropping the images cost nothing. +# +# The calibration itself still has to match the panel exactly: a lineup that +# lands on Window on the site and on Palace in game is worse than no map at all. +set -euo pipefail + +WEB="${WEB_REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../web" && pwd)}" +HUD="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/apps/utility-sw/hud" +SRC="${WEB}/public/radars/metadata.json" + +[ -f "$SRC" ] || { echo "no radar calibration at $SRC (set WEB_REPO)" >&2; exit 1; } + +mkdir -p "${HUD}/radars" + +python3 - "$SRC" "${HUD}/radars/metadata.json" <<'PY' +import json, sys +src, dst = sys.argv[1], sys.argv[2] +d = json.load(open(src)); d.pop("_comment", None) +out = {"_comment": "Mirrored from web/public/radars/metadata.json by scripts/sync-radars.sh. Do not edit here.", **d} +open(dst, "w").write(json.dumps(out, indent=2) + "\n") +print(f"calibration: {len(d)} maps") +PY + +# The images are deliberately absent; a stale copy would be compiled into the +# addon and silently add ~2 MB of textures nothing references. +if [ -d "${HUD}/panorama/images" ]; then + echo "warning: ${HUD}/panorama/images exists but nothing references it -- delete it" >&2 +fi diff --git a/shared/dotnet/FiveStack.Entities/Practice/MapCalloutsPayload.cs b/shared/dotnet/FiveStack.Entities/Practice/MapCalloutsPayload.cs new file mode 100644 index 00000000..00e881d7 --- /dev/null +++ b/shared/dotnet/FiveStack.Entities/Practice/MapCalloutsPayload.cs @@ -0,0 +1,30 @@ +namespace FiveStack.Entities.Practice; + +// What the map itself calls its areas, as the engine has them resolved. The +// panel draws these on the radar and names utility throws from them. +// +// The published extract (web/scripts/extract-map-callouts.mjs) is the normal +// source and always wins; this exists for the maps it cannot cover -- workshop +// and community maps, where a running server is the only thing that has ever +// opened the file. +public class MapCalloutBox +{ + // Raw CS2 source units, world space, already offset by the entity's origin. + public float[] min { get; set; } = new float[3]; + public float[] max { get; set; } = new float[3]; +} + +public class MapCalloutPayload +{ + public string name { get; set; } = string.Empty; + + // A place is legitimately several disjoint volumes, so the boxes travel + // together under one name rather than as separate callouts. + public List boxes { get; set; } = new List(); +} + +public class MapCalloutsPayload +{ + public string map { get; set; } = string.Empty; + public List callouts { get; set; } = new List(); +} diff --git a/shared/dotnet/FiveStack.Utilities/CalloutLookup.cs b/shared/dotnet/FiveStack.Utilities/CalloutLookup.cs new file mode 100644 index 00000000..2b2bc5a0 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/CalloutLookup.cs @@ -0,0 +1,248 @@ +using System.Text; +using System.Text.RegularExpressions; +using FiveStack.Entities.Practice; + +namespace FiveStack.Utilities; + +// What the MAP calls a point. The same answer the panel gives, deliberately: a +// lineup the HUD calls "Window" and the website calls "Connector" is worse than +// neither of them naming it, so the resolution order here mirrors +// web/utilities/mapCallouts.ts and api/src/utility/utility-callouts.service.ts +// exactly and is tested against the same cases. +public static partial class CalloutLookup +{ + // How far outside every place volume a point may sit and still be named. + // The volumes do not tile a map, and a grenade rests on top of geometry as + // often as inside a place. + public const float SnapUnits = 256f; + + // Valve names that read badly once split, keyed by the name with its spaces + // and case removed so a raw token and an already-spaced one both land here. + private static readonly Dictionary Aliases = new( + StringComparer.OrdinalIgnoreCase + ) + { + ["bombsitea"] = "A Site", + ["bombsiteb"] = "B Site", + ["bombsitec"] = "C Site", + ["tspawn"] = "T Spawn", + ["ctspawn"] = "CT Spawn", + ["terroristspawn"] = "T Spawn", + ["counterterroristspawn"] = "CT Spawn", + }; + + /// + /// The name of the place a world point is in, or null when the map has + /// nothing to say about it. + /// + /// XY containment is decided before Z because places stack: a smoke on a + /// roof, or in the air over a site, still belongs to the place beneath it. + /// Z only breaks ties, which is what keeps Nuke and Vertigo from answering + /// with the lower level's callout for a point on the upper one. Where + /// volumes nest ("A Site" containing "Goose") the tightest one wins -- the + /// more specific name is the one a player would say. See Volume for why + /// that is measured in three dimensions. + /// + public static string? Resolve( + Vec3 point, + IEnumerable? callouts, + float snap = SnapUnits + ) + { + if (callouts == null) + { + return null; + } + + string? insideName = null; + float insideArea = float.MaxValue; + + string? aboveName = null; + float aboveGap = float.MaxValue; + float aboveArea = float.MaxValue; + + string? nearestName = null; + float nearestDistance = float.MaxValue; + + foreach (MapCalloutPayload callout in callouts) + { + if (callout?.boxes == null || string.IsNullOrEmpty(callout.name)) + { + continue; + } + + foreach (MapCalloutBox box in callout.boxes) + { + if (box?.min == null || box.max == null || box.min.Length < 3 || box.max.Length < 3) + { + continue; + } + + bool inXY = + point.x >= box.min[0] + && point.x <= box.max[0] + && point.y >= box.min[1] + && point.y <= box.max[1]; + + float area = Volume(box); + + if (inXY) + { + if (point.z >= box.min[2] && point.z <= box.max[2]) + { + if (area < insideArea) + { + insideArea = area; + insideName = callout.name; + } + } + else + { + float gap = Gap(point.z, box.min[2], box.max[2]); + + if (gap < aboveGap || (gap == aboveGap && area < aboveArea)) + { + aboveGap = gap; + aboveArea = area; + aboveName = callout.name; + } + } + + continue; + } + + float dx = Gap(point.x, box.min[0], box.max[0]); + float dy = Gap(point.y, box.min[1], box.max[1]); + float dz = Gap(point.z, box.min[2], box.max[2]); + float distance = MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz)); + + if (distance < nearestDistance) + { + nearestDistance = distance; + nearestName = callout.name; + } + } + } + + if (insideName != null) + { + return insideName; + } + + if (aboveName != null) + { + return aboveName; + } + + return nearestDistance <= snap ? nearestName : null; + } + + /// + /// Resolves and humanises in one step, which is what anything putting a + /// place name on screen actually wants. + /// + public static string ResolveLabel( + Vec3 point, + IEnumerable? callouts, + float snap = SnapUnits + ) + { + return Humanize(Resolve(point, callouts, snap)); + } + + /// + /// "BombsiteA" -> "A Site", "LongDoors" -> "Long Doors". Empty in, empty out. + /// + public static string Humanize(string? raw) + { + string value = (raw ?? string.Empty).Trim(); + + if (value.Length == 0) + { + return string.Empty; + } + + string key = value.Replace(" ", string.Empty).Replace("_", string.Empty); + + if (Aliases.TryGetValue(key, out string? alias)) + { + return alias; + } + + // Valve glues a lowercase joining word between two capitalised ones -- + // TopofMid, BackofA. The camelCase walk below would read that as one + // word and give "Topof Mid", so it is split first. + value = OfJoin().Replace(value, "$1 of $2"); + + var spaced = new StringBuilder(value.Length + 8); + + for (int index = 0; index < value.Length; index++) + { + char current = value[index]; + + if (current == '_' || current == '-') + { + spaced.Append(' '); + + continue; + } + + if (index > 0 && char.IsUpper(current)) + { + char previous = value[index - 1]; + bool afterLower = char.IsLower(previous) || char.IsDigit(previous); + // An acronym only breaks where the next letter starts a word, + // so CTSpawn splits once rather than into C T Spawn. + bool endsAcronym = + char.IsUpper(previous) + && index + 1 < value.Length + && char.IsLower(value[index + 1]); + + if (afterLower || endsAcronym) + { + spaced.Append(' '); + } + } + + spaced.Append(current); + } + + return string.Join( + ' ', + spaced.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries) + ); + } + + [GeneratedRegex("([a-z])of([A-Z])")] + private static partial Regex OfJoin(); + + /// + /// The tightest enclosing volume wins where places overlap. MEASURED, not + /// assumed: scored against `player_kills.attacker_location` (the engine's + /// own answer) over 1,920 labelled kills, smallest-volume beat + /// smallest-footprint 92.5% to 89.8%. Footprint alone loses the stacked + /// pairs -- it called Mirage's Catwalk "Underpass" 41 times, because + /// Underpass sits under it and is the narrower of the two seen from above. + /// + private static float Volume(MapCalloutBox box) + { + return (box.max[0] - box.min[0]) + * (box.max[1] - box.min[1]) + * MathF.Max(box.max[2] - box.min[2], 1f); + } + + private static float Gap(float value, float min, float max) + { + if (value < min) + { + return min - value; + } + + if (value > max) + { + return value - max; + } + + return 0f; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/HudAimGrid.cs b/shared/dotnet/FiveStack.Utilities/HudAimGrid.cs new file mode 100644 index 00000000..170ca8d8 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/HudAimGrid.cs @@ -0,0 +1,136 @@ +namespace FiveStack.Utilities; + +// Turns a crosshair's yaw/pitch error into the one-of-N cell the aim dot sits +// in. Panorama cannot be handed a number to position by, so the travel a +// dialog variable would have carried is a class group instead. +// +// Everything is measured in multiples of the lineup's OWN tolerance, not in +// degrees. A fixed degree span made the drawn tolerance box a decoration: it +// was the same size for a 0.1 degree lineup and a 2 degree one, so "is the dot +// in the box" answered nothing. Normalising by tolerance means the box is the +// tolerance, on every lineup, and landing the dot inside it is the whole +// instruction. +public static class HudAimGrid +{ + // Where one tolerance lands, as a fraction of the distance from centre to + // edge. The stylesheet draws the box at exactly this half-extent. + public const float ToleranceExtent = 0.5f; + + // Past this many tolerances the dot is pinned to the edge; it only has to + // say "miles off, that way". + public const float PinAt = 16f; + + public static int Column(float eyeYaw, float targetYaw, float tolerance, int columns) + { + return Cell(Delta(targetYaw, eyeYaw), tolerance, columns); + } + + public static int Row(float eyePitch, float targetPitch, float tolerance, int rows) + { + return Cell(Delta(eyePitch, targetPitch), tolerance, rows); + } + + // Shortest way round, so 359 and 1 are two degrees apart rather than 358. + public static float Delta(float from, float to) + { + float delta = (from - to) % 360f; + + if (delta > 180f) + { + delta -= 360f; + } + + if (delta < -180f) + { + delta += 360f; + } + + return delta; + } + + // -1..1, where +/-ToleranceExtent is exactly one tolerance out. + public static float Offset(float degrees, float tolerance) + { + if (tolerance <= 0f) + { + tolerance = PracticeLineupUtility.DefaultAimTolerance; + } + + float ratio = degrees / tolerance; + float magnitude = Math.Abs(ratio); + float sign = Math.Sign(ratio); + + // Linear inside the tolerance, because that is the range the player is + // actually working in and it wants every pixel it can get. Outside it, + // 1/sqrt falls away to the edge without ever quite reaching it. + if (magnitude <= 1f) + { + return sign * magnitude * ToleranceExtent; + } + + // Normalised so PinAt tolerances lands exactly on the frame edge rather + // than somewhere short of it. + float curve = 1f - 1f / MathF.Sqrt(Math.Min(magnitude, PinAt)); + float full = 1f - 1f / MathF.Sqrt(PinAt); + + return sign * (ToleranceExtent + (1f - ToleranceExtent) * (curve / full)); + } + + // The box and dot give fine-grained feedback once you know what they are; + // this says the same thing in words so you never have to work it out. Axes + // are only named when they are the part actually out, so a throw that only + // needs a nudge left does not also nag about pitch. + public static string Direction( + float eyeYaw, + float eyePitch, + float targetYaw, + float targetPitch, + float tolerance + ) + { + if (tolerance <= 0f) + { + tolerance = PracticeLineupUtility.DefaultAimTolerance; + } + + // Both axes are measured EYE minus TARGET, which is the direction the + // player has to move to close the gap. Source yaw increases + // anticlockwise -- mouse right lowers it -- so a target below the + // player's yaw is to their right, and the operand order is what carries + // that. Reversed on yaw alone, this told the player to look away from + // the lineup while the pitch half was still right. + float yaw = Delta(eyeYaw, targetYaw); + float pitch = Delta(eyePitch, targetPitch); + + var parts = new List(); + + if (Math.Abs(yaw) > tolerance) + { + parts.Add(yaw > 0f ? "RIGHT" : "LEFT"); + } + + if (Math.Abs(pitch) > tolerance) + { + parts.Add(pitch > 0f ? "UP" : "DOWN"); + } + + if (parts.Count == 0) + { + return "LINED UP - THROW IT"; + } + + return "LOOK " + string.Join(" AND ", parts); + } + + private static int Cell(float degrees, float tolerance, int count) + { + if (count < 2) + { + return 0; + } + + float offset = Math.Clamp(Offset(degrees, tolerance), -1f, 1f); + + return (int)MathF.Round((offset + 1f) / 2f * (count - 1)); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/HudLayoutContract.cs b/shared/dotnet/FiveStack.Utilities/HudLayoutContract.cs new file mode 100644 index 00000000..b6c9090e --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/HudLayoutContract.cs @@ -0,0 +1,221 @@ +using System.Text.RegularExpressions; + +namespace FiveStack.Utilities; + +// Reads the shipped Panorama sources and reports where they and HudSlots have +// drifted apart. Regex rather than a parser: this checks a naming contract, it +// does not need to understand the markup. +public static class HudLayoutContract +{ + private static readonly Regex Variable = new Regex( + @"\{s:([A-Za-z0-9_]+)\}", + RegexOptions.Compiled + ); + + private static readonly Regex Button = new Regex( + "]*\\bid=\"([A-Za-z0-9_]+)\"", + RegexOptions.Compiled + ); + + private static readonly Regex ElementId = new Regex( + "\\bid=\"([A-Za-z0-9_]+)\"", + RegexOptions.Compiled + ); + + public static SortedSet Variables(string layoutXml) + { + return Collect(Variable, layoutXml); + } + + public static SortedSet Buttons(string layoutXml) + { + return Collect(Button, layoutXml); + } + + public static SortedSet ElementIds(string layoutXml) + { + return Collect(ElementId, layoutXml); + } + + // The `#element.stateN` rules a stylesheet declares, e.g. every x0..x12 the + // aim dot can be moved to. + // byClass for grids shared by many elements (.nh-mx.x0), byId for grids + // that belong to one (#aimx.x0). + public static SortedSet ClassStates( + string css, + string carrier, + string prefix, + bool byClass = false + ) + { + var found = new SortedSet(StringComparer.Ordinal); + var rule = new Regex( + $"{(byClass ? "\\." : "#")}{Regex.Escape(carrier)}\\.({Regex.Escape(prefix)}[A-Za-z0-9_]*)\\b" + ); + + foreach (Match match in rule.Matches(css)) + { + found.Add(match.Groups[1].Value); + } + + return found; + } + + public static List Verify(HudLayoutSlots declared, string layoutXml) + { + var problems = new List(); + SortedSet inLayout = Variables(layoutXml); + var inCode = new SortedSet( + declared.Variables.Select(variable => variable.Name), + StringComparer.Ordinal + ); + + foreach (string missing in inLayout.Except(inCode)) + { + problems.Add($"{declared.Layout}: layout declares {{s:{missing}}}, HudSlots does not"); + } + + foreach (string missing in inCode.Except(inLayout)) + { + problems.Add($"{declared.Layout}: HudSlots names '{missing}', layout has no {{s:{missing}}}"); + } + + SortedSet buttonsInLayout = Buttons(layoutXml); + var buttonsInCode = new SortedSet(declared.Buttons, StringComparer.Ordinal); + + foreach (string missing in buttonsInLayout.Except(buttonsInCode)) + { + problems.Add($"{declared.Layout}: layout has Button id='{missing}', HudSlots does not"); + } + + foreach (string missing in buttonsInCode.Except(buttonsInLayout)) + { + problems.Add($"{declared.Layout}: HudSlots expects button '{missing}', layout has none"); + } + + if (!ElementIds(layoutXml).Contains(declared.RootId)) + { + problems.Add($"{declared.Layout}: no element carries the root id '{declared.RootId}'"); + } + + // Each variable is written at its carrier, so that element must exist + // and must be the one holding the {s:...} placeholder. + foreach (HudVariable variable in declared.Variables) + { + IReadOnlyList onElement = VariablesOn(layoutXml, variable.ElementId); + + if (onElement.Count == 0) + { + problems.Add( + $"{declared.Layout}: no element '{variable.ElementId}' to carry {{s:{variable.Name}}}" + ); + } + else if (!onElement.Contains(variable.Name)) + { + problems.Add( + $"{declared.Layout}: element '{variable.ElementId}' renders " + + $"{{s:{string.Join(",", onElement)}}}, not {{s:{variable.Name}}}" + ); + } + } + + return problems; + } + + // Every id the server toggles a class on has to exist, or the toggle is a + // silent no-op. + public static List VerifyElements( + HudLayoutSlots declared, + string layoutXml, + IEnumerable elementIds + ) + { + SortedSet present = ElementIds(layoutXml); + + return elementIds + .Where(id => !present.Contains(id)) + .Select(id => $"{declared.Layout}: no element with id '{id}' to toggle classes on") + .ToList(); + } + + // The {s:...} placeholders rendered by one element. + public static IReadOnlyList VariablesOn(string layoutXml, string elementId) + { + var element = new Regex($"<[A-Za-z]+\\b(?=[^>]*\\bid=\"{Regex.Escape(elementId)}\")[^>]*>"); + Match match = element.Match(layoutXml); + + return match.Success + ? Variable.Matches(match.Value).Select(hit => hit.Groups[1].Value).ToList() + : new List(); + } + + // The classes an element carries in the markup, which is what a toggled + // class has to be written against in the stylesheet. + public static IReadOnlyList ClassesOf(string layoutXml, string elementId) + { + var element = new Regex( + $"<[A-Za-z]+\\b(?=[^>]*\\bid=\"{Regex.Escape(elementId)}\")[^>]*\\bclass=\"([^\"]*)\"" + ); + + Match match = element.Match(layoutXml); + + return match.Success + ? match.Groups[1].Value.Split(' ', StringSplitOptions.RemoveEmptyEntries) + : new string[0]; + } + + // A class the server toggles that no rule selects is a write that changes + // nothing on screen and reports no error. + public static List VerifyToggles( + HudLayoutSlots declared, + string layoutXml, + string css, + IEnumerable<(string ElementId, string Class)> toggles + ) + { + var problems = new List(); + + foreach ((string elementId, string name) in toggles) + { + IReadOnlyList classes = ClassesOf(layoutXml, elementId); + + if (classes.Count == 0) + { + problems.Add($"{declared.Layout}: #{elementId} carries no class to qualify '{name}'"); + + continue; + } + + // Either scoping counts: a rule on the element's own id + // (#radar.de_mirage) selects it just as surely as one on a class it + // carries (.nh-row.selected). + bool styled = + new Regex($"#{Regex.Escape(elementId)}\\.{Regex.Escape(name)}\\b").IsMatch(css) + || classes.Any(carried => + new Regex($"\\.{Regex.Escape(carried)}\\.{Regex.Escape(name)}\\b").IsMatch(css) + ); + + if (!styled) + { + problems.Add( + $"{declared.Layout}: nothing styles '{name}' on #{elementId} " + + $"(expected .{classes[0]}.{name} in the stylesheet)" + ); + } + } + + return problems; + } + + private static SortedSet Collect(Regex pattern, string source) + { + var found = new SortedSet(StringComparer.Ordinal); + + foreach (Match match in pattern.Matches(source)) + { + found.Add(match.Groups[1].Value); + } + + return found; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/HudSlots.cs b/shared/dotnet/FiveStack.Utilities/HudSlots.cs new file mode 100644 index 00000000..914a18e1 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/HudSlots.cs @@ -0,0 +1,244 @@ +namespace FiveStack.Utilities; + +// The contract between a compiled Panorama layout and the C# that drives it. +// Naming a variable the layout does not declare renders nothing and throws +// nothing, so every name a template is allowed to write lives here and is +// asserted against the shipped .xml by HudLayoutContractTests. +// A dialog variable is addressed at the element that renders it, not at the +// layout root -- root propagation is a Panorama behaviour we would be assuming, +// and addressing the carrier is correct either way. +public record HudVariable(string Name, string ElementId); + +public class HudLayoutSlots +{ + public HudLayoutSlots( + string layout, + string rootId, + IReadOnlyList variables, + IReadOnlyList buttons + ) + { + Layout = layout; + RootId = rootId; + Variables = variables; + Buttons = buttons; + } + + public string Layout { get; } + public string RootId { get; } + public IReadOnlyList Variables { get; } + public IReadOnlyList Buttons { get; } + + public string ElementFor(string variable) + { + foreach (HudVariable candidate in Variables) + { + if (candidate.Name == variable) + { + return candidate.ElementId; + } + } + + throw new ArgumentException($"{Layout} declares no variable '{variable}'"); + } +} + +public static class HudSlots +{ + public const string NadeHud = "nade_hud"; + public const string NadeList = "nade_list"; + public const string NadeMap = "nade_map"; + public const string NadeRun = "nade_run"; + public const string NadeEdit = "nade_edit"; + + // Class toggled on a layout's root to show it for one player. The layouts + // are collapsed by default so a spawned entity is invisible until somebody + // is opted in. + public const string Shown = "shown"; + + public const int MeterSteps = 11; + + public const int ListRows = 16; + // Filter chips, in panel. Each is a button id and a class-toggled element. + public static readonly IReadOnlyList ListSides = new[] { "all", "t", "ct" }; + // Mirrors the panel's scope filter. "favorites" is deliberately absent: the + // library payload carries no favourite flag, and a chip that silently + // matches nothing is worse than no chip. + public static readonly IReadOnlyList ListScopes = + new[] { "all", "mine", "team", "public" }; + + public static readonly IReadOnlyList ListTypes = + new[] { "all", "smoke", "flash", "molly", "he" }; + + // Fixed DOM: this many markers exist in nade_map.xml and no more. The busiest + // targets win the slots. + public const int MapMarkers = 40; + + // Shared .nh-mx/.nh-my class groups, so the grid costs 2 x MapGrid rules + // rather than that many per marker. + public const int MapGrid = 64; + + // Steps visible in the execute timeline; the panel scrolls past this. + public const int RunSteps = 12; + + public static readonly HudLayoutSlots Hud = new HudLayoutSlots( + NadeHud, + "NadeHud", + new[] + { + new HudVariable("kicker", "kicker"), + new HudVariable("pos", "pos"), + new HudVariable("title", "title"), + new HudVariable("tech", "tech"), + new HudVariable("aimerr", "aimerr"), + new HudVariable("steer", "steer"), + new HudVariable("throwcolor", "throwcolor"), + new HudVariable("drill", "drill"), + }, + new string[0] + ); + + public static readonly HudLayoutSlots List = new HudLayoutSlots( + NadeList, + "NadeList", + Rows().ToArray(), + Buttons().ToArray() + ); + + public static readonly HudLayoutSlots Map = new HudLayoutSlots( + NadeMap, + "NadeMap", + MapVariables().ToArray(), + MapButtons().ToArray() + ); + + public static readonly HudLayoutSlots Run = new HudLayoutSlots( + NadeRun, + "NadeRun", + RunVariables().ToArray(), + new string[0] + ); + + // Only what utility-lineups.service.ts already knows how to UPDATE. Geometry + // is deliberately absent. + public static readonly IReadOnlyList Visibilities = + new[] { "private", "team", "public" }; + + public static readonly HudLayoutSlots Edit = new HudLayoutSlots( + NadeEdit, + "NadeEdit", + new[] + { + new HudVariable("tag", "tag"), + new HudVariable("title", "title"), + new HudVariable("fname", "fname"), + new HudVariable("fdesc", "fdesc"), + new HudVariable("hint", "hint"), + }, + EditButtons().ToArray() + ); + + public static readonly IReadOnlyList All = + new[] { Hud, List, Map, Run, Edit }; + + private static IEnumerable EditButtons() + { + yield return "name"; + yield return "desc"; + + foreach (string visibility in Visibilities) + { + yield return $"vis_{visibility}"; + } + + yield return "save"; + yield return "revert"; + yield return "close"; + } + + private static IEnumerable RunVariables() + { + yield return new HudVariable("kicker", "kicker"); + yield return new HudVariable("title", "title"); + yield return new HudVariable("clock", "clock"); + + for (int step = 1; step <= RunSteps; step++) + { + yield return new HudVariable($"st{step}", $"st{step}"); + yield return new HudVariable($"sn{step}", $"sn{step}"); + yield return new HudVariable($"sy{step}", $"sy{step}"); + } + } + + private static IEnumerable MapVariables() + { + yield return new HudVariable("title", "title"); + yield return new HudVariable("tag", "tag"); + yield return new HudVariable("focus", "focus"); + yield return new HudVariable("dname", "dname"); + yield return new HudVariable("dmeta", "dmeta"); + yield return new HudVariable("dload", "dload"); + yield return new HudVariable("dlist", "dlist"); + + for (int marker = 1; marker <= MapMarkers; marker++) + { + yield return new HudVariable($"c{marker}", $"c{marker}"); + yield return new HudVariable($"n{marker}", $"n{marker}"); + } + } + + private static IEnumerable MapButtons() + { + for (int marker = 1; marker <= MapMarkers; marker++) + { + yield return $"m{marker}"; + } + + yield return "load"; + yield return "list"; + yield return "close"; + } + + private static IEnumerable Rows() + { + yield return new HudVariable("title", "title"); + yield return new HudVariable("tag", "tag"); + yield return new HudVariable("page", "page"); + + // The buttons already own row1/tab1, so the labels inside them carry + // their own ids. + for (int row = 1; row <= ListRows; row++) + { + yield return new HudVariable($"row{row}", $"row{row}l"); + yield return new HudVariable($"row{row}v", $"row{row}v"); + } + + } + + private static IEnumerable Buttons() + { + for (int row = 1; row <= ListRows; row++) + { + yield return $"row{row}"; + } + + foreach (string side in ListSides) + { + yield return $"side_{side}"; + } + + foreach (string scope in ListScopes) + { + yield return $"scope_{scope}"; + } + + foreach (string type in ListTypes) + { + yield return $"type_{type}"; + } + + yield return "prev"; + yield return "next"; + yield return "close"; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/HudSteady.cs b/shared/dotnet/FiveStack.Utilities/HudSteady.cs new file mode 100644 index 00000000..12c741d6 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/HudSteady.cs @@ -0,0 +1,54 @@ +namespace FiveStack.Utilities; + +// Holds a boolean still until it has meant it for a while. +// +// The guidance panel moves house when the player steps onto the lineup's spot, +// and "on the spot" is a distance test with a hard edge. Standing near that edge +// -- which is exactly where somebody lining a throw up stands -- flips it many +// times a second, and the panel teleports between the top of the screen and the +// bottom on every flip. The reading is correct each time; it is the acting on it +// immediately that is wrong. +public readonly struct HudSteady +{ + private HudSteady(bool committed, bool pending, int since) + { + Committed = committed; + Pending = pending; + Since = since; + } + + /// The value callers should act on. + public bool Committed { get; } + + private bool Pending { get; } + + private int Since { get; } + + public static HudSteady Start(bool value) + { + return new HudSteady(value, value, 0); + } + + /// + /// Feeds a fresh reading. The committed value only follows once the new + /// reading has held for without going back. + /// + public HudSteady Read(bool reading, int now, int holdTicks) + { + if (reading == Committed) + { + // Back to where it was: whatever it was about to become is dropped, + // so a flicker never accumulates towards a move. + return new HudSteady(Committed, Committed, now); + } + + if (reading != Pending) + { + return new HudSteady(Committed, reading, now); + } + + return now - Since >= holdTicks + ? new HudSteady(reading, reading, now) + : new HudSteady(Committed, reading, Since); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/LineupNaming.cs b/shared/dotnet/FiveStack.Utilities/LineupNaming.cs new file mode 100644 index 00000000..297544d0 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/LineupNaming.cs @@ -0,0 +1,71 @@ +using FiveStack.Entities.Practice; +using FiveStack.Enums; + +namespace FiveStack.Utilities; + +// What the map itself would call a throw: "Window Smoke from T Spawn". +// +// A mirror of UtilityCalloutsService.autoName in the api, deliberately, for the +// same reason CalloutLookup is duplicated rather than fetched -- a name the HUD +// shows the moment you save and the name the website shows for the same throw +// have to be the same string. Any change here belongs in both. +public static class LineupNaming +{ + // The api's TYPE_LABELS. Only HighExplosive differs from its enum name. + public static string TypeLabel(string? utilityType) + { + if ( + string.Equals( + utilityType, + nameof(eUtilityType.HighExplosive), + StringComparison.OrdinalIgnoreCase + ) + ) + { + return "HE"; + } + + return string.IsNullOrWhiteSpace(utilityType) ? "" : utilityType!; + } + + /// + /// Empty when the map has no callouts near either end, which is what keeps + /// the caller's own fallback in play rather than replacing it with a name + /// that says nothing. + /// + public static string Auto( + string? utilityType, + Vec3 origin, + Vec3 landing, + IReadOnlyList? callouts + ) + { + if (callouts == null || callouts.Count == 0) + { + return ""; + } + + string from = CalloutLookup.ResolveLabel(origin, callouts); + string to = CalloutLookup.ResolveLabel(landing, callouts); + string type = TypeLabel(utilityType); + + if (to.Length > 0 && from.Length > 0) + { + // Thrown from the place it lands in: "from Window" onto Window says + // nothing, so it collapses to one name. + return to == from ? $"{to} {type}" : $"{to} {type} from {from}"; + } + + if (to.Length > 0) + { + return $"{to} {type}"; + } + + if (from.Length > 0) + { + return $"{type} from {from}"; + } + + return ""; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs b/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs index d16ae7fa..325fed8a 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeDrillRun.cs @@ -31,12 +31,27 @@ private class Pending // 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) + /// + /// Keeps repping rather than completing. A drill somebody started by + /// standing on a spot is "work this until I say stop", and a run that ends + /// itself after three throws cannot be toggled off -- it has already gone. + /// Reps still bound the crosshair fade; they just stop ending the run. + /// + public PracticeDrillRun( + IReadOnlyList queue, + int reps = 1, + bool endless = false + ) { _queue = queue.ToList(); _reps = Math.Max(1, reps); + _endless = endless; } + private readonly bool _endless; + + public bool Endless => _endless; + private readonly int _reps; private int _rep; @@ -54,6 +69,31 @@ public PracticeDrillRun(IReadOnlyList queue, int reps = 1) public int Reps => _reps; + // How many steps of help have been taken away. Earned by landing the + // throw, given back by missing it -- never a function of the rep number, + // which would take the crosshair away from somebody who has missed every + // attempt and is exactly who still needs it. + private int _faded; + + private int FadeSteps => Math.Max(1, _reps - 1); + + /// + /// How visible the aim crosshair should still be, 1 for all of it down to + /// 0 for none. + /// + /// A drill is reps of one throw, and a crosshair that is as loud on the + /// last as on the first trains a player to read the crosshair rather than + /// the map -- so landing one fades it, and the final rep is thrown off what + /// they have actually learned. Missing gives a step back rather than + /// resetting: one bad throw at the end of a good run is a bad throw, not + /// evidence they never knew it, and a full reset there would make the drill + /// feel like it was punishing them. + /// + /// A single-rep run keeps full help -- one throw is practice, not a test. + /// + public float Assist => + _reps <= 1 ? 1f : Math.Clamp(1f - (_faded / (float)FadeSteps), 0f, 1f); + public int Hits { get; private set; } public int Misses { get; private set; } public int Unscored { get; private set; } @@ -93,11 +133,24 @@ public PracticeDrillRun(IReadOnlyList queue, int reps = 1) if (_index >= _queue.Count) { + // Round the queue again rather than ending. The assist is NOT reset + // here: they have already learned this throw once, and handing the + // crosshair back every few reps would undo the point of fading it. + if (_endless) + { + _index = 0; + Current = _queue[0]; + + return Current; + } + Current = null; Ending = eDrillEnd.Completed; return null; } + _faded = 0; + Current = _queue[_index]; return Current; @@ -176,12 +229,14 @@ public bool Score(string? lineupId, UtilityPracticeResult? result) Hits++; Streak++; BestStreak = Math.Max(BestStreak, Streak); + _faded = Math.Min(_faded + 1, FadeSteps); return true; } Misses++; Streak = 0; + _faded = Math.Max(_faded - 1, 0); if (Current != null) { diff --git a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs index 4952b877..24e5b47b 100644 --- a/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/PracticeLineupUtility.cs @@ -385,6 +385,54 @@ public static string NormalizeUtilityType(string utilityType) ); } + /// + /// Whether an id is one the panel owns a row for. + /// + /// A scratch throw -- a meta spot or a draft still being written, sent over + /// for a test -- is deliberately given a non-uuid id so nothing downstream + /// can mistake it for something it can load, edit or delete. That makes the + /// shape of the id the only thing that has to be checked, and it is the + /// same question on both sides of the wire: the panel rejects a + /// practice-result whose lineup id is not a uuid. + /// + public static bool IsPanelId(string? id) + { + return !string.IsNullOrWhiteSpace(id) && Guid.TryParse(id, out _); + } + + /// + /// The radius a throw is judged against when the panel has not said. + /// + /// Mirrors the API's own default. Only ever reached for a throw the panel + /// never sees -- everything with a row behind it is judged by the radius + /// the panel hands back, because a number invented here would tell a player + /// they missed a throw the panel counted. + /// + public const float FallbackSuccessRadius = 96f; + + /// + /// A distance in source units, said the way a person would say it. + /// + /// "2171u, needs 96u" is two numbers in a unit nobody thinks in -- it does + /// not say whether the throw was close or nowhere near, which is the only + /// thing the line exists to answer. A source unit is three quarters of an + /// inch, so the same throw is 41m out needing 1.8m, and 41-versus-2 is a + /// verdict rather than a measurement. + /// + public const float MetresPerUnit = 0.01905f; + + public static string Metres(float units) + { + float metres = Math.Abs(units) * MetresPerUnit; + + // Under ten metres the decimal is the whole point: a smoke half a metre + // off is a good throw and one three metres off is not, and both round + // to the same whole number. Past that the decimal is noise. + return metres < 10f + ? $"{metres:0.0}m" + : $"{metres:0}m"; + } + public static List Filter( IEnumerable lineups, string query, diff --git a/shared/dotnet/FiveStack.Utilities/PracticeStepColors.cs b/shared/dotnet/FiveStack.Utilities/PracticeStepColors.cs new file mode 100644 index 00000000..f22b30c3 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PracticeStepColors.cs @@ -0,0 +1,68 @@ +namespace FiveStack.Utilities; + +/// +/// Distinct colours for the throws of one execute, so a player can tell which +/// of them is theirs and which of the smokes in the air is the one they threw. +/// +/// An execute puts four or five grenades up at once from four or five spots. +/// Drawn in the utility's own colour they are all the same white, so the only +/// way to know which one landed where is to have watched it the whole way -- +/// which is exactly what a player rehearsing a lineup cannot do. A colour per +/// step turns "the smoke" into "the cyan one", which is sayable in comms and +/// readable on the ground afterwards. +/// +/// Ordinal, not random: the same execute has to come up the same way twice, or +/// the colour is one more thing to relearn every run. +/// +public static class PracticeStepColors +{ + public readonly struct StepColor + { + public StepColor(string name, byte r, byte g, byte b) + { + Name = name; + R = r; + G = g; + B = b; + } + + /// What to call it in chat. The colour is only useful if it is sayable. + public string Name { get; } + + public byte R { get; } + public byte G { get; } + public byte B { get; } + } + + // Deliberately no green and no red: those two are the aim reticle's ramp, + // where green means inside tolerance and red means far off it. A step + // wearing either would be answering a question it was not asked. + private static readonly StepColor[] Palette = new[] + { + new StepColor("cyan", 0, 220, 255), + new StepColor("yellow", 255, 225, 60), + new StepColor("magenta", 255, 90, 220), + new StepColor("blue", 90, 130, 255), + new StepColor("orange", 255, 150, 40), + new StepColor("purple", 185, 120, 255), + new StepColor("white", 240, 240, 240), + new StepColor("pink", 255, 150, 190), + }; + + public static int Count => Palette.Length; + + /// + /// The colour for a position in the execute. Wraps rather than running out: + /// an execute longer than the palette repeats a colour, which is worse than + /// distinct but far better than an unlit marker. + /// + public static StepColor For(int index) + { + if (index < 0) + { + index = 0; + } + + return Palette[index % Palette.Length]; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/RadarMaps.cs b/shared/dotnet/FiveStack.Utilities/RadarMaps.cs new file mode 100644 index 00000000..5a6c3a64 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/RadarMaps.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using FiveStack.Enums; + +namespace FiveStack.Utilities; + +// The radar calibration the HUD projects with, mirrored from the web panel by +// scripts/sync-radars.sh. Only maps listed here can draw a minimap; everything +// else falls back to the list. +public static class RadarMaps +{ + // Kept in step with the stylesheet's #radar. rules and the images in + // panorama/images/nadehud. HudLayoutContractTests asserts all three agree. + public static readonly IReadOnlyList All = new[] + { + "de_ancient", + "de_anubis", + "de_cache", + "de_dust2", + "de_inferno", + "de_mirage", + "de_nuke", + "de_overpass", + "de_train", + "de_vertigo", + }; + + // Marker colour classes. The engine's spelling for a molotov is Molotov and + // for an HE is HighExplosive; the stylesheet uses short names. + public static readonly IReadOnlyList Types = new[] { "smoke", "flash", "molly", "he" }; + + public static string TypeClass(string? utilityType) + { + if (string.Equals(utilityType, nameof(eUtilityType.Smoke), StringComparison.OrdinalIgnoreCase)) + { + return "smoke"; + } + + if (string.Equals(utilityType, nameof(eUtilityType.Flash), StringComparison.OrdinalIgnoreCase)) + { + return "flash"; + } + + if (string.Equals(utilityType, nameof(eUtilityType.Molotov), StringComparison.OrdinalIgnoreCase)) + { + return "molly"; + } + + return "he"; + } + + public static bool Has(string? map) + { + return All.Contains(RadarProjection.NormalizeMapName(map)); + } + + // metadata.json ships beside the layouts; a missing or malformed file means + // no minimap rather than a broken one. + public static Dictionary Load(string path) + { + var calibrations = new Dictionary(StringComparer.Ordinal); + + if (!File.Exists(path)) + { + return calibrations; + } + + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); + + foreach (JsonProperty entry in document.RootElement.EnumerateObject()) + { + if (entry.Name.StartsWith("_", StringComparison.Ordinal)) + { + continue; + } + + RadarCalibration? parsed = Parse(entry.Value); + + if (parsed != null) + { + calibrations[entry.Name] = parsed; + } + } + + return calibrations; + } + + private static RadarCalibration? Parse(JsonElement element) + { + if ( + !element.TryGetProperty("resolution", out JsonElement resolution) + || !element.TryGetProperty("offset", out JsonElement offset) + || !offset.TryGetProperty("x", out JsonElement offsetX) + || !offset.TryGetProperty("y", out JsonElement offsetY) + ) + { + return null; + } + + var calibration = new RadarCalibration + { + Resolution = resolution.GetSingle(), + OffsetX = offsetX.GetSingle(), + OffsetY = offsetY.GetSingle(), + }; + + if (element.TryGetProperty("splits", out JsonElement splits)) + { + foreach (JsonElement split in splits.EnumerateArray()) + { + if ( + !split.TryGetProperty("bounds", out JsonElement bounds) + || !split.TryGetProperty("offset", out JsonElement splitOffset) + ) + { + continue; + } + + calibration.Splits.Add( + new RadarSplit + { + BoundsTop = bounds.GetProperty("top").GetSingle(), + BoundsBottom = bounds.GetProperty("bottom").GetSingle(), + OffsetX = splitOffset.GetProperty("x").GetSingle(), + OffsetY = splitOffset.GetProperty("y").GetSingle(), + } + ); + } + } + + return calibration; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/RadarProjection.cs b/shared/dotnet/FiveStack.Utilities/RadarProjection.cs new file mode 100644 index 00000000..767af62c --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/RadarProjection.cs @@ -0,0 +1,99 @@ +using FiveStack.Entities.Practice; + +namespace FiveStack.Utilities; + +// A straight port of web/composables/useRadarProjection.ts. The panel and the +// in-game HUD draw the same lineups onto the same radar images, so they have to +// agree to the pixel -- a lineup that lands on Window on the site and on Palace +// in game is worse than no map at all. Any change here belongs in both. +public class RadarSplit +{ + public float BoundsTop { get; set; } + public float BoundsBottom { get; set; } + public float OffsetX { get; set; } + public float OffsetY { get; set; } +} + +public class RadarCalibration +{ + public float Resolution { get; set; } + public float OffsetX { get; set; } + public float OffsetY { get; set; } + public List Splits { get; set; } = new List(); +} + +public static class RadarProjection +{ + // The web renders into a 1024 square regardless of the source image size, + // and the calibration is expressed against that, not against the png. + public const float Canvas = 1024f; + public const float Pixels = 1024f; + + public static string NormalizeMapName(string? name) + { + string trimmed = (name ?? "").Trim().ToLowerInvariant(); + + return trimmed.EndsWith("_night", StringComparison.Ordinal) + ? trimmed[..^"_night".Length] + : trimmed; + } + + // Nuke and Vertigo stack two playable levels on one image; a point inside a + // split's height band shifts by a percentage of the image, which is what + // puts the lower level on its own half. + public static (float X, float Y) Split(float z, IReadOnlyList? splits) + { + if (splits == null) + { + return (0f, 0f); + } + + foreach (RadarSplit split in splits) + { + if (z > split.BoundsBottom && z < split.BoundsTop) + { + return (split.OffsetX, split.OffsetY); + } + } + + return (0f, 0f); + } + + public static (float X, float Y) Project(Vec3 point, RadarCalibration calibration) + { + (float dx, float dy) = Split(point.z, calibration.Splits); + + float gameX = point.x + calibration.OffsetX; + float gameY = point.y + calibration.OffsetY; + + float pixelX = gameX / calibration.Resolution + dx / 100f * Pixels; + float pixelYFromBottom = gameY / calibration.Resolution + dy / 100f * Pixels; + + return (pixelX * (Canvas / Pixels), Canvas - pixelYFromBottom * (Canvas / Pixels)); + } + + // 0..1 across the image, which is what a one-of-N class grid needs. Points + // off the image are clamped rather than dropped: a marker pinned to the edge + // still says which way to look. + public static (float X, float Y) Normalized(Vec3 point, RadarCalibration calibration) + { + (float x, float y) = Project(point, calibration); + + return (Math.Clamp(x / Canvas, 0f, 1f), Math.Clamp(y / Canvas, 0f, 1f)); + } + + public static (int Column, int Row) Cell( + Vec3 point, + RadarCalibration calibration, + int columns, + int rows + ) + { + (float x, float y) = Normalized(point, calibration); + + return ( + (int)MathF.Round(x * (columns - 1)), + (int)MathF.Round(y * (rows - 1)) + ); + } +} diff --git a/apps/swiftly/src/FiveStack.Utilities/SteamIdUtility.cs b/shared/dotnet/FiveStack.Utilities/SteamIdUtility.cs similarity index 100% rename from apps/swiftly/src/FiveStack.Utilities/SteamIdUtility.cs rename to shared/dotnet/FiveStack.Utilities/SteamIdUtility.cs diff --git a/shared/dotnet/FiveStack.Utilities/UtilityTargetCluster.cs b/shared/dotnet/FiveStack.Utilities/UtilityTargetCluster.cs new file mode 100644 index 00000000..e24bc51c --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/UtilityTargetCluster.cs @@ -0,0 +1,115 @@ +using FiveStack.Entities.Practice; + +namespace FiveStack.Utilities; + +// One place a grenade lands, and every lineup that lands there. The map draws +// targets rather than lineups because the two do not scale the same way: a +// worked map has hundreds of smokes but only a couple of dozen places worth +// smoking, and "the window smoke" is how people ask for them anyway. +public class UtilityTarget +{ + public required string Id { get; init; } + public required Vec3 Landing { get; init; } + public required string UtilityType { get; init; } + public required List Lineups { get; init; } + + public int Count => Lineups.Count; + + public string Name => Lineups[0].name; +} + +public static class UtilityTargetCluster +{ + // Two smokes landing this close are the same smoke as far as a player + // choosing one is concerned. Roughly a smoke's own radius. + public const float RadiusUnits = 150f; + + public static List Build( + IEnumerable lineups, + float radius = RadiusUnits + ) + { + var targets = new List(); + + // Deterministic: the same library must produce the same markers in the + // same order every frame, or they shuffle under the cursor. + foreach ( + LineupRecord lineup in lineups + .OrderBy(l => l.utility_type, StringComparer.Ordinal) + .ThenBy(l => l.name, StringComparer.Ordinal) + .ThenBy(l => l.client_id, StringComparer.Ordinal) + ) + { + UtilityTarget? nearest = null; + float best = float.MaxValue; + + foreach (UtilityTarget target in targets) + { + // Never merge a flash into a smoke: they land together and mean + // completely different things. + if ( + !string.Equals( + target.UtilityType, + lineup.utility_type, + StringComparison.OrdinalIgnoreCase + ) + ) + { + continue; + } + + float distance = Distance(target.Landing, lineup.detonation_position); + + if (distance <= radius && distance < best) + { + best = distance; + nearest = target; + } + } + + if (nearest != null) + { + nearest.Lineups.Add(lineup); + + continue; + } + + targets.Add( + new UtilityTarget + { + Id = Key(lineup), + Landing = lineup.detonation_position, + UtilityType = lineup.utility_type, + Lineups = new List { lineup }, + } + ); + } + + return targets; + } + + // The busiest targets first, so a fixed number of markers spends them on the + // spots the most lineups were written for. + public static List Top(IEnumerable targets, int limit) + { + return targets + .OrderByDescending(target => target.Count) + .ThenBy(target => target.Name, StringComparer.Ordinal) + .Take(limit) + .ToList(); + } + + public static string Key(LineupRecord lineup) + { + return string.IsNullOrEmpty(lineup.id) ? lineup.client_id : lineup.id!; + } + + private static float Distance(Vec3 a, Vec3 b) + { + float dx = a.x - b.x; + float dy = a.y - b.y; + float dz = a.z - b.z; + + return MathF.Sqrt(dx * dx + dy * dy + dz * dz); + } +}