From 677f085d38513630b1357cf04baddc96c8944b87 Mon Sep 17 00:00:00 2001 From: jakzo Date: Mon, 22 Jul 2024 19:51:35 +1000 Subject: [PATCH] boneworks 100% status livesplit component --- changesets/Boneworks_SpeedrunTools_Minor.md | 1 + .../LiveSplit_BoneworksHundredStatus_Major.md | 1 + common/Boneworks/HundredPercentState.cs | 46 ++++- common/LiveSplit/Logger.cs | 7 +- common/Utilities/IpcServer.cs | 158 ++++++++++++--- common/Utilities/Resources.cs | 25 +++ common/Utilities/Unity.cs | 4 +- projects/Boneworks/Randomizer/Project.csproj | 2 +- .../SpeedrunTools/SpeedrunTools.csproj | 12 +- .../resources/DefaultCollectibleOrder.zip | Bin 0 -> 21822 bytes .../SpeedrunTools/src/Features/AslHelper.cs | 135 ------------- .../src/Features/CollectibleRecorder.cs | 175 ++++++++++++++++ .../src/Features/HundredPercentServer.cs | 191 +++++++++++++++++- projects/Boneworks/SpeedrunTools/src/Mod.cs | 3 +- .../SpeedrunTools/src/Speedruns/AntiCheat.cs | 2 + .../SpeedrunTools/src/Speedruns/Mode.cs | 6 +- .../src/Speedruns/SaveUtilities.cs | 20 +- projects/Boneworks/SpeedrunTools/src/Utils.cs | 12 ++ .../BoneworksHundredStatus.csproj | 10 +- .../BoneworksHundredStatus/README.md | 44 +++- .../src/BoneworksStateUpdater.cs | 28 ++- .../BoneworksHundredStatus/src/Component.cs | 105 ++++++++-- 22 files changed, 758 insertions(+), 229 deletions(-) create mode 100644 changesets/Boneworks_SpeedrunTools_Minor.md create mode 100644 changesets/LiveSplit_BoneworksHundredStatus_Major.md create mode 100644 common/Utilities/Resources.cs create mode 100644 projects/Boneworks/SpeedrunTools/resources/DefaultCollectibleOrder.zip delete mode 100644 projects/Boneworks/SpeedrunTools/src/Features/AslHelper.cs create mode 100644 projects/Boneworks/SpeedrunTools/src/Features/CollectibleRecorder.cs diff --git a/changesets/Boneworks_SpeedrunTools_Minor.md b/changesets/Boneworks_SpeedrunTools_Minor.md new file mode 100644 index 0000000..6b8873a --- /dev/null +++ b/changesets/Boneworks_SpeedrunTools_Minor.md @@ -0,0 +1 @@ +Added support for sending data to the Boneworks 100% run status LiveSplit component. diff --git a/changesets/LiveSplit_BoneworksHundredStatus_Major.md b/changesets/LiveSplit_BoneworksHundredStatus_Major.md new file mode 100644 index 0000000..0b524c9 --- /dev/null +++ b/changesets/LiveSplit_BoneworksHundredStatus_Major.md @@ -0,0 +1 @@ +First release. diff --git a/common/Boneworks/HundredPercentState.cs b/common/Boneworks/HundredPercentState.cs index b7696ab..0794f05 100644 --- a/common/Boneworks/HundredPercentState.cs +++ b/common/Boneworks/HundredPercentState.cs @@ -1,13 +1,45 @@ +using System.Collections.Generic; +using System.Linq; + namespace Sst.Common.Boneworks { public class HundredPercentState { public const string NAMED_PIPE = "BoneworksHundredPercent"; + public const string TYPE_AMMO_LIGHT = "ammo_light"; + public const string TYPE_AMMO_MEDIUM = "ammo_medium"; + public const string TYPE_ITEM = "item"; + + public Dictionary rngUnlocks = + new[] { + ("Baseball", "81207815-e447-430b-9ea6-6d8c35842fef", 0.1f), + ("Golf Club", "290780a8-4f88-451a-88a5-1599f8e7e89f", 0.02f), + ("Baton", "53e4ff47-b0f4-426c-956d-ed391ca6f5f7", 0.1f), + } + .ToDictionary(def => def.Item2, def => new RngState() { + name = def.Item1, + attempts = 0, + prevAttemptChance = def.Item3, + probabilityNotDroppedYet = 1f, + hasDropped = false, + }); + public int unlockLevelCount; + public int unlockLevelMax; + public int ammoLevelCount; + public int ammoLevelMax; + public Collectible[] justCollected; + public Collectible[] levelCollectibles; + + public class Collectible { + public string Type; + public string Uuid; + public string DisplayName; + } - public int unlockRngCount; - public int unlockRngMax; - public int unlockNormalCount; - public int unlockNormalMax; - public int unlockNormalLevel; - public int levelAmmoCount; - public int levelAmmoMax; + public class RngState { + public string name; + public int attempts; + public float prevAttemptChance; + public float probabilityNotDroppedYet; + public bool hasDropped; + } } } diff --git a/common/LiveSplit/Logger.cs b/common/LiveSplit/Logger.cs index 179290b..73e7eab 100644 --- a/common/LiveSplit/Logger.cs +++ b/common/LiveSplit/Logger.cs @@ -4,7 +4,8 @@ namespace Sst.Common.LiveSplit { static class Log { public static string LOG_FILE = - Environment.GetEnvironmentVariable("SST_LIVESPLIT_LOG_PATH"); + Environment.GetEnvironmentVariable("SST_LIVESPLIT_LOG_PATH") ?? + $"{BuildInfo.NAME}.log"; public static void Initialize() { if (LOG_FILE == null) @@ -18,9 +19,11 @@ public static void Initialize() { public static void Error(string message) { LogImpl("ERROR", message); } private static void LogImpl(string prefix, string message) { + var text = $"{prefix} [{BuildInfo.NAME}] {message}\n"; + Console.Write(text); if (LOG_FILE == null) return; - File.AppendAllText(LOG_FILE, $"{prefix} [{BuildInfo.NAME}] {message}\n"); + File.AppendAllText(LOG_FILE, text); } } } diff --git a/common/Utilities/IpcServer.cs b/common/Utilities/IpcServer.cs index 3805a2d..c5e20e8 100644 --- a/common/Utilities/IpcServer.cs +++ b/common/Utilities/IpcServer.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.IO.Pipes; using System.Text; -using System.Threading.Tasks; +using System.Threading; namespace Sst.Common.Ipc { public abstract class Logger { @@ -14,7 +14,7 @@ public abstract class Logger { public class Server : IDisposable { public event Action OnClientConnected; public event Action OnClientDisconnected; - // public event Action OnMessageReceived; + public event Action OnMessageReceived; public string Name; @@ -41,59 +41,155 @@ public void Dispose() { } public void Send(string message) { - foreach (var stream in _streams.ToArray()) - SendToStream(stream, message); + foreach (var stream in _streams.ToArray()) { + try { + SendToStream(stream, message); + } catch (Exception ex) { + _logger.Error("Error sending IPC message:"); + _logger.Error(ex.ToString()); + DisposeStream(stream); + } + } } public static void SendToStream(NamedPipeServerStream stream, string message) { if (!stream.IsConnected) return; + var bytes = Encoding.UTF8.GetBytes(message); stream.Write(bytes, 0, bytes.Length); } private void StartNewPipeServerThread() { - new System.Threading.Thread(StartNewPipeServer).Start(); + new Thread(StartNewPipeServer).Start(); } private void StartNewPipeServer() { try { - var stream = new NamedPipeServerStream(Name, PipeDirection.InOut, - MAX_NUMBER_OF_SERVER_INSTANCES, - PipeTransmissionMode.Message); + var stream = new NamedPipeServerStream( + Name, PipeDirection.InOut, MAX_NUMBER_OF_SERVER_INSTANCES, + PipeTransmissionMode.Message, PipeOptions.None, BUFFER_SIZE, + BUFFER_SIZE); _streams.Add(stream); stream.WaitForConnection(); _logger.Debug("Client connected"); if (_isDisposed) return; - StartNewPipeServerThread(); SafeInvoke(() => OnClientConnected?.Invoke(stream)); - // TODO: The stream.Read() call blocks writes - // var buffer = new byte[BUFFER_SIZE]; - // StringBuilder sb = null; - // while (true) { - // if (sb == null) - // sb = new StringBuilder(); - // var numBytes = stream.Read(buffer, 0, buffer.Length); - // if (_isDisposed) - // return; - // if (numBytes <= 0) { - // DisposeStream(stream); - // return; - // } - - // sb.Append(Encoding.UTF8.GetString(buffer, 0, numBytes)); - - // if (stream.IsMessageComplete) { - // var message = sb.ToString().TrimEnd('\0'); - // SafeInvoke(() => OnMessageReceived?.Invoke(message)); - // sb = null; - // } - // } + // TODO: stream.Read() blocks writes and many named pipe features are + // missing from Mono + // Task.Run(() => ReadFromPipeConnection(stream)); + // ReadFromPipeConnection2(stream); + // Task.Run(() => PollPipeConnection(stream)); } catch (Exception ex) { _logger.Error($"Pipe server failed: {ex}"); + } finally { + if (!_isDisposed) + StartNewPipeServerThread(); + } + } + + private void ReadFromPipeConnection(NamedPipeServerStream stream) { + var buffer = new byte[BUFFER_SIZE]; + StringBuilder sb = null; + while (true) { + if (sb == null) + sb = new StringBuilder(); + var numBytes = stream.Read(buffer, 0, buffer.Length); + if (_isDisposed) + return; + if (numBytes <= 0) { + DisposeStream(stream); + return; + } + + sb.Append(Encoding.UTF8.GetString(buffer, 0, numBytes)); + + if (stream.IsMessageComplete) { + var message = sb.ToString().TrimEnd('\0'); + SafeInvoke(() => OnMessageReceived?.Invoke(message)); + sb = null; + } + } + } + + private void ReadFromPipeConnection2(NamedPipeServerStream stream) { + var buffer = new byte[BUFFER_SIZE]; + var sb = new StringBuilder(); + + void ReadCallback(IAsyncResult ar) { + try { + int numBytes = stream.EndRead(ar); + if (_isDisposed) + return; + if (numBytes <= 0) { + DisposeStream(stream); + return; + } + + sb.Append(Encoding.UTF8.GetString(buffer, 0, numBytes)); + + if (stream.IsMessageComplete) { + var message = sb.ToString().TrimEnd('\0'); + SafeInvoke(() => OnMessageReceived?.Invoke(message)); + sb.Clear(); + } + + stream.BeginRead(buffer, 0, buffer.Length, ReadCallback, null); + } catch (Exception ex) { + _logger.Error("Error while reading from pipe connection:"); + _logger.Error(ex.ToString()); + DisposeStream(stream); + } + } + + try { + stream.BeginRead(buffer, 0, buffer.Length, ReadCallback, null); + } catch (Exception ex) { + _logger.Error("Failed to begin read operation:"); + _logger.Error(ex.ToString()); + DisposeStream(stream); + } + } + + private void PollPipeConnection(NamedPipeServerStream stream) { + var buffer = new byte[BUFFER_SIZE]; + var sb = new StringBuilder(); + + try { + while (true) { + if (_isDisposed) + return; + + if (stream.IsConnected) { + while (stream.IsMessageComplete) { + int numBytes = stream.Read(buffer, 0, buffer.Length); + if (numBytes > 0) { + sb.Append(Encoding.UTF8.GetString(buffer, 0, numBytes)); + } else { + DisposeStream(stream); + return; + } + + if (stream.IsMessageComplete) { + var message = sb.ToString().TrimEnd('\0'); + _logger.Debug("Finally got a message! " + message); + SafeInvoke(() => OnMessageReceived?.Invoke(message)); + sb.Clear(); + } + } + } + + _logger.Debug("IPC waiting " + stream.IsConnected + " " + + stream.IsMessageComplete); + Thread.Sleep(1000); + } + } catch (Exception ex) { + _logger.Error("Error while reading from pipe:"); + _logger.Error(ex.ToString()); + DisposeStream(stream); } } diff --git a/common/Utilities/Resources.cs b/common/Utilities/Resources.cs new file mode 100644 index 0000000..cd9c99d --- /dev/null +++ b/common/Utilities/Resources.cs @@ -0,0 +1,25 @@ +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; + +namespace Sst.Utilities { +public static class Resources { + public static void ExtractResource(string resourceName, string dir) { + var assembly = Assembly.GetExecutingAssembly(); + string resourcePath = assembly.GetManifestResourceNames().Single( + str => str.EndsWith(resourceName)); + using (var stream = assembly.GetManifestResourceStream(resourcePath)) { + using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) { + foreach (var entry in archive.Entries) { + var entryStream = entry.Open(); + using (var fileStream = + File.Create(Path.Combine(dir, entry.FullName))) { + entryStream.CopyTo(fileStream); + } + } + } + } + } +} +} \ No newline at end of file diff --git a/common/Utilities/Unity.cs b/common/Utilities/Unity.cs index b74078f..d82041e 100644 --- a/common/Utilities/Unity.cs +++ b/common/Utilities/Unity.cs @@ -94,7 +94,7 @@ public static Color GenerateColor(int i) => Color.HSVToRGB(i * 0.064f % 1f, 0.9f - i / 16 * 0.3f % 0.8f, 0.9f); public static Shader FindShader(string name) => - Resources.FindObjectsOfTypeAll().First(shader => shader.name == - name); + UnityEngine.Resources.FindObjectsOfTypeAll().First( + shader => shader.name == name); } } diff --git a/projects/Boneworks/Randomizer/Project.csproj b/projects/Boneworks/Randomizer/Project.csproj index c8199da..d98d96d 100644 --- a/projects/Boneworks/Randomizer/Project.csproj +++ b/projects/Boneworks/Randomizer/Project.csproj @@ -6,7 +6,7 @@ - true + {9746DCEC-6A9C-4E84-98E2-B141A702FDD9} diff --git a/projects/Boneworks/SpeedrunTools/SpeedrunTools.csproj b/projects/Boneworks/SpeedrunTools/SpeedrunTools.csproj index 68bb426..f8eb8e2 100644 --- a/projects/Boneworks/SpeedrunTools/SpeedrunTools.csproj +++ b/projects/Boneworks/SpeedrunTools/SpeedrunTools.csproj @@ -58,6 +58,9 @@ ..\..\..\references\Boneworks\Il2Cppmscorlib.dll + + ..\..\..\references\Boneworks\Il2CppSystem.Core.dll + ..\..\..\references\Boneworks\SteamVR.dll @@ -79,6 +82,9 @@ ..\..\..\references\Boneworks\TextMeshPro-1.0.55.2017.2.0b12.dll + + ..\..\..\references\Boneworks\Newtonsoft.Json.dll + @@ -86,20 +92,22 @@ + + - + + + COPY "$(TargetPath)" "C:\Users\jakzo\Downloads\LiveSplit_1.8.28\Components\LiveSplit.BoneworksHundredStatus.dll" + \ No newline at end of file diff --git a/projects/LiveSplit/BoneworksHundredStatus/README.md b/projects/LiveSplit/BoneworksHundredStatus/README.md index b0b9907..020a16d 100644 --- a/projects/LiveSplit/BoneworksHundredStatus/README.md +++ b/projects/LiveSplit/BoneworksHundredStatus/README.md @@ -4,8 +4,42 @@ Shows game progress and achievements for Boneworks 100% speedruns in LiveSplit. - Download [BoneworksHundredStatus.dll](https://github.com/jakzo/SlzMods/raw/main/projects/LiveSplit/BoneworksHundredStatus/Components/BoneworksHundredStatus.dll) (right-click on the link -> save as...) - Copy `BoneworksHundredStatus.dll` into `LIVESPLIT/Components/BoneworksHundredStatus.dll` (the `LIVESPLIT` directory depends on where you've installed LiveSplit to) -- Download [Newtonsoft.Json from nuget](https://www.nuget.org/packages/Newtonsoft.Json/) ("download package" link on the side) -- Open as ZIP (eg. rename from `.nupkg` to `.zip`) -- Copy `lib\net45\Newtonsoft.Json.dll` into `LIVESPLIT/Components/Newtonsoft.Json.dll` -- Open LiveSplit and edit layout -- Add (+) -> Other -> Boneworks 100% Status + +## Setup + +1. Make sure you have the SpeedrunTools v2.4 and LootDropBugfix v2.0.1 mods installed or later versions +1. [OPTIONAL] Record the collectibles for your route + - Simply play through the levels as you would normally while SpeedrunTools is running and it will save a list of all the things you collect after you finish each level to `BONEWORKS/UserData/SpeedrunTools/collectible_recordings/LEVEL_NAME.txt` + - Note that playing a level again will cause this file to be overwritten +1. [OPTIONAL] Confirm that you collected everything then copy these files to `BONEWORKS/UserData/SpeedrunTools/collectible_order/LEVEL_NAME.txt` + - This file will now be used during runs to tell you if you missed something and other stats like amount of ammo + - Note that you can manually edit the order or add/delete collectibles from this list if you want to make a tweak without recording the whole thing again + - You can skip this step if you are happy to follow my (jakzo's) route, since SpeedrunTools will copy recordings for this route into this folder if the folder does not already exist +1. Add this LiveSplit component to your layout and it should show the stats while SpeedrunTools is running + - Layout settings -> Add (+) -> Other -> Boneworks 100% Status + - You may also want to open a second LiveSplit instance and have this as the only component there so that you can customize your OBS layout more + +Be aware that some state may not be correct if you restart LiveSplit or Boneworks mid run + +## How it works + +- The order of collectibles are stored in `BONEWORKS/UserData/SpeedrunTools/collectible_order/LEVEL_NAME.txt` + - You may modify these orderings or record your own orderings (instructions above) +- "Level unlocks" readout + - The total number of unlocks is derived from the number of entries with type `item` in the ordering file for the current level + - The count is the number of these items from the ordering file which have been reclaimed +- "Level ammo" readout + - The total number of ammo pickups is derived from the number of entries with type `ammo_*` in the ordering file for the current level + - The count starts at 0 when the level starts and is incremented every time ammo is picked up +- "Missing" entries + - It is expected that you will collect each collectible from the ordering file in the same order they are specified there + - If you collect something which is not the entry after the previous one in the file, all uncollected entries before the entry you just collected will be marked as "missing" and show up in the LiveSplit component + - Collecting an item not in the ordering file has no effect on the readout +- The display for RNG item chances will always be visible + - Each item will start with a cross icon which changes to a tick icon as soon as it is dropped from a box + - A "try" is a single breaking of a box which has a chance to drop the item + - The chance of picking up an item is continuously updated to display the chance the previous box had to drop the item (although in practice it should never change) + - The total is the chance of getting the item after the current number of attempts + - Finishing with a lower total is more lucky, higher total is unlucky, 50% is average + - For example, the golf club has a 2% chance of dropping from each raffle box so if you are very lucky and get it from the first box the total will display 2% but if you are unlucky and it takes breaking 100 boxes before the golf club is dropped then the total will display 87% +- After all RNG items have been collected, a percentage showing the overall luck for the run regarding RNG items will display diff --git a/projects/LiveSplit/BoneworksHundredStatus/src/BoneworksStateUpdater.cs b/projects/LiveSplit/BoneworksHundredStatus/src/BoneworksStateUpdater.cs index e0a35cc..5073541 100644 --- a/projects/LiveSplit/BoneworksHundredStatus/src/BoneworksStateUpdater.cs +++ b/projects/LiveSplit/BoneworksHundredStatus/src/BoneworksStateUpdater.cs @@ -8,14 +8,24 @@ namespace Sst.Livesplit.BoneworksHundredStatus { class BoneworksStateUpdater : IDisposable { public event Action OnReceivedState; - public HundredPercentState State = new HundredPercentState(); + public HundredPercentState State; + public HundredPercentState.Collectible[] LevelCollectibles; + public Dictionary LevelCollectableIndexes; private readonly Common.Ipc.Client _client; public BoneworksStateUpdater() { _client = new Common.Ipc.Client(HundredPercentState.NAMED_PIPE); - _client.OnConnected += () => Log.Info("Connected"); - _client.OnDisconnected += () => Log.Info("Disconnected"); + _client.OnConnected += () => { + Log.Info("Connected"); + State = new HundredPercentState(); + OnReceivedState?.Invoke(State); + }; + _client.OnDisconnected += () => { + Log.Info("Disconnected"); + State = null; + OnReceivedState?.Invoke(State); + }; _client.OnMessageReceived += OnMessage; Log.Info("Listening for Boneworks state change"); } @@ -29,7 +39,17 @@ private void OnMessage(string message) { if (receivedState == null) return; State = receivedState; - OnReceivedState?.Invoke(receivedState); + if (State.levelCollectibles != null) { + LevelCollectibles = State.levelCollectibles; + LevelCollectableIndexes = new Dictionary(); + for (var i = 0; i < LevelCollectibles.Length; i++) { + var collectible = LevelCollectibles[i]; + if (!LevelCollectableIndexes.ContainsKey(collectible.Uuid)) { + LevelCollectableIndexes.Add(collectible.Uuid, i); + } + } + } + OnReceivedState?.Invoke(State); } catch (Exception ex) { Log.Error($"OnMessage error: {ex}"); } diff --git a/projects/LiveSplit/BoneworksHundredStatus/src/Component.cs b/projects/LiveSplit/BoneworksHundredStatus/src/Component.cs index 93e04a2..35f5d68 100644 --- a/projects/LiveSplit/BoneworksHundredStatus/src/Component.cs +++ b/projects/LiveSplit/BoneworksHundredStatus/src/Component.cs @@ -7,6 +7,7 @@ using LiveSplit.UI.Components; using LiveSplit.Model; using Sst.Common.LiveSplit; +using Sst.Common.Boneworks; namespace Sst.Livesplit.BoneworksHundredStatus { public class Component : IComponent { @@ -26,15 +27,53 @@ public class Component : IComponent { private SimpleLabel _progressLabel = new SimpleLabel(); private BoneworksStateUpdater _stateUpdater = new BoneworksStateUpdater(); - private int _eventIndex = 0; + private int _collectiblePos = 0; + private HashSet _missingCollectibles = + new HashSet(); private bool _isDirty = true; - private TimerModel _timer; - private string _prevLevelBarcode; public Component(LiveSplitState state) { Log.Initialize(); - _timer = new TimerModel() { CurrentState = state }; - _stateUpdater.OnReceivedState += receivedState => _isDirty = true; + _stateUpdater.OnReceivedState += OnReceivedState; + } + + private void OnReceivedState(HundredPercentState receivedState) { + _isDirty = true; + if (receivedState == null) + return; + + if (receivedState.levelCollectibles != null) { + _collectiblePos = 0; + _missingCollectibles = new HashSet(); + } + if (receivedState.justCollected != null) { + foreach (var collectible in receivedState.justCollected) { + if (!_stateUpdater.LevelCollectableIndexes.TryGetValue(collectible.Uuid, + out var index)) + continue; + + if (index >= _collectiblePos) { + for (var i = _collectiblePos; i < index; i++) { + _missingCollectibles.Add(_stateUpdater.LevelCollectibles[i]); + } + _collectiblePos = index + 1; + + // Right before we get the last collectible, mark it as missing so + // that we notice it before we reach the finish in case we forgot + // about it + var lastCollectibleIndex = _stateUpdater.LevelCollectibles.Length - 1; + if (index == lastCollectibleIndex - 1) { + _missingCollectibles.Add( + _stateUpdater.LevelCollectibles[lastCollectibleIndex]); + } + } + + if (_missingCollectibles.Contains( + _stateUpdater.LevelCollectibles[index])) { + _missingCollectibles.Remove(_stateUpdater.LevelCollectibles[index]); + } + } + } } public void DrawHorizontal(Graphics g, LiveSplitState state, float height, @@ -70,20 +109,58 @@ public System.Xml.XmlNode GetSettings(System.Xml.XmlDocument document) => document.CreateElement("Settings"); public void SetSettings(System.Xml.XmlNode settings) {} + // TODO: Settings page + // public Control GetSettingsControl(LayoutMode mode) { + // Settings.Mode = mode; + // return Settings; + // } + + // public System.Xml.XmlNode GetSettings(System.Xml.XmlDocument document) { + // return Settings.GetSettings(document); + // } + + // public void SetSettings(System.Xml.XmlNode settings) { + // Settings.SetSettings(settings); + // } + public void Dispose() { _stateUpdater.Dispose(); } public void Update(IInvalidator invalidator, LiveSplitState livesplitState, float width, float height, LayoutMode mode) { - if (_isDirty) { - _isDirty = false; - var state = _stateUpdater.State; - _progressLabel.Text = string.Join("\n", new[] { - $"RNG Unlocks: {state.unlockRngCount} / {state.unlockRngMax}", - $"Other Unlocks: {state.unlockNormalCount} / {state.unlockNormalMax} (level = ~{state.unlockNormalLevel})", - $"Level Ammo: {state.levelAmmoCount} / {state.levelAmmoMax}", - }); - invalidator.Invalidate(0, 0, width, height); + if (!_isDirty) + return; + + _isDirty = false; + var state = _stateUpdater.State; + if (state == null) { + _progressLabel.Text = ""; + } else { + var overallChance = state.rngUnlocks.Aggregate( + 1f, (chance, pair) => + chance * (1f - pair.Value.probabilityNotDroppedYet)); + var overallChanceStr = (overallChance * 100f).ToString("N0"); + _progressLabel.Text = string.Join( + "\n", + _missingCollectibles.Select(c => $"Missed: {c.DisplayName}") + .Concat(state.rngUnlocks.All(pair => pair.Value.hasDropped) + ? new[] { $"Overall RNG chance: {overallChanceStr}%" } + : new string[] {}) + .Concat(new[] { + $"Level unlocks: {state.unlockLevelCount} / {state.unlockLevelMax}", + $"Level Ammo: {state.ammoLevelCount} / {state.ammoLevelMax}", + }) + .Concat(state.rngUnlocks.Select(pair => { + var u = pair.Value; + var status = u.hasDropped ? "✅" : "❌"; + var triesStr = u.attempts == 1 ? "try" : "tries"; + var attemptChanceStr = + (u.prevAttemptChance * 100f).ToString("N0"); + var total = 1f - u.probabilityNotDroppedYet; + var totalStr = (total * 100f).ToString("N0"); + return $"{status} {u.name}: {u.attempts} {triesStr} @ {attemptChanceStr}% per try = {totalStr}% total"; + }))); } + invalidator?.Invalidate(0, 0, width, height); } } }