diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..52ff2e2 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,56 @@ +name: Build Spark + +on: + push: + branches: + - main + - master + - develop + pull_request: + branches: + - main + - master + - develop + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: windows-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore Dependencies + run: dotnet restore Spark.sln + + - name: Build + run: dotnet build Spark.sln --configuration Release --no-restore + + - name: Publish Framework-Dependent + run: dotnet publish Spark.csproj -c Release -p:PublishSingleFile=false --no-build -o ./publish-fd + + - name: Publish Self-Contained + run: dotnet publish Spark.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o ./publish-sc + + - name: Upload Framework-Dependent Artifact + uses: actions/upload-artifact@v4 + with: + name: Spark-Framework-Dependent + path: ./publish-fd/ + retention-days: 30 + + - name: Upload Self-Contained Artifact + uses: actions/upload-artifact@v4 + with: + name: Spark-Self-Contained + path: ./publish-sc/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index cb85490..5f41000 100644 --- a/.gitignore +++ b/.gitignore @@ -360,3 +360,5 @@ bfg-1.14.0.jar .git.bfg-report/ IgniteBotKey.pfx.bak2 SparkMSIX/SparkMSIX_TemporaryKey.pfx.bak +SecretKeys.cs +SecretKeys.cs diff --git a/App.xaml.cs b/App.xaml.cs index db91daa..91a5ed1 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -11,6 +11,9 @@ public partial class App : Application { protected override void OnStartup(StartupEventArgs e) { + // Disable hardware acceleration so Oculus Dash and OBS can capture the window correctly + System.Windows.Media.RenderOptions.ProcessRenderMode = System.Windows.Interop.RenderMode.SoftwareOnly; + // load settings file SparkSettings.Load(); diff --git a/AppUpdater.cs b/AppUpdater.cs new file mode 100644 index 0000000..eebbb4a --- /dev/null +++ b/AppUpdater.cs @@ -0,0 +1,103 @@ +using System; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace Spark +{ + /// + /// Background-checks GitHub for a newer Spark release and hands back what was found so the + /// caller can decide how to surface it (a popup, a footer badge, etc.) — this class does no UI. + /// + public static class AppUpdater + { + public class UpdateInfo + { + public string Version; + public string Changelog; + public string DownloadUrl; + public string FileName; + } + + /// The most recently found update, if any — kept so a dismissed prompt can be reopened later. + public static UpdateInfo PendingUpdate { get; private set; } + + /// Checks the latest GitHub release for this repo. Returns null if up to date, ignored, or the check failed. + public static async Task CheckForUpdatesAsync() + { + try + { + using (var client = new WebClient()) + { + client.Headers.Add("User-Agent", "Spark-Updater"); + client.Headers.Add("Accept", "application/vnd.github.v3+json"); + + string latestReleaseUrl = "https://api.github.com/repos/heisthecat31/Spark/releases/latest"; + string json = await client.DownloadStringTaskAsync(latestReleaseUrl); + var release = JObject.Parse(json); + + string titleName = release["name"]?.ToString(); + if (string.IsNullOrWhiteSpace(titleName)) + { + titleName = release["tag_name"]?.ToString(); + } + + string latestVersionStr = "0.0.0"; + var match = System.Text.RegularExpressions.Regex.Match(titleName ?? "", @"\d+\.\d+(\.\d+)?"); + if (match.Success) + { + latestVersionStr = match.Value; + } + + var currentAssemblyVersion = Assembly.GetExecutingAssembly().GetName().Version; + string currentVersionStr = $"{currentAssemblyVersion.Major}.{currentAssemblyVersion.Minor}.{currentAssemblyVersion.Build}"; + + if (latestVersionStr.Equals(SparkSettings.instance.ignoredUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + Logger.Error($"[Updater] Skipping update check because version {latestVersionStr} is marked as ignored."); + return null; + } + + if (!Version.TryParse(latestVersionStr, out Version latestVersion) || + !Version.TryParse(currentVersionStr, out Version currentVersion) || + latestVersion <= currentVersion) + { + return null; + } + + var assets = release["assets"] as JArray; + var targetAsset = assets?.FirstOrDefault(asset => + { + string name = asset["name"]?.ToString(); + return name != null && name.EndsWith(".msi", StringComparison.OrdinalIgnoreCase); + }); + + string downloadUrl = targetAsset?["browser_download_url"]?.ToString(); + string installerFileName = targetAsset?["name"]?.ToString(); + + if (string.IsNullOrEmpty(downloadUrl)) + { + Logger.Error("[Updater] Newer release found but it has no .msi asset attached."); + return null; + } + + PendingUpdate = new UpdateInfo + { + Version = latestVersionStr, + Changelog = release["body"]?.ToString() ?? "No release notes provided.", + DownloadUrl = downloadUrl, + FileName = installerFileName + }; + return PendingUpdate; + } + } + catch (Exception ex) + { + Logger.Error($"[Updater] Background update check failed: {ex.Message}\n{ex.StackTrace}"); + return null; + } + } + } +} diff --git a/Controllers/ButterRepair.cs b/Controllers/ButterRepair.cs new file mode 100644 index 0000000..2ef79de --- /dev/null +++ b/Controllers/ButterRepair.cs @@ -0,0 +1,500 @@ +// Nullable is enabled here to match ButterV3Reader, which is annotated. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using EchoVRAPI; +using Newtonsoft.Json; + +namespace Spark +{ + /// + /// What recovered from a file, and how much of it survived. + /// Mirrors the type the reader was written against in the Replay Analyser. + /// + public sealed class ReplayReadResult + { + public string? SourcePath { get; set; } + public ReplayFormat Format { get; set; } + public int FormatVersion { get; set; } + public string? ClientName { get; set; } + public string? SessionId { get; set; } + public List Frames { get; set; } = new List(); + public List Diagnostics { get; } = new List(); + public int FailedChunks { get; set; } + public bool TruncatedTail { get; set; } + public int RecoveredPlayerSlots { get; set; } + + public bool HasFrames => Frames.Count > 0; + public bool IsPartial => FailedChunks > 0 || TruncatedTail; + } + + public enum ReplayFormat { Unknown, EchoReplay, Butter, Tape } + + /// + /// Rescues a .butter file the replay viewer refuses to open, by re-reading it with the + /// fault-tolerant decoder and writing what survives back out as .echoreplay. + /// + /// The stock decoder is all-or-nothing: one malformed frame and the whole file is discarded. + /// The usual culprit is a player who joined after recording started, whose index runs past the + /// fixed-size player table in the header. decodes each chunk + /// independently, grows that table on demand, and keeps everything before a truncated tail, so + /// files that fail to load elsewhere still give up most or all of their frames here. + /// + public static class ButterRepair + { + /// Timestamp layout every .echoreplay line starts with. + private const string EchoreplayDateFormat = "yyyy/MM/dd HH:mm:ss.fff"; + + public sealed class RepairResult + { + public bool Success { get; set; } + public string? OutputPath { get; set; } + public int FrameCount { get; set; } + public int FailedChunks { get; set; } + public bool TruncatedTail { get; set; } + public int RecoveredPlayerSlots { get; set; } + public List Diagnostics { get; } = new List(); + + /// One-line summary suitable for a message box. + public string Summary + { + get + { + if (!Success) + { + string why = Diagnostics.Count > 0 ? string.Join("\n", Diagnostics) : "No frames could be recovered."; + return "Couldn't repair this file.\n\n" + why; + } + + List notes = new List(); + if (FailedChunks > 0) notes.Add($"{FailedChunks} damaged chunk(s) skipped"); + if (TruncatedTail) notes.Add("file ended mid-frame, tail dropped"); + if (RecoveredPlayerSlots > 0) notes.Add($"{RecoveredPlayerSlots} late-joining player slot(s) recovered"); + + string detail = notes.Count > 0 ? "\n\n" + string.Join("\n", notes) : ""; + return $"Recovered {FrameCount:N0} frames.\n\nSaved to:\n{OutputPath}{detail}"; + } + } + } + + /// + /// Reads tolerantly and writes the recovered frames as an + /// .echoreplay beside it. Honours the Use Compression setting, so the output is zipped the + /// same way Spark's own recordings are. + /// + /// + /// Where to write. Defaults to the source path with "_fixed.echoreplay" in place of the + /// extension, without overwriting anything already there. + /// + /// + /// A decode of this same file that the caller has already done, reused instead of reading + /// and decoding it a second time. The scan passes the one its broken-check produced. + /// + public static RepairResult RepairToEchoreplay(string butterPath, string? outputPath = null, ReplayReadResult? alreadyRead = null) + { + RepairResult repair = new RepairResult(); + + try + { + if (!File.Exists(butterPath)) + { + repair.Diagnostics.Add("File not found: " + butterPath); + return repair; + } + + ReplayReadResult read = alreadyRead ?? ButterV3Reader.Read(File.ReadAllBytes(butterPath), butterPath); + + repair.Diagnostics.AddRange(read.Diagnostics); + repair.FailedChunks = read.FailedChunks; + repair.TruncatedTail = read.TruncatedTail; + repair.RecoveredPlayerSlots = read.RecoveredPlayerSlots; + repair.FrameCount = read.Frames.Count; + + if (!read.HasFrames) + { + // Nothing usable came back, so there is nothing to write. The diagnostics + // collected above say why, and are shown to the user rather than swallowed. + return repair; + } + + string target = outputPath ?? NextFreePath( + Path.Combine( + Path.GetDirectoryName(butterPath) ?? "", + Path.GetFileNameWithoutExtension(butterPath) + "_fixed.echoreplay")); + + WriteEchoreplay(read.Frames, target, SparkSettings.instance.useCompression); + + repair.OutputPath = target; + repair.Success = true; + + Logger.LogRow(Logger.LogType.File, "butter_repair", + $"Repaired {butterPath}: {read.Frames.Count} frames, {read.FailedChunks} bad chunk(s), truncated={read.TruncatedTail} -> {target}"); + } + catch (Exception e) + { + repair.Diagnostics.Add(e.GetType().Name + ": " + e.Message); + Logger.LogRow(Logger.LogType.Error, $"Butter repair failed for {butterPath}\n{e}"); + } + + return repair; + } + + /// + /// Writes frames in the .echoreplay layout: one line per frame, tab separated, as + /// timestamp \t session-json. Optionally wrapped in a zip, which is what the + /// viewer expects from a compressed recording. + /// + private static void WriteEchoreplay(List frames, string path, bool compress) + { + string directory = Path.GetDirectoryName(path) ?? ""; + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + + if (!compress) + { + using StreamWriter writer = new StreamWriter(path, false); + foreach (Frame frame in frames) writer.WriteLine(FormatLine(frame)); + return; + } + + // Zip via a temp directory, matching how ReplayFilesManager compresses a finished + // recording: a single entry named after the file the viewer expects to find inside. + string tempDir = Path.Combine(Path.GetTempPath(), "spark_butter_repair_" + Guid.NewGuid()); + try + { + Directory.CreateDirectory(tempDir); + + string inner = Path.Combine(tempDir, Path.GetFileNameWithoutExtension(path) + ".echoreplay"); + using (StreamWriter writer = new StreamWriter(inner, false)) + { + foreach (Frame frame in frames) writer.WriteLine(FormatLine(frame)); + } + + if (File.Exists(path)) File.Delete(path); + ZipFile.CreateFromDirectory(tempDir, path); + } + finally + { + try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } + catch (Exception e) { Logger.LogRow(Logger.LogType.Error, "Couldn't clear the repair temp folder\n" + e); } + } + } + + private static string FormatLine(Frame frame) + { + DateTime recorded = frame.recorded_time == default ? DateTime.Now : frame.recorded_time; + return recorded.ToString(EchoreplayDateFormat, CultureInfo.InvariantCulture) + + "\t" + + JsonConvert.SerializeObject(frame); + } + + /// Why a file was judged broken, and what the tolerant reader got out of it. + public sealed class BrokenCheck + { + public bool IsBroken { get; set; } + public string Reason { get; set; } = ""; + public int RecoverableFrames { get; set; } + + /// + /// The decode the check already paid for, passed to the repair so a broken file is + /// read once rather than once to diagnose and again to fix. + /// + internal ReplayReadResult? Read { get; set; } + } + + public sealed class ScanResult + { + public int Scanned { get; set; } + public int Broken { get; set; } + public int Repaired { get; set; } + public int Deleted { get; set; } + public bool Cancelled { get; set; } + public List Details { get; } = new List(); + + public string Summary + { + get + { + string stopped = Cancelled ? "Stopped. " : ""; + if (Scanned == 0) return Cancelled ? "Stopped before checking anything." : "No .butter files found to check."; + if (Broken == 0) return $"{stopped}Checked {Scanned:N0} file(s). None were broken."; + + string body = $"{stopped}Checked {Scanned:N0} file(s).\n\n" + + $"Broken: {Broken:N0}\n" + + $"Repaired: {Repaired:N0}\n" + + $"Originals deleted: {Deleted:N0}"; + + if (Repaired < Broken) + body += $"\n\n{Broken - Repaired:N0} could not be repaired and were left alone."; + + return body; + } + } + } + + /// + /// Decides whether a .butter file is broken, meaning the viewer will not open it but + /// something is still recoverable. + /// + /// Two ways to qualify. The stock decoder — the same one the viewer uses — either throwing + /// or returning nothing is the direct signal. The tolerant reader reporting damaged chunks + /// or a truncated tail is the other: those files sometimes still limp through the stock + /// decoder, but they are missing data and are worth rewriting while the frames are still + /// readable. + /// + /// A file only counts as broken if the tolerant reader can actually recover frames from it. + /// Without that there is nothing to repair, and deleting the original would destroy the + /// only copy of whatever is in there. + /// + public static BrokenCheck CheckBroken(string butterPath) + { + BrokenCheck check = new BrokenCheck(); + + byte[] bytes; + try { bytes = File.ReadAllBytes(butterPath); } + catch (Exception e) + { + check.Reason = "could not be read: " + e.Message; + return check; + } + + // The tolerant read goes first because it is the cheaper of the two passes and settles + // the damage question outright. A file it reports as damaged is broken whatever the + // stock decoder makes of it, so that case skips the stock decode entirely — and the + // decode it produces is handed to the repair, which would otherwise redo it. + ReplayReadResult read; + try { read = ButterV3Reader.Read(bytes, butterPath); } + catch (Exception e) + { + check.Reason = "tolerant read threw: " + e.Message; + return check; + } + + check.Read = read; + check.RecoverableFrames = read.Frames.Count; + + // Nothing to salvage, so leave it exactly where it is. + if (!read.HasFrames) + { + check.Reason = "no frames recoverable"; + return check; + } + + // Damaged or cut short. These sometimes still limp through the stock decoder, but they + // are missing data, and rewriting them while the frames are still readable is the whole + // point of the exercise. + if (read.FailedChunks > 0 || read.TruncatedTail) + { + check.IsBroken = true; + + List bits = new List(); + if (read.FailedChunks > 0) bits.Add($"{read.FailedChunks} damaged chunk(s)"); + if (read.TruncatedTail) bits.Add("truncated tail"); + check.Reason = string.Join(", ", bits); + return check; + } + + // Intact as far as the tolerant reader can tell, so the only question left is whether + // the decoder the viewer actually uses will open it. + try + { + List stock = ButterReplays.ButterFile.FromBytes(bytes); + if (stock != null && stock.Count > 0) + { + check.Reason = "decodes cleanly"; + return check; + } + + check.IsBroken = true; + check.Reason = "the standard decoder returned no frames"; + } + catch (Exception e) + { + check.IsBroken = true; + check.Reason = "the standard decoder failed with " + e.GetType().Name; + } + + return check; + } + + /// + /// Checks every .butter file in , repairs the broken ones, and — + /// when is set — removes the original afterwards. + /// + /// A original is only deleted once its replacement has been written AND read back with the + /// expected frame count. Deleting a recording is not undoable, so a repair that cannot be + /// verified leaves both files in place. + /// + public static ScanResult ScanAndRepair(string folder, bool deleteOriginals, Action? progress = null, CancellationToken token = default) + { + ScanResult scan = new ScanResult(); + + if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) + { + scan.Details.Add("Save folder not found: " + folder); + return scan; + } + + string[] files; + try { files = Directory.GetFiles(folder, "*.butter", SearchOption.TopDirectoryOnly); } + catch (Exception e) + { + scan.Details.Add("Could not list the save folder: " + e.Message); + return scan; + } + + foreach (string file in files) + { + // Checked between files rather than mid-decode: stopping partway through a repair + // could leave a half-written replacement beside an original about to be judged. + if (token.IsCancellationRequested) + { + scan.Cancelled = true; + scan.Details.Add($"Stopped early — {files.Length - scan.Scanned:N0} file(s) not checked."); + break; + } + + scan.Scanned++; + string name = Path.GetFileName(file); + progress?.Invoke($"Checking {name} ({scan.Scanned}/{files.Length})..."); + + BrokenCheck check = CheckBroken(file); + if (!check.IsBroken) + { + scan.Details.Add($"OK {name} — {check.Reason}"); + continue; + } + + scan.Broken++; + progress?.Invoke($"Repairing {name} ({scan.Scanned}/{files.Length})..."); + + // Reuses the decode the check already did, so a broken file is read once. + RepairResult repair = RepairToEchoreplay(file, null, check.Read); + if (!repair.Success || string.IsNullOrEmpty(repair.OutputPath)) + { + scan.Details.Add($"FAILED {name} — {check.Reason}; repair did not produce a file"); + continue; + } + + scan.Repaired++; + + int verified = CountEchoreplayFrames(repair.OutputPath); + bool trustworthy = verified > 0 && verified == repair.FrameCount; + + if (!deleteOriginals) + { + scan.Details.Add($"FIXED {name} — {check.Reason}; {repair.FrameCount:N0} frames -> {Path.GetFileName(repair.OutputPath)}"); + continue; + } + + if (!trustworthy) + { + scan.Details.Add($"KEPT {name} — repaired but the copy read back {verified:N0} of {repair.FrameCount:N0} frames, so the original was left in place"); + continue; + } + + try + { + File.Delete(file); + scan.Deleted++; + scan.Details.Add($"FIXED {name} — {check.Reason}; {repair.FrameCount:N0} frames -> {Path.GetFileName(repair.OutputPath)}, original deleted"); + } + catch (Exception e) + { + scan.Details.Add($"KEPT {name} — repaired, but the original could not be deleted: {e.Message}"); + } + } + + Logger.LogRow(Logger.LogType.File, "butter_repair", + $"Scan of {folder}: scanned={scan.Scanned} broken={scan.Broken} repaired={scan.Repaired} deleted={scan.Deleted}"); + + return scan; + } + + /// + /// Re-reads a written .echoreplay and counts the frames that parse, so a repair can be + /// proven good before the original is thrown away. Handles the zipped form, since that is + /// what gets written when compression is on. Returns -1 if it cannot be read at all. + /// + private static int CountEchoreplayFrames(string path) + { + try + { + bool zipped; + using (FileStream probe = File.OpenRead(path)) + { + zipped = probe.ReadByte() == 0x50 && probe.ReadByte() == 0x4B; + } + + ZipArchive archive = null; + TextReader reader; + + if (zipped) + { + archive = ZipFile.OpenRead(path); + ZipArchiveEntry entry = archive.Entries.FirstOrDefault(e => e.Length > 0) ?? archive.Entries.FirstOrDefault(); + if (entry == null) { archive.Dispose(); return -1; } + reader = new StreamReader(entry.Open()); + } + else + { + reader = new StreamReader(File.OpenRead(path)); + } + + int good = 0; + try + { + string line; + while ((line = reader.ReadLine()) != null) + { + if (line.Length == 0) continue; + + int tab = line.IndexOf('\t'); + if (tab <= 0) continue; + + string json = line.Substring(tab + 1); + int tab2 = json.IndexOf('\t'); + if (tab2 > 0) json = json.Substring(0, tab2); + + if (json.Length >= 2 && json[0] == '{') good++; + } + } + finally + { + reader.Dispose(); + archive?.Dispose(); + } + + return good; + } + catch (Exception e) + { + Logger.LogRow(Logger.LogType.Error, $"Couldn't verify the repaired replay {path}\n{e}"); + return -1; + } + } + + /// Appends a counter rather than overwriting a previous repair of the same file. + private static string NextFreePath(string preferred) + { + if (!File.Exists(preferred)) return preferred; + + string dir = Path.GetDirectoryName(preferred) ?? ""; + string name = Path.GetFileNameWithoutExtension(preferred); + string ext = Path.GetExtension(preferred); + + for (int i = 2; i < 1000; i++) + { + string candidate = Path.Combine(dir, $"{name}_{i}{ext}"); + if (!File.Exists(candidate)) return candidate; + } + + return Path.Combine(dir, $"{name}_{Guid.NewGuid():N}{ext}"); + } + } +} diff --git a/Controllers/ButterV3Reader.cs b/Controllers/ButterV3Reader.cs new file mode 100644 index 0000000..c4a0362 --- /dev/null +++ b/Controllers/ButterV3Reader.cs @@ -0,0 +1,842 @@ +// Ported verbatim from the Replay Analyser (EchoAnalyser.Core/Parsing/ButterV3Reader.cs) so +// Spark can repair the same butter files that tool can already read. Kept byte-for-byte apart +// from this header and the result type below, so fixes can be diffed against the original. +// +// Nullable is enabled for this file alone: the source is annotated, and Spark does not turn +// nullable on project-wide. +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Text; +using EchoVRAPI; + +namespace Spark; + +/// +/// A fault-tolerant reader for the .butter v3 replay container. +/// +/// The stock ButterReplays decoder is all-or-nothing: it decodes the whole file in one +/// pass and throws away every frame if any single frame is malformed. Roughly half of a +/// real capture library trips it, most often on IndexOutOfRangeException from the +/// fixed-size player table in the header — a player who joins after recording started has +/// an index past the end of that table. +/// +/// This reader keeps the same wire format but recovers instead of aborting: +/// * chunks are decoded independently (each opens with a keyframe, so they are +/// self-contained) and a bad chunk costs only that chunk; +/// * the player table grows on demand, so late joiners decode as real players; +/// * a truncated tail — common when Spark is killed mid-match — keeps everything +/// before the truncation. +/// +public static class ButterV3Reader +{ + private const ushort KeyframeMarker = 0xFEFC; + private const ushort DeltaMarker = 0xFEFE; + + public static ReplayReadResult Read(byte[] bytes, string? sourcePath = null) + { + var result = new ReplayReadResult { SourcePath = sourcePath, Format = ReplayFormat.Butter }; + if (bytes.Length < 32) + { + result.Diagnostics.Add("File too small to contain a butter header."); + return result; + } + + using var ms = new MemoryStream(bytes); + using var br = new BinaryReader(ms); + + byte formatVersion = br.ReadByte(); + result.FormatVersion = formatVersion; + if (formatVersion != 3) + { + result.Diagnostics.Add($"Unsupported butter format version {formatVersion} (only v3 is supported)."); + return result; + } + + var header = new ButterHeaderV3(); + uint[] chunkSizes; + try + { + header.KeyframeInterval = br.ReadUInt16(); + header.Compression = (ButterCompression)br.ReadByte(); + header.ClientName = ReadCString(br); + header.SessionId = ReadSessionId(br); + header.SessionIp = ReadIpAddress(br); + + int playerCount = br.ReadByte(); + for (int i = 0; i < playerCount; i++) header.Names.Add(ReadCString(br)); + for (int i = 0; i < playerCount; i++) header.UserIds.Add(br.ReadInt64()); + for (int i = 0; i < playerCount; i++) header.Numbers.Add(br.ReadByte()); + for (int i = 0; i < playerCount; i++) header.Levels.Add(br.ReadByte()); + + header.TotalRoundCount = br.ReadByte(); + byte roundScores = br.ReadByte(); + header.BlueRoundScore = roundScores & 0x0F; + header.OrangeRoundScore = (roundScores >> 4) & 0x0F; + + byte mapByte = br.ReadByte(); + header.PrivateMatch = (mapByte & 1) == 1; + header.MapName = MapNameFromIndex(mapByte >> 1); + header.MatchType = MatchTypeFor(header.MapName, header.PrivateMatch); + + uint chunkCount = br.ReadUInt32(); + if (chunkCount > 2_000_000) + { + result.Diagnostics.Add($"Implausible chunk count {chunkCount}; refusing to read."); + return result; + } + chunkSizes = new uint[chunkCount]; + for (int i = 0; i < chunkCount; i++) chunkSizes[i] = br.ReadUInt32(); + } + catch (Exception ex) + { + result.Diagnostics.Add($"Header unreadable: {ex.GetType().Name}: {ex.Message}"); + return result; + } + + result.ClientName = header.ClientName; + result.SessionId = header.SessionId; + + var frames = new List(); + Frame? previous = null; // last frame decoded, for delta reconstruction + + for (int c = 0; c < chunkSizes.Length; c++) + { + uint size = chunkSizes[c]; + if (size == 0) continue; + if (ms.Position + size > ms.Length) + { + result.TruncatedTail = true; + result.Diagnostics.Add( + $"Chunk {c}/{chunkSizes.Length} claims {size:N0} bytes but only {ms.Length - ms.Position:N0} remain — file truncated (recording likely interrupted)."); + break; + } + + byte[] raw = br.ReadBytes((int)size); + byte[] payload; + try + { + payload = Decompress(raw, header.Compression); + } + catch (Exception ex) + { + result.FailedChunks++; + result.Diagnostics.Add($"Chunk {c}: decompression failed ({ex.GetType().Name})."); + previous = null; // next keyframe re-syncs + continue; + } + + // Each chunk opens with a keyframe, so a failure here is contained: we keep the + // frames decoded so far in this chunk and re-sync on the next chunk's keyframe. + int beforeCount = frames.Count; + try + { + DecodeChunk(payload, header, frames, ref previous); + } + catch (Exception ex) + { + result.FailedChunks++; + result.Diagnostics.Add( + $"Chunk {c}: stopped after {frames.Count - beforeCount} frame(s) — {ex.GetType().Name}: {ex.Message}"); + previous = null; + } + } + + result.Frames = frames; + result.RecoveredPlayerSlots = header.SynthesizedSlots; + if (header.SynthesizedSlots > 0) + result.Diagnostics.Add( + $"{header.SynthesizedSlots} player slot(s) were referenced but missing from the header (mid-match joins); they were recovered as synthetic entries."); + return result; + } + + private static void DecodeChunk(byte[] payload, ButterHeaderV3 header, List frames, ref Frame? previous) + { + using var ms = new MemoryStream(payload); + using var br = new BinaryReader(ms); + + while (ms.Position < ms.Length - 1) + { + ushort marker = br.ReadUInt16(); + if (marker != KeyframeMarker && marker != DeltaMarker) + throw new InvalidDataException($"Lost frame sync (marker 0x{marker:X4})."); + + bool isKeyframe = marker == KeyframeMarker; + if (isKeyframe) previous = null; + if (!isKeyframe && previous == null) + throw new InvalidDataException("Delta frame with no preceding keyframe."); + + Frame f = DecodeFrame(br, header, isKeyframe, previous); + frames.Add(f); + previous = f; + } + } + + private static Frame DecodeFrame(BinaryReader br, ButterHeaderV3 h, bool isKeyframe, Frame? prev) + { + var f = new Frame + { + recorded_time = isKeyframe + ? DateTimeOffset.FromUnixTimeMilliseconds(br.ReadInt64()).LocalDateTime + : prev!.recorded_time.AddMilliseconds(br.ReadUInt16()), + client_name = h.ClientName, + sessionid = h.SessionId, + sessionip = h.SessionIp, + total_round_count = h.TotalRoundCount, + blue_round_score = h.BlueRoundScore, + orange_round_score = h.OrangeRoundScore, + private_match = h.PrivateMatch, + map_name = h.MapName, + match_type = h.MatchType, + }; + f.game_clock = br.ReadSingle() + (isKeyframe ? 0f : prev!.game_clock); + f.game_clock_display = FormatClock(f.game_clock); + + var mask = Bits(br.ReadByte()); + + // ---- game state ------------------------------------------------------- + if (mask[0]) + { + f.game_status = GameStatusFromByte(br.ReadByte()); + // Arena scores are whole points in a byte; Combat scores are fractional and use a + // half. On any other map (lobby, tutorial) no score is written at all. + if (IsArena(f.map_name)) + { + f.blue_points = br.ReadByte(); + f.orange_points = br.ReadByte(); + } + else if (IsCombat(f.map_name)) + { + f.blue_points = (float)ReadHalf(br); + f.orange_points = (float)ReadHalf(br); + } + var sh = Bits(br.ReadByte()); + f.left_shoulder_pressed = sh[0]; + f.right_shoulder_pressed = sh[1]; + f.left_shoulder_pressed2 = sh[2]; + f.right_shoulder_pressed2 = sh[3]; + } + else + { + f.game_status = prev?.game_status; + f.blue_points = prev?.blue_points ?? 0f; + f.orange_points = prev?.orange_points ?? 0f; + f.left_shoulder_pressed = prev?.left_shoulder_pressed ?? false; + f.right_shoulder_pressed = prev?.right_shoulder_pressed ?? false; + f.left_shoulder_pressed2 = prev?.left_shoulder_pressed2 ?? false; + f.right_shoulder_pressed2 = prev?.right_shoulder_pressed2 ?? false; + } + + // ---- pause / restart requests ---------------------------------------- + if (mask[1]) + { + byte b = br.ReadByte(); + f.blue_team_restart_request = (b & 1) > 0; + f.orange_team_restart_request = (b & 2) > 0; + f.pause = new Pause + { + paused_requested_team = TeamFromIndex((byte)((b & 0x0C) >> 2)), + unpaused_team = TeamFromIndex((byte)((b & 0x30) >> 4)), + paused_state = PausedStateFromByte(br.ReadByte()), + paused_timer = (float)ReadHalf(br), + unpaused_timer = (float)ReadHalf(br), + }; + } + else f.pause = prev?.pause; + + // ---- last score ------------------------------------------------------- + if (mask[2]) + { + byte b = br.ReadByte(); + f.last_score = new LastScore + { + team = TeamFromIndex((byte)(b & 3)), + point_amount = (b & 4) > 0 ? 3 : 2, + goal_type = GoalTypeFromIndex((b & 0xF8) >> 3), + person_scored = h.NameAt(br.ReadByte()), + assist_scored = h.NameAt(br.ReadByte()), + disc_speed = (float)ReadHalf(br), + distance_thrown = (float)ReadHalf(br), + }; + } + else f.last_score = prev?.last_score; + + // ---- last throw ------------------------------------------------------- + if (mask[3]) + { + f.last_throw = new LastThrow + { + arm_speed = (float)ReadHalf(br), + total_speed = (float)ReadHalf(br), + off_axis_spin_deg = (float)ReadHalf(br), + wrist_throw_penalty = (float)ReadHalf(br), + rot_per_sec = (float)ReadHalf(br), + pot_speed_from_rot = (float)ReadHalf(br), + speed_from_arm = (float)ReadHalf(br), + speed_from_movement = (float)ReadHalf(br), + speed_from_wrist = (float)ReadHalf(br), + wrist_align_to_throw_deg = (float)ReadHalf(br), + throw_align_to_movement_deg = (float)ReadHalf(br), + off_axis_penalty = (float)ReadHalf(br), + throw_move_penalty = (float)ReadHalf(br), + }; + } + else f.last_throw = prev?.last_throw; + + // ---- local VR player -------------------------------------------------- + if (mask[4]) + { + var (pos, rot) = ReadPose(br); + pos += prev?.player is { } pp ? ToVec(pp.vr_position) : Vector3.Zero; + f.player = new VRPlayer + { + vr_position = ToList(pos), + vr_forward = ToList(Forward(rot)), + vr_left = ToList(Left(rot)), + vr_up = ToList(Up(rot)), + }; + } + else f.player = prev?.player; + + // ---- disc ------------------------------------------------------------- + if (mask[5]) + { + var (pos, rot) = ReadPose(br); + pos += prev?.disc is { } pd ? ToVec(pd.position) : Vector3.Zero; + f.disc = new Disc + { + position = ToList(pos), + forward = ToList(Forward(rot)), + left = ToList(Left(rot)), + up = ToList(Up(rot)), + velocity = new List + { + (float)ReadHalf(br) + (prev?.disc?.velocity is { Count: > 0 } v0 ? v0[0] : 0f), + (float)ReadHalf(br) + (prev?.disc?.velocity is { Count: > 1 } v1 ? v1[1] : 0f), + (float)ReadHalf(br) + (prev?.disc?.velocity is { Count: > 2 } v2 ? v2[2] : 0f), + }, + }; + } + else f.disc = prev?.disc; + + // ---- combat payload --------------------------------------------------- + if (mask[6]) + { + f.contested = br.ReadBoolean(); + f.payload_checkpoint = br.ReadByte(); + f.payload_defenders = br.ReadByte(); + f.payload_distance = (float)ReadHalf(br); + f.payload_speed = (float)ReadHalf(br); + } + else + { + f.contested = prev?.contested ?? false; + f.payload_checkpoint = prev?.payload_checkpoint ?? 0; + f.payload_defenders = prev?.payload_defenders ?? 0; + f.payload_distance = prev?.payload_distance ?? 0f; + f.payload_speed = prev?.payload_speed ?? 0f; + } + + // ---- rule changes ----------------------------------------------------- + if (mask[7]) + { + f.rules_changed_at = br.ReadInt64(); + f.rules_changed_by = ReadCString(br); + } + else + { + f.rules_changed_at = prev?.rules_changed_at ?? 0; + f.rules_changed_by = prev?.rules_changed_by; + } + + // ---- teams ------------------------------------------------------------ + var teamMask = Bits(br.ReadByte()); + f.teams = new List { new(), new(), new() }; + f.teams[0].possession = teamMask[0]; + f.teams[1].possession = teamMask[1]; + + for (int t = 0; t < 3; t++) + { + f.teams[t].stats = teamMask[t + 2] + ? ReadStats(br) + : prev?.teams?[t].stats ?? new Stats(); + + int count = br.ReadByte(); + var players = new List(count); + for (int i = 0; i < count; i++) + players.Add(DecodePlayer(br, h, f, prev)); + f.teams[t].players = players; + f.teams[t].team = t switch { 0 => "BLUE TEAM", 1 => "ORANGE TEAM", _ => "SPECTATORS" }; + } + + // Trailing skeleton block. Even when the poses are not needed, the bytes have to be + // consumed exactly or every subsequent frame in the chunk loses sync. + byte boneHeader = br.ReadByte(); + bool hasBones = (boneHeader & 1) == 1; + int bonePlayerCount = boneHeader >> 1; + if (hasBones) ReadBones(br, bonePlayerCount, prev, f); + + return f; + } + + private static Player DecodePlayer(BinaryReader br, ButterHeaderV3 h, Frame f, Frame? prev) + { + byte idx = br.ReadByte(); + var p = new Player + { + name = h.NameAt(idx), + playerid = br.ReadByte(), + level = h.LevelAt(idx), + number = h.NumberAt(idx), + userid = h.UserIdAt(idx), + }; + + Player? old = prev?.GetPlayer(p.userid); + + var m = Bits(br.ReadByte()); + p.possession = m[0]; + p.blocking = m[1]; + p.stunned = m[2]; + p.invulnerable = m[3]; + p.is_emote_playing = m[4]; + + if (m[5]) + { + p.stats = ReadStats(br); + if (old?.stats != null) p.stats += old.stats; + } + else p.stats = old?.stats ?? new Stats(); + + bool inArena = IsArena(f.map_name); + if (m[6]) + { + p.ping = br.ReadInt16() + (old?.ping ?? 0); + p.packetlossratio = (float)ReadHalf(br) + (old?.packetlossratio ?? 0f); + if (inArena) + { + p.holding_left = HoldingFromByte(br.ReadByte()); + p.holding_right = HoldingFromByte(br.ReadByte()); + } + } + else + { + p.ping = old?.ping ?? 0; + p.packetlossratio = old?.packetlossratio ?? 0f; + if (inArena) + { + p.holding_left = old?.holding_left ?? "none"; + p.holding_right = old?.holding_right ?? "none"; + } + } + + p.velocity = m[7] + ? ToList(ReadVector3Half(br) + (old?.velocity is { Count: 3 } ov ? ToVec(ov) : Vector3.Zero)) + : old?.velocity ?? new List { 0, 0, 0 }; + + var pm = Bits(br.ReadByte()); + p.head = new Transform(); + p.body = new Transform(); + p.lhand = new Transform(); + p.rhand = new Transform(); + + p.head.position = pm[0] + ? ToList(ReadVector3Half(br) + (old?.head?.Position ?? Vector3.Zero)) + : old?.head?.position ?? new List { 0, 0, 0 }; + p.head.Rotation = pm[1] ? ReadSmallestThree(br) : old?.head?.Rotation ?? Quaternion.Identity; + + p.body.position = pm[2] + ? ToList(ReadVector3Half(br) + (old?.body?.Position ?? Vector3.Zero)) + : old?.body?.position ?? new List { 0, 0, 0 }; + p.body.Rotation = pm[3] ? ReadSmallestThree(br) : old?.body?.Rotation ?? Quaternion.Identity; + + p.lhand.position = pm[4] + ? ToList(ReadVector3Half(br) + (old?.lhand?.Position ?? Vector3.Zero)) + : old?.lhand?.position ?? new List { 0, 0, 0 }; + p.lhand.Rotation = pm[5] ? ReadSmallestThree(br) : old?.lhand?.Rotation ?? Quaternion.Identity; + + p.rhand.position = pm[6] + ? ToList(ReadVector3Half(br) + (old?.rhand?.Position ?? Vector3.Zero)) + : old?.rhand?.position ?? new List { 0, 0, 0 }; + p.rhand.Rotation = pm[7] ? ReadSmallestThree(br) : old?.rhand?.Rotation ?? Quaternion.Identity; + + if (IsCombat(f.map_name)) + { + byte loadout = br.ReadByte(); + p.Weapon = WeaponNames[loadout & 3]; + p.Ordnance = OrdnanceNames[(loadout & 0x0C) >> 2]; + p.TacMod = TacModNames[(loadout & 0x30) >> 4]; + p.Arm = ((loadout & 0x40) >> 6) == 0 ? "Left" : "Right"; + } + + return p; + } + + private const int BoneCount = 23; + + /// + /// Per bone-player: a 3-byte position mask and a 3-byte rotation mask, then only the + /// bones whose bit is set, stored as deltas against the previous frame. + /// + private static void ReadBones(BinaryReader br, int count, Frame? prev, Frame f) + { + var previousBones = prev?.bones?.user_bones; + var bones = new Bones { user_bones = new BonePlayer[count] }; + + for (int p = 0; p < count; p++) + { + var bp = new BonePlayer { bone_o = new float[92], bone_t = new float[69] }; + bones.user_bones[p] = bp; + + var posMask = Bits(br.ReadBytes(3)); + var rotMask = Bits(br.ReadBytes(3)); + BonePlayer? old = previousBones != null && p < previousBones.Length ? previousBones[p] : null; + + for (int b = 0; b < BoneCount; b++) + { + Vector3 basePos = old?.GetPosition(b) ?? Vector3.Zero; + bp.SetPosition(b, posMask[b] + ? basePos + ReadVector3FixedPrecision(br, -2f, 2f, 14) + : basePos); + } + for (int b = 0; b < BoneCount; b++) + { + Quaternion baseRot = old?.GetRotation(b) ?? Quaternion.Identity; + bp.SetRotation(b, rotMask[b] ? ReadSmallestThree(br) * baseRot : baseRot); + } + } + f.bones = bones; + } + + private static List Bits(byte[] bytes) + { + var list = new List(bytes.Length * 8); + foreach (byte b in bytes) list.AddRange(Bits(b)); + return list; + } + + private static Vector3 ReadVector3FixedPrecision(BinaryReader br, float min, float max, int bits) => + new(ReadFixedPrecisionFloat(br, min, max, bits), + ReadFixedPrecisionFloat(br, min, max, bits), + ReadFixedPrecisionFloat(br, min, max, bits)); + + private static float ReadFixedPrecisionFloat(BinaryReader br, float min, float max, int bits) + { + double v = ReadVarInt(br); + v += Math.Pow(2.0, bits - 1); + v /= Math.Pow(2.0, bits); + v *= max - min; + v += min; + return (float)v; + } + + private static long ReadVarInt(BinaryReader br) + { + ulong n = ReadVarUInt(br); + return (long)((n >> 1) ^ (0UL - (n & 1))); + } + + /// SQLite4-style variable length integer. + private static ulong ReadVarUInt(BinaryReader br) + { + byte a0 = br.ReadByte(); + if (a0 < 241) return a0; + byte a1 = br.ReadByte(); + if (a0 <= 248) return (ulong)(240 + ((a0 - 241L) << 8) + a1); + byte a2 = br.ReadByte(); + if (a0 == 249) return 2288UL + ((ulong)a1 << 8) + a2; + byte a3 = br.ReadByte(); + if (a0 == 250) return a1 + ((ulong)a2 << 8) + ((ulong)a3 << 16); + byte a4 = br.ReadByte(); + if (a0 == 251) return a1 + ((ulong)a2 << 8) + ((ulong)a3 << 16) + ((ulong)a4 << 24); + byte a5 = br.ReadByte(); + if (a0 == 252) return a1 + ((ulong)a2 << 8) + ((ulong)a3 << 16) + ((ulong)a4 << 24) + ((ulong)a5 << 32); + byte a6 = br.ReadByte(); + if (a0 == 253) return a1 + ((ulong)a2 << 8) + ((ulong)a3 << 16) + ((ulong)a4 << 24) + ((ulong)a5 << 32) + ((ulong)a6 << 40); + byte a7 = br.ReadByte(); + if (a0 == 254) return a1 + ((ulong)a2 << 8) + ((ulong)a3 << 16) + ((ulong)a4 << 24) + ((ulong)a5 << 32) + ((ulong)a6 << 40) + ((ulong)a7 << 48); + byte a8 = br.ReadByte(); + return a1 + ((ulong)a2 << 8) + ((ulong)a3 << 16) + ((ulong)a4 << 24) + ((ulong)a5 << 32) + ((ulong)a6 << 40) + ((ulong)a7 << 48) + ((ulong)a8 << 56); + } + + // ------------------------------------------------------------------ helpers + + private static byte[] Decompress(byte[] raw, ButterCompression format) => format switch + { + ButterCompression.None => raw, + ButterCompression.Gzip => Gunzip(raw), + ButterCompression.Zstd3 or ButterCompression.Zstd7 or ButterCompression.Zstd15 + or ButterCompression.Zstd22 or ButterCompression.Zstd7Dict => Unzstd(raw), + _ => throw new NotSupportedException($"Unknown compression format {(byte)format}."), + }; + + private static byte[] Gunzip(byte[] raw) + { + using var src = new MemoryStream(raw); + using var gz = new System.IO.Compression.GZipStream(src, System.IO.Compression.CompressionMode.Decompress); + using var dst = new MemoryStream(); + gz.CopyTo(dst); + return dst.ToArray(); + } + + private static byte[] Unzstd(byte[] raw) + { + using var d = new ZstdNet.Decompressor(); + return d.Unwrap(raw); + } + + private static List Bits(byte b) + { + var list = new List(8); + for (int i = 0; i < 8; i++) list.Add((b & (1 << i)) != 0); + return list; + } + + private static string ReadCString(BinaryReader br, int max = 1024) + { + var bytes = new List(); + for (int i = 0; i < max; i++) + { + byte b = br.ReadByte(); + if (b == 0) break; + bytes.Add(b); + } + return Encoding.ASCII.GetString(bytes.ToArray()); + } + + private static string ReadSessionId(BinaryReader br) + { + string s = Convert.ToHexString(br.ReadBytes(16)); + return s.Insert(20, "-").Insert(16, "-").Insert(12, "-").Insert(8, "-"); + } + + private static string ReadIpAddress(BinaryReader br) + { + var b = br.ReadBytes(4); + return $"{b[0]}.{b[1]}.{b[2]}.{b[3]}"; + } + + private static double ReadHalf(BinaryReader br) => (double)BitConverter.ToHalf(br.ReadBytes(2)); + + private static Vector3 ReadVector3Half(BinaryReader br) => + new((float)ReadHalf(br), (float)ReadHalf(br), (float)ReadHalf(br)); + + private static (Vector3, Quaternion) ReadPose(BinaryReader br) => + (ReadVector3Half(br), ReadSmallestThree(br)); + + /// + /// Smallest-three quaternion decode. The dropped-component index lives in the low two + /// bits; the three kept components follow as 10-bit values scaled to ±1/√2. + /// + private static Quaternion ReadSmallestThree(BinaryReader br) + { + uint packed = br.ReadUInt32(); + uint index = packed & 3; + float a = Uncompress((packed & 0x00000FFCu) >> 2); + float b = Uncompress((packed & 0x003FF000u) >> 12); + float c = Uncompress((packed & 0xFFC00000u) >> 22); + float d = MathF.Sqrt(MathF.Max(0f, 1f - a * a - b * b - c * c)); + return index switch + { + 0 => new Quaternion(d, a, b, c), + 1 => new Quaternion(a, d, b, c), + 2 => new Quaternion(a, b, d, c), + _ => new Quaternion(a, b, c, d), + }; + + static float Uncompress(uint v) => (float)(v / 1023.0 * 1.41421356 - 0.70710678); + } + + /// + /// Stats are written in alphabetical field order — 10 single-byte counters, then a + /// half-precision possession time, then a 16-bit stun count. 14 bytes total. + /// + /// + /// Stats are stored as a delta against the previous frame, and the writer casts that + /// difference straight to a byte — so a counter that goes *down* (a player rejoining, a + /// round resetting the scoreboard) wraps round to 255 rather than clamping. Reading these + /// back as unsigned turns a -1 into +255 and permanently inflates the running total, so + /// every wrapped field has to come back signed. + /// + /// The two exceptions are written differently: stuns is clamped non-negative by the + /// writer before being stored as a 16-bit value, and possession_time is a half, + /// which carries its own sign. + /// + private static Stats ReadStats(BinaryReader br) => new() + { + assists = br.ReadSByte(), + blocks = br.ReadSByte(), + catches = br.ReadSByte(), + goals = br.ReadSByte(), + interceptions = br.ReadSByte(), + passes = br.ReadSByte(), + points = br.ReadSByte(), + saves = br.ReadSByte(), + steals = br.ReadSByte(), + shots_taken = br.ReadSByte(), + possession_time = (float)ReadHalf(br), + stuns = br.ReadUInt16(), + }; + + private static Vector3 ToVec(List? l) => + l is { Count: >= 3 } ? new Vector3(l[0], l[1], l[2]) : Vector3.Zero; + + private static List ToList(Vector3 v) => new() { v.X, v.Y, v.Z }; + + private static Vector3 Forward(Quaternion q) => Vector3.Transform(new Vector3(0, 0, 1), q); + private static Vector3 Left(Quaternion q) => Vector3.Transform(new Vector3(1, 0, 0), q); + private static Vector3 Up(Quaternion q) => Vector3.Transform(new Vector3(0, 1, 0), q); + + private static string FormatClock(float time) + { + int minutes = (int)time / 60; + int seconds = (int)time % 60; + int hundredths = (int)((time - (int)time) * 100f); + return $"{minutes:D2}:{seconds:D2}.{hundredths:D2}"; + } + + /// Matches EchoVRAPI.Frame.InArena, which keys off the map rather than match type. + private static bool IsArena(string? mapName) => mapName == "mpl_arena_a"; + + private static readonly string[] CombatMaps = + { "mpl_combat_fission", "mpl_combat_combustion", "mpl_combat_dyson", "mpl_combat_gauss" }; + + private static bool IsCombat(string? mapName) => mapName != null && CombatMaps.Contains(mapName); + + private static string TeamFromIndex(byte i) => i switch + { + 0 => "blue", + 1 => "orange", + 2 => "spectator", + _ => "none", + }; + + private static string PausedStateFromByte(byte b) => b switch + { + 0 => "unpaused", + 1 => "paused", + 2 => "unpausing", + 3 => "pausing", + _ => "unpaused", + }; + + private static string GameStatusFromByte(byte b) => b switch + { + 1 => "", + 2 => "pre_match", + 3 => "round_start", + 4 => "playing", + 5 => "score", + 6 => "round_over", + 7 => "post_match", + 8 => "pre_sudden_death", + 9 => "sudden_death", + 10 => "post_sudden_death", + _ => "NOT FOUND", + }; + + private static readonly string[] WeaponNames = { "rocket", "blaster", "scout", "assault" }; + private static readonly string[] OrdnanceNames = { "stun", "det", "burst", "arc" }; + private static readonly string[] TacModNames = { "sensor", "wraith", "heal", "shield" }; + + private static readonly string[] GoalTypes = + { + "unknown", "BOUNCE_SHOT", "INSIDE_SHOT", "LONG_BOUNCE_SHOT", "LONG_SHOT", + "SELF_GOAL", "SLAM_DUNK", "BUMPER_SHOT", "HEADBUTT", + }; + + private static string GoalTypeFromIndex(int i) => + i >= 0 && i < GoalTypes.Length ? GoalTypes[i].Replace("_", " ") : "unknown"; + + private static readonly string[] MapNames = + { + "uncoded", "mpl_lobby_b2", "mpl_arena_a", "mpl_combat_fission", + "mpl_combat_combustion", "mpl_combat_dyson", "mpl_combat_gauss", "mpl_tutorial_arena", + }; + + private static string MapNameFromIndex(int i) => + i >= 0 && i < MapNames.Length ? MapNames[i] : "uncoded"; + + private static string MatchTypeFor(string map, bool isPrivate) => map switch + { + "mpl_arena_a" => isPrivate ? "Echo_Arena_Private" : "Echo_Arena", + "mpl_combat_fission" or "mpl_combat_combustion" or "mpl_combat_dyson" or "mpl_combat_gauss" + => isPrivate ? "Echo_Combat_Private" : "Echo_Combat", + _ => "Unknown", + }; + + private static string? HoldingFromByte(byte b) => b switch + { + 255 => "none", + 254 => "geo", + 253 => "disc", + 252 => null, + _ => b.ToString(), + }; +} + +internal enum ButterCompression : byte +{ + None = 0, + Gzip = 1, + Zstd3 = 2, + Zstd7 = 3, + Zstd15 = 4, + Zstd22 = 5, + Zstd7Dict = 6, +} + +/// +/// Header player table that grows on demand. The stock decoder throws when a frame +/// references a slot beyond the recorded roster — which is exactly what happens when +/// somebody joins after recording began. Synthesising the slot keeps the replay readable. +/// +internal sealed class ButterHeaderV3 +{ + public ushort KeyframeInterval; + public ButterCompression Compression; + public string ClientName = ""; + public string SessionId = ""; + public string SessionIp = ""; + public int TotalRoundCount; + public int BlueRoundScore; + public int OrangeRoundScore; + public bool PrivateMatch; + public string MapName = "mpl_arena_a"; + public string MatchType = "Echo_Arena"; + + public readonly List Names = new(); + public readonly List UserIds = new(); + public readonly List Numbers = new(); + public readonly List Levels = new(); + + public int SynthesizedSlots { get; private set; } + + /// + /// Slots are 1-based on the wire; index 0 means "no player". A frame may reference a + /// slot past the recorded roster when somebody joined after recording began, so the + /// table is grown with a stable synthetic identity instead of throwing. + /// + private bool Resolve(byte slot, out int i) + { + i = slot - 1; + if (slot == 0) return false; + while (Names.Count <= i) + { + Names.Add($"Unknown player {Names.Count + 1}"); + UserIds.Add(-1000L - Names.Count); + Numbers.Add(0); + Levels.Add(0); + SynthesizedSlots++; + } + return true; + } + + public string NameAt(byte slot) => Resolve(slot, out int i) ? Names[i] : "[INVALID]"; + public long UserIdAt(byte slot) => Resolve(slot, out int i) ? UserIds[i] : 0; + public int NumberAt(byte slot) => Resolve(slot, out int i) ? Numbers[i] : -1; + public int LevelAt(byte slot) => Resolve(slot, out int i) ? Levels[i] : -1; +} diff --git a/Controllers/CameraWrite/CameraWriteController.cs b/Controllers/CameraWrite/CameraWriteController.cs index dba941b..bf1bc59 100644 --- a/Controllers/CameraWrite/CameraWriteController.cs +++ b/Controllers/CameraWrite/CameraWriteController.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Diagnostics; @@ -18,6 +18,7 @@ class CameraWriteController private static string BaseUrl => "http://127.0.0.1:" + (Program.spectateMeController.spectateMe ? SpectateMeController.SPECTATEME_PORT : "6721") + "/"; private static Dictionary playerCameraIndices = new Dictionary(); private static System.Timers.Timer autoFollowTimer; + public static GoProCamera goProCamera = new GoProCamera(); public const float Deg2Rad = 1 / 57.29578f; public const float Rad2Deg = 57.29578f; @@ -75,15 +76,20 @@ private static void StartAutoFollowTimer() autoFollowTimer.Elapsed += (sender, e) => AutoFollowPlayer(); autoFollowTimer.AutoReset = true; autoFollowTimer.Start(); - LogRow(LogType.File, "Started auto-follow timer for player: " + SparkSettings.instance.followPlayerName); + LogRow(LogType.File, SimpleSpectateController.LogFile, + "Started auto-follow timer for player: " + SparkSettings.instance.followPlayerName); } } + /// Who the last "Auto-following" line named, so the 2s timer doesn't repeat itself. + private static string lastAutoFollowLogged; + private static void StopAutoFollowTimer() { autoFollowTimer?.Stop(); autoFollowTimer?.Dispose(); autoFollowTimer = null; + lastAutoFollowLogged = null; } private static void AutoFollowPlayer() @@ -94,7 +100,15 @@ private static void AutoFollowPlayer() if (SparkSettings.instance.spectatorCamera == 3 && !string.IsNullOrEmpty(SparkSettings.instance.followPlayerName)) { - LogRow(LogType.File, "Auto-following player: " + SparkSettings.instance.followPlayerName); + // This runs every two seconds, so only say something when the target actually + // changes — otherwise it buries everything else in the log. + if (lastAutoFollowLogged != SparkSettings.instance.followPlayerName) + { + lastAutoFollowLogged = SparkSettings.instance.followPlayerName; + LogRow(LogType.File, SimpleSpectateController.LogFile, + "Auto-following player: " + SparkSettings.instance.followPlayerName); + } + SpectatorCamFindPlayer(SparkSettings.instance.followPlayerName, null, true); } else @@ -138,7 +152,11 @@ public static void UseCameraControlKeys() StopAutoFollowTimer(); FollowDischolder(null, SparkSettings.instance.discHolderFollowCamMode == 1, SparkSettings.instance.discHolderFollowRestrictTeam); break; + case 5: + StopAutoFollowTimer(); + break; } + goProCamera.Enabled = SparkSettings.instance.spectatorCamera == 5; } catch (Exception ex) { diff --git a/Controllers/CameraWrite/GoProCamera.cs b/Controllers/CameraWrite/GoProCamera.cs new file mode 100644 index 0000000..81b8418 --- /dev/null +++ b/Controllers/CameraWrite/GoProCamera.cs @@ -0,0 +1,73 @@ +using System; +using System.Numerics; +using System.Threading.Tasks; +using EchoVRAPI; + +namespace Spark +{ + public class GoProCamera : CameraModule + { + private Vector3 smoothPos = Vector3.Zero; + private Quaternion smoothRot = Quaternion.Identity; + private bool initialized = false; + + protected override Task Update(CameraTransform cameraTransform, float deltaTime) + { + // Use the already-fetched frame to avoid network jitter + Frame frame = Program.lastFrame; + if (frame == null) return Task.CompletedTask; + + // Get the target player + string playerName = SparkSettings.instance.goProPlayerName; + Player targetPlayer = null; + + if (!string.IsNullOrEmpty(playerName)) + { + targetPlayer = frame.GetPlayer(playerName); + } + + if (targetPlayer == null) + { + targetPlayer = frame.GetPlayer(frame.client_name); + } + + if (targetPlayer == null) return Task.CompletedTask; + + // Latch directly to the hand position with a small offset right and up + Vector3 handPos = SparkSettings.instance.goProTargetHand == 0 + ? targetPlayer.lhand.Position + : targetPlayer.rhand.Position; + + Vector3 camPos = handPos + new Vector3(0.15f, 0.2f, 0f); + + // Use the player's head direction directly — same as POV mode + // Build the rotation from the head's forward and up vectors + Vector3 headForward = targetPlayer.head.forward.ToVector3(); + Vector3 headUp = targetPlayer.head.up.ToVector3(); + if (headForward.LengthSquared() < 0.001f) headForward = Vector3.UnitZ; + if (headUp.LengthSquared() < 0.001f) headUp = Vector3.UnitY; + Quaternion camRot = CameraWriteController.QuaternionLookRotation(headForward, headUp); + + // Initialize on first frame to avoid lerping from origin + if (!initialized) + { + smoothPos = camPos; + smoothRot = camRot; + initialized = true; + } + + // Smooth — higher value = more responsive, lower = smoother + float t = Math.Clamp(6f * deltaTime, 0f, 1f); + smoothPos = Vector3.Lerp(smoothPos, camPos, t); + smoothRot = Quaternion.Slerp(smoothRot, camRot, t); + + cameraTransform.Position = smoothPos; + cameraTransform.Rotation = smoothRot; + + // Convert FOV to radians + cameraTransform.fovy = SparkSettings.instance.goProWiderFov * CameraWriteController.Deg2Rad; + + return Task.CompletedTask; + } + } +} diff --git a/Controllers/EchoGPController.cs b/Controllers/EchoGPController.cs index 9b1c890..5ddd1d6 100644 --- a/Controllers/EchoGPController.cs +++ b/Controllers/EchoGPController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Numerics; @@ -153,14 +153,7 @@ public enum State } }; - [Serializable] - [Obsolete] - public class TriggerRegion - { - public Vector3 position; - public Vector3 eulerAngles; - public Vector3 scale; - } + [Serializable] public class TriggerSphere @@ -325,74 +318,7 @@ private static bool InTrigger(Frame f, TriggerSphere region) return distance < region.radius / 2f; } - private static bool InTrigger(Frame f, TriggerRegion region) - { - (Vector3 pos, _) = f.GetCameraTransform(); - - (pos.X, pos.Z) = (pos.Z, pos.X); - - float distance = Vector3.Distance(region.position, pos); - float maxRadius = MathF.Max(MathF.Max(region.scale.X, region.scale.Y), region.scale.Z); - - return distance < maxRadius; - } - - private static bool InTrigger3(Frame f, TriggerRegion region) - { - // https://stackoverflow.com/a/53559963 - - Vector3 I = region.position; - (Vector3 P, Quaternion rotation) = f.GetCameraTransform(); - - // var xLocal = - - // b1,b2,b3,b4,t1,t2,t3,t4 = cube3d - // - // dir1 = (t1-b1) - // size1 = np.linalg.norm(dir1) - // dir1 = dir1 / size1 - // - // dir2 = (b2-b1) - // size2 = np.linalg.norm(dir2) - // dir2 = dir2 / size2 - // - // dir3 = (b4-b1) - // size3 = np.linalg.norm(dir3) - // dir3 = dir3 / size3 - // - // cube3d_center = (b1 + t3)/2.0 - // - // dir_vec = points - cube3d_center - // - // res1 = np.where( (np.absolute(np.dot(dir_vec, dir1)) * 2) > size1 )[0] - // res2 = np.where( (np.absolute(np.dot(dir_vec, dir2)) * 2) > size2 )[0] - // res3 = np.where( (np.absolute(np.dot(dir_vec, dir3)) * 2) > size3 )[0] - // - // return list( set().union(res1, res2, res3) ) - return false; - } - - private static bool InTrigger2(Frame f, TriggerRegion region) - { - (Vector3 pos, Quaternion rot) = f.GetCameraTransform(); - - (pos.X, pos.Z) = (pos.Z, pos.X); - - - Vector3 localSpace = InverseTransformPoint(region.position, region.eulerAngles, Vector3.One, pos); - // Matrix4x4 rotMatrix = GetRotationMatrix(region.eulerAngles); - // Matrix4x4.Invert(rotMatrix, out Matrix4x4 inverse); - // Vector3 localSpace = Vector3.Transform(pos - region.position, inverse); - - return MathF.Abs(localSpace.X) < region.scale.X && - MathF.Abs(localSpace.Y) < region.scale.Y && - MathF.Abs(localSpace.Z) < region.scale.Z; - - // return MathF.Abs(localSpace.X) < 1 && - // MathF.Abs(localSpace.Y) < 1 && - // MathF.Abs(localSpace.Z) < 1; - } private void Start() { diff --git a/Controllers/EchoLogSessionReader.cs b/Controllers/EchoLogSessionReader.cs new file mode 100644 index 0000000..45255f2 --- /dev/null +++ b/Controllers/EchoLogSessionReader.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Spark +{ + /// + /// Pulls the current session id straight out of EchoVR's own r14 log. + /// + /// The /session API only answers once you're in a *match*: a social lobby comes back as error + /// code -6 with no session id at all, so a friend sitting in a lobby has nothing joinable to + /// advertise and nobody can drop in on them. The client does record every room it's in, as an + /// Oculus room data-store update: + /// + /// {"category":"social","provider":"ovr","message":"ovr_Room_UpdateDataStore", + /// "data":{"seqid":8,"lobbyid":"B6B4E0BC-3185-4684-85BF-C6920268E19B", ...}} + /// + /// The most recent "lobbyid" is the room you're in now, and the client writes the all-zero guid + /// when you leave. Matches still take their id from the API — that's authoritative and already + /// correct — and this only covers the lobby case. + /// + /// Checked against 62 real logs. Two other markers look usable and are not: + /// + /// - "[NSLOBBY] requesting N player session in game session {guid}" is only written when the + /// client actively matchmakes a session, not when it's placed in one or rejoins. It was + /// absent entirely in 34 of the 62 logs where lobbyid had the right answer, and stale in + /// one more. There was no log where it knew something lobbyid didn't. + /// - "lobby_id" (underscore) inside ovr_RichPresence_Set lines is frequently the all-zero + /// guid *while you're in a room*, so matching it loosely would wipe the real value. The + /// pattern below is deliberately strict about the key name. + /// + public static class EchoLogSessionReader + { + private static readonly Regex lobbyIdRegex = new Regex( + @"""lobbyid""\s*:\s*""([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})""", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex httpPortRegex = new Regex( + @"Bound HTTP listener to\s+[0-9.]+:(\d+)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// What the client writes for lobbyid once you've left the room. + private const string NullSessionId = "00000000-0000-0000-0000-000000000000"; + + /// Beyond this the first read is capped to the tail, to keep startup off a huge log. + private const long MaxFirstReadBytes = 8 * 1024 * 1024; + + /// Most a single poll will pull in, so one slow catch-up can't stall the caller. + private const long MaxChunkBytes = 4 * 1024 * 1024; + + /// + /// Skip anything longer than this. Room updates measured 325-370 characters across the real + /// logs, so this leaves generous headroom while still skipping the profile dumps, which are + /// the only genuinely large lines. + /// + private const int MaxScannedLineLength = 2048; + + /// How often the background watcher re-reads the log. + private const int PollIntervalMs = 1000; + + private static readonly object stateLock = new object(); + private static bool watching; + private static string currentLogPath; + private static long readPosition; + private static string sessionId; + private static DateTime lastDirScan = DateTime.MinValue; + + /// + /// The room the local client is in according to its log, or null when it has left. + /// + /// This is a cached read — the file work happens on the background watcher started by the + /// first caller — so it's safe to call from the UI thread every frame. + /// + public static string CurrentSessionId + { + get + { + EnsureWatching(); + lock (stateLock) + { + return sessionId; + } + } + } + + /// + /// Starts the background watcher if it isn't already going. Reading the log means hitting + /// the disk, which mustn't happen on the UI thread, so the value is refreshed off-thread and + /// callers only ever read what it last found. + /// + private static void EnsureWatching() + { + lock (stateLock) + { + if (watching) return; + watching = true; + } + + Task.Run(async () => + { + try + { + while (Program.running) + { + Poll(); + await Task.Delay(PollIntervalMs); + } + } + finally + { + lock (stateLock) { watching = false; } + } + }); + } + + /// The log folder that goes with the configured echovr.exe, or null if unset/missing. + public static string LogDirectory + { + get + { + try + { + string exePath = SparkSettings.instance?.echoVRPath; + if (string.IsNullOrEmpty(exePath)) return null; + + string exeDir = Path.GetDirectoryName(exePath); + if (string.IsNullOrEmpty(exeDir)) return null; + + // ...\ready-at-dawn-echo-arena\bin\win10\echovr.exe -> ...\ready-at-dawn-echo-arena\_local\r14logs + string dir = Path.GetFullPath(Path.Combine(exeDir, "..", "..", "_local", "r14logs")); + return Directory.Exists(dir) ? dir : null; + } + catch (Exception) + { + return null; + } + } + } + + /// + /// Log reading only describes the game running on *this* PC, so it's meaningless when Spark + /// is pointed at a Quest across the network. + /// + public static bool WatchingLocalGame + { + get + { + string ip = SparkSettings.instance?.echoVRIP; + return string.IsNullOrEmpty(ip) || ip == "127.0.0.1" || ip == "localhost" || ip == "::1"; + } + } + + public static void Reset() + { + lock (stateLock) + { + currentLogPath = null; + readPosition = 0; + sessionId = null; + lastDirScan = DateTime.MinValue; + } + } + + private static void Poll() + { + if (!WatchingLocalGame) + { + lock (stateLock) { sessionId = null; } + return; + } + + try + { + string path = FindActiveLogFile(); + if (path == null) + { + lock (stateLock) { sessionId = null; } + return; + } + + bool fresh; + long startAt; + lock (stateLock) + { + fresh = !string.Equals(path, currentLogPath, StringComparison.OrdinalIgnoreCase); + if (fresh) + { + currentLogPath = path; + readPosition = 0; + sessionId = null; + } + startAt = readPosition; + } + + using FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + + // A log that was rotated or truncated under us restarts from the top. + if (startAt > stream.Length) startAt = 0; + + // Don't chew through a several-hundred-megabyte log on the first read; the tail still + // carries the most recent join, and any session change after this is picked up live. + if (fresh && stream.Length > MaxFirstReadBytes) startAt = stream.Length - MaxFirstReadBytes; + + long available = Math.Min(stream.Length - startAt, MaxChunkBytes); + if (available <= 0) return; + + stream.Seek(startAt, SeekOrigin.Begin); + byte[] buffer = new byte[available]; + int read = stream.Read(buffer, 0, (int)available); + if (read <= 0) return; + + string text = Encoding.UTF8.GetString(buffer, 0, read); + + // The game is writing this file as we read it, so the tail is very often a half- + // written line. Leave it for the next poll — consuming it would split a session id + // across two reads and neither half would match. + int lastNewline = text.LastIndexOf('\n'); + long consumed; + if (lastNewline >= 0) + { + text = text.Substring(0, lastNewline + 1); + consumed = Encoding.UTF8.GetByteCount(text); + } + else if (read >= MaxChunkBytes) + { + // A single line longer than the whole chunk. It can't be one we care about + // (those are short), so skip past it rather than re-reading it forever. + text = string.Empty; + consumed = read; + } + else + { + // Nothing complete yet — wait for the rest of the line. + return; + } + + string found = null; + bool sawLobbyId = false; + foreach (string line in text.Split('\n')) + { + // The room updates run to a few hundred characters; the profile JSON the client + // dumps runs to hundreds of KB on a single line and can't hold this pattern. + if (line.Length > MaxScannedLineLength) continue; + + Match m = lobbyIdRegex.Match(line); + if (!m.Success) continue; + + // Last one in the chunk wins — including the all-zero guid, which is how the + // client says you've left rather than moved. + string id = m.Groups[1].Value.ToUpperInvariant(); + sawLobbyId = true; + found = id == NullSessionId ? null : id; + } + + lock (stateLock) + { + readPosition = startAt + consumed; + + // Only overwrite when this chunk actually said something about the room; a chunk + // with no lobbyid line at all means "no news", not "you've left". + if (sawLobbyId) sessionId = found; + } + } + catch (IOException) + { + // Log locked mid-write — the next poll picks up where this one left off. + } + catch (Exception e) + { + Logger.LogRow(Logger.LogType.Error, $"Error reading EchoVR log for session id.\n{e}"); + } + } + + /// + /// Picks the log belonging to the client Spark is actually watching. Spectator clients write + /// into the same folder, so "newest file" alone would follow the wrong one whenever two + /// clients are up; the HTTP port each one binds tells them apart. + /// + private static string FindActiveLogFile() + { + string dir = LogDirectory; + if (dir == null) return null; + + // Listing the directory is the expensive part, so only redo it every few seconds. In + // between, keep tailing the file already open unless it has gone away. + lock (stateLock) + { + if (currentLogPath != null && + (DateTime.UtcNow - lastDirScan).TotalSeconds < 5 && + File.Exists(currentLogPath)) + { + return currentLogPath; + } + lastDirScan = DateTime.UtcNow; + } + + List candidates = new DirectoryInfo(dir) + .GetFiles("*.log") + .Where(f => (DateTime.Now - f.LastWriteTime).TotalHours < 24) + .OrderByDescending(f => f.LastWriteTime) + .Take(6) + .ToList(); + + if (candidates.Count == 0) return null; + if (candidates.Count == 1) return candidates[0].FullName; + + int wantedPort = SparkSettings.instance?.echoVRPort ?? 6721; + foreach (FileInfo file in candidates) + { + if (BoundHttpPort(file.FullName) == wantedPort) return file.FullName; + } + + return candidates[0].FullName; + } + + /// The HTTP API port this log's client bound, or -1 if it never got that far. + private static int BoundHttpPort(string path) + { + try + { + using FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using StreamReader reader = new StreamReader(stream); + + // The bind happens during startup, well inside the first few hundred lines. + for (int i = 0; i < 1500; i++) + { + string line = reader.ReadLine(); + if (line == null) break; + if (line.Length > 512) continue; + + Match m = httpPortRegex.Match(line); + if (m.Success && int.TryParse(m.Groups[1].Value, out int port)) return port; + } + } + catch (Exception) + { + // An unreadable log just doesn't win the port match. + } + + return -1; + } + } +} diff --git a/Controllers/FriendsPresence.cs b/Controllers/FriendsPresence.cs new file mode 100644 index 0000000..adb9b8e --- /dev/null +++ b/Controllers/FriendsPresence.cs @@ -0,0 +1,438 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Spark +{ + /// + /// Talks to the Spark friends bot. Presence goes up from whichever Spark is watching the game, + /// and comes back down to anything that wants to know where a friend is — the Friends tab and + /// Simple Spectate mode both read from here. + /// + /// The bot is the only route to a *private* match or a lobby: those never show up in the public + /// match API, but the player's own Spark knows the session id and publishes it. + /// + public static class FriendsPresence + { + public const string SessionTypeMatch = "match"; + public const string SessionTypeLobby = "lobby"; + public const string SessionTypeMenu = "menu"; + + /// How long a presence row stays fresh on the bot. Heartbeat has to beat this. + private const int HeartbeatSeconds = 30; + + /// + /// How often the heartbeat wakes up. Shorter than because the + /// lobby id can land in the log a moment after the game reports being in a lobby, and a + /// friend waiting to join shouldn't sit on a stale id for half a minute. + /// + private const int HeartbeatTickSeconds = 5; + + /// + /// How long to keep asking the log for a lobby id after entering a lobby, and how often. + /// The first read starts the log watcher and so always comes back empty, and the client can + /// take a moment to write the room line, so the id is rarely there on the first attempt. + /// + private const int LobbyIdRetryMs = 1000; + private const int LobbyIdAttempts = 20; + + private static readonly HttpClient http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + private static readonly object stateLock = new object(); + + private static string BotUrl => SecretKeys.FRIENDS_BOT_URL; + + public static bool Configured => DiscordOAuth.IsLoggedIn && !string.IsNullOrEmpty(DiscordOAuth.oauthToken); + + /// + /// Set while this Spark is running as a spectator. Two PCs signed into the same Discord + /// account share one presence row, so the spectating copy has to keep quiet or it overwrites + /// the playing copy's session id with its own — and then follows itself in circles. + /// + public static bool SuppressPresencePush { get; set; } + + // Last thing we published, replayed by the heartbeat so the row never goes stale mid-match. + private static string lastLobbyId; + private static string lastTeam; + private static string lastMode; + private static string lastSessionType = SessionTypeMenu; + private static bool offline = true; + private static bool heartbeatRunning; + + // Registration is per Discord token, so a logout/login re-registers rather than reusing a + // row the new account doesn't own. + private static Task registerTask; + private static string registeredForToken; + + /// Last thing we logged publishing, so the heartbeat doesn't repeat itself. + private static string lastLoggedSignature; + + /// + /// Bumped by every push. The lobby push reads the log off-thread, so this lets it drop its + /// result if the game already moved on to something else while it was reading. + /// + private static int pushGeneration; + + // ─── Requests ────────────────────────────────────────────────────────── + + private static HttpRequestMessage MakeRequest(HttpMethod method, string path, object body = null) + { + HttpRequestMessage req = new HttpRequestMessage(method, BotUrl + path); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", DiscordOAuth.oauthToken); + if (body != null) + { + req.Content = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"); + } + return req; + } + + private static async Task SendAsync(HttpRequestMessage req) + { + HttpResponseMessage resp = await http.SendAsync(req); + string content = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) throw new Exception(content); + return JObject.Parse(content); + } + + // ─── Reading ─────────────────────────────────────────────────────────── + + /// Registers this Discord account with the bot and returns the friend code. + public static async Task RegisterAsync(string echoUsername) + { + using HttpRequestMessage req = MakeRequest(HttpMethod.Post, "/friends/register", new { echo_username = echoUsername }); + JObject result = await SendAsync(req); + return result["friend_code"]?.ToString(); + } + + /// + /// Makes sure this account has a row on the bot, registering it once per login if not. + /// + /// Presence updates are an UPDATE against an existing row, so without this anyone who never + /// opened the Friends tab would push into nowhere and simply never show up for their friends + /// — and the tab is off by default, so that's most people. Cheap and idempotent after the + /// first call, which is why every push goes through it. + /// + public static Task EnsureRegisteredAsync() + { + lock (stateLock) + { + if (!Configured) return Task.FromResult(null); + + // Single-flight: several pushes can land at once on startup, and they should share + // one registration rather than racing to create the same row. + if (registerTask == null || registeredForToken != DiscordOAuth.oauthToken) + { + registeredForToken = DiscordOAuth.oauthToken; + registerTask = RegisterOnceAsync(); + } + + return registerTask; + } + } + + private static async Task RegisterOnceAsync() + { + try + { + string echoUsername = null; + try { echoUsername = Program.lastFrame?.client_name; } catch (Exception) { } + if (string.IsNullOrEmpty(echoUsername)) echoUsername = SparkSettings.instance?.client_name; + + string code = await RegisterAsync(echoUsername); + if (!string.IsNullOrEmpty(code) && SparkSettings.instance != null && + SparkSettings.instance.myFriendCode != code) + { + SparkSettings.instance.myFriendCode = code; + SparkSettings.instance.Save(); + } + return code; + } + catch (Exception e) + { + Console.WriteLine("FriendsPresence: register failed: " + e.Message); + + // Don't cache a failure — the next push should be free to try again. + lock (stateLock) { registerTask = null; } + return null; + } + } + + public static async Task GetFriendsAsync() + { + using HttpRequestMessage req = MakeRequest(HttpMethod.Get, "/friends/list"); + JObject result = await SendAsync(req); + return result["friends"] as JArray; + } + + /// + /// This account's own presence row. On the spectating PC that's the *playing* PC's session, + /// which is exactly what makes "spectate myself across two PCs" work. + /// + public static async Task GetMeAsync() + { + using HttpRequestMessage req = MakeRequest(HttpMethod.Get, "/friends/me"); + return await SendAsync(req); + } + + public static async Task LookupAsync(string friendCode) + { + using HttpRequestMessage req = MakeRequest(HttpMethod.Get, $"/friends/lookup/{friendCode}"); + return await SendAsync(req); + } + + public static async Task AddFriendAsync(string friendCode) + { + using HttpRequestMessage req = MakeRequest(HttpMethod.Post, $"/friends/add/{friendCode}"); + await SendAsync(req); + } + + public static async Task RemoveFriendAsync(string friendCode) + { + using HttpRequestMessage req = MakeRequest(HttpMethod.Delete, $"/friends/remove/{friendCode}"); + await SendAsync(req); + } + + public static async Task SetVisibilityAsync(bool isPublic) + { + using HttpRequestMessage req = MakeRequest(HttpMethod.Post, "/friends/visibility", new { @public = isPublic }); + await SendAsync(req); + } + + // ─── Publishing ──────────────────────────────────────────────────────── + + /// In a match — the session id from the game API is authoritative here. + public static void PushMatch(string sessionId, string team, string mode) + { + Publish(NextGeneration(), sessionId, team, mode, SessionTypeMatch); + } + + /// + /// In a social lobby. The API has no session id to give in this state, so it comes from the + /// client's log instead — without it friends can see you're in a lobby but can't join you. + /// + public static void PushLobby() + { + // Reading the log touches the disk and this is called from the game-polling loop, so + // keep it off that thread. + int generation = NextGeneration(); + Task.Run(async () => + { + // Announce the lobby immediately so friends see the state without waiting, even if + // there is no id to go with it yet. + string id = EchoLogSessionReader.CurrentSessionId; + Publish(generation, id, null, "In Lobby", SessionTypeLobby); + if (!string.IsNullOrEmpty(id)) return; + + // Then chase the id. Publishing null and leaving it to the heartbeat meant a friend + // showed as "In Lobby" but greyed out for the first few seconds - and permanently, + // if the room line landed between heartbeat ticks and the id never changed again. + for (int attempt = 0; attempt < LobbyIdAttempts; attempt++) + { + await Task.Delay(LobbyIdRetryMs); + + lock (stateLock) + { + // Left the lobby, or a newer push superseded this one. + if (generation != pushGeneration) return; + } + + id = EchoLogSessionReader.CurrentSessionId; + if (string.IsNullOrEmpty(id)) continue; + + // Publish re-checks the generation under the lock before sending. + Publish(generation, id, null, "In Lobby", SessionTypeLobby); + return; + } + + Logger.LogRow(Logger.LogType.Error, + "FriendsPresence: no lobby id found in the EchoVR log after " + + (LobbyIdAttempts * LobbyIdRetryMs / 1000) + "s; friends will see the lobby " + + "but will not be able to join it."); + }); + } + + public static void PushMenu() + { + Publish(NextGeneration(), null, null, "In Menu", SessionTypeMenu); + } + + public static void PushOffline() + { + lock (stateLock) + { + pushGeneration++; + offline = true; + lastLoggedSignature = null; + lastLobbyId = null; + lastTeam = null; + lastMode = null; + lastSessionType = SessionTypeMenu; + } + + if (!Configured || SuppressPresencePush) return; + + Task.Run(async () => + { + try + { + await EnsureRegisteredAsync(); + using HttpRequestMessage req = MakeRequest(HttpMethod.Post, "/friends/offline"); + using HttpResponseMessage resp = await http.SendAsync(req); + Report("offline", null, resp.IsSuccessStatusCode ? null : $"HTTP {(int)resp.StatusCode}"); + } + catch (Exception e) + { + Report("offline", null, e.Message); + } + }); + } + + private static int NextGeneration() + { + lock (stateLock) + { + return ++pushGeneration; + } + } + + private static void Publish(int generation, string sessionId, string team, string mode, string sessionType) + { + lock (stateLock) + { + // A newer push already landed while we were working out what to say — drop this one. + if (generation != pushGeneration) return; + + offline = false; + lastLobbyId = sessionId; + lastTeam = team; + lastMode = mode; + lastSessionType = sessionType; + } + + Send(sessionId, team, mode, sessionType); + StartHeartbeat(); + } + + private static void Send(string sessionId, string team, string mode, string sessionType) + { + if (!Configured || SuppressPresencePush) return; + + Task.Run(async () => + { + try + { + await EnsureRegisteredAsync(); + using HttpRequestMessage req = MakeRequest(HttpMethod.Post, "/friends/lobby", new + { + lobby_id = sessionId, + team, + mode, + session_type = sessionType, + }); + using HttpResponseMessage resp = await http.SendAsync(req); + Report(sessionType, sessionId, resp.IsSuccessStatusCode ? null : $"HTTP {(int)resp.StatusCode}"); + } + catch (Exception e) + { + Report(sessionType, sessionId, e.Message); + } + }); + } + + /// + /// Logs the *outcome* of a push, not the attempt. + /// + /// This used to log before the request was even sent, so a rejected push — an expired Discord + /// token, the bot down — looked exactly like a successful one, which is worse than silence + /// when you're trying to work out why a friend can't see you. Successes are logged only when + /// what we advertise changes, since the heartbeat repeats itself every 30s; failures are + /// always logged. + /// + private static void Report(string sessionType, string sessionId, string error) + { + string what = $"{sessionType} session {sessionId ?? "(none)"}"; + + if (error != null) + { + lock (stateLock) { lastLoggedSignature = null; } + Logger.LogRow(Logger.LogType.File, SimpleSpectateController.LogFile, + $"Friends presence: FAILED to publish {what} — {error}"); + return; + } + + string signature = $"{sessionType}|{sessionId}"; + lock (stateLock) + { + if (signature == lastLoggedSignature) return; + lastLoggedSignature = signature; + } + + Logger.LogRow(Logger.LogType.File, SimpleSpectateController.LogFile, + $"Friends presence: published {what}."); + } + + /// + /// Presence used to be pushed only when the connection state changed, so anyone who stayed in + /// one match longer than the bot's staleness window silently dropped to "offline" for their + /// friends. Re-sending on a timer keeps the row alive, and re-reads the lobby id from the log + /// so a lobby that changes underneath us is picked up too. + /// + private static void StartHeartbeat() + { + lock (stateLock) + { + if (heartbeatRunning) return; + heartbeatRunning = true; + } + + Task.Run(async () => + { + DateTime lastSend = DateTime.UtcNow; + + while (Program.running) + { + await Task.Delay(HeartbeatTickSeconds * 1000); + + try + { + string sessionId, team, mode, sessionType; + lock (stateLock) + { + if (offline) continue; + sessionId = lastLobbyId; + team = lastTeam; + mode = lastMode; + sessionType = lastSessionType; + } + + bool changed = false; + if (sessionType == SessionTypeLobby) + { + string fromLog = EchoLogSessionReader.CurrentSessionId; + if (fromLog != sessionId) + { + sessionId = fromLog; + changed = true; + lock (stateLock) { lastLobbyId = sessionId; } + } + } + + if (!changed && (DateTime.UtcNow - lastSend).TotalSeconds < HeartbeatSeconds) continue; + + lastSend = DateTime.UtcNow; + Send(sessionId, team, mode, sessionType); + } + catch (Exception e) + { + Console.WriteLine("FriendsPresence: heartbeat error: " + e.Message); + } + } + + lock (stateLock) { heartbeatRunning = false; } + }); + } + } +} diff --git a/Controllers/MediaController.cs b/Controllers/MediaController.cs index 080c2c3..788073f 100644 --- a/Controllers/MediaController.cs +++ b/Controllers/MediaController.cs @@ -359,7 +359,13 @@ private void CheckButtonActions() { if ((currentTime - _lastReleaseTime) < debounce) { - _lastState = currentState; + // Don't advance _lastState here — leaving it at 0 means this same + // rising edge gets re-evaluated (and re-debounced) on the next poll + // tick instead of being silently latched as a press. Setting it to 1 + // used to fool the "sustained press" branch below into computing + // `held` against a stale _pressStartTime from a previous press, + // which could fire ProcessHold() instantly or swallow a click + // entirely during fast multi-click patterns. return; } diff --git a/Controllers/OBS.cs b/Controllers/OBS.cs index 29a074c..e198de5 100644 --- a/Controllers/OBS.cs +++ b/Controllers/OBS.cs @@ -1,4 +1,4 @@ -using OBSWebsocketDotNet; +using OBSWebsocketDotNet; using System; using System.Collections.Generic; using System.Diagnostics; @@ -261,7 +261,7 @@ private void OnConnect(object sender, EventArgs e) ws.StartReplayBuffer(); } } - catch (Exception exp) + catch (Exception) { Debug.WriteLine("Replay buffer not enabled in startup"); } diff --git a/Controllers/ReplayFilesManager.cs b/Controllers/ReplayFilesManager.cs index 41b27f2..b653ae5 100644 --- a/Controllers/ReplayFilesManager.cs +++ b/Controllers/ReplayFilesManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -17,6 +17,7 @@ public class ReplayFilesManager : IDisposable { private ButterFile butter; public string fileName; + private ulong tapeHandle = 0; private readonly object butterWritingLock = new object(); private readonly object fileWritingLock = new object(); @@ -41,9 +42,10 @@ public class ReplayFilesManager : IDisposable private const string fileNameFormat = "rec_yyyy-MM-dd_HH-mm-ss"; private int lastButterNumChunks; - private static readonly List fullDeltaTimes = new List { 33.3333333f, 66.666666f, 100 }; - private static int FrameInterval => Math.Clamp((int)(fullDeltaTimes[SparkSettings.instance.targetDeltaTimeIndexFull] / Program.StatsIntervalMs), 1, 10000); - private int frameIndex; + private static readonly List fullDeltaTimes = new List { 16.6666666f, 33.3333333f, 100f }; + private static float TargetDeltaTime => SparkSettings.instance.targetDeltaTimeIndexFull < fullDeltaTimes.Count ? fullDeltaTimes[SparkSettings.instance.targetDeltaTimeIndexFull] : 33.3333333f; + private DateTime lastRecordedEchoreplayTime = DateTime.MinValue; + private DateTime lastRecordedButterTime = DateTime.MinValue; public ReplayFilesManager() { @@ -78,9 +80,13 @@ public ReplayFilesManager() }; Program.SparkClosing += () => { - // Stop accepting new writes, but allow buffer to drain - writeQueue.CompleteAdding(); + // Queue the final flush BEFORE closing the queue. Split() begins with + // `if (writeQueue.IsAddingCompleted) return;`, so completing first made the + // shutdown split a no-op: the butter file was never written out, the tape never + // closed and the last .echoreplay never zipped. Now the flush is queued, then + // adding is closed so nothing new arrives while the buffer drains. Split(); + writeQueue.CompleteAdding(); }; } @@ -112,15 +118,14 @@ private void FileWritingLoop() private void AddEchoreplayFrame(DateTime timestamp, string session, string bones) { - frameIndex++; - if (!SparkSettings.instance.enableReplayBuffer) { if (!SparkSettings.instance.enableFullLogging) return; - if (!SparkSettings.instance.saveEchoreplayFiles) return; + if (!SparkSettings.instance.saveEchoreplayFiles && !SparkSettings.instance.saveTapeFiles) return; } - if (frameIndex % FrameInterval != 0) return; + if ((timestamp - lastRecordedEchoreplayTime).TotalMilliseconds < TargetDeltaTime * 0.85f) return; + lastRecordedEchoreplayTime = timestamp; try { @@ -143,11 +148,13 @@ private void AddEchoreplayFrame(DateTime timestamp, string session, string bones string lineToWrite = bones != null ? $"{timestamp.ToString(echoreplayDateFormat)}\t{session}\t{bones}" : $"{timestamp.ToString(echoreplayDateFormat)}\t{session}"; + + long unixTimeMs = ((DateTimeOffset)timestamp).ToUnixTimeMilliseconds(); // Offload IO to background thread without blocking main thread if (!writeQueue.IsAddingCompleted) { - writeQueue.Add(() => WriteEchoreplayLineDirect(lineToWrite)); + writeQueue.Add(() => WriteEchoreplayLineDirect(lineToWrite, session, unixTimeMs)); } } } @@ -178,7 +185,8 @@ private void AddButterFrame(Frame f) if (!SparkSettings.instance.enableFullLogging) return; if (!SparkSettings.instance.saveButterFiles) return; - if (frameIndex % FrameInterval != 0) return; + if ((f.recorded_time - lastRecordedButterTime).TotalMilliseconds < TargetDeltaTime * 0.85f) return; + lastRecordedButterTime = f.recorded_time; if (!writeQueue.IsAddingCompleted) { @@ -210,17 +218,25 @@ private void WriteOutButterFile() } } - private void WriteEchoreplayLineDirect(string line) + private void WriteEchoreplayLineDirect(string line, string session, long unixTimeMs) { lock (fileWritingLock) { if (!Directory.Exists(SparkSettings.instance.saveFolder)) return; - string filePath = Path.Combine(SparkSettings.instance.saveFolder, fileName + ".echoreplay"); + if (SparkSettings.instance.saveEchoreplayFiles) + { + string filePath = Path.Combine(SparkSettings.instance.saveFolder, fileName + ".echoreplay"); + + using (StreamWriter streamWriter = new StreamWriter(filePath, true)) + { + streamWriter.WriteLine(line); + } + } - using (StreamWriter streamWriter = new StreamWriter(filePath, true)) + if (SparkSettings.instance.saveTapeFiles && tapeHandle != 0) { - streamWriter.WriteLine(line); + TapeFFI.TapeWriteFrame(tapeHandle, unixTimeMs, session); } } } @@ -278,48 +294,73 @@ public void Split() { if (writeQueue.IsAddingCompleted) return; - writeQueue.Add(() => + writeQueue.Add(() => { splitting = true; - - lock (butterWritingLock) - { - WriteOutButterFile(); - butter = new ButterFile(compressionFormat: SparkSettings.instance.butterCompressionFormat); - lastButterNumChunks = 0; - } - lock (fileWritingLock) + // Everything below can throw: a full disk, a save folder that has been moved or + // unmounted, a file another process is holding open. FileWritingLoop catches and + // logs that so the writer thread survives — but without this finally the flag + // resets were skipped and splitting stayed true for the rest of the process. + // GentleClose waits on these flags, so a single failed write left every later + // shutdown stuck on "Compressing Replay File..." with nothing left to compress. + try { - string lastFilename = fileName; - fileName = DateTime.Now.ToString(fileNameFormat); + lock (butterWritingLock) + { + WriteOutButterFile(); + butter = new ButterFile(compressionFormat: SparkSettings.instance.butterCompressionFormat); + lastButterNumChunks = 0; + } - if (SparkSettings.instance.useCompression && !string.IsNullOrEmpty(lastFilename)) + lock (fileWritingLock) { - string oldFile = Path.Combine(SparkSettings.instance.saveFolder, lastFilename + ".echoreplay"); - if (File.Exists(oldFile)) + string lastFilename = fileName; + fileName = DateTime.Now.ToString(fileNameFormat); + + if (tapeHandle != 0) { - zipping = true; - try + TapeFFI.TapeClose(tapeHandle); + tapeHandle = 0; + } + + if (SparkSettings.instance.saveTapeFiles && !string.IsNullOrEmpty(SparkSettings.instance.saveFolder) && Directory.Exists(SparkSettings.instance.saveFolder)) + { + string tapePath = Path.Combine(SparkSettings.instance.saveFolder, fileName + ".tape"); + tapeHandle = TapeFFI.TapeCreate(tapePath, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + + if (SparkSettings.instance.useCompression && !string.IsNullOrEmpty(lastFilename)) + { + string oldFile = Path.Combine(SparkSettings.instance.saveFolder, lastFilename + ".echoreplay"); + if (File.Exists(oldFile)) { - string tempDir = Path.Combine(SparkSettings.instance.saveFolder, "temp_zip_" + Guid.NewGuid()); - Directory.CreateDirectory(tempDir); + zipping = true; + try + { + string tempDir = Path.Combine(SparkSettings.instance.saveFolder, "temp_zip_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); - string destFile = Path.Combine(tempDir, lastFilename + ".echoreplay"); - File.Move(oldFile, destFile); + string destFile = Path.Combine(tempDir, lastFilename + ".echoreplay"); + File.Move(oldFile, destFile); - ZipFile.CreateFromDirectory(tempDir, oldFile); - Directory.Delete(tempDir, true); - } - catch (Exception ex) - { - Logger.LogRow(Logger.LogType.Error, "Error zipping split file: " + ex.Message); + ZipFile.CreateFromDirectory(tempDir, oldFile); + Directory.Delete(tempDir, true); + } + catch (Exception ex) + { + Logger.LogRow(Logger.LogType.Error, "Error zipping split file: " + ex.Message); + } + zipping = false; } - zipping = false; } } } - splitting = false; + finally + { + zipping = false; + splitting = false; + } }); replayBufferTimestamps.Clear(); diff --git a/Controllers/SimpleSpectateController.cs b/Controllers/SimpleSpectateController.cs new file mode 100644 index 0000000..955b78c --- /dev/null +++ b/Controllers/SimpleSpectateController.cs @@ -0,0 +1,586 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using Newtonsoft.Json.Linq; + +namespace Spark +{ + /// + /// Keeps a local spectator client sitting in whatever session the person you're following is in. + /// + /// The old version only knew how to scan the public match API for a display name, which meant it + /// could never follow anyone into a private match or a social lobby — those simply aren't listed. + /// Presence from the friends bot doesn't have that limit: the player's own Spark publishes the + /// session id it's actually in, whatever kind of session that is. So a friend (or your own second + /// PC, signed into the same Discord account) can be followed anywhere. The public API stays on as + /// a fallback for targets who aren't friends and aren't running Spark. + /// + public class SimpleSpectateController + { + /// + /// The spectator client has to be on 6721: that's the port + /// writes camera commands to. + /// + public const int SPECTATOR_PORT = 6721; + + private const string PublicMatchesUrl = "https://g.echovrce.com/status/matches"; + private const int PollMs = 3000; + + /// + /// How long to let a freshly launched EchoVR come up before treating it as failed. A cold + /// start runs to tens of seconds, far past , so the follow loop has to + /// wait this out rather than deciding a client that hasn't answered yet is dead. + /// + private const int ClientBootSeconds = 120; + + /// How long to let a join settle before reading back which slot it landed in. + private const int SlotCheckDelayMs = 6000; + + /// How long before a join to the same session is worth trying again. + private const int JoinRetrySeconds = 15; + + /// + /// Spark\Log\spectate.tsv. Shared with so both halves of a + /// two-PC setup — what gets published, and what gets followed — land in one file. + /// + internal const string LogFile = "spectate"; + + public enum FollowMode + { + /// Follow a named friend, falling back to the public match API. + Named, + + /// Follow this Discord account's own other PC. + Self, + } + + private CancellationTokenSource cancel; + + public bool Running => cancel != null && !cancel.IsCancellationRequested; + + /// Who we're following, for status display. + public string TargetName { get; private set; } = ""; + + public FollowMode Mode { get; private set; } = FollowMode.Named; + + /// Raised with a short human-readable status whenever the loop changes what it's doing. + public event Action StatusChanged; + + /// + /// Launches the spectator client and starts following. is + /// ignored in . + /// + public void Start(FollowMode mode, string targetName, bool anonymous, bool autoJoin) + { + Stop(); + + Mode = mode; + TargetName = mode == FollowMode.Self + ? (string.IsNullOrWhiteSpace(SparkSettings.instance.client_name) ? "your other PC" : SparkSettings.instance.client_name) + : targetName; + + if (!File.Exists(SparkSettings.instance.echoVRPath)) + { + new MessageBox(Properties.Resources.echovr_path_not_set, Properties.Resources.Error).Show(); + return; + } + + // This client is a spectator, not a player. If it kept publishing presence it would + // overwrite the row it's reading from — fatal in Self mode, wrong in every mode. + FriendsPresence.SuppressPresencePush = true; + + cancel = new CancellationTokenSource(); + CancellationToken token = cancel.Token; + + Task.Run(async () => + { + try + { + await RunAsync(anonymous, autoJoin, token); + } + catch (OperationCanceledException) + { + // Stop() was called. + } + catch (Exception e) + { + Logger.LogRow(Logger.LogType.Error, $"Simple Spectate: follow loop died.\n{e}"); + SetStatus("Stopped after an error"); + } + }, token); + } + + public void Stop() + { + cancel?.Cancel(); + cancel = null; + FriendsPresence.SuppressPresencePush = false; + } + + private async Task RunAsync(bool anonymous, bool autoJoin, CancellationToken token) + { + // Launched bare, with no session id. A spectator client isn't allowed to start with + // -lobbyid, so it comes up on -spectatorstream — which drops it into an unrelated + // public match — and is then moved into the target's session over the local HTTP API. + // That's the same join the follow loop makes for every later hop, so there's one code + // path for getting into a session rather than two. + SetStatus("Starting the spectator client..."); + LaunchSpectator(anonymous); + DateTime lastLaunch = DateTime.UtcNow; + + // Nothing can be asked of the client until it answers on its HTTP port. + if (!await WaitForSpectatorAsync(lastLaunch, token)) + { + SetStatus("The spectator client didn't start."); + return; + } + + SetStatus($"Looking for {TargetName}..."); + + string lastAttemptSession = null; + DateTime lastAttemptTime = DateTime.MinValue; + + while (!token.IsCancellationRequested && Program.running) + { + await Task.Delay(PollMs, token); + + try + { + string targetSessionId = await ResolveTargetSessionAsync(token); + (bool clientAlive, string ourSessionId) = await GetSpectatorStateAsync(); + + if (targetSessionId == null) + { + // Target went quiet. Stay put rather than dropping out of a session we're + // happily watching — they're probably mid-transition. + SetStatus(ourSessionId != null + ? $"Watching — waiting for {TargetName}" + : $"Waiting for {TargetName} to join a session..."); + continue; + } + + if (SameSession(ourSessionId, targetSessionId)) + { + SetStatus($"Spectating {TargetName}"); + continue; + } + + if (!clientAlive) + { + // A cold EchoVR start takes far longer than one poll, so a client that isn't + // answering yet is usually still booting rather than gone. Relaunching on + // the first silent poll is what made EchoVR open and close over and over: + // each pass killed a client that had never been given time to finish + // starting, so it never got far enough to answer. + if ((DateTime.UtcNow - lastLaunch).TotalSeconds < ClientBootSeconds) + { + SetStatus("Waiting for the spectator client to start..."); + continue; + } + + Logger.LogRow(Logger.LogType.File, LogFile, "Simple Spectate: spectator client not responding, relaunching."); + SetStatus("Restarting the spectator client..."); + LaunchSpectator(anonymous); + lastLaunch = DateTime.UtcNow; + lastAttemptSession = null; + lastAttemptTime = DateTime.MinValue; + continue; + } + + // Don't re-issue a join we've only just made — but do let it be retried, since a + // join can quietly fail to take and we'd otherwise never try that session again. + if (SameSession(lastAttemptSession, targetSessionId) && + (DateTime.UtcNow - lastAttemptTime).TotalSeconds < JoinRetrySeconds) + { + continue; + } + + SetStatus($"Joining {TargetName}'s session..."); + Logger.LogRow(Logger.LogType.File, LogFile, $"Simple Spectate: following {TargetName} into {targetSessionId}."); + + lastAttemptSession = targetSessionId; + lastAttemptTime = DateTime.UtcNow; + bool joined = await JoinAsSpectatorAsync(targetSessionId, anonymous, token, SetStatus); + lastLaunch = DateTime.UtcNow; // the join may have relaunched the client + + await Task.Delay(5000, token); + + Application.Current?.Dispatcher.Invoke(CameraWriteController.UseCameraControlKeys); + + // "Keep following them between sessions" governs the later hops only. The first + // one always has to happen: -spectatorstream on its own leaves the client + // watching a public match that has nothing to do with the target. + if (joined && !autoJoin) + { + SetStatus($"Spectating {TargetName}. Auto-follow is off."); + return; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + Console.WriteLine($"Simple Spectate loop error: {e.Message}"); + } + } + } + + // ─── Finding the target ──────────────────────────────────────────────── + + /// + /// The session the target is in, or null if we can't place them. Bot presence first — it's + /// the only source that covers private matches and lobbies — then the public match API. + /// + private async Task ResolveTargetSessionAsync(CancellationToken token) + { + string fromBot = await ResolveFromBotAsync(); + if (fromBot != null) return fromBot; + + // Self-follow has no public-API equivalent: it's your own account's presence or nothing. + if (Mode == FollowMode.Self) return null; + + return await ResolveFromPublicApiAsync(token); + } + + private async Task ResolveFromBotAsync() + { + if (!FriendsPresence.Configured) return null; + + try + { + // A Spark that never opened the Friends tab has no row on the bot yet, and /me would + // 404 forever without this. + await FriendsPresence.EnsureRegisteredAsync(); + + if (Mode == FollowMode.Self) + { + JObject me = await FriendsPresence.GetMeAsync(); + return JoinableSessionId(me); + } + + JArray friends = await FriendsPresence.GetFriendsAsync(); + if (friends == null) return null; + + foreach (JToken friend in friends) + { + if (!NameMatches(friend, TargetName)) continue; + return JoinableSessionId(friend); + } + } + catch (Exception e) + { + Console.WriteLine("Simple Spectate: friends lookup failed: " + e.Message); + } + + return null; + } + + private async Task ResolveFromPublicApiAsync(CancellationToken token) + { + try + { + string json = await FetchUtils.GetRequestAsync(PublicMatchesUrl, null); + if (string.IsNullOrEmpty(json)) return null; + + JArray labels = JObject.Parse(json)["labels"] as JArray; + if (labels == null) return null; + + foreach (JToken server in labels) + { + if (server["players"] is not JArray players) continue; + + bool targetHere = players.Any(p => + string.Equals((string)p["display_name"], TargetName, StringComparison.OrdinalIgnoreCase)); + + if (targetHere) return (string)server["id"]; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + Console.WriteLine("Simple Spectate: public match search failed: " + e.Message); + } + + return null; + } + + private static bool NameMatches(JToken friend, string targetName) + { + if (string.IsNullOrWhiteSpace(targetName)) return false; + + // The bot returns both names and either may be the one the user typed. + return string.Equals(friend["echo_username"]?.ToString(), targetName, StringComparison.OrdinalIgnoreCase) || + string.Equals(friend["discord_username"]?.ToString(), targetName, StringComparison.OrdinalIgnoreCase); + } + + /// The session id from a bot presence row, if it's online and actually in one. + private static string JoinableSessionId(JToken presence) + { + if (presence == null) return null; + if (presence["online"]?.Value() != true) return null; + + return NormalizeSessionId(presence["lobby_id"]?.ToString()); + } + + /// + /// Session ids turn up either as a bare guid or as a guid with a trailing ".something". + /// The game wants the bare guid in upper case, which is the form the server browser sends. + /// + private static string NormalizeSessionId(string sessionId) + { + return string.IsNullOrEmpty(sessionId) ? null : sessionId.Split('.')[0].ToUpperInvariant(); + } + + // ─── The local spectator client ──────────────────────────────────────── + + /// + /// Brings up a spectator client already sitting in , for the + /// one-shot "spectate this" buttons that have no follow loop of their own. + /// + /// The two steps can't be collapsed into a single launch: a spectator client isn't allowed + /// to start with -lobbyid, so it has to come up on -spectatorstream and be moved into the + /// session afterwards over its local HTTP API. + /// + /// Passes -noovr, which also lets this run beside a playing client. + public static async Task SpectateSessionAsync(string sessionId, bool anonymous, CancellationToken token = default) + { + if (string.IsNullOrEmpty(sessionId)) return false; + + LaunchSpectatorClient(anonymous); + + if (!await WaitForSpectatorClientAsync(DateTime.UtcNow, token, null)) + { + Logger.LogRow(Logger.LogType.File, LogFile, "Simple Spectate: spectator client never came up, nothing to join into."); + return false; + } + + bool joined = await JoinAsSpectatorAsync(NormalizeSessionId(sessionId), anonymous, token, null); + Logger.LogRow(Logger.LogType.File, LogFile, $"Simple Spectate: one-shot join into {sessionId} {(joined ? "succeeded" : "failed")}."); + return joined; + } + + private void LaunchSpectator(bool anonymous) => LaunchSpectatorClient(anonymous); + + /// + /// Whether the spectator client is actually in a spectator slot, or was seated as a player. + /// Null when it can't be told. + /// + /// This has to be read back rather than assumed, because nothing in the join request steers + /// it. The server only resolves an unassigned entrant role for social and *public* + /// arena/combat lobbies (evr_match.go, the TeamUnassigned switch); a private arena falls + /// through, the role stays unassigned, and the game seats the client on a team. Asking for + /// a role in the join body does not help — team_idx -1, 2 and omitting it entirely all come + /// back as a player. + /// + private static async Task IsInSpectatorSlotAsync() + { + try + { + string json = await FetchUtils.GetRequestAsync($"http://127.0.0.1:{SPECTATOR_PORT}/session", null); + if (string.IsNullOrEmpty(json) || json[0] != '{') return null; + + JObject frame = JObject.Parse(json); + string me = frame["client_name"]?.ToString(); + if (string.IsNullOrEmpty(me)) return null; + if (frame["teams"] is not JArray teams) return null; + + for (int i = 0; i < teams.Count; i++) + { + if (teams[i]["players"] is not JArray players) continue; + + foreach (JToken player in players) + { + if (!string.Equals(player["name"]?.ToString(), me, StringComparison.OrdinalIgnoreCase)) continue; + + // teams[0] and teams[1] are the playing teams; anything after them is + // spectators, so landing outside the first two is the good case. + return i >= 2; + } + } + + // In the session but on nobody's roster, which is what a spectator looks like. + return true; + } + catch (Exception) + { + return null; + } + } + + /// + /// Whether the session is a public match. The match API lists exactly the public ones, so a + /// session it doesn't know about is private. Null when the lookup can't be completed. + /// + private static async Task IsPublicSessionAsync(string sessionId) + { + try + { + string json = await FetchUtils.GetRequestAsync(PublicMatchesUrl, null); + if (string.IsNullOrEmpty(json)) return null; + if (JObject.Parse(json)["labels"] is not JArray labels) return null; + + foreach (JToken server in labels) + { + if (SameSession(NormalizeSessionId((string)server["id"]), sessionId)) return true; + } + + return false; + } + catch (Exception) + { + return null; + } + } + + /// + /// Puts the spectator client into as a spectator. + /// + /// A private match has to be entered through the launch arguments. The join API carries no + /// role, and the server only resolves an unassigned role for social and *public* + /// arena/combat lobbies, so joining a private match that way gets the client seated on a + /// team instead — measured with team_idx -1, team_idx 2, and with the field omitted, all + /// three of which came back as a player. Pairing the session id with -spectatorstream at + /// launch is the only way into a private match as a spectator. + /// + /// Public matches keep the join API, which is much cheaper: no client restart. + /// + private static async Task JoinAsSpectatorAsync(string sessionId, bool anonymous, CancellationToken token, Action status) + { + if (await IsPublicSessionAsync(sessionId) == false) + { + Logger.LogRow(Logger.LogType.File, LogFile, + $"Simple Spectate: {sessionId} is a private match, launching into it rather than joining over the API."); + return await RelaunchIntoSessionAsync(sessionId, anonymous, token, status); + } + + if (await Program.APISpectate(sessionId, overrideIP: "127.0.0.1", overridePort: SPECTATOR_PORT)) + { + await Task.Delay(SlotCheckDelayMs, token); + + bool? spectating = await IsInSpectatorSlotAsync(); + if (spectating != false) + { + // Spectating, or the placement couldn't be read — either way don't churn the + // client on a guess. + return true; + } + + Logger.LogRow(Logger.LogType.File, LogFile, + $"Simple Spectate: join to {sessionId} seated us as a player, relaunching into the session instead."); + } + + // The match looked public, or we couldn't tell, and the join still didn't leave us + // spectating. Fall back to the launch that always works. + return await RelaunchIntoSessionAsync(sessionId, anonymous, token, status); + } + + /// + /// Restarts the spectator client straight into , pairing the + /// session id with -spectatorstream so the client is a spectator from the moment it loads. + /// Costs a client restart, which is why it isn't used for matches the API can handle. + /// + private static async Task RelaunchIntoSessionAsync(string sessionId, bool anonymous, CancellationToken token, Action status) + { + status?.Invoke("Restarting the spectator client into the session..."); + LaunchSpectatorClient(anonymous, sessionId); + + if (!await WaitForSpectatorClientAsync(DateTime.UtcNow, token, status)) return false; + + await Task.Delay(SlotCheckDelayMs, token); + return await IsInSpectatorSlotAsync() != false; + } + + private static void LaunchSpectatorClient(bool anonymous, string sessionId = null) + { + try { Program.KillEchoVR($"-httpport {SPECTATOR_PORT}"); } + catch (Exception e) + { + // Nothing to kill, or a process's command line couldn't be read. Either way the + // launch below is still the thing we actually want to happen. + Logger.LogRow(Logger.LogType.Error, $"Simple Spectate: couldn't close the old spectator client.\n{e}"); + } + + try + { + // sessionId is normally null, so this comes up on -spectatorstream alone and is + // moved into the target's session by the join. It is only passed — becoming + // -lobbyid — when that join seated us as a player and there is nothing else left + // to try. See JoinAsSpectatorAsync. + Program.StartEchoVR( + Program.JoinType.Spectator, + port: SPECTATOR_PORT, + noovr: anonymous, + session_id: sessionId); + } + catch (Exception e) + { + Logger.LogRow(Logger.LogType.Error, $"Simple Spectate: failed to launch the spectator client.\n{e}"); + } + } + + /// + /// Waits for a freshly launched spectator client to start answering on its HTTP port. + /// False means it never did within . + /// + private Task WaitForSpectatorAsync(DateTime launchedAt, CancellationToken token) + => WaitForSpectatorClientAsync(launchedAt, token, SetStatus); + + private static async Task WaitForSpectatorClientAsync(DateTime launchedAt, CancellationToken token, Action status) + { + while (!token.IsCancellationRequested && Program.running) + { + (bool alive, string _) = await GetSpectatorStateAsync(); + if (alive) return true; + + if ((DateTime.UtcNow - launchedAt).TotalSeconds > ClientBootSeconds) return false; + + status?.Invoke("Waiting for the spectator client to start..."); + await Task.Delay(PollMs, token); + } + + return false; + } + + /// + /// Whether the local spectator client is up, and what it's watching. Asked directly rather + /// than read off , which on a spectator PC may be pointed at + /// a Quest. The two answers are separate because a client sitting in the menu or a lobby + /// replies with no session id — that's alive with nowhere to be, not a dead client. + /// + private static async Task<(bool alive, string sessionId)> GetSpectatorStateAsync() + { + try + { + string json = await FetchUtils.GetRequestAsync($"http://127.0.0.1:{SPECTATOR_PORT}/session", null); + if (string.IsNullOrEmpty(json)) return (false, null); + if (json[0] != '{') return (true, null); + + string sessionId = JObject.Parse(json)["sessionid"]?.ToString(); + return (true, string.IsNullOrEmpty(sessionId) ? null : sessionId.ToUpperInvariant()); + } + catch (Exception) + { + return (false, null); + } + } + + private static bool SameSession(string a, string b) + { + return !string.IsNullOrEmpty(a) && string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + } + + private void SetStatus(string status) + { + try { StatusChanged?.Invoke(status); } + catch (Exception) { /* status display is best-effort */ } + } + } +} diff --git a/Controllers/TTSController.cs b/Controllers/TTSController.cs index 39a7bb0..035e912 100644 --- a/Controllers/TTSController.cs +++ b/Controllers/TTSController.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; @@ -10,6 +11,7 @@ using System.Threading; using System.Threading.Tasks; using System.Windows.Media; +using System.Speech.Synthesis; using Newtonsoft.Json; using Spark.Properties; @@ -23,7 +25,7 @@ public class TTSController { { "en-US-Standard-D", "en-US-Standard-C" }, { "ja-JP-Standard-D", "ja-JP-Standard-B" } } }; - private bool playing = true; + private readonly Thread ttsThread; private readonly Queue rateLimiterQueue = new Queue(); private const float rateLimitPerSecond = 15; @@ -32,6 +34,7 @@ public class TTSController // Use BlockingCollection for efficient threading (no polling/sleep loops) private readonly BlockingCollection ttsQueue = new BlockingCollection(); + private readonly SpeechSynthesizer synth; public static string CacheFolder { get { @@ -54,10 +57,13 @@ public static string CacheFolder { public TTSController() { + synth = new SpeechSynthesizer(); + SetOutputToDefaultAudioDevice(); LoadTtsSpeed(); ttsThread = new Thread(TTSThread); ttsThread.IsBackground = true; + ttsThread.SetApartmentState(ApartmentState.STA); // Ensure STA for MediaPlayer ttsThread.Start(); Task.Run(async () => @@ -125,14 +131,14 @@ private void RegisterEvents() { if (SparkSettings.instance.throwSpeedTTS && frame.last_throw.total_speed > 10) { - SpeakAsync($"{frame.last_throw.total_speed:N1}"); + SpeakAsync(SparkSettings.instance.ttsSpecific ? $"{frame.last_throw.total_speed:N2}" : $"{frame.last_throw.total_speed:N1}"); } }; Program.BigBoost += (frame, team, player, speed, howLongAgo) => { if (SparkSettings.instance.maxBoostSpeedTTS && player.name == frame.client_name) { - SpeakAsync($"{speed:N0} {Resources.tts_meters_per_second}"); + SpeakAsync(SparkSettings.instance.ttsSpecific ? $"{speed:N1} {Resources.tts_meters_per_second}" : $"{speed:N0} {Resources.tts_meters_per_second}"); } }; Program.PlayspaceAbuse += (frame, team, player, playspacePos) => @@ -144,32 +150,38 @@ private void RegisterEvents() }; Program.Joust += (frame, team, player, isNeutral, joustTime, maxSpeed, maxTubeExitSpeed) => { + string joustTimeStr = SparkSettings.instance.ttsSpecific ? $"{joustTime:N2}" : $"{joustTime:N1}"; + string maxSpeedStr = SparkSettings.instance.ttsSpecific ? $"{maxSpeed:N1}" : $"{maxSpeed:N0}"; + if (SparkSettings.instance.joustTimeTTS && !SparkSettings.instance.joustSpeedTTS) { - SpeakAsync($"{team.color} {joustTime:N1}"); + SpeakAsync($"{team.color} {joustTimeStr}"); } else if (!SparkSettings.instance.joustTimeTTS && SparkSettings.instance.joustSpeedTTS) { - SpeakAsync($"{team.color} {maxSpeed:N0} {Resources.tts_meters_per_second}"); + SpeakAsync($"{team.color} {maxSpeedStr} {Resources.tts_meters_per_second}"); } else if (SparkSettings.instance.joustTimeTTS && SparkSettings.instance.joustSpeedTTS) { - SpeakAsync($"{team.color} {joustTime:N1} {maxSpeed:N0} {Resources.tts_meters_per_second}"); + SpeakAsync($"{team.color} {joustTimeStr} {maxSpeedStr} {Resources.tts_meters_per_second}"); } }; Program.Goal += (frame, goalEvent) => { + string distanceStr = SparkSettings.instance.ttsSpecific ? $"{frame.last_score.distance_thrown:N2}" : $"{frame.last_score.distance_thrown:N1}"; + string speedStr = SparkSettings.instance.ttsSpecific ? $"{frame.last_score.disc_speed:N2}" : $"{frame.last_score.disc_speed:N1}"; + if (SparkSettings.instance.goalDistanceTTS && SparkSettings.instance.goalSpeedTTS) { - SpeakAsync($"{frame.last_score.distance_thrown:N1} {Resources.tts_meters}. {frame.last_score.disc_speed:N1} {Resources.tts_meters_per_second}"); + SpeakAsync($"{distanceStr} {Resources.tts_meters}. {speedStr} {Resources.tts_meters_per_second}"); } else if (SparkSettings.instance.goalDistanceTTS) { - SpeakAsync($"{frame.last_score.distance_thrown:N1} {Resources.tts_meters}"); + SpeakAsync($"{distanceStr} {Resources.tts_meters}"); } else if (SparkSettings.instance.goalSpeedTTS) { - SpeakAsync($"{frame.last_score.disc_speed:N1} {Resources.tts_meters_per_second}"); + SpeakAsync($"{speedStr} {Resources.tts_meters_per_second}"); } }; Program.RulesChanged += frame => @@ -180,27 +192,85 @@ private void RegisterEvents() } lastRulesChangedTimer.Restart(); }; + Program.LargePing += (frame, team, player) => + { + if (SparkSettings.instance.pingSpikeTTS && + (!SparkSettings.instance.pingSpikeTTSPrivateOnly || frame.private_match)) + { + SpeakAsync($"{player.name}'s ping spiked to {player.ping}"); + } + }; } ~TTSController() { - ttsThread?.Abort(); + try + { + ttsQueue?.CompleteAdding(); + synth?.Dispose(); + } + catch { } + } + + /// + /// The speeds on offer, as multiples of the voice's normal pace: tenths from half speed to + /// double. That is the range the neural voices behind the Spark API document for their speaking + /// rate, so every step sounds different online. The offline Windows voice moves one step of its + /// own -10..10 rate per tenth, as the four speeds this replaced already did. + /// + public static readonly IReadOnlyList SpeedOptions = Enumerable.Range(5, 16) + .Select(tenths => new SpeedOption(tenths / 10.0)) + .ToList(); + + public sealed class SpeedOption + { + public SpeedOption(double rate) + { + Rate = rate; + } + + public double Rate { get; } + + /// + /// "1.4x", plus the name the old four-speed list used where this is one of those speeds, so + /// whatever someone had picked is still easy to find. + /// + public string Label + { + get + { + string name = Rate switch + { + 0.6 => Resources.Slow, + 1.0 => Resources.Normal, + 1.4 => Resources.Fast, + 1.8 => Resources.Very_Fast, + _ => null, + }; + string speed = $"{Rate:0.0}x"; + return name == null ? speed : $"{speed} ({name})"; + } + } + + public override string ToString() => Label; + } + + /// The offered speed closest to , or normal speed if it isn't usable. + public static double NearestSpeed(double speed) + { + if (double.IsNaN(speed) || speed <= 0) return 1.0; + return SpeedOptions.OrderBy(option => Math.Abs(option.Rate - speed)).First().Rate; } public void LoadTtsSpeed() { try { - int savedValue = SparkSettings.instance.ttsSpeedIndex; - // Validate range - if (savedValue < 1 || savedValue > 4) savedValue = 2; // Default to 2 (1.0x) if invalid - - int index = savedValue - 1; - SetRateInternal(index); + ApplyRate(NearestSpeed(SparkSettings.instance.ttsSpeedMultiplier)); } catch { - SetRateInternal(1); + ApplyRate(1.0); } } @@ -209,7 +279,6 @@ private void TTSThread() MediaPlayer mediaPlayer = new MediaPlayer(); mediaPlayer.MediaEnded += (sender, e) => { - playing = false; }; // Use GetConsumingEnumerable to block until item exists (CPU efficient) @@ -219,20 +288,46 @@ private void TTSThread() try { - // Ensure previous playback stops - mediaPlayer.Stop(); - mediaPlayer.Open(new Uri(result)); - playing = true; - mediaPlayer.Play(); - - // Only trim cache probabilistically to save IO - if (_rng.Next(0, 10) == 0) + if (result.StartsWith("OFFLINE|")) { - Task.Run(TrimCacheFolder); + string offlineText = result.Substring(8); + mediaPlayer.Stop(); // Ensure any playing audio stops + synth.SpeakAsyncCancelAll(); // Stop any currently playing offline speech + + try + { + if (SparkSettings.instance.ttsVoice == 0) + { + synth.SelectVoiceByHints(VoiceGender.Male); + } + else + { + synth.SelectVoiceByHints(VoiceGender.Female); + } + } + catch { } + + synth.SpeakAsync(offlineText); + Thread.Sleep(50); + } + else + { + synth.SpeakAsyncCancelAll(); // Stop offline speech if API audio plays + // Ensure previous playback stops + mediaPlayer.Stop(); + mediaPlayer.Open(new Uri(result)); + + mediaPlayer.Play(); + + // Only trim cache probabilistically to save IO + if (_rng.Next(0, 10) == 0) + { + Task.Run(TrimCacheFolder); + } + + // Small buffer to prevent stutter if rapid fire + Thread.Sleep(50); } - - // Small buffer to prevent stutter if rapid fire - Thread.Sleep(50); } catch { @@ -243,17 +338,13 @@ private void TTSThread() public float Rate => currentRate; - public void SetRate(int speedIndex) + /// A multiple of normal speed; snapped to the nearest of . + public void SetRate(double speed) { - if (speedIndex < 0) speedIndex = 1; - if (speedIndex > 3) speedIndex = 1; - - int value = speedIndex + 1; - - SparkSettings.instance.TTSSpeed = value; - SparkSettings.instance.ttsSpeedIndex = value; - - Task.Run(() => + speed = NearestSpeed(speed); + SparkSettings.instance.ttsSpeedMultiplier = speed; + + Task.Run(() => { try { @@ -261,26 +352,28 @@ public void SetRate(int speedIndex) } catch { } }); - - SetRateInternal(speedIndex); + + ApplyRate(speed); } - - private void SetRateInternal(int speedIndex) + + private void ApplyRate(double speed) { - switch (speedIndex) - { - case 0: currentRate = 0.6f; break; - case 1: currentRate = 1.0f; break; - case 2: currentRate = 1.4f; break; - case 3: currentRate = 1.8f; break; - default: currentRate = 1.0f; break; - } - - currentRateString = currentRate.ToString("F1"); + currentRate = (float)speed; + // A step of the offline voice's -10..10 rate per tenth: the old speeds used exactly this + // (0.6x was -4, 1.4x was 4, 1.8x was 8). + synth.Rate = Math.Clamp((int)Math.Round((speed - 1.0) * 10), -10, 10); + // Goes into every cached clip's filename. Invariant, and still "1.4" rather than "1.40", so + // clips cached at the old speeds keep being found. + currentRateString = speed.ToString("0.0#", CultureInfo.InvariantCulture); } public void SetOutputToDefaultAudioDevice() { + try + { + synth.SetOutputToDefaultAudioDevice(); + } + catch { } } public void SpeakAsync(string text) @@ -332,7 +425,7 @@ private void Speak(string text) cleanText = cleanText.Substring(0, 50); } - string filePath = Path.Combine(CacheFolder, $"{currentRateString}_{SparkSettings.instance.languageIndex}_{SparkSettings.instance.useWavenetVoices}_{SparkSettings.instance.ttsVoice}_{cleanText}.mp3"); + string filePath = Path.Combine(CacheFolder, $"v2_{currentRateString}_{SparkSettings.instance.languageIndex}_{SparkSettings.instance.ttsVoice}_{cleanText}.mp3"); if (File.Exists(filePath)) { @@ -343,24 +436,27 @@ private void Speak(string text) // Run network request try { + string voiceName = SparkSettings.instance.ttsVoice == 1 ? "en-US-Wavenet-C" : "en-US-Wavenet-D"; + string json = JsonConvert.SerializeObject(new Dictionary { {"text", text}, - {"language_code", voiceTypes[SparkSettings.instance.useWavenetVoices ? 0 : 1, SparkSettings.instance.languageIndex, SparkSettings.instance.ttsVoice]}, - {"voice_name", voiceTypes[SparkSettings.instance.useWavenetVoices ? 0 : 1, SparkSettings.instance.languageIndex, SparkSettings.instance.ttsVoice]}, + {"language_code", voiceName}, + {"voice_name", voiceName}, {"speaking_rate", currentRate}, }); HttpRequestMessage request = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri($"{Program.APIURL}/tts"), + RequestUri = new Uri("https://sparkapi-production-e6df.up.railway.app/tts"), Content = new StringContent(json, Encoding.UTF8, MediaTypeNames.Application.Json), }; // Synchronous wait here is fine because we are already in Task.Run from SpeakAsync // and we want to ensure the file is written before queueing HttpResponseMessage response = FetchUtils.client.SendAsync(request).Result; + response.EnsureSuccessStatusCode(); byte[] bytes = response.Content.ReadAsByteArrayAsync().Result; if (bytes.Length > 0) @@ -368,10 +464,15 @@ private void Speak(string text) File.WriteAllBytes(filePath, bytes); ttsQueue.Add(filePath); } + else + { + ttsQueue.Add("OFFLINE|" + text); + } } catch { - // Ignore TTS generation errors + // Ignore TTS generation errors and fallback to offline + ttsQueue.Add("OFFLINE|" + text); } } diff --git a/Controllers/TapeFFI.cs b/Controllers/TapeFFI.cs new file mode 100644 index 0000000..f787038 --- /dev/null +++ b/Controllers/TapeFFI.cs @@ -0,0 +1,19 @@ +using System; +using System.Runtime.InteropServices; + +namespace Spark +{ + public static class TapeFFI + { + private const string DllName = "tape.dll"; + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern ulong TapeCreate([MarshalAs(UnmanagedType.LPStr)] string cPath, long startUnixMs); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int TapeWriteFrame(ulong handle, long timestampMs, [MarshalAs(UnmanagedType.LPStr)] string cJsonStr); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int TapeClose(ulong handle); + } +} diff --git a/Data Containers/AccumulatedFrame.cs b/Data Containers/AccumulatedFrame.cs index 70992f4..cb8f26b 100644 --- a/Data Containers/AccumulatedFrame.cs +++ b/Data Containers/AccumulatedFrame.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; @@ -34,6 +34,8 @@ public class AccumulatedFrame public float smoothedServerScore; public List currentDiskTrajectory = new List(); + + public LastKill lastKill = new LastKill(); /// /// enum of all possible ways a game could have ended. @@ -136,6 +138,8 @@ public void Accumulate(Frame newFrame, Frame lastFrame) { frame.teams[i].team = newFrame.teams[i].team; } + + lastKill = CombatDataParser.CurrentLastKill; foreach (Team team in newFrame.teams) { diff --git a/Data Containers/CombatDataParser.cs b/Data Containers/CombatDataParser.cs new file mode 100644 index 0000000..58064f2 --- /dev/null +++ b/Data Containers/CombatDataParser.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace Spark +{ + public static class CombatDataParser + { + public static ConcurrentDictionary CurrentCombatStats = new ConcurrentDictionary(); + public static LastKill CurrentLastKill = new LastKill(); + public static object ParseLock = new object(); + + /// + /// Kills this session, newest first. The API only ever exposes the single most recent kill, + /// which it holds until the next one, so the feed is built by noticing when that value + /// changes — the same way the Arena side accumulates jousts out of per-frame events. + /// + /// Read it under : it is written from the frame-fetch thread and + /// read from the UI thread. + /// + /// + public static readonly List KillFeed = new List(); + + /// How many kills to keep. The dashboard shows about six at a time. + private const int KillFeedLimit = 40; + + /// Session the feed belongs to, so a new match starts from an empty one. + private static string killFeedSessionId; + + /// Whether the feed has seen its first frame yet; sessionid alone can't say. + private static bool killFeedStarted; + + /// The last kill added to the feed, as killer|victim|weapon. + private static string lastFeedKillKey; + + /// + /// The victim's death count when was recorded, or -1 when their + /// row couldn't be found. Lets a repeat of an identical kill be told apart from the API simply + /// still holding the previous one. + /// + private static int lastFeedVictimDeaths = -1; + + /// The killer's kill count at the same moment, or -1 when there's no killer row. + private static int lastFeedKillerKills = -1; + + public static void Parse(string rawSessionJson) + { + if (string.IsNullOrWhiteSpace(rawSessionJson)) return; + + try + { + JObject json = JObject.Parse(rawSessionJson); + + lock (ParseLock) + { + CurrentCombatStats.Clear(); + + // Deaths by name, for telling a repeated identical kill from a held one below. + // last_kill identifies players by name only, so that's the key it has to be. + Dictionary deathsByName = new Dictionary(); + Dictionary killsByName = new Dictionary(); + + if (json["teams"] is JArray teams) + { + foreach (var team in teams) + { + if (team["players"] is JArray players) + { + foreach (var player in players) + { + if (player["userid"] == null) continue; + long userId = player["userid"].ToObject(); + var stats = player["stats"]; + + if (stats != null) + { + CombatStats cs = new CombatStats(); + cs.kills = stats["kills"]?.ToObject() ?? 0; + cs.assists = stats["assists"]?.ToObject() ?? 0; + cs.deaths = stats["deaths"]?.ToObject() ?? 0; + cs.damage = stats["damage"]?.ToObject() ?? 0; + cs.damage_taken = stats["damage_taken"]?.ToObject() ?? 0; + cs.damage_taken_raw = stats["damage_taken_raw"]?.ToObject() ?? 0; + cs.eliminations = stats["eliminations"]?.ToObject() ?? 0; + cs.objective_eliminations = stats["objective_eliminations"]?.ToObject() ?? 0; + cs.objective_time = stats["objective_time"]?.ToObject() ?? 0; + cs.objective_damage = stats["objective_damage"]?.ToObject() ?? 0; + cs.hill_captures = stats["hill_captures"]?.ToObject() ?? 0; + cs.hill_defends = stats["hill_defends"]?.ToObject() ?? 0; + + CurrentCombatStats[userId] = cs; + + string playerName = player["name"]?.ToString(); + if (!string.IsNullOrEmpty(playerName) && !deathsByName.ContainsKey(playerName)) + { + deathsByName[playerName] = cs.deaths; + killsByName[playerName] = cs.kills; + } + } + } + } + } + } + + if (json["last_kill"] != null) + { + CurrentLastKill.killer = json["last_kill"]["killer"]?.ToString() ?? ""; + CurrentLastKill.killed = json["last_kill"]["killed"]?.ToString() ?? ""; + CurrentLastKill.killed_with = json["last_kill"]["killed_with"]?.ToString() ?? ""; + } + + UpdateKillFeed(json["sessionid"]?.ToString(), json["last_kill"], deathsByName, killsByName); + } + } + catch (Exception) + { + // Ignore parse errors, just means missing or malformed combat API data + } + } + + /// + /// Adds last_kill to the feed when it describes a kill that hasn't been counted yet. + /// Call under . + /// + /// The API holds its last kill until the next one, so this sees the same kill on every + /// frame and has to notice when it changes. That alone would lose the second of two + /// identical kills — A kills B, B respawns, A kills B again with the same weapon — because + /// nothing in last_kill differs, so the players' counters are checked as well: if the + /// victim's deaths and the killer's kills have both gone up while last_kill looks the same, + /// that's a new kill. Both, not just deaths — a suicide or a fall adds a death without + /// crediting anyone, and if the API leaves last_kill alone for those, deaths alone would + /// replay the previous kill into the feed. + /// + /// + private static void UpdateKillFeed(string sessionId, JToken lastKill, + Dictionary deathsByName, Dictionary killsByName) + { + string killer = lastKill?["killer"]?.ToString() ?? ""; + string killed = lastKill?["killed"]?.ToString() ?? ""; + string weapon = lastKill?["killed_with"]?.ToString() ?? ""; + + bool isKill = !string.IsNullOrEmpty(killer) || !string.IsNullOrEmpty(killed); + string key = isKill ? killer + "|" + killed + "|" + weapon : null; + int victimDeaths = killed.Length > 0 && deathsByName.TryGetValue(killed, out int d) ? d : -1; + int killerKills = killer.Length > 0 && killsByName.TryGetValue(killer, out int k) ? k : -1; + + if (!killFeedStarted || sessionId != killFeedSessionId) + { + // New match. Whatever last_kill already holds happened before we got here — possibly + // in the previous match, since the API carries it over — so it's taken as the starting + // point rather than shown as a fresh kill. + killFeedStarted = true; + killFeedSessionId = sessionId; + KillFeed.Clear(); + lastFeedKillKey = key; + lastFeedVictimDeaths = victimDeaths; + lastFeedKillerKills = killerKills; + return; + } + + if (key == null) return; + + bool changed = key != lastFeedKillKey; + bool victimDied = victimDeaths >= 0 && lastFeedVictimDeaths >= 0 && victimDeaths > lastFeedVictimDeaths; + // No killer (a self or environment kill) means there's no kill count to require. + bool killerScored = killer.Length == 0 || + (killerKills >= 0 && lastFeedKillerKills >= 0 && killerKills > lastFeedKillerKills); + bool repeated = !changed && victimDied && killerScored; + + // Keep the death count current even when nothing is added, so a later repeat is measured + // against the latest figure and not the one from when the kill first appeared. + if (!changed && !repeated) + { + if (victimDeaths >= 0) lastFeedVictimDeaths = victimDeaths; + if (killerKills >= 0) lastFeedKillerKills = killerKills; + return; + } + + // A copy, not CurrentLastKill itself: that one object is overwritten every frame, so + // storing it would turn every entry in the feed into whatever the latest kill is. + KillFeed.Insert(0, new LastKill { killer = killer, killed = killed, killed_with = weapon }); + if (KillFeed.Count > KillFeedLimit) + { + KillFeed.RemoveRange(KillFeedLimit, KillFeed.Count - KillFeedLimit); + } + + lastFeedKillKey = key; + lastFeedVictimDeaths = victimDeaths; + lastFeedKillerKills = killerKills; + } + + public static CombatStats GetCombatStats(long userid) + { + if (CurrentCombatStats.TryGetValue(userid, out var stats)) + { + return stats; + } + return new CombatStats(); + } + } +} diff --git a/Data Containers/CombatStats.cs b/Data Containers/CombatStats.cs new file mode 100644 index 0000000..b4a4352 --- /dev/null +++ b/Data Containers/CombatStats.cs @@ -0,0 +1,64 @@ +using System; + +namespace Spark +{ + public class CombatStats + { + public int kills { get; set; } = 0; + public int assists { get; set; } = 0; + public int deaths { get; set; } = 0; + public float damage { get; set; } = 0; + public float damage_taken { get; set; } = 0; + public float damage_taken_raw { get; set; } = 0; + public int eliminations { get; set; } = 0; + public int objective_eliminations { get; set; } = 0; + public float objective_time { get; set; } = 0; + public float objective_damage { get; set; } = 0; + public int hill_captures { get; set; } = 0; + public int hill_defends { get; set; } = 0; + + public static CombatStats operator +(CombatStats a, CombatStats b) + { + if (a == null) return b; + if (b == null) return a; + + return new CombatStats + { + kills = a.kills + b.kills, + assists = a.assists + b.assists, + deaths = a.deaths + b.deaths, + damage = a.damage + b.damage, + damage_taken = a.damage_taken + b.damage_taken, + damage_taken_raw = a.damage_taken_raw + b.damage_taken_raw, + eliminations = a.eliminations + b.eliminations, + objective_eliminations = a.objective_eliminations + b.objective_eliminations, + objective_time = a.objective_time + b.objective_time, + objective_damage = a.objective_damage + b.objective_damage, + hill_captures = a.hill_captures + b.hill_captures, + hill_defends = a.hill_defends + b.hill_defends + }; + } + + public static CombatStats operator -(CombatStats a, CombatStats b) + { + if (a == null) return new CombatStats(); + if (b == null) return a; + + return new CombatStats + { + kills = Math.Max(0, a.kills - b.kills), + assists = Math.Max(0, a.assists - b.assists), + deaths = Math.Max(0, a.deaths - b.deaths), + damage = Math.Max(0, a.damage - b.damage), + damage_taken = Math.Max(0, a.damage_taken - b.damage_taken), + damage_taken_raw = Math.Max(0, a.damage_taken_raw - b.damage_taken_raw), + eliminations = Math.Max(0, a.eliminations - b.eliminations), + objective_eliminations = Math.Max(0, a.objective_eliminations - b.objective_eliminations), + objective_time = Math.Max(0, a.objective_time - b.objective_time), + objective_damage = Math.Max(0, a.objective_damage - b.objective_damage), + hill_captures = Math.Max(0, a.hill_captures - b.hill_captures), + hill_defends = Math.Max(0, a.hill_defends - b.hill_defends) + }; + } + } +} diff --git a/Data Containers/GearNames.cs b/Data Containers/GearNames.cs new file mode 100644 index 0000000..cfd853d --- /dev/null +++ b/Data Containers/GearNames.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json.Linq; + +namespace Spark +{ + /// + /// Turns the combat API's internal gear names into the names players see in game. + /// + /// The API reports weapons, ordnance and tac mods by internal name — "assault", "det", "wraith" — + /// and a kill's killed_with by the damage source, which is often not the weapon's own name at all: + /// a Comet kill can arrive as "scout rifle explosion" or "scout_aoe", a Meteor kill as + /// "rocket_aoe". The table here is the game's own (rad15\json\balance\mp_equipment.json), whose + /// aliases list exactly those damage sources, so every one resolves to the weapon that caused it. + /// + /// + /// It comes from the Quest APK's source copy rather than the compiled PC data, because only the + /// source still has the cut gear — Starburst, Translocator, Attack Drone and the rest — sitting in + /// it as commented-out entries, which compiling strips. Those entries carry "cut": true, and are + /// otherwise exactly as the source has them. + /// + /// + public static class GearNames + { + private const string ResourceName = "Spark.mp_equipment.json"; + + private static readonly Lazy<(Dictionary exact, Dictionary items)> table = + new Lazy<(Dictionary, Dictionary)>(Load); + + /// + /// The in-game name for , or unchanged when + /// it isn't in the table — so a weapon added after this table was extracted still shows up, + /// just under its raw name, instead of disappearing. + /// + public static string Display(string apiName) + { + if (string.IsNullOrWhiteSpace(apiName)) return apiName; + + string key = Normalise(apiName); + if (table.Value.exact.TryGetValue(key, out string display)) return display; + + // A damage source is named after the item that caused it: every alias the game lists begins + // with its item's internal name ("scout rifle explosion", "scout_aoe", "rocket_aoe", + // "blaster_cone"). The cut gear comes with no aliases at all, so rather than guess which + // variants the engine emits for it — magnum_aoe? magnum explosion? — apply that same rule. + int space = key.IndexOf(' '); + if (space > 0 && table.Value.items.TryGetValue(key.Substring(0, space), out display)) return display; + + return apiName; + } + + /// + /// Lookup key. Case-insensitive, and underscores, hyphens and spaces are treated alike: the + /// table itself mixes "scout rifle explosion" with "scout_aoe", so the API can't be relied on to + /// use whichever separator a given alias happens to be written with. + /// + private static string Normalise(string name) + { + char[] chars = name.Trim().ToLowerInvariant().Select(c => c == '_' || c == '-' ? ' ' : c).ToArray(); + return string.Join(" ", new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } + + /// + /// The table stores names the way the game's HUD shows them, in capitals. Spark's UI is title + /// case throughout, so "STUN FIELD" becomes "Stun Field" to sit alongside everything else. + /// + private static string TitleCase(string upper) + { + return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(upper.ToLowerInvariant()); + } + + private static (Dictionary exact, Dictionary items) Load() + { + // exact: every name the table gives, aliases and display names included. + // items: internal item names only — the words a damage source is allowed to start with. + Dictionary map = new Dictionary(); + Dictionary items = new Dictionary(); + try + { + using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(ResourceName); + if (stream == null) + { + Logger.LogRow(Logger.LogType.Error, $"Gear name table missing from the build ({ResourceName}); showing API names."); + return (map, items); + } + + using StreamReader reader = new StreamReader(stream); + JToken gear = JObject.Parse(reader.ReadToEnd())["gear_table"]; + + foreach (string category in new[] { "weapons", "ordnances", "tacmods" }) + { + if (gear?[category] is not JArray entries) continue; + + foreach (JToken item in entries) + { + string display = item["display_name"]?.ToString(); + if (string.IsNullOrWhiteSpace(display)) continue; + display = TitleCase(display); + + string internalName = item["name"]?.ToString(); + if (!string.IsNullOrWhiteSpace(internalName)) items.TryAdd(Normalise(internalName), display); + + // The internal name, every damage-source alias, and the display name itself — + // the last so an API that already sends "Pulsar" still comes out consistently. + IEnumerable keys = new[] { item["name"]?.ToString(), item["display_name"]?.ToString() } + .Concat(item["aliases"] is JArray aliases ? aliases.Select(a => a.ToString()) : Enumerable.Empty()); + + foreach (string key in keys) + { + if (string.IsNullOrWhiteSpace(key)) continue; + // First writer wins, so a later category can't silently repoint a name. + map.TryAdd(Normalise(key), display); + } + } + } + } + catch (Exception e) + { + Logger.LogRow(Logger.LogType.Error, $"Couldn't read the gear name table; showing API names.\n{e}"); + } + + return (map, items); + } + } +} diff --git a/Data Containers/LastKill.cs b/Data Containers/LastKill.cs new file mode 100644 index 0000000..4c41ecb --- /dev/null +++ b/Data Containers/LastKill.cs @@ -0,0 +1,11 @@ +using System; + +namespace Spark +{ + public class LastKill + { + public string killer { get; set; } = ""; + public string killed { get; set; } = ""; + public string killed_with { get; set; } = ""; + } +} diff --git a/Data Containers/MatchPlayer.cs b/Data Containers/MatchPlayer.cs index 4ec167b..a58c55e 100644 --- a/Data Containers/MatchPlayer.cs +++ b/Data Containers/MatchPlayer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -42,6 +42,9 @@ public MatchPlayer(AccumulatedFrame round, Player player) currentStats = player.stats; cachedStats = new Stats(); oldRoundStats = new Stats(); + currentCombatStats = new CombatStats(); + cachedCombatStats = new CombatStats(); + oldRoundCombatStats = new CombatStats(); PlayTime = 0; InvertedTime = 0; GoalsNum = 0; @@ -67,6 +70,9 @@ public MatchPlayer(MatchPlayer matchPlayer) currentStats = matchPlayer.currentStats; cachedStats = matchPlayer.cachedStats; oldRoundStats = matchPlayer.oldRoundStats; + currentCombatStats = matchPlayer.currentCombatStats; + cachedCombatStats = matchPlayer.cachedCombatStats; + oldRoundCombatStats = matchPlayer.oldRoundCombatStats; PlayTime = matchPlayer.PlayTime; InvertedTime = matchPlayer.InvertedTime; GoalsNum = matchPlayer.GoalsNum; @@ -110,6 +116,18 @@ public Dictionary ToDict() { "blocks", Blocks }, { "interceptions", Interceptions }, { "assists", Assists }, + { "combat_kills", CombatKills }, + { "combat_assists", CombatAssists }, + { "combat_deaths", CombatDeaths }, + { "combat_damage", CombatDamage }, + { "combat_damage_taken", CombatDamageTaken }, + { "combat_damage_taken_raw", CombatDamageTakenRaw }, + { "combat_eliminations", CombatEliminations }, + { "combat_objective_eliminations", CombatObjectiveEliminations }, + { "combat_objective_time", CombatObjectiveTime }, + { "combat_objective_damage", CombatObjectiveDamage }, + { "combat_hill_captures", CombatHillCaptures }, + { "combat_hill_defends", CombatHillDefends }, // { "turnovers", Turnovers }, // TODO enable once the db supports it { "average_speed", averageSpeed[0] }, { "average_speed_lhand", averageSpeed[1] }, @@ -137,6 +155,10 @@ public Dictionary ToDict() public Stats cachedStats = new Stats(); public Stats oldRoundStats = new Stats(); + public CombatStats currentCombatStats = new CombatStats(); + public CombatStats cachedCombatStats = new CombatStats(); + public CombatStats oldRoundCombatStats = new CombatStats(); + public float PossessionTime { get => Math.Clamp(cachedStats.possession_time + currentStats.possession_time - oldRoundStats.possession_time, MINCLAMP, int.MaxValue); @@ -202,6 +224,78 @@ public int Assists set => currentStats.assists = value; } + public int CombatKills + { + get => Math.Clamp(cachedCombatStats.kills + currentCombatStats.kills - oldRoundCombatStats.kills, MINCLAMP, int.MaxValue); + set => currentCombatStats.kills = value; + } + + public int CombatAssists + { + get => Math.Clamp(cachedCombatStats.assists + currentCombatStats.assists - oldRoundCombatStats.assists, MINCLAMP, int.MaxValue); + set => currentCombatStats.assists = value; + } + + public int CombatDeaths + { + get => Math.Clamp(cachedCombatStats.deaths + currentCombatStats.deaths - oldRoundCombatStats.deaths, MINCLAMP, int.MaxValue); + set => currentCombatStats.deaths = value; + } + + public float CombatDamage + { + get => Math.Max(MINCLAMP, cachedCombatStats.damage + currentCombatStats.damage - oldRoundCombatStats.damage); + set => currentCombatStats.damage = value; + } + + public float CombatDamageTaken + { + get => Math.Max(MINCLAMP, cachedCombatStats.damage_taken + currentCombatStats.damage_taken - oldRoundCombatStats.damage_taken); + set => currentCombatStats.damage_taken = value; + } + + public float CombatDamageTakenRaw + { + get => Math.Max(MINCLAMP, cachedCombatStats.damage_taken_raw + currentCombatStats.damage_taken_raw - oldRoundCombatStats.damage_taken_raw); + set => currentCombatStats.damage_taken_raw = value; + } + + public int CombatEliminations + { + get => Math.Clamp(cachedCombatStats.eliminations + currentCombatStats.eliminations - oldRoundCombatStats.eliminations, MINCLAMP, int.MaxValue); + set => currentCombatStats.eliminations = value; + } + + public int CombatObjectiveEliminations + { + get => Math.Clamp(cachedCombatStats.objective_eliminations + currentCombatStats.objective_eliminations - oldRoundCombatStats.objective_eliminations, MINCLAMP, int.MaxValue); + set => currentCombatStats.objective_eliminations = value; + } + + public float CombatObjectiveTime + { + get => Math.Max(MINCLAMP, cachedCombatStats.objective_time + currentCombatStats.objective_time - oldRoundCombatStats.objective_time); + set => currentCombatStats.objective_time = value; + } + + public float CombatObjectiveDamage + { + get => Math.Max(MINCLAMP, cachedCombatStats.objective_damage + currentCombatStats.objective_damage - oldRoundCombatStats.objective_damage); + set => currentCombatStats.objective_damage = value; + } + + public int CombatHillCaptures + { + get => Math.Clamp(cachedCombatStats.hill_captures + currentCombatStats.hill_captures - oldRoundCombatStats.hill_captures, MINCLAMP, int.MaxValue); + set => currentCombatStats.hill_captures = value; + } + + public int CombatHillDefends + { + get => Math.Clamp(cachedCombatStats.hill_defends + currentCombatStats.hill_defends - oldRoundCombatStats.hill_defends, MINCLAMP, int.MaxValue); + set => currentCombatStats.hill_defends = value; + } + public float DistanceBetweenHands { get @@ -270,8 +364,8 @@ public void UpdateAverageSpeedRHand(float newSpeed) public void AddRecentVelocity(float vel) { recentVelocities.Add(vel); - // anything older than 10s at 60hz - while (recentVelocities.Count > Program.StatsIntervalMs * 10) + // anything older than 10s + while (recentVelocities.Count > (1000f / Program.MeasuredIntervalMs) * 10) { recentVelocities.RemoveAt(0); } @@ -286,7 +380,6 @@ public void AddRecentVelocity(float vel) int maxVelIndex = recentVelocities.IndexOf(maxVel); if (maxVel < boostVelCutoff) { - var asdf = 1; } if (reset) @@ -294,12 +387,12 @@ public void AddRecentVelocity(float vel) recentVelocities.Clear(); } - return (maxVel, (float)(recentVelocities.Count - maxVelIndex) / Program.StatsIntervalMs); + return (maxVel, (recentVelocities.Count - maxVelIndex) * (Program.MeasuredIntervalMs / 1000f)); } public float GetSmoothedVelocity(float smoothTime = 1) { - int N = (int)(smoothTime * Program.StatsIntervalMs); + int N = (int)(smoothTime * (1000f / Program.MeasuredIntervalMs)); if (N > recentVelocities.Count - 1) { return recentVelocities.Average(); @@ -325,6 +418,7 @@ public void CacheStats(Stats newPlayerStats) } cachedStats += currentStats; + cachedCombatStats += currentCombatStats; } /// @@ -336,6 +430,9 @@ public void StoreLastRoundStats(MatchPlayer lastPlayer) // TODO this looks wrong oldRoundStats = lastPlayer.oldRoundStats; oldRoundStats = cachedStats + currentStats; + + oldRoundCombatStats = lastPlayer.oldRoundCombatStats; + oldRoundCombatStats = cachedCombatStats + currentCombatStats; } public static MatchPlayer operator +(MatchPlayer a, MatchPlayer b) @@ -350,6 +447,9 @@ public void StoreLastRoundStats(MatchPlayer lastPlayer) currentStats = a.currentStats + b.currentStats, cachedStats = a.cachedStats + b.cachedStats, oldRoundStats = a.oldRoundStats + b.oldRoundStats, + currentCombatStats = a.currentCombatStats + b.currentCombatStats, + cachedCombatStats = a.cachedCombatStats + b.cachedCombatStats, + oldRoundCombatStats = a.oldRoundCombatStats + b.oldRoundCombatStats, PlayTime = a.PlayTime + b.PlayTime, InvertedTime = a.InvertedTime + b.InvertedTime, GoalsNum = a.GoalsNum + b.GoalsNum, @@ -379,6 +479,8 @@ public void Accumulate(Frame frame, Player player, Frame lastFrame) // } currentStats = player.stats; + + currentCombatStats = CombatDataParser.GetCombatStats(player.userid); } } } \ No newline at end of file diff --git a/DiscordOAuth.cs b/DiscordOAuth.cs index ee9f5af..100798a 100644 --- a/DiscordOAuth.cs +++ b/DiscordOAuth.cs @@ -37,6 +37,7 @@ public class DiscordOAuth public static string igniteUploadKey = string.Empty; public static string firebaseCred; + public static bool IsAdmin { get; private set; } = false; public static List availableAccessCodes = new List(); private static readonly AccessCodeKey personalAccessCode = new AccessCodeKey @@ -315,6 +316,7 @@ private static async void FetchUserDetails() { "global_name", jsonResponse["global_name"]?.ToString() } }; + FetchAdminStatus(); FetchAccessCodes(); } catch (Exception ex) @@ -324,6 +326,27 @@ private static async void FetchUserDetails() } } + private static async void FetchAdminStatus() + { + try + { + HttpResponseMessage adminResponse = await client.GetAsync("https://sparkapi-production-e6df.up.railway.app/admins"); + if (adminResponse.IsSuccessStatusCode) + { + string responseString = await adminResponse.Content.ReadAsStringAsync(); + List admins = JsonConvert.DeserializeObject>(responseString); + if (admins != null && DiscordUserID != null) + { + IsAdmin = admins.Contains(DiscordUserID); + } + } + } + catch (Exception ex) + { + Logger.LogRow(Logger.LogType.Error, $"Error fetching admin status: {ex.Message}"); + } + } + private static async void FetchAccessCodes() { try diff --git a/EchoVRAPIPassthrough.cs b/EchoVRAPIPassthrough.cs index decdc1b..30f29a2 100644 --- a/EchoVRAPIPassthrough.cs +++ b/EchoVRAPIPassthrough.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Net.Http; using System.Text; @@ -14,9 +14,9 @@ public static class EchoVRAPIPassthrough { private static void AddDefaultHeaders(HttpContext context) { - context.Response.Headers.Add("Access-Control-Allow-Origin", "*"); - context.Response.Headers.Add("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With"); - context.Response.Headers.Add("Content-Type", "application/json"); + context.Response.Headers.Append("Access-Control-Allow-Origin", "*"); + context.Response.Headers.Append("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With"); + context.Response.Headers.Append("Content-Type", "application/json"); } public static void MapRoutes(IEndpointRouteBuilder endpoints) diff --git a/FetchUtils.cs b/FetchUtils.cs index 7e05be4..6c9f15e 100644 --- a/FetchUtils.cs +++ b/FetchUtils.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Net; @@ -59,27 +59,23 @@ public static async Task GetRequestAsync(string uri, Dictionary - /// Generic method for getting data from a web url. Returns a Stream - /// - /// The URL to GET - /// Key-value pairs for headers. Leave null if none. public static async Task GetRequestAsyncStream(string uri, Dictionary headers) { try { - HttpWebRequest request = (HttpWebRequest) WebRequest.Create(uri); + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri); if (headers != null) { foreach ((string key, string value) in headers) { - request.Headers[key] = value; + request.Headers.Add(key, value); } } - request.UserAgent = $"Spark/{Program.AppVersionString()}"; - using WebResponse response = await request.GetResponseAsync(); - return response.GetResponseStream(); + request.Headers.UserAgent.ParseAdd($"Spark/{Program.AppVersionString()}"); + HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStreamAsync(); } catch (Exception e) { @@ -89,6 +85,33 @@ public static async Task GetRequestAsyncStream(string uri, Dictionary onProgress) + { + using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.UserAgent.ParseAdd("Spark-Updater"); + using HttpResponseMessage response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + long? totalBytes = response.Content.Headers.ContentLength; + using Stream contentStream = await response.Content.ReadAsStreamAsync(); + using FileStream fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); + + byte[] buffer = new byte[8192]; + long totalRead = 0; + int bytesRead; + + while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length)) != 0) + { + await fileStream.WriteAsync(buffer, 0, bytesRead); + totalRead += bytesRead; + if (totalBytes.HasValue && onProgress != null) + { + int percentage = (int)((totalRead * 100) / totalBytes.Value); + onProgress(percentage); + } + } + } + /// /// Generic method for posting data to a web url /// diff --git a/GlobalHotKey.cs b/GlobalHotKey.cs index 26e1150..d97501c 100644 --- a/GlobalHotKey.cs +++ b/GlobalHotKey.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Runtime.InteropServices; @@ -120,7 +120,7 @@ private static void ComponentDispatcherThreadFilterMessage(ref MSG msg, ref bool Debug.WriteLine("Regsistre"); hotKey.Register(); } - catch (Exception e) + catch (Exception) { Logger.LogRow(Logger.LogType.Error, "Error passing hotkey to system."); } diff --git a/IgniteBot.Installer/Spark.Installer.wixproj b/IgniteBot.Installer/Spark.Installer.wixproj index 6163711..d3be410 100644 --- a/IgniteBot.Installer/Spark.Installer.wixproj +++ b/IgniteBot.Installer/Spark.Installer.wixproj @@ -5,7 +5,11 @@ Release Spark.Installer - ..\bin\$(Configuration)\net10.0-windows10.0.17763.0 + + ..\bin\$(Configuration)\net10.0-windows10.0.17763.0 + $(SparkOutputDir) Debug @@ -16,7 +20,7 @@ - + PublishedComponents INSTALLFOLDER true @@ -38,6 +42,10 @@ True Binaries;Content;Satellites INSTALLFOLDER + + SelfContained=true;RuntimeIdentifier=win-x64;AppendRuntimeIdentifierToOutputPath=false @@ -47,21 +55,68 @@ true - - + + + + <_NestedRuntimeOutput>$(SparkOutputDir)\win-x64 + + + <_NestedRuntimeFile Include="$(_NestedRuntimeOutput)\**\*" /> + + + + <_NestedRuntimeOutputHadFiles Condition=" '@(_NestedRuntimeFile)' != '' ">true + + + + + + + $(DefineConstants);BuildVersion=%(AssemblyVersion.Version) - - - - + + + + - - - + + %(SparkAssembly.Version) + + + + <_BuiltInstaller Include="$(OutDir)**\$(TargetFileName)" /> + + + + + + - \ No newline at end of file + diff --git a/Logger.cs b/Logger.cs index 588518b..5b4c892 100644 --- a/Logger.cs +++ b/Logger.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -339,25 +339,25 @@ static async void Upload(string name, string data, string appName) return; // TODO This was uploading *super* often for some reason - var values = new Dictionary - { - { passwordField, SecretKeys.webLogPassword }, - { "file", name }, - { "data", data }, - { "folder", appName } - }; - try - { - var content = new FormUrlEncodedContent(values); - // var response = await client.PostAsync(SecretKeys.webLogURL, content); - // if (!response.IsSuccessStatusCode) - // { - // Console.WriteLine(response.Content.ToString()); - // } - } - catch (Exception e) - { - Console.WriteLine("Cound not upload log to the server: " + data.Substring(0, data.Length > 1000 ? 1000 : data.Length) + "\n Error: " + e); - } + // var values = new Dictionary + // { + // { passwordField, SecretKeys.webLogPassword }, + // { "file", name }, + // { "data", data }, + // { "folder", appName } + // }; + // try + // { + // var content = new FormUrlEncodedContent(values); + // // var response = await client.PostAsync(SecretKeys.webLogURL, content); + // // if (!response.IsSuccessStatusCode) + // // { + // // Console.WriteLine(response.Content.ToString()); + // // } + // } + // catch (Exception e) + // { + // Console.WriteLine("Cound not upload log to the server: " + data.Substring(0, data.Length > 1000 ? 1000 : data.Length) + "\n Error: " + e); + // } } } \ No newline at end of file diff --git a/Overlay/package-lock.json b/Overlay/package-lock.json index 18a650e..254136e 100644 --- a/Overlay/package-lock.json +++ b/Overlay/package-lock.json @@ -10,13 +10,14 @@ "dependencies": { "bulma": "^0.9.4", "reconnecting-websocket": "^4.4.0", + "sass": "^1.101.0", "three": "^0.151.3", "three.meshline": "^1.4.0", "ws": "^8.11.0" }, "devDependencies": { "@sveltejs/adapter-static": "^2.0.2", - "@sveltejs/kit": "^1.15.4", + "@sveltejs/kit": "^1.30.4", "@typescript-eslint/eslint-plugin": "^5.58.0", "@typescript-eslint/parser": "^5.58.0", "copyfiles": "^2.4.1", @@ -27,7 +28,7 @@ "prettier-plugin-svelte": "^2.10.0", "scss": "^0.2.4", "svelte": "^3.58.0", - "svelte-check": "^3.2.0", + "svelte-check": "^3.8.5", "svelte-preprocess": "^5.0.3", "tslib": "^2.5.0", "typescript": "^5.0.4", @@ -442,6 +443,16 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", @@ -485,10 +496,11 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.18", @@ -541,6 +553,315 @@ "node": ">= 8" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.21", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", @@ -557,25 +878,26 @@ } }, "node_modules/@sveltejs/kit": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.15.4.tgz", - "integrity": "sha512-m+Tid9nbtFawmiu85lDlal0AQ7UeuV48UsuKMe06QLr3ntMQSUzIPqyswNRZqFrar6NhVTUXQ0aO61M3U4MWpQ==", + "version": "1.30.4", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.30.4.tgz", + "integrity": "sha512-JSQIQT6XvdchCRQEm7BABxPC56WP5RYVONAi+09S8tmzeP43fBsRlr95bFmsTQM2RHBldfgQk+jgdnsKI75daA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { - "@sveltejs/vite-plugin-svelte": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^2.5.0", "@types/cookie": "^0.5.1", "cookie": "^0.5.0", - "devalue": "^4.3.0", + "devalue": "^4.3.1", "esm-env": "^1.0.0", "kleur": "^4.1.5", "magic-string": "^0.30.0", - "mime": "^3.0.0", + "mrmime": "^1.0.1", "sade": "^1.8.1", - "set-cookie-parser": "^2.5.1", + "set-cookie-parser": "^2.6.0", "sirv": "^2.0.2", "tiny-glob": "^0.2.9", - "undici": "5.20.0" + "undici": "^5.28.3" }, "bin": { "svelte-kit": "svelte-kit.js" @@ -584,28 +906,48 @@ "node": "^16.14 || >=18" }, "peerDependencies": { - "svelte": "^3.54.0", + "svelte": "^3.54.0 || ^4.0.0-next.0 || ^5.0.0-next.0", "vite": "^4.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.0.4.tgz", - "integrity": "sha512-pjqhW00KwK2uzDGEr+yJBwut+D+4XfJO/+bHHdHzPRXn9+1Jeq5JcFHyrUiYaXgHtyhX0RsllCTm4ssAx4ZY7Q==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.5.3.tgz", + "integrity": "sha512-erhNtXxE5/6xGZz/M9eXsmI7Pxa6MS7jyTy06zN3Ck++ldrppOnOlJwHHTsMC7DHDQdgUp4NAc4cDNQ9eGdB/w==", "dev": true, + "license": "MIT", "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^1.0.4", "debug": "^4.3.4", "deepmerge": "^4.3.1", "kleur": "^4.1.5", - "magic-string": "^0.30.0", - "svelte-hmr": "^0.15.1", + "magic-string": "^0.30.3", + "svelte-hmr": "^0.15.3", "vitefu": "^0.2.4" }, "engines": { "node": "^14.18.0 || >= 16" }, "peerDependencies": { - "svelte": "^3.54.0", + "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0-next.0", + "vite": "^4.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-1.0.4.tgz", + "integrity": "sha512-zjiuZ3yydBtwpF3bj0kQNV0YXe+iKE545QGZVTaylW3eAzFr+pJ/cwK8lZEaRp4JtaJXhD5DyWAV4AxLh6DgaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": "^14.18.0 || >= 16" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^2.2.0", + "svelte": "^3.54.0 || ^4.0.0", "vite": "^4.0.0" } }, @@ -961,18 +1303,6 @@ "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.4.tgz", "integrity": "sha512-86FlT5+1GrsgKbPLRRY7cGDg8fsJiP/jzTqXXVqiUZZ2aZT8uemEOHlU1CDU+TxklPEZ11HZNNWclRBBecP4CQ==" }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dev": true, - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1148,6 +1478,7 @@ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -1161,11 +1492,22 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/devalue": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.0.tgz", - "integrity": "sha512-n94yQo4LI3w7erwf84mhRUkUJfhLoCZiLyoOZ/QFsDbcWNZePrLwbQpvZBUG2TNxwV3VjCKPxkiiQA6pe3TrTA==", - "dev": true + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.3.tgz", + "integrity": "sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg==", + "dev": true, + "license": "MIT" }, "node_modules/dir-glob": { "version": "3.0.1", @@ -1740,6 +2082,12 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.6.tgz", + "integrity": "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -1809,7 +2157,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -1827,7 +2175,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -1904,6 +2252,7 @@ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -1955,15 +2304,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", - "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.13" - }, - "engines": { - "node": ">=12" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/merge2": { @@ -1988,18 +2335,6 @@ "node": ">=8.6" } }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "dev": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -2096,6 +2431,13 @@ "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "dev": true }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, "node_modules/noms": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", @@ -2527,6 +2869,54 @@ "rimraf": "bin.js" } }, + "node_modules/sass": { + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/scss": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/scss/-/scss-0.2.4.tgz", @@ -2623,20 +3013,10 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "dev": true, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/string_decoder": { "version": "0.10.31", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", @@ -2727,66 +3107,67 @@ } }, "node_modules/svelte-check": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.2.0.tgz", - "integrity": "sha512-6ZnscN8dHEN5Eq5LgIzjj07W9nc9myyBH+diXsUAuiY/3rt0l65/LCIQYlIuoFEjp2F1NhXqZiJwV9omPj9tMw==", + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.5.tgz", + "integrity": "sha512-3OGGgr9+bJ/+1nbPgsvulkLC48xBsqsgtc8Wam281H4G9F5v3mYGa2bHRsPuwHC5brKl4AxJH95QF73kmfihGQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.17", "chokidar": "^3.4.1", - "fast-glob": "^3.2.7", - "import-fresh": "^3.2.1", "picocolors": "^1.0.0", "sade": "^1.7.4", - "svelte-preprocess": "^5.0.3", + "svelte-preprocess": "^5.1.3", "typescript": "^5.0.3" }, "bin": { "svelte-check": "bin/svelte-check" }, "peerDependencies": { - "svelte": "^3.55.0" + "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" } }, "node_modules/svelte-hmr": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.1.tgz", - "integrity": "sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", + "integrity": "sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==", "dev": true, + "license": "ISC", "engines": { "node": "^12.20 || ^14.13.1 || >= 16" }, "peerDependencies": { - "svelte": ">=3.19.0" + "svelte": "^3.19.0 || ^4.0.0" } }, "node_modules/svelte-preprocess": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.3.tgz", - "integrity": "sha512-GrHF1rusdJVbOZOwgPWtpqmaexkydznKzy5qIC2FabgpFyKN57bjMUUUqPRfbBXK5igiEWn1uO/DXsa2vJ5VHA==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.4.tgz", + "integrity": "sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { "@types/pug": "^2.0.6", "detect-indent": "^6.1.0", - "magic-string": "^0.27.0", + "magic-string": "^0.30.5", "sorcery": "^0.11.0", "strip-indent": "^3.0.0" }, "engines": { - "node": ">= 14.10.0" + "node": ">= 16.0.0" }, "peerDependencies": { "@babel/core": "^7.10.2", "coffeescript": "^2.5.1", "less": "^3.11.3 || ^4.0.0", "postcss": "^7 || ^8", - "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0", + "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "pug": "^3.0.0", "sass": "^1.26.8", "stylus": "^0.55.0", "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "svelte": "^3.23.0", + "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -2822,18 +3203,6 @@ } } }, - "node_modules/svelte-preprocess/node_modules/magic-string": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", - "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.13" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -2986,15 +3355,16 @@ } }, "node_modules/undici": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.20.0.tgz", - "integrity": "sha512-J3j60dYzuo6Eevbawwp1sdg16k5Tf768bxYK4TUJRH7cBM4kFCbf3mOnM/0E3vQYXvpxITbbWmBafaDbxLDz3g==", + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", "dev": true, + "license": "MIT", "dependencies": { - "busboy": "^1.6.0" + "@fastify/busboy": "^2.0.0" }, "engines": { - "node": ">=12.18" + "node": ">=14.0" } }, "node_modules/untildify": { @@ -3071,12 +3441,13 @@ } }, "node_modules/vitefu": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.4.tgz", - "integrity": "sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", + "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", "dev": true, + "license": "MIT", "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "vite": { @@ -3408,6 +3779,12 @@ "integrity": "sha512-IoD2MfUnOV58ghIHCiil01PcohxjbYR/qCxsoC+xNgUwh1EY8jOOrYmu3d3a71+tJJ23uscEV4X2HJWMsPJu4g==", "dev": true }, + "@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true + }, "@humanwhocodes/config-array": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", @@ -3438,9 +3815,9 @@ "dev": true }, "@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true }, "@jridgewell/trace-mapping": { @@ -3487,6 +3864,117 @@ "fastq": "^1.6.0" } }, + "@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "optional": true, + "requires": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6", + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "dependencies": { + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "optional": true + } + } + }, + "@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "optional": true + }, + "@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "optional": true + }, + "@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "optional": true + }, + "@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "optional": true + }, + "@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "optional": true + }, + "@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "optional": true + }, + "@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "optional": true + }, + "@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "optional": true + }, + "@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "optional": true + }, + "@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "optional": true + }, + "@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "optional": true + }, + "@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "optional": true + }, + "@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "optional": true + }, "@polka/url": { "version": "1.0.0-next.21", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", @@ -3501,40 +3989,50 @@ "requires": {} }, "@sveltejs/kit": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.15.4.tgz", - "integrity": "sha512-m+Tid9nbtFawmiu85lDlal0AQ7UeuV48UsuKMe06QLr3ntMQSUzIPqyswNRZqFrar6NhVTUXQ0aO61M3U4MWpQ==", + "version": "1.30.4", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.30.4.tgz", + "integrity": "sha512-JSQIQT6XvdchCRQEm7BABxPC56WP5RYVONAi+09S8tmzeP43fBsRlr95bFmsTQM2RHBldfgQk+jgdnsKI75daA==", "dev": true, "requires": { - "@sveltejs/vite-plugin-svelte": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^2.5.0", "@types/cookie": "^0.5.1", "cookie": "^0.5.0", - "devalue": "^4.3.0", + "devalue": "^4.3.1", "esm-env": "^1.0.0", "kleur": "^4.1.5", "magic-string": "^0.30.0", - "mime": "^3.0.0", + "mrmime": "^1.0.1", "sade": "^1.8.1", - "set-cookie-parser": "^2.5.1", + "set-cookie-parser": "^2.6.0", "sirv": "^2.0.2", "tiny-glob": "^0.2.9", - "undici": "5.20.0" + "undici": "^5.28.3" } }, "@sveltejs/vite-plugin-svelte": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.0.4.tgz", - "integrity": "sha512-pjqhW00KwK2uzDGEr+yJBwut+D+4XfJO/+bHHdHzPRXn9+1Jeq5JcFHyrUiYaXgHtyhX0RsllCTm4ssAx4ZY7Q==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.5.3.tgz", + "integrity": "sha512-erhNtXxE5/6xGZz/M9eXsmI7Pxa6MS7jyTy06zN3Ck++ldrppOnOlJwHHTsMC7DHDQdgUp4NAc4cDNQ9eGdB/w==", "dev": true, "requires": { + "@sveltejs/vite-plugin-svelte-inspector": "^1.0.4", "debug": "^4.3.4", "deepmerge": "^4.3.1", "kleur": "^4.1.5", - "magic-string": "^0.30.0", - "svelte-hmr": "^0.15.1", + "magic-string": "^0.30.3", + "svelte-hmr": "^0.15.3", "vitefu": "^0.2.4" } }, + "@sveltejs/vite-plugin-svelte-inspector": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-1.0.4.tgz", + "integrity": "sha512-zjiuZ3yydBtwpF3bj0kQNV0YXe+iKE545QGZVTaylW3eAzFr+pJ/cwK8lZEaRp4JtaJXhD5DyWAV4AxLh6DgaQ==", + "dev": true, + "requires": { + "debug": "^4.3.4" + } + }, "@types/cookie": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.1.tgz", @@ -3762,15 +4260,6 @@ "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.4.tgz", "integrity": "sha512-86FlT5+1GrsgKbPLRRY7cGDg8fsJiP/jzTqXXVqiUZZ2aZT8uemEOHlU1CDU+TxklPEZ11HZNNWclRBBecP4CQ==" }, - "busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dev": true, - "requires": { - "streamsearch": "^1.1.0" - } - }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3911,10 +4400,16 @@ "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", "dev": true }, + "detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "optional": true + }, "devalue": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.0.tgz", - "integrity": "sha512-n94yQo4LI3w7erwf84mhRUkUJfhLoCZiLyoOZ/QFsDbcWNZePrLwbQpvZBUG2TNxwV3VjCKPxkiiQA6pe3TrTA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.3.tgz", + "integrity": "sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg==", "dev": true }, "dir-glob": { @@ -4359,6 +4854,11 @@ "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", "dev": true }, + "immutable": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.6.tgz", + "integrity": "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ==" + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -4413,7 +4913,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true + "devOptional": true }, "is-fullwidth-code-point": { "version": "3.0.0", @@ -4425,7 +4925,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "requires": { "is-extglob": "^2.1.1" } @@ -4522,12 +5022,12 @@ } }, "magic-string": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", - "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "requires": { - "@jridgewell/sourcemap-codec": "^1.4.13" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "merge2": { @@ -4546,12 +5046,6 @@ "picomatch": "^2.3.1" } }, - "mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "dev": true - }, "min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4615,6 +5109,12 @@ "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "dev": true }, + "node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "optional": true + }, "noms": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", @@ -4906,6 +5406,32 @@ } } }, + "sass": { + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "requires": { + "@parcel/watcher": "^2.4.1", + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "dependencies": { + "chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "requires": { + "readdirp": "^5.0.0" + } + }, + "readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==" + } + } + }, "scss": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/scss/-/scss-0.2.4.tgz", @@ -4977,14 +5503,7 @@ "source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true - }, - "streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "dev": true + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" }, "string_decoder": { "version": "0.10.31", @@ -5049,50 +5568,37 @@ "dev": true }, "svelte-check": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.2.0.tgz", - "integrity": "sha512-6ZnscN8dHEN5Eq5LgIzjj07W9nc9myyBH+diXsUAuiY/3rt0l65/LCIQYlIuoFEjp2F1NhXqZiJwV9omPj9tMw==", + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.5.tgz", + "integrity": "sha512-3OGGgr9+bJ/+1nbPgsvulkLC48xBsqsgtc8Wam281H4G9F5v3mYGa2bHRsPuwHC5brKl4AxJH95QF73kmfihGQ==", "dev": true, "requires": { "@jridgewell/trace-mapping": "^0.3.17", "chokidar": "^3.4.1", - "fast-glob": "^3.2.7", - "import-fresh": "^3.2.1", "picocolors": "^1.0.0", "sade": "^1.7.4", - "svelte-preprocess": "^5.0.3", + "svelte-preprocess": "^5.1.3", "typescript": "^5.0.3" } }, "svelte-hmr": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.1.tgz", - "integrity": "sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", + "integrity": "sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==", "dev": true, "requires": {} }, "svelte-preprocess": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.3.tgz", - "integrity": "sha512-GrHF1rusdJVbOZOwgPWtpqmaexkydznKzy5qIC2FabgpFyKN57bjMUUUqPRfbBXK5igiEWn1uO/DXsa2vJ5VHA==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.4.tgz", + "integrity": "sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==", "dev": true, "requires": { "@types/pug": "^2.0.6", "detect-indent": "^6.1.0", - "magic-string": "^0.27.0", + "magic-string": "^0.30.5", "sorcery": "^0.11.0", "strip-indent": "^3.0.0" - }, - "dependencies": { - "magic-string": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", - "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", - "dev": true, - "requires": { - "@jridgewell/sourcemap-codec": "^1.4.13" - } - } } }, "text-table": { @@ -5223,12 +5729,12 @@ "dev": true }, "undici": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.20.0.tgz", - "integrity": "sha512-J3j60dYzuo6Eevbawwp1sdg16k5Tf768bxYK4TUJRH7cBM4kFCbf3mOnM/0E3vQYXvpxITbbWmBafaDbxLDz3g==", + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", "dev": true, "requires": { - "busboy": "^1.6.0" + "@fastify/busboy": "^2.0.0" } }, "untildify": { @@ -5266,9 +5772,9 @@ } }, "vitefu": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.4.tgz", - "integrity": "sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", + "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", "dev": true, "requires": {} }, diff --git a/Overlay/package.json b/Overlay/package.json index 1927fdf..b788fff 100644 --- a/Overlay/package.json +++ b/Overlay/package.json @@ -14,29 +14,30 @@ }, "devDependencies": { "@sveltejs/adapter-static": "^2.0.2", - "@sveltejs/kit": "^1.15.4", + "@sveltejs/kit": "^1.30.4", "@typescript-eslint/eslint-plugin": "^5.58.0", "@typescript-eslint/parser": "^5.58.0", + "copyfiles": "^2.4.1", "eslint": "^8.38.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-svelte3": "^4.0.0", "prettier": "^2.8.7", "prettier-plugin-svelte": "^2.10.0", + "scss": "^0.2.4", "svelte": "^3.58.0", - "svelte-check": "^3.2.0", + "svelte-check": "^3.8.5", "svelte-preprocess": "^5.0.3", "tslib": "^2.5.0", "typescript": "^5.0.4", - "vite": "^4.2.1", - "scss": "^0.2.4", - "copyfiles": "^2.4.1" + "vite": "^4.2.1" }, "type": "module", "dependencies": { "bulma": "^0.9.4", "reconnecting-websocket": "^4.4.0", - "ws": "^8.11.0", + "sass": "^1.101.0", "three": "^0.151.3", - "three.meshline": "^1.4.0" + "three.meshline": "^1.4.0", + "ws": "^8.11.0" } } diff --git a/Overlay/src/routes/(bulma)/overlays/match_setup/+page.svelte b/Overlay/src/routes/(bulma)/overlays/match_setup/+page.svelte index 08f70b2..445b7dd 100644 --- a/Overlay/src/routes/(bulma)/overlays/match_setup/+page.svelte +++ b/Overlay/src/routes/(bulma)/overlays/match_setup/+page.svelte @@ -260,7 +260,15 @@ let isLoading = true; let dirty = false; sw.subscribe("overlay_config", data => { - config = data; + if (!dirty) { + config = data; + } else { + // Only update non-text fields if the user is currently editing + config.visibility = data.visibility; + config.team_names_source = data.team_names_source; + config.round_scores = data.round_scores; + config.caster_prefs = data.caster_prefs; + } isLoading = false; }); onMount(() => { diff --git a/Overlay/static/js/full_overlay.js b/Overlay/static/js/full_overlay.js index 25ede9d..ba182f5 100644 --- a/Overlay/static/js/full_overlay.js +++ b/Overlay/static/js/full_overlay.js @@ -152,18 +152,24 @@ if (orange_goal_banner !== null && blue_goal_banner !== null) { sw.subscribe("goal", goal => { if (goal["team_scored"] === "orange") { orange_goal_banner.classList.add("visible"); - orange_goal_type.innerText = goal["goal_type"]; - orange_goal_player_name.innerText = goal["player_name"]; - orange_goal_point_value.innerText = goal["point_value"]; + if (orange_goal_banner_text) orange_goal_banner_text.innerText = goal["goal_type"]; + if (orange_goal_banner_secondary) orange_goal_banner_secondary.innerText = goal["player_name"]; + if (orange_goal_banner_point_value) orange_goal_banner_point_value.innerText = goal["point_value"]; + if (orange_goal_type) orange_goal_type.innerText = goal["goal_type"]; + if (orange_goal_player_name) orange_goal_player_name.innerText = goal["player_name"]; + if (orange_goal_point_value) orange_goal_point_value.innerText = goal["point_value"]; // hide it after a delay setTimeout(function () { orange_goal_banner.classList.remove("visible"); }, 10000); } else if (goal["team_scored"] === "blue") { blue_goal_banner.classList.add("visible"); - blue_goal_type.innerText = goal["goal_type"]; - blue_goal_player_name.innerText = goal["player_name"]; - blue_goal_point_value.innerText = goal["point_value"]; + if (blue_goal_banner_text) blue_goal_banner_text.innerText = goal["goal_type"]; + if (blue_goal_banner_secondary) blue_goal_banner_secondary.innerText = goal["player_name"]; + if (blue_goal_banner_point_value) blue_goal_banner_point_value.innerText = goal["point_value"]; + if (blue_goal_type) blue_goal_type.innerText = goal["goal_type"]; + if (blue_goal_player_name) blue_goal_player_name.innerText = goal["player_name"]; + if (blue_goal_point_value) blue_goal_point_value.innerText = goal["point_value"]; // hide it after a delay setTimeout(function () { blue_goal_banner.classList.remove("visible"); diff --git a/Overlay/svelte.config.js b/Overlay/svelte.config.js index f2c12f6..f245587 100644 --- a/Overlay/svelte.config.js +++ b/Overlay/svelte.config.js @@ -9,7 +9,13 @@ const config = { kit: { adapter: adapter(), - trailingSlash: "always", + typescript: { + config: (config) => { + delete config.compilerOptions.importsNotUsedAsValues; + delete config.compilerOptions.preserveValueImports; + return config; + } + } } }; diff --git a/Overlay/tsconfig.json b/Overlay/tsconfig.json index 6ae0c8c..c8f81f5 100644 --- a/Overlay/tsconfig.json +++ b/Overlay/tsconfig.json @@ -8,7 +8,10 @@ "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, - "strict": true + "strict": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "ignoreDeprecations": "5.0" } // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // diff --git a/OverlayServer.cs b/OverlayServer.cs index d43a5fc..6b3d370 100644 --- a/OverlayServer.cs +++ b/OverlayServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -24,9 +24,9 @@ namespace Spark { public class OverlayServer { - private IWebHost server; + private IHost server; private readonly SemaphoreSlim restartLock = new SemaphoreSlim(1, 1); - private bool isRestarting = false; + public static string StaticOverlayFolder => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "IgniteVR", "Spark", "Overlays"); @@ -55,7 +55,7 @@ private async Task RestartServer() try { - isRestarting = true; + // Fetch data needed for the server await OverlaysCustom.FetchOverlayData(); @@ -68,11 +68,14 @@ private async Task RestartServer() } // Create and start the server - server = WebHost + server = Host .CreateDefaultBuilder() - .UseKestrel(x => { x.ListenAnyIP(6724); }) - .ConfigureLogging((logging) => { /* Configure logging if needed, or keep silent to save perf */ }) - .UseStartup() + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseKestrel(x => { x.ListenAnyIP(6724); }) + .ConfigureLogging((logging) => { /* Configure logging if needed, or keep silent to save perf */ }) + .UseStartup(); + }) .Build(); await server.StartAsync(); @@ -83,7 +86,7 @@ private async Task RestartServer() } finally { - isRestarting = false; + restartLock.Release(); } } @@ -136,6 +139,26 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) // Global CORS policy - applied before static files app.UseCors("CorsPolicy"); + string buildArenaPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Overlay", "build_arena"); + string buildCombatPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Overlay", "build_combat"); + + app.Use(async (context, next) => + { + if (!context.Request.Path.Value.Contains(".")) + { + string pathWithHtml = context.Request.Path.Value + ".html"; + string relativePath = pathWithHtml.TrimStart('/'); + + if (File.Exists(Path.Combine(StaticOverlayFolder, relativePath)) || + File.Exists(Path.Combine(buildArenaPath, relativePath)) || + File.Exists(Path.Combine(buildCombatPath, relativePath))) + { + context.Request.Path = pathWithHtml; + } + } + await next(); + }); + // Serve from AppData Overlays folder app.UseFileServer(new FileServerOptions { @@ -150,17 +173,34 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) } }); - // Serve from Build folder - string buildPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Overlay", "build"); - if (Directory.Exists(buildPath)) + // Serve from Arena Build folder + if (Directory.Exists(buildArenaPath)) + { + app.UseFileServer(new FileServerOptions + { + FileProvider = new PhysicalFileProvider(buildArenaPath), + RequestPath = "", + EnableDefaultFiles = true, + StaticFileOptions = + { + ServeUnknownFileTypes = true, + ContentTypeProvider = provider, + OnPrepareResponse = onPrepareResponse + } + }); + } + + // Serve from Combat Build folder + if (Directory.Exists(buildCombatPath)) { app.UseFileServer(new FileServerOptions { - FileProvider = new PhysicalFileProvider(buildPath), + FileProvider = new PhysicalFileProvider(buildCombatPath), RequestPath = "", EnableDefaultFiles = true, StaticFileOptions = { + ServeUnknownFileTypes = true, ContentTypeProvider = provider, OnPrepareResponse = onPrepareResponse } @@ -331,7 +371,7 @@ private static void MapEmbeddedResources(Microsoft.AspNetCore.Routing.IEndpointR _ => "application/octet-stream" }; - context.Response.Headers.Add("content-type", contentType); + context.Response.Headers.Append("content-type", contentType); // Prefer loading from disk if available (for dev/modding), fall back to resource if (sparkPath != null) { diff --git a/OverlaysCustom.cs b/OverlaysCustom.cs index 9159c98..89f7371 100644 --- a/OverlaysCustom.cs +++ b/OverlaysCustom.cs @@ -11,7 +11,7 @@ namespace Spark { public static class OverlaysCustom { - private static Dictionary overlayData; + public static bool downloading; public static async Task FetchOverlayData() diff --git a/PC-PC_Spectate_Me.md b/PC-PC_Spectate_Me.md index eb84767..63baaf3 100644 --- a/PC-PC_Spectate_Me.md +++ b/PC-PC_Spectate_Me.md @@ -1,11 +1,59 @@ -## Using "Spectate Me" on two 2 PCs +## Spectating yourself on a second PC -### You will need: +There are two ways to do this. **Simple Spectate Mode** works over the internet and follows you +into private matches and lobbies; **Spectate Me** is the older local-network method. -- One PCs capable of running EchoVR on the same network +--- + +## Option 1 — Simple Spectate Mode (recommended) + +Follows you using Friends presence, so the two PCs don't need to be on the same network and it +works for public matches, private matches and social lobbies alike. + +### You will need + +- Echo VR installed on both PCs +- Spark installed on both PCs +- API enabled in the game settings on both PCs (Spark can turn this on for you) +- Both copies of Spark logged into Discord with **the same account** + +### Setup + +1. On your playing PC: + 1. Open Spark and log in with Discord (button at the top left) + 2. Launch the game and play as normal + +2. On your spectating PC: + 1. Open Spark and log in with Discord — the same account + 2. Go to **Settings → Simple Spectate Mode** + 3. Choose **Myself, on my other PC** + 4. Click **Launch Spectator** + +The spectator client launches and then follows your playing PC into every session it joins, +switching automatically whenever you change match or drop back to a lobby. + +While it's following, the spectating copy of Spark stops publishing its own presence — both PCs +sign in as the same Discord account and share one presence row, so otherwise the spectator would +overwrite the session it's trying to read and end up chasing itself. + +### Spectating anyone else + +Pick **Anyone, by name** instead and type their Echo VR or Discord name. + +Anyone can be followed into public matches — Spark searches the public match API by name, no setup +needed on their end. If they're also on your Friends list and running Spark, you'll follow them into +private matches and lobbies as well, because their own client publishes the session id and those +sessions are never listed publicly. + +--- + +## Option 2 — Spectate Me (same network only) + +### You will need + +- Both PCs capable of running Echo VR on the same network - Spark installed on both PCs - API enabled in the game settings on both PCs - - You can use Spark to enable the API setting ### Setup @@ -13,14 +61,13 @@ 1. Launch the game normally and join a private/public match or lobby 2. Launch Spark 3. The IP Address in Spark should be set to 127.0.0.1 (or click the Local PC button) - -2. On your Spectating PC: +2. On your spectating PC: 1. Open Spark 2. Click "Settings" 3. Put your playing PC's local IP address in the area for Quest IP. 4. Change the port to 6724 5. If the playing PC is in a match/lobby and has Spark running, then Spark should say "Connected" at this point. 6. Click "Spectate Me" - + If all is well, then your spectator will follow you into pubs and privates. diff --git a/Program.cs b/Program.cs index 6c8319e..cd6f10d 100644 --- a/Program.cs +++ b/Program.cs @@ -39,6 +39,16 @@ internal class Program /// public static bool running = true; + /// + /// Set by the -uipreview command-line flag. Populates the dashboard from a fixed sample match + /// so the layout can be worked on without a live game. Read-only: it never uploads, writes to + /// the database, or touches the real API. + /// + public static bool uiPreviewMode; + + /// Set by -uipreviewcombat alongside -uipreview: same fixed-frame preview, but as an Echo Combat match so the Combat dashboard can be worked on too. + public static bool uiPreviewCombat; + public enum ConnectionState { NotConnected, Menu, // loading screen or menu - the response is not json @@ -55,6 +65,12 @@ public enum ConnectionState { // public const string APIURL = "http://127.0.0.1:8000"; public const string WRITE_API_URL = "http://127.0.0.1:6723/"; + /// + /// The Railway backend that actually answers. Was hardcoded inline in TTSController and + /// DiscordOAuth; APIURL above still points at api.ignitevr.gg, which no longer responds. + /// + public const string SPARK_API_URL = "https://sparkapi-production-e6df.up.railway.app"; + // public static string currentAccessCodeUsername = ""; public static string InstalledSpeakerSystemVersion = ""; public static bool IsSpeakerSystemUpdateAvailable; @@ -94,7 +110,6 @@ private class UserAtTime static List stunningMatchedPairs = new List(); private const float stunMatchingTimeout = 4f; - public static string lastDateTimeString; public static string lastJSON; public static string lastBonesJSON; public static ulong fetchFrameIndex = 0; @@ -116,11 +131,17 @@ private class UserAtTime /// /// Not actually Hz. 1/Hz. /// - public static float StatsIntervalMs => statsDeltaTimes[SparkSettings.instance.lowFrequencyMode ? 1 : 0]; - private static bool? lastLowFreqMode = null; + public static float StatsIntervalMs => statsDeltaTimes[SparkSettings.instance.lowFrequencyMode ? 2 : 0]; - // 30 or 15 hz main fetch speed - private static readonly List statsDeltaTimes = new List { 33.3333333f, 66.6666666f }; + /// + /// Actual measured time between fetches. StatsIntervalMs is only the target, + /// the OS timer wakes on ~15.6ms ticks so the rate is usually lower and jitters. + /// Use this, not StatsIntervalMs, to convert between sample counts and seconds + /// + public static float MeasuredIntervalMs { get; private set; } = 16.6666666f; + + // 60, 30 or 15 hz main fetch speed + private static readonly List statsDeltaTimes = new List { 16.6666666f, 33.3333333f, 66.6666666f }; public static LiveWindow liveWindow; @@ -143,6 +164,12 @@ private class UserAtTime public static TTSController synth; public static ReplayClips replayClips; public static ReplayFilesManager replayFilesManager; + + /// + /// How long shutdown will wait for the replay buffer to finish writing and compressing + /// before giving up and closing regardless. + /// + private const int ReplayFlushTimeoutSeconds = 120; public static CameraWriteController cameraWriteController; public static CameraWrite cameraWriteWindow; public static EchoGPController echoGPController; @@ -159,6 +186,7 @@ private class UserAtTime public static Medal medal; private static OverlayServer overlayServer; public static SpectateMeController spectateMeController; + public static SimpleSpectateController simpleSpectateController; public static UploadController uploadController; public static LocalDatabase localDatabase; public static NetMQEvents netMQEvents; @@ -168,8 +196,6 @@ private class UserAtTime private static Timer ccuCounter; public static CameraController cameraController; - public static CoreWebView2Environment webView2Environment; - #region Event Callbacks @@ -192,7 +218,7 @@ private class UserAtTime /// /// Called when a frame is finished with conversion and it is an Echo Combat frame /// - public static Action NewCombatFrame; + public static event Action NewCombatFrame; /// /// Called when connectedToGame state changes. /// This could be on loading screen or lobby @@ -225,8 +251,8 @@ private class UserAtTime public static Action NewRound; - public static Action JoinedLobby; - public static Action LeftLobby; + public static Action JoinedLobby = delegate { }; + public static Action LeftLobby = delegate { }; public static Action PlayerJoined; public static Action PlayerLeft; @@ -239,7 +265,7 @@ private class UserAtTime /// /// Frame is the last frame of the last match /// - public static Action MatchReset; + public static event Action MatchReset; /// /// Frame, nearest player, distance to podium /// @@ -319,8 +345,8 @@ private class UserAtTime public static Action LargePing; public static Action RulesChanged; - public static Action ManualClip; - public static Action BadWordDetected; + public static Action ManualClip = delegate { }; + public static Action BadWordDetected = delegate { }; /// /// For any event type that has EventData @@ -413,12 +439,23 @@ public static void Main(string[] args, App app) netMQEvents = new NetMQEvents(); - InstalledSpeakerSystemVersion = FindEchoSpeakerSystemInstallVersion(); - if (InstalledSpeakerSystemVersion.Length > 0) + _ = Task.Run(() => { - string[] latestSpeakerSystemVer = GetLatestSpeakerSystemURLVer(); - IsSpeakerSystemUpdateAvailable = latestSpeakerSystemVer[1] != InstalledSpeakerSystemVersion; - } + try + { + InstalledSpeakerSystemVersion = FindEchoSpeakerSystemInstallVersion(); + if (InstalledSpeakerSystemVersion.Length > 0) + { + string[] latestSpeakerSystemVer = GetLatestSpeakerSystemURLVer(); + IsSpeakerSystemUpdateAvailable = IsNewerSpeakerSystemVersion( + latestSpeakerSystemVer[1], InstalledSpeakerSystemVersion); + } + } + catch (Exception ex) + { + Logger.Error($"[SpeakerSystem] Background update check failed: {ex.Message}"); + } + }); SparkSettings.instance.sparkExeLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Spark.exe"); @@ -480,6 +517,14 @@ public static void Main(string[] args, App app) SparkSettings.instance.showDatabaseLog = true; } + // Fills the UI with a fixed sample match so the dashboard can be looked at and + // iterated on without a live game. Nothing is uploaded or written while it's on. + if (args.Contains("-uipreview")) + { + uiPreviewMode = true; + uiPreviewCombat = args.Contains("-uipreviewcombat"); + } + // make an exception for certain users // Note that these usernames are not the access codes. Don't even try. @@ -492,15 +537,34 @@ public static void Main(string[] args, App app) - if (!SparkSettings.instance.onlyActivateHighlightsWhenGameIsOpen && - SparkSettings.instance.isNVHighlightsEnabled) - { - HighlightsHelper.SetupNVHighlights(); - } - else + _ = Task.Run(() => { - HighlightsHelper.InitHighlightsSDK(true); - } + if (!SparkSettings.instance.onlyActivateHighlightsWhenGameIsOpen && + SparkSettings.instance.isNVHighlightsEnabled) + { + HighlightsHelper.SetupNVHighlights(); + } + else + { + HighlightsHelper.InitHighlightsSDK(true); + } + + // Safely update UI once done, if highlights successfully initialized + if (HighlightsHelper.didHighlightsInit && HighlightsHelper.isNVHighlightsEnabled) + { + Application.Current?.Dispatcher?.Invoke(() => + { + if (liveWindow != null) + { + liveWindow.showHighlights.IsEnabled = HighlightsHelper.DoNVClipsExist(); + liveWindow.showHighlights.Visibility = Visibility.Visible; + liveWindow.showHighlights.Content = HighlightsHelper.DoNVClipsExist() + ? Properties.Resources.Show + " " + HighlightsHelper.nvHighlightClipCount + " " + Properties.Resources.Highlights + : Properties.Resources.No_clips_available; + } + }); + } + }); // only enable Highlights when game is open ConnectedToGame += (_, _) => @@ -546,6 +610,7 @@ public static void Main(string[] args, App app) speechRecognizer = new SpeechRecognition(); loggerEvents = new LoggerEvents(); spectateMeController = new SpectateMeController(); + simpleSpectateController = new SimpleSpectateController(); uploadController = new UploadController(); localDatabase = new LocalDatabase(); cameraController = new CameraController(); @@ -583,7 +648,16 @@ public static void Main(string[] args, App app) { try { - EchoVRSettingsManager.ReloadLoadingTips(); + JToken embeddedTips = EchoVRSettingsManager.ReadEchoVRLoadingTips(true); + if (embeddedTips != null) + { + EchoVRSettingsManager.loadingTips = embeddedTips; + } + else + { + EchoVRSettingsManager.ReloadLoadingTips(); + } + if (EchoVRSettingsManager.loadingTips != null) { JToken toolsAll = EchoVRSettingsManager.loadingTips["tools-all"]; @@ -655,9 +729,9 @@ public static void Main(string[] args, App app) private static void OnLeftGame(Frame obj) { - // Tell the Friends bot the user went offline - try { _ = FriendsTab.PushOffline(); } - catch (Exception e) { Console.WriteLine("Friends offline push error: " + e.Message); } + // Tell the Friends bot the user is now in the menu/transitioning + try { FriendsPresence.PushMenu(); } + catch (Exception e) { Console.WriteLine("Friends menu push error: " + e.Message); } } @@ -684,6 +758,133 @@ public static bool IsWindowsStore() private static void CCUCounter(object state) { _ = FetchUtils.client.PostAsync($"/spark_is_open?hw_id={DeviceId}&client_name={SparkSettings.instance.client_name}", null); + _ = ReportPlaytime(); + } + + private static string anonymousDeviceIdVal; + + /// + /// An opaque, stable per-install id: SHA-256 of the MAC address, truncated to 32 hex chars. + /// + /// Deliberately not itself, which is the raw MAC. The global + /// playtime pool needs to tell installs apart so it can de-duplicate and rate-limit them — + /// it does not need to know whose they are, and a hash gives the first without the second. + /// + /// + public static string AnonymousDeviceId + { + get + { + if (anonymousDeviceIdVal != null) return anonymousDeviceIdVal; + + string mac = DeviceId; + if (string.IsNullOrEmpty(mac)) return null; + + byte[] hash = System.Security.Cryptography.SHA256.HashData( + Encoding.UTF8.GetBytes("spark-playtime-v1:" + mac)); + return anonymousDeviceIdVal = Convert.ToHexString(hash)[..32].ToLowerInvariant(); + } + } + + /// + /// The name this install appears under on the playtime leaderboard: the Discord identity + /// when the user is logged in, otherwise their EchoVR name. Null when neither is set, in + /// which case the install still contributes to the community total but gets no board row. + /// + /// Discord is preferred because it's the one of the two the user can't casually retype — + /// is a free-text field in Settings, so it makes a + /// weak claim to an identity. Reads global_name (Discord's display name) ahead of the + /// username handle, matching what the rest of the app shows. + /// + /// + private static string LeaderboardDisplayName() + { + // IsLoggedIn only checks for a token; the profile fetch that fills discordUserData can + // still have failed, so both are checked — same guard the player card uses. + if (DiscordOAuth.IsLoggedIn && DiscordOAuth.discordUserData != null) + { + if (DiscordOAuth.discordUserData.TryGetValue("global_name", out string globalName) && + !string.IsNullOrWhiteSpace(globalName)) + { + return globalName; + } + + if (DiscordOAuth.discordUserData.TryGetValue("username", out string username) && + !string.IsNullOrWhiteSpace(username)) + { + return username; + } + } + + string echoName = SparkSettings.instance.client_name; + return string.IsNullOrWhiteSpace(echoName) ? null : echoName; + } + + /// + /// Contributes this install's lifetime playtime to the global pool, on the CCU heartbeat + /// that already runs every 60 seconds. + /// + /// Sends the running total rather than a delta since the last report, which makes the call + /// idempotent: a dropped response, a retry or a duplicate costs nothing, and an install + /// that was closed for a week simply catches up on its next report. The server keeps the + /// larger of what it holds and what arrives, capped by the wall-clock time since that + /// install last reported. + /// + /// + private static async Task ReportPlaytime() + { + if (!SparkSettings.instance.shareGlobalPlaytime) return; + + string deviceId = AnonymousDeviceId; + if (deviceId == null) return; + + // Written to settings every 60s by LiveWindow.UpdateSessionCard, on the same cadence + // as this heartbeat, so it is at most one tick behind the live figure. + double seconds = SparkSettings.instance.totalPlaytimeSeconds; + if (seconds <= 0) return; + + try + { + string displayName = LeaderboardDisplayName(); + + string body = JsonConvert.SerializeObject(new + { + device_id = deviceId, + seconds, + version = AppVersionString(), + display_name = displayName, + }); + using StringContent content = new StringContent(body, Encoding.UTF8, "application/json"); + using HttpResponseMessage response = + await FetchUtils.client.PostAsync($"{SPARK_API_URL}/playtime", content); + } + catch (Exception) + { + // Best effort. A missed report costs nothing: the next one carries the same total. + } + } + + /// + /// Global playtime across every install that reports in, or null if it hasn't been fetched + /// yet (or the server is unreachable). Refreshed by . + /// + public static double? GlobalPlaytimeSeconds { get; private set; } + + public static async Task RefreshGlobalPlaytime() + { + try + { + string json = await FetchUtils.GetRequestAsync($"{SPARK_API_URL}/playtime/total"); + if (string.IsNullOrEmpty(json)) return; + + JObject parsed = JObject.Parse(json); + double? total = (double?)parsed["total_seconds"]; + if (total.HasValue) GlobalPlaytimeSeconds = total.Value; + } + catch (Exception) + { + // Leave the last known value in place rather than blanking the label on a blip. + } } /// @@ -726,13 +927,33 @@ private static async Task GentleClose() if (replayFilesManager != null) { - while (replayFilesManager.zipping || - replayFilesManager.replayThreadActive || + // Bounded. This wait used to be open-ended, so anything that left a flag set or a + // write that never returned trapped the app on "Compressing Replay File..." with + // no way out but the task manager. The cap is generous enough for a genuinely + // large replay; past it we say so in the log and close anyway, since a truncated + // recording beats a process that won't exit. + DateTime replayFlushDeadline = DateTime.UtcNow.AddSeconds(ReplayFlushTimeoutSeconds); + + while (replayFilesManager.zipping || + replayFilesManager.replayThreadActive || replayFilesManager.splitting) { + if (DateTime.UtcNow > replayFlushDeadline) + { + LogRow(LogType.Error, + $"Replay flush did not finish within {ReplayFlushTimeoutSeconds}s " + + $"(zipping={replayFilesManager.zipping}, queued={replayFilesManager.replayThreadActive}, splitting={replayFilesManager.splitting}). Closing anyway."); + break; + } + if (closingWindow != null) closingWindow.label.Content = Resources.Compressing_Replay_File___; await Task.Delay(10); } + + // Never called anywhere before this, so the write thread, the queue and its + // cancellation token were all left dangling — along with the two-second bounded + // wait inside Dispose that was written for exactly this moment. + replayFilesManager.Dispose(); } if (closingWindow != null) closingWindow.label.Content = Resources.Closing_NVIDIA_Highlights___; @@ -783,9 +1004,32 @@ private static async Task MainLoop() { fetchClient.Timeout = TimeSpan.FromSeconds(5); + // The preview holds a fixed frame, so the fetch loop would only overwrite it. Park here + // instead of polling an API we're deliberately not talking to. + if (uiPreviewMode) + { + lastFrame = UiPreviewData.BuildFrame(); + UiPreviewData.PopulateRounds(lastFrame); + UiPreviewData.PopulateEventLog(); + if (uiPreviewCombat) UiPreviewData.PopulateCombatPreview(lastFrame); + connectionState = ConnectionState.InGame; + while (running) await Task.Delay(250); + return; + } + DateTime lastFetch = DateTime.UtcNow; + Stopwatch loopSw = Stopwatch.StartNew(); while (running) { + // measure what the loop did + // bounds reject the first iteration and the long waits during disconnect backoff + double loopMs = loopSw.Elapsed.TotalMilliseconds; + loopSw.Restart(); + if (loopMs > 1 && loopMs < 200) + { + MeasuredIntervalMs = MeasuredIntervalMs * .9f + (float)loopMs * .1f; + } + fetchSw.Restart(); frameIndex++; @@ -851,6 +1095,7 @@ private static async Task MainLoop() }); // parse the API data + CombatDataParser.Parse(session); Frame f = Frame.FromJSON(frameTime, session, bones); if (f != null) @@ -894,6 +1139,22 @@ private static async Task MainLoop() LogRow(LogType.Error, $"Error in fetch request.\n{ex}"); } + if (connectionState != lastConnectionState) + { + switch (connectionState) + { + case ConnectionState.Menu: + FriendsPresence.PushMenu(); + break; + case ConnectionState.InLobby: + FriendsPresence.PushLobby(); + break; + case ConnectionState.NotConnected: + FriendsPresence.PushOffline(); + break; + } + } + lastConnectionState = connectionState; @@ -901,7 +1162,7 @@ private static async Task MainLoop() int delay = Math.Clamp((int)(StatsIntervalMs - fetchSw.ElapsedMilliseconds - 3), 0, 1000); if (delay > 0) { - Thread.Sleep(delay); + await Task.Delay(delay); } } } @@ -1144,7 +1405,10 @@ public static bool KillEchoVR(string findInArgs = null) Process[] process = Process.GetProcessesByName("echovr"); foreach (Process p in process) { - if (findInArgs == null || GetCommandLine(p).Contains(findInArgs)) + // GetCommandLine returns null when a process's command line can't be read. That + // used to throw straight out of here, abandoning the rest of the kill, so a caller + // that kills-then-launches could stack a second client on top of the first. + if (findInArgs == null || GetCommandLine(p)?.Contains(findInArgs) == true) { try { @@ -1209,6 +1473,36 @@ public static bool StartEchoVR(JoinType joinType, int port = 6721, bool noovr = return true; } + /// + /// Moves an already-running client into a session without claiming a team slot. + /// + /// The body deliberately carries no team_idx: that field is what makes this a player join. + /// Every value claims a team — including -1, which reads like "no team" but is the Random + /// Team option — so sending it drops a spectator client into the match as a player. Leaving + /// it out entirely keeps the client in the mode it launched in, so a -spectatorstream client + /// stays a spectator. This mirrors the server browser's spectate, which is known to work. + /// + public static async Task APISpectate(string session_id, string overrideIP = null, int overridePort = 0) + { + try + { + Dictionary body = new Dictionary() + { + { "session_id", session_id }, + { "password", "" }, + }; + + string ip = overrideIP ?? SparkSettings.instance.echoVRIP; + int port = overridePort == 0 ? SparkSettings.instance.echoVRPort : overridePort; + string resp = await FetchUtils.PostRequestAsync($"http://{ip}:{port}/join_session", null, JsonConvert.SerializeObject(body)); + return !string.IsNullOrEmpty(resp) && resp.StartsWith("{"); + } + catch (Exception) + { + return false; + } + } + public static async Task APIJoin(string session_id, int teamIndex = -1, string overrideIP = null, int overridePort = 0) { try @@ -2382,13 +2676,6 @@ private static void GenerateEvents(Frame frame, Frame lastFrame) } // TODO check if a pass was made - if (false) - { - CurrentRound.events.Enqueue(new EventData(CurrentRound, EventContainer.EventType.pass, frame.game_clock, - team, player, null, player.head.Position, Vector3.Zero)); - LogRow(LogType.File, frame.sessionid, - frame.game_clock_display + " - " + player.name + " made a pass"); - } } } @@ -2480,7 +2767,7 @@ private static void OnJoinedGame(Frame frame) } } - _ = FriendsTab.PushLobbyUpdate(lobbyId, team, mode); + FriendsPresence.PushMatch(lobbyId, team, mode); } catch (Exception e) { @@ -3172,7 +3459,7 @@ private static bool CheckIfLaunchedWithCustomURLHandlerParam(string[] args) await APIJoin(parts[3], joinType == JoinType.Spectator ? 2 : -1); } } - catch (Exception e) + catch (Exception) { new MessageBox(Resources.Failed_to_send_join_data_to_the_game__Maybe_you_left_the_game_, Resources.Error, Quit).Show(); } @@ -3188,12 +3475,47 @@ private static bool CheckIfLaunchedWithCustomURLHandlerParam(string[] args) } + /// + /// True only when a published release is genuinely newer than what is installed. + /// This used to be a plain inequality, which meant running a build ahead of the + /// newest published release prompted the user to "update" to an older one. + /// Tags look like "v0.4.5"; suffixes such as "v0.4.5_BETA" compare as 0.4.5. + /// + private static bool IsNewerSpeakerSystemVersion(string remoteTag, string localTag) + { + int[] remote = ParseSpeakerSystemVersion(remoteTag); + int[] local = ParseSpeakerSystemVersion(localTag); + if (remote == null || local == null) + { + // Unparseable on either side: only offer an update if something changed. + return !string.IsNullOrEmpty(remoteTag) && remoteTag != localTag; + } + for (int i = 0; i < 3; i++) + { + if (remote[i] != local[i]) return remote[i] > local[i]; + } + return false; + } + + private static int[] ParseSpeakerSystemVersion(string tag) + { + if (string.IsNullOrWhiteSpace(tag)) return null; + string[] parts = tag.TrimStart('v', 'V').Split('.'); + int[] nums = new int[3]; + for (int i = 0; i < 3 && i < parts.Length; i++) + { + string digits = new string(parts[i].TakeWhile(char.IsDigit).ToArray()); + if (digits.Length == 0 || !int.TryParse(digits, out nums[i])) return null; + } + return nums; + } + private static string[] GetLatestSpeakerSystemURLVer() { string[] ret = new string[2]; try { - HttpWebRequest req = (HttpWebRequest)WebRequest.Create(@"https://api.github.com/repos/iblowatsports/Echo-VR-Speaker-System/releases/latest"); + HttpWebRequest req = (HttpWebRequest)WebRequest.Create(@"https://api.github.com/repos/heisthecat31/Echo-VR-Speaker-System/releases/latest"); req.Accept = "application/json"; req.UserAgent = "Spark"; @@ -3204,7 +3526,11 @@ private static string[] GetLatestSpeakerSystemURLVer() // Session Contents string textResp = sr.ReadToEnd(); VersionJson versionJson = JsonConvert.DeserializeObject(textResp); - ret[0] = versionJson.assets.First(url => url.browser_download_url.EndsWith("exe")).browser_download_url; + // FirstOrDefault: a release with no .exe attached used to throw here. + Asset installer = versionJson.assets? + .FirstOrDefault(url => url.browser_download_url != null && + url.browser_download_url.EndsWith("exe")); + ret[0] = installer?.browser_download_url; ret[1] = versionJson.tag_name; } catch (Exception e) @@ -3747,6 +4073,7 @@ internal static void Quit() running = false; SparkClosing?.Invoke(); + try { FriendsPresence.PushOffline(); } catch { } SparkSettings.instance.Save(); if (closingWindow != null) diff --git a/Properties/LoadingTips.Designer.cs b/Properties/LoadingTips.Designer.cs index 1b08ba9..8970b74 100644 --- a/Properties/LoadingTips.Designer.cs +++ b/Properties/LoadingTips.Designer.cs @@ -73,7 +73,7 @@ public static string _6_Years_ { /// public static string _93__of_players_in_the_VR_Master_League_are_High_Diamond_ { get { - return ResourceManager.GetString("93% of players in the VR Master League are High Diamond!", resourceCulture); + return ResourceManager.GetString("2% of echo players are good.", resourceCulture); } } @@ -92,7 +92,7 @@ public static string _echoreplay_files_can_be_used_to_play_back_and_analyze_matc /// public static string A2 { get { - return ResourceManager.GetString("A2", resourceCulture); + return ResourceManager.GetString("Do Not Play A2", resourceCulture); } } @@ -101,7 +101,7 @@ public static string A2 { /// public static string Ask_anyone_in_the_beta { get { - return ResourceManager.GetString("Ask anyone in the beta", resourceCulture); + return ResourceManager.GetString("We don\'t have a beta.", resourceCulture); } } @@ -155,7 +155,7 @@ public static string CV1_is_the_most_comfortable_headset_by_far_ { /// public static string DOME { get { - return ResourceManager.GetString("DOME", resourceCulture); + return ResourceManager.GetString("Im better", resourceCulture); } } @@ -164,7 +164,7 @@ public static string DOME { /// public static string DOMErs_only { get { - return ResourceManager.GetString("DOMErs only", resourceCulture); + return ResourceManager.GetString("Vanilla Coke is the best.", resourceCulture); } } @@ -173,7 +173,7 @@ public static string DOMErs_only { /// public static string Echo_Arena_2 { get { - return ResourceManager.GetString("Echo Arena 2", resourceCulture); + return ResourceManager.GetString("Echo Arena 3", resourceCulture); } } @@ -202,7 +202,7 @@ public static string Enable_TTS_in_Spark_for_events_like_exact_throw_speed__jous /// public static string Get_Bigmand_ { get { - return ResourceManager.GetString("Get Bigmand!", resourceCulture); + return ResourceManager.GetString("Get Bigmad!", resourceCulture); } } @@ -220,7 +220,7 @@ public static string God_is_my_copilot__and_RAD_Natalie_is_my_boss_ { /// public static string I_didn_t_get_the_CTO_job_from_John_Carmack_ { get { - return ResourceManager.GetString("I didn\'t get the CTO job from John Carmack.", resourceCulture); + return ResourceManager.GetString("What is a Boz?", resourceCulture); } } @@ -229,7 +229,7 @@ public static string I_didn_t_get_the_CTO_job_from_John_Carmack_ { /// public static string I_m_not_an_ads_guy { get { - return ResourceManager.GetString("I\'m not an ads guy", resourceCulture); + return ResourceManager.GetString("Screw ads.", resourceCulture); } } @@ -240,8 +240,7 @@ public static string I_m_not_an_ads_guy { /// public static string Ignite_Playercards_show_you_bars_for_your_stats_with_your_percentile_compared_to_other_players_ { get { - return ResourceManager.GetString("Ignite Playercards show you bars for your stats with your percentile compared to " + - "other players.", resourceCulture); + return ResourceManager.GetString("Spark Player Cards can be made inside the app!", resourceCulture); } } @@ -261,7 +260,7 @@ public static string League_casters_love_it_when_you_send_them_a_spark____link_i /// public static string Lone_Echo_2_releases_tomorrow_ { get { - return ResourceManager.GetString("Lone Echo 2 releases tomorrow!", resourceCulture); + return ResourceManager.GetString("Lone Echo 2 is peak.", resourceCulture); } } @@ -335,7 +334,7 @@ public static string Spark_can_automatically_clip_your_goals_or_saves_using_Meda /// public static string Spark_is_available_in_both_English_and_Japanese_ { get { - return ResourceManager.GetString("Spark is available in both English and Japanese!", resourceCulture); + return ResourceManager.GetString("Spark is available in both English and mostly Japanese!", resourceCulture); } } @@ -430,7 +429,7 @@ public static string The_strategic_dinging_self_goal_2_pointer_was_not_possible_ /// public static string Try_Rumble_today_ { get { - return ResourceManager.GetString("Try Rumble today!", resourceCulture); + return ResourceManager.GetString("Aqua is my favourite mod!", resourceCulture); } } diff --git a/Properties/LoadingTips.resx b/Properties/LoadingTips.resx index c620098..1f2d674 100644 --- a/Properties/LoadingTips.resx +++ b/Properties/LoadingTips.resx @@ -85,8 +85,8 @@ xml:space="preserve"> .echoreplay files can be used to play back and analyze matches in the Replay Viewer. - - Spark is available in both English and Japanese! + + Spark is available in both English and mostly Japanese! @@ -114,9 +114,9 @@ Use the server score provided by Spark to choose a server that is fair for both teams. - - Ignite Playercards show you bars for your stats with your percentile compared to other players. + Spark Player Cards can be made inside the app! CV1 is the most comfortable headset by far. @@ -150,11 +150,11 @@ You can set up the in-game emote gesture to activate clips with Spark! - - Lone Echo 2 releases tomorrow! + + Lone Echo 2 is peak. - - Echo Arena 2 + + Echo Arena 3 Same Disc, Different Day @@ -166,8 +166,8 @@ God is my copilot, and CHAD_Natalie is my boss. - - Get Bigmand! + + Get Bigmad! No one knew if backboards were possible until qlyoung made them real in 2018. @@ -176,14 +176,14 @@ xml:space="preserve"> The strategic dinging self-goal 2-pointer was not possible until Lemming scored his iconic goal at OC5 - - 93% of players in the VR Master League are High Diamond! + + 2% of echo players are good. - - DOME + + Im better - - DOMErs only + + Vanilla Coke is the best. You can install Spark Mini directly on the Quest Pro and open it while playing the game. @@ -213,17 +213,17 @@ Use the Echo-G for a competitive advantage. - - Try Rumble today! + + Aqua is my favourite mod! - - I'm not an ads guy + + Screw ads. - - A2 + + Do Not Play A2 - - I didn't get the CTO job from John Carmack. + + What is a Boz? Bigman and iblowatsports don't attend LAN events @@ -234,7 +234,7 @@ 6 Years. - - Ask anyone in the beta + + We don't have a beta. \ No newline at end of file diff --git a/QuestIPFetching.cs b/QuestIPFetching.cs index e606376..05858fe 100644 --- a/QuestIPFetching.cs +++ b/QuestIPFetching.cs @@ -108,7 +108,7 @@ public static void GetCurrentIPAndPingNetwork() { foreach (NetworkInterface adapter in NetworkInterface.GetAllNetworkInterfaces().Where(ni => ni.OperationalStatus == OperationalStatus.Up && (ni.NetworkInterfaceType == NetworkInterfaceType.Wireless80211 || ni.NetworkInterfaceType == NetworkInterfaceType.Ethernet))) { - GatewayIPAddressInformation? addr = adapter.GetIPProperties().GatewayAddresses.FirstOrDefault(); + GatewayIPAddressInformation addr = adapter.GetIPProperties().GatewayAddresses.FirstOrDefault(); if (addr != null && !addr.Address.ToString().Equals("0.0.0.0")) { foreach (UnicastIPAddressInformation unicastIPAddressInformation in adapter.GetIPProperties().UnicastAddresses) @@ -128,7 +128,7 @@ public static async Task GetCurrentIPAndPingNetworkAsync() { foreach (NetworkInterface adapter in NetworkInterface.GetAllNetworkInterfaces().Where(ni => ni.OperationalStatus == OperationalStatus.Up && (ni.NetworkInterfaceType == NetworkInterfaceType.Wireless80211 || ni.NetworkInterfaceType == NetworkInterfaceType.Ethernet))) { - GatewayIPAddressInformation? addr = adapter.GetIPProperties().GatewayAddresses.FirstOrDefault(); + GatewayIPAddressInformation addr = adapter.GetIPProperties().GatewayAddresses.FirstOrDefault(); if (addr != null && !addr.Address.ToString().Equals("0.0.0.0")) { foreach (UnicastIPAddressInformation unicastIPAddressInformation in adapter.GetIPProperties().UnicastAddresses) @@ -164,20 +164,36 @@ public static async Task GetCurrentIPAndPingNetworkAsync() } } - public static async void PingIPList(List IPs, int threadID) + public static async Task PingIPList(List IPs, int threadID) { - IEnumerable> tasks = IPs.Select(ip => new Ping().SendPingAsync(ip, 4000)); - PingReply[] results = await Task.WhenAll(tasks); - switch (threadID) + try { - case 1: - IPPingThread1Done = true; - break; - case 2: - IPPingThread2Done = true; - break; - default: - break; + IEnumerable> tasks = IPs.Select(async ip => + { + using Ping ping = new Ping(); + return await ping.SendPingAsync(ip, 4000); + }); + await Task.WhenAll(tasks); + } + catch (Exception ex) + { + // unreachable hosts are normal on a subnet sweep, the ARP table is what actually finds the Quest + Logger.LogRow(Logger.LogType.Error, $"Ping sweep {threadID} failed.\n{ex}"); + } + finally + { + // must run even on failure, the search loop waits on these flags + switch (threadID) + { + case 1: + IPPingThread1Done = true; + break; + case 2: + IPPingThread2Done = true; + break; + default: + break; + } } } @@ -210,12 +226,10 @@ public static void PingNetworkIPs(IPAddress address, IPAddress mask) return destIp; }) .ToList(); - IPSearchthread1 = new Thread(() => PingIPList(pingReplyTasks, 1)); - IPSearchthread2 = new Thread(() => PingIPList(pingReplyTasks2, 2)); IPPingThread1Done = false; IPPingThread2Done = false; - IPSearchthread1.Start(); - IPSearchthread2.Start(); + _ = PingIPList(pingReplyTasks, 1); + _ = PingIPList(pingReplyTasks2, 2); } diff --git a/README.md b/README.md index 5239bb6..bb9a28c 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,25 @@ # Spark Modified fork of Spark with extra features and bug fixes, new features include -- Discord Login Fix -- Server Browser Tab For Spectators -- TTS No Longer Requires Discord Login -- Auto Follow User Has Been Reworked -- Changed Spark Link To Echo Taxi Links -- Removed Rumble -- Added PlayerStats And Leaderboard -- Ability To Update In Spark -- Different Colour Themes In Settings -- Added friebds tab -- Reworked UI -- Update option within settings tab -- PlayerCard tab so you can make your own playercards -- EchoVRCE Portal tab -- Media Controller and New clipping option using in game "A" button - + Installation instructions: -https://echopedia.gg/wiki/Spark +[https://echopedia.gg/wiki/Spark](https://echopedia.ntsfranz.com/index.php/Echopedia) ![Ignite Logo](img/ignite_logo_128.png) -.NET 10 and WPF rewrite of IgniteBot 1.x, which was written in Windows Forms. +.NET 10 and WPF Spark is a Windows desktop program that can record, process, and upload EchoVR data from either a local EchoVR client or an Oculus Quest on the same network. ## License Source available under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html) -image + +## Idle Dash +image + +## Combat Dash +image + +## Arena Dash +image + + diff --git a/ReplayAnalyser/Controls/ArenaMap.cs b/ReplayAnalyser/Controls/ArenaMap.cs new file mode 100644 index 0000000..85f916a --- /dev/null +++ b/ReplayAnalyser/Controls/ArenaMap.cs @@ -0,0 +1,440 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/Controls/ArenaMap.cs). The additive +// rasteriser is unchanged. Team colours now come from the Spark theme — brightened, since light +// adding up needs each hue at full strength — and the floor stays dark on every theme, because +// glowing routes only read against one. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Spark.ReplayAnalyser.Analysis; + +namespace Spark.ReplayAnalyser.Controls; + +public enum ArenaView +{ + /// Looking down the Y axis: arena length across the screen, width up the screen. + TopDown, + /// Looking from the side: arena length across the screen, height up the screen. + Side, +} + +/// One set of movement paths drawn in a single colour. +public sealed class TrackLayer +{ + public IReadOnlyList> Paths { get; init; } = Array.Empty>(); + /// The theme colour to draw in. + public Tone Tone { get; init; } = Tone.Accent; + /// A fixed colour instead of — the disc is white whatever the theme. + public Color? Color { get; init; } + /// Relative brightness, for putting the disc over the players or vice versa. + public double Weight { get; init; } = 1; +} + +/// +/// The Echo Arena playing field with movement drawn over it. +/// +/// Paths are rasterised additively rather than binned into a grid: every stroke deposits a +/// little light, so a lane skated a hundred times burns bright while a one-off run stays a +/// faint thread, and two teams crossing the same space blend towards white. That keeps the +/// individual routes legible, which a blurred density map destroys. +/// +public sealed class ArenaMap : ThemedElement +{ + public static readonly DependencyProperty TracksProperty = DependencyProperty.Register( + nameof(Tracks), typeof(IReadOnlyList), typeof(ArenaMap), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnTracksChanged)); + + public static readonly DependencyProperty ViewProperty = DependencyProperty.Register( + nameof(View), typeof(ArenaView), typeof(ArenaMap), + new FrameworkPropertyMetadata(ArenaView.TopDown, + FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.AffectsMeasure, + OnTracksChanged)); + + public static readonly DependencyProperty ShotsProperty = DependencyProperty.Register( + nameof(Shots), typeof(IReadOnlyList), typeof(ArenaMap), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty GoalsProperty = DependencyProperty.Register( + nameof(Goals), typeof(IReadOnlyList), typeof(ArenaMap), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + /// The side this map belongs to — highlights the goal they attack. + public static readonly DependencyProperty AttackingProperty = DependencyProperty.Register( + nameof(Attacking), typeof(TeamSide?), typeof(ArenaMap), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public IReadOnlyList? Tracks + { + get => (IReadOnlyList?)GetValue(TracksProperty); + set => SetValue(TracksProperty, value); + } + public ArenaView View { get => (ArenaView)GetValue(ViewProperty); set => SetValue(ViewProperty, value); } + public IReadOnlyList? Shots + { + get => (IReadOnlyList?)GetValue(ShotsProperty); + set => SetValue(ShotsProperty, value); + } + public IReadOnlyList? Goals + { + get => (IReadOnlyList?)GetValue(GoalsProperty); + set => SetValue(GoalsProperty, value); + } + public TeamSide? Attacking { get => (TeamSide?)GetValue(AttackingProperty); set => SetValue(AttackingProperty, value); } + + private BitmapSource? _image; + private Size _imageFor; + private string _imageColours = ""; + + /// + /// A top-down render of the arena, drawn to the same 80 x 32 m bounds the analysis uses, + /// so routes line up with the geometry players actually skate around. + /// + private static readonly BitmapSource? ArenaFloor = LoadFloor(); + + private static BitmapSource? LoadFloor() + { + try + { + var bmp = new BitmapImage(); + bmp.BeginInit(); + bmp.UriSource = new Uri("pack://application:,,,/Spark;component/ReplayAnalyser/Resources/arena-top.png", UriKind.Absolute); + bmp.CacheOption = BitmapCacheOption.OnLoad; + bmp.EndInit(); + bmp.Freeze(); + return bmp; + } + catch + { + // Missing artwork should not take the map down; the plain floor still works. + return null; + } + } + + public ArenaMap() + { + RenderOptions.SetBitmapScalingMode(this, BitmapScalingMode.HighQuality); + ClipToBounds = true; + } + + private static void OnTracksChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => + ((ArenaMap)d)._image = null; + + // The routes are baked into the bitmap in the old theme's team colours. + protected override void OnThemeChanged() => _image = null; + + private const double Pad = 6; + private double ArenaWidthMetres => 2 * ArenaGeometry.HalfLength; + private double ArenaHeightMetres => View == ArenaView.TopDown + ? 2 * ArenaGeometry.HalfWidth + : 2 * ArenaGeometry.HalfHeight; + + /// Take the width on offer and ask for exactly the height the arena needs. + protected override Size MeasureOverride(Size available) + { + double w = double.IsInfinity(available.Width) || available.Width <= 0 ? 640 : available.Width; + double h = (w - Pad * 2) * (ArenaHeightMetres / ArenaWidthMetres) + Pad * 2; + if (!double.IsInfinity(available.Height) && available.Height > 0 && h > available.Height) + { + h = available.Height; + w = (h - Pad * 2) * (ArenaWidthMetres / ArenaHeightMetres) + Pad * 2; + } + return new Size(w, h); + } + + /// The floor the routes glow against: the theme's own ground where that is dark enough. + private static Color FloorColour(ChartPalette pal) => pal.IsDark + ? Draw.Mix(pal.GroundColor, Colors.Black, 0.35) + : Color.FromRgb(0x0B, 0x0E, 0x14); + + private static Color LayerColour(TrackLayer layer, ChartPalette pal) => + layer.Color ?? Draw.Glow(pal.ColorOf(layer.Tone)); + + protected override void OnRender(DrawingContext dc) + { + double w = ActualWidth, h = ActualHeight; + if (w < 40 || h < 20) return; + double dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var pal = Palette; + + double scale = Math.Min((w - Pad * 2) / ArenaWidthMetres, (h - Pad * 2) / ArenaHeightMetres); + double pw = ArenaWidthMetres * scale, ph = ArenaHeightMetres * scale; + var field = new Rect((w - pw) / 2, (h - ph) / 2, pw, ph); + + double radius = Math.Min(14, ph / 4); + var clip = new RectangleGeometry(field, radius, radius); + clip.Freeze(); + + // Dark floor, so the additive lines have something to glow against. + var floorColour = FloorColour(pal); + var floorBrush = Draw.Solid(floorColour); + dc.DrawRoundedRectangle(floorBrush, null, field, radius, radius); + + // The real arena floor underneath, held well back so the movement stays the subject. + var floor = View == ArenaView.TopDown ? ArenaFloor : null; + if (floor != null) + { + dc.PushClip(clip); + dc.PushOpacity(0.42); + // The render has orange on the left; this app puts blue there, so mirror it. + dc.PushTransform(new ScaleTransform(-1, 1, field.Left + field.Width / 2, 0)); + dc.DrawImage(floor, field); + dc.Pop(); + dc.Pop(); + dc.Pop(); + } + + double Zx(float z) => field.Left + (z + ArenaGeometry.HalfLength) / ArenaWidthMetres * field.Width; + double Cy(float v) => View == ArenaView.TopDown + ? field.Top + (v + ArenaGeometry.HalfWidth) / ArenaHeightMetres * field.Height + : field.Bottom - (v + ArenaGeometry.HalfHeight) / ArenaHeightMetres * field.Height; + + dc.PushClip(clip); + + // Third lines sit under the movement. Over the arena render they only need to hint at + // the zone boundaries, so they drop right back; on the plain floor they carry it alone. + byte tone = floor != null ? (byte)0x44 : (byte)0x2C; + var thirdPen = new Pen(Draw.Solid(tone, tone, tone, floor != null ? (byte)0x70 : (byte)0xFF), 1) + { + DashStyle = new DashStyle(new double[] { 5, 5 }, 0), + }; + thirdPen.Freeze(); + foreach (float z in new[] { -ArenaGeometry.HalfLength / 3f, ArenaGeometry.HalfLength / 3f }) + { + double x = Math.Round(Zx(z)) + 0.5; + dc.DrawLine(thirdPen, new Point(x, field.Top), new Point(x, field.Bottom)); + } + if (floor == null) + { + double mid = Math.Round(Zx(0)) + 0.5; + dc.DrawLine(Draw.Pen(Draw.Solid(0x36, 0x36, 0x36), 1.2), new Point(mid, field.Top), new Point(mid, field.Bottom)); + } + + // The movement itself. + var size = new Size(Math.Round(field.Width), Math.Round(field.Height)); + string colours = string.Join("|", (Tracks ?? Array.Empty()).Select(l => LayerColour(l, pal).ToString())); + if (_image == null || _imageFor != size || _imageColours != colours) + { + _image = RenderTracks((int)size.Width, (int)size.Height, Zx, Cy, field, pal); + _imageFor = size; + _imageColours = colours; + } + if (_image != null) dc.DrawImage(_image, field); + + var blue = Draw.Solid(Draw.Glow(pal.ColorOf(Tone.Blue))); + var orange = Draw.Solid(Draw.Glow(pal.ColorOf(Tone.Orange))); + DrawGoal(dc, Zx(-ArenaGeometry.GoalZ), field, scale, blue, Attacking == TeamSide.Orange, dpi, "BLUE"); + DrawGoal(dc, Zx(ArenaGeometry.GoalZ), field, scale, orange, Attacking == TeamSide.Blue, dpi, "ORANGE"); + + foreach (var s in Shots ?? Array.Empty()) + { + var p = new Point(Zx(s.Z), Cy(View == ArenaView.TopDown ? s.X : s.Y)); + dc.DrawEllipse(null, Draw.Pen(Draw.Solid(0xD2, 0xD2, 0xD2, 0xC0), 1.5), p, 5, 5); + } + var good = Draw.Solid(Draw.Glow(pal.ColorOf(Tone.Good))); + foreach (var g in Goals ?? Array.Empty()) + { + var p = new Point(Zx(g.Z), Cy(View == ArenaView.TopDown ? g.X : g.Y)); + dc.DrawEllipse(good, Draw.Pen(floorBrush, 1.5), p, 6, 6); + } + dc.Pop(); + + dc.DrawRoundedRectangle(null, Draw.Pen(pal.Border, 1.4), field, radius, radius); + } + + private static void DrawGoal(DrawingContext dc, double x, Rect field, double scale, + Brush colour, bool highlighted, double dpi, string label) + { + double cy = field.Top + field.Height / 2; + double r = Math.Max(6, ArenaGeometry.GoalRadius * scale); + + dc.DrawLine(Draw.Pen(Draw.Fade(colour, highlighted ? 0.5 : 0.22), 1.2), + new Point(x, field.Top + 2), new Point(x, field.Bottom - 2)); + dc.DrawEllipse(null, Draw.Pen(Draw.Fade(colour, highlighted ? 1 : 0.5), highlighted ? 2.2 : 1.4), + new Point(x, cy), r, r); + + var t = Draw.Text(label, 9.5, Draw.Fade(colour, highlighted ? 1 : 0.55), dpi, Draw.UiBold); + dc.DrawText(t, new Point(x - t.Width / 2, field.Bottom - t.Height - 5)); + } + + // ------------------------------------------------------------------ additive rasteriser + + /// + /// Draw every path into a floating-point RGB buffer, one thin anti-aliased line at a + /// time, adding light rather than replacing it. Overlap is what carries the information: + /// a lane crossed once stays dim, a lane crossed constantly saturates to white. + /// + private BitmapSource? RenderTracks(int w, int h, Func zx, Func cy, Rect field, + ChartPalette pal) + { + var layers = Tracks; + if (layers == null || layers.Count == 0 || w < 8 || h < 8) return null; + + var acc = new float[w * h * 3]; + bool any = false; + + foreach (var layer in layers) + { + // Per-crossing deposit. Low, so it takes many passes over the same line to go + // white — that is what makes the common routes stand out from the one-offs. + var colour = LayerColour(layer, pal); + float strength = (float)(0.16 * layer.Weight); + float lr = colour.R / 255f * strength; + float lg = colour.G / 255f * strength; + float lb = colour.B / 255f * strength; + + foreach (var path in layer.Paths) + { + if (path.Count < 2) continue; + any = true; + + double px = zx(path[0].Z) - field.Left; + double py = cy(View == ArenaView.TopDown ? path[0].X : path[0].Y) - field.Top; + for (int i = 1; i < path.Count; i++) + { + double nx = zx(path[i].Z) - field.Left; + double ny = cy(View == ArenaView.TopDown ? path[i].X : path[i].Y) - field.Top; + AddLine(acc, w, h, px, py, nx, ny, lr, lg, lb); + px = nx; py = ny; + } + } + } + if (!any) return null; + + // A soft copy added back on top gives the halo around busy lanes without smearing the + // lines themselves, which is the difference between a glow and a blur. + AddBloom(acc, w, h, radius: 3, weight: 0.55f); + + // Scale so the busiest few percent clip to white and everything else keeps its range. + float ceiling = Percentile(acc, w, h, 0.995f); + if (ceiling <= 0) return null; + + var px8 = new byte[w * h * 4]; + for (int i = 0, o = 0; i < w * h; i++, o += 4) + { + float r = acc[i * 3] / ceiling; + float g = acc[i * 3 + 1] / ceiling; + float b = acc[i * 3 + 2] / ceiling; + + // Lift the faint end so single passes stay visible, then clamp the hot end. + r = (float)Math.Pow(Math.Clamp(r, 0, 1), 0.62); + g = (float)Math.Pow(Math.Clamp(g, 0, 1), 0.62); + b = (float)Math.Pow(Math.Clamp(b, 0, 1), 0.62); + + float lum = Math.Max(r, Math.Max(g, b)); + byte a = (byte)(Math.Clamp(lum * 1.25f, 0, 1) * 255); + if (a == 0) continue; + + px8[o + 0] = (byte)(Math.Clamp(b, 0, 1) * a); // Pbgra32 is pre-multiplied + px8[o + 1] = (byte)(Math.Clamp(g, 0, 1) * a); + px8[o + 2] = (byte)(Math.Clamp(r, 0, 1) * a); + px8[o + 3] = a; + } + + var bmp = BitmapSource.Create(w, h, 96, 96, PixelFormats.Pbgra32, null, px8, w * 4); + bmp.Freeze(); + return bmp; + } + + /// Anti-aliased line, accumulating colour into the buffer instead of overwriting. + private static void AddLine(float[] acc, int w, int h, + double x0, double y0, double x1, double y1, + float r, float g, float b) + { + double dx = x1 - x0, dy = y1 - y0; + double len = Math.Sqrt(dx * dx + dy * dy); + if (len < 1e-6) + { + AddPoint(acc, w, h, x0, y0, r, g, b); + return; + } + // A long straight run between two samples is still one crossing, so step by pixel. + int steps = (int)Math.Ceiling(len); + if (steps > 4000) return; // guard against garbage coordinates + + double sx = dx / steps, sy = dy / steps; + // Spread the same total light over however many steps this segment takes, so a fast + // player crossing the arena does not deposit less than a slow one covering the same + // ground. + for (int i = 0; i <= steps; i++) + AddPoint(acc, w, h, x0 + sx * i, y0 + sy * i, r, g, b); + } + + /// Bilinear splat of one sample across the four pixels it straddles. + private static void AddPoint(float[] acc, int w, int h, double x, double y, + float r, float g, float b) + { + if (x < 0 || y < 0 || x >= w - 1 || y >= h - 1) return; + int xi = (int)x, yi = (int)y; + float fx = (float)(x - xi), fy = (float)(y - yi); + + Splat(acc, w, xi, yi, (1 - fx) * (1 - fy), r, g, b); + Splat(acc, w, xi + 1, yi, fx * (1 - fy), r, g, b); + Splat(acc, w, xi, yi + 1, (1 - fx) * fy, r, g, b); + Splat(acc, w, xi + 1, yi + 1, fx * fy, r, g, b); + } + + private static void Splat(float[] acc, int w, int x, int y, float k, float r, float g, float b) + { + int i = (y * w + x) * 3; + acc[i] += r * k; + acc[i + 1] += g * k; + acc[i + 2] += b * k; + } + + private static void AddBloom(float[] acc, int w, int h, int radius, float weight) + { + var blur = new float[acc.Length]; + Array.Copy(acc, blur, acc.Length); + + var tmp = new float[acc.Length]; + for (int c = 0; c < 3; c++) + { + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + { + float sum = 0; int n = 0; + for (int d = -radius; d <= radius; d++) + { + int xx = x + d; + if (xx < 0 || xx >= w) continue; + sum += blur[(y * w + xx) * 3 + c]; n++; + } + tmp[(y * w + x) * 3 + c] = n == 0 ? 0 : sum / n; + } + + for (int x = 0; x < w; x++) + for (int y = 0; y < h; y++) + { + float sum = 0; int n = 0; + for (int d = -radius; d <= radius; d++) + { + int yy = y + d; + if (yy < 0 || yy >= h) continue; + sum += tmp[(yy * w + x) * 3 + c]; n++; + } + blur[(y * w + x) * 3 + c] = n == 0 ? 0 : sum / n; + } + } + + for (int i = 0; i < acc.Length; i++) acc[i] += blur[i] * weight; + } + + private static float Percentile(float[] acc, int w, int h, float fraction) + { + var values = new List(); + for (int i = 0; i < w * h; i++) + { + float v = Math.Max(acc[i * 3], Math.Max(acc[i * 3 + 1], acc[i * 3 + 2])); + if (v > 0) values.Add(v); + } + if (values.Count == 0) return 0; + values.Sort(); + int idx = (int)Math.Clamp(fraction * (values.Count - 1), 0, values.Count - 1); + return values[idx]; + } +} diff --git a/ReplayAnalyser/Controls/ChartPrimitives.cs b/ReplayAnalyser/Controls/ChartPrimitives.cs new file mode 100644 index 0000000..cce03e1 --- /dev/null +++ b/ReplayAnalyser/Controls/ChartPrimitives.cs @@ -0,0 +1,215 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/Controls/ChartPrimitives.cs). The drawing +// helpers are unchanged; the fixed Palette is replaced by ChartPalette, which reads the active +// Spark theme, and series/markers carry a Tone instead of a brush. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Windows; +using System.Windows.Media; + +namespace Spark.ReplayAnalyser.Controls; + +/// Shared drawing helpers for the hand-rendered charts. +internal static class Draw +{ + private static readonly FontFamily UiFamily = new FontFamily("Segoe UI Variable Text, Segoe UI"); + + public static readonly Typeface Ui = + new Typeface(UiFamily, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal); + public static readonly Typeface UiBold = + new Typeface(UiFamily, FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal); + public static readonly Typeface Mono = + new Typeface(new FontFamily("Consolas"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal); + + public static FormattedText Text(string s, double size, Brush brush, double pixelsPerDip, + Typeface? face = null) => + new FormattedText(s, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, + face ?? Ui, size, brush, pixelsPerDip); + + /// Pen that will not blur: snapped to device pixels. + public static Pen Pen(Brush b, double thickness) + { + var p = new Pen(b, thickness); + p.Freeze(); + return p; + } + + public static Brush Solid(byte r, byte g, byte b, byte a = 255) => Solid(Color.FromArgb(a, r, g, b)); + + public static Brush Solid(Color c) + { + var br = new SolidColorBrush(c); + br.Freeze(); + return br; + } + + public static Brush Fade(Brush source, double opacity) + { + if (source is SolidColorBrush s) + { + var c = s.Color; + var br = new SolidColorBrush(Color.FromArgb((byte)(255 * opacity), c.R, c.G, c.B)); + br.Freeze(); + return br; + } + return source; + } + + /// + /// Choose an axis step that lands on 1/2/5 x 10^n, so labels read as round numbers + /// rather than arbitrary fractions of the data range. + /// + public static double NiceStep(double range, int targetTicks) + { + if (range <= 0 || targetTicks <= 0) return 1; + double raw = range / targetTicks; + double mag = Math.Pow(10, Math.Floor(Math.Log10(raw))); + double norm = raw / mag; + double step = norm switch + { + <= 1 => 1, + <= 2 => 2, + <= 5 => 5, + _ => 10, + }; + return step * mag; + } + + public static string Clock(double seconds) + { + if (double.IsNaN(seconds) || double.IsInfinity(seconds)) return "-"; + var t = TimeSpan.FromSeconds(Math.Max(0, seconds)); + return t.TotalHours >= 1 ? t.ToString(@"h\:mm\:ss") : t.ToString(@"m\:ss"); + } + + public static Color Mix(Color a, Color b, double t) => Color.FromArgb(a.A, + (byte)Math.Round(a.R + (b.R - a.R) * t), + (byte)Math.Round(a.G + (b.G - a.G) * t), + (byte)Math.Round(a.B + (b.B - a.B) * t)); + + /// + /// The same hue at full brightness. Theme colours are shaded to read as text on the window — + /// deep on a bright theme — but light added up on the arena floor needs its brightest form, + /// or the busy lanes never reach white. + /// + public static Color Glow(Color c) + { + int max = Math.Max(c.R, Math.Max(c.G, c.B)); + if (max <= 0) return Colors.White; + double k = 255.0 / max; + return Color.FromRgb((byte)Math.Min(255, c.R * k), (byte)Math.Min(255, c.G * k), (byte)Math.Min(255, c.B * k)); + } + + public static double Luminance(Color c) => (0.2126 * c.R + 0.7152 * c.G + 0.0722 * c.B) / 255.0; +} + +/// +/// The active Spark theme's colours, resolved once per theme for the charts to draw with. Frozen +/// copies rather than the theme's own brushes, which are live objects and slow to render with. +/// +internal sealed class ChartPalette +{ + public Brush Ground = null!, Card = null!, Raised = null!, BorderSoft = null!, Border = null!, Track = null!; + public Brush Text = null!, Dim = null!, Faint = null!; + public Color GroundColor, CardColor; + /// True when the window is dark; bright themes shade everything the other way. + public bool IsDark; + + private readonly Dictionary _tones = new Dictionary(); + + public Brush Of(Tone tone) => _tones[tone].brush; + public Color ColorOf(Tone tone) => _tones[tone].color; + + public static ChartPalette From(FrameworkElement fe) + { + Color Get(string key, Color fallback) => fe.TryFindResource(key) is SolidColorBrush b ? b.Color : fallback; + + var p = new ChartPalette + { + GroundColor = Get("SurfaceGround", Color.FromRgb(0x15, 0x15, 0x15)), + CardColor = Get("SurfaceCard", Color.FromRgb(0x26, 0x26, 0x26)), + }; + p.Ground = Draw.Solid(p.GroundColor); + p.Card = Draw.Solid(p.CardColor); + p.Raised = Draw.Solid(Get("SurfaceRaised", Color.FromRgb(0x30, 0x30, 0x30))); + p.BorderSoft = Draw.Solid(Get("SurfaceBorderSoft", Color.FromRgb(0x2C, 0x2C, 0x2C))); + p.Border = Draw.Solid(Get("SurfaceBorder", Color.FromRgb(0x3B, 0x3B, 0x3B))); + p.Track = Draw.Solid(Get("SurfaceTrack", Color.FromRgb(0x43, 0x43, 0x43))); + + var text = Get("TextPrimary", Color.FromRgb(0xEC, 0xEC, 0xEC)); + p.Text = Draw.Solid(text); + p.Dim = Draw.Solid(Get("TextDim", Color.FromRgb(0xA5, 0xA5, 0xA5))); + p.Faint = Draw.Solid(Get("TextFaint", Color.FromRgb(0x78, 0x78, 0x78))); + p.IsDark = Draw.Luminance(text) > 0.5; + + foreach (Tone tone in Enum.GetValues(typeof(Tone))) + { + var c = Get(ToneKeys.Solid(tone), Colors.Gray); + p._tones[tone] = (c, Draw.Solid(c)); + } + return p; + } +} + +/// +/// Base for the hand-drawn charts. They paint in OnRender rather than with bound brushes, so +/// they need telling when Spark changes theme: each probe below holds a live reference to a +/// theme brush, and ThemesController swaps every brush on a change, which lands here. +/// +public abstract class ThemedElement : FrameworkElement +{ + private static DependencyProperty Probe(string name) => DependencyProperty.Register( + name, typeof(object), typeof(ThemedElement), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, + (d, _) => ((ThemedElement)d).ThemeChanged())); + + private static readonly DependencyProperty SurfaceProbeProperty = Probe("SurfaceProbe"); + private static readonly DependencyProperty TextProbeProperty = Probe("TextProbe"); + private static readonly DependencyProperty AccentProbeProperty = Probe("AccentProbe"); + private static readonly DependencyProperty TeamProbeProperty = Probe("TeamProbe"); + + private ChartPalette? _palette; + + protected ThemedElement() + { + SetResourceReference(SurfaceProbeProperty, "SurfaceCard"); + SetResourceReference(TextProbeProperty, "TextPrimary"); + SetResourceReference(AccentProbeProperty, "ControlAccent"); + SetResourceReference(TeamProbeProperty, "TeamBlue"); + } + + internal ChartPalette Palette => _palette ??= ChartPalette.From(this); + + private void ThemeChanged() + { + _palette = null; + OnThemeChanged(); + } + + /// For anything cached from the old theme's colours. + protected virtual void OnThemeChanged() { } +} + +/// One line on a . +public sealed class ChartSeries +{ + public string Name { get; init; } = ""; + public Tone Tone { get; init; } = Tone.Accent; + /// When above zero, the area under the line is filled with the tone at this opacity. + public double FillOpacity { get; init; } + public double Thickness { get; init; } = 2; + public IReadOnlyList<(double X, double Y)> Points { get; init; } = Array.Empty<(double, double)>(); + /// Draw as a step line — right for scores, which change instantaneously. + public bool Stepped { get; init; } +} + +/// A vertical event marker drawn across a . +public sealed class ChartMarker +{ + public double X { get; init; } + public Tone Tone { get; init; } = Tone.Accent; + public string Label { get; init; } = ""; + public string Tooltip { get; init; } = ""; +} diff --git a/ReplayAnalyser/Controls/CompareBar.cs b/ReplayAnalyser/Controls/CompareBar.cs new file mode 100644 index 0000000..5ee8298 --- /dev/null +++ b/ReplayAnalyser/Controls/CompareBar.cs @@ -0,0 +1,122 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/Controls/CompareBar.cs). The split track is +// unchanged. It takes Spark's theme colours, and when there is no room for the label beside the +// track it moves the label above it instead of drawing nothing. +#nullable enable + +using System; +using System.Windows; +using System.Windows.Media; + +namespace Spark.ReplayAnalyser.Controls; + +/// +/// One head-to-head stat. The label sits on the left where the eye starts, the two numbers +/// bracket a single split track, and the winning side is the one whose bar is longer — the +/// bars share one track rather than sitting in two separate gutters, so the comparison is +/// readable at a glance instead of needing to be measured. +/// +public sealed class CompareBar : ThemedElement +{ + public static readonly DependencyProperty LabelProperty = DependencyProperty.Register( + nameof(Label), typeof(string), typeof(CompareBar), + new FrameworkPropertyMetadata("", FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty BlueProperty = DependencyProperty.Register( + nameof(Blue), typeof(double), typeof(CompareBar), + new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty OrangeProperty = DependencyProperty.Register( + nameof(Orange), typeof(double), typeof(CompareBar), + new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty FormatProperty = DependencyProperty.Register( + nameof(Format), typeof(string), typeof(CompareBar), + new FrameworkPropertyMetadata("0.#", FrameworkPropertyMetadataOptions.AffectsRender)); + + public string Label { get => (string)GetValue(LabelProperty); set => SetValue(LabelProperty, value); } + public double Blue { get => (double)GetValue(BlueProperty); set => SetValue(BlueProperty, value); } + public double Orange { get => (double)GetValue(OrangeProperty); set => SetValue(OrangeProperty, value); } + public string Format { get => (string)GetValue(FormatProperty); set => SetValue(FormatProperty, value); } + + private const double MaxLabelWidth = 190; + private const double NumberWidth = 58; + private const double TrackHeight = 10; + private const double RowHeight = 30; + /// Below this width the label goes above the track rather than beside it. + private const double StackBelow = 400; + + protected override Size MeasureOverride(Size available) + { + double w = double.IsInfinity(available.Width) ? 520 : available.Width; + return new Size(w, w < StackBelow ? RowHeight + 16 : RowHeight); + } + + protected override void OnRender(DrawingContext dc) + { + double w = ActualWidth, h = ActualHeight; + if (w < NumberWidth * 2 + 40) return; + double dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var pal = Palette; + + bool stacked = w < StackBelow; + double labelWidth = stacked ? 0 : Math.Min(MaxLabelWidth, w * 0.3); + // The bar row is the bottom RowHeight; a stacked label takes the space above it. + double rowTop = h - RowHeight; + + bool blueWins = Blue > Orange; + bool tied = Math.Abs(Blue - Orange) < 1e-9; + + var label = Draw.Text(Label, 12, pal.Dim, dpi); + label.MaxTextWidth = Math.Max(10, stacked ? w : labelWidth - 8); + label.MaxLineCount = 1; + label.Trimming = TextTrimming.CharacterEllipsis; + dc.DrawText(label, stacked + ? new Point(0, 0) + : new Point(0, rowTop + (RowHeight - label.Height) / 2)); + + var blue = pal.Of(Tone.Blue); + var orange = pal.Of(Tone.Orange); + + // Winning number is bright and bold; the other is dimmed, so the row can be scanned. + var blueText = Draw.Text(Blue.ToString(Format), 13.5, + tied || blueWins ? blue : Draw.Fade(blue, 0.62), dpi, Draw.Mono); + var orangeText = Draw.Text(Orange.ToString(Format), 13.5, + tied || !blueWins ? orange : Draw.Fade(orange, 0.62), dpi, Draw.Mono); + + double trackLeft = labelWidth + NumberWidth; + double trackRight = w - NumberWidth; + double trackWidth = Math.Max(20, trackRight - trackLeft); + double centre = trackLeft + trackWidth / 2; + double y = rowTop + (RowHeight - TrackHeight) / 2; + + dc.DrawText(blueText, new Point(trackLeft - 10 - blueText.Width, rowTop + (RowHeight - blueText.Height) / 2)); + dc.DrawText(orangeText, new Point(trackRight + 10, rowTop + (RowHeight - orangeText.Height) / 2)); + + // Empty track first, then each side's share growing outward from the middle. + dc.DrawRoundedRectangle(pal.Raised, null, + new Rect(trackLeft, y, trackWidth, TrackHeight), TrackHeight / 2, TrackHeight / 2); + + double total = Math.Abs(Blue) + Math.Abs(Orange); + if (total <= 0) + { + var none = Draw.Text("none", 10.5, pal.Faint, dpi); + dc.DrawText(none, new Point(centre - none.Width / 2, rowTop + (RowHeight - none.Height) / 2)); + return; + } + + double half = trackWidth / 2; + double bw = half * (Math.Abs(Blue) / total); + double ow = half * (Math.Abs(Orange) / total); + + if (bw > 1) + dc.DrawRoundedRectangle(blue, null, + new Rect(centre - bw, y, bw, TrackHeight), TrackHeight / 2, TrackHeight / 2); + if (ow > 1) + dc.DrawRoundedRectangle(orange, null, + new Rect(centre, y, ow, TrackHeight), TrackHeight / 2, TrackHeight / 2); + + // Hairline at the midpoint so an even split is obvious. + dc.DrawRectangle(pal.Card, null, + new Rect(Math.Round(centre) - 0.5, y - 2, 1, TrackHeight + 4)); + } +} diff --git a/ReplayAnalyser/Controls/Layout.cs b/ReplayAnalyser/Controls/Layout.cs new file mode 100644 index 0000000..08e3784 --- /dev/null +++ b/ReplayAnalyser/Controls/Layout.cs @@ -0,0 +1,153 @@ +// Part of the Replay Analyser port. The standalone app had a 1560px window and laid its pages +// out in fixed side-by-side grids; a Spark tab can be anywhere from about 440px wide upwards, so +// the pages reflow with these instead. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace Spark.ReplayAnalyser.Controls; + +/// +/// Lays its children out in as many equal columns as fit, wrapping to new rows as the page gets +/// narrower — side by side on a wide page, stacked on a narrow one. Cells in a row share the +/// row's height, so cards placed next to each other line up. +/// +public sealed class Columns : Panel +{ + public static readonly DependencyProperty MinColumnWidthProperty = DependencyProperty.Register( + nameof(MinColumnWidth), typeof(double), typeof(Columns), + new FrameworkPropertyMetadata(320d, FrameworkPropertyMetadataOptions.AffectsMeasure)); + + public static readonly DependencyProperty MaxColumnsProperty = DependencyProperty.Register( + nameof(MaxColumns), typeof(int), typeof(Columns), + new FrameworkPropertyMetadata(2, FrameworkPropertyMetadataOptions.AffectsMeasure)); + + public static readonly DependencyProperty GapProperty = DependencyProperty.Register( + nameof(Gap), typeof(double), typeof(Columns), + new FrameworkPropertyMetadata(12d, FrameworkPropertyMetadataOptions.AffectsMeasure)); + + /// + /// Relative column widths such as "3,2", used when the row holds exactly that many columns. + /// Any other column count falls back to equal widths. + /// + public static readonly DependencyProperty WeightsProperty = DependencyProperty.Register( + nameof(Weights), typeof(string), typeof(Columns), + new FrameworkPropertyMetadata("", FrameworkPropertyMetadataOptions.AffectsMeasure)); + + public double MinColumnWidth { get => (double)GetValue(MinColumnWidthProperty); set => SetValue(MinColumnWidthProperty, value); } + public int MaxColumns { get => (int)GetValue(MaxColumnsProperty); set => SetValue(MaxColumnsProperty, value); } + public double Gap { get => (double)GetValue(GapProperty); set => SetValue(GapProperty, value); } + public string Weights { get => (string)GetValue(WeightsProperty); set => SetValue(WeightsProperty, value); } + + private double[] _widths = Array.Empty(); + private readonly List _rowHeights = new List(); + + private List Visible() => + InternalChildren.Cast().Where(c => c.Visibility != Visibility.Collapsed).ToList(); + + private double[] ColumnWidths(double width, int count) + { + if (count == 0) return Array.Empty(); + double gap = Gap; + int cols = (int)Math.Floor((width + gap) / (MinColumnWidth + gap)); + cols = Math.Clamp(cols, 1, Math.Max(1, Math.Min(MaxColumns, count))); + + double[] weights = (Weights ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double v) && v > 0 ? v : 1) + .ToArray(); + if (weights.Length != cols) weights = Enumerable.Repeat(1.0, cols).ToArray(); + + double usable = Math.Max(0, width - gap * (cols - 1)); + double total = weights.Sum(); + return weights.Select(k => usable * k / total).ToArray(); + } + + protected override Size MeasureOverride(Size available) + { + var children = Visible(); + double width = double.IsInfinity(available.Width) + ? children.Count * MinColumnWidth + Math.Max(0, children.Count - 1) * Gap + : available.Width; + + _widths = ColumnWidths(width, children.Count); + _rowHeights.Clear(); + + int cols = Math.Max(1, _widths.Length); + for (int i = 0; i < children.Count; i++) + { + int col = i % cols; + children[i].Measure(new Size(_widths.Length == 0 ? width : _widths[col], double.PositiveInfinity)); + if (col == 0) _rowHeights.Add(0); + _rowHeights[^1] = Math.Max(_rowHeights[^1], children[i].DesiredSize.Height); + } + + double height = _rowHeights.Sum() + Math.Max(0, _rowHeights.Count - 1) * Gap; + return new Size(double.IsInfinity(available.Width) ? width : available.Width, height); + } + + protected override Size ArrangeOverride(Size final) + { + var children = Visible(); + if (_widths.Length == 0 || Math.Abs(_widths.Sum() + Gap * (_widths.Length - 1) - final.Width) > 0.5) + { + // Arranged at a different width than measured: redo the rows for the real width. + MeasureOverride(new Size(final.Width, double.PositiveInfinity)); + } + + int cols = Math.Max(1, _widths.Length); + double y = 0; + for (int i = 0; i < children.Count; i++) + { + int col = i % cols; + int row = i / cols; + if (col == 0 && row > 0) y += _rowHeights[row - 1] + Gap; + + double x = 0; + for (int c = 0; c < col; c++) x += _widths[c] + Gap; + children[i].Arrange(new Rect(x, y, _widths[col], row < _rowHeights.Count ? _rowHeights[row] : 0)); + } + return final; + } +} + +/// +/// Hands mouse-wheel scrolling on to the page. A table that scrolls sideways sits inside a page +/// that scrolls down, and without this the inner scroller swallows the wheel and the page stops +/// moving whenever the pointer is over the table. +/// +public static class WheelBubble +{ + public static readonly DependencyProperty EnabledProperty = DependencyProperty.RegisterAttached( + "Enabled", typeof(bool), typeof(WheelBubble), new PropertyMetadata(false, OnEnabledChanged)); + + public static bool GetEnabled(DependencyObject d) => (bool)d.GetValue(EnabledProperty); + public static void SetEnabled(DependencyObject d, bool value) => d.SetValue(EnabledProperty, value); + + private static void OnEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not UIElement element) return; + element.PreviewMouseWheel -= OnPreviewMouseWheel; + if ((bool)e.NewValue) element.PreviewMouseWheel += OnPreviewMouseWheel; + } + + private static void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + if (e.Handled || sender is not DependencyObject d) return; + if (VisualTreeHelper.GetParent(d) is not UIElement parent) return; + + e.Handled = true; + parent.RaiseEvent(new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta) + { + RoutedEvent = UIElement.MouseWheelEvent, + Source = sender, + }); + } +} diff --git a/ReplayAnalyser/Controls/LineChart.cs b/ReplayAnalyser/Controls/LineChart.cs new file mode 100644 index 0000000..0ea680a --- /dev/null +++ b/ReplayAnalyser/Controls/LineChart.cs @@ -0,0 +1,305 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/Controls/LineChart.cs). Scales, geometry and +// the crosshair are unchanged. It now draws in the Spark theme's colours, and the legend wraps +// onto extra rows instead of running off a narrow chart. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Media; + +namespace Spark.ReplayAnalyser.Controls; + +/// +/// A time-series chart drawn by hand: axes with round-numbered ticks, one or more lines, +/// optional event markers, and a crosshair that reads out the value under the pointer. +/// +public sealed class LineChart : ThemedElement +{ + public static readonly DependencyProperty SeriesProperty = DependencyProperty.Register( + nameof(Series), typeof(IReadOnlyList), typeof(LineChart), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty MarkersProperty = DependencyProperty.Register( + nameof(Markers), typeof(IReadOnlyList), typeof(LineChart), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty YLabelProperty = DependencyProperty.Register( + nameof(YLabel), typeof(string), typeof(LineChart), + new FrameworkPropertyMetadata("", FrameworkPropertyMetadataOptions.AffectsRender)); + + /// Format applied to Y tick labels and the crosshair readout. + public static readonly DependencyProperty YFormatProperty = DependencyProperty.Register( + nameof(YFormat), typeof(string), typeof(LineChart), + new FrameworkPropertyMetadata("0.#", FrameworkPropertyMetadataOptions.AffectsRender)); + + /// Pin the Y axis to this range instead of fitting the data. + public static readonly DependencyProperty YMinProperty = DependencyProperty.Register( + nameof(YMin), typeof(double?), typeof(LineChart), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty YMaxProperty = DependencyProperty.Register( + nameof(YMax), typeof(double?), typeof(LineChart), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + /// + /// Format for X tick labels. Time series want the default clock; a chart counting matches + /// wants plain numbers, and labelling match 3 as "0:03" is simply wrong. + /// + public static readonly DependencyProperty XIsTimeProperty = DependencyProperty.Register( + nameof(XIsTime), typeof(bool), typeof(LineChart), + new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty XLabelProperty = DependencyProperty.Register( + nameof(XLabel), typeof(string), typeof(LineChart), + new FrameworkPropertyMetadata("", FrameworkPropertyMetadataOptions.AffectsRender)); + + /// Draw a heavier line at y = 0, for charts that swing either side of it. + public static readonly DependencyProperty ShowZeroLineProperty = DependencyProperty.Register( + nameof(ShowZeroLine), typeof(bool), typeof(LineChart), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender)); + + public IReadOnlyList? Series + { + get => (IReadOnlyList?)GetValue(SeriesProperty); + set => SetValue(SeriesProperty, value); + } + public IReadOnlyList? Markers + { + get => (IReadOnlyList?)GetValue(MarkersProperty); + set => SetValue(MarkersProperty, value); + } + public string YLabel { get => (string)GetValue(YLabelProperty); set => SetValue(YLabelProperty, value); } + public string YFormat { get => (string)GetValue(YFormatProperty); set => SetValue(YFormatProperty, value); } + public double? YMin { get => (double?)GetValue(YMinProperty); set => SetValue(YMinProperty, value); } + public double? YMax { get => (double?)GetValue(YMaxProperty); set => SetValue(YMaxProperty, value); } + public bool ShowZeroLine { get => (bool)GetValue(ShowZeroLineProperty); set => SetValue(ShowZeroLineProperty, value); } + public bool XIsTime { get => (bool)GetValue(XIsTimeProperty); set => SetValue(XIsTimeProperty, value); } + public string XLabel { get => (string)GetValue(XLabelProperty); set => SetValue(XLabelProperty, value); } + + private Point? _cursor; + + public LineChart() + { + ClipToBounds = true; + MouseMove += (_, e) => { _cursor = e.GetPosition(this); InvalidateVisual(); }; + MouseLeave += (_, _) => { _cursor = null; InvalidateVisual(); }; + } + + private const double PadLeft = 46, PadRight = 14, PadBottom = 26; + private const double LegendTop = 7, LegendRow = 17; + + protected override void OnRender(DrawingContext dc) + { + double w = ActualWidth, h = ActualHeight; + if (w < 60 || h < 50) return; + double dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var pal = Palette; + + // The chart sits in a well cut into the card, like Spark's other inset panels. + dc.DrawRoundedRectangle(pal.Ground, null, new Rect(0, 0, w, h), 6, 6); + + var series = (Series ?? Array.Empty()).Where(s => s.Points.Count > 0).ToList(); + if (series.Count == 0) + { + var none = Draw.Text("no data", 12, pal.Dim, dpi); + dc.DrawText(none, new Point((w - none.Width) / 2, (h - none.Height) / 2)); + return; + } + + // ---- legend layout first: it decides where the plot can start ---- + var legend = new List<(ChartSeries s, FormattedText t, Point at)>(); + { + double lx = PadLeft + 2, row = 0; + foreach (var s in series.Where(s => !string.IsNullOrEmpty(s.Name))) + { + var t = Draw.Text(s.Name, 10.5, pal.Dim, dpi); + double itemWidth = 13 + t.Width; + if (lx > PadLeft + 2 && lx + itemWidth > w - PadRight) { lx = PadLeft + 2; row++; } + legend.Add((s, t, new Point(lx, LegendTop + row * LegendRow))); + lx += itemWidth + 14; + } + } + double padTop = legend.Count == 0 ? 14 : legend.Max(l => l.at.Y) + LegendRow + 4; + + var plot = new Rect(PadLeft, padTop, Math.Max(1, w - PadLeft - PadRight), Math.Max(1, h - padTop - PadBottom)); + + double xMin = series.Min(s => s.Points.Min(p => p.X)); + double xMax = series.Max(s => s.Points.Max(p => p.X)); + double yMin = YMin ?? series.Min(s => s.Points.Min(p => p.Y)); + double yMax = YMax ?? series.Max(s => s.Points.Max(p => p.Y)); + if (Math.Abs(xMax - xMin) < 1e-9) xMax = xMin + 1; + if (Math.Abs(yMax - yMin) < 1e-9) { yMin -= 0.5; yMax += 0.5; } + if (YMin == null && YMax == null) + { + double pad = (yMax - yMin) * 0.12; + yMin -= pad; yMax += pad; + } + + double X(double v) => plot.Left + (v - xMin) / (xMax - xMin) * plot.Width; + double Y(double v) => plot.Bottom - (v - yMin) / (yMax - yMin) * plot.Height; + + // ---- grid + axis labels ---- + var gridPen = Draw.Pen(pal.BorderSoft, 1); + double yStep = Draw.NiceStep(yMax - yMin, plot.Height < 120 ? 3 : 4); + for (double v = Math.Ceiling(yMin / yStep) * yStep; v <= yMax + 1e-9; v += yStep) + { + double y = Math.Round(Y(v)) + 0.5; + dc.DrawLine(gridPen, new Point(plot.Left, y), new Point(plot.Right, y)); + var t = Draw.Text(v.ToString(YFormat), 10.5, pal.Faint, dpi); + dc.DrawText(t, new Point(plot.Left - t.Width - 7, y - t.Height / 2)); + } + + // Fewer ticks when the chart is narrow, so the clock labels never collide. + double xStep = Draw.NiceStep(xMax - xMin, Math.Clamp((int)(plot.Width / 70), 2, 6)); + if (!XIsTime) xStep = Math.Max(1, Math.Round(xStep)); + for (double v = Math.Ceiling(xMin / xStep) * xStep; v <= xMax + 1e-9; v += xStep) + { + double x = Math.Round(X(v)) + 0.5; + dc.DrawLine(gridPen, new Point(x, plot.Top), new Point(x, plot.Bottom)); + var t = Draw.Text(XIsTime ? Draw.Clock(v) : v.ToString("0.#"), 10.5, pal.Faint, dpi); + dc.DrawText(t, new Point(x - t.Width / 2, plot.Bottom + 6)); + } + + if (ShowZeroLine && yMin < 0 && yMax > 0) + { + double y = Math.Round(Y(0)) + 0.5; + dc.DrawLine(Draw.Pen(pal.Border, 1.5), new Point(plot.Left, y), new Point(plot.Right, y)); + } + + // ---- event markers behind the lines ---- + foreach (var mk in Markers ?? Array.Empty()) + { + if (mk.X < xMin || mk.X > xMax) continue; + double x = Math.Round(X(mk.X)) + 0.5; + var colour = pal.Of(mk.Tone); + dc.DrawLine(Draw.Pen(Draw.Fade(colour, 0.35), 1), + new Point(x, plot.Top), new Point(x, plot.Bottom)); + dc.DrawEllipse(colour, null, new Point(x, plot.Top + 4), 3, 3); + } + + // ---- the series ---- + foreach (var s in series) + { + var colour = pal.Of(s.Tone); + var geo = BuildGeometry(s, X, Y, plot, out var fillGeo); + if (s.FillOpacity > 0 && fillGeo != null) + dc.DrawGeometry(Draw.Fade(colour, s.FillOpacity), null, fillGeo); + dc.DrawGeometry(null, Draw.Pen(colour, s.Thickness), geo); + } + + // ---- crosshair readout ---- + if (_cursor is { } c && c.X >= plot.Left && c.X <= plot.Right && c.Y >= plot.Top && c.Y <= plot.Bottom) + { + double xv = xMin + (c.X - plot.Left) / plot.Width * (xMax - xMin); + double cx = Math.Round(c.X) + 0.5; + dc.DrawLine(Draw.Pen(Draw.Fade(pal.Dim, 0.6), 1), + new Point(cx, plot.Top), new Point(cx, plot.Bottom)); + + var lines = new List<(string text, Brush brush)> + { + (XIsTime ? Draw.Clock(xv) : $"{XLabel} {xv:0.#}".Trim(), pal.Text), + }; + foreach (var s in series) + { + var pt = Nearest(s.Points, xv); + if (pt == null) continue; + var colour = pal.Of(s.Tone); + dc.DrawEllipse(colour, Draw.Pen(pal.Ground, 1.5), new Point(X(pt.Value.X), Y(pt.Value.Y)), 4, 4); + lines.Add(($"{s.Name} {pt.Value.Y.ToString(YFormat)}", colour)); + } + DrawTooltip(dc, lines, c, plot, dpi, pal); + } + + // ---- legend ---- + foreach (var (s, t, at) in legend) + { + dc.DrawRoundedRectangle(pal.Of(s.Tone), null, new Rect(at.X, at.Y + 6, 9, 3), 1.5, 1.5); + dc.DrawText(t, new Point(at.X + 13, at.Y)); + } + + if (!string.IsNullOrEmpty(YLabel)) + { + var t = Draw.Text(YLabel, 10.5, pal.Faint, dpi); + dc.PushTransform(new RotateTransform(-90, 12, plot.Top + plot.Height / 2)); + dc.DrawText(t, new Point(12 - t.Width / 2, plot.Top + plot.Height / 2 - t.Height / 2)); + dc.Pop(); + } + } + + private static (double X, double Y)? Nearest(IReadOnlyList<(double X, double Y)> pts, double x) + { + if (pts.Count == 0) return null; + int lo = 0, hi = pts.Count - 1; + while (lo < hi) + { + int mid = (lo + hi) / 2; + if (pts[mid].X < x) lo = mid + 1; else hi = mid; + } + if (lo > 0 && Math.Abs(pts[lo - 1].X - x) < Math.Abs(pts[lo].X - x)) lo--; + return pts[lo]; + } + + private static Geometry BuildGeometry(ChartSeries s, Func X, Func Y, + Rect plot, out Geometry? fill) + { + var stroke = new StreamGeometry(); + StreamGeometry? area = s.FillOpacity > 0 ? new StreamGeometry() : null; + + using (var sc = stroke.Open()) + { + var first = new Point(X(s.Points[0].X), Y(s.Points[0].Y)); + sc.BeginFigure(first, false, false); + var prev = first; + for (int i = 1; i < s.Points.Count; i++) + { + var p = new Point(X(s.Points[i].X), Y(s.Points[i].Y)); + if (s.Stepped) sc.LineTo(new Point(p.X, prev.Y), true, false); + sc.LineTo(p, true, false); + prev = p; + } + } + stroke.Freeze(); + + if (area != null) + { + using (var ac = area.Open()) + { + var first = new Point(X(s.Points[0].X), Y(s.Points[0].Y)); + ac.BeginFigure(new Point(first.X, plot.Bottom), true, true); + ac.LineTo(first, true, false); + var prev = first; + for (int i = 1; i < s.Points.Count; i++) + { + var p = new Point(X(s.Points[i].X), Y(s.Points[i].Y)); + if (s.Stepped) ac.LineTo(new Point(p.X, prev.Y), true, false); + ac.LineTo(p, true, false); + prev = p; + } + ac.LineTo(new Point(prev.X, plot.Bottom), true, false); + } + area.Freeze(); + } + + fill = area; + return stroke; + } + + private static void DrawTooltip(DrawingContext dc, List<(string text, Brush brush)> lines, + Point at, Rect plot, double dpi, ChartPalette pal) + { + var texts = lines.Select(l => Draw.Text(l.text, 11, l.brush, dpi)).ToList(); + double tw = texts.Max(t => t.Width) + 18; + double th = texts.Sum(t => t.Height) + 12; + double x = at.X + 14, y = at.Y - th - 8; + if (x + tw > plot.Right) x = at.X - tw - 14; + if (x < 0) x = 2; + if (y < plot.Top) y = plot.Top + 4; + + var box = new Rect(x, y, tw, th); + dc.DrawRoundedRectangle(Draw.Fade(pal.Raised, 0.97), Draw.Pen(pal.Border, 1), box, 5, 5); + double ty = y + 6; + foreach (var t in texts) { dc.DrawText(t, new Point(x + 9, ty)); ty += t.Height; } + } +} diff --git a/ReplayAnalyser/Controls/MetricBar.cs b/ReplayAnalyser/Controls/MetricBar.cs new file mode 100644 index 0000000..b1d726b --- /dev/null +++ b/ReplayAnalyser/Controls/MetricBar.cs @@ -0,0 +1,111 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/Controls/MetricBar.cs). The centred bar is +// unchanged. It takes Spark's theme colours, and on a narrow page the label and figures move +// above the track instead of the whole row disappearing. +#nullable enable + +using System; +using System.Windows; +using System.Windows.Media; + +namespace Spark.ReplayAnalyser.Controls; + +/// +/// One habit measured against the players in the same lobbies, drawn as a bar growing out +/// from a centre line: right and green when the player is ahead of the room, left and red +/// when behind. The centre is "same as everyone else", which is the only comparison that +/// means anything across mixed skill levels. +/// +public sealed class MetricBar : ThemedElement +{ + public static readonly DependencyProperty EdgeProperty = DependencyProperty.Register( + nameof(Edge), typeof(double), typeof(MetricBar), + new FrameworkPropertyMetadata(0d, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty LabelProperty = DependencyProperty.Register( + nameof(Label), typeof(string), typeof(MetricBar), + new FrameworkPropertyMetadata("", FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty ValueTextProperty = DependencyProperty.Register( + nameof(ValueText), typeof(string), typeof(MetricBar), + new FrameworkPropertyMetadata("", FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty PeerTextProperty = DependencyProperty.Register( + nameof(PeerText), typeof(string), typeof(MetricBar), + new FrameworkPropertyMetadata("", FrameworkPropertyMetadataOptions.AffectsRender)); + + public double Edge { get => (double)GetValue(EdgeProperty); set => SetValue(EdgeProperty, value); } + public string Label { get => (string)GetValue(LabelProperty); set => SetValue(LabelProperty, value); } + public string ValueText { get => (string)GetValue(ValueTextProperty); set => SetValue(ValueTextProperty, value); } + public string PeerText { get => (string)GetValue(PeerTextProperty); set => SetValue(PeerTextProperty, value); } + + private const double LabelWidth = 168; + private const double NumberWidth = 116; + private const double TrackHeight = 9; + private const double RowHeight = 28; + /// Below this width the label and figures sit above the track. + private const double StackBelow = 420; + /// Edge magnitude that fills half the track. Beyond this the bar simply clips. + private const double FullScale = 1.0; + + protected override Size MeasureOverride(Size available) + { + double w = double.IsInfinity(available.Width) ? 460 : available.Width; + return new Size(w, w < StackBelow ? RowHeight + 16 : RowHeight); + } + + protected override void OnRender(DrawingContext dc) + { + double w = ActualWidth, h = ActualHeight; + if (w < 120) return; + double dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var pal = Palette; + + bool stacked = w < StackBelow; + double rowTop = h - RowHeight; + + var numbers = Draw.Text($"{ValueText} vs {PeerText}", 11.5, pal.Dim, dpi, Draw.Mono); + var label = Draw.Text(Label, 12.5, pal.Text, dpi); + label.MaxLineCount = 1; + label.Trimming = TextTrimming.CharacterEllipsis; + + double trackLeft; + if (stacked) + { + // Label left and figures right on the top line; the track gets the full width below. + label.MaxTextWidth = Math.Max(10, w - numbers.Width - 12); + dc.DrawText(label, new Point(0, 0)); + dc.DrawText(numbers, new Point(w - numbers.Width, (label.Height - numbers.Height) / 2)); + trackLeft = 0; + } + else + { + label.MaxTextWidth = LabelWidth - 8; + dc.DrawText(label, new Point(0, rowTop + (RowHeight - label.Height) / 2)); + dc.DrawText(numbers, new Point(LabelWidth, rowTop + (RowHeight - numbers.Height) / 2)); + // Long figures push the track along rather than running underneath it. + trackLeft = LabelWidth + Math.Max(NumberWidth, numbers.Width + 14); + } + + double trackWidth = Math.Max(30, w - trackLeft); + double centre = trackLeft + trackWidth / 2; + double y = rowTop + (RowHeight - TrackHeight) / 2; + + dc.DrawRoundedRectangle(pal.Raised, null, + new Rect(trackLeft, y, trackWidth, TrackHeight), TrackHeight / 2, TrackHeight / 2); + + double frac = Math.Clamp(Edge / FullScale, -1, 1); + double len = Math.Abs(frac) * (trackWidth / 2); + if (len > 1) + { + var brush = pal.Of(Edge >= 0 ? Tone.Good : Tone.Bad); + var rect = Edge >= 0 + ? new Rect(centre, y, len, TrackHeight) + : new Rect(centre - len, y, len, TrackHeight); + dc.DrawRoundedRectangle(brush, null, rect, TrackHeight / 2, TrackHeight / 2); + } + + // Centre line: level with the room. + dc.DrawRectangle(pal.Faint, null, + new Rect(Math.Round(centre) - 0.5, y - 3, 1, TrackHeight + 6)); + } +} diff --git a/ReplayAnalyser/Controls/RadarChart.cs b/ReplayAnalyser/Controls/RadarChart.cs new file mode 100644 index 0000000..6885992 --- /dev/null +++ b/ReplayAnalyser/Controls/RadarChart.cs @@ -0,0 +1,136 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/Controls/RadarChart.cs). Geometry and labels +// are unchanged; the two shapes take theme tones rather than fixed brushes. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Media; + +namespace Spark.ReplayAnalyser.Controls; + +public sealed record RadarAxis(string Label, double Value); + +/// +/// Radar plot of a player's rated areas. Two shapes can be overlaid so a player can be +/// compared against an opponent or against the lobby average. +/// +public sealed class RadarChart : ThemedElement +{ + public static readonly DependencyProperty AxesProperty = DependencyProperty.Register( + nameof(Axes), typeof(IReadOnlyList), typeof(RadarChart), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + /// Optional second shape, drawn behind the first — the comparison. + public static readonly DependencyProperty CompareProperty = DependencyProperty.Register( + nameof(Compare), typeof(IReadOnlyList), typeof(RadarChart), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty ToneProperty = DependencyProperty.Register( + nameof(Tone), typeof(Tone), typeof(RadarChart), + new FrameworkPropertyMetadata(Tone.Accent, FrameworkPropertyMetadataOptions.AffectsRender)); + + public static readonly DependencyProperty CompareToneProperty = DependencyProperty.Register( + nameof(CompareTone), typeof(Tone), typeof(RadarChart), + new FrameworkPropertyMetadata(Tone.Orange, FrameworkPropertyMetadataOptions.AffectsRender)); + + public IReadOnlyList? Axes + { + get => (IReadOnlyList?)GetValue(AxesProperty); + set => SetValue(AxesProperty, value); + } + public IReadOnlyList? Compare + { + get => (IReadOnlyList?)GetValue(CompareProperty); + set => SetValue(CompareProperty, value); + } + public Tone Tone { get => (Tone)GetValue(ToneProperty); set => SetValue(ToneProperty, value); } + public Tone CompareTone { get => (Tone)GetValue(CompareToneProperty); set => SetValue(CompareToneProperty, value); } + + protected override void OnRender(DrawingContext dc) + { + var axes = Axes; + if (axes == null || axes.Count < 3) return; + + double w = ActualWidth, h = ActualHeight; + if (w < 80 || h < 80) return; + double dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var pal = Palette; + + var centre = new Point(w / 2, h / 2 + 4); + // Leave room for the labels that sit outside the outermost ring. + double radius = Math.Min(w, h) / 2 - 34; + if (radius < 20) return; + + int n = axes.Count; + Point At(int i, double frac) + { + double a = -Math.PI / 2 + 2 * Math.PI * i / n; + return new Point(centre.X + Math.Cos(a) * radius * frac, + centre.Y + Math.Sin(a) * radius * frac); + } + + // ---- rings and spokes ---- + var ringPen = Draw.Pen(pal.Border, 1); + for (int r = 1; r <= 4; r++) + { + var g = new StreamGeometry(); + using (var c = g.Open()) + { + c.BeginFigure(At(0, r / 4.0), false, true); + for (int i = 1; i < n; i++) c.LineTo(At(i, r / 4.0), true, false); + } + g.Freeze(); + dc.DrawGeometry(null, ringPen, g); + } + for (int i = 0; i < n; i++) + dc.DrawLine(ringPen, centre, At(i, 1)); + + // ---- shapes ---- + var accent = pal.Of(Tone); + if (Compare is { Count: > 2 }) + DrawShape(dc, Compare, At, pal.Of(CompareTone), 0.10, 1.5, dashed: true); + DrawShape(dc, axes, At, accent, 0.22, 2, dashed: false); + + // ---- vertices and labels ---- + for (int i = 0; i < n; i++) + { + var p = At(i, Math.Clamp(axes[i].Value / 100.0, 0, 1)); + dc.DrawEllipse(accent, Draw.Pen(pal.Card, 1.5), p, 3.5, 3.5); + + var label = Draw.Text(axes[i].Label, 10.5, pal.Dim, dpi, Draw.UiBold); + var value = Draw.Text($"{axes[i].Value:F0}", 10.5, pal.Text, dpi, Draw.Mono); + var anchor = At(i, 1.16); + + double lx = anchor.X - label.Width / 2; + double ly = anchor.Y - label.Height / 2 - 5; + // Nudge the side labels outward so they clear the outer ring. + double cos = Math.Cos(-Math.PI / 2 + 2 * Math.PI * i / n); + if (cos > 0.5) lx = anchor.X - 2; + else if (cos < -0.5) lx = anchor.X - label.Width + 2; + + dc.DrawText(label, new Point(lx, ly)); + dc.DrawText(value, new Point(anchor.X - value.Width / 2 + (cos > 0.5 ? label.Width / 2 : cos < -0.5 ? -label.Width / 2 : 0), + ly + label.Height - 1)); + } + } + + private static void DrawShape(DrawingContext dc, IReadOnlyList axes, + Func at, Brush colour, + double fillOpacity, double thickness, bool dashed) + { + var g = new StreamGeometry(); + using (var c = g.Open()) + { + c.BeginFigure(at(0, Math.Clamp(axes[0].Value / 100.0, 0, 1)), true, true); + for (int i = 1; i < axes.Count; i++) + c.LineTo(at(i, Math.Clamp(axes[i].Value / 100.0, 0, 1)), true, false); + } + g.Freeze(); + + var pen = new Pen(colour, thickness); + if (dashed) pen.DashStyle = new DashStyle(new double[] { 3, 3 }, 0); + pen.Freeze(); + dc.DrawGeometry(Draw.Fade(colour, fillOpacity), pen, g); + } +} diff --git a/ReplayAnalyser/Controls/Tone.cs b/ReplayAnalyser/Controls/Tone.cs new file mode 100644 index 0000000..a66d7bc --- /dev/null +++ b/ReplayAnalyser/Controls/Tone.cs @@ -0,0 +1,141 @@ +// Part of the Replay Analyser port. The original app painted with fixed brushes handed out by its +// view models; Spark re-themes at runtime, so colours here are roles that resolve against the +// active theme instead. +#nullable enable + +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using System.Windows.Shapes; + +namespace Spark.ReplayAnalyser.Controls; + +/// +/// A colour role rather than a colour. View models say what something means — blue team, a +/// strength, a weakness — and the view looks up what that looks like in the current Spark theme, +/// so a theme change repaints every page instead of leaving the colours it was built with. +/// +public enum Tone +{ + Primary, + Dim, + Faint, + Accent, + Blue, + Orange, + Good, + Warn, + Bad, +} + +/// The theme resource keys behind each (see ThemesController). +public static class ToneKeys +{ + /// The tone at full strength: lines, text, markers. + public static string Solid(Tone tone) => tone switch + { + Tone.Primary => "TextPrimary", + Tone.Dim => "TextDim", + Tone.Faint => "TextFaint", + Tone.Accent => "ControlAccent", + Tone.Blue => "TeamBlue", + Tone.Orange => "TeamOrange", + Tone.Good => "StatusGood", + Tone.Warn => "StatusWarn", + Tone.Bad => "StatusBad", + _ => "TextDim", + }; + + /// A wash of the tone that text can sit on. Neutral tones fall back to a raised surface. + public static string Tint(Tone tone) => tone switch + { + Tone.Blue => "TeamBlueTint", + Tone.Orange => "TeamOrangeTint", + Tone.Good => "StatusGoodTint", + Tone.Warn => "StatusWarnTint", + Tone.Bad => "StatusBadTint", + _ => "SurfaceRaised", + }; + + /// The border that goes with . + public static string Edge(Tone tone) => tone switch + { + Tone.Blue => "TeamBlueEdge", + Tone.Orange => "TeamOrangeEdge", + Tone.Good => "StatusGoodEdge", + Tone.Warn => "StatusWarnEdge", + Tone.Bad => "StatusBadEdge", + _ => "SurfaceBorder", + }; +} + +/// +/// Attached properties that paint an element in a , as a live reference to the +/// theme brush — the XAML equivalent of {DynamicResource TeamBlue}, but chosen by a binding. +/// +public static class ToneBrush +{ + public static readonly DependencyProperty BackgroundProperty = Register("Background", Paint.Background); + public static readonly DependencyProperty ForegroundProperty = Register("Foreground", Paint.Foreground); + public static readonly DependencyProperty FillProperty = Register("Fill", Paint.Fill); + public static readonly DependencyProperty StrokeProperty = Register("Stroke", Paint.Stroke); + public static readonly DependencyProperty TintProperty = Register("Tint", Paint.Tint); + public static readonly DependencyProperty EdgeProperty = Register("Edge", Paint.Edge); + + public static Tone? GetBackground(DependencyObject d) => (Tone?)d.GetValue(BackgroundProperty); + public static void SetBackground(DependencyObject d, Tone? v) => d.SetValue(BackgroundProperty, v); + public static Tone? GetForeground(DependencyObject d) => (Tone?)d.GetValue(ForegroundProperty); + public static void SetForeground(DependencyObject d, Tone? v) => d.SetValue(ForegroundProperty, v); + public static Tone? GetFill(DependencyObject d) => (Tone?)d.GetValue(FillProperty); + public static void SetFill(DependencyObject d, Tone? v) => d.SetValue(FillProperty, v); + public static Tone? GetStroke(DependencyObject d) => (Tone?)d.GetValue(StrokeProperty); + public static void SetStroke(DependencyObject d, Tone? v) => d.SetValue(StrokeProperty, v); + /// Background in the tone's tint. + public static Tone? GetTint(DependencyObject d) => (Tone?)d.GetValue(TintProperty); + public static void SetTint(DependencyObject d, Tone? v) => d.SetValue(TintProperty, v); + /// Border brush in the tone's edge colour. + public static Tone? GetEdge(DependencyObject d) => (Tone?)d.GetValue(EdgeProperty); + public static void SetEdge(DependencyObject d, Tone? v) => d.SetValue(EdgeProperty, v); + + private enum Paint { Background, Foreground, Fill, Stroke, Tint, Edge } + + // Nullable with a null default, so the first value always registers as a change — with a + // plain enum default, binding the default tone would never apply its brush. + private static DependencyProperty Register(string name, Paint paint) => + DependencyProperty.RegisterAttached(name, typeof(Tone?), typeof(ToneBrush), + new PropertyMetadata(null, (d, e) => { if (e.NewValue is Tone t) Apply(d, paint, t); })); + + private static void Apply(DependencyObject d, Paint paint, Tone tone) + { + string key = paint switch + { + Paint.Tint => ToneKeys.Tint(tone), + Paint.Edge => ToneKeys.Edge(tone), + _ => ToneKeys.Solid(tone), + }; + + DependencyProperty? target = (paint, d) switch + { + (Paint.Background or Paint.Tint, Border) => Border.BackgroundProperty, + (Paint.Background or Paint.Tint, Panel) => Panel.BackgroundProperty, + (Paint.Background or Paint.Tint, Control) => Control.BackgroundProperty, + (Paint.Background or Paint.Tint, TextBlock) => TextBlock.BackgroundProperty, + (Paint.Foreground, TextBlock) => TextBlock.ForegroundProperty, + (Paint.Foreground, Control) => Control.ForegroundProperty, + (Paint.Foreground, TextElement) => TextElement.ForegroundProperty, + (Paint.Fill, Shape) => Shape.FillProperty, + (Paint.Stroke, Shape) => Shape.StrokeProperty, + (Paint.Edge, Border) => Border.BorderBrushProperty, + (Paint.Edge, Control) => Control.BorderBrushProperty, + _ => null, + }; + if (target == null) return; + + switch (d) + { + case FrameworkElement fe: fe.SetResourceReference(target, key); break; + case FrameworkContentElement fce: fce.SetResourceReference(target, key); break; + } + } +} diff --git a/ReplayAnalyser/Core/Analysis/ArenaGeometry.cs b/ReplayAnalyser/Core/Analysis/ArenaGeometry.cs new file mode 100644 index 0000000..291328b --- /dev/null +++ b/ReplayAnalyser/Core/Analysis/ArenaGeometry.cs @@ -0,0 +1,98 @@ +// Ported from the Replay Analyser (EchoAnalyser.Core/Analysis/ArenaGeometry.cs). Logic is unchanged; only the +// namespace, explicit usings and nullable context differ, so fixes can be diffed against +// the original. +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Numerics; + +namespace Spark.ReplayAnalyser.Analysis; + +/// +/// Echo Arena's playing field in API/game space (metres). +/// +/// All of these numbers were measured from recorded matches rather than assumed: the disc +/// stays inside Z ±40, X ±16, Y ±10, and the scoreboard ticks over when the disc reaches +/// Z ≈ ±36 at X ≈ 0, Y ≈ 0 — which is the goal ring. +/// +/// Blue scores at +Z and orange scores at -Z, so each team defends the goal that carries +/// its own name. +/// +public static class ArenaGeometry +{ + /// Distance from centre to a goal ring along Z, measured from scoring frames. + public const float GoalZ = 36f; + + /// Distance from centre to the end wall — the limit of the play space. + public const float HalfLength = 40f; + + /// Radius of the scoring ring. + public const float GoalRadius = 2.5f; + + /// Half-width (X) and half-height (Y) of the play space. + public const float HalfWidth = 16f; + public const float HalfHeight = 10f; + + /// The goal blue defends, at negative Z. + public static readonly Vector3 BlueGoal = new(0f, 0f, -GoalZ); + + /// The goal orange defends, at positive Z. + public static readonly Vector3 OrangeGoal = new(0f, 0f, GoalZ); + + /// The goal a team is trying to score in. + public static Vector3 AttackingGoal(TeamSide side) => + side == TeamSide.Blue ? OrangeGoal : BlueGoal; + + /// The goal a team is defending. + public static Vector3 DefendingGoal(TeamSide side) => + side == TeamSide.Blue ? BlueGoal : OrangeGoal; + + /// +1 if the team attacks toward +Z, -1 otherwise. + public static float AttackDirection(TeamSide side) => side == TeamSide.Blue ? 1f : -1f; + + /// + /// Where a position sits relative to a team's own goal, as a 0..1 value: + /// 0 = on top of their own goal, 1 = on top of the goal they are attacking. + /// Players can drift into the spawn tunnels beyond the walls, so this clamps. + /// + public static float FieldProgress(Vector3 position, TeamSide side) + { + float z = position.Z * AttackDirection(side); // own goal now at -HalfLength + return Math.Clamp((z + HalfLength) / (2f * HalfLength), 0f, 1f); + } + + public static ArenaZone ZoneFor(Vector3 position, TeamSide side) + { + float p = FieldProgress(position, side); + if (p < 1f / 3f) return ArenaZone.DefensiveThird; + if (p > 2f / 3f) return ArenaZone.OffensiveThird; + return ArenaZone.Neutral; + } + + public static float DistanceToGoal(Vector3 position, Vector3 goal) => + Vector3.Distance(position, goal); + + /// True when a position is close enough to the goal to count as guarding it. + public static bool InGoalieBox(Vector3 position, TeamSide defending) => + Vector3.Distance(position, DefendingGoal(defending)) < 12f; + + public static bool IsInOwnHalf(Vector3 position, TeamSide side) => + FieldProgress(position, side) < 0.5f; + + /// Normalised 0..1 X/Z coordinates for heat-mapping, Z first (arena runs along Z). + public static (float x, float y) ToTopDownUv(Vector3 p) => + ((p.Z + HalfLength) / (2f * HalfLength), (p.X + HalfWidth) / (2f * HalfWidth)); + + /// Normalised 0..1 Z/Y coordinates for the side-on view. + public static (float x, float y) ToSideUv(Vector3 p) => + ((p.Z + HalfLength) / (2f * HalfLength), (p.Y + HalfHeight) / (2f * HalfHeight)); +} + +public enum ArenaZone { DefensiveThird, Neutral, OffensiveThird } + +public enum TeamSide { Blue = 0, Orange = 1, Spectator = 2 } diff --git a/ReplayAnalyser/Core/Analysis/CareerAnalyser.cs b/ReplayAnalyser/Core/Analysis/CareerAnalyser.cs new file mode 100644 index 0000000..4ce1129 --- /dev/null +++ b/ReplayAnalyser/Core/Analysis/CareerAnalyser.cs @@ -0,0 +1,375 @@ +// Ported from the Replay Analyser (EchoAnalyser.Core/Analysis/CareerAnalyser.cs). Logic is unchanged; only the +// namespace, explicit usings and nullable context differ, so fixes can be diffed against +// the original. +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Spark.ReplayAnalyser.Model; + +namespace Spark.ReplayAnalyser.Analysis; + +/// How a single measured habit compares to the people the player actually played with. +public sealed class CareerMetric +{ + public string Name { get; init; } = ""; + /// What the number means, in the player's terms. + public string Description { get; init; } = ""; + public double Value { get; init; } + /// The same figure for everyone else in the same lobbies. + public double PeerValue { get; init; } + /// True when a smaller number is the better one. + public bool LowerIsBetter { get; init; } + public string Format { get; init; } = "0.00"; + + /// Signed edge over peers, normalised: +1 means twice as good, -1 twice as bad. + public double Edge + { + get + { + if (PeerValue <= 0 && Value <= 0) return 0; + if (PeerValue <= 0) return LowerIsBetter ? -1 : 1; + double ratio = Value / PeerValue; + double signed = LowerIsBetter ? 1 - ratio : ratio - 1; + return Math.Clamp(signed, -1.5, 1.5); + } + } + + /// Change from the earliest third of their history to the most recent third. + public double EarlyValue { get; init; } + public double RecentValue { get; init; } + + public double TrendEdge + { + get + { + if (EarlyValue <= 0) return 0; + double ratio = RecentValue / EarlyValue; + double signed = LowerIsBetter ? 1 - ratio : ratio - 1; + return Math.Clamp(signed, -1.5, 1.5); + } + } + + public string Verdict => Edge switch + { + >= 0.35 => "Strong", + >= 0.12 => "Above the room", + > -0.12 => "About average", + > -0.35 => "Below the room", + _ => "Weak", + }; + + public string TrendText => Math.Abs(TrendEdge) < 0.12 + ? "steady" + : TrendEdge > 0 ? "improving" : "slipping"; + + public string ValueText => Value.ToString(Format); + public string PeerText => PeerValue.ToString(Format); +} + +/// Everything we can say about one player across their whole indexed library. +public sealed class CareerProfile +{ + public string Player { get; init; } = ""; + public int Matches { get; init; } + public double LiveMinutes { get; init; } + public DateTime FirstSeen { get; init; } + public DateTime LastSeen { get; init; } + + public int GoalPoints { get; init; } + public int Goals { get; init; } + public int Assists { get; init; } + public int Saves { get; init; } + public int Stuns { get; init; } + public int MissedGrabs { get; init; } + public int GrabChances { get; init; } + public int CarriedIntoStun { get; init; } + public int TurnoversAtHome { get; init; } + public int Turnovers { get; init; } + + public List Metrics { get; } = new(); + public List Insights { get; } = new(); + public string Summary { get; set; } = ""; + /// Matches in the order they were played, for charting form. + public List Timeline { get; } = new(); + + public IEnumerable Strengths => + Metrics.Where(x => x.Edge >= 0.12).OrderByDescending(x => x.Edge); + public IEnumerable Weaknesses => + Metrics.Where(x => x.Edge <= -0.12).OrderBy(x => x.Edge); +} + +/// +/// Reads a player's whole indexed library and says what they are good at, what they are not, +/// and what has changed. +/// +/// Every judgement is made against the other players in the same lobbies rather than against +/// fixed numbers. Someone in high-level scrims will look worse on absolute stats than someone +/// farming beginners, and the point of the report is to be useful to both. +/// +public static class CareerAnalyser +{ + /// Minimum live seconds in a match before it counts towards the career view. + private const double MinSecondsPerMatch = 60; + + public static IReadOnlyList KnownPlayers(LibraryIndex index, int minMatches = 3) + { + var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var m in index.Entries.Values.Where(e => e.Ok)) + foreach (var p in m.Players.Where(p => p.SecondsPlayed >= MinSecondsPerMatch)) + counts[p.Name] = counts.GetValueOrDefault(p.Name) + 1; + + return counts.Where(kv => kv.Value >= minMatches) + .OrderByDescending(kv => kv.Value) + .Select(kv => kv.Key) + .ToList(); + } + + public static CareerProfile? Build(LibraryIndex index, string player) + { + var rows = new List<(MatchSummary match, PlayerSummary me, List peers)>(); + + foreach (var m in index.Entries.Values.Where(e => e.Ok).OrderBy(e => e.Recorded)) + { + var me = m.Players.FirstOrDefault(p => + string.Equals(p.Name, player, StringComparison.OrdinalIgnoreCase) && + p.SecondsPlayed >= MinSecondsPerMatch); + if (me == null) continue; + + var peers = m.Players + .Where(p => p.SecondsPlayed >= MinSecondsPerMatch && p.Name != me.Name) + .ToList(); + rows.Add((m, me, peers)); + } + + if (rows.Count == 0) return null; + + double liveSeconds = rows.Sum(r => r.me.SecondsPlayed); + var profile = new CareerProfile + { + Player = rows[^1].me.Name, + Matches = rows.Count, + LiveMinutes = liveSeconds / 60.0, + FirstSeen = rows[0].match.Recorded, + LastSeen = rows[^1].match.Recorded, + GoalPoints = rows.Sum(r => r.me.GoalPoints), + Goals = rows.Sum(r => r.me.Goals), + Assists = rows.Sum(r => r.me.Assists), + Saves = rows.Sum(r => r.me.Saves), + Stuns = rows.Sum(r => r.me.Stuns), + MissedGrabs = rows.Sum(r => r.me.MissedGrabs), + GrabChances = rows.Sum(r => r.me.GrabChances), + CarriedIntoStun = rows.Sum(r => r.me.CarriedIntoStun), + TurnoversAtHome = rows.Sum(r => r.me.TurnoversAtHome), + Turnovers = rows.Sum(r => r.me.Turnovers), + }; + profile.Timeline.AddRange(rows.Select(r => r.match)); + + BuildMetrics(profile, rows); + BuildInsights(profile); + profile.Summary = Describe(profile); + return profile; + } + + // ------------------------------------------------------------------ metrics + + private static void BuildMetrics( + CareerProfile profile, + List<(MatchSummary match, PlayerSummary me, List peers)> rows) + { + // The first and last thirds of their history, for the trend. + int third = Math.Max(1, rows.Count / 3); + var early = rows.Take(third).ToList(); + var recent = rows.Skip(Math.Max(0, rows.Count - third)).ToList(); + + void Add(string name, string description, Func pick, + bool lowerIsBetter = false, string format = "0.00") + { + profile.Metrics.Add(new CareerMetric + { + Name = name, + Description = description, + LowerIsBetter = lowerIsBetter, + Format = format, + Value = WeightedMean(rows.Select(r => (pick(r.me), r.me.SecondsPlayed))), + PeerValue = WeightedMean(rows.SelectMany(r => r.peers.Select(p => (pick(p), p.SecondsPlayed)))), + EarlyValue = WeightedMean(early.Select(r => (pick(r.me), r.me.SecondsPlayed))), + RecentValue = WeightedMean(recent.Select(r => (pick(r.me), r.me.SecondsPlayed))), + }); + } + + Add("Scoring", "Points you put on the board per minute of live play", + p => p.PointsPerMinute); + Add("Finishing", "Share of your shots that go in", + p => p.ShotsTaken == 0 ? 0 : (double)p.Goals / p.ShotsTaken, format: "0%"); + Add("Playmaking", "Assists per minute", + p => p.SecondsPlayed <= 0 ? 0 : p.Assists / (p.SecondsPlayed / 60.0)); + Add("Distribution", "Handovers to team-mates per minute", + p => p.SecondsPlayed <= 0 ? 0 : Math.Max(p.Passes, p.Handovers) / (p.SecondsPlayed / 60.0)); + Add("Saves", "Saves per minute", + p => p.SecondsPlayed <= 0 ? 0 : p.Saves / (p.SecondsPlayed / 60.0)); + Add("Physicality", "Stuns landed per minute", + p => p.StunsPerMinute); + Add("Work rate", "Metres skated per minute", + p => p.SecondsPlayed <= 0 ? 0 : p.DistanceTravelled / (p.SecondsPlayed / 60.0), format: "0"); + Add("Hands", "Share of reachable loose discs you actually take", + p => p.GrabSuccessRate, format: "0%"); + Add("Missed grabs", "Loose discs within reach that you did not take, per minute", + p => p.MissedGrabsPerMinute, lowerIsBetter: true); + Add("Disc security", "Turnovers per minute", + p => p.TurnoversPerMinute, lowerIsBetter: true); + Add("Turnovers at home", "Giveaways inside your own third, per minute", + p => p.SecondsPlayed <= 0 ? 0 : p.TurnoversAtHome / (p.SecondsPlayed / 60.0), + lowerIsBetter: true); + Add("Carrying into contact", "Times stunned out of possession, per minute", + p => p.SecondsPlayed <= 0 ? 0 : p.CarriedIntoStun / (p.SecondsPlayed / 60.0), + lowerIsBetter: true); + } + + private static double WeightedMean(IEnumerable<(double value, double weight)> items) + { + double sum = 0, weight = 0; + foreach (var (v, w) in items) + { + if (w <= 0 || double.IsNaN(v)) continue; + sum += v * w; + weight += w; + } + return weight <= 0 ? 0 : sum / weight; + } + + // ------------------------------------------------------------------ insights + + private static void BuildInsights(CareerProfile p) + { + foreach (var m in p.Strengths.Take(3)) + p.Insights.Add(new Insight + { + Kind = InsightKind.Strength, + Area = AreaFor(m.Name), + Title = $"{m.Name} is your edge", + Detail = $"{m.ValueText} against {m.PeerText} for everyone else you play with — " + + $"{Describe(m.Edge)}. This has been {m.TrendText} over your history.", + Evidence = $"{m.ValueText} vs {m.PeerText}", + Confidence = 0.5 + Math.Min(0.45, Math.Abs(m.Edge) / 2), + }); + + foreach (var m in p.Weaknesses.Take(3)) + p.Insights.Add(new Insight + { + Kind = InsightKind.Weakness, + Area = AreaFor(m.Name), + Title = $"{m.Name} is costing you", + Detail = $"{m.ValueText} against {m.PeerText} for everyone else in your lobbies — " + + $"{Describe(m.Edge)}. This has been {m.TrendText}.", + Action = ActionFor(m.Name), + Evidence = $"{m.ValueText} vs {m.PeerText}", + Confidence = 0.5 + Math.Min(0.45, Math.Abs(m.Edge) / 2), + }); + + // Direction of travel matters as much as the level. + var slipping = p.Metrics.Where(m => m.TrendEdge <= -0.2).OrderBy(m => m.TrendEdge).FirstOrDefault(); + if (slipping != null) + p.Insights.Add(new Insight + { + Kind = InsightKind.Improvement, + Area = AreaFor(slipping.Name), + Title = $"{slipping.Name} has gone backwards", + Detail = $"Your first matches averaged {slipping.EarlyValue.ToString(slipping.Format)}; " + + $"your recent ones {slipping.RecentValue.ToString(slipping.Format)}.", + Action = ActionFor(slipping.Name), + Evidence = $"{slipping.EarlyValue.ToString(slipping.Format)} → {slipping.RecentValue.ToString(slipping.Format)}", + Confidence = 0.65, + }); + + var climbing = p.Metrics.Where(m => m.TrendEdge >= 0.25).OrderByDescending(m => m.TrendEdge).FirstOrDefault(); + if (climbing != null) + p.Insights.Add(new Insight + { + Kind = InsightKind.Note, + Area = AreaFor(climbing.Name), + Title = $"{climbing.Name} is climbing", + Detail = $"Up from {climbing.EarlyValue.ToString(climbing.Format)} to " + + $"{climbing.RecentValue.ToString(climbing.Format)} across your library. Keep doing whatever changed.", + Evidence = $"{climbing.EarlyValue.ToString(climbing.Format)} → {climbing.RecentValue.ToString(climbing.Format)}", + Confidence = 0.6, + }); + + if (p.GrabChances >= 30) + { + var hands = p.Metrics.First(m => m.Name == "Hands"); + p.Insights.Add(new Insight + { + Kind = hands.Edge >= 0 ? InsightKind.Note : InsightKind.Improvement, + Area = InsightArea.Possession, + Title = "Hands, in raw numbers", + Detail = $"{p.GrabChances - p.MissedGrabs} of {p.GrabChances} loose discs taken when one was " + + $"within reach ({hands.ValueText}); the room manages {hands.PeerText}.", + Action = hands.Edge < 0 + ? "Meet the disc with the hand already open rather than reaching late. Most misses are a hand arriving after the disc has passed." + : null, + Evidence = $"{p.MissedGrabs} missed", + Confidence = 0.7, + }); + } + + p.Insights.Sort((a, b) => b.Confidence.CompareTo(a.Confidence)); + } + + private static string Describe(double edge) => Math.Abs(edge) switch + { + >= 0.5 => edge > 0 ? "far ahead of the room" : "far behind the room", + >= 0.25 => edge > 0 ? "clearly better" : "clearly worse", + >= 0.12 => edge > 0 ? "a little better" : "a little worse", + _ => "about the same", + }; + + private static InsightArea AreaFor(string metric) => metric switch + { + "Scoring" or "Finishing" => InsightArea.Finishing, + "Playmaking" or "Distribution" => InsightArea.Passing, + "Saves" => InsightArea.Defence, + "Physicality" or "Carrying into contact" => InsightArea.Physicality, + "Work rate" => InsightArea.Movement, + "Hands" or "Missed grabs" => InsightArea.Possession, + _ => InsightArea.Discipline, + }; + + private static string? ActionFor(string metric) => metric switch + { + "Scoring" => "Get closer before releasing. Most low-scoring records are shot selection, not shot power.", + "Finishing" => "Stop taking the first shot available. One more pass to get inside the three-line converts far better.", + "Playmaking" => "Look up before you carry. The pass out and back beats driving into a set defence.", + "Distribution" => "Use your team-mates as a release valve instead of holding until you are covered.", + "Saves" => "Start a step off the line so you can read the throw rather than committing early.", + "Physicality" => "Body the carrier rather than chasing the disc — a stun buys your team more than a missed intercept.", + "Work rate" => "You are covering less ground than the room. Reset your position after every exchange instead of watching the play.", + "Hands" => "Meet the disc with the hand already open. Reaching late is what turns a catch into a bobble.", + "Missed grabs" => "Set your hand where the disc will be, not where it is. Most misses are a late reach.", + "Disc security" => "Move the disc earlier. Your giveaways are coming from holding it into pressure.", + "Turnovers at home" => "Clear it long from your own third rather than trying to beat the first forechecker.", + "Carrying into contact" => "Release before contact. Being stunned on the disc hands them the counter for free.", + _ => null, + }; + + private static string Describe(CareerProfile p) + { + var best = p.Strengths.FirstOrDefault(); + var worst = p.Weaknesses.FirstOrDefault(); + + string span = p.FirstSeen.Year > 2000 + ? $"{p.FirstSeen:d MMM} to {p.LastSeen:d MMM}" + : "your library"; + + string line = $"{p.Player} across {p.Matches} matches ({p.LiveMinutes:F0} minutes of live play, {span}): " + + $"{p.GoalPoints} points from {p.Goals} goals, {p.Assists} assists, {p.Saves} saves and {p.Stuns} stuns."; + + if (best != null) line += $" Your clearest edge is {best.Name.ToLowerInvariant()}."; + if (worst != null) line += $" The biggest thing to fix is {worst.Name.ToLowerInvariant()}."; + return line; + } +} diff --git a/ReplayAnalyser/Core/Analysis/InsightEngine.cs b/ReplayAnalyser/Core/Analysis/InsightEngine.cs new file mode 100644 index 0000000..20ca1c8 --- /dev/null +++ b/ReplayAnalyser/Core/Analysis/InsightEngine.cs @@ -0,0 +1,746 @@ +// Ported from the Replay Analyser (EchoAnalyser.Core/Analysis/InsightEngine.cs). Logic is unchanged; only the +// namespace, explicit usings and nullable context differ, so fixes can be diffed against +// the original. +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Numerics; +using Spark.ReplayAnalyser.Model; + +namespace Spark.ReplayAnalyser.Analysis; + +public enum InsightKind { Strength, Weakness, Improvement, Counter, Note } + +public enum InsightArea +{ + Finishing, Possession, Passing, Defence, Positioning, Physicality, + Mechanics, Movement, Discipline, TeamShape, +} + +public sealed class Insight +{ + public InsightKind Kind { get; init; } + public InsightArea Area { get; init; } + public string Title { get; init; } = ""; + public string Detail { get; init; } = ""; + /// What to actually do about it. + public string? Action { get; init; } + /// 0..1 — how strongly the data supports this, used for ordering. + public double Confidence { get; init; } = 0.5; + /// The number the insight is built on, for display next to the text. + public string? Evidence { get; init; } +} + +public sealed class PlayerReport +{ + public PlayerAnalysis Player { get; init; } = null!; + public MatchAnalysis Match { get; init; } = null!; + public List Insights { get; } = new(); + + public IEnumerable Strengths => Insights.Where(i => i.Kind == InsightKind.Strength); + public IEnumerable Weaknesses => Insights.Where(i => i.Kind == InsightKind.Weakness); + public IEnumerable Improvements => Insights.Where(i => i.Kind == InsightKind.Improvement); + public IEnumerable Counters => Insights.Where(i => i.Kind == InsightKind.Counter); + + /// A 0-100 rating per area, for the radar chart. + public Dictionary AreaScores { get; } = new(); + public double OverallRating { get; set; } + public string Summary { get; set; } = ""; +} + +/// +/// Rule-based coaching. Every rule compares a player against the other players in the same +/// match rather than against absolute numbers, so the advice stays meaningful whether the +/// lobby is casual or a scrim between ranked teams. +/// +public static class InsightEngine +{ + public static PlayerReport BuildReport(MatchAnalysis match, PlayerAnalysis player, bool asOpponent) + { + var report = new PlayerReport { Player = player, Match = match }; + var peers = match.AllPlayers.Where(p => p.UserId != player.UserId && p.SecondsPlayed > 30).ToList(); + var teammates = match.AllPlayers + .Where(p => p.Side == player.Side && p.UserId != player.UserId && p.SecondsPlayed > 30).ToList(); + + ScoreAreas(report, player, peers); + + if (asOpponent) BuildCounterPlan(report, match, player, peers); + else BuildSelfReview(report, match, player, peers, teammates); + + report.Insights.Sort((a, b) => b.Confidence.CompareTo(a.Confidence)); + report.Summary = BuildSummary(match, player, report, asOpponent); + return report; + } + + // ------------------------------------------------------------------ ratings + + private static void ScoreAreas(PlayerReport r, PlayerAnalysis p, List peers) + { + double PerMin(double v) => p.SecondsPlayed <= 0 ? 0 : v / (p.SecondsPlayed / 60.0); + + double finishing = Rank(p.Goals, peers.Select(x => (double)x.Goals)) * 0.6 + + Rank(p.ShotAccuracy, peers.Select(x => x.ShotAccuracy)) * 0.4; + double possession = Rank(p.PossessionTime, peers.Select(x => (double)x.PossessionTime)) * 0.6 + + (1 - Rank(PerMin(p.Turnovers), peers.Select(x => x.SecondsPlayed <= 0 ? 0 : x.Turnovers / (x.SecondsPlayed / 60.0)))) * 0.4; + double passing = Rank(p.PassCount, peers.Select(x => (double)x.PassCount)) * 0.7 + + Rank(p.Assists, peers.Select(x => (double)x.Assists)) * 0.3; + double defence = Rank(p.Saves, peers.Select(x => (double)x.Saves)) * 0.4 + + Rank(p.Blocks, peers.Select(x => (double)x.Blocks)) * 0.2 + + Rank(p.Interceptions, peers.Select(x => (double)x.Interceptions)) * 0.2 + + Rank(p.Steals, peers.Select(x => (double)x.Steals)) * 0.2; + double physical = Rank(p.Stuns, peers.Select(x => (double)x.Stuns)) * 0.7 + + (1 - Rank(p.TimesStunned, peers.Select(x => (double)x.TimesStunned))) * 0.3; + double movement = Rank(p.AverageSpeed, peers.Select(x => x.AverageSpeed)) * 0.5 + + Rank(p.DistanceTravelled, peers.Select(x => x.DistanceTravelled)) * 0.5; + // Throw mechanics only exist for the client that recorded the replay, so rank them + // against the other players who also have telemetry — usually nobody, in which case + // the area is left off the chart entirely rather than invented. + var throwPeers = peers.Where(x => x.HasThrowTelemetry && x.Throws.Count >= 3).ToList(); + bool hasMechanics = p.HasThrowTelemetry && p.Throws.Count >= 3; + double mechanics = hasMechanics + ? Rank(p.AverageThrowSpeed, throwPeers.Select(x => x.AverageThrowSpeed)) * 0.6 + + (1 - Rank(p.AverageThrowPenalty, throwPeers.Select(x => x.AverageThrowPenalty))) * 0.4 + : 0; + // Balance rewards a player who contributes at both ends rather than camping. + double positioning = 1 - Math.Abs(p.AverageFieldProgress - 0.5) * 2; + double discipline = 1 - Rank(PerMin(p.Turnovers), peers.Select(x => x.SecondsPlayed <= 0 ? 0 : x.Turnovers / (x.SecondsPlayed / 60.0))); + + r.AreaScores[InsightArea.Finishing] = finishing * 100; + r.AreaScores[InsightArea.Possession] = possession * 100; + r.AreaScores[InsightArea.Passing] = passing * 100; + r.AreaScores[InsightArea.Defence] = defence * 100; + r.AreaScores[InsightArea.Physicality] = physical * 100; + r.AreaScores[InsightArea.Movement] = movement * 100; + if (hasMechanics) r.AreaScores[InsightArea.Mechanics] = mechanics * 100; + r.AreaScores[InsightArea.Positioning] = Math.Clamp(positioning, 0, 1) * 100; + r.AreaScores[InsightArea.Discipline] = discipline * 100; + + r.OverallRating = r.AreaScores.Values.Average(); + } + + /// + /// The middle of this lobby for each stat a scouting read is built on. + /// + /// Absolute thresholds do not scout: in a lobby where everyone holds the disc four + /// seconds, "slow to release" is true of all of them and therefore tells you nothing. + /// A read has to say how this player differs from the others in the same game. + /// + private sealed class LobbyNorms + { + public double HoldSeconds { get; private init; } + public double StunsPerMin { get; private init; } + public double StunnedPerMin { get; private init; } + public double ShotDistance { get; private init; } + + public static LobbyNorms From(MatchAnalysis m) + { + var pool = m.AllPlayers.Where(x => x.SecondsPlayed > 30).ToList(); + return new LobbyNorms + { + HoldSeconds = Median(pool.Where(x => x.PossessionCount >= 3) + .Select(x => x.AveragePossessionSeconds)), + StunsPerMin = Median(pool.Select(x => x.StunsPerMinute)), + StunnedPerMin = Median(pool.Select(x => PerMinute(x.TimesStunned, x.SecondsPlayed))), + ShotDistance = Median(pool.Where(x => x.ShotPositions.Count >= 2) + .Select(x => AverageShotDistance(x))), + }; + } + } + + private static double PerMinute(double value, double seconds) => + seconds <= 0 ? 0 : value / (seconds / 60.0); + + private static double AverageShotDistance(PlayerAnalysis p) => + p.ShotPositions.Count == 0 + ? 0 + : p.ShotPositions.Average(v => Vector3.Distance(v, ArenaGeometry.AttackingGoal(p.Side))); + + private static double Median(IEnumerable values) + { + var list = values.Where(v => !double.IsNaN(v)).OrderBy(v => v).ToList(); + if (list.Count == 0) return 0; + return list.Count % 2 == 1 + ? list[list.Count / 2] + : (list[list.Count / 2 - 1] + list[list.Count / 2]) / 2; + } + + /// Percentile of within the peer set, 0..1. + private static double Rank(double value, IEnumerable peers) + { + var list = peers.ToList(); + if (list.Count == 0) return 0.5; + int below = list.Count(v => v < value); + int equal = list.Count(v => Math.Abs(v - value) < 1e-9); + return (below + equal * 0.5) / list.Count; + } + + // ------------------------------------------------------------------ self review + + private static void BuildSelfReview(PlayerReport r, MatchAnalysis m, PlayerAnalysis p, + List peers, List mates) + { + double minutes = Math.Max(p.SecondsPlayed / 60.0, 0.01); + var opponents = m.AllPlayers.Where(x => x.Side != p.Side && x.SecondsPlayed > 30).ToList(); + + // ---- finishing ---- + if (p.ShotsTaken >= 4) + { + double acc = p.ShotAccuracy; + double peerAcc = peers.Count > 0 ? peers.Where(x => x.ShotsTaken >= 3).Select(x => x.ShotAccuracy).DefaultIfEmpty(0).Average() : 0; + if (acc < 0.25 && acc < peerAcc * 0.7) + r.Insights.Add(new Insight + { + Kind = InsightKind.Weakness, Area = InsightArea.Finishing, + Title = "Shots are not converting", + Detail = $"You scored {p.Goals} from {p.ShotsTaken} shots ({acc:P0}). Everyone else in this match averaged {peerAcc:P0}.", + Action = "Take fewer contested shots from range. Carry to inside the three-line or pass to a team-mate with a clean lane before releasing.", + Evidence = $"{acc:P0} vs {peerAcc:P0} lobby average", + Confidence = 0.85, + }); + else if (acc > 0.5 && p.Goals >= 2) + r.Insights.Add(new Insight + { + Kind = InsightKind.Strength, Area = InsightArea.Finishing, + Title = "Clinical in front of goal", + Detail = $"{p.Goals} goals from {p.ShotsTaken} shots ({acc:P0}) — well above the {peerAcc:P0} lobby average.", + Evidence = $"{acc:P0} conversion", + Confidence = 0.8, + }); + } + else if (p.PossessionTime > 20 && p.ShotsTaken <= 1) + r.Insights.Add(new Insight + { + Kind = InsightKind.Improvement, Area = InsightArea.Finishing, + Title = "Holding the disc without threatening", + Detail = $"You held the disc for {p.PossessionTime:F0}s but took only {p.ShotsTaken} shot(s).", + Action = "When you win the disc in the neutral third, look at the goal first. A shot on target forces a save and a rebound; a slow carry lets the defence reset.", + Evidence = $"{p.PossessionTime:F0}s held, {p.ShotsTaken} shots", + Confidence = 0.7, + }); + + // ---- turnovers ---- + double toPerMin = p.Turnovers / minutes; + double peerTo = peers.Count > 0 ? peers.Average(x => x.SecondsPlayed <= 0 ? 0 : x.Turnovers / (x.SecondsPlayed / 60.0)) : 0; + if (p.Turnovers >= 3 && toPerMin > peerTo * 1.3) + { + var defensiveLosses = p.Events.Count(e => e.Kind == MatchEventKind.Turnover && + ArenaGeometry.ZoneFor(e.Position, p.Side) == ArenaZone.DefensiveThird); + r.Insights.Add(new Insight + { + Kind = InsightKind.Weakness, Area = InsightArea.Discipline, + Title = "Giving the disc away too often", + Detail = $"{p.Turnovers} turnovers ({toPerMin:F1}/min vs {peerTo:F1}/min for everyone else)" + + (defensiveLosses > 0 ? $", and {defensiveLosses} of them were in your own defensive third." : "."), + Action = defensiveLosses > 0 + ? "Stop trying to beat the first forechecker near your own goal. Clear it long or hand it to the goalie side team-mate." + : "Release earlier. Most losses come from holding one beat too long while a defender closes.", + Evidence = $"{p.Turnovers} turnovers", + Confidence = 0.85, + }); + } + + // ---- possession style ---- + if (p.PossessionCount >= 5) + { + double avgHold = p.AveragePossessionSeconds; + double peerHold = peers.Where(x => x.PossessionCount >= 3).Select(x => x.AveragePossessionSeconds).DefaultIfEmpty(0).Average(); + if (avgHold > peerHold * 1.6 && avgHold > 3) + r.Insights.Add(new Insight + { + Kind = InsightKind.Improvement, Area = InsightArea.Possession, + Title = "Holding the disc too long", + Detail = $"Your average possession lasts {avgHold:F1}s against {peerHold:F1}s for the rest of the lobby.", + Action = "Move it inside two seconds. Long carries let both defenders converge and turn a 3v2 into a 1v2.", + Evidence = $"{avgHold:F1}s average hold", + Confidence = 0.75, + }); + else if (avgHold < peerHold * 0.6 && p.PassCount > 4) + r.Insights.Add(new Insight + { + Kind = InsightKind.Strength, Area = InsightArea.Possession, + Title = "Quick release", + Detail = $"You move the disc on in {avgHold:F1}s on average — faster than the {peerHold:F1}s lobby average, which keeps the defence rotating.", + Evidence = $"{avgHold:F1}s average hold", + Confidence = 0.65, + }); + } + + // ---- passing ---- + if (mates.Count > 0) + { + // If nobody in the lobby moved the disc at all there is nothing to compare, and + // "0 passes against a lobby average of 0" is not a coaching point. + int passes = p.PassCount; + double peerPasses = peers.Select(x => (double)x.PassCount).DefaultIfEmpty(0).Average(); + + if (peerPasses > 0 && passes <= peerPasses * 0.5 && p.PossessionTime > 15) + r.Insights.Add(new Insight + { + Kind = InsightKind.Improvement, Area = InsightArea.Passing, + Title = "Playing too much alone", + Detail = $"{passes} passes against a lobby average of {peerPasses:F0}, despite {p.PossessionTime:F0}s of disc time.", + Action = "Use your team-mates as a release valve when the first look is covered — a pass out and back beats a contested carry.", + Evidence = $"{passes} passes", + Confidence = 0.7, + }); + else if (passes > peerPasses * 1.5 && passes >= 6) + r.Insights.Add(new Insight + { + Kind = InsightKind.Strength, Area = InsightArea.Passing, + Title = "Connects the team", + Detail = $"{passes} passes — well clear of the {peerPasses:F0} lobby average" + + (p.PassTargets.Count > 0 ? $", most often to {p.PassTargets.OrderByDescending(kv => kv.Value).First().Key}." : "."), + Evidence = $"{passes} passes", + Confidence = 0.7, + }); + } + + // ---- defence and shape ---- + if (p.OffensiveThirdShare > 0.55 && p.DefensiveThirdShare < 0.15) + { + int concededWhileUp = m.Goals.Count(g => g.Side != p.Side); + r.Insights.Add(new Insight + { + Kind = InsightKind.Weakness, Area = InsightArea.Positioning, + Title = "Living in the offensive third", + Detail = $"You spent {p.OffensiveThirdShare:P0} of live play in the attacking third and only {p.DefensiveThirdShare:P0} defending. Your team conceded {concededWhileUp} goal(s).", + Action = "Set a trigger to recover: the moment a team-mate loses the disc past midfield, turn and skate back rather than chasing the strip.", + Evidence = $"{p.OffensiveThirdShare:P0} time up front", + Confidence = 0.8, + }); + } + else if (p.DefensiveThirdShare > 0.6) + r.Insights.Add(new Insight + { + Kind = InsightKind.Improvement, Area = InsightArea.Positioning, + Title = "Anchored on your own goal", + Detail = $"{p.DefensiveThirdShare:P0} of your live time was in the defensive third and {p.SecondsInGoalieBox:F0}s inside the goalie box.", + Action = p.Saves > 3 + ? "The goaltending is working, but step out to the neutral third after a save so your team has an outlet instead of a 2v3." + : "Sitting deep without saves means you are removing yourself from play. Push to midfield and contest the disc.", + Evidence = $"{p.DefensiveThirdShare:P0} time back", + Confidence = 0.7, + }); + + if (p.Saves >= 3) + r.Insights.Add(new Insight + { + Kind = InsightKind.Strength, Area = InsightArea.Defence, + Title = "Reliable last line", + Detail = $"{p.Saves} saves and {p.Blocks} block(s) — you were repeatedly the reason the goal stayed clean.", + Evidence = $"{p.Saves} saves", + Confidence = 0.75, + }); + + // ---- physicality ---- + if (p.TimesStunned >= 6 && p.TimesStunned > p.Stuns * 1.6) + r.Insights.Add(new Insight + { + Kind = InsightKind.Weakness, Area = InsightArea.Physicality, + Title = "Losing the contact battle", + Detail = $"Stunned {p.TimesStunned} times while landing {p.Stuns}, costing roughly {p.SecondsStunned:F0}s frozen.", + Action = "Approach off-angle instead of straight at a defender, and keep your arms in to block rather than swinging first and missing.", + Evidence = $"{p.TimesStunned} stunned / {p.Stuns} landed", + Confidence = 0.8, + }); + else if (p.Stuns >= 5 && p.Stuns > p.TimesStunned * 1.4) + r.Insights.Add(new Insight + { + Kind = InsightKind.Strength, Area = InsightArea.Physicality, + Title = "Wins the contact battle", + Detail = $"{p.Stuns} stuns landed against {p.TimesStunned} taken — you controlled the physical exchanges.", + Evidence = $"{p.Stuns} stuns", + Confidence = 0.7, + }); + + // ---- throw mechanics ---- + if (p.HasThrowTelemetry && p.Throws.Count >= 5) + { + double avgPenalty = p.AverageThrowPenalty; + double wrist = p.Throws.Average(t => t.WristThrowPenalty); + double offAxis = p.Throws.Average(t => t.OffAxisSpinDeg); + double speed = p.AverageThrowSpeed; + double peerSpeed = peers.Where(x => x.HasThrowTelemetry && x.Throws.Count >= 3).Select(x => x.AverageThrowSpeed).DefaultIfEmpty(0).Average(); + + if (wrist > 0.15) + r.Insights.Add(new Insight + { + Kind = InsightKind.Improvement, Area = InsightArea.Mechanics, + Title = "Wrist snap is costing you throw speed", + Detail = $"Your throws carry an average wrist penalty of {wrist:P0} over {p.Throws.Count} recorded throws.", + Action = "Release with the wrist aligned to the throw direction — flicking across the line of the throw is what the game penalises.", + Evidence = $"{wrist:P0} wrist penalty", + Confidence = 0.75, + }); + if (offAxis > 25) + r.Insights.Add(new Insight + { + Kind = InsightKind.Improvement, Area = InsightArea.Mechanics, + Title = "Disc is leaving the hand off-axis", + Detail = $"Average off-axis spin of {offAxis:F0}° means the disc wobbles and bleeds speed in flight.", + Action = "Square your shoulders to the target before release rather than throwing across your body.", + Evidence = $"{offAxis:F0}° off-axis", + Confidence = 0.7, + }); + if (peerSpeed > 0 && speed > peerSpeed * 1.15) + r.Insights.Add(new Insight + { + Kind = InsightKind.Strength, Area = InsightArea.Mechanics, + Title = "Heavy throw", + Detail = $"Average release of {speed:F1} m/s (peak {p.MaxThrowSpeed:F1}) against a {peerSpeed:F1} m/s lobby average.", + Evidence = $"{speed:F1} m/s average", + Confidence = 0.7, + }); + } + + if (r.Insights.Count == 0) + r.Insights.Add(new Insight + { + Kind = InsightKind.Note, Area = InsightArea.Possession, + Title = "Not enough live play to judge", + Detail = $"Only {p.SecondsPlayed:F0}s of live time was recorded for this player, which is too little to draw conclusions from.", + Confidence = 0.3, + }); + } + + // ------------------------------------------------------------------ scouting + + private static void BuildCounterPlan(PlayerReport r, MatchAnalysis m, PlayerAnalysis p, List peers) + { + double minutes = Math.Max(p.SecondsPlayed / 60.0, 0.01); + var norms = LobbyNorms.From(m); + + // Where do they shoot from? Side bias is the single most actionable read. + if (p.ShotPositions.Count >= 3) + { + double avgX = p.ShotPositions.Average(v => v.X); + int left = p.ShotPositions.Count(v => v.X < -2); + int right = p.ShotPositions.Count(v => v.X > 2); + int total = p.ShotPositions.Count; + if (left >= total * 0.6 || right >= total * 0.6) + { + string sideName = left > right ? "left" : "right"; + int count = Math.Max(left, right); + r.Insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Finishing, + Title = $"Shoots from the {sideName}", + Detail = $"{count} of {total} shots came from the {sideName} side of the arena (mean X {avgX:F1}m).", + Action = $"Shade your goalie to the {sideName} post and force them to cut back across the middle before releasing.", + Evidence = $"{count}/{total} shots {sideName}", + Confidence = 0.8, + }); + } + + double avgDist = AverageShotDistance(p); + if (avgDist > 35 && (norms.ShotDistance <= 0 || avgDist >= norms.ShotDistance * 1.2)) + r.Insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Finishing, + Title = "Settles for long shots", + Detail = $"Their average shot is taken {avgDist:F0}m from goal, against " + + $"{norms.ShotDistance:F0}m for the rest of this lobby.", + Action = "Do not over-commit to pressuring them at range — sag into the lane and let the goalie read the long throw.", + Evidence = $"{avgDist:F0}m average shot distance", + Confidence = 0.75, + }); + else if (avgDist < 18 && (norms.ShotDistance <= 0 || avgDist <= norms.ShotDistance * 0.8)) + r.Insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Finishing, + Title = "Drives all the way in", + Detail = $"Their average shot is from just {avgDist:F0}m against {norms.ShotDistance:F0}m " + + "for this lobby — they want to get inside before releasing.", + Action = "Meet them early at the three-line with a body. If they get past the last block they will score.", + Evidence = $"{avgDist:F0}m average shot distance", + Confidence = 0.8, + }); + } + + // Hold time tells you whether to pressure immediately or contain. + if (p.PossessionCount >= 4) + { + double hold = p.AveragePossessionSeconds; + if (hold > 3.5 && (norms.HoldSeconds <= 0 || hold >= norms.HoldSeconds * 1.25)) + r.Insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Possession, + Title = "Slow to release", + Detail = $"They hold the disc {hold:F1}s per possession across {p.PossessionCount} possessions, " + + $"against {norms.HoldSeconds:F1}s for the rest of this lobby.", + Action = "Double them the moment they catch. They will not move it quickly enough to punish the second defender leaving.", + Evidence = $"{hold:F1}s per possession", + Confidence = 0.8, + }); + else if (hold < 1.5 && (norms.HoldSeconds <= 0 || hold <= norms.HoldSeconds * 0.75)) + r.Insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Passing, + Title = "One-touch player", + Detail = $"They release in {hold:F1}s on average — the danger is the pass, not the carry.", + Action = "Do not chase the catcher. Cover the passing lane to their most-used target instead.", + Evidence = $"{hold:F1}s per possession", + Confidence = 0.75, + }); + } + + if (p.PassTargets.Count > 0) + { + var fav = p.PassTargets.OrderByDescending(kv => kv.Value).First(); + int totalPasses = p.PassTargets.Values.Sum(); + if (totalPasses >= 4 && fav.Value >= totalPasses * 0.55) + r.Insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Passing, + Title = $"Predictable outlet to {fav.Key}", + Detail = $"{fav.Value} of their {totalPasses} handovers went to {fav.Key}.", + Action = $"Sit in the lane between them and {fav.Key}. Taking that option away forces a decision they clearly do not want to make.", + Evidence = $"{fav.Value}/{totalPasses} to {fav.Key}", + Confidence = 0.8, + }); + } + + // Positional read: are they exploitable on the counter? + if (p.OffensiveThirdShare > 0.5 && p.DefensiveThirdShare < 0.2) + r.Insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Positioning, + Title = "Never recovers", + Detail = $"They spend {p.OffensiveThirdShare:P0} of live play in their attacking third and only {p.DefensiveThirdShare:P0} back.", + Action = "Break out fast the instant they commit. The space behind them is the whole game — one long outlet puts you 2v1.", + Evidence = $"{p.OffensiveThirdShare:P0} up front", + Confidence = 0.85, + }); + else if (p.SecondsInGoalieBox > p.SecondsPlayed * 0.45) + r.Insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Defence, + Title = "Dedicated goalie", + Detail = $"They sat in the goalie box for {p.SecondsInGoalieBox:F0}s ({p.SecondsInGoalieBox / Math.Max(p.SecondsPlayed, 1):P0} of their live time) and made {p.Saves} saves.", + Action = "Do not shoot into them from range. Draw them off the line with a drive, then feed the trailer for a tap in.", + Evidence = $"{p.Saves} saves, {p.SecondsInGoalieBox:F0}s in box", + Confidence = 0.8, + }); + + // Physical threat. + double stunRate = p.Stuns / minutes; + if (stunRate > 1.2 && p.Stuns >= 4 && (norms.StunsPerMin <= 0 || stunRate >= norms.StunsPerMin * 1.35)) + r.Insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Physicality, + Title = "Aggressive on contact", + Detail = $"{p.Stuns} stuns in {minutes:F1} minutes ({stunRate:F1}/min against " + + $"{norms.StunsPerMin:F1}/min for this lobby) — they hunt bodies, not just the disc.", + Action = "Keep your hands up when entering their range and never approach in a straight line. Bait the swing, then go past them.", + Evidence = $"{p.Stuns / minutes:F1} stuns/min", + Confidence = 0.8, + }); + + double stunnedRate = PerMinute(p.TimesStunned, p.SecondsPlayed); + if (p.TimesStunned >= 5 && (norms.StunnedPerMin <= 0 || stunnedRate >= norms.StunnedPerMin * 1.35)) + r.Insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Physicality, + Title = "Vulnerable to contact", + Detail = $"They were stunned {p.TimesStunned} times ({stunnedRate:F1}/min against " + + $"{norms.StunnedPerMin:F1}/min for this lobby), losing about {p.SecondsStunned:F0}s.", + Action = "Lead with a stun when they carry. Taking them out of the play once is usually enough to spring a counter.", + Evidence = $"{p.TimesStunned} times stunned", + Confidence = 0.7, + }); + + // Mechanical read on their throw. + if (p.HasThrowTelemetry && p.Throws.Count >= 5) + { + double speed = p.AverageThrowSpeed; + double peerSpeed = peers.Where(x => x.HasThrowTelemetry && x.Throws.Count >= 3).Select(x => x.AverageThrowSpeed).DefaultIfEmpty(0).Average(); + if (peerSpeed > 0 && speed < peerSpeed * 0.85) + r.Insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Mechanics, + Title = "Weak release", + Detail = $"Average throw speed {speed:F1} m/s against {peerSpeed:F1} m/s for the rest of the lobby.", + Action = "Their long shots hang. Play the goalie a step off the line and read the flight rather than committing early.", + Evidence = $"{speed:F1} m/s vs {peerSpeed:F1} m/s", + Confidence = 0.75, + }); + else if (peerSpeed > 0 && speed > peerSpeed * 1.15) + r.Insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Mechanics, + Title = "Heavy shot", + Detail = $"Average {speed:F1} m/s, peaking at {p.MaxThrowSpeed:F1} m/s.", + Action = "Deny the wind-up instead of trying to react to it. Body them before they set their feet.", + Evidence = $"peak {p.MaxThrowSpeed:F1} m/s", + Confidence = 0.75, + }); + } + + // Only one player on a side can be the one to key on. In a lobby where everybody + // chipped in, saying "take this player out first" about each of them in turn is + // worse than saying nothing, so this needs a real margin over their own team-mates. + var sameTeam = m.AllPlayers + .Where(x => x.Side == p.Side && x.UserId != p.UserId && x.SecondsPlayed > 30) + .ToList(); + double bestMateRate = sameTeam.Count == 0 ? 0 : sameTeam.Max(x => x.PointsPerMinute); + bool leadsTeam = sameTeam.Count == 0 || p.PointsPerMinute > bestMateRate; + bool clearMargin = bestMateRate <= 0 || p.PointsPerMinute >= bestMateRate * 1.4; + + if (p.GoalPoints >= 4 && leadsTeam && clearMargin) + r.Insights.Add(new Insight + { + Kind = InsightKind.Note, Area = InsightArea.Finishing, + Title = "Primary scoring threat", + Detail = $"{p.GoalPoints} points ({p.Goals}G {p.Assists}A) at {p.PointsPerMinute:F1}/min — " + + (sameTeam.Count == 0 + ? $"over {minutes:F1} minutes of live play." + : $"clear of the next-best on their team at {bestMateRate:F1}/min."), + Action = "This is the player to take out of the game first. Assign your best defender and accept giving space elsewhere.", + Evidence = $"{p.PointsPerMinute:F1} pts/min", + Confidence = 0.9, + }); + else if (p.GoalPoints >= 3 && sameTeam.Count > 0 && leadsTeam) + r.Insights.Add(new Insight + { + Kind = InsightKind.Note, Area = InsightArea.Finishing, + Title = "Scores, but is not the one to key on", + Detail = $"{p.GoalPoints} points at {p.PointsPerMinute:F1}/min against {bestMateRate:F1}/min for their " + + "best team-mate — the threat is spread across the team.", + Action = "Do not build the defence around this one player. Hold your shape and cover the lanes rather than following them.", + Evidence = $"{p.PointsPerMinute:F1} pts/min", + Confidence = 0.6, + }); + + if (r.Insights.Count == 0) + r.Insights.Add(new Insight + { + Kind = InsightKind.Note, Area = InsightArea.Possession, + Title = "Too little footage to scout", + Detail = $"Only {p.SecondsPlayed:F0}s of live play recorded — not enough to establish a tendency.", + Confidence = 0.3, + }); + } + + private static string BuildSummary(MatchAnalysis m, PlayerAnalysis p, PlayerReport r, bool asOpponent) + { + string best = r.AreaScores.Count == 0 ? "—" : r.AreaScores.OrderByDescending(kv => kv.Value).First().Key.ToString(); + string worst = r.AreaScores.Count == 0 ? "—" : r.AreaScores.OrderBy(kv => kv.Value).First().Key.ToString(); + double mins = p.SecondsPlayed / 60.0; + + if (asOpponent) + return $"{p.Name} played {mins:F1} min for {(p.Side == TeamSide.Blue ? "BLUE" : "ORANGE")}, " + + $"scoring {p.GoalPoints} points on {p.ShotsTaken} shots with {p.Saves} saves and {p.Stuns} stuns. " + + $"Strongest in {best.ToLowerInvariant()}, weakest in {worst.ToLowerInvariant()} — attack that."; + + return $"{p.Name}: {p.GoalPoints} points ({p.Goals}G {p.Assists}A), {p.Saves} saves, {p.Stuns} stuns, " + + $"{p.PossessionTime:F0}s on disc and {p.Turnovers} turnovers across {mins:F1} min of live play. " + + $"Best area is {best.ToLowerInvariant()}; the clearest gain is in {worst.ToLowerInvariant()}."; + } + + // ------------------------------------------------------------------ team level + + public static List BuildTeamInsights(MatchAnalysis m, TeamSide side) + { + var team = side == TeamSide.Blue ? m.Blue : m.Orange; + var other = side == TeamSide.Blue ? m.Orange : m.Blue; + var list = new List(); + + if (team.PossessionShare > 0.6) + list.Add(new Insight + { + Kind = InsightKind.Strength, Area = InsightArea.Possession, + Title = "Controlled the disc", + Detail = $"{team.Name} held possession {team.PossessionShare:P0} of the time.", + Evidence = $"{team.PossessionShare:P0} possession", Confidence = 0.8, + }); + else if (team.PossessionShare < 0.4) + list.Add(new Insight + { + Kind = InsightKind.Weakness, Area = InsightArea.Possession, + Title = "Chased the disc all match", + Detail = $"Only {team.PossessionShare:P0} possession — the disc lived with {other.Name}.", + Action = "Win the neutral more often: send one player to contest the reset and a second to cover the outlet rather than both chasing the carrier.", + Evidence = $"{team.PossessionShare:P0} possession", Confidence = 0.8, + }); + + double noneBack = team.DefendersBackShare[0]; + if (noneBack > 0.35) + list.Add(new Insight + { + Kind = InsightKind.Weakness, Area = InsightArea.TeamShape, + Title = "Goal left unguarded", + Detail = $"For {noneBack:P0} of live play {team.Name} had nobody in their defensive third.", + Action = "Rotate so at least one player is always below the disc. An all-in press is only worth it when you actually have possession.", + Evidence = $"{noneBack:P0} with nobody back", Confidence = 0.85, + }); + + double allBack = team.DefendersBackShare[3]; + if (allBack > 0.3) + list.Add(new Insight + { + Kind = InsightKind.Improvement, Area = InsightArea.TeamShape, + Title = "Over-committed to defence", + Detail = $"All three players were in the defensive third for {allBack:P0} of live play.", + Action = "Once the disc is cleared, push at least one player to midfield so you have an outlet instead of conceding possession again.", + Evidence = $"{allBack:P0} with everyone back", Confidence = 0.7, + }); + + if (team.ShotsTaken >= 5) + { + if (team.ShotAccuracy < 0.2) + list.Add(new Insight + { + Kind = InsightKind.Weakness, Area = InsightArea.Finishing, + Title = "Wasteful in front of goal", + Detail = $"{team.Goals} goals from {team.ShotsTaken} shots ({team.ShotAccuracy:P0}).", + Action = "Work one more pass before shooting. Shots from outside the three-line against a set goalie are close to free possession for the other team.", + Evidence = $"{team.ShotAccuracy:P0} conversion", Confidence = 0.8, + }); + else if (team.ShotAccuracy > 0.45) + list.Add(new Insight + { + Kind = InsightKind.Strength, Area = InsightArea.Finishing, + Title = "Efficient attack", + Detail = $"{team.Goals} goals from just {team.ShotsTaken} shots ({team.ShotAccuracy:P0}).", + Evidence = $"{team.ShotAccuracy:P0} conversion", Confidence = 0.75, + }); + } + + if (team.AverageSpread > 45) + list.Add(new Insight + { + Kind = InsightKind.Improvement, Area = InsightArea.TeamShape, + Title = "Team is stretched thin", + Detail = $"Average distance between team-mates was {team.AverageSpread:F0}m.", + Action = "Play closer together through the neutral third so a turnover has support instead of leaving isolated 1v2s.", + Evidence = $"{team.AverageSpread:F0}m spread", Confidence = 0.6, + }); + else if (team.AverageSpread < 18 && team.AverageSpread > 0) + list.Add(new Insight + { + Kind = InsightKind.Improvement, Area = InsightArea.TeamShape, + Title = "Team is bunched up", + Detail = $"Average distance between team-mates was only {team.AverageSpread:F0}m.", + Action = "Spread the width. Three players in the same lane means one defender covers all of you.", + Evidence = $"{team.AverageSpread:F0}m spread", Confidence = 0.6, + }); + + if (team.Turnovers > other.Turnovers * 1.5 && team.Turnovers >= 6) + list.Add(new Insight + { + Kind = InsightKind.Weakness, Area = InsightArea.Discipline, + Title = "Lost the turnover battle", + Detail = $"{team.Turnovers} turnovers against {other.Turnovers} for {other.Name}.", + Action = "Simplify under pressure — the cheap losses are what fed their counter-attacks.", + Evidence = $"{team.Turnovers} vs {other.Turnovers}", Confidence = 0.8, + }); + + list.Sort((a, b) => b.Confidence.CompareTo(a.Confidence)); + return list; + } +} diff --git a/ReplayAnalyser/Core/Analysis/MatchAnalyser.cs b/ReplayAnalyser/Core/Analysis/MatchAnalyser.cs new file mode 100644 index 0000000..ed9b98e --- /dev/null +++ b/ReplayAnalyser/Core/Analysis/MatchAnalyser.cs @@ -0,0 +1,948 @@ +// Ported from the Replay Analyser (EchoAnalyser.Core/Analysis/MatchAnalyser.cs). Logic is unchanged; only the +// namespace, explicit usings and nullable context differ, so fixes can be diffed against +// the original. +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Numerics; +using Spark.ReplayAnalyser.Model; +using Spark.ReplayAnalyser.Parsing; +using EchoVRAPI; + +namespace Spark.ReplayAnalyser.Analysis; + +/// +/// Turns a decoded replay into a . +/// +/// Everything is derived in a single forward pass over the frames. The scoreboard counters +/// in the API are cumulative, so events are recovered by watching those counters step up; +/// positional metrics integrate over frame deltas. +/// +public static class MatchAnalyser +{ + /// + /// How close a hand has to get to a loose disc before we call it a chance the player + /// could have taken. + /// + /// Chosen from the data rather than guessed. Catches happen at a median of 0.28 m, and + /// sweeping the threshold shows conversion falling from 100% at 0.45 m to 87% at 0.9 m and + /// 68% at 1.5 m. Tight thresholds record nothing missable; loose ones count discs nobody + /// could reach. Roughly an arm's length is where a miss starts to mean something. + /// + public static float ReachRange { get; set; } = 0.9f; + + /// Frames outside live play are ignored for positional and possession metrics. + private static bool IsLive(Frame f) => + f.game_status is "playing" or "sudden_death"; + + /// + /// Whether this player physically has the disc in a hand. + /// + /// Player.possession is not this: it marks who *owns* the disc, staying true after a + /// throw until somebody else takes it, so it reads as held for 97% of live play. The hands + /// tell the truth — a disc is actually gripped about a third of the time — and that is the + /// signal a carry time or a missed grab has to be built on. + /// + private static bool IsGripping(Player p) => + p.holding_left == "disc" || p.holding_right == "disc"; + + public static MatchAnalysis Analyse(ReplayReadResult read, CancellationToken ct = default) + { + var m = new MatchAnalysis + { + FilePath = read.SourcePath ?? "", + Format = read.Format.ToString(), + IsPartial = read.IsPartial, + FrameCount = read.Frames.Count, + ClientName = read.ClientName ?? "", + SessionId = read.SessionId ?? "", + }; + m.Diagnostics.AddRange(read.Diagnostics); + + var frames = read.Frames; + if (frames.Count == 0) + { + m.Diagnostics.Add("No frames could be decoded from this file."); + return m; + } + + var meta = frames.FirstOrDefault(f => !string.IsNullOrEmpty(f.map_name)) ?? frames[0]; + m.MapName = meta.map_name ?? ""; + m.MatchType = meta.match_type ?? ""; + m.PrivateMatch = meta.private_match; + m.StartTime = frames[0].recorded_time; + m.EndTime = frames[^1].recorded_time; + double span = (m.EndTime - m.StartTime).TotalSeconds; + m.SampleRateHz = span > 0 ? frames.Count / span : 0; + + var state = new AnalysisState(m); + + Frame? prev = null; + foreach (var f in frames) + { + ct.ThrowIfCancellationRequested(); + state.Step(f, prev); + prev = f; + } + state.Finish(frames[^1]); + return m; + } + + /// Mutable bookkeeping carried across the frame walk. + private sealed class AnalysisState + { + private readonly MatchAnalysis _m; + private readonly Dictionary _players = new(); + private readonly Dictionary _last = new(); + private readonly Dictionary _openPossession = new(); + + private DateTime _t0; + private bool _started; + private string _lastScoreKey = ""; + private string _lastThrowKey = ""; + private long _lastHolderId; + private double _liveSeconds; + private double _blueHoldSeconds, _orangeHoldSeconds; + private double _nextSampleAt; + private readonly TrackBuilder _discTrack = new(); + private readonly Dictionary _tracks = new(); + + // ---- grab / mistake tracking ---- + /// + /// Measured from real catches: the hand is within about 0.3 m when a player actually + /// takes the disc, and three quarters of catches happen inside 0.45 m. Anything closer + /// than this was genuinely takeable. + /// + private static float GrabRange => ReachRange; + /// Below this the chance is over — the hand has left the disc. + private static float GrabReleaseRange => ReachRange * 2f; + /// You cannot re-grab your own throw immediately, so ignore this window. + private const double SelfReleaseGrace = 0.75; + /// How long after a chance we still count a catch as having converted it. + private const double GrabResolveWindow = 0.8; + + private sealed class GrabChance + { + public TimeSpan Start; + public float Closest = float.MaxValue; + public Vector3 Position; + public Vector3 Disc; + public double InRangeSeconds; + } + + private readonly Dictionary _openChance = new(); + private readonly Dictionary _lastHeldAt = new(); + private readonly Dictionary _wasStunned = new(); + /// Chances waiting to see whether anyone converted them. + private readonly List<(long userId, GrabChance chance)> _pendingChances = new(); + /// Who physically has the disc right now, 0 when it is loose. + private long _grippingId; + private int _runningBlue, _runningOrange; + /// Points each side put on the board across the whole recording. + private int _scoredBlue, _scoredOrange; + private float _prevBoardBlue = -1, _prevBoardOrange = -1; + private int _resets; + /// Defenders each side had in their own third as of the previous frame. + private int _lastBlueBack, _lastOrangeBack; + /// When each player last registered a shot, so a shot is not read as a giveaway. + private readonly Dictionary _lastShotAt = new(); + /// The most recently closed possession for each player, so we can label how it ended. + private readonly Dictionary _lastClosed = new(); + /// How many throws we managed to attribute to each player. + private readonly Dictionary _throwCounts = new(); + private const double SampleInterval = 2.0; // seconds between time-series points + + public AnalysisState(MatchAnalysis m) => _m = m; + + public void Step(Frame f, Frame? prev) + { + if (!_started) { _t0 = f.recorded_time; _started = true; } + TimeSpan t = f.recorded_time - _t0; + double dt = prev == null ? 0 : (f.recorded_time - prev.recorded_time).TotalSeconds; + if (dt is < 0 or > 1.0) dt = 0; // clamp gaps from dropped frames + + bool live = IsLive(f); + if (live) _liveSeconds += dt; + if (dt > 0) + { + string status = string.IsNullOrEmpty(f.game_status) ? "unknown" : f.game_status; + _m.StatusSeconds[status] = _m.StatusSeconds.GetValueOrDefault(status) + dt; + } + + TrackScoreboard(f); + RecordScore(f, t); + RecordThrow(f, t); + AccumulateDiscTrack(f, t, live); + + bool anyHolding = (f.teams ?? new List()) + .Any(tm => (tm.players ?? new List()).Any(IsGripping)); + _grippingId = 0; + + var holders = new List(); + int blueBack = 0, orangeBack = 0; + var bluePositions = new List(); + var orangePositions = new List(); + + for (int ti = 0; ti < (f.teams?.Count ?? 0); ti++) + { + var team = f.teams![ti]; + var side = (TeamSide)ti; + if (side == TeamSide.Spectator) continue; + + foreach (var p in team.players ?? new List()) + { + var pa = GetPlayer(p, side); + var pos = Vec(p.head?.position) ?? Vec(p.body?.position) ?? Vector3.Zero; + + TrackOf(pa.UserId).Add(pos, t.TotalSeconds, live); + + if (live) + { + pa.SecondsPlayed += dt; + AccumulatePosition(pa, p, pos, dt, side); + if (side == TeamSide.Blue) bluePositions.Add(pos); else orangePositions.Add(pos); + if (ArenaGeometry.ZoneFor(pos, side) == ArenaZone.DefensiveThird) + { + if (side == TeamSide.Blue) blueBack++; else orangeBack++; + } + } + + DetectStatEvents(pa, p, t, f, pos, side); + DetectGrabErrors(pa, p, t, f, pos, live, anyHolding); + DetectCarryIntoStun(pa, p, t, pos); + TrackPossession(pa, p, t, pos, side, dt, live); + if (p.possession) holders.Add(pa); + if (IsGripping(p)) _grippingId = pa.UserId; + + _last[pa.UserId] = new PlayerSnapshot(p, pos); + } + } + + if (live && dt > 0) + { + MarkLastDefender(f, TeamSide.Blue, dt); + MarkLastDefender(f, TeamSide.Orange, dt); + _lastBlueBack = blueBack; + _lastOrangeBack = orangeBack; + } + + if (live) + { + if (holders.Count > 0) + { + var h = holders[0]; + if (h.Side == TeamSide.Blue) _blueHoldSeconds += dt; else _orangeHoldSeconds += dt; + _lastHolderId = h.UserId; + } + TrackTeamShape(bluePositions, orangePositions, blueBack, orangeBack, dt); + SampleSeries(f, t); + ResolveChances(t, _grippingId); + + } + } + + /// + /// The player furthest back on a side owns the goal for that instant. Tracking who + /// that is, and for how long, is what separates a nominated goalie from a team that + /// simply rotates whoever happens to be deepest. + /// + private void MarkLastDefender(Frame f, TeamSide side, double dt) + { + PlayerAnalysis? deepest = null; + float best = float.MaxValue; + + int ti = (int)side; + if (f.teams == null || ti >= f.teams.Count) return; + foreach (var p in f.teams[ti].players ?? new List()) + { + var pos = Vec(p.head?.position) ?? Vec(p.body?.position); + if (pos == null) continue; + float progress = ArenaGeometry.FieldProgress(pos.Value, side); + if (progress >= best) continue; + + long id = p.userid != 0 ? p.userid : HashName(p.name); + if (!_players.TryGetValue(id, out var pa)) continue; + best = progress; + deepest = pa; + } + + if (deepest != null) deepest.SecondsAsLastDefender += dt; + } + + private void AccumulatePosition(PlayerAnalysis pa, Player p, Vector3 pos, double dt, TeamSide side) + { + if (dt <= 0) return; + + if (_last.TryGetValue(pa.UserId, out var prevSnap)) + { + double d = Vector3.Distance(pos, prevSnap.Position); + if (d < 30) // ignore teleports on respawn + { + pa.DistanceTravelled += d; + double speed = d / dt; + if (speed > pa.MaxSpeed && speed < 60) pa.MaxSpeed = speed; + } + } + + var zone = ArenaGeometry.ZoneFor(pos, side); + switch (zone) + { + case ArenaZone.DefensiveThird: pa.DefensiveThirdShare += dt; break; + case ArenaZone.Neutral: pa.NeutralShare += dt; break; + default: pa.OffensiveThirdShare += dt; break; + } + pa.AverageFieldProgress += ArenaGeometry.FieldProgress(pos, side) * dt; + if (ArenaGeometry.InGoalieBox(pos, side)) pa.SecondsInGoalieBox += dt; + + pa.AveragePosition += pos * (float)dt; + + if (p.stunned) pa.SecondsStunned += dt; + if (p.ping > 0) pa.AveragePing = pa.AveragePing == 0 ? p.ping : (pa.AveragePing * 7 + p.ping) / 8; + } + + /// + /// Spot a hand that reached a loose disc and came away empty. + /// + /// A chance opens when a player who is not stunned gets a hand inside grab range of a + /// disc nobody is holding, and closes when the hand leaves. It only counts as a miss if + /// nobody took the disc: being beaten to a genuine fifty-fifty is a lost contest, not a + /// whiff, and the two deserve different names. Their own throw is excluded for a moment + /// afterwards, since the disc cannot be re-taken straight away. + /// + private void DetectGrabErrors(PlayerAnalysis pa, Player p, TimeSpan t, Frame f, + Vector3 pos, bool live, bool anyHolding) + { + var discPos = Vec(f.disc?.position); + if (IsGripping(p)) _lastHeldAt[pa.UserId] = t; + + if (!live || discPos == null) + { + _openChance.Remove(pa.UserId); + return; + } + + float reach = float.MaxValue; + foreach (var hand in new[] { p.lhand, p.rhand }) + { + var hp = Vec(hand?.position); + if (hp == null) continue; + reach = Math.Min(reach, Vector3.Distance(hp.Value, discPos.Value)); + } + if (reach == float.MaxValue) return; + + bool eligible = !anyHolding && !p.stunned && + (!_lastHeldAt.TryGetValue(pa.UserId, out var held) || + (t - held).TotalSeconds > SelfReleaseGrace); + + if (eligible && reach <= GrabRange) + { + if (!_openChance.TryGetValue(pa.UserId, out var chance)) + { + chance = new GrabChance { Start = t, Position = pos, Disc = discPos.Value }; + _openChance[pa.UserId] = chance; + pa.GrabChances++; + } + chance.Closest = Math.Min(chance.Closest, reach); + chance.InRangeSeconds += 1.0 / Math.Max(1, _m.SampleRateHz); + } + else if (_openChance.TryGetValue(pa.UserId, out var open) && + (reach > GrabReleaseRange || p.stunned || anyHolding)) + { + _openChance.Remove(pa.UserId); + // Too brief to have been a real opportunity — a hand passing through. + if (open.InRangeSeconds >= 0.10) + _pendingChances.Add((pa.UserId, open)); + } + } + + /// Being stunned out of possession is a carry that should have been released. + private void DetectCarryIntoStun(PlayerAnalysis pa, Player p, TimeSpan t, Vector3 pos) + { + bool was = _wasStunned.TryGetValue(pa.UserId, out var w) && w; + if (p.stunned && !was && + _lastHeldAt.TryGetValue(pa.UserId, out var held) && + (t - held).TotalSeconds <= 0.4) + { + Add(new Mistake + { + Kind = MistakeKind.CarriedIntoStun, + Time = t, Player = pa.Name, UserId = pa.UserId, Position = pos, + Detail = "stunned out of possession instead of moving the disc on", + }, pa); + } + _wasStunned[pa.UserId] = p.stunned; + } + + /// + /// Decide the pending chances now that we know who ended up with the disc. A chance is + /// only a miss once enough time has passed for a catch to have shown up. + /// + private void ResolveChances(TimeSpan t, long holderId) + { + for (int i = _pendingChances.Count - 1; i >= 0; i--) + { + var (userId, chance) = _pendingChances[i]; + double age = (t - chance.Start).TotalSeconds; + + // They took it after all. + if (holderId == userId && age <= GrabResolveWindow + 0.5) + { + _pendingChances.RemoveAt(i); + continue; + } + if (age < GrabResolveWindow) continue; + + _pendingChances.RemoveAt(i); + if (!_players.TryGetValue(userId, out var pa)) continue; + + bool takenByOther = holderId != 0 && holderId != userId; + Add(new Mistake + { + Kind = takenByOther ? MistakeKind.LostContest : MistakeKind.MissedGrab, + Time = chance.Start, + Player = pa.Name, + UserId = userId, + Position = chance.Position, + DiscPosition = chance.Disc, + Distance = chance.Closest, + Detail = takenByOther + ? $"hand {chance.Closest:F2}m from the disc and the other team took it" + : $"hand {chance.Closest:F2}m from a loose disc and did not take it", + }, pa); + } + } + + private void Add(Mistake mistake, PlayerAnalysis pa) + { + _m.Mistakes.Add(mistake); + pa.Mistakes.Add(mistake); + } + + private void AccumulateDiscTrack(Frame f, TimeSpan t, bool live) + { + var d = Vec(f.disc?.position); + _discTrack.Add(d ?? Vector3.Zero, t.TotalSeconds, live && d != null); + } + + /// + /// Collects a position stream into continuous strokes. + /// + /// Points are thinned as they arrive — a player drifting in a straight line does not + /// need a vertex every frame — and a stroke is cut whenever the position jumps or the + /// clock stops, so a respawn never draws a line across the whole arena. + /// + private sealed class TrackBuilder + { + private const double MinSeconds = 0.06; // ~16 Hz is plenty for a drawn path + private const double MinMetres = 0.25; + private const double BreakSeconds = 0.5; + private const double BreakMetres = 8; + + private readonly List> _segments = new(); + private List? _current; + private Vector3 _last; + private double _lastAt; + + public void Add(Vector3 p, double seconds, bool live) + { + if (!live) { _current = null; return; } + + if (_current != null && + (seconds - _lastAt > BreakSeconds || Vector3.Distance(p, _last) > BreakMetres)) + _current = null; + + if (_current == null) + { + _current = new List { p }; + _segments.Add(_current); + _last = p; + _lastAt = seconds; + return; + } + + if (seconds - _lastAt < MinSeconds && Vector3.Distance(p, _last) < MinMetres) return; + + _current.Add(p); + _last = p; + _lastAt = seconds; + } + + public void CopyTo(List> target) + { + foreach (var seg in _segments) + if (seg.Count > 1) target.Add(seg); + } + } + + private void TrackTeamShape(List blue, List orange, int blueBack, int orangeBack, double dt) + { + if (dt <= 0) return; + _m.Blue.DefendersBackShare[Math.Min(blueBack, 3)] += dt; + _m.Orange.DefendersBackShare[Math.Min(orangeBack, 3)] += dt; + _m.Blue.AverageSpread += MeanPairDistance(blue) * dt; + _m.Orange.AverageSpread += MeanPairDistance(orange) * dt; + } + + private static double MeanPairDistance(List pts) + { + if (pts.Count < 2) return 0; + double sum = 0; int n = 0; + for (int i = 0; i < pts.Count; i++) + for (int j = i + 1; j < pts.Count; j++) { sum += Vector3.Distance(pts[i], pts[j]); n++; } + return n == 0 ? 0 : sum / n; + } + + private void SampleSeries(Frame f, TimeSpan t) + { + if (t.TotalSeconds < _nextSampleAt) return; + _nextSampleAt = t.TotalSeconds + SampleInterval; + + double s = t.TotalSeconds; + _m.BlueScoreSeries.Add(new TimePoint(s, f.blue_points)); + _m.OrangeScoreSeries.Add(new TimePoint(s, f.orange_points)); + _m.ScoreDifferential.Add(new TimePoint(s, f.blue_points - f.orange_points)); + + double total = _blueHoldSeconds + _orangeHoldSeconds; + _m.PossessionSeries.Add(new TimePoint(s, total <= 0 ? 0.5 : _blueHoldSeconds / total)); + + var d = Vec(f.disc?.position); + if (d != null) + _m.DiscFieldPosition.Add(new TimePoint(s, d.Value.Z)); + } + + /// + /// Follow the raw scoreboard so the headline score survives a recording that spans + /// more than one round. + /// + /// A long capture often contains several games back to back. Reading the final frame + /// then reports whatever the *current* round happens to sit at — one file here ends + /// "2-0" after twelve goals — so the totals are accumulated from the board's upward + /// steps instead, and a step downwards is counted as a round boundary rather than + /// subtracted. + /// + private void TrackScoreboard(Frame f) + { + float blue = f.blue_points, orange = f.orange_points; + if (_prevBoardBlue < 0) + { + _prevBoardBlue = blue; + _prevBoardOrange = orange; + // Whatever is already on the board when recording starts was scored earlier. + _scoredBlue = (int)blue; + _scoredOrange = (int)orange; + return; + } + + if (blue < _prevBoardBlue || orange < _prevBoardOrange) _resets++; + else + { + _scoredBlue += (int)(blue - _prevBoardBlue); + _scoredOrange += (int)(orange - _prevBoardOrange); + } + _prevBoardBlue = blue; + _prevBoardOrange = orange; + } + + /// Goals are announced through last_score; the key detects a fresh one. + private void RecordScore(Frame f, TimeSpan t) + { + var ls = f.last_score; + if (ls == null) return; + string key = $"{ls.team}|{ls.person_scored}|{ls.disc_speed:F3}|{ls.distance_thrown:F3}|{ls.goal_type}|{ls.point_amount}"; + if (key == _lastScoreKey) return; + bool first = _lastScoreKey.Length == 0; + _lastScoreKey = key; + // The very first frame already carries the previous goal; only count it if the + // scoreboard is non-zero, which means play is genuinely underway. + if (first && f.blue_points == 0 && f.orange_points == 0) return; + + var side = ls.team?.StartsWith("blue", StringComparison.OrdinalIgnoreCase) == true + ? TeamSide.Blue : TeamSide.Orange; + + var record = new GoalRecord + { + Time = t, + Side = side, + Scorer = ls.person_scored ?? "", + Assist = string.IsNullOrWhiteSpace(ls.assist_scored) || ls.assist_scored == "[INVALID]" + ? null : ls.assist_scored, + Points = ls.point_amount, + GoalType = (ls.goal_type ?? "").Replace('_', ' '), + DiscSpeed = ls.disc_speed, + DistanceThrown = ls.distance_thrown, + DefendersBack = side == TeamSide.Blue ? _lastOrangeBack : _lastBlueBack, + BlueScore = side == TeamSide.Blue ? _runningBlue + ls.point_amount : _runningBlue, + OrangeScore = side == TeamSide.Orange ? _runningOrange + ls.point_amount : _runningOrange, + }; + if (side == TeamSide.Blue) _runningBlue += ls.point_amount; else _runningOrange += ls.point_amount; + _m.Goals.Add(record); + + var scorer = _m.FindPlayer(record.Scorer); + var ev = new MatchEvent + { + Kind = MatchEventKind.Goal, + Time = t, + GameClock = f.game_clock, + Player = record.Scorer, + UserId = scorer?.UserId ?? 0, + Side = side, + Position = scorer?.AveragePosition ?? Vector3.Zero, + DiscPosition = Vec(f.disc?.position) ?? Vector3.Zero, + Detail = record.GoalType, + Value = record.DiscSpeed, + SecondaryPlayer = record.Assist, + }; + _m.Events.Add(ev); + scorer?.Events.Add(ev); + } + + /// Throw mechanics arrive in last_throw, attributed to the last holder. + private void RecordThrow(Frame f, TimeSpan t) + { + var lt = f.last_throw; + if (lt == null || lt.total_speed <= 0) return; + string key = $"{lt.total_speed:F4}|{lt.arm_speed:F4}|{lt.off_axis_spin_deg:F4}|{lt.rot_per_sec:F4}"; + if (key == _lastThrowKey) return; + bool first = _lastThrowKey.Length == 0; + _lastThrowKey = key; + if (first) return; + + if (_lastHolderId == 0 || !_players.TryGetValue(_lastHolderId, out var pa)) return; + var pos = _last.TryGetValue(_lastHolderId, out var snap) ? snap.Position : Vector3.Zero; + + var rec = new ThrowRecord + { + Player = pa.Name, + Side = pa.Side, + Time = t, + TotalSpeed = lt.total_speed, + ArmSpeed = lt.arm_speed, + SpeedFromMovement = lt.speed_from_movement, + SpeedFromWrist = lt.speed_from_wrist, + OffAxisSpinDeg = lt.off_axis_spin_deg, + WristThrowPenalty = lt.wrist_throw_penalty, + OffAxisPenalty = lt.off_axis_penalty, + ThrowMovePenalty = lt.throw_move_penalty, + RotPerSec = lt.rot_per_sec, + Position = pos, + }; + pa.Throws.Add(rec); + _throwCounts[pa.UserId] = _throwCounts.GetValueOrDefault(pa.UserId) + 1; + } + + /// The API's per-player counters only ever increase, so a step up is an event. + private void DetectStatEvents(PlayerAnalysis pa, Player p, TimeSpan t, Frame f, Vector3 pos, TeamSide side) + { + var s = p.stats; + if (s == null) return; + + if (!_last.TryGetValue(pa.UserId, out var prevSnap)) + { + Sync(pa, s); + return; + } + var ps = prevSnap.Stats; + if (ps == null) { Sync(pa, s); return; } + + var disc = Vec(f.disc?.position) ?? Vector3.Zero; + + void Emit(MatchEventKind kind, int count, string? detail = null) + { + for (int i = 0; i < count; i++) + { + var ev = new MatchEvent + { + Kind = kind, Time = t, GameClock = f.game_clock, + Player = pa.Name, UserId = pa.UserId, Side = side, + Position = pos, DiscPosition = disc, Detail = detail, + }; + _m.Events.Add(ev); + pa.Events.Add(ev); + } + } + + int d; + if ((d = s.saves - ps.saves) > 0) Emit(MatchEventKind.Save, d); + if ((d = s.stuns - ps.stuns) > 0) Emit(MatchEventKind.Stun, d); + if ((d = s.steals - ps.steals) > 0) Emit(MatchEventKind.Steal, d); + if ((d = s.interceptions - ps.interceptions) > 0) Emit(MatchEventKind.Interception, d); + if ((d = s.blocks - ps.blocks) > 0) Emit(MatchEventKind.Block, d); + if ((d = s.passes - ps.passes) > 0) Emit(MatchEventKind.Pass, d); + if ((d = s.catches - ps.catches) > 0) Emit(MatchEventKind.Catch, d); + if ((d = s.assists - ps.assists) > 0) Emit(MatchEventKind.Assist, d); + if ((d = s.shots_taken - ps.shots_taken) > 0) + { + Emit(MatchEventKind.ShotTaken, d); + pa.ShotPositions.Add(pos); + _lastShotAt[pa.UserId] = t; + } + if ((d = s.goals - ps.goals) > 0) pa.GoalPositions.Add(pos); + + if (p.stunned && !prevSnap.Stunned) pa.TimesStunned++; + + Sync(pa, s); + } + + private static void Sync(PlayerAnalysis pa, Stats s) + { + pa.Points = Math.Max(pa.Points, s.points); + pa.Goals = Math.Max(pa.Goals, s.goals); + pa.Assists = Math.Max(pa.Assists, s.assists); + pa.Saves = Math.Max(pa.Saves, s.saves); + pa.Stuns = Math.Max(pa.Stuns, s.stuns); + pa.Passes = Math.Max(pa.Passes, s.passes); + pa.Catches = Math.Max(pa.Catches, s.catches); + pa.Steals = Math.Max(pa.Steals, s.steals); + pa.Blocks = Math.Max(pa.Blocks, s.blocks); + pa.Interceptions = Math.Max(pa.Interceptions, s.interceptions); + pa.ShotsTaken = Math.Max(pa.ShotsTaken, s.shots_taken); + pa.PossessionTime = Math.Max(pa.PossessionTime, s.possession_time); + } + + private void TrackPossession(PlayerAnalysis pa, Player p, TimeSpan t, Vector3 pos, + TeamSide side, double dt, bool live) + { + bool had = _openPossession.ContainsKey(pa.UserId); + // Carry time is time with the disc in hand. Team possession share, tracked + // separately, keeps using ownership, which is the usual meaning of the word. + bool holding = IsGripping(p); + + if (holding && !had) + { + var span = new PossessionSpan + { + Player = pa.Name, UserId = pa.UserId, Side = side, + Start = t, End = t, StartPosition = pos, EndPosition = pos, + }; + _openPossession[pa.UserId] = span; + pa.PossessionCount++; + + // A change of holder across teams while the disc was live is a turnover for + // whoever had it last -- unless they had just shot, in which case the opponent + // collecting the rebound is the normal course of play, not a giveaway. + if (_lastHolderId != 0 && _lastHolderId != pa.UserId && + _players.TryGetValue(_lastHolderId, out var previousHolder) && + previousHolder.Side != side && live) + { + bool justShot = _lastShotAt.TryGetValue(previousHolder.UserId, out var shotAt) + && (t - shotAt).TotalSeconds <= ShotGraceSeconds; + LabelPriorSpan(previousHolder.UserId, justShot ? "shot" : "turnover"); + + if (!justShot) + { + previousHolder.Turnovers++; + if (ArenaGeometry.ZoneFor(pos, previousHolder.Side) == ArenaZone.DefensiveThird) + Add(new Mistake + { + Kind = MistakeKind.TurnoverAtHome, + Time = t, Player = previousHolder.Name, UserId = previousHolder.UserId, + Position = pos, + Detail = $"lost the disc to {pa.Name} inside your own third", + }, previousHolder); + var ev = new MatchEvent + { + Kind = MatchEventKind.Turnover, Time = t, + Player = previousHolder.Name, UserId = previousHolder.UserId, + Side = previousHolder.Side, Position = pos, + SecondaryPlayer = pa.Name, + }; + _m.Events.Add(ev); + previousHolder.Events.Add(ev); + } + } + else if (_lastHolderId != 0 && _lastHolderId != pa.UserId && + _players.TryGetValue(_lastHolderId, out var mate) && mate.Side == side) + { + // Same-team handover: credit the passer. + mate.PassTargets[pa.Name] = mate.PassTargets.GetValueOrDefault(pa.Name) + 1; + LabelPriorSpan(mate.UserId, "pass"); + } + } + else if (!holding && had) + { + var span = _openPossession[pa.UserId]; + span.End = t; + span.EndPosition = pos; + _openPossession.Remove(pa.UserId); + if (span.Seconds > 0.05) + { + pa.Possessions.Add(span); + _m.Possessions.Add(span); + _lastClosed[pa.UserId] = span; + } + } + } + + /// A shot taken this recently means the loss of the disc was the shot, not a giveaway. + private const double ShotGraceSeconds = 2.0; + + /// + /// Label how a player's last possession ended. The span is usually already closed by + /// the time the next player picks the disc up, so check the closed one first. + /// + private void LabelPriorSpan(long userId, string outcome) + { + if (_openPossession.TryGetValue(userId, out var open)) open.Outcome = outcome; + else if (_lastClosed.TryGetValue(userId, out var closed)) closed.Outcome = outcome; + } + + private TrackBuilder TrackOf(long userId) + { + if (!_tracks.TryGetValue(userId, out var b)) _tracks[userId] = b = new TrackBuilder(); + return b; + } + + private PlayerAnalysis GetPlayer(Player p, TeamSide side) + { + long id = p.userid != 0 ? p.userid : HashName(p.name); + if (!_players.TryGetValue(id, out var pa)) + { + pa = new PlayerAnalysis + { + Name = p.name ?? "unknown", + UserId = id, + Side = side, + Level = p.level, + Number = p.number, + }; + _players[id] = pa; + (side == TeamSide.Blue ? _m.Blue : _m.Orange).Players.Add(pa); + } + // A player can switch teams mid-session; keep the most recent side. + if (pa.Side != side) + { + var from = pa.Side == TeamSide.Blue ? _m.Blue : _m.Orange; + var to = side == TeamSide.Blue ? _m.Blue : _m.Orange; + from.Players.Remove(pa); + if (!to.Players.Contains(pa)) to.Players.Add(pa); + pa.Side = side; + } + return pa; + } + + private static long HashName(string? name) => + string.IsNullOrEmpty(name) ? 0 : -Math.Abs((long)name.GetHashCode()); + + public void Finish(Frame last) + { + // Totals across the whole recording, so a multi-round capture still adds up. + _m.BlueScore = _scoredBlue; + _m.OrangeScore = _scoredOrange; + _m.RoundsRecorded = _resets + 1; + _m.Blue.Score = _m.BlueScore; + _m.Orange.Score = _m.OrangeScore; + + foreach (var span in _openPossession.Values) + { + span.Outcome = "end of match"; + if (span.Seconds > 0.05) + { + _m.Possessions.Add(span); + var owner = _m.AllPlayers.FirstOrDefault(p => p.UserId == span.UserId); + owner?.Possessions.Add(span); + } + } + + _m.LiveSeconds = _liveSeconds; + + // Echo VR only records last_throw for the client that captured the replay, so the + // player we attributed the most throws to is that client. Only they get real throw + // mechanics; for everyone else the data is absent rather than poor. + if (_throwCounts.Count > 0) + { + var best = _throwCounts.OrderByDescending(kv => kv.Value).First(); + if (_players.TryGetValue(best.Key, out var recorder)) + { + _m.RecordingPlayer = recorder.Name; + recorder.HasThrowTelemetry = true; + } + } + + CreditGoalsFromScoreboard(); + + _discTrack.CopyTo(_m.DiscTracks); + foreach (var p in _m.AllPlayers) + if (_tracks.TryGetValue(p.UserId, out var b)) b.CopyTo(p.Tracks); + + double totalHold = _blueHoldSeconds + _orangeHoldSeconds; + _m.Blue.PossessionSeconds = (float)_blueHoldSeconds; + _m.Orange.PossessionSeconds = (float)_orangeHoldSeconds; + _m.Blue.PossessionShare = totalHold <= 0 ? 0 : _blueHoldSeconds / totalHold; + _m.Orange.PossessionShare = totalHold <= 0 ? 0 : _orangeHoldSeconds / totalHold; + + foreach (var team in new[] { _m.Blue, _m.Orange }) + { + if (_liveSeconds > 0) + { + team.AverageSpread /= _liveSeconds; + for (int i = 0; i < team.DefendersBackShare.Length; i++) + team.DefendersBackShare[i] /= _liveSeconds; + } + foreach (var p in team.Players) FinishPlayer(p, totalHold); + } + } + + /// + /// Private matches leave every player's goals and passes counters at + /// zero, even though the scoreboard moves. The game still announces who scored + /// through last_score, so count those and take whichever source says more. + /// + private void CreditGoalsFromScoreboard() + { + var goals = new Dictionary(StringComparer.OrdinalIgnoreCase); + var assists = new Dictionary(StringComparer.OrdinalIgnoreCase); + var points = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var g in _m.Goals) + { + if (!string.IsNullOrWhiteSpace(g.Scorer)) + { + goals[g.Scorer] = goals.GetValueOrDefault(g.Scorer) + 1; + points[g.Scorer] = points.GetValueOrDefault(g.Scorer) + g.Points; + } + if (!string.IsNullOrWhiteSpace(g.Assist)) + assists[g.Assist!] = assists.GetValueOrDefault(g.Assist!) + 1; + } + + foreach (var p in _m.AllPlayers) + { + if (goals.TryGetValue(p.Name, out int g)) p.Goals = Math.Max(p.Goals, g); + if (assists.TryGetValue(p.Name, out int a)) p.Assists = Math.Max(p.Assists, a); + p.GoalPoints = points.GetValueOrDefault(p.Name); + } + } + + private static void FinishPlayer(PlayerAnalysis p, double totalHold) + { + double t = p.SecondsPlayed; + if (t > 0) + { + p.AverageSpeed = p.DistanceTravelled / t; + p.AveragePosition /= (float)t; + p.AverageFieldProgress /= t; + double zoneTotal = p.DefensiveThirdShare + p.NeutralShare + p.OffensiveThirdShare; + if (zoneTotal > 0) + { + p.DefensiveThirdShare /= zoneTotal; + p.NeutralShare /= zoneTotal; + p.OffensiveThirdShare /= zoneTotal; + } + } + p.PossessionShare = totalHold <= 0 ? 0 : p.PossessionTime / totalHold; + } + + private static Vector3? Vec(List? l) => + l is { Count: >= 3 } ? new Vector3(l[0], l[1], l[2]) : null; + + private readonly record struct PlayerSnapshot(Player P, Vector3 Position) + { + public Stats? Stats => P.stats; + public bool Stunned => P.stunned; + } + } +} diff --git a/ReplayAnalyser/Core/Analysis/ScoutingAnalyser.cs b/ReplayAnalyser/Core/Analysis/ScoutingAnalyser.cs new file mode 100644 index 0000000..beb060d --- /dev/null +++ b/ReplayAnalyser/Core/Analysis/ScoutingAnalyser.cs @@ -0,0 +1,549 @@ +// Ported from the Replay Analyser (EchoAnalyser.Core/Analysis/ScoutingAnalyser.cs). Logic is unchanged; only the +// namespace, explicit usings and nullable context differ, so fixes can be diffed against +// the original. +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Spark.ReplayAnalyser.Model; + +namespace Spark.ReplayAnalyser.Analysis; + +/// What one player's record looks like when the two of you are on opposite teams. +public sealed class HeadToHead +{ + public string Viewer { get; init; } = ""; + public string Opponent { get; init; } = ""; + /// Matches you have both appeared in. + public int Meetings { get; init; } + public int AsOpponents { get; init; } + public int AsTeammates { get; init; } + public int Wins { get; init; } + public int Losses { get; init; } + public int Draws { get; init; } + /// Their scoring rate in the matches they played against you. + public double TheirPointsPerMinute { get; init; } + /// Their scoring rate everywhere else, for comparison. + public double TheirBaselinePointsPerMinute { get; init; } + public string Summary { get; init; } = ""; + + public bool HasFaced => AsOpponents > 0; + public string Record => $"{Wins}W {Losses}L" + (Draws > 0 ? $" {Draws}D" : ""); +} + +/// A counter-plan on one player built from every indexed match they appear in. +public sealed class CareerScout +{ + public string Player { get; init; } = ""; + public int Matches { get; init; } + public double LiveMinutes { get; init; } + public DateTime FirstSeen { get; init; } + public DateTime LastSeen { get; init; } + + /// A short label for how they play: goalie, presser, rotator. + public string Style { get; set; } = ""; + /// The one-line brief that leads the report. + public string Summary { get; set; } = ""; + + public List Insights { get; } = new(); + public List Metrics { get; } = new(); + public HeadToHead? Versus { get; set; } + + public int GoalPoints { get; init; } + public int Goals { get; init; } + public int Assists { get; init; } + public int Saves { get; init; } + public int Stuns { get; init; } + + public CareerMetric? Metric(string name) => + Metrics.FirstOrDefault(m => m.Name == name); +} + +/// +/// Builds a scouting report on a player from their whole indexed history rather than one game. +/// +/// A single match tells you what somebody did once; a season tells you what they will do +/// again. Every read here is still relative — measured against the players they actually +/// share lobbies with — so scouting somebody from a scrim pool and somebody from public +/// matches both produce advice you can act on. +/// +public static class ScoutingAnalyser +{ + /// Ignore appearances too short to say anything about. + private const double MinSecondsPerMatch = 60; + + /// Below this many matches the report says so rather than pretending to be certain. + private const int ThinEvidence = 4; + + public static CareerScout? Build(LibraryIndex index, string target, string? viewer = null) + { + var rows = Rows(index, target); + if (rows.Count == 0) return null; + + double liveSeconds = rows.Sum(r => r.me.SecondsPlayed); + var scout = new CareerScout + { + Player = rows[^1].me.Name, + Matches = rows.Count, + LiveMinutes = liveSeconds / 60.0, + FirstSeen = rows[0].match.Recorded, + LastSeen = rows[^1].match.Recorded, + GoalPoints = rows.Sum(r => r.me.GoalPoints), + Goals = rows.Sum(r => r.me.Goals), + Assists = rows.Sum(r => r.me.Assists), + Saves = rows.Sum(r => r.me.Saves), + Stuns = rows.Sum(r => r.me.Stuns), + }; + + BuildMetrics(scout, rows); + scout.Style = DescribeStyle(scout); + if (!string.IsNullOrWhiteSpace(viewer) && + !string.Equals(viewer, target, StringComparison.OrdinalIgnoreCase)) + scout.Versus = BuildHeadToHead(index, target, viewer!); + + BuildCounters(scout, rows); + scout.Summary = Describe(scout); + return scout; + } + + private static List<(MatchSummary match, PlayerSummary me, List peers)> Rows( + LibraryIndex index, string player) + { + var rows = new List<(MatchSummary, PlayerSummary, List)>(); + foreach (var m in index.Entries.Values.Where(e => e.Ok).OrderBy(e => e.Recorded)) + { + var me = m.Players.FirstOrDefault(p => + string.Equals(p.Name, player, StringComparison.OrdinalIgnoreCase) && + p.SecondsPlayed >= MinSecondsPerMatch); + if (me == null) continue; + var peers = m.Players + .Where(p => p.SecondsPlayed >= MinSecondsPerMatch && p.Name != me.Name) + .ToList(); + rows.Add((m, me, peers)); + } + return rows; + } + + // ------------------------------------------------------------------ metrics + + private static void BuildMetrics( + CareerScout scout, + List<(MatchSummary match, PlayerSummary me, List peers)> rows) + { + int third = Math.Max(1, rows.Count / 3); + var early = rows.Take(third).ToList(); + var recent = rows.Skip(Math.Max(0, rows.Count - third)).ToList(); + + void Add(string name, string description, Func pick, + bool lowerIsBetter = false, string format = "0.00") + { + scout.Metrics.Add(new CareerMetric + { + Name = name, + Description = description, + LowerIsBetter = lowerIsBetter, + Format = format, + Value = WeightedMean(rows.Select(r => (pick(r.me), r.me.SecondsPlayed))), + PeerValue = WeightedMean(rows.SelectMany(r => r.peers.Select(p => (pick(p), p.SecondsPlayed)))), + EarlyValue = WeightedMean(early.Select(r => (pick(r.me), r.me.SecondsPlayed))), + RecentValue = WeightedMean(recent.Select(r => (pick(r.me), r.me.SecondsPlayed))), + }); + } + + Add("Scoring", "Points on the board per minute", p => p.PointsPerMinute); + Add("Finishing", "Share of their shots that go in", + p => p.ShotsTaken == 0 ? 0 : (double)p.Goals / p.ShotsTaken, format: "0%"); + Add("Playmaking", "Assists per minute", + p => PerMinute(p.Assists, p.SecondsPlayed)); + Add("Distribution", "Handovers to team-mates per minute", + p => PerMinute(Math.Max(p.Passes, p.Handovers), p.SecondsPlayed)); + Add("Saves", "Saves per minute", p => PerMinute(p.Saves, p.SecondsPlayed)); + Add("Physicality", "Stuns landed per minute", p => p.StunsPerMinute); + Add("Work rate", "Metres skated per minute", + p => PerMinute(p.DistanceTravelled, p.SecondsPlayed), format: "0"); + Add("Hands", "Share of reachable loose discs they take", p => p.GrabSuccessRate, format: "0%"); + Add("Disc security", "Turnovers per minute", p => p.TurnoversPerMinute, lowerIsBetter: true); + Add("Turnovers at home", "Giveaways inside their own third, per minute", + p => PerMinute(p.TurnoversAtHome, p.SecondsPlayed), lowerIsBetter: true); + Add("Carrying into contact", "Times stunned while on the disc, per minute", + p => PerMinute(p.CarriedIntoStun, p.SecondsPlayed), lowerIsBetter: true); + Add("Hold time", "Seconds they keep the disc each time they get it", + p => p.AveragePossessionSeconds, format: "0.0"); + Add("Press height", "How far up the arena they sit, 0 is their own goal", + p => p.AverageFieldProgress, format: "0%"); + Add("Goal cover", "Share of their time inside their own goalie box", + p => p.SecondsPlayed <= 0 ? 0 : p.SecondsInGoalieBox / p.SecondsPlayed, format: "0%"); + + // Throw mechanics only exist for whoever recorded the replay, so both sides of this + // comparison have to be restricted to players who actually have telemetry. Without + // that the peer figure collapses to zero and every recorder looks like a cannon. + var mine = rows.Where(r => r.me.HasThrowTelemetry && r.me.AverageThrowSpeed > 0).ToList(); + var theirs = rows.SelectMany(r => r.peers.Where(p => p.HasThrowTelemetry && p.AverageThrowSpeed > 0)) + .ToList(); + if (mine.Count > 0 && theirs.Count > 0) + scout.Metrics.Add(new CareerMetric + { + Name = "Throw speed", + Description = "Average release speed, among players whose throws were recorded", + Format = "0.0", + Value = WeightedMean(mine.Select(r => ((double)r.me.AverageThrowSpeed, r.me.SecondsPlayed))), + PeerValue = WeightedMean(theirs.Select(p => ((double)p.AverageThrowSpeed, p.SecondsPlayed))), + EarlyValue = WeightedMean(mine.Take(Math.Max(1, mine.Count / 3)) + .Select(r => ((double)r.me.AverageThrowSpeed, r.me.SecondsPlayed))), + RecentValue = WeightedMean(mine.Skip(Math.Max(0, mine.Count - Math.Max(1, mine.Count / 3))) + .Select(r => ((double)r.me.AverageThrowSpeed, r.me.SecondsPlayed))), + }); + } + + private static double PerMinute(double value, double seconds) => + seconds <= 0 ? 0 : value / (seconds / 60.0); + + private static double WeightedMean(IEnumerable<(double value, double weight)> items) + { + double sum = 0, weight = 0; + foreach (var (v, w) in items) + { + if (w <= 0 || double.IsNaN(v)) continue; + sum += v * w; + weight += w; + } + return weight <= 0 ? 0 : sum / weight; + } + + // ------------------------------------------------------------------ head to head + + private static HeadToHead BuildHeadToHead(LibraryIndex index, string target, string viewer) + { + int meetings = 0, asOpponents = 0, asTeammates = 0, wins = 0, losses = 0, draws = 0; + double theirPoints = 0, theirSeconds = 0; + double elsePoints = 0, elseSeconds = 0; + + foreach (var m in index.Entries.Values.Where(e => e.Ok)) + { + var them = m.Players.FirstOrDefault(p => + string.Equals(p.Name, target, StringComparison.OrdinalIgnoreCase) && + p.SecondsPlayed >= MinSecondsPerMatch); + if (them == null) continue; + + var you = m.Players.FirstOrDefault(p => + string.Equals(p.Name, viewer, StringComparison.OrdinalIgnoreCase) && + p.SecondsPlayed >= MinSecondsPerMatch); + + if (you == null) + { + elsePoints += them.GoalPoints; + elseSeconds += them.SecondsPlayed; + continue; + } + + meetings++; + if (you.Side == them.Side) { asTeammates++; continue; } + + asOpponents++; + theirPoints += them.GoalPoints; + theirSeconds += them.SecondsPlayed; + + // Sides are 0 = blue, 1 = orange. + int yourScore = you.Side == 0 ? m.BlueScore : m.OrangeScore; + int theirScore = you.Side == 0 ? m.OrangeScore : m.BlueScore; + if (yourScore > theirScore) wins++; + else if (yourScore < theirScore) losses++; + else draws++; + } + + double theirRate = theirSeconds <= 0 ? 0 : theirPoints / (theirSeconds / 60.0); + double baseRate = elseSeconds <= 0 ? 0 : elsePoints / (elseSeconds / 60.0); + + string summary; + if (asOpponents == 0) + summary = meetings == 0 + ? $"You have no indexed matches with {target}." + : $"You have only ever been on {target}'s team ({asTeammates} match(es)), never against them."; + else + { + summary = $"You have faced {target} {asOpponents} time(s): {wins}W {losses}L" + + (draws > 0 ? $" {draws}D" : "") + "."; + if (baseRate > 0 && theirRate > 0) + { + double delta = theirRate / baseRate - 1; + if (Math.Abs(delta) >= 0.2) + summary += delta > 0 + ? $" They score {delta:P0} faster against you than against everyone else — {theirRate:F2} vs {baseRate:F2} points per minute." + : $" They score {-delta:P0} slower against you than against everyone else — {theirRate:F2} vs {baseRate:F2} points per minute."; + } + if (asTeammates > 0) summary += $" You have also played {asTeammates} match(es) alongside them."; + } + + return new HeadToHead + { + Viewer = viewer, Opponent = target, + Meetings = meetings, AsOpponents = asOpponents, AsTeammates = asTeammates, + Wins = wins, Losses = losses, Draws = draws, + TheirPointsPerMinute = theirRate, + TheirBaselinePointsPerMinute = baseRate, + Summary = summary, + }; + } + + // ------------------------------------------------------------------ counters + + private static void BuildCounters( + CareerScout s, + List<(MatchSummary match, PlayerSummary me, List peers)> rows) + { + void Counter(InsightKind kind, InsightArea area, string title, string detail, + string? action, string? evidence, double confidence) => + s.Insights.Add(new Insight + { + Kind = kind, Area = area, Title = title, Detail = detail, + Action = action, Evidence = evidence, + Confidence = Math.Min(confidence, EvidenceCap(s.Matches)), + }); + + var scoring = s.Metric("Scoring")!; + var finishing = s.Metric("Finishing")!; + var hold = s.Metric("Hold time")!; + var press = s.Metric("Press height")!; + var goalCover = s.Metric("Goal cover")!; + var physical = s.Metric("Physicality")!; + var carrying = s.Metric("Carrying into contact")!; + var hands = s.Metric("Hands")!; + var security = s.Metric("Disc security")!; + var atHome = s.Metric("Turnovers at home")!; + var work = s.Metric("Work rate")!; + var distribution = s.Metric("Distribution")!; + var throwSpeed = s.Metric("Throw speed"); + + // ---- how much of a threat are they at all ---- + if (scoring.Edge >= 0.3) + Counter(InsightKind.Note, InsightArea.Finishing, + "Primary scoring threat", + $"{scoring.ValueText} points per minute against {scoring.PeerText} for the players around them, " + + $"across {s.Matches} matches. This has been {scoring.TrendText}.", + "Assign your best defender to them from the first whistle and accept giving up space elsewhere.", + $"{scoring.ValueText} vs {scoring.PeerText} pts/min", 0.9); + else if (scoring.Edge <= -0.3) + Counter(InsightKind.Note, InsightArea.Finishing, + "Not the one who beats you", + $"{scoring.ValueText} points per minute against {scoring.PeerText} for the room. " + + "They are not the player winning these games.", + "Do not spend your best defender here. Cover their team-mates and let this one take the shot.", + $"{scoring.ValueText} vs {scoring.PeerText} pts/min", 0.8); + + // ---- role ---- + double cover = goalCover.Value; + if (cover > 0.3 && goalCover.Edge > 0.15) + Counter(InsightKind.Counter, InsightArea.Defence, + "Plays the goal", + $"They spend {goalCover.ValueText} of their time in their own goalie box, against {goalCover.PeerText} for the room, " + + $"and average {s.Metric("Saves")!.ValueText} saves per minute.", + "Do not shoot into them from range — that is the shot they are set for. Drive to draw them off the line, then feed the trailer.", + $"{goalCover.ValueText} in the box", 0.85); + else if (press.Value > 0.55 && press.Edge > 0.1) + Counter(InsightKind.Counter, InsightArea.Positioning, + "Presses high and stays there", + $"They average {press.ValueText} up the arena against {press.PeerText} for the room — they commit forward and rarely recover.", + "Break out the instant they commit. The space behind them is the whole game: one long outlet turns it into a 2v1.", + $"{press.ValueText} press height", 0.85); + else if (press.Value < 0.4) + Counter(InsightKind.Counter, InsightArea.Positioning, + "Sits deep", + $"They average {press.ValueText} up the arena against {press.PeerText} for the room — they hang back rather than joining the attack.", + "Their half is where the game is won. Push numbers forward; they will not punish you on the counter.", + $"{press.ValueText} press height", 0.75); + + // ---- what to do the moment they touch it ---- + if (hold.Edge > 0.25 && hold.Value > 2.5) + Counter(InsightKind.Counter, InsightArea.Possession, + "Holds the disc", + $"They keep it {hold.ValueText}s per possession against {hold.PeerText}s for the room.", + "Double them the moment they catch. They will not move it fast enough to punish the second defender leaving.", + $"{hold.ValueText}s per possession", 0.85); + else if (hold.Edge < -0.25 && distribution.Edge > 0) + Counter(InsightKind.Counter, InsightArea.Passing, + "One-touch player", + $"They release in {hold.ValueText}s against {hold.PeerText}s for the room, and hand off " + + $"{distribution.ValueText} times a minute against {distribution.PeerText}.", + "Do not chase the catcher — you will never get there. Cover the passing lane and make them carry it instead.", + $"{hold.ValueText}s per possession", 0.8); + + // ---- physical exchanges ---- + if (physical.Edge >= 0.3) + Counter(InsightKind.Counter, InsightArea.Physicality, + "Hunts bodies", + $"{physical.ValueText} stuns per minute against {physical.PeerText} for the room, over {s.LiveMinutes:F0} minutes of play.", + "Never approach them in a straight line, and keep your arms up entering their range. Bait the swing, then go past.", + $"{physical.ValueText} stuns/min", 0.85); + + if (carrying.Edge <= -0.25) + Counter(InsightKind.Counter, InsightArea.Physicality, + "Carries into contact", + $"They get stunned on the disc {carrying.ValueText} times a minute against {carrying.PeerText} for the room.", + "Lead with the stun when they are carrying rather than reaching for the disc. They will skate into it.", + $"{carrying.ValueText} stunned-on-disc/min", 0.8); + + // ---- hands and giveaways: where the free possessions come from ---- + if (hands.Edge <= -0.15) + Counter(InsightKind.Counter, InsightArea.Possession, + "Unreliable hands", + $"They take {hands.ValueText} of the loose discs that come within reach; the room manages {hands.PeerText}.", + "Contest every fifty-fifty with them rather than backing off — a meaningful share of those end up yours.", + $"{hands.ValueText} of reachable discs", 0.8); + + if (atHome.Edge <= -0.25) + Counter(InsightKind.Counter, InsightArea.Discipline, + "Gives it away in their own third", + $"{atHome.ValueText} giveaways per minute inside their own third, against {atHome.PeerText} for the room.", + "Forecheck them. Put your first defender on them the moment they collect it behind their own line.", + $"{atHome.ValueText} home giveaways/min", 0.85); + else if (security.Edge <= -0.25) + Counter(InsightKind.Counter, InsightArea.Discipline, + "Loses the disc under pressure", + $"{security.ValueText} turnovers per minute against {security.PeerText} for the room.", + "Pressure rather than contain. They will cough it up if you close the first option.", + $"{security.ValueText} turnovers/min", 0.8); + + // ---- the shot itself ---- + if (finishing.Edge >= 0.25 && finishing.Value > 0) + Counter(InsightKind.Counter, InsightArea.Finishing, + "Finishes what they get", + $"{finishing.ValueText} of their shots go in, against {finishing.PeerText} for the room.", + "Deny the shot rather than the carry. Once they are lined up the goalie is already beaten more often than not.", + $"{finishing.ValueText} conversion", 0.8); + else if (finishing.Edge <= -0.25 && finishing.PeerValue > 0) + Counter(InsightKind.Counter, InsightArea.Finishing, + "Wasteful shooter", + $"Only {finishing.ValueText} of their shots go in, against {finishing.PeerText} for the room.", + "Let them shoot from range. Every attempt they take from distance is possession back for you.", + $"{finishing.ValueText} conversion", 0.75); + + if (throwSpeed is { Value: > 0, PeerValue: > 0 }) + { + if (throwSpeed.Edge >= 0.15) + Counter(InsightKind.Counter, InsightArea.Mechanics, + "Heavy release", + $"They release at {throwSpeed.ValueText} m/s against {throwSpeed.PeerText} m/s for the room.", + "Deny the wind-up instead of trying to react to the throw. Body them before they set.", + $"{throwSpeed.ValueText} m/s", 0.7); + else if (throwSpeed.Edge <= -0.15) + Counter(InsightKind.Counter, InsightArea.Mechanics, + "Light release", + $"They release at {throwSpeed.ValueText} m/s against {throwSpeed.PeerText} m/s for the room — their long throws hang.", + "Play the goalie a step off the line and read the flight. There is time to react to anything they throw from distance.", + $"{throwSpeed.ValueText} m/s", 0.7); + } + + if (work.Edge <= -0.25) + Counter(InsightKind.Counter, InsightArea.Movement, + "Low work rate", + $"{work.ValueText} metres per minute against {work.PeerText} for the room.", + "Move the disc side to side. They do not cover ground to recover, so lateral play pulls them out of the play entirely.", + $"{work.ValueText} m/min", 0.7); + + // ---- direction of travel: old tape can mislead ---- + var climbing = s.Metrics.Where(m => m.TrendEdge >= 0.3 && m.Name is "Scoring" or "Finishing" or "Physicality") + .OrderByDescending(m => m.TrendEdge).FirstOrDefault(); + if (climbing != null) + Counter(InsightKind.Note, InsightArea.Finishing, + $"{climbing.Name} is trending up", + $"Their early matches averaged {climbing.EarlyValue.ToString(climbing.Format)}; recent ones " + + $"{climbing.RecentValue.ToString(climbing.Format)}. They are better now than their overall record suggests.", + "Scout the recent games, not the average. Whatever worked against them months ago may not any more.", + $"{climbing.EarlyValue.ToString(climbing.Format)} → {climbing.RecentValue.ToString(climbing.Format)}", 0.7); + + // ---- your own record against them ---- + if (s.Versus is { HasFaced: true } h) + { + bool losing = h.Losses > h.Wins; + Counter(losing ? InsightKind.Weakness : InsightKind.Strength, InsightArea.Possession, + $"Your record against them: {h.Record}", + h.Summary, + losing + ? "They have your number so far. Change one specific thing from the reads above rather than playing the same game harder." + : null, + $"{h.AsOpponents} meetings", 0.85); + } + + if (s.Matches < ThinEvidence) + Counter(InsightKind.Note, InsightArea.Possession, + "Thin evidence", + $"Only {s.Matches} indexed match(es) with {s.Player} in them ({s.LiveMinutes:F0} minutes). " + + "Treat everything above as a starting read rather than a settled tendency.", + "Index more replays containing them to firm this up.", + $"{s.Matches} matches", 0.95); + + s.Insights.Sort((a, b) => b.Confidence.CompareTo(a.Confidence)); + } + + /// Caps how confident any read can be when it rests on very few matches. + private static double EvidenceCap(int matches) => matches switch + { + <= 1 => 0.45, + 2 => 0.6, + 3 => 0.7, + <= 6 => 0.8, + _ => 1.0, + }; + + private static string DescribeStyle(CareerScout s) + { + double cover = s.Metric("Goal cover")?.Value ?? 0; + double press = s.Metric("Press height")?.Value ?? 0.5; + double stuns = s.Metric("Physicality")?.Edge ?? 0; + double hold = s.Metric("Hold time")?.Edge ?? 0; + + string role = cover > 0.3 ? "goalie" + : press > 0.55 ? "presser" + : press < 0.4 ? "deep rotator" + : "rotator"; + + string temper = stuns >= 0.3 ? "physical " : ""; + string disc = hold > 0.25 ? ", carries the disc" : hold < -0.25 ? ", moves it early" : ""; + return $"{temper}{role}{disc}"; + } + + /// + /// Metrics that describe a style rather than a standard. How high somebody presses or how + /// long they hold the disc is a tendency to plan around, not something they are good or + /// bad at, so these never appear as a strength or a weakness. + /// + private static readonly HashSet Descriptive = + new() { "Hold time", "Press height", "Goal cover", "Work rate" }; + + /// Reads naturally in "their strength is …" and "attack their …". + private static string Phrase(string metric) => metric switch + { + "Scoring" => "scoring rate", + "Saves" => "goaltending", + "Disc security" => "disc security", + "Turnovers at home" => "handling of the disc in their own third", + "Carrying into contact" => "disc protection under contact", + "Hands" => "hands", + "Throw speed" => "throw power", + _ => metric.ToLowerInvariant(), + }; + + private static string Describe(CareerScout s) + { + string span = s.FirstSeen.Year > 2000 + ? $"{s.FirstSeen:d MMM} to {s.LastSeen:d MMM}" + : "your library"; + + var quality = s.Metrics.Where(m => !Descriptive.Contains(m.Name)).ToList(); + var worst = quality.Where(m => m.Edge <= -0.12).OrderBy(m => m.Edge).FirstOrDefault(); + var best = quality.Where(m => m.Edge >= 0.12).OrderByDescending(m => m.Edge).FirstOrDefault(); + + string line = $"{s.Player} across {s.Matches} indexed matches ({s.LiveMinutes:F0} minutes of live play, {span}): " + + $"{s.GoalPoints} points from {s.Goals} goals, {s.Assists} assists, {s.Saves} saves and {s.Stuns} stuns. " + + $"Plays as a {s.Style}."; + + if (best != null) line += $" Their strength is {Phrase(best.Name)}."; + if (worst != null) line += $" Attack their {Phrase(worst.Name)}."; + if (s.Versus is { HasFaced: true } h) line += $" You are {h.Record} against them."; + return line; + } +} diff --git a/ReplayAnalyser/Core/Analysis/TeamAnalyser.cs b/ReplayAnalyser/Core/Analysis/TeamAnalyser.cs new file mode 100644 index 0000000..8209cc6 --- /dev/null +++ b/ReplayAnalyser/Core/Analysis/TeamAnalyser.cs @@ -0,0 +1,482 @@ +// Ported from the Replay Analyser (EchoAnalyser.Core/Analysis/TeamAnalyser.cs). Logic is unchanged; only the +// namespace, explicit usings and nullable context differ, so fixes can be diffed against +// the original. +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Spark.ReplayAnalyser.Model; + +namespace Spark.ReplayAnalyser.Analysis; + +/// Where a player actually spent the match, regardless of what they were told to play. +public enum PlayerRole +{ + /// Sits on the goal. Rarely leaves the defensive third. + Goalie, + /// Plays behind the disc — the back half of the stack. + Back, + /// Covers the whole sheet, rotating between the two ends. + Rotator, + /// Lives in the attacking third — the front of the stack. + Front, +} + +public sealed class RoleAssignment +{ + public PlayerAnalysis Player { get; init; } = null!; + public PlayerRole Role { get; init; } + /// 0 = own goal, 1 = the goal they attack. + public double FieldProgress { get; init; } + public double GoalieShare { get; init; } + public double LastDefenderShare { get; init; } + /// Plain-language reason, so the label can be checked against the numbers. + public string Evidence { get; init; } = ""; + + public string RoleName => Role switch + { + PlayerRole.Goalie => "Goalie", + PlayerRole.Back => "Back stack", + PlayerRole.Front => "Front stack", + _ => "Rotator", + }; +} + +/// How one team played: who filled which slot, and the shape and habits that came out of it. +public sealed class TeamProfile +{ + public TeamSide Side { get; init; } + public string Name => Side == TeamSide.Blue ? "BLUE" : "ORANGE"; + public int Score { get; init; } + public List Roles { get; } = new(); + + public IEnumerable Front => Roles.Where(r => r.Role == PlayerRole.Front); + public IEnumerable Back => + Roles.Where(r => r.Role is PlayerRole.Back or PlayerRole.Goalie); + + /// Average field progress for the side — how high up the sheet they camp. + public double PressHeight { get; init; } + /// Share of live play with at least one player home. Higher is more disciplined. + public double RotationScore { get; init; } + /// Share of live play with nobody home at all. + public double OpenBackShare { get; init; } + public double Spacing { get; init; } + public double AverageHold { get; init; } + public double StunsPerMinute { get; init; } + public double ShotAccuracy { get; init; } + public double PossessionShare { get; init; } + + public string StackShape { get; set; } = ""; + /// True when the outfield all play at the same height — a rotation, not a stack. + public bool IsFlatRotation { get; set; } + public List StyleTags { get; } = new(); + public string Summary { get; set; } = ""; + + /// 0-100 overall, from the parts of the game that decide matches. + public double Rating { get; set; } +} + +/// +/// Team-level reading of a match: the shape each side played, and where the game was won. +/// +/// Everything is measured against the other team in the same match rather than against +/// absolutes, because "high press" only means anything relative to who you were playing. +/// +public static class TeamAnalyser +{ + /// Time on the goal, as a share of live play, that marks a dedicated goalie. + private const double GoalieShareThreshold = 0.30; + private const double LastDefenderThreshold = 0.45; + + public static TeamProfile BuildProfile(MatchAnalysis m, TeamSide side) + { + var team = side == TeamSide.Blue ? m.Blue : m.Orange; + var players = Participating(team.Players); + + var profile = new TeamProfile + { + Side = side, + Score = team.Score, + PressHeight = Weighted(players, p => p.AverageFieldProgress), + RotationScore = 1 - team.DefendersBackShare[0], + OpenBackShare = team.DefendersBackShare[0], + Spacing = team.AverageSpread, + AverageHold = players.Count == 0 ? 0 : Weighted(players, p => p.AveragePossessionSeconds), + StunsPerMinute = players.Sum(p => p.Stuns) / + Math.Max(1.0, players.Sum(p => p.SecondsPlayed) / 60.0), + ShotAccuracy = team.ShotAccuracy, + PossessionShare = team.PossessionShare, + StackShape = "", + }; + + foreach (var r in AssignRoles(players)) profile.Roles.Add(r); + profile.StackShape = Shape(profile); + FlattenRoles(profile); + return profile; + } + + /// + /// Once a side is judged a flat rotation, calling two of them "front stack" contradicts + /// the shape we just reported. Collapse the outfield labels so the summary and the + /// per-player rows tell the same story. + /// + private static void FlattenRoles(TeamProfile p) + { + if (!p.IsFlatRotation) return; + + for (int i = 0; i < p.Roles.Count; i++) + { + var r = p.Roles[i]; + if (r.Role == PlayerRole.Goalie) continue; + p.Roles[i] = new RoleAssignment + { + Player = r.Player, + Role = PlayerRole.Rotator, + FieldProgress = r.FieldProgress, + GoalieShare = r.GoalieShare, + LastDefenderShare = r.LastDefenderShare, + Evidence = $"split {r.Player.DefensiveThirdShare:P0} home / " + + $"{r.Player.OffensiveThirdShare:P0} away — the side rotated rather than stacked", + }; + } + } + + /// Build both profiles together so their style tags and ratings can be relative. + public static (TeamProfile blue, TeamProfile orange) BuildBoth(MatchAnalysis m) + { + var blue = BuildProfile(m, TeamSide.Blue); + var orange = BuildProfile(m, TeamSide.Orange); + Compare(m, blue, orange); + Compare(m, orange, blue); + return (blue, orange); + } + + /// + /// Drop anyone who was in the lobby but not playing. + /// + /// An idle player parked in their own goal reads as a textbook goalie — 90%+ in the box, + /// last man back almost always — and would hand the team a defensive shape it never had. + /// Distance covered separates them cleanly: a real goalie still moves, an idle one does + /// not, so anyone travelling a small fraction of what their team-mates did is excluded. + /// + private static List Participating(IEnumerable all) + { + var present = all.Where(p => p.SecondsPlayed > 20).ToList(); + if (present.Count < 2) return present; + + var distances = present.Select(p => p.DistanceTravelled).OrderBy(d => d).ToList(); + double median = distances[distances.Count / 2]; + if (median <= 0) return present; + + var active = present.Where(p => + p.DistanceTravelled >= median * 0.25 || + p.GoalPoints > 0 || p.Saves > 0 || p.Stuns > 0 || p.ShotsTaken > 0).ToList(); + + return active.Count >= 2 ? active : present; + } + + // ------------------------------------------------------------------ roles + + private static IEnumerable AssignRoles(List players) + { + if (players.Count == 0) yield break; + + // Rank by how far up the sheet each player lives; the labels then describe the + // team's own distribution rather than an absolute idea of "high" or "deep". + var ordered = players.OrderBy(p => p.AverageFieldProgress).ToList(); + + foreach (var p in ordered) + { + double t = Math.Max(p.SecondsPlayed, 1); + double goalieShare = p.SecondsInGoalieBox / t; + double lastDefShare = p.SecondsAsLastDefender / t; + double progress = p.AverageFieldProgress; + + PlayerRole role; + string evidence; + + if (goalieShare >= GoalieShareThreshold && lastDefShare >= LastDefenderThreshold) + { + role = PlayerRole.Goalie; + evidence = $"{goalieShare:P0} of live play inside the goalie box and last man back {lastDefShare:P0} of the time"; + } + else if (progress <= 0.44 || lastDefShare >= LastDefenderThreshold) + { + role = PlayerRole.Back; + evidence = $"average position {progress:P0} up the sheet, last man back {lastDefShare:P0} of the time"; + } + else if (progress >= 0.56 && p.OffensiveThirdShare > p.DefensiveThirdShare) + { + role = PlayerRole.Front; + evidence = $"{p.OffensiveThirdShare:P0} of live play in the attacking third against {p.DefensiveThirdShare:P0} at home"; + } + else + { + role = PlayerRole.Rotator; + evidence = $"split {p.DefensiveThirdShare:P0} home / {p.OffensiveThirdShare:P0} away — covers the whole sheet"; + } + + yield return new RoleAssignment + { + Player = p, + Role = role, + FieldProgress = progress, + GoalieShare = goalieShare, + LastDefenderShare = lastDefShare, + Evidence = evidence, + }; + } + } + + private static string Shape(TeamProfile p) + { + if (p.Roles.Count == 0) return "not enough footage"; + + int goalies = p.Roles.Count(r => r.Role == PlayerRole.Goalie); + int back = p.Roles.Count(r => r.Role == PlayerRole.Back); + int rot = p.Roles.Count(r => r.Role == PlayerRole.Rotator); + int front = p.Roles.Count(r => r.Role == PlayerRole.Front); + + // A side whose outfield players all sit at the same height is not "nobody forward" — + // it is a rotation, where whoever is nearest takes the front slot. Judging that by + // absolute thresholds mislabels good rotating teams as passive. + var outfield = p.Roles.Where(r => r.Role != PlayerRole.Goalie).Select(r => r.FieldProgress).ToList(); + bool flat = outfield.Count >= 2 && outfield.Max() - outfield.Min() < 0.14; + p.IsFlatRotation = flat; + + if (flat) + return goalies > 0 + ? $"{goalies} on goal, {outfield.Count} rotating in front" + : $"{outfield.Count} rotating, no fixed stack"; + + if (goalies > 0 && front > 0) + return $"{goalies} on goal, {back + rot} through the middle, {front} up"; + if (goalies > 0) + { + var parts = new List { $"{goalies} on goal" }; + if (back > 0) parts.Add($"{back} sitting back"); + if (rot > 0) parts.Add($"{rot} rotating"); + return string.Join(", ", parts); + } + if (rot == p.Roles.Count) return $"{rot} rotating, no fixed goalie"; + if (front == 0) return $"{back + rot} playing behind the disc"; + if (back + goalies == 0) return $"all {front + rot} pushed up, no dedicated back"; + return $"{front} up, {rot} rotating, {back + goalies} back"; + } + + // ------------------------------------------------------------------ comparison + + private static void Compare(MatchAnalysis m, TeamProfile us, TeamProfile them) + { + if (us.PressHeight > them.PressHeight + 0.05) + us.StyleTags.Add("press high"); + else if (us.PressHeight < them.PressHeight - 0.05) + us.StyleTags.Add("sit deep"); + + if (us.OpenBackShare > them.OpenBackShare + 0.08) + us.StyleTags.Add("leave the back open"); + else if (us.OpenBackShare < them.OpenBackShare - 0.08) + us.StyleTags.Add("stay disciplined at the back"); + + if (us.Spacing > them.Spacing + 2) us.StyleTags.Add("stretch the sheet wide"); + else if (us.Spacing < them.Spacing - 2) us.StyleTags.Add("play bunched together"); + + if (us.AverageHold > them.AverageHold * 1.2) us.StyleTags.Add("carry the disc rather than move it"); + else if (us.AverageHold < them.AverageHold * 0.8) us.StyleTags.Add("move the disc quickly"); + + if (us.StunsPerMinute > them.StunsPerMinute * 1.25) us.StyleTags.Add("play physically"); + else if (us.StunsPerMinute < them.StunsPerMinute * 0.75) us.StyleTags.Add("avoid contact"); + + us.Rating = RateTeam(us, them); + us.Summary = Describe(m, us, them); + } + + /// + /// A 0-100 read on how well the side played, weighted towards the things that decide + /// matches: putting the disc in, keeping it, and not leaving the goal open. + /// + private static double RateTeam(TeamProfile us, TeamProfile them) + { + double scoreShare = us.Score + them.Score <= 0 ? 0.5 : (double)us.Score / (us.Score + them.Score); + double accuracy = us.ShotAccuracy + them.ShotAccuracy <= 0 + ? 0.5 + : us.ShotAccuracy / (us.ShotAccuracy + them.ShotAccuracy); + double possession = Math.Clamp(us.PossessionShare, 0, 1); + double discipline = Math.Clamp(1 - us.OpenBackShare, 0, 1); + + return Math.Clamp(scoreShare * 0.4 + accuracy * 0.2 + possession * 0.2 + discipline * 0.2, 0, 1) * 100; + } + + private static string Describe(MatchAnalysis m, TeamProfile us, TeamProfile them) + { + var goalie = us.Roles.FirstOrDefault(r => r.Role == PlayerRole.Goalie); + var front = us.Front.Select(r => r.Player.Name).ToList(); + var back = us.Back.Select(r => r.Player.Name).ToList(); + + string shape = $"{us.Name} played {us.StackShape}"; + + // When everyone plays at the same height there is no front stack to name, and calling + // two of them "up top" would contradict the shape we just reported. + string names = ""; + if (!us.IsFlatRotation && front.Count > 0) names = $" with {Join(front)} up top"; + if (goalie != null) + names += $"{(names.Length > 0 ? " and" : " with")} {goalie.Player.Name} on the goal"; + else if (!us.IsFlatRotation && back.Count > 0 && front.Count > 0) + names += $" and {Join(back)} holding the back"; + + string tags = us.StyleTags.Count > 0 ? $" They {Join(us.StyleTags)}." : ""; + string result = us.Score > them.Score + ? $" They won it {us.Score}-{them.Score}" + : us.Score < them.Score + ? $" They lost it {us.Score}-{them.Score}" + : $" It finished level at {us.Score}"; + + return shape + names + "." + tags + result + + $", converting {us.ShotAccuracy:P0} of their shots with {us.PossessionShare:P0} of the disc."; + } + + private static string Join(IReadOnlyList items) => items.Count switch + { + 0 => "", + 1 => items[0], + 2 => $"{items[0]} and {items[1]}", + _ => string.Join(", ", items.Take(items.Count - 1)) + " and " + items[^1], + }; + + private static double Weighted(List players, Func pick) + { + double total = players.Sum(p => p.SecondsPlayed); + if (total <= 0) return 0; + return players.Sum(p => pick(p) * p.SecondsPlayed) / total; + } + + // ------------------------------------------------------------------ matchup + + /// + /// What the opposition did to this team. This is the half of the analysis a team cannot + /// get from their own stat line: which of their shapes was exploited, and by whom. + /// + public static List BuildMatchup(MatchAnalysis m, TeamProfile us, TeamProfile them) + { + var insights = new List(); + var conceded = m.Goals.Where(g => g.Side != us.Side).ToList(); + var scored = m.Goals.Where(g => g.Side == us.Side).ToList(); + + // ---- were we open at the back when they scored? ---- + if (conceded.Count >= 3) + { + int open = conceded.Count(g => g.DefendersBack == 0); + if (open >= conceded.Count * 0.5) + insights.Add(new Insight + { + Kind = InsightKind.Weakness, Area = InsightArea.TeamShape, + Title = "They scored into an empty back", + Detail = $"{open} of the {conceded.Count} goals {them.Name} scored went in with nobody " + + $"in your defensive third. {us.Name} had the back open {us.OpenBackShare:P0} of live play.", + Action = "Set a hard rule that one player stays below the disc. Losing an attacker costs less than conceding the counter.", + Evidence = $"{open}/{conceded.Count} with nobody home", + Confidence = 0.9, + }); + } + + // ---- press mismatch ---- + double gap = them.PressHeight - us.PressHeight; + if (Math.Abs(gap) > 0.08) + { + bool theyHigher = gap > 0; + insights.Add(new Insight + { + Kind = theyHigher ? InsightKind.Weakness : InsightKind.Note, + Area = InsightArea.TeamShape, + Title = theyHigher ? "They pushed further up than you did" : "You played higher than they did", + Detail = $"{them.Name} averaged {them.PressHeight:P0} up the sheet against {us.PressHeight:P0} for {us.Name}.", + Action = theyHigher + ? "They are taking the space in front of your goal for free. Meet them at the midline instead of collecting the disc behind your own." + : "You held the higher ground. Keep an outlet behind the disc so the turnover does not become a breakaway.", + Evidence = $"{them.PressHeight:P0} vs {us.PressHeight:P0}", + Confidence = 0.75, + }); + } + + // ---- who hurt us ---- + var theirBest = m.AllPlayers + .Where(p => p.Side != us.Side && p.SecondsPlayed > 30) + .OrderByDescending(p => p.GoalPoints) + .ThenByDescending(p => p.Goals) + .ThenByDescending(p => p.ShotAccuracy) + .FirstOrDefault(); + if (theirBest is { GoalPoints: >= 4 }) + { + var ourDeepest = us.Roles.OrderBy(r => r.FieldProgress).FirstOrDefault(); + insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Defence, + Title = $"{theirBest.Name} did the damage", + Detail = $"{theirBest.GoalPoints} of {them.Score} points, {theirBest.Goals} goals from " + + $"{theirBest.ShotsTaken} shots ({theirBest.ShotAccuracy:P0})." + + (ourDeepest != null ? $" Your deepest player was {ourDeepest.Player.Name}." : ""), + Action = ourDeepest != null + ? $"Give {ourDeepest.Player.Name} help rather than leaving them one-on-one. A second body at the three-line forces the pass instead of the shot." + : "Assign a specific player to them rather than defending zonally.", + Evidence = $"{theirBest.GoalPoints} of {them.Score} points", + Confidence = 0.85, + }); + } + + // ---- shape mismatch: front count ---- + int ourFront = us.Front.Count(); + int theirFront = them.Front.Count(); + if (theirFront > ourFront && theirFront >= 2) + insights.Add(new Insight + { + Kind = InsightKind.Weakness, Area = InsightArea.TeamShape, + Title = "Outnumbered in the attacking half", + Detail = $"{them.Name} committed {theirFront} players forward against your {ourFront}. " + + $"Their shape was {them.StackShape}; yours was {us.StackShape}.", + Action = "Either match their numbers and accept the trade, or collapse two defenders on the carrier and play the counter off the turnover.", + Evidence = $"{theirFront} up vs {ourFront}", + Confidence = 0.7, + }); + + // ---- conversion ---- + if (them.ShotAccuracy > us.ShotAccuracy * 1.6 && m.Orange.ShotsTaken + m.Blue.ShotsTaken > 10) + insights.Add(new Insight + { + Kind = InsightKind.Weakness, Area = InsightArea.Finishing, + Title = "They made their chances count and you did not", + Detail = $"{them.Name} converted {them.ShotAccuracy:P0} of their shots against {us.ShotAccuracy:P0} for {us.Name}" + + (scored.Count > 0 || conceded.Count > 0 + ? $" — {conceded.Count} goals conceded against {scored.Count} scored." + : "."), + Action = "Stop shooting from range. Work one more pass to get inside the three-line where their goalie has no time to set.", + Evidence = $"{them.ShotAccuracy:P0} vs {us.ShotAccuracy:P0}", + Confidence = 0.8, + }); + + // ---- goal profile: where their scoring came from ---- + if (conceded.Count >= 3) + { + double avgDist = conceded.Average(g => g.DistanceThrown); + bool longRange = avgDist > 18; + insights.Add(new Insight + { + Kind = InsightKind.Counter, Area = InsightArea.Defence, + Title = longRange ? "They beat you from distance" : "They walked it in", + Detail = $"Their goals averaged {avgDist:F0}m out" + + (longRange ? " — long shots your goalie did not stop." : " — scored from inside, past the last defender."), + Action = longRange + ? "Your goalie is starting too deep. Step out to cut the angle and make them come inside." + : "You are being beaten at the last line. Add a body between the three-line and the goal rather than chasing the disc high.", + Evidence = $"{avgDist:F0}m average", + Confidence = 0.7, + }); + } + + insights.Sort((a, b) => b.Confidence.CompareTo(a.Confidence)); + return insights; + } +} diff --git a/ReplayAnalyser/Core/Model/MatchAnalysis.cs b/ReplayAnalyser/Core/Model/MatchAnalysis.cs new file mode 100644 index 0000000..c87675b --- /dev/null +++ b/ReplayAnalyser/Core/Model/MatchAnalysis.cs @@ -0,0 +1,357 @@ +// Ported from the Replay Analyser (EchoAnalyser.Core/Model/MatchAnalysis.cs). Logic is unchanged; only the +// namespace, explicit usings and nullable context differ, so fixes can be diffed against +// the original. +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Numerics; +using Spark.ReplayAnalyser.Analysis; + +namespace Spark.ReplayAnalyser.Model; + +public enum MatchEventKind +{ + Goal, Save, Stun, Steal, Interception, Block, Pass, Catch, + ShotTaken, Assist, PossessionGained, PossessionLost, Throw, Turnover, +} + +public sealed class MatchEvent +{ + public MatchEventKind Kind { get; init; } + public TimeSpan Time { get; init; } + public float GameClock { get; init; } + public string Player { get; init; } = ""; + public long UserId { get; init; } + public TeamSide Side { get; init; } + public Vector3 Position { get; init; } + public Vector3 DiscPosition { get; init; } + + /// Free-form detail: goal type, the player involved on the other end of a pass, etc. + public string? Detail { get; init; } + public float Value { get; init; } + public string? SecondaryPlayer { get; init; } + + public string Label => Kind switch + { + MatchEventKind.Goal => $"Goal — {Player}", + MatchEventKind.Save => $"Save — {Player}", + MatchEventKind.Stun => $"Stun — {Player}", + MatchEventKind.Steal => $"Steal — {Player}", + MatchEventKind.Interception => $"Interception — {Player}", + MatchEventKind.Block => $"Block — {Player}", + MatchEventKind.Turnover => $"Turnover — {Player}", + _ => $"{Kind} — {Player}", + }; +} + +/// A single continuous stretch of one player holding the disc. +public sealed class PossessionSpan +{ + public string Player { get; init; } = ""; + public long UserId { get; init; } + public TeamSide Side { get; init; } + public TimeSpan Start { get; init; } + public TimeSpan End { get; set; } + public Vector3 StartPosition { get; init; } + public Vector3 EndPosition { get; set; } + public double Seconds => (End - Start).TotalSeconds; + /// How the span ended: "pass", "shot", "turnover", "end of match", or "unknown". + public string Outcome { get; set; } = "unknown"; + public bool EndedInShot => Outcome == "shot"; + public bool EndedInTurnover => Outcome == "turnover"; +} + +public sealed class ThrowRecord +{ + public string Player { get; init; } = ""; + public TeamSide Side { get; init; } + public TimeSpan Time { get; init; } + public float TotalSpeed { get; init; } + public float ArmSpeed { get; init; } + public float SpeedFromMovement { get; init; } + public float SpeedFromWrist { get; init; } + public float OffAxisSpinDeg { get; init; } + public float WristThrowPenalty { get; init; } + public float OffAxisPenalty { get; init; } + public float ThrowMovePenalty { get; init; } + public float RotPerSec { get; init; } + public Vector3 Position { get; init; } + + /// Fraction of potential speed lost to the three penalty terms. + public float TotalPenalty => WristThrowPenalty + OffAxisPenalty + ThrowMovePenalty; +} + +public sealed class GoalRecord +{ + public TimeSpan Time { get; init; } + public TeamSide Side { get; init; } + public string Scorer { get; init; } = ""; + public string? Assist { get; init; } + public int Points { get; init; } + public string GoalType { get; init; } = ""; + public float DiscSpeed { get; init; } + public float DistanceThrown { get; init; } + public int BlueScore { get; init; } + public int OrangeScore { get; init; } + + /// How many defenders the conceding side had in their own third as this went in. + public int DefendersBack { get; init; } +} + +/// Occupancy counts over a 2D grid, used for the heatmaps. +public sealed class Heatmap +{ + public int Width { get; } + public int Height { get; } + public float[,] Cells { get; } + public float Max { get; private set; } + + public Heatmap(int width, int height) + { + Width = width; Height = height; + Cells = new float[width, height]; + } + + public void Add(float normX, float normY, float weight = 1f) + { + int x = (int)Math.Clamp(normX * Width, 0, Width - 1); + int y = (int)Math.Clamp(normY * Height, 0, Height - 1); + Cells[x, y] += weight; + if (Cells[x, y] > Max) Max = Cells[x, y]; + } + + public float Normalized(int x, int y) => Max <= 0 ? 0 : Cells[x, y] / Max; + + /// + /// The value at the given percentile of the occupied cells. Scaling a heatmap by its + /// single hottest cell flattens everything else into the noise floor — one long stall in + /// front of goal should not erase the rest of the picture — so the renderer scales by a + /// high percentile instead and lets the very hottest cells clip. + /// + public float Percentile(double fraction) + { + var values = new List(); + foreach (var v in Cells) if (v > 0) values.Add(v); + if (values.Count == 0) return 0; + values.Sort(); + int i = (int)Math.Clamp(fraction * (values.Count - 1), 0, values.Count - 1); + return values[i]; + } +} + +public sealed class PlayerAnalysis +{ + public string Name { get; set; } = ""; + public long UserId { get; set; } + public TeamSide Side { get; set; } + public int Level { get; set; } + public int Number { get; set; } + + // ---- raw scoreboard ---- + /// + /// The game's own points counter. In public matches this equals the points the + /// player put on the board; in private matches it is a personal score that can total far + /// more than the team scored, so is what the scoreboard uses. + /// + public int Points { get; set; } + + /// + /// Points this player actually put on the board, summed from the goals attributed to + /// them. This always reconciles with the team score. + /// + public int GoalPoints { get; set; } + public int Goals { get; set; } + public int Assists { get; set; } + public int Saves { get; set; } + public int Stuns { get; set; } + public int Passes { get; set; } + public int Catches { get; set; } + public int Steals { get; set; } + public int Blocks { get; set; } + public int Interceptions { get; set; } + public int ShotsTaken { get; set; } + public float PossessionTime { get; set; } + + // ---- derived ---- + public double SecondsPlayed { get; set; } + public double DistanceTravelled { get; set; } + public double AverageSpeed { get; set; } + public double MaxSpeed { get; set; } + public int TimesStunned { get; set; } + public double SecondsStunned { get; set; } + public int Turnovers { get; set; } + public int PossessionCount { get; set; } + public double AveragePossessionSeconds => + PossessionCount == 0 ? 0 : PossessionTime / PossessionCount; + public double ShotAccuracy => ShotsTaken == 0 ? 0 : (double)Goals / ShotsTaken; + + /// Share of match time spent in each third of the field, own goal first. + public double DefensiveThirdShare { get; set; } + public double NeutralShare { get; set; } + public double OffensiveThirdShare { get; set; } + + public double AverageFieldProgress { get; set; } + public double SecondsAsLastDefender { get; set; } + public double SecondsInGoalieBox { get; set; } + + public Vector3 AveragePosition { get; set; } + + /// Errors we could identify frame by frame — see . + public List Mistakes { get; } = new(); + + public int MissedGrabs => Mistakes.Count(x => x.Kind == MistakeKind.MissedGrab); + public int LostContests => Mistakes.Count(x => x.Kind == MistakeKind.LostContest); + public int CarriedIntoStun => Mistakes.Count(x => x.Kind == MistakeKind.CarriedIntoStun); + public int TurnoversAtHome => Mistakes.Count(x => x.Kind == MistakeKind.TurnoverAtHome); + + /// + /// Chances to take a free disc where the hand actually reached it. The denominator for + /// grab reliability — a miss only means something against how often they were in range. + /// + public int GrabChances { get; set; } + public double GrabSuccessRate => GrabChances == 0 ? 0 : 1 - (double)MissedGrabs / GrabChances; + + public List Throws { get; } = new(); + public List Possessions { get; } = new(); + public List Events { get; } = new(); + + /// + /// This player's movement, as polylines in arena space. Each entry is one continuous + /// stroke; a new one starts after a respawn, a stoppage, or a gap in the recording, so + /// drawing them never joins two unrelated positions with a false straight line. + /// + public List> Tracks { get; } = new(); + + /// Where this player was standing when they took each shot. + public List ShotPositions { get; } = new(); + public List GoalPositions { get; } = new(); + + /// Counts of passes made to each team-mate. + public Dictionary PassTargets { get; } = new(); + + /// + /// Handovers to a team-mate counted from the disc changing hands. Private matches report + /// passes as zero for everybody, so this is what the UI falls back to. + /// + public int Handovers => PassTargets.Values.Sum(); + + /// + /// The pass count to reason about: the game's own figure where it has one, and our + /// counted handovers where it does not. + /// + public int PassCount => Passes > 0 ? Passes : Handovers; + + /// + /// True when the recording carries real throw telemetry for this player. Echo VR only + /// fills last_throw for the client that recorded the replay, so for everyone else + /// throw mechanics are simply unknown — which is different from being bad at throwing. + /// + public bool HasThrowTelemetry { get; set; } + + public double AverageThrowSpeed => Throws.Count == 0 ? 0 : Throws.Average(t => t.TotalSpeed); + public double MaxThrowSpeed => Throws.Count == 0 ? 0 : Throws.Max(t => t.TotalSpeed); + public double AverageThrowPenalty => Throws.Count == 0 ? 0 : Throws.Average(t => t.TotalPenalty); + + public double PointsPerMinute => SecondsPlayed <= 0 ? 0 : GoalPoints / (SecondsPlayed / 60.0); + public double StunsPerMinute => SecondsPlayed <= 0 ? 0 : Stuns / (SecondsPlayed / 60.0); + public double PossessionShare { get; set; } + + public int AveragePing { get; set; } +} + +public sealed class TeamAnalysis +{ + public TeamSide Side { get; set; } + public string Name { get; set; } = ""; + public int Score { get; set; } + public List Players { get; } = new(); + + public float PossessionSeconds { get; set; } + public double PossessionShare { get; set; } + public int Goals => Players.Sum(p => p.Goals); + public int Saves => Players.Sum(p => p.Saves); + public int Stuns => Players.Sum(p => p.Stuns); + public int Turnovers => Players.Sum(p => p.Turnovers); + public int ShotsTaken => Players.Sum(p => p.ShotsTaken); + public double ShotAccuracy => ShotsTaken == 0 ? 0 : (double)Goals / ShotsTaken; + + /// Mean distance between team-mates — low means clustered, high means stretched. + public double AverageSpread { get; set; } + /// Share of playing time with 0, 1, 2, 3+ players back on defence. + public double[] DefendersBackShare { get; set; } = new double[4]; +} + +/// One sampled point on a time-series chart. +public readonly record struct TimePoint(double Seconds, double Value); + +public sealed class MatchAnalysis +{ + public string FilePath { get; set; } = ""; + public string FileName => Path.GetFileName(FilePath); + public string Format { get; set; } = ""; + public string MapName { get; set; } = ""; + public string MatchType { get; set; } = ""; + public bool PrivateMatch { get; set; } + public string SessionId { get; set; } = ""; + public string ClientName { get; set; } = ""; + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public TimeSpan Duration => EndTime - StartTime; + public int FrameCount { get; set; } + public double SampleRateHz { get; set; } + + /// Seconds of actual live play — excludes round starts, goal celebrations and pauses. + public double LiveSeconds { get; set; } + public TimeSpan LiveTime => TimeSpan.FromSeconds(LiveSeconds); + + /// Seconds spent in each game_status, so the UI can show where the clock went. + public Dictionary StatusSeconds { get; } = new(); + + /// The player whose client recorded this replay, if we could identify it. + public string? RecordingPlayer { get; set; } + public bool IsPartial { get; set; } + public List Diagnostics { get; } = new(); + + public int BlueScore { get; set; } + public int OrangeScore { get; set; } + + /// + /// How many rounds this file contains. Spark keeps recording across consecutive games, + /// so one capture can hold several, and the scores here are the totals over all of them. + /// + public int RoundsRecorded { get; set; } = 1; + public TeamAnalysis Blue { get; set; } = new() { Side = TeamSide.Blue, Name = "BLUE" }; + public TeamAnalysis Orange { get; set; } = new() { Side = TeamSide.Orange, Name = "ORANGE" }; + + public List Goals { get; } = new(); + /// Every identified error in the match, both sides. + public List Mistakes { get; } = new(); + public List Events { get; } = new(); + public List Possessions { get; } = new(); + + /// Score difference (blue minus orange) sampled through the match. + public List ScoreDifferential { get; } = new(); + public List BlueScoreSeries { get; } = new(); + public List OrangeScoreSeries { get; } = new(); + /// Rolling share of possession held by blue, 0..1. + public List PossessionSeries { get; } = new(); + /// Disc position along the length of the arena, showing territorial swing. + public List DiscFieldPosition { get; } = new(); + + /// The disc's path through the match, in the same segmented form as player tracks. + public List> DiscTracks { get; } = new(); + + public IEnumerable AllPlayers => Blue.Players.Concat(Orange.Players); + + public PlayerAnalysis? FindPlayer(string name) => + AllPlayers.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + + public bool IsArena => MapName == "mpl_arena_a"; + + public string ScoreLine => $"{BlueScore} – {OrangeScore}"; +} diff --git a/ReplayAnalyser/Core/Model/MatchSummary.cs b/ReplayAnalyser/Core/Model/MatchSummary.cs new file mode 100644 index 0000000..a297fd6 --- /dev/null +++ b/ReplayAnalyser/Core/Model/MatchSummary.cs @@ -0,0 +1,240 @@ +// Ported from the Replay Analyser (EchoAnalyser.Core/Model/MatchSummary.cs). Logic is unchanged; only the +// namespace, explicit usings and nullable context differ, so fixes can be diffed against +// the original. +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Spark.ReplayAnalyser.Model; + +/// One player's line in a . +public sealed class PlayerSummary +{ + public string Name { get; set; } = ""; + public long UserId { get; set; } + public int Side { get; set; } + /// The game's own personal-score counter, which private matches inflate. + public int Points { get; set; } + /// Points actually put on the board — this reconciles with the team score. + public int GoalPoints { get; set; } + public int Goals { get; set; } + public int Assists { get; set; } + public int Saves { get; set; } + public int Stuns { get; set; } + public int Passes { get; set; } + /// Handovers counted from the disc changing hands, for matches reporting no passes. + public int Handovers { get; set; } + public int Turnovers { get; set; } + public int ShotsTaken { get; set; } + public double PossessionTime { get; set; } + public double SecondsPlayed { get; set; } + public double DistanceTravelled { get; set; } + public double AverageSpeed { get; set; } + public double DefensiveThirdShare { get; set; } + public double OffensiveThirdShare { get; set; } + public bool HasThrowTelemetry { get; set; } + public double AverageThrowSpeed { get; set; } + + // ---- errors we could identify frame by frame ---- + public int GrabChances { get; set; } + public int MissedGrabs { get; set; } + public int LostContests { get; set; } + public int CarriedIntoStun { get; set; } + public int TurnoversAtHome { get; set; } + public double AveragePossessionSeconds { get; set; } + public double SecondsInGoalieBox { get; set; } + public double SecondsAsLastDefender { get; set; } + public double AverageFieldProgress { get; set; } + + public double GrabSuccessRate => GrabChances == 0 ? 0 : 1 - (double)MissedGrabs / GrabChances; + public double MissedGrabsPerMinute => SecondsPlayed <= 0 ? 0 : MissedGrabs / (SecondsPlayed / 60.0); + + public double PointsPerMinute => SecondsPlayed <= 0 ? 0 : GoalPoints / (SecondsPlayed / 60.0); + public double StunsPerMinute => SecondsPlayed <= 0 ? 0 : Stuns / (SecondsPlayed / 60.0); + public double TurnoversPerMinute => SecondsPlayed <= 0 ? 0 : Turnovers / (SecondsPlayed / 60.0); +} + +/// +/// The condensed result of analysing one replay — enough to browse, filter and chart a +/// whole library without re-parsing gigabytes of frames every time the app starts. +/// +public sealed class MatchSummary +{ + public string FilePath { get; set; } = ""; + public long FileSize { get; set; } + public long FileStamp { get; set; } + public string Format { get; set; } = ""; + public string MapName { get; set; } = ""; + public string MatchType { get; set; } = ""; + public bool PrivateMatch { get; set; } + public DateTime Recorded { get; set; } + public double DurationSeconds { get; set; } + public double LiveSeconds { get; set; } + public int FrameCount { get; set; } + public int BlueScore { get; set; } + public int OrangeScore { get; set; } + public int GoalCount { get; set; } + public bool IsPartial { get; set; } + public string? RecordingPlayer { get; set; } + public string? Error { get; set; } + public List Players { get; set; } = new(); + + [JsonIgnore] public string FileName => Path.GetFileName(FilePath); + [JsonIgnore] public TimeSpan Duration => TimeSpan.FromSeconds(DurationSeconds); + [JsonIgnore] public bool IsArena => MapName == "mpl_arena_a"; + [JsonIgnore] public bool Ok => Error == null && FrameCount > 0; + [JsonIgnore] public string ScoreLine => $"{BlueScore} – {OrangeScore}"; + + public static MatchSummary From(MatchAnalysis m, FileInfo fi) => new() + { + FilePath = m.FilePath, + FileSize = fi.Length, + FileStamp = fi.LastWriteTimeUtc.Ticks, + Format = m.Format, + MapName = m.MapName, + MatchType = m.MatchType, + PrivateMatch = m.PrivateMatch, + Recorded = m.StartTime, + DurationSeconds = m.Duration.TotalSeconds, + LiveSeconds = m.LiveSeconds, + FrameCount = m.FrameCount, + BlueScore = m.BlueScore, + OrangeScore = m.OrangeScore, + GoalCount = m.Goals.Count, + IsPartial = m.IsPartial, + RecordingPlayer = m.RecordingPlayer, + Players = m.AllPlayers.Select(p => new PlayerSummary + { + Name = p.Name, + UserId = p.UserId, + Side = (int)p.Side, + Points = p.Points, + GoalPoints = p.GoalPoints, + Goals = p.Goals, + Assists = p.Assists, + Saves = p.Saves, + Stuns = p.Stuns, + Passes = p.Passes, + Handovers = p.Handovers, + Turnovers = p.Turnovers, + ShotsTaken = p.ShotsTaken, + PossessionTime = p.PossessionTime, + SecondsPlayed = p.SecondsPlayed, + DistanceTravelled = p.DistanceTravelled, + AverageSpeed = p.AverageSpeed, + DefensiveThirdShare = p.DefensiveThirdShare, + OffensiveThirdShare = p.OffensiveThirdShare, + HasThrowTelemetry = p.HasThrowTelemetry, + AverageThrowSpeed = p.AverageThrowSpeed, + GrabChances = p.GrabChances, + MissedGrabs = p.MissedGrabs, + LostContests = p.LostContests, + CarriedIntoStun = p.CarriedIntoStun, + TurnoversAtHome = p.TurnoversAtHome, + AveragePossessionSeconds = p.AveragePossessionSeconds, + SecondsInGoalieBox = p.SecondsInGoalieBox, + SecondsAsLastDefender = p.SecondsAsLastDefender, + AverageFieldProgress = p.AverageFieldProgress, + }).ToList(), + }; +} + +/// +/// A JSON-backed cache of match summaries, so indexing a replay folder is a one-off cost. +/// Entries are invalidated when a file's size or timestamp changes. +/// +public sealed class LibraryIndex +{ + private static readonly JsonSerializerOptions Json = new() + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// + /// Bumped whenever the analysis or the summary shape changes. A cache written by an older + /// build is thrown away rather than shown: stale numbers that look plausible are worse + /// than an empty library, because nothing about them says they are out of date. + /// + public const int CurrentSchema = 3; + + public int Schema { get; set; } = CurrentSchema; + + public Dictionary Entries { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + + /// Spark's own copy, alongside the rest of its data. + public static string DefaultPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "IgniteVR", "Spark", "ReplayAnalyser", "library-index.json"); + + /// + /// Where the standalone Replay Analyser keeps its index. The analysis is the same code, so a + /// library indexed there is valid here: on first run it's copied over rather than rebuilt, + /// which would otherwise mean re-parsing every replay. Copied, not shared — two apps writing + /// one file would keep overwriting each other. + /// + public static string StandalonePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "EchoAnalyser", "library-index.json"); + + private sealed class Payload + { + public int Schema { get; set; } + public Dictionary Entries { get; set; } = new(); + } + + public static LibraryIndex Load(string? path = null) + { + path ??= DefaultPath; + try + { + if (!File.Exists(path) && path == DefaultPath && File.Exists(StandalonePath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.Copy(StandalonePath, path); + } + + if (!File.Exists(path)) return new LibraryIndex(); + var loaded = JsonSerializer.Deserialize(File.ReadAllText(path), Json); + if (loaded == null || loaded.Schema != CurrentSchema) return new LibraryIndex(); + return new LibraryIndex + { + Entries = new Dictionary( + loaded.Entries, StringComparer.OrdinalIgnoreCase), + }; + } + catch + { + // A corrupt or older-format cache is not worth failing over — rebuild it. + return new LibraryIndex(); + } + } + + public void Save(string? path = null) + { + path ??= DefaultPath; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var tmp = path + ".tmp"; + File.WriteAllText(tmp, JsonSerializer.Serialize( + new Payload { Schema = CurrentSchema, Entries = Entries }, Json)); + File.Move(tmp, path, overwrite: true); + } + + /// True when we already hold an up-to-date summary for this file. + public bool IsCurrent(FileInfo fi) => + Entries.TryGetValue(fi.FullName, out var e) + && e.FileSize == fi.Length + && e.FileStamp == fi.LastWriteTimeUtc.Ticks; + + public MatchSummary? Get(string path) => Entries.GetValueOrDefault(path); + + public void Put(MatchSummary s) => Entries[s.FilePath] = s; +} diff --git a/ReplayAnalyser/Core/Model/Mistake.cs b/ReplayAnalyser/Core/Model/Mistake.cs new file mode 100644 index 0000000..62378cd --- /dev/null +++ b/ReplayAnalyser/Core/Model/Mistake.cs @@ -0,0 +1,49 @@ +// Ported from the Replay Analyser (EchoAnalyser.Core/Model/Mistake.cs). Logic is unchanged; only the +// namespace, explicit usings and nullable context differ, so fixes can be diffed against +// the original. +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Numerics; + +namespace Spark.ReplayAnalyser.Model; + +public enum MistakeKind +{ + /// Hand was on a free disc and came away without it, with nobody else taking it. + MissedGrab, + /// In range of a free disc and an opponent took it instead. + LostContest, + /// Carried the disc into contact and was stunned out of possession. + CarriedIntoStun, + /// Gave the disc to the other team inside your own third. + TurnoverAtHome, +} + +/// One identifiable error, with the moment and the place it happened. +public sealed class Mistake +{ + public MistakeKind Kind { get; init; } + public TimeSpan Time { get; init; } + public string Player { get; init; } = ""; + public long UserId { get; init; } + public Vector3 Position { get; init; } + public Vector3 DiscPosition { get; init; } + /// How close the hand got, for grab errors. + public float Distance { get; init; } + public string Detail { get; init; } = ""; + + public string Label => Kind switch + { + MistakeKind.MissedGrab => "Missed grab", + MistakeKind.LostContest => "Lost the contest", + MistakeKind.CarriedIntoStun => "Carried into contact", + MistakeKind.TurnoverAtHome => "Turnover at home", + _ => Kind.ToString(), + }; +} diff --git a/ReplayAnalyser/Core/Parsing/IndexingLimits.cs b/ReplayAnalyser/Core/Parsing/IndexingLimits.cs new file mode 100644 index 0000000..99c0e58 --- /dev/null +++ b/ReplayAnalyser/Core/Parsing/IndexingLimits.cs @@ -0,0 +1,58 @@ +// Ported from the Replay Analyser (EchoAnalyser.Core/Parsing/IndexingLimits.cs). Logic is unchanged; only the +// namespace, explicit usings and nullable context differ, so fixes can be diffed against +// the original. +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +namespace Spark.ReplayAnalyser.Parsing; + +/// +/// How many replays it is safe to decode at once. +/// +/// A large .butter file expands to tens of thousands of fully-populated frames, so a +/// single worker can hold well over a gigabyte. Fanning out to every core will happily eat +/// 10 GB+ and start swapping on a smaller machine, which is far slower than using fewer +/// workers — so the limit comes from free memory as much as from core count. +/// +public static class IndexingLimits +{ + /// Rough peak working set for one in-flight replay, in bytes. + private const double BytesPerWorker = 1.5 * 1024 * 1024 * 1024; + + public static int MaxParallelism + { + get + { + int byCpu = Math.Max(1, Environment.ProcessorCount - 1); + + long available = AvailableMemoryBytes(); + if (available <= 0) return Math.Min(byCpu, 4); + + // Leave roughly a quarter of what is free for everything else on the machine. + int byMemory = (int)(available * 0.75 / BytesPerWorker); + return Math.Clamp(Math.Min(byCpu, byMemory), 1, 8); + } + } + + private static long AvailableMemoryBytes() + { + try + { + var info = GC.GetGCMemoryInfo(); + // TotalAvailableMemoryBytes is the container/machine limit; subtract what the + // process already holds to approximate the headroom left. + long limit = info.TotalAvailableMemoryBytes; + if (limit <= 0) return 0; + return Math.Max(0, limit - GC.GetTotalMemory(false)); + } + catch + { + return 0; + } + } +} diff --git a/ReplayAnalyser/Core/Parsing/ReplayLoader.cs b/ReplayAnalyser/Core/Parsing/ReplayLoader.cs new file mode 100644 index 0000000..8b771ed --- /dev/null +++ b/ReplayAnalyser/Core/Parsing/ReplayLoader.cs @@ -0,0 +1,189 @@ +// Ported from the Replay Analyser (EchoAnalyser.Core/Parsing/ReplayLoader.cs). Logic is unchanged; only the +// namespace, explicit usings and nullable context differ, so fixes can be diffed against +// the original. +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.IO.Compression; +using System.Text; +using EchoVRAPI; +using Newtonsoft.Json; + +namespace Spark.ReplayAnalyser.Parsing; + +/// +/// Entry point for reading any replay this tool understands. Everything is normalised to a +/// List<Frame> so the analysis layer never has to care where the data came from. +/// +public static class ReplayLoader +{ + public static readonly string[] SupportedExtensions = { ".echoreplay", ".butter", ".tape" }; + + public static ReplayFormat DetectFormat(string path) + { + string ext = Path.GetExtension(path).ToLowerInvariant(); + return ext switch + { + ".echoreplay" => ReplayFormat.EchoReplay, + ".butter" => ReplayFormat.Butter, + ".tape" => ReplayFormat.Tape, + _ => ReplayFormat.Unknown, + }; + } + + public static ReplayReadResult Load(string path, CancellationToken ct = default) + { + var format = DetectFormat(path); + try + { + return format switch + { + ReplayFormat.Butter => ButterV3Reader.Read(File.ReadAllBytes(path), path), + ReplayFormat.EchoReplay => ReadEchoReplay(path, ct), + ReplayFormat.Tape => ReadTape(path), + _ => Failed(path, format, "Unrecognised file extension."), + }; + } + catch (Exception ex) + { + return Failed(path, format, $"{ex.GetType().Name}: {ex.Message}"); + } + } + + private static ReplayReadResult Failed(string path, ReplayFormat fmt, string message) + { + var r = new ReplayReadResult { SourcePath = path, Format = fmt }; + r.Diagnostics.Add(message); + return r; + } + + /// + /// .echoreplay is one API response per line: timestamp \t json [\t bonedata]. + /// Modern files wrap that in a zip; older ones are the raw text. A malformed line is + /// skipped rather than aborting the read. + /// + private static ReplayReadResult ReadEchoReplay(string path, CancellationToken ct) + { + var result = new ReplayReadResult { SourcePath = path, Format = ReplayFormat.EchoReplay }; + + using Stream stream = OpenPossiblyZipped(path, out string? innerName); + using var reader = new StreamReader(stream, Encoding.UTF8); + + int lineNo = 0, malformed = 0; + string? line; + while ((line = reader.ReadLine()) != null) + { + ct.ThrowIfCancellationRequested(); + lineNo++; + if (line.Length == 0) continue; + + int tab = line.IndexOf('\t'); + if (tab <= 0) { malformed++; continue; } + + string timePart = line[..tab]; + string rest = line[(tab + 1)..]; + + // An optional third field carries bone data; the JSON is the second field. + int tab2 = rest.IndexOf('\t'); + string json = tab2 > 0 ? rest[..tab2] : rest; + if (json.Length < 2 || json[0] != '{') { malformed++; continue; } + + Frame? frame; + try { frame = JsonConvert.DeserializeObject(json); } + catch { malformed++; continue; } + if (frame == null) { malformed++; continue; } + + frame.recorded_time = ParseTimestamp(timePart); + result.Frames.Add(frame); + } + + if (malformed > 0) + result.Diagnostics.Add($"{malformed:N0} of {lineNo:N0} line(s) were malformed and skipped."); + + var first = result.Frames.FirstOrDefault(); + result.ClientName = first?.client_name; + result.SessionId = first?.sessionid; + return result; + } + + private static Stream OpenPossiblyZipped(string path, out string? innerName) + { + innerName = null; + var head = new byte[2]; + using (var probe = File.OpenRead(path)) + { + if (probe.Read(head, 0, 2) < 2) return new MemoryStream(Array.Empty()); + } + + // "PK" — a zip container holding a single entry with the replay text. + if (head[0] == 0x50 && head[1] == 0x4B) + { + var zip = ZipFile.OpenRead(path); + var entry = zip.Entries.FirstOrDefault(e => e.Length > 0) ?? zip.Entries.FirstOrDefault(); + if (entry == null) { zip.Dispose(); return new MemoryStream(Array.Empty()); } + innerName = entry.FullName; + return new ZipEntryStream(zip, entry.Open()); + } + + return File.OpenRead(path); + } + + private static DateTime ParseTimestamp(string s) + { + // Recorded as "yyyy/MM/dd HH:mm:ss.fff", with some older files using '-' separators. + if (DateTime.TryParseExact(s, new[] + { + "yyyy/MM/dd HH:mm:ss.fff", + "yyyy/MM/dd HH:mm:ss", + "yyyy-MM-dd HH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ss.fffZ", + }, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var dt)) + return dt; + return DateTime.TryParse(s, out var any) ? any : DateTime.MinValue; + } + + /// + /// .tape is a zstd-framed protobuf stream. The bundled native library only exposes + /// writing, so rather than half-decode it we report clearly and point at the converter. + /// + private static ReplayReadResult ReadTape(string path) + { + var r = new ReplayReadResult { SourcePath = path, Format = ReplayFormat.Tape }; + r.Diagnostics.Add( + "The .tape container (zstd-framed protobuf) has no managed reader yet. " + + "Convert it first with: tapedeck show --format json \"" + Path.GetFileName(path) + "\""); + return r; + } + + /// Keeps the owning alive for the lifetime of the entry stream. + private sealed class ZipEntryStream : Stream + { + private readonly ZipArchive _archive; + private readonly Stream _inner; + + public ZipEntryStream(ZipArchive archive, Stream inner) { _archive = archive; _inner = inner; } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _inner.Length; + public override long Position { get => _inner.Position; set => throw new NotSupportedException(); } + public override void Flush() => _inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) { _inner.Dispose(); _archive.Dispose(); } + base.Dispose(disposing); + } + } +} diff --git a/ReplayAnalyser/ReplayAnalyserTab.xaml b/ReplayAnalyser/ReplayAnalyserTab.xaml new file mode 100644 index 0000000..d4abc26 --- /dev/null +++ b/ReplayAnalyser/ReplayAnalyserTab.xaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ReplayAnalyser/ReplayAnalyserTab.xaml.cs b/ReplayAnalyser/ReplayAnalyserTab.xaml.cs new file mode 100644 index 0000000..c6caa3e --- /dev/null +++ b/ReplayAnalyser/ReplayAnalyserTab.xaml.cs @@ -0,0 +1,84 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using Spark.ReplayAnalyser.ViewModels; + +namespace Spark +{ + /// + /// The Replay Analyser, as a Spark tab: the standalone app's library, match breakdown, player + /// heatmaps, team shapes, coaching and career pages, over the same analysis code. + /// + public partial class ReplayAnalyserTab : UserControl + { + private ReplayAnalyserViewModel viewModel; + private int currentPage; + + public ReplayAnalyserTab() + { + InitializeComponent(); + } + + /// + /// The view model is made the first time the tab is shown rather than with the window, so + /// reading the index and the replay folder never adds to Spark's own startup. + /// + private async void OnLoaded(object sender, RoutedEventArgs e) + { + if (viewModel != null) return; + + viewModel = new ReplayAnalyserViewModel(); + viewModel.PropertyChanged += ViewModelPropertyChanged; + DataContext = viewModel; + + try + { + await viewModel.InitialiseAsync(); + } + catch (Exception ex) + { + Logger.LogRow(Logger.LogType.Error, $"Replay Analyser couldn't load the replay library.\n{ex}"); + viewModel.LibraryStatus = $"Couldn't load the replay library: {ex.Message}"; + } + } + + private void ViewModelPropertyChanged(object sender, PropertyChangedEventArgs e) + { + // Opening a replay from the Library should land on the match itself. + if (e.PropertyName == nameof(ReplayAnalyserViewModel.HasMatch) && viewModel.HasMatch && currentPage == 0) + { + GoToPage(1); + } + } + + private void OnPageChecked(object sender, RoutedEventArgs e) + { + // Fires for the initially checked page while the XAML is still loading, before Pages exists. + if (Pages == null) return; + if (sender is RadioButton { Tag: string tag } && int.TryParse(tag, out int index)) + { + ShowPage(index); + } + } + + /// Switch page as if its button had been clicked, so the navigation stays in step. + private void GoToPage(int index) + { + RadioButton button = Nav.Children.OfType().FirstOrDefault(b => (string)b.Tag == index.ToString()); + if (button != null) button.IsChecked = true; + } + + private void ShowPage(int index) + { + currentPage = index; + foreach (UIElement child in Pages.Children) + { + child.Visibility = child is FrameworkElement { Tag: string tag } && tag == index.ToString() + ? Visibility.Visible + : Visibility.Collapsed; + } + } + } +} diff --git a/ReplayAnalyser/Resources/arena-top.png b/ReplayAnalyser/Resources/arena-top.png new file mode 100644 index 0000000..f69ba00 Binary files /dev/null and b/ReplayAnalyser/Resources/arena-top.png differ diff --git a/ReplayAnalyser/ViewModels/CareerVm.cs b/ReplayAnalyser/ViewModels/CareerVm.cs new file mode 100644 index 0000000..0da6eef --- /dev/null +++ b/ReplayAnalyser/ViewModels/CareerVm.cs @@ -0,0 +1,80 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/ViewModels/CareerVm.cs). Unchanged apart from +// theme tones in place of brushes. +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using Spark.ReplayAnalyser.Analysis; +using Spark.ReplayAnalyser.Controls; + +namespace Spark.ReplayAnalyser.ViewModels; + +/// One measured habit, ready to bind. +public sealed class MetricVm +{ + public MetricVm(CareerMetric m) + { + Source = m; + VerdictTone = m.Edge switch + { + >= 0.12 => Tone.Good, + > -0.12 => Tone.Dim, + _ => Tone.Bad, + }; + TrendTone = m.TrendEdge switch + { + >= 0.12 => Tone.Good, + > -0.12 => Tone.Faint, + _ => Tone.Bad, + }; + } + + public CareerMetric Source { get; } + public string Name => Source.Name; + public string Description => Source.Description; + public string ValueText => Source.ValueText; + public string PeerText => Source.PeerText; + public double Edge => Source.Edge; + public string Verdict => Source.Verdict; + public string TrendText => Source.TrendText; + public Tone VerdictTone { get; } + public Tone TrendTone { get; } +} + +/// Display wrapper for a whole career profile. +public sealed class CareerVm +{ + public CareerVm(CareerProfile p) + { + Profile = p; + Metrics = p.Metrics.OrderByDescending(m => m.Edge).Select(m => new MetricVm(m)).ToList(); + Insights = p.Insights.Select(i => new InsightVm(i)).ToList(); + } + + public CareerProfile Profile { get; } + public string Player => Profile.Player; + public string Summary => Profile.Summary; + public IReadOnlyList Metrics { get; } + public IReadOnlyList Insights { get; } + + public string MatchesText => $"{Profile.Matches}"; + public string MinutesText => $"{Profile.LiveMinutes:F0}"; + public string PointsText => $"{Profile.GoalPoints}"; + public string GoalsText => $"{Profile.Goals}"; + public string AssistsText => $"{Profile.Assists}"; + public string SavesText => $"{Profile.Saves}"; + public string StunsText => $"{Profile.Stuns}"; + + public string MissedGrabsText => $"{Profile.MissedGrabs}"; + public string GrabChancesText => $"{Profile.GrabChances}"; + public string GrabRateText => Profile.GrabChances == 0 + ? "–" + : $"{1 - (double)Profile.MissedGrabs / Profile.GrabChances:P0}"; + public string CarriedIntoStunText => $"{Profile.CarriedIntoStun}"; + public string TurnoversAtHomeText => $"{Profile.TurnoversAtHome}"; + public string TurnoversText => $"{Profile.Turnovers}"; + + public string SpanText => Profile.FirstSeen.Year > 2000 + ? $"{Profile.FirstSeen:d MMM yyyy} – {Profile.LastSeen:d MMM yyyy}" + : ""; +} diff --git a/ReplayAnalyser/ViewModels/Infra.cs b/ReplayAnalyser/ViewModels/Infra.cs new file mode 100644 index 0000000..386fc75 --- /dev/null +++ b/ReplayAnalyser/ViewModels/Infra.cs @@ -0,0 +1,51 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/ViewModels/Infra.cs). Unchanged apart from +// the namespace and explicit usings. +#nullable enable + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Input; + +namespace Spark.ReplayAnalyser.ViewModels; + +public abstract class ObservableObject : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected void Raise([CallerMemberName] string? name = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + + protected bool Set(ref T field, T value, [CallerMemberName] string? name = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + Raise(name); + return true; + } +} + +public sealed class RelayCommand : ICommand +{ + private readonly Action _run; + private readonly Func? _can; + + public RelayCommand(Action run, Func? can = null) + { + _run = run; + _can = can; + } + + public RelayCommand(Action run, Func? can = null) + : this(_ => run(), can == null ? null : _ => can()) { } + + public event EventHandler? CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + + public bool CanExecute(object? parameter) => _can?.Invoke(parameter) ?? true; + public void Execute(object? parameter) => _run(parameter); +} diff --git a/ReplayAnalyser/ViewModels/MatchEntryVm.cs b/ReplayAnalyser/ViewModels/MatchEntryVm.cs new file mode 100644 index 0000000..18f2c79 --- /dev/null +++ b/ReplayAnalyser/ViewModels/MatchEntryVm.cs @@ -0,0 +1,116 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/ViewModels/MatchEntryVm.cs). Unchanged, plus +// the separate score and error fields the Spark library rows colour individually. +#nullable enable + +using System; +using System.IO; +using System.Linq; +using Spark.ReplayAnalyser.Model; + +namespace Spark.ReplayAnalyser.ViewModels; + +/// +/// A replay file in the library list. Starts as bare file metadata and gains a scoreline, +/// duration and roster once the folder has been indexed. +/// +public sealed class MatchEntryVm : ObservableObject +{ + private MatchSummary? _summary; + + public MatchEntryVm(FileInfo file, MatchSummary? summary) + { + File = file; + _summary = summary; + } + + public FileInfo File { get; } + public string FullPath => File.FullName; + public string FileName => File.Name; + public string Extension => File.Extension.TrimStart('.').ToLowerInvariant(); + public double SizeMb => File.Length / 1048576.0; + + public MatchSummary? Summary + { + get => _summary; + set + { + _summary = value; + Raise(nameof(Summary)); + Raise(nameof(IsIndexed)); + Raise(nameof(ScoreLine)); + Raise(nameof(HasScore)); + Raise(nameof(BlueScoreText)); + Raise(nameof(OrangeScoreText)); + Raise(nameof(SubTitle)); + Raise(nameof(Recorded)); + Raise(nameof(RosterLine)); + Raise(nameof(HasError)); + Raise(nameof(DurationText)); + } + } + + public bool IsIndexed => _summary != null; + public bool HasError => _summary is { Ok: false }; + + /// + /// Spark names its files rec_YYYY-MM-DD_HH-mm-ss, so the timestamp is readable before + /// the file has ever been opened. + /// + public DateTime Recorded + { + get + { + if (_summary is { Recorded.Year: > 2000 }) return _summary.Recorded; + var name = Path.GetFileNameWithoutExtension(File.Name); + int i = name.IndexOf("20", StringComparison.Ordinal); + if (i >= 0 && DateTime.TryParseExact( + name.Substring(i, Math.Min(19, name.Length - i)), + "yyyy-MM-dd_HH-mm-ss", null, System.Globalization.DateTimeStyles.None, out var d)) + return d; + return File.LastWriteTime; + } + } + + public string ScoreLine => _summary is { Ok: true } s ? $"{s.BlueScore} – {s.OrangeScore}" : "–"; + public bool HasScore => _summary is { Ok: true }; + public string BlueScoreText => _summary is { Ok: true } s ? s.BlueScore.ToString() : ""; + public string OrangeScoreText => _summary is { Ok: true } s ? s.OrangeScore.ToString() : ""; + + public string DurationText => _summary is { Ok: true } s + ? TimeSpan.FromSeconds(s.DurationSeconds).ToString(@"m\:ss") + : $"{SizeMb:F0} MB"; + + public string SubTitle + { + get + { + if (_summary == null) return $".{Extension} — not indexed yet"; + if (!_summary.Ok) return _summary.Error ?? "could not be read"; + string map = _summary.MapName switch + { + "mpl_arena_a" => "Arena", + "mpl_combat_dyson" => "Combat · Dyson", + "mpl_combat_combustion" => "Combat · Combustion", + "mpl_combat_fission" => "Combat · Fission", + "mpl_combat_gauss" => "Combat · Surge", + "mpl_lobby_b2" => "Lobby", + _ => string.IsNullOrEmpty(_summary.MapName) ? "unknown map" : _summary.MapName, + }; + string kind = _summary.PrivateMatch ? "private" : "public"; + return $"{map} · {kind} · {TimeSpan.FromSeconds(_summary.LiveSeconds):m\\:ss} live"; + } + } + + public string RosterLine => _summary is { Ok: true } s && s.Players.Count > 0 + ? string.Join(", ", s.Players.OrderByDescending(p => p.SecondsPlayed).Take(8).Select(p => p.Name)) + : ""; + + public bool Matches(string query) + { + if (string.IsNullOrWhiteSpace(query)) return true; + if (FileName.Contains(query, StringComparison.OrdinalIgnoreCase)) return true; + if (_summary == null) return false; + if (_summary.MapName.Contains(query, StringComparison.OrdinalIgnoreCase)) return true; + return _summary.Players.Any(p => p.Name.Contains(query, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/ReplayAnalyser/ViewModels/PlayerVm.cs b/ReplayAnalyser/ViewModels/PlayerVm.cs new file mode 100644 index 0000000..b1b1ab7 --- /dev/null +++ b/ReplayAnalyser/ViewModels/PlayerVm.cs @@ -0,0 +1,189 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/ViewModels/PlayerVm.cs). Figures and text are +// unchanged; colours are theme tones instead of brushes, so the views can follow Spark's theme. +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Spark.ReplayAnalyser.Analysis; +using Spark.ReplayAnalyser.Controls; +using Spark.ReplayAnalyser.Model; + +namespace Spark.ReplayAnalyser.ViewModels; + +/// Display wrapper around one player's analysis for the tables and detail panes. +public sealed class PlayerVm : ObservableObject +{ + public PlayerVm(PlayerAnalysis p, MatchAnalysis match) + { + Player = p; + Match = match; + } + + public PlayerAnalysis Player { get; } + public MatchAnalysis Match { get; } + + public string Name => Player.Name; + public TeamSide Side => Player.Side; + public bool IsBlue => Player.Side == TeamSide.Blue; + public string TeamName => IsBlue ? "BLUE" : "ORANGE"; + public Tone Tone => IsBlue ? Tone.Blue : Tone.Orange; + + private bool _isSelected; + /// Whether this is the player the detail pane is showing, for highlighting their row. + public bool IsSelected { get => _isSelected; set => Set(ref _isSelected, value); } + + public int Points => Player.GoalPoints; + public int PersonalScore => Player.Points; + public int Goals => Player.Goals; + public int Assists => Player.Assists; + public int Saves => Player.Saves; + public int Stuns => Player.Stuns; + /// Private matches report zero passes, so fall back to handovers we counted. + public int Passes => Player.Passes > 0 ? Player.Passes : Player.Handovers; + public int Catches => Player.Catches; + public int Steals => Player.Steals; + public int Blocks => Player.Blocks; + public int Interceptions => Player.Interceptions; + public int Turnovers => Player.Turnovers; + public int ShotsTaken => Player.ShotsTaken; + public int TimesStunned => Player.TimesStunned; + + public string MinutesText => $"{Player.SecondsPlayed / 60.0:F1}"; + public string PossessionText => $"{Player.PossessionTime:F0}s"; + public string HoldText => $"{Player.AveragePossessionSeconds:F1}s"; + public string AccuracyText => Player.ShotsTaken == 0 ? "–" : $"{Player.ShotAccuracy:P0}"; + public string DistanceText => $"{Player.DistanceTravelled:F0}m"; + public string SpeedText => $"{Player.AverageSpeed:F1}"; + public string PointsPerMinText => $"{Player.PointsPerMinute:F1}"; + public string DefShareText => $"{Player.DefensiveThirdShare:P0}"; + public string NeutralShareText => $"{Player.NeutralShare:P0}"; + public string OffShareText => $"{Player.OffensiveThirdShare:P0}"; + + public bool HasThrowData => Player.HasThrowTelemetry && Player.Throws.Count > 0; + public string ThrowSpeedText => HasThrowData ? $"{Player.AverageThrowSpeed:F1} m/s" : "not recorded"; + public string ThrowPeakText => HasThrowData ? $"{Player.MaxThrowSpeed:F1} m/s" : "–"; + public string ThrowCountText => HasThrowData ? $"{Player.Throws.Count}" : "–"; + public string WristPenaltyText => HasThrowData + ? $"{Player.Throws.Average(t => t.WristThrowPenalty):P0}" : "–"; + public string OffAxisText => HasThrowData + ? $"{Player.Throws.Average(t => t.OffAxisSpinDeg):F0}°" : "–"; + + /// This player's movement, in their team colour. + public IReadOnlyList Tracks => _tracks ??= new[] + { + new TrackLayer { Paths = Player.Tracks, Tone = Tone }, + }; + private IReadOnlyList? _tracks; + public IReadOnlyList ShotPositions => Player.ShotPositions; + public IReadOnlyList GoalPositions => Player.GoalPositions; + + public string PassTargetsText => Player.PassTargets.Count == 0 + ? "no completed handovers recorded" + : string.Join(" ", Player.PassTargets.OrderByDescending(kv => kv.Value) + .Select(kv => $"{kv.Key} ×{kv.Value}")); + + /// The scoreboard summary line used at the top of the detail pane. + public string HeadlineText => + $"{Points} pts · {Goals}G {Assists}A · {Saves} saves · {Stuns} stuns · {MinutesText} min live"; + + public string CompactStats => + $"{Points} pts {Goals}G {Assists}A {Saves} SV {Stuns} STN {Turnovers} TO {MinutesText} min"; +} + +/// One coaching insight, ready to bind. +public sealed class InsightVm +{ + public InsightVm(Insight i) + { + Source = i; + Tone = i.Kind switch + { + InsightKind.Strength => Tone.Good, + InsightKind.Weakness => Tone.Bad, + InsightKind.Improvement => Tone.Warn, + InsightKind.Counter => Tone.Accent, + _ => Tone.Dim, + }; + } + + public Insight Source { get; } + public string Kind => Source.Kind.ToString().ToUpperInvariant(); + public string Area => Source.Area.ToString(); + public string Title => Source.Title; + public string Detail => Source.Detail; + public string? Action => Source.Action; + public string? Evidence => Source.Evidence; + public bool HasAction => !string.IsNullOrEmpty(Source.Action); + public bool HasEvidence => !string.IsNullOrEmpty(Source.Evidence); + public Tone Tone { get; } +} + +/// One row in the goal timeline. +public sealed class GoalVm +{ + public GoalVm(GoalRecord g) + { + Source = g; + Tone = g.Side == TeamSide.Blue ? Tone.Blue : Tone.Orange; + } + + public GoalRecord Source { get; } + public string TimeText => Source.Time.ToString(@"m\:ss"); + public string Scorer => Source.Scorer; + public string Team => Source.Side == TeamSide.Blue ? "BLUE" : "ORANGE"; + public string PointsText => $"{Source.Points}pt"; + public string TypeText => Source.GoalType; + public string AssistText => string.IsNullOrEmpty(Source.Assist) ? "" : $"assist {Source.Assist}"; + public string SpeedText => $"{Source.DiscSpeed:F1} m/s"; + public string DistanceText => $"{Source.DistanceThrown:F1} m"; + public string ScoreText => $"{Source.BlueScore}–{Source.OrangeScore}"; + public Tone Tone { get; } + + /// Everything under the scorer's name on one line, skipping whatever wasn't recorded. + public string DetailText => string.Join(" · ", + new[] { TypeText, PointsText, AssistText, DistanceText }.Where(s => !string.IsNullOrWhiteSpace(s))); +} + +/// +/// A whole-roster entry: every player in the lobby gets one, so a team can read its own +/// review without clicking through the roster one name at a time. +/// +public sealed class PlayerCardVm +{ + public PlayerCardVm(PlayerVm player, PlayerReport report) + { + Player = player; + Summary = report.Summary; + Rating = report.OverallRating; + + // Lead with what they did well, then the single clearest thing to work on. + Highlights = report.Insights + .Where(i => i.Kind == InsightKind.Strength) + .OrderByDescending(i => i.Confidence).Take(2) + .Select(i => new InsightVm(i)).ToList(); + Fixes = report.Insights + .Where(i => i.Kind is InsightKind.Weakness or InsightKind.Improvement) + .OrderByDescending(i => i.Confidence).Take(2) + .Select(i => new InsightVm(i)).ToList(); + + var best = report.AreaScores.OrderByDescending(kv => kv.Value).FirstOrDefault(); + var worst = report.AreaScores.OrderBy(kv => kv.Value).FirstOrDefault(); + BestArea = report.AreaScores.Count == 0 ? "" : best.Key.ToString(); + WorstArea = report.AreaScores.Count == 0 ? "" : worst.Key.ToString(); + } + + public PlayerVm Player { get; } + public string Name => Player.Name; + public Tone Tone => Player.Tone; + public string CompactStats => Player.CompactStats; + public string Summary { get; } + public double Rating { get; } + public string RatingText => $"{Rating:F0}"; + public string BestArea { get; } + public string WorstArea { get; } + public bool HasBestArea => BestArea.Length > 0; + public IReadOnlyList Highlights { get; } + public IReadOnlyList Fixes { get; } + public bool HasAnything => Highlights.Count > 0 || Fixes.Count > 0; +} diff --git a/ReplayAnalyser/ViewModels/ReplayAnalyserViewModel.cs b/ReplayAnalyser/ViewModels/ReplayAnalyserViewModel.cs new file mode 100644 index 0000000..3bc7459 --- /dev/null +++ b/ReplayAnalyser/ViewModels/ReplayAnalyserViewModel.cs @@ -0,0 +1,1062 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/ViewModels/MainViewModel.cs). The analysis, +// indexing, coaching, career and trend logic is unchanged. What differs for Spark: +// - startup work (loading the index, scanning the folder) happens when the tab is first opened, +// and off the UI thread, rather than while Spark itself is starting; +// - the replay folder defaults to Spark's own save folder, and a folder picked here is remembered; +// - charts carry theme tones instead of fixed brushes. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Data; +using System.Windows.Media; +using Spark.ReplayAnalyser.Analysis; +using Spark.ReplayAnalyser.Controls; +using Spark.ReplayAnalyser.Model; +using Spark.ReplayAnalyser.Parsing; + +namespace Spark.ReplayAnalyser.ViewModels; + +public sealed class ReplayAnalyserViewModel : ObservableObject +{ + private readonly LibraryIndex _index = new LibraryIndex(); + private CancellationTokenSource? _indexCts; + private CancellationTokenSource? _loadCts; + private bool _initialised; + private int _scanGeneration; + + public ReplayAnalyserViewModel() + { + _files = new ObservableCollection(); + _filesView = MakeView(_files); + + BrowseCommand = new RelayCommand(Browse, () => IsReady); + RefreshCommand = new RelayCommand(() => { RememberFolder(FolderPath); _ = ScanFolderAsync(FolderPath); }, () => IsReady); + IndexCommand = new RelayCommand(() => _ = IndexFolderAsync(), () => IsReady && !IsIndexing); + CancelIndexCommand = new RelayCommand(() => _indexCts?.Cancel(), () => IsIndexing); + OpenCommand = new RelayCommand(o => { if (o is MatchEntryVm e) _ = LoadMatchAsync(e); }); + + FolderPath = DefaultFolder(); + LibraryStatus = "Loading the replay library…"; + } + + /// + /// Load the cached index and read the folder. Kept out of the constructor so none of it runs + /// until someone actually opens the tab. + /// + public async Task InitialiseAsync() + { + if (_initialised) return; + _initialised = true; + + var loaded = await Task.Run(() => LibraryIndex.Load()); + lock (_index) _index.Entries = loaded.Entries; + IsReady = true; + + await ScanFolderAsync(FolderPath); + RefreshCareerPlayers(); + RefreshCoachRoster(); + } + + private bool _isReady; + public bool IsReady + { + get => _isReady; + private set { if (Set(ref _isReady, value)) CommandManager_Refresh(); } + } + + // ------------------------------------------------------------------ library + + private ObservableCollection _files; + public ObservableCollection Files { get => _files; private set => Set(ref _files, value); } + + private ICollectionView _filesView; + public ICollectionView FilesView { get => _filesView; private set => Set(ref _filesView, value); } + + private ICollectionView MakeView(ObservableCollection files) + { + var view = new ListCollectionView(files); + view.Filter = o => o is MatchEntryVm e && e.Matches(SearchText); + return view; + } + + private string _folderPath = ""; + public string FolderPath { get => _folderPath; set => Set(ref _folderPath, value); } + + private string _searchText = ""; + public string SearchText + { + get => _searchText; + set { if (Set(ref _searchText, value)) FilesView.Refresh(); } + } + + private MatchEntryVm? _selectedEntry; + public MatchEntryVm? SelectedEntry + { + get => _selectedEntry; + set { if (Set(ref _selectedEntry, value) && value != null) _ = LoadMatchAsync(value); } + } + + private string _libraryStatus = ""; + public string LibraryStatus { get => _libraryStatus; set => Set(ref _libraryStatus, value); } + + private bool _isIndexing; + public bool IsIndexing + { + get => _isIndexing; + set { if (Set(ref _isIndexing, value)) { Raise(nameof(NotIndexing)); CommandManager_Refresh(); } } + } + public bool NotIndexing => !_isIndexing; + + private double _indexProgress; + public double IndexProgress { get => _indexProgress; set => Set(ref _indexProgress, value); } + + public RelayCommand BrowseCommand { get; } + public RelayCommand RefreshCommand { get; } + public RelayCommand IndexCommand { get; } + public RelayCommand CancelIndexCommand { get; } + public RelayCommand OpenCommand { get; } + + private static void CommandManager_Refresh() => + System.Windows.Input.CommandManager.InvalidateRequerySuggested(); + + /// + /// The folder picked here last time, else wherever Spark saves its recordings — which is + /// where the replays will be for almost everyone. + /// + private static string DefaultFolder() + { + string picked = SparkSettings.instance?.replayAnalyserFolder ?? ""; + if (!string.IsNullOrWhiteSpace(picked) && Directory.Exists(picked)) return picked; + + string saves = SparkSettings.instance?.saveFolder ?? ""; + if (!string.IsNullOrWhiteSpace(saves) && saves != "none" && Directory.Exists(saves)) return saves; + + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Spark", "replays"); + } + + /// + /// Remember a folder chosen on purpose. Spark's own save folder is stored as "no choice", so + /// the library keeps following it if the save folder is moved later. + /// + private static void RememberFolder(string folder) + { + if (SparkSettings.instance == null || !Directory.Exists(folder)) return; + string value = string.Equals(Path.GetFullPath(folder).TrimEnd('\\'), + SafeFullPath(SparkSettings.instance.saveFolder), StringComparison.OrdinalIgnoreCase) + ? "" + : folder; + if (SparkSettings.instance.replayAnalyserFolder == value) return; + SparkSettings.instance.replayAnalyserFolder = value; + SparkSettings.instance.Save(); + } + + private static string SafeFullPath(string path) + { + try { return string.IsNullOrWhiteSpace(path) || path == "none" ? "" : Path.GetFullPath(path).TrimEnd('\\'); } + catch { return ""; } + } + + private void Browse() + { + var dlg = new Microsoft.Win32.OpenFolderDialog + { + Title = "Choose the folder holding your replays", + InitialDirectory = Directory.Exists(FolderPath) ? FolderPath : "", + }; + if (dlg.ShowDialog() == true) + { + FolderPath = dlg.FolderName; + RememberFolder(FolderPath); + _ = ScanFolderAsync(FolderPath); + } + } + + private async Task ScanFolderAsync(string folder) + { + int generation = ++_scanGeneration; + string? selectedPath = _selectedEntry?.FullPath; + + if (!Directory.Exists(folder)) + { + ReplaceFiles(new List(), null); + LibraryStatus = "That folder does not exist."; + return; + } + + List entries; + long bytes; + try + { + // Listing a big folder and statting every file is slow enough to hitch the window. + (entries, bytes) = await Task.Run(() => + { + var found = new[] { "*.butter", "*.echoreplay", "*.tape" } + .SelectMany(p => Directory.EnumerateFiles(folder, p)) + .Select(f => new FileInfo(f)) + .Where(f => f.Length > 2000) // clip pointers and empty captures + .OrderByDescending(f => f.LastWriteTimeUtc) + .ToList(); + + List list; + lock (_index) + list = found.Select(f => new MatchEntryVm(f, _index.IsCurrent(f) ? _index.Get(f.FullName) : null)).ToList(); + return (list, found.Sum(f => f.Length)); + }); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + if (generation == _scanGeneration) LibraryStatus = $"Could not read that folder: {ex.Message}"; + return; + } + + // A newer scan started while this one was reading; its result wins. + if (generation != _scanGeneration) return; + + ReplaceFiles(entries, selectedPath); + int indexed = entries.Count(f => f.IsIndexed); + LibraryStatus = entries.Count == 0 + ? "No replay files in this folder." + : $"{entries.Count} replays · {indexed} indexed · {bytes / 1073741824.0:F1} GB"; + } + + /// + /// Swap in a whole new list at once — adding thousands of rows one at a time through a + /// filtered view is what makes a big library slow to show. The open match stays selected. + /// + private void ReplaceFiles(List entries, string? selectedPath) + { + Files = new ObservableCollection(entries); + FilesView = MakeView(Files); + + _selectedEntry = selectedPath == null + ? null + : entries.FirstOrDefault(e => string.Equals(e.FullPath, selectedPath, StringComparison.OrdinalIgnoreCase)); + Raise(nameof(SelectedEntry)); + } + + /// + /// Parse and analyse every un-indexed file in the background so the library list can show + /// scores and rosters. Parsing is the slow part, so this runs on all but one core. + /// + private async Task IndexFolderAsync() + { + if (IsIndexing) return; + _indexCts = new CancellationTokenSource(); + var ct = _indexCts.Token; + IsIndexing = true; + IndexProgress = 0; + + var todo = Files.Where(f => !f.IsIndexed).ToList(); + int total = todo.Count, done = 0; + if (total == 0) + { + LibraryStatus = "Everything in this folder is already indexed."; + IsIndexing = false; + return; + } + + try + { + await Task.Run(() => + { + var opts = new ParallelOptions + { + CancellationToken = ct, + MaxDegreeOfParallelism = IndexingLimits.MaxParallelism, + }; + Parallel.ForEach(todo, opts, entry => + { + MatchSummary summary; + try + { + var read = ReplayLoader.Load(entry.FullPath); + var m = MatchAnalyser.Analyse(read, ct); + summary = MatchSummary.From(m, entry.File); + if (!m.AllPlayers.Any() && m.FrameCount == 0) + summary.Error = read.Diagnostics.FirstOrDefault() ?? "no frames decoded"; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + summary = new MatchSummary + { + FilePath = entry.FullPath, + FileSize = entry.File.Length, + FileStamp = entry.File.LastWriteTimeUtc.Ticks, + Error = ex.Message, + }; + } + + lock (_index) _index.Put(summary); + int n = Interlocked.Increment(ref done); + Application.Current?.Dispatcher.BeginInvoke(() => + { + entry.Summary = summary; + IndexProgress = n * 100.0 / total; + LibraryStatus = $"Indexing… {n}/{total}"; + }); + }); + }, ct); + + LibraryStatus = $"Indexed {done} replays."; + } + catch (OperationCanceledException) + { + LibraryStatus = $"Indexing stopped after {done} of {total}."; + } + finally + { + lock (_index) { try { _index.Save(); } catch { /* cache is best-effort */ } } + IsIndexing = false; + IndexProgress = 0; + RebuildTrends(); + RefreshCareerPlayers(); + RefreshCoachRoster(); + } + } + + // ------------------------------------------------------------------ current match + + private bool _isLoading; + public bool IsLoading { get => _isLoading; set => Set(ref _isLoading, value); } + + private string _loadStatus = ""; + public string LoadStatus { get => _loadStatus; set => Set(ref _loadStatus, value); } + + private MatchAnalysis? _match; + public MatchAnalysis? Match + { + get => _match; + private set + { + Set(ref _match, value); + Raise(nameof(HasMatch)); + Raise(nameof(CoachingReady)); + Raise(nameof(MatchTitle)); + Raise(nameof(MatchSubtitle)); + Raise(nameof(BlueScoreText)); + Raise(nameof(OrangeScoreText)); + Raise(nameof(MatchTracks)); + Raise(nameof(ScoreSeries)); + Raise(nameof(PossessionSeries)); + Raise(nameof(TerritorySeries)); + Raise(nameof(GoalMarkers)); + } + } + + public bool HasMatch => _match != null; + + public string MatchTitle => _match == null ? "" : _match.FileName; + public string MatchSubtitle + { + get + { + if (_match is not { } m) return ""; + string map = m.IsArena ? "Echo Arena" : m.MapName; + return $"{map} · {m.StartTime:ddd d MMM yyyy, HH:mm} · {m.Duration:m\\:ss} recorded · " + + $"{m.LiveTime:m\\:ss} live play · {m.FrameCount:N0} frames @ {m.SampleRateHz:F0} Hz"; + } + } + + public string BlueScoreText => _match?.BlueScore.ToString() ?? "–"; + public string OrangeScoreText => _match?.OrangeScore.ToString() ?? "–"; + + public ObservableCollection Players { get; } = new ObservableCollection(); + public ObservableCollection BluePlayers { get; } = new ObservableCollection(); + public ObservableCollection OrangePlayers { get; } = new ObservableCollection(); + public ObservableCollection Goals { get; } = new ObservableCollection(); + + private PlayerVm? _selectedPlayer; + public PlayerVm? SelectedPlayer + { + get => _selectedPlayer; + set + { + var previous = _selectedPlayer; + if (!Set(ref _selectedPlayer, value)) return; + if (previous != null) previous.IsSelected = false; + if (value != null) value.IsSelected = true; + Raise(nameof(HasSelectedPlayer)); + RebuildSelfReport(); + RebuildTrends(); + } + } + public bool HasSelectedPlayer => _selectedPlayer != null; + + private PlayerVm? _selectedOpponent; + public PlayerVm? SelectedOpponent + { + get => _selectedOpponent; + set + { + if (!Set(ref _selectedOpponent, value)) return; + Raise(nameof(HasSelectedOpponent)); + RebuildScoutReport(); + } + } + public bool HasSelectedOpponent => _selectedOpponent != null; + + public async Task LoadMatchAsync(MatchEntryVm entry) + { + _loadCts?.Cancel(); + _loadCts = new CancellationTokenSource(); + var ct = _loadCts.Token; + + IsLoading = true; + LoadStatus = $"Reading {entry.FileName}…"; + try + { + var analysis = await Task.Run(() => + { + var read = ReplayLoader.Load(entry.FullPath); + ct.ThrowIfCancellationRequested(); + return MatchAnalyser.Analyse(read, ct); + }, ct); + + if (ct.IsCancellationRequested) return; + ApplyMatch(analysis, entry); + LoadStatus = analysis.Diagnostics.Count > 0 + ? string.Join(" · ", analysis.Diagnostics.Take(2)) + : ""; + } + catch (OperationCanceledException) { /* superseded by a newer selection */ } + catch (Exception ex) + { + Match = null; + LoadStatus = $"Could not read this replay: {ex.Message}"; + } + finally + { + if (!ct.IsCancellationRequested) IsLoading = false; + } + } + + private void ApplyMatch(MatchAnalysis m, MatchEntryVm entry) + { + Match = m; + + Players.Clear(); BluePlayers.Clear(); OrangePlayers.Clear(); Goals.Clear(); + + foreach (var p in m.Blue.Players.OrderByDescending(p => p.Points).ThenByDescending(p => p.SecondsPlayed)) + { var vm = new PlayerVm(p, m); Players.Add(vm); BluePlayers.Add(vm); } + foreach (var p in m.Orange.Players.OrderByDescending(p => p.Points).ThenByDescending(p => p.SecondsPlayed)) + { var vm = new PlayerVm(p, m); Players.Add(vm); OrangePlayers.Add(vm); } + + foreach (var g in m.Goals) Goals.Add(new GoalVm(g)); + + // Default to the player whose client recorded the replay — that is almost always the + // person sitting in front of the app. + var self = (m.RecordingPlayer != null + ? Players.FirstOrDefault(p => p.Name == m.RecordingPlayer) + : null) + ?? Players.OrderByDescending(p => p.Player.SecondsPlayed).FirstOrDefault(); + SelectedPlayer = self; + SelectedOpponent = Players + .Where(p => self == null || p.Side != self.Side) + .OrderByDescending(p => p.Points) + .ThenByDescending(p => p.Player.SecondsPlayed) + .FirstOrDefault(); + + RebuildTeamCompare(); + RebuildTeamInsights(); + RebuildLobbyCards(); + + if (entry.Summary == null || !entry.Summary.Ok) + { + var summary = MatchSummary.From(m, entry.File); + entry.Summary = summary; + lock (_index) { _index.Put(summary); try { _index.Save(); } catch { } } + } + } + + // ------------------------------------------------------------------ charts + + /// + /// Both teams plus the disc on one map, which is the view that shows how the game was + /// actually played: where each side lived, and the lanes the disc travelled between them. + /// + public IReadOnlyList MatchTracks + { + get + { + if (_match is not { } m) return Array.Empty(); + var layers = new List + { + new TrackLayer + { + Paths = m.Blue.Players.SelectMany(p => p.Tracks).ToList(), + Tone = Tone.Blue, + Weight = 0.85, + }, + new TrackLayer + { + Paths = m.Orange.Players.SelectMany(p => p.Tracks).ToList(), + Tone = Tone.Orange, + Weight = 0.85, + }, + new TrackLayer + { + Paths = m.DiscTracks, + Color = Color.FromRgb(0xE8, 0xF6, 0xFF), + Weight = 1.5, + }, + }; + return layers; + } + } + + public IReadOnlyList ScoreSeries => _match == null + ? Array.Empty() + : new[] + { + new ChartSeries + { + Name = "Blue", Tone = Tone.Blue, Stepped = true, Thickness = 2, FillOpacity = 0.12, + Points = _match.BlueScoreSeries.Select(p => (p.Seconds, p.Value)).ToList(), + }, + new ChartSeries + { + Name = "Orange", Tone = Tone.Orange, Stepped = true, Thickness = 2, FillOpacity = 0.12, + Points = _match.OrangeScoreSeries.Select(p => (p.Seconds, p.Value)).ToList(), + }, + }; + + public IReadOnlyList PossessionSeries => _match == null + ? Array.Empty() + : new[] + { + new ChartSeries + { + Name = "Blue share of possession", Tone = Tone.Blue, Thickness = 2, FillOpacity = 0.14, + Points = _match.PossessionSeries.Select(p => (p.Seconds, p.Value * 100)).ToList(), + }, + }; + + public IReadOnlyList TerritorySeries => _match == null + ? Array.Empty() + : new[] + { + new ChartSeries + { + Name = "Disc position along the arena", Tone = Tone.Accent, Thickness = 1.4, + Points = _match.DiscFieldPosition.Select(p => (p.Seconds, p.Value)).ToList(), + }, + }; + + public IReadOnlyList GoalMarkers => _match == null + ? Array.Empty() + : _match.Goals.Select(g => new ChartMarker + { + X = g.Time.TotalSeconds, + Tone = g.Side == TeamSide.Blue ? Tone.Blue : Tone.Orange, + Label = g.Scorer, + Tooltip = $"{g.Scorer} {g.Points}pt", + }).ToList(); + + // ------------------------------------------------------------------ team comparison + + public ObservableCollection TeamCompare { get; } = new ObservableCollection(); + + public sealed record CompareRow(string Label, double Blue, double Orange, string Format); + + private void RebuildTeamCompare() + { + TeamCompare.Clear(); + if (_match is not { } m) return; + var b = m.Blue; var o = m.Orange; + + void Row(string label, double blue, double orange, string fmt = "0.#") => + TeamCompare.Add(new CompareRow(label, blue, orange, fmt)); + + Row("Score", m.BlueScore, m.OrangeScore, "0"); + Row("Goals", b.Goals, o.Goals, "0"); + Row("Shots taken", b.ShotsTaken, o.ShotsTaken, "0"); + Row("Shot accuracy %", b.ShotAccuracy * 100, o.ShotAccuracy * 100, "0"); + Row("Possession %", b.PossessionShare * 100, o.PossessionShare * 100, "0"); + Row("Saves", b.Saves, o.Saves, "0"); + Row("Stuns", b.Stuns, o.Stuns, "0"); + Row("Turnovers", b.Turnovers, o.Turnovers, "0"); + Row("Team spread (m)", b.AverageSpread, o.AverageSpread, "0.0"); + Row("Time with nobody back %", b.DefendersBackShare[0] * 100, o.DefendersBackShare[0] * 100, "0"); + Row("Distance skated (m)", b.Players.Sum(p => p.DistanceTravelled), o.Players.Sum(p => p.DistanceTravelled), "0"); + } + + // ------------------------------------------------------------------ coaching + + public ObservableCollection SelfInsights { get; } = new ObservableCollection(); + public ObservableCollection ScoutInsights { get; } = new ObservableCollection(); + + /// Shape notes for each side, so a full team can read both halves of the game. + public ObservableCollection BlueTeamInsights { get; } = new ObservableCollection(); + public ObservableCollection OrangeTeamInsights { get; } = new ObservableCollection(); + + /// Every player in the lobby, reviewed — no clicking through the roster. + public ObservableCollection BlueCards { get; } = new ObservableCollection(); + public ObservableCollection OrangeCards { get; } = new ObservableCollection(); + + private string _selfSummary = ""; + public string SelfSummary { get => _selfSummary; set => Set(ref _selfSummary, value); } + + private string _scoutSummary = ""; + public string ScoutSummary { get => _scoutSummary; set => Set(ref _scoutSummary, value); } + + private IReadOnlyList _selfRadar = Array.Empty(); + public IReadOnlyList SelfRadar { get => _selfRadar; set => Set(ref _selfRadar, value); } + + private IReadOnlyList _scoutRadar = Array.Empty(); + public IReadOnlyList ScoutRadar { get => _scoutRadar; set => Set(ref _scoutRadar, value); } + + private PlayerReport? _selfReport, _scoutReport; + + // ---- scope: this one match, or everything indexed ---- + + /// + /// When true the coaching report is built from the whole indexed library instead of the + /// loaded replay, so a plan can be made against somebody before you ever load a game + /// they are in. + /// + private bool _coachAllTime; + public bool CoachAllTime + { + get => _coachAllTime; + set + { + if (!Set(ref _coachAllTime, value)) return; + Raise(nameof(CoachMatchScope)); + Raise(nameof(CoachingReady)); + RebuildSelfReport(); + RebuildScoutReport(); + } + } + public bool CoachMatchScope => !_coachAllTime; + + /// Anyone who appears in the index, most-played first. + public ObservableCollection CoachRoster { get; } = new ObservableCollection(); + + private string? _coachSelfName; + public string? CoachSelfName + { + get => _coachSelfName; + set + { + if (!Set(ref _coachSelfName, value)) return; + RebuildSelfReport(); + // The head-to-head record depends on who is asking, so the scout follows. + if (_coachAllTime) RebuildScoutReport(); + } + } + + private string? _coachOpponentName; + public string? CoachOpponentName + { + get => _coachOpponentName; + set { if (Set(ref _coachOpponentName, value)) RebuildScoutReport(); } + } + + /// All-time coaching needs an index; match coaching needs a loaded replay. + public bool CoachingReady => _coachAllTime ? CoachRoster.Count > 0 : HasMatch; + + private string _coachScopeNote = ""; + public string CoachScopeNote { get => _coachScopeNote; set => Set(ref _coachScopeNote, value); } + + private CareerProfile? _careerSelf; + private CareerScout? _careerScout; + + /// + /// Everyone in the index, not just those with enough footage for a career verdict — you + /// often want a read on somebody you have only met once, and the report says when the + /// evidence is thin rather than hiding them. + /// + private void RefreshCoachRoster() + { + string? prevSelf = _coachSelfName, prevOpp = _coachOpponentName; + CoachRoster.Clear(); + + List names; + string? recorder; + lock (_index) + { + names = CareerAnalyser.KnownPlayers(_index, minMatches: 1).ToList(); + recorder = _index.Entries.Values + .Where(e => e.Ok && e.RecordingPlayer != null) + .GroupBy(e => e.RecordingPlayer!) + .OrderByDescending(g => g.Count()) + .Select(g => g.Key) + .FirstOrDefault(); + } + foreach (var n in names) CoachRoster.Add(n); + + _coachSelfName = prevSelf != null && names.Contains(prevSelf) ? prevSelf + : recorder != null && names.Contains(recorder) ? recorder + : names.FirstOrDefault(); + _coachOpponentName = prevOpp != null && names.Contains(prevOpp) ? prevOpp + : names.FirstOrDefault(n => n != _coachSelfName); + + Raise(nameof(CoachSelfName)); + Raise(nameof(CoachOpponentName)); + Raise(nameof(CoachingReady)); + + if (_coachAllTime) { RebuildSelfReport(); RebuildScoutReport(); } + } + + private void RebuildSelfReport() + { + SelfInsights.Clear(); + SelfSummary = ""; + _selfReport = null; + _careerSelf = null; + + if (_coachAllTime) + { + if (!string.IsNullOrEmpty(_coachSelfName)) + { + lock (_index) _careerSelf = CareerAnalyser.Build(_index, _coachSelfName); + if (_careerSelf != null) + { + foreach (var i in _careerSelf.Insights) SelfInsights.Add(new InsightVm(i)); + SelfSummary = _careerSelf.Summary; + } + else + { + SelfSummary = $"No indexed match has {_coachSelfName} playing long enough to judge."; + } + } + UpdateScopeNote(); + } + else if (_match is { } m && _selectedPlayer is { } p) + { + _selfReport = InsightEngine.BuildReport(m, p.Player, asOpponent: false); + foreach (var i in _selfReport.Insights) SelfInsights.Add(new InsightVm(i)); + SelfSummary = _selfReport.Summary; + } + RebuildRadars(); + } + + private void RebuildScoutReport() + { + ScoutInsights.Clear(); + ScoutSummary = ""; + _scoutReport = null; + _careerScout = null; + + if (_coachAllTime) + { + if (!string.IsNullOrEmpty(_coachOpponentName)) + { + lock (_index) + _careerScout = ScoutingAnalyser.Build(_index, _coachOpponentName, _coachSelfName); + if (_careerScout != null) + { + foreach (var i in _careerScout.Insights) ScoutInsights.Add(new InsightVm(i)); + ScoutSummary = _careerScout.Summary; + } + else + { + ScoutSummary = $"No indexed match has {_coachOpponentName} playing long enough to scout."; + } + } + UpdateScopeNote(); + } + else if (_match is { } m && _selectedOpponent is { } p) + { + _scoutReport = InsightEngine.BuildReport(m, p.Player, asOpponent: true); + foreach (var i in _scoutReport.Insights) ScoutInsights.Add(new InsightVm(i)); + ScoutSummary = _scoutReport.Summary; + } + RebuildRadars(); + } + + private void UpdateScopeNote() + { + int matches = _careerScout?.Matches ?? _careerSelf?.Matches ?? 0; + CoachScopeNote = matches == 0 + ? "Index your replay folder on the Library page to build all-time reports." + : $"Built from every indexed replay — {_careerSelf?.Matches ?? 0} with " + + $"{_coachSelfName}, {_careerScout?.Matches ?? 0} with {_coachOpponentName}."; + } + + private void RebuildTeamInsights() + { + BlueTeamInsights.Clear(); + OrangeTeamInsights.Clear(); + BlueMatchup.Clear(); + OrangeMatchup.Clear(); + BlueTeam = null; + OrangeTeam = null; + if (_match is not { } m) return; + + foreach (var i in InsightEngine.BuildTeamInsights(m, TeamSide.Blue)) + BlueTeamInsights.Add(new InsightVm(i)); + foreach (var i in InsightEngine.BuildTeamInsights(m, TeamSide.Orange)) + OrangeTeamInsights.Add(new InsightVm(i)); + + var (blue, orange) = TeamAnalyser.BuildBoth(m); + BlueTeam = new TeamVm(blue); + OrangeTeam = new TeamVm(orange); + + // Each list answers "what did the other side do to us", which is the half a team + // cannot see from its own stat line. + foreach (var i in TeamAnalyser.BuildMatchup(m, blue, orange)) BlueMatchup.Add(new InsightVm(i)); + foreach (var i in TeamAnalyser.BuildMatchup(m, orange, blue)) OrangeMatchup.Add(new InsightVm(i)); + } + + private TeamVm? _blueTeam; + public TeamVm? BlueTeam + { + get => _blueTeam; + private set { Set(ref _blueTeam, value); Raise(nameof(HasTeams)); } + } + + private TeamVm? _orangeTeam; + public TeamVm? OrangeTeam { get => _orangeTeam; private set => Set(ref _orangeTeam, value); } + + public bool HasTeams => _blueTeam != null; + + /// What orange did to blue, and vice versa. + public ObservableCollection BlueMatchup { get; } = new ObservableCollection(); + public ObservableCollection OrangeMatchup { get; } = new ObservableCollection(); + + /// + /// Review every player once, up front. The rules are cheap — they run off numbers the + /// frame walk already produced — so there is no reason to make a team click each name + /// in turn to find out what the analysis says about them. + /// + private void RebuildLobbyCards() + { + BlueCards.Clear(); + OrangeCards.Clear(); + if (_match is not { } m) return; + + foreach (var vm in Players) + { + if (vm.Player.SecondsPlayed < 20) continue; // spectators and brief visitors + var report = InsightEngine.BuildReport(m, vm.Player, asOpponent: false); + var card = new PlayerCardVm(vm, report); + (vm.IsBlue ? BlueCards : OrangeCards).Add(card); + } + } + + private static readonly InsightArea[] RadarOrder = + { + InsightArea.Finishing, InsightArea.Possession, InsightArea.Passing, + InsightArea.Defence, InsightArea.Physicality, InsightArea.Positioning, + InsightArea.Movement, InsightArea.Discipline, InsightArea.Mechanics, + }; + + /// + /// Build both radars off the same axes. Overlaying two polygons only means anything if + /// their spokes line up, so when the two players do not share an area — throw mechanics + /// exist only for the recording client — that spoke is dropped from both. + /// + /// + /// Career reports rate a player by how far they sit from the room rather than by a + /// percentile within one lobby, so the radar plots that edge: 50 is level with the people + /// they play against, 100 is comfortably ahead. + /// + private static readonly string[] CareerRadarOrder = + { + "Scoring", "Finishing", "Playmaking", "Distribution", + "Saves", "Physicality", "Hands", "Disc security", "Work rate", + }; + + private static IReadOnlyList CareerRadar( + IReadOnlyList? metrics, IReadOnlyList axes) + { + if (metrics == null) return Array.Empty(); + var list = new List(axes.Count); + foreach (var name in axes) + { + var m = metrics.FirstOrDefault(x => x.Name == name); + double value = m == null ? 50 : Math.Clamp(50 + m.Edge * 40, 0, 100); + list.Add(new RadarAxis(name, value)); + } + return list; + } + + private void RebuildRadars() + { + if (_coachAllTime) + { + var axes = CareerRadarOrder.Where(n => + (_careerSelf?.Metrics.Any(m => m.Name == n) ?? false) || + (_careerScout?.Metrics.Any(m => m.Name == n) ?? false)).ToList(); + + SelfRadar = CareerRadar(_careerSelf?.Metrics, axes); + ScoutRadar = CareerRadar(_careerScout?.Metrics, axes); + return; + } + + if (_selfReport == null && _scoutReport == null) + { + SelfRadar = Array.Empty(); + ScoutRadar = Array.Empty(); + return; + } + + var areas = RadarOrder.Where(a => + (_selfReport?.AreaScores.ContainsKey(a) ?? true) && + (_scoutReport?.AreaScores.ContainsKey(a) ?? true)).ToList(); + + SelfRadar = _selfReport == null + ? Array.Empty() + : areas.Select(a => new RadarAxis(Pretty(a), _selfReport.AreaScores[a])).ToList(); + ScoutRadar = _scoutReport == null + ? Array.Empty() + : areas.Select(a => new RadarAxis(Pretty(a), _scoutReport.AreaScores[a])).ToList(); + } + + private static string Pretty(InsightArea a) => a switch + { + InsightArea.TeamShape => "Team shape", + _ => a.ToString(), + }; + + // ------------------------------------------------------------------ all-time career + + public ObservableCollection CareerPlayers { get; } = new ObservableCollection(); + + private string? _selectedCareerPlayer; + public string? SelectedCareerPlayer + { + get => _selectedCareerPlayer; + set { if (Set(ref _selectedCareerPlayer, value)) RebuildCareer(); } + } + + private CareerVm? _career; + public CareerVm? Career + { + get => _career; + private set { Set(ref _career, value); Raise(nameof(HasCareer)); } + } + public bool HasCareer => _career != null; + + private string _careerStatus = ""; + public string CareerStatus { get => _careerStatus; set => Set(ref _careerStatus, value); } + + /// + /// Refresh the list of players we have enough footage on. Whoever recorded the most + /// replays is almost certainly the person using the app, so they are pre-selected. + /// + private void RefreshCareerPlayers() + { + string? previous = _selectedCareerPlayer; + CareerPlayers.Clear(); + + List names; + string? recorder; + lock (_index) + { + names = CareerAnalyser.KnownPlayers(_index).ToList(); + recorder = _index.Entries.Values + .Where(e => e.Ok && e.RecordingPlayer != null) + .GroupBy(e => e.RecordingPlayer!) + .OrderByDescending(g => g.Count()) + .Select(g => g.Key) + .FirstOrDefault(); + } + + foreach (var n in names) CareerPlayers.Add(n); + + CareerStatus = names.Count == 0 + ? "Index the replay folder to build an all-time picture." + : $"{names.Count} players with enough footage to judge."; + + string? pick = previous != null && names.Contains(previous) ? previous + : recorder != null && names.Contains(recorder) ? recorder + : names.FirstOrDefault(); + + // Assign through the field so the setter does not fire before the list is populated. + _selectedCareerPlayer = pick; + Raise(nameof(SelectedCareerPlayer)); + RebuildCareer(); + } + + private void RebuildCareer() + { + if (string.IsNullOrEmpty(_selectedCareerPlayer)) { Career = null; return; } + CareerProfile? profile; + lock (_index) profile = CareerAnalyser.Build(_index, _selectedCareerPlayer); + Career = profile == null ? null : new CareerVm(profile); + } + + // ------------------------------------------------------------------ trends across the library + + public ObservableCollection Trends { get; } = new ObservableCollection(); + + public sealed record TrendRow( + string FileName, DateTime When, string Score, double Points, double Goals, + double Stuns, double Saves, double Turnovers, double Minutes, double PointsPerMinute); + + private IReadOnlyList _trendSeries = Array.Empty(); + public IReadOnlyList TrendSeries { get => _trendSeries; set => Set(ref _trendSeries, value); } + + private string _trendSummary = "Index the folder to build a career view across every replay."; + public string TrendSummary { get => _trendSummary; set => Set(ref _trendSummary, value); } + + /// + /// Pull every indexed match the selected player appears in, so form can be read over + /// time rather than from a single game. + /// + private void RebuildTrends() + { + Trends.Clear(); + TrendSeries = Array.Empty(); + string? name = _selectedPlayer?.Name; + if (name == null) + { + TrendSummary = "Open a match and pick a player to see their history."; + return; + } + + List<(MatchSummary m, PlayerSummary p)> rows; + lock (_index) + { + rows = _index.Entries.Values + .Where(s => s.Ok) + .Select(s => (m: s, p: s.Players.FirstOrDefault(x => + string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase))!)) + .Where(t => t.p != null && t.p.SecondsPlayed > 30) + .OrderBy(t => t.m.Recorded) + .ToList(); + } + + if (rows.Count == 0) + { + TrendSummary = $"No indexed matches found for {name}. Index the folder to build their history."; + return; + } + + foreach (var (m, p) in rows) + Trends.Add(new TrendRow( + Path.GetFileName(m.FilePath), m.Recorded, $"{m.BlueScore}–{m.OrangeScore}", + p.GoalPoints, p.Goals, p.Stuns, p.Saves, p.Turnovers, + p.SecondsPlayed / 60.0, p.PointsPerMinute)); + + double totalMin = rows.Sum(t => t.p.SecondsPlayed) / 60.0; + TrendSummary = $"{name} — {rows.Count} indexed matches, {totalMin:F0} minutes of live play. " + + $"{rows.Sum(t => t.p.GoalPoints)} points from {rows.Sum(t => t.p.Goals)} goals, " + + $"{rows.Sum(t => t.p.Assists)} assists, {rows.Sum(t => t.p.Saves)} saves and " + + $"{rows.Sum(t => t.p.Stuns)} stuns."; + + // Chart against match number rather than date, so a long break does not squash the line. + var pts = rows.Select((t, i) => ((double)(i + 1), t.p.PointsPerMinute)).ToList(); + var stuns = rows.Select((t, i) => ((double)(i + 1), t.p.StunsPerMinute)).ToList(); + var tos = rows.Select((t, i) => ((double)(i + 1), t.p.TurnoversPerMinute)).ToList(); + TrendSeries = new[] + { + new ChartSeries { Name = "Points / min", Tone = Tone.Accent, Points = pts, Thickness = 2 }, + // Blue rather than the original orange: in Spark's palette orange and the "bad" colour + // are close enough to confuse stuns with turnovers. + new ChartSeries { Name = "Stuns / min", Tone = Tone.Blue, Points = stuns, Thickness = 1.6 }, + new ChartSeries { Name = "Turnovers / min", Tone = Tone.Bad, Points = tos, Thickness = 1.6 }, + }; + } +} diff --git a/ReplayAnalyser/ViewModels/TeamVm.cs b/ReplayAnalyser/ViewModels/TeamVm.cs new file mode 100644 index 0000000..87baf52 --- /dev/null +++ b/ReplayAnalyser/ViewModels/TeamVm.cs @@ -0,0 +1,72 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/ViewModels/TeamVm.cs). Unchanged apart from +// theme tones in place of brushes. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Spark.ReplayAnalyser.Analysis; +using Spark.ReplayAnalyser.Controls; +using Spark.ReplayAnalyser.Model; + +namespace Spark.ReplayAnalyser.ViewModels; + +/// One player's slot in the team shape, ready to bind. +public sealed class RoleVm +{ + public RoleVm(RoleAssignment r, Tone tone) + { + Source = r; + Tone = tone; + } + + public RoleAssignment Source { get; } + public Tone Tone { get; } + public string Name => Source.Player.Name; + public string RoleName => Source.RoleName; + public string Evidence => Source.Evidence; + + public string FieldProgressText => $"{Source.FieldProgress:P0}"; + public string GoalieShareText => $"{Source.GoalieShare:P0}"; + public string LastBackText => $"{Source.LastDefenderShare:P0}"; + public string StatLine => + $"{Source.Player.GoalPoints} pts · {Source.Player.Goals}G {Source.Player.Assists}A · " + + $"{Source.Player.Saves} SV · {Source.Player.Stuns} STN"; + + /// Bar width, 0..1, showing how far up the sheet they live. + public double Position => Math.Clamp(Source.FieldProgress, 0, 1); +} + +/// Display wrapper for a whole team's profile. +public sealed class TeamVm +{ + public TeamVm(TeamProfile p) + { + Profile = p; + Tone = p.Side == TeamSide.Blue ? Tone.Blue : Tone.Orange; + Roles = p.Roles.OrderBy(r => r.FieldProgress).Select(r => new RoleVm(r, Tone)).ToList(); + Tags = p.StyleTags.Select(Capitalise).ToList(); + } + + public TeamProfile Profile { get; } + public Tone Tone { get; } + public string Name => Profile.Name; + public string StackShape => Profile.StackShape; + public string Summary => Profile.Summary; + public string ScoreText => Profile.Score.ToString(); + public string RatingText => $"{Profile.Rating:F0}"; + + public IReadOnlyList Roles { get; } + public IReadOnlyList Tags { get; } + + public string PressText => $"{Profile.PressHeight:P0}"; + public string BackOpenText => $"{Profile.OpenBackShare:P0}"; + public string SpacingText => $"{Profile.Spacing:F1} m"; + public string HoldText => $"{Profile.AverageHold:F1} s"; + public string StunsText => $"{Profile.StunsPerMinute:F1} /min"; + public string AccuracyText => $"{Profile.ShotAccuracy:P0}"; + public string PossessionText => $"{Profile.PossessionShare:P0}"; + + private static string Capitalise(string s) => + string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s[1..]; +} diff --git a/ReplayAnalyser/Views/AllTimeView.xaml b/ReplayAnalyser/Views/AllTimeView.xaml new file mode 100644 index 0000000..2c9c885 --- /dev/null +++ b/ReplayAnalyser/Views/AllTimeView.xaml @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ReplayAnalyser/Views/AllTimeView.xaml.cs b/ReplayAnalyser/Views/AllTimeView.xaml.cs new file mode 100644 index 0000000..4c6168b --- /dev/null +++ b/ReplayAnalyser/Views/AllTimeView.xaml.cs @@ -0,0 +1,8 @@ +using System.Windows.Controls; + +namespace Spark.ReplayAnalyser.Views; + +public partial class AllTimeView : UserControl +{ + public AllTimeView() => InitializeComponent(); +} diff --git a/ReplayAnalyser/Views/CoachingView.xaml b/ReplayAnalyser/Views/CoachingView.xaml new file mode 100644 index 0000000..7f33a29 --- /dev/null +++ b/ReplayAnalyser/Views/CoachingView.xaml @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ReplayAnalyser/Views/CoachingView.xaml.cs b/ReplayAnalyser/Views/CoachingView.xaml.cs new file mode 100644 index 0000000..3665064 --- /dev/null +++ b/ReplayAnalyser/Views/CoachingView.xaml.cs @@ -0,0 +1,8 @@ +using System.Windows.Controls; + +namespace Spark.ReplayAnalyser.Views; + +public partial class CoachingView : UserControl +{ + public CoachingView() => InitializeComponent(); +} diff --git a/ReplayAnalyser/Views/Converters.cs b/ReplayAnalyser/Views/Converters.cs new file mode 100644 index 0000000..aca016e --- /dev/null +++ b/ReplayAnalyser/Views/Converters.cs @@ -0,0 +1,64 @@ +// Ported from the Replay Analyser (EchoAnalyser.App/Converters.cs). Unchanged apart from the +// namespace; ShareToWidth is dropped, as the Spark views draw those bars with a ProgressBar. +#nullable enable + +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace Spark.ReplayAnalyser.Views; + +public sealed class BoolToVisibility : IValueConverter +{ + /// Set to true to hide when the bound value is true. + public bool Invert { get; set; } + + public object Convert(object? value, Type t, object? p, CultureInfo c) + { + bool b = value is bool v && v; + if (Invert) b = !b; + return b ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object? value, Type t, object? p, CultureInfo c) => + value is Visibility vis && vis == Visibility.Visible; +} + +public sealed class NotEmptyToVisibility : IValueConverter +{ + public object Convert(object? value, Type t, object? p, CultureInfo c) + { + bool has = value switch + { + null => false, + string s => !string.IsNullOrWhiteSpace(s), + System.Collections.ICollection col => col.Count > 0, + _ => true, + }; + return has ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object? value, Type t, object? p, CultureInfo c) => + throw new NotSupportedException(); +} + +/// Flips a boolean, so a pair of radio buttons can share one backing property. +public sealed class InverseBool : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => + value is bool b ? !b : true; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => + value is bool b ? !b : false; +} + +/// Visible when a count is zero — for "nothing here" notes beside a list. +public sealed class ZeroToVisibility : IValueConverter +{ + public object Convert(object? value, Type t, object? p, CultureInfo c) => + value is int n && n == 0 ? Visibility.Visible : Visibility.Collapsed; + + public object ConvertBack(object? value, Type t, object? p, CultureInfo c) => + throw new NotSupportedException(); +} diff --git a/ReplayAnalyser/Views/LibraryView.xaml b/ReplayAnalyser/Views/LibraryView.xaml new file mode 100644 index 0000000..4eb4dba --- /dev/null +++ b/ReplayAnalyser/Views/LibraryView.xaml @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Style="{StaticResource AccentButtonStyle}" + Height="46"/> - - - - - - + + + - - + + - + - - + + Foreground="{DynamicResource TextDim}" + Margin="0,3,0,0"/> - + - - - - + Style="{StaticResource ServerBrowserButtonStyle}"/> - - + + - + - - - - - - - - - - + + + - @@ -417,76 +372,78 @@ - - + + - - - - - + Style="{StaticResource TitleTextStyle}"/> + + + + + + + + + + - + - - - + - + - - + Style="{StaticResource SecondaryButtonStyle}" + Padding="12,4" + Margin="0,8,0,0"/> @@ -495,36 +452,32 @@ - - - - - + - - - + + + - @@ -534,33 +487,33 @@ - - - - - - - \ No newline at end of file + diff --git a/Windows/LiveWindow/CreateServerControls.xaml.cs b/Windows/LiveWindow/CreateServerControls.xaml.cs index 23d1e23..01a0472 100644 --- a/Windows/LiveWindow/CreateServerControls.xaml.cs +++ b/Windows/LiveWindow/CreateServerControls.xaml.cs @@ -29,6 +29,15 @@ public CreateServerControls() StartBackgroundThreads(); } + /// + /// Resolved once at build time, not a DynamicResource: the details panel is rebuilt from + /// scratch every time a server is selected, so it always picks up the current theme anyway. + /// + private static System.Windows.Media.Brush ThemeBrush(string key, System.Windows.Media.Brush fallback) + { + return Application.Current.TryFindResource(key) as System.Windows.Media.Brush ?? fallback; + } + private static string IndexToRegion(int index) { return index switch @@ -224,7 +233,7 @@ public string DisplayName } public string StatusText => open ? "OPEN" : "LOCKED"; - public string PlayerCountText => $"👥 {players?.Count ?? 0} players"; + public string PlayerCountText => $"{players?.Count ?? 0} players"; public int BlueScore => game_state?.blue_score ?? 0; public int OrangeScore => game_state?.orange_score ?? 0; @@ -329,33 +338,44 @@ private void UpdateServerDetails(ServerData server) var serverId = server.id?.Split('.')[0]?.ToUpper() ?? "N/A"; + System.Windows.Media.Brush textPrimary = ThemeBrush("TextPrimary", System.Windows.Media.Brushes.White); + System.Windows.Media.Brush textDim = ThemeBrush("TextDim", System.Windows.Media.Brushes.LightGray); + System.Windows.Media.Brush textFaint = ThemeBrush("TextFaint", System.Windows.Media.Brushes.Gray); + System.Windows.Media.Brush statusGood = ThemeBrush("StatusGood", System.Windows.Media.Brushes.Green); + System.Windows.Media.Brush statusBad = ThemeBrush("StatusBad", System.Windows.Media.Brushes.Red); + System.Windows.Media.Brush teamBlue = ThemeBrush("TeamBlue", System.Windows.Media.Brushes.CornflowerBlue); + System.Windows.Media.Brush teamOrange = ThemeBrush("TeamOrange", System.Windows.Media.Brushes.Orange); + System.Windows.Media.Brush surfaceRaised = ThemeBrush("SurfaceRaised", System.Windows.Media.Brushes.DimGray); + System.Windows.Media.Brush controlAccent = ThemeBrush("ControlAccent", System.Windows.Media.Brushes.SteelBlue); + System.Windows.Media.Brush controlAccentForeground = ThemeBrush("ControlAccentForeground", System.Windows.Media.Brushes.White); + var titleLabel = new TextBlock { Text = mode, FontSize = 18, FontWeight = FontWeights.Bold, - Foreground = System.Windows.Media.Brushes.White, + Foreground = textPrimary, Margin = new Thickness(0, 0, 0, 5) }; - + var idLabel = new TextBlock { Text = $"ID: {serverId}", FontSize = 11, - Foreground = System.Windows.Media.Brushes.LightGray, + Foreground = textDim, Margin = new Thickness(0, 0, 0, 20) }; - + ServerDetailsPanel.Children.Add(titleLabel); ServerDetailsPanel.Children.Add(idLabel); - + var statusFrame = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 10) }; var statusTitle = new TextBlock { Text = "Status:", FontSize = 11, FontWeight = FontWeights.Bold, - Foreground = System.Windows.Media.Brushes.LightGray, + Foreground = textDim, VerticalAlignment = VerticalAlignment.Center }; var statusValue = new TextBlock @@ -363,14 +383,14 @@ private void UpdateServerDetails(ServerData server) Text = server.open ? "OPEN" : "LOCKED", FontSize = 11, FontWeight = FontWeights.Bold, - Foreground = server.open ? System.Windows.Media.Brushes.Green : System.Windows.Media.Brushes.Red, + Foreground = server.open ? statusGood : statusBad, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(5, 0, 0, 0) }; statusFrame.Children.Add(statusTitle); statusFrame.Children.Add(statusValue); ServerDetailsPanel.Children.Add(statusFrame); - + var playerCount = server.players?.Count ?? 0; var playersFrame = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 10) }; var playersTitle = new TextBlock @@ -378,7 +398,7 @@ private void UpdateServerDetails(ServerData server) Text = "Players:", FontSize = 11, FontWeight = FontWeights.Bold, - Foreground = System.Windows.Media.Brushes.LightGray, + Foreground = textDim, VerticalAlignment = VerticalAlignment.Center }; var playersValue = new TextBlock @@ -386,49 +406,69 @@ private void UpdateServerDetails(ServerData server) Text = $"{playerCount}/14", FontSize = 11, FontWeight = FontWeights.Bold, - Foreground = System.Windows.Media.Brushes.White, + Foreground = textPrimary, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(5, 0, 0, 0) }; playersFrame.Children.Add(playersTitle); playersFrame.Children.Add(playersValue); ServerDetailsPanel.Children.Add(playersFrame); - + if (server.game_state != null) { var blueScore = server.game_state.blue_score; var orangeScore = server.game_state.orange_score; - + var scoreFrame = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 15) }; var scoreTitle = new TextBlock { Text = "Score:", FontSize = 11, FontWeight = FontWeights.Bold, - Foreground = System.Windows.Media.Brushes.LightGray, + Foreground = textDim, VerticalAlignment = VerticalAlignment.Center }; - var scoreValue = new TextBlock + var scoreBlueValue = new TextBlock { - Text = $"🔵 {blueScore} - {orangeScore} 🟠", + Text = blueScore.ToString(), FontSize = 11, + FontFamily = new System.Windows.Media.FontFamily("Consolas"), FontWeight = FontWeights.Bold, - Foreground = System.Windows.Media.Brushes.White, + Foreground = teamBlue, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(5, 0, 0, 0) }; + var scoreDash = new TextBlock + { + Text = " - ", + FontSize = 11, + FontFamily = new System.Windows.Media.FontFamily("Consolas"), + Foreground = textFaint, + VerticalAlignment = VerticalAlignment.Center + }; + var scoreOrangeValue = new TextBlock + { + Text = orangeScore.ToString(), + FontSize = 11, + FontFamily = new System.Windows.Media.FontFamily("Consolas"), + FontWeight = FontWeights.Bold, + Foreground = teamOrange, + VerticalAlignment = VerticalAlignment.Center + }; scoreFrame.Children.Add(scoreTitle); - scoreFrame.Children.Add(scoreValue); + scoreFrame.Children.Add(scoreBlueValue); + scoreFrame.Children.Add(scoreDash); + scoreFrame.Children.Add(scoreOrangeValue); ServerDetailsPanel.Children.Add(scoreFrame); } - + var buttonFrame = new StackPanel { Margin = new Thickness(0, 0, 0, 25) }; - + var spectateButton = new Button { - Content = "👁 SPECTATE", - Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(35, 35, 35)), - Foreground = System.Windows.Media.Brushes.White, + Content = "SPECTATE", + Background = surfaceRaised, + Foreground = textPrimary, FontSize = 12, FontWeight = FontWeights.Bold, Height = 40, @@ -437,12 +477,12 @@ private void UpdateServerDetails(ServerData server) Margin = new Thickness(0, 0, 0, 10) }; spectateButton.Click += (s, e) => JoinServer(server); - + var joinAsPlayerButton = new Button { - Content = "🎮 JOIN AS PLAYER", - Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(59, 130, 246)), - Foreground = System.Windows.Media.Brushes.White, + Content = "JOIN AS PLAYER", + Background = controlAccent, + Foreground = controlAccentForeground, FontSize = 12, FontWeight = FontWeights.Bold, Height = 40, @@ -451,12 +491,12 @@ private void UpdateServerDetails(ServerData server) Margin = new Thickness(0, 0, 0, 10) }; joinAsPlayerButton.Click += (s, e) => ShowJoinAsPlayerPopup(server); - + var copyButton = new Button { - Content = "📋 COPY LOBBY ID", - Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(100, 116, 139)), - Foreground = System.Windows.Media.Brushes.White, + Content = "COPY LOBBY ID", + Background = surfaceRaised, + Foreground = textPrimary, FontSize = 12, FontWeight = FontWeights.Bold, Height = 40, @@ -476,102 +516,102 @@ private void UpdateServerDetails(ServerData server) Text = $"Players ({playerCount})", FontSize = 12, FontWeight = FontWeights.Bold, - Foreground = System.Windows.Media.Brushes.White, + Foreground = textPrimary, VerticalAlignment = VerticalAlignment.Center }; playerHeader.Children.Add(playerTitle); ServerDetailsPanel.Children.Add(playerHeader); - + var playerScrollViewer = new ScrollViewer { Height = 200, VerticalScrollBarVisibility = ScrollBarVisibility.Auto }; - + var playerContainer = new StackPanel(); - + if (server.players != null && server.players.Count > 0) { var bluePlayers = server.players.Where(p => p.team == "blue").ToList(); var orangePlayers = server.players.Where(p => p.team == "orange").ToList(); var noTeamPlayers = server.players.Where(p => string.IsNullOrEmpty(p.team) || (p.team != "blue" && p.team != "orange")).ToList(); - + if (bluePlayers.Count > 0) { var blueHeader = new TextBlock { - Text = "🔵 BLUE TEAM", + Text = "BLUE TEAM", FontSize = 10, FontWeight = FontWeights.Bold, - Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(96, 165, 250)), + Foreground = teamBlue, Margin = new Thickness(0, 0, 0, 5) }; playerContainer.Children.Add(blueHeader); - + foreach (var player in bluePlayers) { var playerLabel = new TextBlock { Text = $"• {player.display_name ?? "Unknown"}", FontSize = 9, - Foreground = System.Windows.Media.Brushes.White, + Foreground = textPrimary, Margin = new Thickness(10, 0, 0, 3) }; playerContainer.Children.Add(playerLabel); } - + playerContainer.Children.Add(new Border { Height = 10 }); } - + if (orangePlayers.Count > 0) { var orangeHeader = new TextBlock { - Text = "🟠 ORANGE TEAM", + Text = "ORANGE TEAM", FontSize = 10, FontWeight = FontWeights.Bold, - Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(251, 146, 60)), + Foreground = teamOrange, Margin = new Thickness(0, 0, 0, 5) }; playerContainer.Children.Add(orangeHeader); - + foreach (var player in orangePlayers) { var playerLabel = new TextBlock { Text = $"• {player.display_name ?? "Unknown"}", FontSize = 9, - Foreground = System.Windows.Media.Brushes.White, + Foreground = textPrimary, Margin = new Thickness(10, 0, 0, 3) }; playerContainer.Children.Add(playerLabel); } - + if (noTeamPlayers.Count > 0) { playerContainer.Children.Add(new Border { Height = 10 }); } } - + if (noTeamPlayers.Count > 0) { var noTeamHeader = new TextBlock { - Text = "⚪ NO TEAM", + Text = "NO TEAM", FontSize = 10, FontWeight = FontWeights.Bold, - Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(100, 116, 139)), + Foreground = textFaint, Margin = new Thickness(0, 0, 0, 5) }; playerContainer.Children.Add(noTeamHeader); - + foreach (var player in noTeamPlayers) { var playerLabel = new TextBlock { Text = $"• {player.display_name ?? "Unknown"}", FontSize = 9, - Foreground = System.Windows.Media.Brushes.White, + Foreground = textPrimary, Margin = new Thickness(10, 0, 0, 3) }; playerContainer.Children.Add(playerLabel); @@ -584,25 +624,28 @@ private void UpdateServerDetails(ServerData server) { Text = "No players in server", FontSize = 10, - Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(100, 116, 139)), + Foreground = textFaint, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 20, 0, 0) }; playerContainer.Children.Add(emptyLabel); } - + playerScrollViewer.Content = playerContainer; ServerDetailsPanel.Children.Add(playerScrollViewer); - - spectateButton.MouseEnter += (s, e) => spectateButton.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(50, 50, 50)); - spectateButton.MouseLeave += (s, e) => spectateButton.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(35, 35, 35)); - - joinAsPlayerButton.MouseEnter += (s, e) => joinAsPlayerButton.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(37, 99, 235)); - joinAsPlayerButton.MouseLeave += (s, e) => joinAsPlayerButton.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(59, 130, 246)); - - copyButton.MouseEnter += (s, e) => copyButton.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(71, 85, 105)); - copyButton.MouseLeave += (s, e) => copyButton.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(100, 116, 139)); + + System.Windows.Media.Brush controlMouseOver = ThemeBrush("ControlMouseOverBackground", System.Windows.Media.Brushes.DimGray); + System.Windows.Media.Brush controlBright = ThemeBrush("ControlBrightDefaultBackground", System.Windows.Media.Brushes.SlateGray); + + spectateButton.MouseEnter += (s, e) => spectateButton.Background = controlMouseOver; + spectateButton.MouseLeave += (s, e) => spectateButton.Background = surfaceRaised; + + joinAsPlayerButton.MouseEnter += (s, e) => joinAsPlayerButton.Background = controlBright; + joinAsPlayerButton.MouseLeave += (s, e) => joinAsPlayerButton.Background = controlAccent; + + copyButton.MouseEnter += (s, e) => copyButton.Background = controlMouseOver; + copyButton.MouseLeave += (s, e) => copyButton.Background = surfaceRaised; } private void ShowJoinAsPlayerPopup(ServerData server) @@ -828,11 +871,10 @@ private void JoinAsSpectator(ServerData server) return; } - string echoPath = SparkSettings.instance.echoVRPath; - string arguments = $"-lobbyid {lobbyId} -spectatorstream"; - - System.Diagnostics.Process.Start(echoPath, arguments); - + // A spectator client can't be launched with -lobbyid. It comes up on + // -spectatorstream and is then moved into the session over its HTTP API. + _ = SimpleSpectateController.SpectateSessionAsync(lobbyId, SparkSettings.instance.spectatorStreamNoOVR); + new MessageBox($"Spectating {server.DisplayName}...", "Joining Server").Show(); } catch (Exception ex) @@ -973,24 +1015,21 @@ private async Task CheckApiConnection() private void UpdateApiStatus(bool connected) { - if (connected) - { - ApiStatusLabel.Text = "API: Connected"; - ApiStatusLabel.Foreground = System.Windows.Media.Brushes.Green; - StatusIndicator.Fill = System.Windows.Media.Brushes.Green; - } - else - { - ApiStatusLabel.Text = "API: Disconnected"; - ApiStatusLabel.Foreground = System.Windows.Media.Brushes.Red; - StatusIndicator.Fill = System.Windows.Media.Brushes.Red; - } + System.Windows.Media.Brush statusColor = ThemeBrush( + connected ? "StatusGood" : "StatusBad", + connected ? System.Windows.Media.Brushes.Green : System.Windows.Media.Brushes.Red); + + ApiStatusLabel.Text = connected ? "API: Connected" : "API: Disconnected"; + ApiStatusLabel.Foreground = statusColor; + StatusIndicator.Fill = statusColor; } private void UpdateStatus(bool success, string message) { StatusTextBlock.Text = message; - StatusIndicator.Fill = success ? System.Windows.Media.Brushes.Green : System.Windows.Media.Brushes.Red; + StatusIndicator.Fill = ThemeBrush( + success ? "StatusGood" : "StatusBad", + success ? System.Windows.Media.Brushes.Green : System.Windows.Media.Brushes.Red); } private string GetApiBaseUrl() diff --git a/Windows/LiveWindow/DashboardWebHost.cs b/Windows/LiveWindow/DashboardWebHost.cs new file mode 100644 index 0000000..93e4cc2 --- /dev/null +++ b/Windows/LiveWindow/DashboardWebHost.cs @@ -0,0 +1,497 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows; +using EchoVRAPI; +using Microsoft.Web.WebView2.Core; +using Microsoft.Web.WebView2.Wpf; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Spark +{ + /// + /// Renders the dashboard from the design's own markup (resources/dashboard.html) in a WebView2, + /// and feeds it the live frame. + /// + /// The design was authored as HTML/CSS; hand-porting it to XAML is what let the two drift apart, + /// so this hosts it directly instead. Only the dashboard body is web-rendered — the header, + /// rails, tabs and every button stay native WPF. + /// + /// + public class DashboardWebHost + { + private readonly WebView2 view; + private bool ready; + private string lastPayload; + + public DashboardWebHost(WebView2 view) + { + this.view = view; + } + + public bool Ready => ready; + + /// + /// Raised every time the page finishes navigating — first load AND every reload (see + /// ). A reload resets the page's own JS state + /// back to its hardcoded default theme, so anything that only pushes state on change (rather + /// than unconditionally) needs to know a reload happened and re-push, or the page is stuck + /// showing stale defaults that the caller thinks it already sent. + /// + public event Action Loaded; + + /// Raised when the page's dashboard-item dropdown changes (Last Throw / Player Speeds). + public event Action DashItemChanged; + + /// Raised when the page's joust-order dropdown changes (Recent / Fastest). + public event Action JoustOrderChanged; + + public async void Start() + { + try + { + CoreWebView2Environment environment = await WebViewEnvironment.GetAsync(); + await view.EnsureCoreWebView2Async(environment); + + string page = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "resources", "dashboard.html"); + if (!File.Exists(page)) + { + LogRowIfPossible($"Dashboard page missing at {page}; falling back to the native dashboard."); + return; + } + + // No context menu, no dev tools, no browser chrome — it's a panel, not a browser. + view.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; + view.CoreWebView2.Settings.AreDevToolsEnabled = false; + view.CoreWebView2.Settings.IsStatusBarEnabled = false; + view.CoreWebView2.Settings.IsZoomControlEnabled = false; + + view.CoreWebView2.WebMessageReceived += OnWebMessage; + view.CoreWebView2.NavigationCompleted += (_, _) => + { + ready = true; + lastPayload = null; // force the next PushFrame through too, same reasoning as Loaded + view.Visibility = Visibility.Visible; + Loaded?.Invoke(); + }; + + view.CoreWebView2.Navigate(new Uri(page).AbsoluteUri); + } + catch (Exception ex) + { + LogRowIfPossible($"Dashboard WebView2 failed to start; using the native dashboard.\n{ex}"); + } + } + + private void OnWebMessage(object sender, CoreWebView2WebMessageReceivedEventArgs e) + { + try + { + dynamic message = JsonConvert.DeserializeObject(e.WebMessageAsJson); + string type = (string)message.type; + int value = (int)message.value; + + if (type == "dashItem") DashItemChanged?.Invoke(value); + else if (type == "joustOrder") JoustOrderChanged?.Invoke(value); + } + catch (Exception ex) + { + LogRowIfPossible($"Bad dashboard message.\n{ex}"); + } + } + + /// Pushes the three theme colours so the page derives the same palette the app does. + public void PushTheme(string dark, string mid, string light) + { + if (!ready) return; + Execute($"applyTheme({Quote(dark)},{Quote(mid)},{Quote(light)})"); + } + + public void PushDashItem(int index) + { + if (!ready) return; + Execute($"setDashItem({index})"); + } + + /// Sends one frame's worth of state, skipping the call when nothing changed. + public void PushFrame(Frame frame) + { + if (!ready || frame == null) return; + + object payload = BuildPayload(frame); + string json = JsonConvert.SerializeObject(payload); + if (json == lastPayload) return; + + lastPayload = json; + Execute($"applyData({json})"); + } + + private static object BuildPayload(Frame frame) + { + bool isCombat = frame.match_type != null && + frame.match_type.StartsWith("Echo_Combat", StringComparison.OrdinalIgnoreCase); + object combat = isCombat ? BuildCombatPayload(frame) : null; + + List players = new List(); + int pingTotal = 0, pingCount = 0, worst = 0; + float lossTotal = 0f; + + for (int t = 0; t < frame.teams.Count && t < 3; t++) + { + string team = t == 0 ? "blue" : t == 1 ? "orange" : "text-faint"; + foreach (Player player in frame.teams[t].players) + { + players.Add(new + { + name = player.name, + team, + ping = player.ping, + speed = player.velocity.ToVector3().Length() + }); + + if (player.ping > 0) + { + pingTotal += player.ping; + pingCount++; + worst = Math.Max(worst, player.ping); + } + + lossTotal += player.packetlossratio; + } + } + + LastThrow throwData = frame.last_throw; + object throwPayload = throwData == null || throwData.total_speed <= 0 ? null : new + { + total = throwData.total_speed, + arm = throwData.speed_from_arm, + wrist = throwData.speed_from_wrist, + move = throwData.speed_from_movement, + armSpeed = throwData.arm_speed, + rots = throwData.rot_per_sec, + pot = throwData.pot_speed_from_rot, + offAxis = throwData.off_axis_spin_deg, + wristAlign = throwData.wrist_align_to_throw_deg, + moveAlign = throwData.throw_align_to_movement_deg + }; + + List jousts = Program.LastJousts.ToList(); + if (SparkSettings.instance.dashboardJoustTimeOrder == 1) + { + jousts.Sort((first, second) => second.joustTimeMillis.CompareTo(first.joustTimeMillis)); + } + jousts.Reverse(); + + AccumulatedFrame[] rounds = Program.rounds.ToArray(); + GoalData[] goals = Program.LastGoals.ToArray(); + + return new + { + mode = isCombat ? "combat" : "arena", + combat, + disc = frame.disc?.velocity?.ToVector3().Length() ?? 0f, + possession = frame.possession != null && frame.possession.Count > 0 ? frame.possession[0] : -1, + @throw = throwPayload, + players, + avgPing = pingCount > 0 ? pingTotal / pingCount : (int?)null, + worstPing = worst, + loss = players.Count > 0 ? lossTotal / players.Count * 100f : 0f, + serverScore = Program.CurrentRound.smoothedServerScore, + serverStatus = ServerStatusText(), + serverWarn = Program.CurrentRound.serverScore < -1.5f, + jousts = jousts.Select(joust => new + { + name = joust.player.name, + team = joust.player.team_color == Team.TeamColor.blue ? "blue" : "orange", + seconds = joust.joustTimeMillis / 1000f + }), + roundsTotal = rounds.Length, + rounds = rounds.Reverse().Take(6).Select(round => new + { + time = round.finishReason == AccumulatedFrame.FinishReason.not_finished + ? "live" + : round.matchTime.ToLocalTime().ToString("t"), + orange = round.frame.orange_points, + blue = round.frame.blue_points, + round = round.frame.total_round_count > 0 + ? "R" + (round.frame.blue_round_score + round.frame.orange_round_score + 1) / round.frame.total_round_count + : "" + }), + goals = goals.Reverse().Take(8).Select(goal => new + { + time = goal.GameClock.ToString("N0", CultureInfo.InvariantCulture) + "s", + points = goal.LastScore.point_amount, + scorer = goal.LastScore.person_scored, + team = goal.LastScore.team == "blue" ? "blue" : "orange", + speed = goal.LastScore.disc_speed, + distance = goal.LastScore.distance_thrown + }) + }; + } + + /// + /// Builds the combat-mode payload: loadouts/rosters from CombatDataParser and the raw + /// per-player weapon/ordnance/tacmod fields off Program.lastJSON (the WPF-native combat + /// dashboard reads the same two sources — see the now-superseded UpdateCombatDashboard). + /// + private static object BuildCombatPayload(Frame frame) + { + if (string.IsNullOrEmpty(Program.lastJSON)) return null; + + JObject jsonObj; + try { jsonObj = JObject.Parse(Program.lastJSON); } + catch { return null; } + + string blueScore = jsonObj["blue_round_score"]?.ToString() ?? jsonObj["blue_points"]?.ToString() ?? "0"; + string orangeScore = jsonObj["orange_round_score"]?.ToString() ?? jsonObj["orange_points"]?.ToString() ?? "0"; + string clock = frame.game_clock_display?.Length > 3 ? frame.game_clock_display[..^3] : frame.game_clock_display; + + int roundsPlayed = (int)(jsonObj["blue_round_score"]?.ToObject() ?? 0) + (int)(jsonObj["orange_round_score"]?.ToObject() ?? 0); + int totalRounds = jsonObj["total_round_count"]?.ToObject() ?? frame.total_round_count; + string roundText = totalRounds > 0 ? $"Round {Math.Min(roundsPlayed + 1, totalRounds)} of {totalRounds}" : "No round"; + + string mapName = jsonObj["map_name"]?.ToString(); + string mapLabel = CombatMapDisplayName(mapName); + + JArray teamsArray = jsonObj["teams"] as JArray; + const string BLUE = "var(--blue)", ORANGE = "var(--orange)"; + Dictionary nameColors = new Dictionary(); + List<(string name, int ping, string weapon, string mods, int kills, int assists, int deaths, int damage, bool isClient)>[] rawTeams = + { + new List<(string, int, string, string, int, int, int, int, bool)>(), + new List<(string, int, string, string, int, int, int, int, bool)>() + }; + + for (int t = 0; t < 2 && t < frame.teams.Count; t++) + { + Team apiTeam = frame.teams[t]; + JToken jsonTeam = teamsArray != null && teamsArray.Count > t ? teamsArray[t] : null; + JArray jsonPlayers = jsonTeam?["players"] as JArray; + string teamColor = t == 0 ? BLUE : ORANGE; + + for (int p = 0; p < apiTeam.players.Count; p++) + { + Player apiPlayer = apiTeam.players[p]; + JToken jsonPlayer = jsonPlayers != null && jsonPlayers.Count > p ? jsonPlayers[p] : null; + + // The API gives internal names ("assault", "det"); show the ones players know. + string weapon = GearNames.Display(jsonPlayer?["Weapon"]?.ToString() ?? jsonPlayer?["weapon"]?.ToString() ?? "N/A"); + string ordnance = GearNames.Display(jsonPlayer?["Ordnance"]?.ToString() ?? jsonPlayer?["ordnance"]?.ToString() ?? "N/A"); + string tacmod = GearNames.Display(jsonPlayer?["TacMod"]?.ToString() ?? jsonPlayer?["tacmod"]?.ToString() ?? "N/A"); + string mods = string.IsNullOrEmpty(ordnance) || ordnance == "N/A" + ? (string.IsNullOrEmpty(tacmod) || tacmod == "N/A" ? "" : tacmod) + : (string.IsNullOrEmpty(tacmod) || tacmod == "N/A" ? ordnance : $"{ordnance} · {tacmod}"); + + CombatStats stats = CombatDataParser.GetCombatStats(apiPlayer.userid); + rawTeams[t].Add((apiPlayer.name, apiPlayer.ping, weapon, mods, stats.kills, stats.assists, stats.deaths, (int)stats.damage, apiPlayer.name == frame.client_name)); + nameColors[apiPlayer.name] = teamColor; + } + } + + int peakDamage = Math.Max(1, rawTeams[0].Concat(rawTeams[1]).Select(p => p.damage).DefaultIfEmpty(0).Max()); + + List Dress(int t, string color) => rawTeams[t].Select(p => (object)new + { + name = p.name, + color, + bg = p.isClient ? "var(--raised)" : "transparent", + kills = p.kills, + assists = p.assists, + deaths = p.deaths, + damage = p.damage.ToString("N0"), + weapon = p.weapon, + mods = p.mods, + dmgPct = Math.Round(Math.Clamp((double)p.damage / peakDamage, 0.0, 1.0) * 100) + "%" + }).ToList(); + + int blueKills = rawTeams[0].Sum(p => p.kills), orangeKills = rawTeams[1].Sum(p => p.kills); + int blueDamage = rawTeams[0].Sum(p => p.damage), orangeDamage = rawTeams[1].Sum(p => p.damage); + + object[] rosters = + { + new { name = "Blue team", color = BLUE, players = Dress(0, BLUE), summary = $"{blueKills} K · {blueDamage:N0} dmg" }, + new { name = "Orange team", color = ORANGE, players = Dress(1, ORANGE), summary = $"{orangeKills} K · {orangeDamage:N0} dmg" } + }; + + object[] totals = + { + new { name = "Blue", color = BLUE, tint = "var(--blue-tint)", kills = blueKills, damage = blueDamage.ToString("N0"), objective = FormatObjectiveTime(SumObjectiveTime(frame.teams.Count > 0 ? frame.teams[0] : null)) }, + new { name = "Orange", color = ORANGE, tint = "var(--orange-tint)", kills = orangeKills, damage = orangeDamage.ToString("N0"), objective = FormatObjectiveTime(SumObjectiveTime(frame.teams.Count > 1 ? frame.teams[1] : null)) } + }; + + // ── Objective (capture point vs payload) ────────────────────────── + bool isPayloadMap = mapName == "mpl_combat_fission" || mapName == "mpl_combat_gauss"; + object objective; + if (isPayloadMap) + { + JToken payload = jsonObj["payload"]; + float distance = payload?["distance"]?.ToObject() ?? 0; + float speed = payload?["speed"]?.ToObject() ?? 0; + float pct = payload?["progress"]?.ToObject() ?? payload?["percentage"]?.ToObject() ?? Math.Clamp(distance / 200f, 0f, 1f); + bool isMoving = speed > 0.05f; + + objective = new + { + isPayload = true, + stateText = isMoving ? "MOVING" : "STOPPED", + stateColor = isMoving ? "var(--good)" : "var(--text-faint)", + payloadPct = Math.Round(pct * 100) + "%", + distance = $"{distance:N1} m", + speed = $"{speed:N2} m/s" + }; + } + else + { + bool isContested = jsonObj["contested"]?.ToObject() ?? false; + float blueProgress = jsonObj["blue_points"]?.ToObject() ?? 0; + float orangeProgress = jsonObj["orange_points"]?.ToObject() ?? 0; + + string stateText; string stateColor; + if (isContested) { stateText = "CONTESTED"; stateColor = "var(--bad)"; } + else if (blueProgress > orangeProgress) { stateText = "BLUE HOLDS"; stateColor = BLUE; } + else if (orangeProgress > blueProgress) { stateText = "ORANGE HOLDS"; stateColor = ORANGE; } + else { stateText = "NEUTRAL"; stateColor = "var(--text-faint)"; } + + objective = new + { + isPayload = false, + stateText, + stateColor, + bluePct = $"{blueProgress:N0}%", + orangePct = $"{orangeProgress:N0}%" + }; + } + + // ── Kill feed ─────────────────────────────────────────────────────── + List kills; + lock (CombatDataParser.ParseLock) + { + kills = CombatDataParser.KillFeed.Select((k, i) => (object)new + { + killer = string.IsNullOrEmpty(k.killer) ? "Self" : k.killer, + victim = string.IsNullOrEmpty(k.killed) ? "Unknown" : k.killed, + weapon = GearNames.Display(k.killed_with), + killerColor = nameColors.TryGetValue(k.killer ?? "", out string kc) ? kc : "var(--text-dim)", + victimColor = nameColors.TryGetValue(k.killed ?? "", out string vc) ? vc : "var(--text-dim)", + bg = i == 0 ? "var(--raised)" : "transparent" + }).ToList(); + } + + // ── Network (blue/orange only — no spectators, matching the original native combat dashboard) ── + int pingTotal = 0, pingCount = 0, worstPing = 0; + float lossTotal = 0f; + List pings = new List(); + for (int t = 0; t < 2; t++) + { + string color = t == 0 ? BLUE : ORANGE; + foreach (var p in rawTeams[t]) + { + if (p.ping > 0) + { + pingTotal += p.ping; + pingCount++; + worstPing = Math.Max(worstPing, p.ping); + } + + string pingColor = p.ping <= 0 ? "var(--text-faint)" : p.ping < 70 ? "var(--good)" : p.ping < 110 ? "var(--warn)" : "var(--bad)"; + pings.Add(new { name = p.name, color, ping = p.ping > 0 ? p.ping.ToString() : "--", pct = Math.Min(100, p.ping / 2) + "%", pingColor }); + } + } + int pingPlayerCount = rawTeams[0].Count + rawTeams[1].Count; + // packetlossratio isn't carried in the raw tuple above, so it's pulled straight off the frame. + for (int t = 0; t < 2 && t < frame.teams.Count; t++) + { + foreach (Player player in frame.teams[t].players) lossTotal += player.packetlossratio; + } + float lossPercent = pingPlayerCount > 0 ? lossTotal / pingPlayerCount * 100f : 0f; + + object net = new + { + status = ServerStatusText(), + warn = Program.CurrentRound.serverScore < -1.5f, + avg = pingCount > 0 ? (int?)(pingTotal / pingCount) : null, + worst = worstPing, + loss = pingPlayerCount > 0 ? lossPercent.ToString("N1") : "--", + score = Program.CurrentRound.serverScore > 0 ? Program.CurrentRound.smoothedServerScore.ToString("N1") : "--", + scorePct = Math.Max(0, Math.Min(100, Program.CurrentRound.smoothedServerScore / 150.0 * 100.0)) + }; + + return new + { + map = mapLabel, + blueScore, + orangeScore, + clock, + roundText, + objective, + totals, + rosters, + kills, + feedCount = kills.Count + " total", + pings, + net + }; + } + + private static float SumObjectiveTime(Team team) + { + if (team?.players == null) return 0f; + return team.players.Sum(p => CombatDataParser.GetCombatStats(p.userid).objective_time); + } + + private static string FormatObjectiveTime(float seconds) + { + TimeSpan span = TimeSpan.FromSeconds(Math.Max(0, seconds)); + return $"{(int)span.TotalMinutes}:{span.Seconds:D2}"; + } + + private static string CombatMapDisplayName(string mapName) + { + return mapName switch + { + "mpl_combat_dyson" => "DYSON", + "mpl_combat_combustion" => "COMBUSTION", + "mpl_combat_fission" => "FISSION", + "mpl_combat_gauss" => "SURGE", + _ => "--" + }; + } + + private static string ServerStatusText() + { + if (Program.CurrentRound.serverScore > 0) + { + return $"{Properties.Resources.Score_} {Program.CurrentRound.smoothedServerScore:N1}"; + } + + if (Math.Abs(Program.CurrentRound.serverScore - -1) < .1f) return ">150"; + if (Program.CurrentRound.serverScore < -1.5f) return "Wrong player count"; + return $"{Properties.Resources.Score_} --"; + } + + private void Execute(string script) + { + try + { + view.CoreWebView2?.ExecuteScriptAsync(script); + } + catch (Exception) + { + // A torn-down WebView during shutdown isn't worth reporting. + } + } + + private static string Quote(string value) + { + return JsonConvert.SerializeObject(value ?? string.Empty); + } + + private static void LogRowIfPossible(string message) + { + try { Logger.LogRow(Logger.LogType.Error, message); } catch { } + } + } +} diff --git a/Windows/LiveWindow/EventLogEntry.cs b/Windows/LiveWindow/EventLogEntry.cs new file mode 100644 index 0000000..9ad49ae --- /dev/null +++ b/Windows/LiveWindow/EventLogEntry.cs @@ -0,0 +1,114 @@ +using System.Text.RegularExpressions; +using System.Windows; +using System.Windows.Media; + +namespace Spark +{ + /// + /// One row in the reworked Event Log tab: a parsed game-clock time (when present), the message, + /// and a best-effort category used to colour it. + /// + /// The raw log is unstructured text from many call sites across the app (see LoggerEvents.cs and + /// scattered LogRow(LogType.File, ...) calls) — there's no shared schema to key off. Every game- + /// event line does follow one consistent shape ("{game_clock_display} - {message}", from + /// LoggerEvents.Log), so lines matching that get a parsed time and are classified by keyword; + /// everything else — camera-calibration diagnostics, system messages — falls through to a plain + /// untagged row rather than being mis-classified. + /// + /// + public sealed class EventLogEntry + { + private static readonly Regex TimedLine = new Regex(@"^(\d{2}:\d{2}\.\d{2})\s*-\s*(.*)$", RegexOptions.Compiled); + + public string Time { get; } + public string Message { get; } + public string Category { get; } + + /// + /// Resolved once, at classification time, from whatever theme is current then — not a + /// DynamicResource, so a later theme change won't retint rows already in the list. Rows are + /// historical and scroll off quickly; keeping this simple beats chasing live re-tinting for a + /// scrolling log. + /// + public Brush Accent { get; } + + private EventLogEntry(string time, string message, string category, Brush accent) + { + Time = time; + Message = message; + Category = category; + Accent = accent; + } + + /// Classifies one already-trimmed log line. Never returns null. + public static EventLogEntry Parse(string line) + { + Match match = TimedLine.Match(line); + string time = match.Success ? match.Groups[1].Value : ""; + string message = match.Success ? match.Groups[2].Value : line; + + (string category, string brushKey) = Classify(message); + Brush accent = Application.Current.TryFindResource(brushKey) as Brush ?? Brushes.Gray; + return new EventLogEntry(time, message, category, accent); + } + + /// + /// Ordered keyword rules — first match wins. Ordering matters where phrases overlap (e.g. + /// "Player Left" vs "Player switched"), so more specific phrases are checked first. + /// + private static (string category, string brushKey) Classify(string message) + { + if (Contains(message, "scored at") || Contains(message, "Goal angle") || Regex.IsMatch(message, @"^ORANGE:\s*\d+\s*BLUE:\s*\d+")) + return ("GOAL", "ControlAccent"); + + if (Contains(message, "made a save")) + return ("SAVE", "StatusGood"); + + if (Contains(message, "Player Joined") || message == "Joined game") + return ("JOIN", "StatusGood"); + + if (Contains(message, "Player Left") || message == "Left game") + return ("LEAVE", "StatusWarn"); + + if (Contains(message, "switched to") && Contains(message, "team")) + return ("TEAM", "StatusWarn"); + + if (Contains(message, "stunned")) + return ("STUN", "TeamOrange"); + + if (Contains(message, "threw the disk")) + return ("THROW", "TeamBlue"); + + if (Contains(message, "joust time")) + return ("JOUST", "TeamBlue"); + + if (Contains(message, "boosted to")) + return ("BOOST", "TeamBlue"); + + if (Contains(message, "made a catch") || Contains(message, "received a pass") || Contains(message, "intercepted a throw") || Contains(message, "turned over the disk") || Contains(message, "took a shot")) + return ("PLAY", "TextDim"); + + if (Contains(message, "paused the game") || Contains(message, "requested a pause") || Contains(message, "unpaused the game") || Contains(message, "restart request")) + return ("MATCH", "StatusWarn"); + + if (Contains(message, "used the") && Contains(message, "emote")) + return ("EMOTE", "TextFaint"); + + if (Contains(message, "ping went above")) + return ("WARN", "StatusBad"); + + if (Contains(message, "changed the private match rules")) + return ("RULES", "StatusWarn"); + + if (Contains(message, "abused their playspace")) + return ("WARN", "StatusBad"); + + return (null, "TextDim"); + } + + private static bool Contains(string haystack, string needle) + { + return haystack.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0; + } + } +} diff --git a/Windows/LiveWindow/FriendsTab.xaml b/Windows/LiveWindow/FriendsTab.xaml index e2f19fb..797bdbd 100644 --- a/Windows/LiveWindow/FriendsTab.xaml +++ b/Windows/LiveWindow/FriendsTab.xaml @@ -1,13 +1,155 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -24,28 +166,26 @@ - - + - + - - + - - - + + Use the Discord login button at the top left of Spark. Once logged in, your friend code will appear on the right and you can start adding friends. @@ -59,7 +199,7 @@ - @@ -70,42 +210,35 @@ - + - - - + + + - + - + + ToolTip="{Binding JoinButtonTooltip}" + Click="JoinFriendClicked"/> @@ -114,8 +247,8 @@ - + @@ -124,102 +257,73 @@ - + - - - - + + + + Style="{StaticResource AccentActionButton}" Click="AddFriendClicked"/> - + - - + + - - + Foreground="{DynamicResource TextPrimary}" HorizontalAlignment="Center"/> + Style="{StaticResource RoundedButton}" BorderBrush="{DynamicResource SurfaceBorder}" + Click="CopyCodeClicked"/> - - - + + + - - + + - - - - + + + + - - + + + Style="{StaticResource RoundedButton}" Background="Transparent" FontWeight="Normal" + BorderBrush="{DynamicResource SurfaceBorderSoft}" BorderThickness="1" + Foreground="{DynamicResource TextDim}"/> diff --git a/Windows/LiveWindow/FriendsTab.xaml.cs b/Windows/LiveWindow/FriendsTab.xaml.cs index 3fd8939..0027afb 100644 --- a/Windows/LiveWindow/FriendsTab.xaml.cs +++ b/Windows/LiveWindow/FriendsTab.xaml.cs @@ -1,17 +1,13 @@ using System; -using System.Collections.Generic; using System.Collections.ObjectModel; -using System.IO; using System.Linq; -using System.Net; using System.Net.Http; -using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Input; -using System.Windows.Media; +using System.Windows.Shapes; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -23,31 +19,9 @@ public partial class FriendsTab : UserControl private bool isRunning = true; private bool isPublic = true; private static readonly HttpClient http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; - - // ─── Bot API — same Ignite API server, same Discord OAuth token ────── - - private static string BotUrl => SecretKeys.FRIENDS_BOT_URL; - - private static bool BotConfigured => - DiscordOAuth.IsLoggedIn && - !string.IsNullOrEmpty(DiscordOAuth.oauthToken); - - private static HttpRequestMessage MakeRequest(HttpMethod method, string path, object body = null) - { - var req = new HttpRequestMessage(method, BotUrl + path); - req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", DiscordOAuth.oauthToken); - if (body != null) - req.Content = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"); - return req; - } - - private static async Task SendAsync(HttpRequestMessage req) - { - var resp = await http.SendAsync(req); - string content = await resp.Content.ReadAsStringAsync(); - if (!resp.IsSuccessStatusCode) throw new Exception(content); - return JObject.Parse(content); - } + private readonly object stateLock = new object(); + private bool isRefreshing = false; + private bool isPollingActive = false; // ─── Lifecycle ───────────────────────────────────────────────────────── @@ -55,52 +29,74 @@ public FriendsTab() { InitializeComponent(); FriendsItemsControl.ItemsSource = friends; + + // React when the user logs into Discord - registered ONCE in constructor to avoid event handler leak + DiscordOAuth.Authenticated += () => Task.Run(async () => await RefreshAll()); } private void UserControl_Loaded(object sender, RoutedEventArgs e) { - // React when the user logs into Discord - DiscordOAuth.Authenticated += () => Dispatcher.Invoke(async () => await RefreshAll()); - // Initialise immediately if already logged in - Dispatcher.BeginInvoke(new Action(async () => await RefreshAll())); + Task.Run(async () => await RefreshAll()); } // ─── Auth state ──────────────────────────────────────────────────────── private async Task RefreshAll() { - if (!BotConfigured) + lock (stateLock) { - ShowNotLoggedIn(); - return; + if (isRefreshing) return; + isRefreshing = true; } - ShowLoggedIn(); - SetStatus(null, "Connecting..."); - await RegisterWithBot(); - await PollFriends(); - StartPolling(); + try + { + if (!FriendsPresence.Configured) + { + ShowNotLoggedIn(); + return; + } + + ShowLoggedIn(); + SetStatus(null, "Connecting..."); + await RegisterWithBot(); + await PollFriends(); + StartPolling(); + } + finally + { + lock (stateLock) + { + isRefreshing = false; + } + } } private void ShowNotLoggedIn() { - NotLoggedInPanel.Visibility = Visibility.Visible; - FriendsSection.Visibility = Visibility.Collapsed; - MyFriendCodeSection.Visibility = Visibility.Collapsed; - SetStatus(false, "Log in with Discord to use Friends"); + Dispatcher.Invoke(() => + { + NotLoggedInPanel.Visibility = Visibility.Visible; + FriendsSection.Visibility = Visibility.Collapsed; + MyFriendCodeSection.Visibility = Visibility.Collapsed; + SetStatus(false, "Log in with Discord to use Friends"); + }); } private void ShowLoggedIn() { - NotLoggedInPanel.Visibility = Visibility.Collapsed; - FriendsSection.Visibility = Visibility.Visible; - - if (!string.IsNullOrEmpty(SparkSettings.instance.myFriendCode)) + Dispatcher.Invoke(() => { - MyFriendCodeText.Text = FormatCode(SparkSettings.instance.myFriendCode); - MyFriendCodeSection.Visibility = Visibility.Visible; - } + NotLoggedInPanel.Visibility = Visibility.Collapsed; + FriendsSection.Visibility = Visibility.Visible; + + if (!string.IsNullOrEmpty(SparkSettings.instance.myFriendCode)) + { + MyFriendCodeText.Text = FormatCode(SparkSettings.instance.myFriendCode); + MyFriendCodeSection.Visibility = Visibility.Visible; + } + }); } // ─── Bot API calls ───────────────────────────────────────────────────── @@ -109,30 +105,25 @@ private async Task RegisterWithBot() { try { - string echoUsername = null; - try { if (Program.lastFrame != null) echoUsername = Program.lastFrame.client_name; } catch { } - - using var req = MakeRequest(HttpMethod.Post, "/friends/register", new { echo_username = echoUsername }); - var result = await SendAsync(req); - - string code = result["friend_code"]?.ToString(); - if (!string.IsNullOrEmpty(code)) + // Shared with the presence pusher, so opening the tab doesn't register a second time. + string code = await FriendsPresence.EnsureRegisteredAsync(); + if (string.IsNullOrEmpty(code)) { - SparkSettings.instance.myFriendCode = code; - SparkSettings.instance.Save(); - - Dispatcher.Invoke(() => - { - MyFriendCodeText.Text = FormatCode(code); - MyFriendCodeSection.Visibility = Visibility.Visible; - }); + // EnsureRegisteredAsync reports failure as a null code rather than throwing. + SetStatus(false, "Could not reach Friends bot"); + return; } + Dispatcher.Invoke(() => + { + MyFriendCodeText.Text = FormatCode(code); + MyFriendCodeSection.Visibility = Visibility.Visible; + }); + // Initial visibility try { - using var lookReq = MakeRequest(HttpMethod.Get, $"/friends/lookup/{code}"); - var lookRes = await SendAsync(lookReq); + JObject lookRes = await FriendsPresence.LookupAsync(code); if (lookRes["is_public"] != null) isPublic = lookRes["is_public"].Value() == 1; UpdateVisibilityUI(); } catch { } @@ -148,32 +139,46 @@ private async Task RegisterWithBot() private void StartPolling() { - if (!isRunning) return; + lock (stateLock) + { + if (isPollingActive || !isRunning) return; + isPollingActive = true; + } + Task.Run(async () => { - while (isRunning) + try { - await Task.Delay(20000); - try { await PollFriends(); } catch { } + while (isRunning) + { + await Task.Delay(2500); + try { await PollFriends(); } catch { } + } + } + finally + { + lock (stateLock) + { + isPollingActive = false; + } } }); } private async Task PollFriends() { - if (!BotConfigured) return; + if (!FriendsPresence.Configured) return; try { - using var req = MakeRequest(HttpMethod.Get, "/friends/list"); - var result = await SendAsync(req); - var friendList = result["friends"] as JArray; + JArray friendList = await FriendsPresence.GetFriendsAsync(); + if (friendList == null) return; Dispatcher.Invoke(() => { // Update existing, add new, remove old var incomingCodes = friendList.Select(f => f["friend_code"]?.ToString()).ToList(); - + // Remove var toRemove = friends.Where(f => !incomingCodes.Contains(f.FriendCode)).ToList(); foreach (var f in toRemove) friends.Remove(f); @@ -189,15 +194,23 @@ private async Task PollFriends() friends.Add(friend); } - friend.Name = fJson["echo_username"]?.ToString() - ?? fJson["discord_username"]?.ToString() + friend.Name = fJson["echo_username"]?.ToString() + ?? fJson["discord_username"]?.ToString() ?? code; - + bool online = fJson["online"]?.Value() ?? false; if (online) - friend.UpdateStatus(fJson["lobby_id"]?.ToString(), fJson["team"]?.ToString(), fJson["mode"]?.ToString()); + { + friend.UpdateStatus( + fJson["lobby_id"]?.ToString(), + fJson["team"]?.ToString(), + fJson["mode"]?.ToString(), + fJson["session_type"]?.ToString()); + } else + { friend.SetOffline(); + } } UpdateOnlineCount(); @@ -218,14 +231,13 @@ private void AddFriend() { try { - using var req = MakeRequest(HttpMethod.Post, $"/friends/add/{code}"); - await SendAsync(req); + await FriendsPresence.AddFriendAsync(code); Dispatcher.Invoke(() => AddFriendCodeBox.Text = ""); await PollFriends(); } - catch (Exception ex) - { - Dispatcher.Invoke(() => new MessageBox("Error: " + ex.Message, "Error").Show()); + catch (Exception ex) + { + Dispatcher.Invoke(() => new MessageBox("Error: " + ex.Message, "Error").Show()); } }); } @@ -238,8 +250,7 @@ private void RemoveFriendClicked(object sender, RoutedEventArgs e) { try { - using var req = MakeRequest(HttpMethod.Delete, $"/friends/remove/{code}"); - await SendAsync(req); + await FriendsPresence.RemoveFriendAsync(code); await PollFriends(); } catch { } }); @@ -250,12 +261,11 @@ private void RemoveFriendClicked(object sender, RoutedEventArgs e) private async void VisibilityToggleClicked(object sender, RoutedEventArgs e) { - if (!BotConfigured) return; + if (!FriendsPresence.Configured) return; bool newState = !isPublic; try { - using var req = MakeRequest(HttpMethod.Post, "/friends/visibility", new { @public = newState }); - await SendAsync(req); + await FriendsPresence.SetVisibilityAsync(newState); isPublic = newState; UpdateVisibilityUI(); } catch { } @@ -265,7 +275,9 @@ private void UpdateVisibilityUI() { Dispatcher.Invoke(() => { - VisibilityDot.Fill = isPublic ? new SolidColorBrush(Color.FromRgb(76, 175, 80)) : new SolidColorBrush(Color.FromRgb(244, 67, 54)); + // SetResourceReference rather than FindResource: this keeps a live link to the theme + // brush, so the dot recolours with everything else when the theme changes. + VisibilityDot.SetResourceReference(Shape.FillProperty, isPublic ? "StatusGood" : "StatusBad"); VisibilityButtonText.Text = isPublic ? "Visible — Click to Hide" : "Hidden — Click to Show"; VisibilityDescription.Text = isPublic ? "You are visible to friends." : "You are hidden. Friends see you as offline."; }); @@ -275,12 +287,82 @@ private async void JoinFriendClicked(object sender, RoutedEventArgs e) { if (sender is Button btn && btn.DataContext is FriendViewModel friend) { - if (string.IsNullOrEmpty(friend.SessionId)) return; + if (string.IsNullOrEmpty(friend.SessionId)) + { + // Both join routes need an id - /join_session takes one, and the game is + // launched with -lobbyid - so there is nothing to try without it. Say why + // instead of returning silently off a button that now looks clickable. + ShowNoSessionNotice(friend); + return; + } bool ok = await TryApiJoin(friend.SessionId); if (!ok) ShowTeamChooser(friend); } } + /// + /// Explains why a friend cannot be joined yet. A lobby id comes from that friend's own + /// game log rather than the API, so it is missing when their Spark is older, is watching a + /// Quest over the network, or simply has not seen the room line yet. + /// + private void ShowNoSessionNotice(FriendViewModel friend) + { + var win = new Window + { + Title = $"Join {friend.Name}", + Width = 380, + SizeToContent = SizeToContent.Height, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + ResizeMode = ResizeMode.NoResize, + Topmost = true + }; + win.SetResourceReference(Control.BackgroundProperty, "SurfaceGround"); + + var stack = new StackPanel { Margin = new Thickness(20) }; + + var heading = new TextBlock + { + Text = $"{friend.Name} is not joinable yet", + FontSize = 14, + FontWeight = FontWeights.Bold, + Margin = new Thickness(0, 0, 0, 10), + TextWrapping = TextWrapping.Wrap + }; + heading.SetResourceReference(TextBlock.ForegroundProperty, "TextPrimary"); + stack.Children.Add(heading); + + var body = new TextBlock + { + Text = "Their Spark has not shared a session id. A lobby id is read from the " + + "friend's own game log, so this happens when they are running an older " + + "Spark, are playing on Quest over the network, or have only just joined " + + "the room.\n\nIt should become joinable on its own once their Spark " + + "reports the room.", + FontSize = 12, + LineHeight = 18, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 0, 0, 16) + }; + body.SetResourceReference(TextBlock.ForegroundProperty, "TextDim"); + stack.Children.Add(body); + + var ok = new Button + { + Content = "OK", + Height = 32, + Width = 90, + HorizontalAlignment = HorizontalAlignment.Right, + BorderThickness = new Thickness(0) + }; + ok.SetResourceReference(Control.BackgroundProperty, "SurfaceRaised"); + ok.SetResourceReference(Control.ForegroundProperty, "TextPrimary"); + ok.Click += (s2, e2) => win.Close(); + stack.Children.Add(ok); + + win.Content = stack; + win.ShowDialog(); + } + private async Task TryApiJoin(string sessionId) { try @@ -296,13 +378,47 @@ private async Task TryApiJoin(string sessionId) private void ShowTeamChooser(FriendViewModel friend) { - var win = new Window { Title = $"Join {friend.Name}", Width = 280, Height = 220, WindowStartupLocation = WindowStartupLocation.CenterScreen, Background = (Brush)new BrushConverter().ConvertFrom("#121212"), ResizeMode = ResizeMode.NoResize, Topmost = true }; + var win = new Window + { + Title = $"Join {friend.Name}", + Width = 280, + Height = 220, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + ResizeMode = ResizeMode.NoResize, + Topmost = true + }; + win.SetResourceReference(Control.BackgroundProperty, "SurfaceGround"); + var stack = new StackPanel { Margin = new Thickness(20) }; - stack.Children.Add(new TextBlock { Text = $"Join {friend.Name} as:", Foreground = Brushes.White, FontSize = 14, FontWeight = FontWeights.Bold, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 0, 0, 14) }); - foreach (var (label, hex, arg) in new[] { ("Blue", "#3B82F6", "blue"), ("Orange", "#F59E0B", "orange"), ("Spectator", "#6B7280", "spectator") }) + var heading = new TextBlock + { + Text = $"Join {friend.Name} as:", + FontSize = 14, + FontWeight = FontWeights.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 0, 0, 14) + }; + heading.SetResourceReference(TextBlock.ForegroundProperty, "TextPrimary"); + stack.Children.Add(heading); + + foreach (var (label, brushKey, foregroundKey, arg) in new[] + { + ("Blue", "TeamBlue", "TeamBlueForeground", "blue"), + ("Orange", "TeamOrange", "TeamOrangeForeground", "orange"), + ("Spectator", "SurfaceRaised", "TextPrimary", "spectator"), + }) { var teamArg = arg; - var btn = new Button { Content = label, Height = 34, Margin = new Thickness(0, 0, 0, 8), Background = (SolidColorBrush)new BrushConverter().ConvertFrom(hex), Foreground = Brushes.White, FontWeight = FontWeights.Bold, BorderThickness = new Thickness(0) }; + var btn = new Button + { + Content = label, + Height = 34, + Margin = new Thickness(0, 0, 0, 8), + FontWeight = FontWeights.Bold, + BorderThickness = new Thickness(0) + }; + btn.SetResourceReference(Control.BackgroundProperty, brushKey); + btn.SetResourceReference(Control.ForegroundProperty, foregroundKey); btn.Click += (s, ev) => { LaunchEcho(friend.SessionId, teamArg); win.Close(); }; stack.Children.Add(btn); } @@ -315,21 +431,18 @@ private void LaunchEcho(string sessionId, string team) try { string lobbyId = sessionId.Split('.')[0]; - string args = team == "spectator" ? $"-lobbyid {lobbyId} -spectatorstream" : $"-lobbyid {lobbyId} -lobbyteam {team}"; - System.Diagnostics.Process.Start(SparkSettings.instance.echoVRPath, args); - } catch { } - } - public static async Task PushLobbyUpdate(string lobbyId, string team, string mode) - { - if (!DiscordOAuth.IsLoggedIn || string.IsNullOrEmpty(DiscordOAuth.oauthToken)) return; - try { using var req = MakeRequest(HttpMethod.Post, "/friends/lobby", new { lobby_id = lobbyId, team, mode }); await http.SendAsync(req); } catch { } - } + if (team == "spectator") + { + // A spectator client can't be launched with -lobbyid, so this can't just be + // another Process.Start: it has to come up on -spectatorstream and then be + // moved into the session over its HTTP API. + _ = SimpleSpectateController.SpectateSessionAsync(lobbyId, SparkSettings.instance.spectatorStreamNoOVR); + return; + } - public static async Task PushOffline() - { - if (!DiscordOAuth.IsLoggedIn || string.IsNullOrEmpty(DiscordOAuth.oauthToken)) return; - try { using var req = MakeRequest(HttpMethod.Post, "/friends/offline"); await http.SendAsync(req); } catch { } + System.Diagnostics.Process.Start(SparkSettings.instance.echoVRPath, $"-lobbyid {lobbyId} -lobbyteam {team}"); + } catch { } } private void AddFriendKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Enter) AddFriend(); } @@ -338,49 +451,125 @@ public static async Task PushOffline() private void RefreshClicked(object sender, RoutedEventArgs e) => Task.Run(() => PollFriends()); private void UpdateOnlineCount() { int n = friends.Count(f => f.IsOnline); OnlineCountBadge.Visibility = Visibility.Visible; OnlineCountText.Text = $"{n} Online"; } private void UpdateEmptyHint() { if (EmptyFriendsHint != null) EmptyFriendsHint.Visibility = friends.Count == 0 ? Visibility.Visible : Visibility.Collapsed; } - private void SetStatus(bool? ok, string text) { Dispatcher.Invoke(() => { ApiStatusText.Text = text; ApiStatusDot.Fill = new SolidColorBrush(ok == null ? Color.FromRgb(255, 165, 0) : ok == true ? Color.FromRgb(76, 175, 80) : Color.FromRgb(244, 67, 54)); }); } + + private void SetStatus(bool? ok, string text) + { + Dispatcher.Invoke(() => + { + ApiStatusText.Text = text; + ApiStatusDot.SetResourceReference(Shape.FillProperty, ok == null ? "StatusWarn" : ok == true ? "StatusGood" : "StatusBad"); + }); + } + private string FormatCode(string code) => code?.Length == 8 ? code.Substring(0, 4) + " " + code.Substring(4) : (code ?? ""); public void Shutdown() => isRunning = false; } + /// + /// A friend row. Colours aren't held here — the view maps and + /// onto theme brushes with DynamicResource, so a theme change repaints + /// the list without the tab having to hear about it. + /// public class FriendViewModel : System.ComponentModel.INotifyPropertyChanged { - private string name = "", friendCode = "", statusColor = "#444444", teamColor = "#444444"; + /// Which team-ish colour this row wears. + public enum AccentKind { None, Blue, Orange } + + /// What the friend is doing, in as much detail as we can join on. + public enum PresenceKind { Offline, Menu, Lobby, Match } + + private string name = "", friendCode = ""; private string modeText = "Offline", separatorText = "", sessionTypeText = "", statusBadgeText = "", sessionId = "", sessionIdText = ""; - private bool isOnline = false; + private AccentKind accent = AccentKind.None; + private PresenceKind presence = PresenceKind.Offline; private double opacity = 0.6; public string FriendCode { get => friendCode; set { friendCode = value; Notify(); } } public string Name { get => string.IsNullOrEmpty(name) ? FriendCode : name; set { name = value; Notify(); } } - public string StatusColor { get => statusColor; set { statusColor = value; Notify(); } } - public string TeamColor { get => teamColor; set { teamColor = value; Notify(); } } public string ModeText { get => modeText; set { modeText = value; Notify(); } } public string SeparatorText { get => separatorText; set { separatorText = value; Notify(); } } public string SessionTypeText { get => sessionTypeText; set { sessionTypeText = value; Notify(); } } - public string StatusBadgeText { get => statusBadgeText; set { statusBadgeText = value; Notify(); } } - public string SessionId { get => sessionId; set { sessionId = value; Notify(); } } + public string StatusBadgeText { get => statusBadgeText; set { statusBadgeText = value; Notify(); Notify(nameof(ShowStatusBadge)); } } + public string SessionId { get => sessionId; set { sessionId = value; Notify(); NotifyMultiple("IsJoinable", "HasJoinableSession", "JoinButtonTooltip"); } } public string SessionIdText { get => sessionIdText; set { sessionIdText = value; Notify(); } } - public bool IsOnline { get => isOnline; set { isOnline = value; Notify(); NotifyMultiple("ShowJoinButton","ShowStatusBadge","IsJoinable"); } } + public AccentKind Accent { get => accent; set { accent = value; Notify(); } } public double Opacity { get => opacity; set { opacity = value; Notify(); } } - public Visibility ShowStatusBadge => IsOnline ? Visibility.Visible : Visibility.Collapsed; - public Visibility ShowJoinButton => IsOnline ? Visibility.Visible : Visibility.Collapsed; - public bool IsJoinable => IsOnline && !string.IsNullOrEmpty(SessionId); + public PresenceKind Presence + { + get => presence; + set { presence = value; Notify(); NotifyMultiple("IsOnline", "ShowJoinButton", "ShowStatusBadge", "IsJoinable", "HasJoinableSession", "JoinButtonTooltip"); } + } + + public bool IsOnline => Presence != PresenceKind.Offline; + + public Visibility ShowStatusBadge => IsOnline && !string.IsNullOrEmpty(StatusBadgeText) ? Visibility.Visible : Visibility.Collapsed; + + /// + /// Lobbies are joinable now that their session id comes out of the friend's log, so the + /// button shows for anything with a session behind it — not just matches. + /// + public Visibility ShowJoinButton => IsOnline ? Visibility.Visible : Visibility.Collapsed; + + /// + /// Enabled for anyone online. This used to also require a session id, which greyed the + /// button out for friends sitting in a lobby whose client had not reported a room id yet - + /// the common case, since a lobby id comes from the friend's own game log rather than the + /// API. A greyed button gave no clue why, so the button now stays live and the click + /// explains what is missing. + /// + public bool IsJoinable => IsOnline; - public void UpdateStatus(string lobbyId, string team, string mode) + /// True when we actually hold something we can hand to the game. + public bool HasJoinableSession => !string.IsNullOrEmpty(SessionId); + + public string JoinButtonTooltip => HasJoinableSession + ? $"Join {Name}" + : $"{Name} has not shared a joinable session id yet"; + + public void UpdateStatus(string lobbyId, string team, string mode, string sessionType) { - IsOnline = true; Opacity = 1.0; StatusColor = "#4CAF50"; SessionId = lobbyId ?? ""; + SessionId = lobbyId ?? ""; + + Presence = sessionType switch + { + FriendsPresence.SessionTypeMatch => PresenceKind.Match, + FriendsPresence.SessionTypeLobby => PresenceKind.Lobby, + FriendsPresence.SessionTypeMenu => PresenceKind.Menu, + // Older Spark builds don't send a session type; having a session id means a match. + _ => string.IsNullOrEmpty(lobbyId) ? PresenceKind.Menu : PresenceKind.Match, + }; + + Opacity = 1.0; + string raw = (mode ?? "Unknown").Replace("_", " "); ModeText = raw.Length > 0 ? char.ToUpper(raw[0]) + raw.Substring(1).ToLower() : "Unknown"; - SeparatorText = " • "; SessionTypeText = "In Match"; - StatusBadgeText = (team ?? "Lobby").ToUpper(); - TeamColor = StatusBadgeText == "BLUE" ? "#3B82F6" : StatusBadgeText == "ORANGE" ? "#F59E0B" : "#444444"; - SessionIdText = "Session: " + (lobbyId?.Length > 12 ? lobbyId.Substring(0, 12) + "..." : lobbyId); + SeparatorText = " • "; + SessionTypeText = Presence switch + { + PresenceKind.Match => "In Match", + PresenceKind.Lobby => string.IsNullOrEmpty(SessionId) ? "In Lobby" : "In Lobby — joinable", + _ => "In Menu", + }; + + StatusBadgeText = Presence == PresenceKind.Match ? (team ?? "lobby").ToUpper() : Presence == PresenceKind.Lobby ? "LOBBY" : ""; + Accent = StatusBadgeText switch + { + "BLUE" => AccentKind.Blue, + "ORANGE" => AccentKind.Orange, + _ => AccentKind.None, + }; + + SessionIdText = string.IsNullOrEmpty(SessionId) + ? "" + : "Session: " + (SessionId.Length > 12 ? SessionId.Substring(0, 12) + "..." : SessionId); } public void SetOffline() { - IsOnline = false; Opacity = 0.6; StatusColor = "#444444"; TeamColor = "#444444"; + Presence = PresenceKind.Offline; + Opacity = 0.6; + Accent = AccentKind.None; ModeText = "Offline"; SeparatorText = ""; SessionTypeText = ""; StatusBadgeText = ""; SessionId = ""; SessionIdText = ""; } diff --git a/Windows/LiveWindow/LiveWindow.xaml b/Windows/LiveWindow/LiveWindow.xaml index 6f6fc44..ae7b83f 100644 --- a/Windows/LiveWindow/LiveWindow.xaml +++ b/Windows/LiveWindow/LiveWindow.xaml @@ -1,4 +1,4 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -35,259 +172,1100 @@ + + + + + - + - + - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - 0 - - - 00:00.00 - - - - - - - - - 0 - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + - - + - - + - @@ -298,11 +1276,11 @@ - + - @@ -422,23 +1400,103 @@ - + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - @@ -512,11 +1570,11 @@ - + - @@ -524,15 +1582,103 @@ - - + + + + + + + + + + + + + + + + + + - + + + - + - - + + + + + + + + + + + + + + + + + + + + + + + - + - + + + + + + + + + + - - - - - - - - - + - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - Dashboard Tabs (Visible tabs on the main screen) - - - - - - - - - - - - - - - - - - - Spectatorstream (Spectates highest level public match) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + --- + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +