From 8dc897febae2f81284b5f15abb52fb5953478392 Mon Sep 17 00:00:00 2001 From: ivan tkachenko Date: Thu, 10 Jul 2025 00:12:55 +0300 Subject: [PATCH 1/5] Move track choosing out of the Jester patch class --- MuzikaGromche/Plugin.cs | 43 +++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index 9b4ef28..012da97 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -52,6 +52,34 @@ namespace MuzikaGromche } ]; + public static int IndexOfTrack(string trackName) + { + return Array.FindIndex(Tracks, track => track.Name == trackName); + } + + public static Track ChooseTrack() + { + var seed = RoundManager.Instance.dungeonGenerator.Generator.ChosenSeed; + var sha = SHA256.Create(); + var hash = sha.ComputeHash(BitConverter.GetBytes(seed)); + var trackId = 0; + foreach (var t in hash) + { + // modulus division on byte array + trackId *= 256 % Tracks.Length; + trackId %= Tracks.Length; + trackId += t % Tracks.Length; + trackId %= Tracks.Length; + } +#if DEBUG + // Override for testing + trackId = IndexOfTrack("DeployDestroy"); +#endif + var track = Tracks[trackId]; + Debug.Log($"Seed is {seed}, chosen track is \"{track.Name}\", {trackId} out of {Tracks.Length} tracks"); + return Tracks[trackId]; + } + public static Coroutine JesterLightSwitching; public static Track CurrentTrack; @@ -214,20 +242,7 @@ namespace MuzikaGromche __instance.creatureVoice.Stop(); // ...and start modded music - var seed = RoundManager.Instance.dungeonGenerator.Generator.ChosenSeed; - var sha = SHA256.Create(); - var hash = sha.ComputeHash(BitConverter.GetBytes(seed)); - var trackId = 0; - foreach (var t in hash) - { - // modulus division on byte array - trackId *= 256 % Plugin.Tracks.Length; - trackId %= Plugin.Tracks.Length; - trackId += t % Plugin.Tracks.Length; - trackId %= Plugin.Tracks.Length; - } - Debug.Log($"Seed is {seed}, chosen track is {trackId} out of {Plugin.Tracks.Length} tracks"); - Plugin.CurrentTrack = Plugin.Tracks[trackId]; + Plugin.CurrentTrack = Plugin.ChooseTrack(); __instance.popUpTimer = Plugin.CurrentTrack.WindUpTimer; __instance.farAudio.maxDistance = 150; __instance.farAudio.clip = Plugin.CurrentTrack.LoadedStart; From aead76272198b1bb8759cf58bf45023cac2eb4be Mon Sep 17 00:00:00 2001 From: ivan tkachenko Date: Sat, 12 Jul 2025 01:57:54 +0300 Subject: [PATCH 2/5] Add configuration weights for tracks Range is [0..100] but it's relative to total/sum. The algorithm guards against "all set to zero" scenario. This is not usable without synchronization. This commit provides none. --- MuzikaGromche/Plugin.cs | 116 +++++++++++++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 13 deletions(-) diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index 012da97..73489a2 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using BepInEx; +using BepInEx.Configuration; using HarmonyLib; using UnityEngine; using UnityEngine.Networking; @@ -60,23 +61,15 @@ namespace MuzikaGromche public static Track ChooseTrack() { var seed = RoundManager.Instance.dungeonGenerator.Generator.ChosenSeed; - var sha = SHA256.Create(); - var hash = sha.ComputeHash(BitConverter.GetBytes(seed)); - var trackId = 0; - foreach (var t in hash) - { - // modulus division on byte array - trackId *= 256 % Tracks.Length; - trackId %= Tracks.Length; - trackId += t % Tracks.Length; - trackId %= Tracks.Length; - } + int[] weights = [.. Tracks.Select(track => track.Weight.Value)]; + var rwi = new RandomWeightedIndex(weights); + var trackId = rwi.GetRandomWeightedIndex(seed); #if DEBUG // Override for testing - trackId = IndexOfTrack("DeployDestroy"); + // trackId = IndexOfTrack("DeployDestroy"); #endif var track = Tracks[trackId]; - Debug.Log($"Seed is {seed}, chosen track is \"{track.Name}\", {trackId} out of {Tracks.Length} tracks"); + Debug.Log($"Seed is {seed}, chosen track is \"{track.Name}\", #{trackId} of {rwi}"); return Tracks[trackId]; } @@ -158,6 +151,13 @@ namespace MuzikaGromche track.LoadedStart = DownloadHandlerAudioClip.GetContent(requests[i * 2]); track.LoadedLoop = DownloadHandlerAudioClip.GetContent(requests[i * 2 + 1]); } + // Initialize config + var chanceRange = new AcceptableValueRange(0, 100); + foreach (var track in Tracks) + { + string description = $"Random (relative) chance of selecting track {track.Name}. Set to zero to effectively disable the track."; + track.Weight = Config.Bind("Tracks", track.Name, 50, new ConfigDescription(description, chanceRange)); + } new Harmony(PluginInfo.PLUGIN_NAME).PatchAll(typeof(JesterPatch)); } else @@ -186,6 +186,9 @@ namespace MuzikaGromche public AudioClip LoadedStart; public AudioClip LoadedLoop; + // How often this track should be chosen, relative to the sum of weights of all tracks. + public ConfigEntry Weight; + public string FileNameStart => $"{Name}Start.{Ext}"; public string FileNameLoop => $"{Name}Loop.{Ext}"; private string Ext => AudioType switch @@ -197,6 +200,93 @@ namespace MuzikaGromche }; } + public readonly struct RandomWeightedIndex + { + public RandomWeightedIndex(int[] weights) + { + Weights = weights; + TotalWeights = Weights.Sum(); + if (TotalWeights == 0) + { + // If everything is set to zero, everything is equally possible + Weights = [.. Weights.Select(_ => 1)]; + TotalWeights = Weights.Length; + } + } + + private byte[] GetHash(int seed) + { + var buffer = new byte[4 * (1 + Weights.Length)]; + var offset = 0; + Buffer.BlockCopy(BitConverter.GetBytes(seed), 0, buffer, offset, sizeof(int)); + // Make sure that tweaking weights even a little drastically changes the outcome + foreach (var weight in Weights) + { + offset += 4; + Buffer.BlockCopy(BitConverter.GetBytes(weight), 0, buffer, offset, sizeof(int)); + } + var sha = SHA256.Create(); + var hash = sha.ComputeHash(buffer); + return hash; + } + + private int GetRawIndex(byte[] hash) + { + if (TotalWeights == 0) + { + // Should not happen, but what if Weights array is empty? + return -1; + } + + var index = 0; + foreach (var t in hash) + { + // modulus division on byte array + index *= 256 % TotalWeights; + index %= TotalWeights; + index += t % TotalWeights; + index %= TotalWeights; + } + return index; + } + + private int GetWeightedIndex(int rawIndex) + { + if (rawIndex < 0 || rawIndex >= TotalWeights) + { + return -1; + } + + int sum = 0; + foreach (var (weight, index) in Weights.Select((x, i) => (x, i))) + { + sum += weight; + if (rawIndex < sum) + { + // Found + return index; + } + } + + return -1; + } + + public int GetRandomWeightedIndex(int seed) + { + var hash = GetHash(seed); + var index = GetRawIndex(hash); + return GetWeightedIndex(index); + } + + public override string ToString() + { + return $"Weighted(Total={TotalWeights}, Weights=[{string.Join(',', Weights)}])"; + } + + readonly private int[] Weights; + readonly public int TotalWeights { get; } + } + [HarmonyPatch(typeof(JesterAI))] internal class JesterPatch { From 34e72da74857a48f87f7492d9bcdefc490388632 Mon Sep 17 00:00:00 2001 From: ivan tkachenko Date: Sat, 12 Jul 2025 17:32:29 +0300 Subject: [PATCH 3/5] Add config synchronization via CSync It only synchronizes from host to clients. --- MuzikaGromche/MuzikaGromche.csproj | 5 ++++ MuzikaGromche/Plugin.cs | 43 ++++++++++++++++++++++++------ manifest.json | 3 ++- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/MuzikaGromche/MuzikaGromche.csproj b/MuzikaGromche/MuzikaGromche.csproj index e0cfde1..2d6680f 100644 --- a/MuzikaGromche/MuzikaGromche.csproj +++ b/MuzikaGromche/MuzikaGromche.csproj @@ -15,6 +15,11 @@ + + diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index 73489a2..af69800 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -5,6 +5,8 @@ using System.Linq; using System.Security.Cryptography; using BepInEx; using BepInEx.Configuration; +using CSync.Extensions; +using CSync.Lib; using HarmonyLib; using UnityEngine; using UnityEngine.Networking; @@ -12,8 +14,11 @@ using UnityEngine.Networking; namespace MuzikaGromche { [BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)] + [BepInDependency("com.sigurd.csync", "5.0.1")] public class Plugin : BaseUnityPlugin { + internal new static Config Config { get; private set; } = null; + public static Track[] Tracks = [ new Track { @@ -151,13 +156,7 @@ namespace MuzikaGromche track.LoadedStart = DownloadHandlerAudioClip.GetContent(requests[i * 2]); track.LoadedLoop = DownloadHandlerAudioClip.GetContent(requests[i * 2 + 1]); } - // Initialize config - var chanceRange = new AcceptableValueRange(0, 100); - foreach (var track in Tracks) - { - string description = $"Random (relative) chance of selecting track {track.Name}. Set to zero to effectively disable the track."; - track.Weight = Config.Bind("Tracks", track.Name, 50, new ConfigDescription(description, chanceRange)); - } + Config = new Config(base.Config); new Harmony(PluginInfo.PLUGIN_NAME).PatchAll(typeof(JesterPatch)); } else @@ -187,7 +186,7 @@ namespace MuzikaGromche public AudioClip LoadedLoop; // How often this track should be chosen, relative to the sum of weights of all tracks. - public ConfigEntry Weight; + public SyncedEntry Weight; public string FileNameStart => $"{Name}Start.{Ext}"; public string FileNameLoop => $"{Name}Loop.{Ext}"; @@ -287,6 +286,34 @@ namespace MuzikaGromche readonly public int TotalWeights { get; } } + public class Config : SyncedConfig2 + { + public Config(ConfigFile configFile) : base(PluginInfo.PLUGIN_GUID) + { + var chanceRange = new AcceptableValueRange(0, 100); + + foreach (var track in Plugin.Tracks) + { + string description = $"Random (relative) chance of selecting track {track.Name}. Set to zero to effectively disable the track."; + track.Weight = configFile.BindSyncedEntry( + new ConfigDefinition("Tracks", track.Name), + 50, + new ConfigDescription(description, chanceRange, track)); + } + + // HACK because CSync doesn't provide an API to register a list of config entries + // See https://github.com/lc-sigurd/CSync/issues/11 + foreach (var track in Plugin.Tracks) + { + // This is basically what ConfigFile.PopulateEntryContainer does + SyncedEntryBase entryBase = track.Weight; + EntryContainer.Add(entryBase.BoxedEntry.ToSyncedEntryIdentifier(), entryBase); + } + + ConfigManager.Register(this); + } + } + [HarmonyPatch(typeof(JesterAI))] internal class JesterPatch { diff --git a/manifest.json b/manifest.json index 8cc5352..c6b419f 100644 --- a/manifest.json +++ b/manifest.json @@ -5,6 +5,7 @@ "description": "Glaza zakryvaj", "website_url": "https://git.vilunov.me/nikita/muzika-gromche", "dependencies": [ - "BepInEx-BepInExPack-5.4.2100" + "BepInEx-BepInExPack-5.4.2100", + "Sigurd-CSync-5.0.1" ] } From 13fd51c36663b893004bdb115134c120072c598c Mon Sep 17 00:00:00 2001 From: ivan tkachenko Date: Sat, 12 Jul 2025 04:49:20 +0300 Subject: [PATCH 4/5] Add LethalConfig with suitable custom options The custom callback attempts to prevent modifications mid-round. Use IsHost to check for permissions, as IsClient is always true for everyone even in local single-player setting. There is a bug in LethalConfig which makes it possible to modify entries bypassing the callback once per round, but it is pretty hard to abuse: https://github.com/AinaVT/LethalConfig/issues/60 --- MuzikaGromche/MuzikaGromche.csproj | 1 + MuzikaGromche/Plugin.cs | 29 +++++++++++++++++++++++++++++ NuGet.Config | 1 + manifest.json | 3 ++- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/MuzikaGromche/MuzikaGromche.csproj b/MuzikaGromche/MuzikaGromche.csproj index 2d6680f..d107302 100644 --- a/MuzikaGromche/MuzikaGromche.csproj +++ b/MuzikaGromche/MuzikaGromche.csproj @@ -20,6 +20,7 @@ of generating code at compile time. See https://github.com/lc-sigurd/CSync/issues/11 --> + diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index af69800..a1e968e 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -7,6 +7,9 @@ using BepInEx; using BepInEx.Configuration; using CSync.Extensions; using CSync.Lib; +using LethalConfig; +using LethalConfig.ConfigItems; +using LethalConfig.ConfigItems.Options; using HarmonyLib; using UnityEngine; using UnityEngine.Networking; @@ -15,6 +18,7 @@ namespace MuzikaGromche { [BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)] [BepInDependency("com.sigurd.csync", "5.0.1")] + [BepInDependency("ainavt.lc.lethalconfig", "1.4.6")] public class Plugin : BaseUnityPlugin { internal new static Config Config { get; private set; } = null; @@ -299,6 +303,13 @@ namespace MuzikaGromche new ConfigDefinition("Tracks", track.Name), 50, new ConfigDescription(description, chanceRange, track)); + + var slider = new IntSliderConfigItem(track.Weight.Entry, new IntSliderOptions + { + RequiresRestart = false, + CanModifyCallback = CanModifyWeightsNow, + }); + LethalConfigManager.AddConfigItem(slider); } // HACK because CSync doesn't provide an API to register a list of config entries @@ -312,6 +323,24 @@ namespace MuzikaGromche ConfigManager.Register(this); } + + public static CanModifyResult CanModifyWeightsNow() + { + var startOfRound = StartOfRound.Instance; + if (!startOfRound) + { + return CanModifyResult.True(); // Main menu + } + if (!startOfRound.IsHost) + { + return CanModifyResult.False("Only for host"); + } + if (!startOfRound.inShipPhase) + { + return CanModifyResult.False("Only while orbiting"); + } + return CanModifyResult.True(); + } } [HarmonyPatch(typeof(JesterAI))] diff --git a/NuGet.Config b/NuGet.Config index fffd918..a58b3de 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -2,5 +2,6 @@ + diff --git a/manifest.json b/manifest.json index c6b419f..e9b793f 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,7 @@ "website_url": "https://git.vilunov.me/nikita/muzika-gromche", "dependencies": [ "BepInEx-BepInExPack-5.4.2100", - "Sigurd-CSync-5.0.1" + "Sigurd-CSync-5.0.1", + "ainavt.lc.lethalconfig-1.4.6" ] } From 3b055e3d914876969452f3758645711935ea8bff Mon Sep 17 00:00:00 2001 From: ivan tkachenko Date: Sat, 12 Jul 2025 19:33:12 +0300 Subject: [PATCH 5/5] Split config into sections per track language, add quick toggle per section --- MuzikaGromche/Plugin.cs | 48 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index a1e968e..7f256b3 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -27,36 +27,42 @@ namespace MuzikaGromche new Track { Name = "MuzikaGromche", + Language = Language.RUSSIAN, WindUpTimer = 46.3f, Bpm = 130f, }, new Track { Name = "VseVZale", + Language = Language.RUSSIAN, WindUpTimer = 39f, Bpm = 138f, }, new Track { Name = "DeployDestroy", + Language = Language.RUSSIAN, WindUpTimer = 40.7f, Bpm = 130f, }, new Track { Name = "MoyaZhittya", + Language = Language.ENGLISH, WindUpTimer = 34.5f, Bpm = 120f, }, new Track { Name = "Gorgorod", + Language = Language.RUSSIAN, WindUpTimer = 43.2f, Bpm = 180f, }, new Track { Name = "Durochka", + Language = Language.RUSSIAN, WindUpTimer = 37f, Bpm = 130f, } @@ -168,11 +174,19 @@ namespace MuzikaGromche Logger.LogError("Could not load audio file"); } } + }; + + public record Language(string Short, string Full) + { + public static readonly Language ENGLISH = new("EN", "English"); + public static readonly Language RUSSIAN = new("RU", "Russian"); } public class Track { public string Name; + // Language of the track's lyrics. + public Language Language; // Wind-up time can and should be shorter than the Start audio track, // so that the "pop" effect can be baked into the Start audio and kept away // from the looped part. This also means that the light show starts before @@ -295,12 +309,42 @@ namespace MuzikaGromche public Config(ConfigFile configFile) : base(PluginInfo.PLUGIN_GUID) { var chanceRange = new AcceptableValueRange(0, 100); + var languageSectionButtonExists = new HashSet(); foreach (var track in Plugin.Tracks) { - string description = $"Random (relative) chance of selecting track {track.Name}. Set to zero to effectively disable the track."; + var language = track.Language; + string section = $"Tracks.{language.Short}"; + + // Create section toggle + if (!languageSectionButtonExists.Contains(language)) + { + languageSectionButtonExists.Add(language); + string buttonOptionName = $"Toggle all {language.Full} tracks"; + string buttonDescription = "Toggle all tracks in this section ON or OFF. Effective immediately."; + string buttonText = "Toggle"; + var button = new GenericButtonConfigItem(section, buttonOptionName, buttonDescription, buttonText, () => + { + if (CanModifyWeightsNow()) + { + var tracks = Plugin.Tracks.Where(t => t.Language.Equals(language)).ToList(); + var isOff = tracks.All(t => t.Weight.Value == 0); + var newWeight = isOff ? 50 : 0; + foreach (var t in tracks) + { + t.Weight.LocalValue = newWeight; + } + } + }); + button.ButtonOptions.CanModifyCallback = CanModifyWeightsNow; + LethalConfigManager.AddConfigItem(button); + } + + // Create slider entry for track + string name = $"[{language.Short}] {track.Name}"; + string description = $"Language: {language.Full}\n\nRandom (relative) chance of selecting this track.\n\nSet to zero to effectively disable the track."; track.Weight = configFile.BindSyncedEntry( - new ConfigDefinition("Tracks", track.Name), + new ConfigDefinition(section, track.Name), 50, new ConfigDescription(description, chanceRange, track));