From 0dceca04f5ff68bfe2819830ed7f2b6d600a61b1 Mon Sep 17 00:00:00 2001 From: zfolmt Date: Thu, 23 Oct 2025 18:15:46 -0500 Subject: [PATCH 1/8] Add command category support for quip wheel (#23) * Add command category support for quip wheel * Document local build workflow * Ensure pipelines restore packages explicitly * Use NuGet client package for V Rising references --- .github/workflows/build.yml | 4 +- Configuration/QuipManager.cs | 236 +++++++++++++++++++++++++++++- Patches/ActionWheelSystemPatch.cs | 2 +- README.md | 18 +++ RetroCamera.csproj | 8 +- Settings.cs | 3 +- Systems/RetroCamera.cs | 16 +- Utilities/Persistence.cs | 10 +- scripts/init.sh | 61 -------- 9 files changed, 265 insertions(+), 93 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index db60cda..b6fec55 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: fetch-depth: 0 - name: Restore dependencies - run: dotnet restore + run: dotnet restore RetroCamera.csproj --source https://api.nuget.org/v3/index.json --source https://nuget.bepinex.dev/v3/index.json - name: Discover .csproj id: discover_csproj @@ -68,7 +68,7 @@ jobs: fi - name: Build (Release) - run: dotnet build . --configuration Release -p:Version=${{ env.version }} -p:RunGenerateREADME=false + run: dotnet build RetroCamera.csproj --configuration Release --no-restore -p:Version=${{ env.version }} -p:RunGenerateREADME=false - name: GH Release uses: softprops/action-gh-release@v1 diff --git a/Configuration/QuipManager.cs b/Configuration/QuipManager.cs index b19719c..9f74048 100644 --- a/Configuration/QuipManager.cs +++ b/Configuration/QuipManager.cs @@ -1,32 +1,254 @@ -using RetroCamera.Utilities; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using RetroCamera.Utilities; using Stunlock.Localization; namespace RetroCamera.Configuration; + internal static class QuipManager { - public static IReadOnlyDictionary CommandQuips => _commandQuips; - static readonly Dictionary _commandQuips = []; + public const string BACK_TO_CATEGORIES_LABEL = "Back to Categories"; + + public static LocalizationKey BackToCategoriesLabelKey => _backToCategoriesLabelKey; + public static IReadOnlyDictionary CommandQuips => _readOnlyCommandQuips; + + static readonly LocalizationKey _backToCategoriesLabelKey = LocalizationManager.GetLocalizationKey(BACK_TO_CATEGORIES_LABEL); + static readonly Dictionary _commandQuips = []; + static readonly ReadOnlyDictionary _readOnlyCommandQuips = new(_commandQuips); + + static readonly Dictionary _categoriesBySlot = []; + static readonly ReadOnlyDictionary _readOnlyCategories = new(_categoriesBySlot); + static readonly Dictionary> _quipSlotsByCategory = []; + + static byte? _activeCategory; + + public static IReadOnlyDictionary GetCategories() => _readOnlyCategories; + public static byte? ActiveCategory => _activeCategory; + public readonly struct CommandQuip(string name, string command) { public readonly LocalizationKey NameKey = LocalizationManager.GetLocalizationKey(name); public string Name { get; init; } = name; public string Command { get; init; } = command; + public bool IsEmpty => string.IsNullOrWhiteSpace(Name) || string.IsNullOrWhiteSpace(Command); } + public readonly struct Command { public string Name { get; init; } public string InputString { get; init; } } + + public readonly struct CommandCategory + { + public CommandCategory(string name, IEnumerable quipSlots, IEnumerable> entries) + { + Name = name ?? string.Empty; + NameKey = LocalizationManager.GetLocalizationKey(Name); + + var slotList = quipSlots != null ? new List(quipSlots) : new List(); + QuipSlots = new ReadOnlyCollection(slotList); + + var entryList = entries != null ? new List>(entries) : new List>(); + Entries = new ReadOnlyCollection>(entryList); + } + + public string Name { get; init; } + public LocalizationKey NameKey { get; } + public IReadOnlyList QuipSlots { get; } + public IReadOnlyList> Entries { get; } + public bool HasEntries => Entries.Count > 0; + } + public static void TryLoadCommands() { var loaded = Persistence.LoadCommands(); - if (loaded != null) + if (loaded == null) + { + return; + } + + _commandQuips.Clear(); + + foreach (var keyValuePair in loaded) + { + var slot = keyValuePair.Key; + Command command = keyValuePair.Value; + _commandQuips[slot] = new CommandQuip(command.Name, command.InputString); + } + + RefreshCategories(); + } + + public static CommandCategory SetCategory(byte slot, string name, IEnumerable quipSlots) + { + byte[] orderedSlots = CreateOrderedSlotArray(quipSlots); + var category = new CommandCategory(name, orderedSlots, BuildCategoryEntries(orderedSlots)); + _categoriesBySlot[slot] = category; + _quipSlotsByCategory[slot] = category.QuipSlots; + + if (_activeCategory.HasValue && _activeCategory.Value == slot && !category.HasEntries) + { + _activeCategory = null; + } + + return category; + } + + public static bool RemoveCategory(byte slot) + { + var removed = _categoriesBySlot.Remove(slot); + _quipSlotsByCategory.Remove(slot); + + if (_activeCategory.HasValue && _activeCategory.Value == slot) + { + _activeCategory = null; + } + + return removed; + } + + public static void ClearCategories() + { + _categoriesBySlot.Clear(); + _quipSlotsByCategory.Clear(); + _activeCategory = null; + } + + public static bool TryGetCategory(byte slot, out CommandCategory category) => _categoriesBySlot.TryGetValue(slot, out category); + + public static IReadOnlyList> GetQuipsForCategory(byte slot) + { + if (_categoriesBySlot.TryGetValue(slot, out var category)) + { + return category.Entries; + } + + return Array.Empty>(); + } + + public static IReadOnlyList GetQuipSlotsForCategory(byte slot) + { + if (_quipSlotsByCategory.TryGetValue(slot, out var slots)) + { + return slots; + } + + return Array.Empty(); + } + + public static bool TryGetQuipSlotForCategory(byte categorySlot, byte quipIndex, out byte quipSlot) + { + if (_categoriesBySlot.TryGetValue(categorySlot, out var category) + && quipIndex < category.QuipSlots.Count) + { + quipSlot = category.QuipSlots[quipIndex]; + return true; + } + + quipSlot = default; + return false; + } + + public static bool TryGetQuip(byte slot, out CommandQuip commandQuip) + { + if (_activeCategory.HasValue) + { + byte activeSlot = _activeCategory.Value; + + if (TryGetQuipSlotForCategory(activeSlot, slot, out var resolvedSlot) + && _commandQuips.TryGetValue(resolvedSlot, out commandQuip)) + { + return true; + } + } + + return _commandQuips.TryGetValue(slot, out commandQuip); + } + + public static bool SetActiveCategory(byte slot) + { + if (_categoriesBySlot.TryGetValue(slot, out var category) && category.HasEntries) + { + _activeCategory = slot; + return true; + } + + return false; + } + + public static void ClearActiveCategory() + { + _activeCategory = null; + } + + public static bool TryGetActiveCategory(out CommandCategory category) + { + if (_activeCategory.HasValue && _categoriesBySlot.TryGetValue(_activeCategory.Value, out category)) + { + return true; + } + + category = default; + return false; + } + + public static void RefreshCategories() + { + if (_categoriesBySlot.Count == 0) + { + return; + } + + var snapshot = new List>(_categoriesBySlot); + + foreach (var pair in snapshot) + { + byte slot = pair.Key; + var existing = pair.Value; + var refreshed = new CommandCategory(existing.Name, existing.QuipSlots, BuildCategoryEntries(existing.QuipSlots)); + _categoriesBySlot[slot] = refreshed; + _quipSlotsByCategory[slot] = refreshed.QuipSlots; + } + + if (_activeCategory.HasValue + && (!_categoriesBySlot.TryGetValue(_activeCategory.Value, out var active) || !active.HasEntries)) + { + _activeCategory = null; + } + } + + static byte[] CreateOrderedSlotArray(IEnumerable quipSlots) + { + if (quipSlots == null) + { + return Array.Empty(); + } + + var slots = new List(); + + foreach (var quipSlot in quipSlots) + { + slots.Add(quipSlot); + } + + return slots.Count == 0 ? Array.Empty() : slots.ToArray(); + } + + static IEnumerable> BuildCategoryEntries(IEnumerable orderedSlots) + { + if (orderedSlots == null) + { + yield break; + } + + foreach (var slot in orderedSlots) { - foreach (var keyValuePair in loaded) + if (_commandQuips.TryGetValue(slot, out var commandQuip) && !commandQuip.IsEmpty) { - Command command = keyValuePair.Value; - _commandQuips.TryAdd(keyValuePair.Key, new CommandQuip(command.Name, command.InputString)); + yield return new KeyValuePair(slot, commandQuip); } } } diff --git a/Patches/ActionWheelSystemPatch.cs b/Patches/ActionWheelSystemPatch.cs index 69dcdfc..3268b8f 100644 --- a/Patches/ActionWheelSystemPatch.cs +++ b/Patches/ActionWheelSystemPatch.cs @@ -59,7 +59,7 @@ static bool SendQuipChatMessagePrefix(byte index) _lastQuipSendTime = now; - if (CommandQuips.TryGetValue(index, out CommandQuip commandQuip)) + if (TryGetQuip(index, out CommandQuip commandQuip)) { SendCommandQuip(commandQuip); return false; diff --git a/README.md b/README.md index 3a25164..68f0ae5 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ - [Sponsors](#sponsors) - [Features](#features) - [Configuration](#configuration) +- [Development](#development) + - [Local build setup](#local-build-setup) + - [Manual build](#manual-build) - [Credits](#credits) ## Sponsor this project @@ -23,6 +26,21 @@ Yes, this has action mode (right bracket default, can rebind). Streamlined Moder - **Command Wheel**: Add your label and raw command strings to the config file, enable the wheel in the menu options for RetroCamera, and use right alt key (default, can rebind) to use them on the fly! - **Configuration:** Configuration for keybinds and options done at the in-game menu with rebinding support. Current keybinds: toggle mod functioning, toggle action mode, toggle HUD, and toggle batform fog; complete journal quest; +## Development + +### Local build setup + +1. Clone this repository. +2. Run `./scripts/init.sh` to install the .NET SDK (if missing), restore NuGet dependencies (including `VRising.Unhollowed.Client`), and invoke a Release build with the appropriate `dotnet build` command. The script maintains a repo-local `.dotnet` folder so contributors without a global installation can still compile the mod. + +The V Rising client assemblies are delivered through the `VRising.Unhollowed.Client` NuGet package, so no manual extraction into a `third_party/` directory is required. + +### Manual build + +- Restore NuGet packages with `dotnet restore RetroCamera.csproj --source https://api.nuget.org/v3/index.json --source https://nuget.bepinex.dev/v3/index.json`. +- After restoring packages, build with `dotnet build RetroCamera.csproj --configuration Release --no-restore`. +- Visual Studio users can open `RetroCamera.csproj`, select the **Release** configuration, and build the project directly. + ## Credits - The modding Discord logo and RetroCamera logo were both made by [@Odjit](https://github.com/Odjit), a very talented artist who also authors the Kindred mods! ([Kindred](https://thunderstore.io/c/v-rising/p/odjit/)) diff --git a/RetroCamera.csproj b/RetroCamera.csproj index 9e7bd2d..a93619c 100644 --- a/RetroCamera.csproj +++ b/RetroCamera.csproj @@ -20,13 +20,7 @@ - - - - - third_party/VRising.GameData.dll - false - + diff --git a/Settings.cs b/Settings.cs index f777faa..7a68faa 100644 --- a/Settings.cs +++ b/Settings.cs @@ -8,6 +8,7 @@ using static RetroCamera.Utilities.CameraState; using static RetroCamera.Utilities.Persistence; using static RetroCamera.Configuration.QuipManager; +using static RetroCamera.Systems.RetroCamera; using BoolChanged = RetroCamera.Configuration.MenuOption.OptionChangedHandler; using FloatChanged = RetroCamera.Configuration.MenuOption.OptionChangedHandler; using UnityEngine.InputSystem; @@ -178,7 +179,7 @@ static void TryExitActionMode() } static void UpdateActionModeState(bool enabled) { - RetroCamera.ActionMode(enabled); + ActionMode(enabled); if (IsMenuOpen) IsMenuOpen = false; if (ActionWheelSystemPatch._wheelVisible) ActionWheelSystemPatch._wheelVisible = false; diff --git a/Systems/RetroCamera.cs b/Systems/RetroCamera.cs index 9d4df8a..7995be5 100644 --- a/Systems/RetroCamera.cs +++ b/Systems/RetroCamera.cs @@ -41,7 +41,7 @@ static void UpdateEnabled(bool enabled) { if (ZoomModifierSystem != null) ZoomModifierSystem.Enabled = !enabled; - if (_crosshair != null) _crosshair.active = enabled && Settings.AlwaysShowCrosshair && !_inBuildMode; + if (_crosshair != null) _crosshair.SetActive(enabled && Settings.AlwaysShowCrosshair && !_inBuildMode); if (!enabled) { @@ -256,10 +256,8 @@ static void BuildCrosshair() CursorData cursorData = CursorController._CursorDatas.First(x => x.CursorType == CursorType.Game_Normal); if (cursorData == null) return; - _crosshairPrefab = new("Crosshair") - { - active = false - }; + _crosshairPrefab = new("Crosshair"); + _crosshairPrefab.SetActive(false); _crosshairPrefab.AddComponent(); RectTransform rectTransform = _crosshairPrefab.AddComponent(); @@ -275,7 +273,7 @@ static void BuildCrosshair() Image image = _crosshairPrefab.AddComponent(); image.sprite = Sprite.Create(cursorData.Texture, new Rect(0, 0, cursorData.Texture.width, cursorData.Texture.height), new Vector2(0.5f, 0.5f), 100f); - _crosshairPrefab.active = false; + _crosshairPrefab.SetActive(false); } catch (Exception ex) { @@ -297,7 +295,7 @@ static void UpdateCrosshair() _canvasScaler = uiCanvas.GetComponent(); _crosshair = Instantiate(_crosshairPrefab, uiCanvas.transform); - _crosshair.active = true; + _crosshair.SetActive(true); } bool rotatingCamera = false; @@ -306,7 +304,7 @@ static void UpdateCrosshair() bool shouldHandle = _validGameplayInputState && (_isMouseLocked || rotatingCamera); - _cachedVignette?.active = Settings.ShowVignette; + if (_cachedVignette != null) _cachedVignette.active = Settings.ShowVignette; if (shouldHandle && !IsMenuOpen) { @@ -330,7 +328,7 @@ static void UpdateCrosshair() if (_crosshair != null) { - _crosshair.active = crosshairVisible || Settings.AlwaysShowCrosshair; + _crosshair.SetActive(crosshairVisible || Settings.AlwaysShowCrosshair); float scale = Settings.CrosshairSize; _crosshair.transform.localScale = new(scale, scale, scale); diff --git a/Utilities/Persistence.cs b/Utilities/Persistence.cs index 009d298..decf0e8 100644 --- a/Utilities/Persistence.cs +++ b/Utilities/Persistence.cs @@ -38,7 +38,7 @@ internal static class Persistence public static void SaveCommands() => SaveDictionary(CommandQuips, COMMANDS_KEY); public static Dictionary LoadKeybinds() => LoadDictionary(KEYBINDS_KEY); public static Dictionary LoadOptions() => LoadDictionary(OPTIONS_KEY); - public static Dictionary LoadCommands() => LoadDictionary(COMMANDS_KEY); + public static Dictionary LoadCommands() => LoadDictionary(COMMANDS_KEY); static Dictionary LoadDictionary(string fileKey) { if (!_filePaths.TryGetValue(fileKey, out string filePath)) return null; @@ -52,12 +52,12 @@ static Dictionary LoadDictionary(string fileKey) { File.Create(filePath).Dispose(); - if (fileKey == COMMANDS_KEY && typeof(T) == typeof(int) && typeof(U) == typeof(Command)) + if (fileKey == COMMANDS_KEY && typeof(T) == typeof(byte) && typeof(U) == typeof(Command)) { - var defaultDict = new Dictionary(); + var defaultDict = new Dictionary(); for (int i = 0; i < 8; i++) { - defaultDict[i] = new Command { Name = "", InputString = "" }; + defaultDict[(byte)i] = new Command { Name = "", InputString = "" }; } File.WriteAllText(filePath, JsonSerializer.Serialize(defaultDict, _jsonOptions)); @@ -125,4 +125,4 @@ public override void Write(Utf8JsonWriter writer, MenuOption value, JsonSerializ jsonObj[TYPE_PROPERTY] = value.GetType().Name; jsonObj.WriteTo(writer, options); } -} \ No newline at end of file +} diff --git a/scripts/init.sh b/scripts/init.sh index 115b78d..d1e9142 100755 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -4,9 +4,6 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PROJECT_FILE="${PROJECT_ROOT}/RetroCamera.csproj" -THIRD_PARTY_DIR="${PROJECT_ROOT}/third_party" -GAMEDATA_DLL="${THIRD_PARTY_DIR}/VRising.GameData.dll" -GAMEDATA_URL="${VRISING_GAMEDATA_URL:-https://thunderstore.io/package/download/adainrivers/VRising_GameData/0.2.2/}" DESIRED_DOTNET_CHANNEL="${DOTNET_INSTALL_CHANNEL:-8.0}" DOTNET_INSTALL_DIR="${PROJECT_ROOT}/.dotnet" DOTNET_INSTALL_SCRIPT="${DOTNET_INSTALL_DIR}/dotnet-install.sh" @@ -69,48 +66,6 @@ if ! "${DOTNET_CMD}" --list-sdks | grep -q "^${DESIRED_DOTNET_CHANNEL}"; then DOTNET_CMD="${DOTNET_INSTALL_DIR}/dotnet" fi -mkdir -p "${THIRD_PARTY_DIR}" - -if [ ! -f "${GAMEDATA_DLL}" ]; then - echo "Fetching VRising.GameData.dll from ${GAMEDATA_URL}..." - TMP_ZIP="$(mktemp)" - curl -fSL "${GAMEDATA_URL}" -o "${TMP_ZIP}" - unzip -qjo "${TMP_ZIP}" "VRising.GameData.dll" -d "${THIRD_PARTY_DIR}" - rm -f "${TMP_ZIP}" -fi - -if [ -n "${VRISING_REFERENCE_ARCHIVE:-}" ]; then - echo "Extracting additional reference assemblies from ${VRISING_REFERENCE_ARCHIVE}..." - ARCHIVE_PATH="${VRISING_REFERENCE_ARCHIVE}" - CLEANUP_ARCHIVE=0 - if [[ "${VRISING_REFERENCE_ARCHIVE}" =~ ^https?:// ]]; then - ARCHIVE_PATH="$(mktemp)" - CLEANUP_ARCHIVE=1 - curl -fSL "${VRISING_REFERENCE_ARCHIVE}" -o "${ARCHIVE_PATH}" - fi - unzip -qjo "${ARCHIVE_PATH}" "*.dll" -d "${THIRD_PARTY_DIR}" || { - echo "Failed to extract reference archive." >&2 - [ "${CLEANUP_ARCHIVE}" -eq 1 ] && rm -f "${ARCHIVE_PATH}" - exit 1 - } - [ "${CLEANUP_ARCHIVE}" -eq 1 ] && rm -f "${ARCHIVE_PATH}" -fi - -if [ ! -f "${GAMEDATA_DLL}" ]; then - echo "Failed to obtain VRising.GameData.dll. Set VRISING_GAMEDATA_URL to a valid download." >&2 - exit 1 -fi - -# Track any managed VRising assemblies that are still absent after the automated -# downloads so we can gracefully skip the build instead of hard failing. This -# keeps the init script useful in CI containers that do not have the proprietary -# archives while still surfacing actionable guidance for developers who do. -MISSING_GAME_ASSEMBLIES=() - -if [ ! -f "${THIRD_PARTY_DIR}/ProjectM.dll" ]; then - MISSING_GAME_ASSEMBLIES+=("ProjectM.dll") -fi - RESTORE_SOURCES=( --source "https://api.nuget.org/v3/index.json" --source "https://nuget.bepinex.dev/v3/index.json" @@ -121,22 +76,6 @@ echo "Using dotnet executable at: ${DOTNET_CMD}" echo "Restoring NuGet packages..." "${DOTNET_CMD}" restore "${PROJECT_FILE}" "${RESTORE_SOURCES[@]}" -if [ "${#MISSING_GAME_ASSEMBLIES[@]}" -ne 0 ]; then - cat < Date: Thu, 23 Oct 2025 18:52:10 -0500 Subject: [PATCH 2/8] Add persistence support for command categories (#24) * Add persistence support for command categories * Document build script for local verification --- README.md | 2 ++ Utilities/Persistence.cs | 74 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 68f0ae5..a048059 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Yes, this has action mode (right bracket default, can rebind). Streamlined Moder 1. Clone this repository. 2. Run `./scripts/init.sh` to install the .NET SDK (if missing), restore NuGet dependencies (including `VRising.Unhollowed.Client`), and invoke a Release build with the appropriate `dotnet build` command. The script maintains a repo-local `.dotnet` folder so contributors without a global installation can still compile the mod. + > **Tip:** Use this script for local verification and continuous integration checks instead of calling `dotnet build` directly; it guarantees the same SDK channel and NuGet feeds that the project depends on. + The V Rising client assemblies are delivered through the `VRising.Unhollowed.Client` NuGet package, so no manual extraction into a `third_party/` directory is required. ### Manual build diff --git a/Utilities/Persistence.cs b/Utilities/Persistence.cs index decf0e8..77f4dae 100644 --- a/Utilities/Persistence.cs +++ b/Utilities/Persistence.cs @@ -1,5 +1,6 @@ using BepInEx; using RetroCamera.Configuration; +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -14,7 +15,10 @@ internal static class Persistence { WriteIndented = true, IncludeFields = true, - Converters = { new MenuOptionJsonConverter() } + Converters = + { + new MenuOptionJsonConverter() + } }; static readonly string _directoryPath = Path.Join(Paths.ConfigPath, MyPluginInfo.PLUGIN_NAME); @@ -22,23 +26,71 @@ internal static class Persistence const string KEYBINDS_KEY = "Keybinds"; const string OPTIONS_KEY = "Options"; const string COMMANDS_KEY = "Commands"; + const string COMMAND_CATEGORIES_KEY = "CommandCategories"; static readonly string _keybindsJson = Path.Combine(_directoryPath, $"{KEYBINDS_KEY}.json"); static readonly string _settingsJson = Path.Combine(_directoryPath, $"{OPTIONS_KEY}.json"); static readonly string _commandsJson = Path.Combine(_directoryPath, $"{COMMANDS_KEY}.json"); + static readonly string _commandCategoriesJson = Path.Combine(_directoryPath, $"{COMMAND_CATEGORIES_KEY}.json"); static readonly Dictionary _filePaths = new() { {KEYBINDS_KEY, _keybindsJson }, {OPTIONS_KEY, _settingsJson }, - {COMMANDS_KEY, _commandsJson } + {COMMANDS_KEY, _commandsJson }, + {COMMAND_CATEGORIES_KEY, _commandCategoriesJson } }; public static void SaveKeybinds() => SaveDictionary(Keybinds, KEYBINDS_KEY); public static void SaveOptions() => SaveDictionary(Options, OPTIONS_KEY); public static void SaveCommands() => SaveDictionary(CommandQuips, COMMANDS_KEY); + public static void SaveCommandCategories() + { + var categories = QuipManager.GetCategories(); + var dtoDictionary = new Dictionary(); + + foreach (var pair in categories) + { + var category = pair.Value; + + var entries = new List(); + foreach (var entry in category.Entries) + { + var commandQuip = entry.Value; + var quipDto = new CommandQuipDto + { + Name = commandQuip.Name ?? string.Empty, + Command = commandQuip.Command ?? string.Empty + }; + + entries.Add(new CommandCategoryEntryDto + { + Slot = entry.Key, + Quip = quipDto + }); + } + + var quipSlots = category.QuipSlots != null && category.QuipSlots.Count > 0 + ? new List(category.QuipSlots) + : new List(); + + dtoDictionary[pair.Key] = new CommandCategoryDto + { + Name = category.Name ?? string.Empty, + QuipSlots = quipSlots, + Entries = entries + }; + } + + SaveDictionary(dtoDictionary, COMMAND_CATEGORIES_KEY); + } public static Dictionary LoadKeybinds() => LoadDictionary(KEYBINDS_KEY); public static Dictionary LoadOptions() => LoadDictionary(OPTIONS_KEY); public static Dictionary LoadCommands() => LoadDictionary(COMMANDS_KEY); + public static Dictionary LoadCommandCategories() + { + var loaded = LoadDictionary(COMMAND_CATEGORIES_KEY); + return loaded ?? new Dictionary(); + } static Dictionary LoadDictionary(string fileKey) { if (!_filePaths.TryGetValue(fileKey, out string filePath)) return null; @@ -96,6 +148,24 @@ static void SaveDictionary(IReadOnlyDictionary fileData, string file } } } +internal sealed class CommandCategoryDto +{ + public string Name { get; set; } = string.Empty; + public List QuipSlots { get; set; } = new(); + public List Entries { get; set; } = new(); +} + +internal sealed class CommandCategoryEntryDto +{ + public byte Slot { get; set; } + public CommandQuipDto Quip { get; set; } = new(); +} + +internal sealed class CommandQuipDto +{ + public string Name { get; set; } = string.Empty; + public string Command { get; set; } = string.Empty; +} internal class MenuOptionJsonConverter : JsonConverter { const string TYPE_PROPERTY = "OptionType"; From 44209b3d970286637f2211d13e1f93d707d1df94 Mon Sep 17 00:00:00 2001 From: zfolmt Date: Thu, 23 Oct 2025 19:28:47 -0500 Subject: [PATCH 3/8] Load persisted command categories (#25) * Load persisted command categories * Document build command in AGENTS * Clarify build bootstrap instructions --- AGENTS.md | 6 +++++ Configuration/QuipManager.cs | 49 ++++++++++++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 901df4b..bb6f7bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,12 @@ --- +## Build & Testing + +* **Bootstrap with the init script first:** From the repository root run `./scripts/init.sh` to install the expected .NET SDK (if missing), restore packages, and build in Release. Capture the full command output and note success or any environment-related failures. +* **Direct builds remain valid:** When the `dotnet` CLI is already available, you may additionally run `dotnet build RetroCamera.csproj --configuration Release` from the repository root. Continue to report the exact command(s) and their outcomes. + +--- ## Strong Typing & Domain Modeling * **Favor classes and structs over loose data structures:** diff --git a/Configuration/QuipManager.cs b/Configuration/QuipManager.cs index 9f74048..7073bbf 100644 --- a/Configuration/QuipManager.cs +++ b/Configuration/QuipManager.cs @@ -63,22 +63,61 @@ public CommandCategory(string name, IEnumerable quipSlots, IEnumerable loadedCommands = Persistence.LoadCommands(); - if (loaded == null) + if (loadedCommands == null) { return; } + Dictionary loadedCategories = Persistence.LoadCommandCategories() ?? new Dictionary(); + _commandQuips.Clear(); - foreach (var keyValuePair in loaded) + foreach (KeyValuePair commandPair in loadedCommands) { - var slot = keyValuePair.Key; - Command command = keyValuePair.Value; + byte slot = commandPair.Key; + Command command = commandPair.Value; _commandQuips[slot] = new CommandQuip(command.Name, command.InputString); } + ClearCategories(); + + if (loadedCategories.Count > 0) + { + foreach (KeyValuePair categoryPair in loadedCategories) + { + byte categorySlot = categoryPair.Key; + CommandCategoryDto categoryDto = categoryPair.Value ?? new CommandCategoryDto(); + + List quipSlots = categoryDto.QuipSlots != null && categoryDto.QuipSlots.Count > 0 + ? new List(categoryDto.QuipSlots) + : new List(); + + if (categoryDto.Entries != null && categoryDto.Entries.Count > 0) + { + foreach (CommandCategoryEntryDto entry in categoryDto.Entries) + { + if (entry == null) + { + continue; + } + + CommandQuipDto quipDto = entry.Quip ?? new CommandQuipDto(); + byte entrySlot = entry.Slot; + _commandQuips[entrySlot] = new CommandQuip(quipDto.Name, quipDto.Command); + + if (!quipSlots.Contains(entrySlot)) + { + quipSlots.Add(entrySlot); + } + } + } + + SetCategory(categorySlot, categoryDto.Name ?? string.Empty, quipSlots); + } + } + RefreshCategories(); } From e47d12aea4dcb4886756144217922c3dc7b01bc7 Mon Sep 17 00:00:00 2001 From: zfolmt Date: Fri, 24 Oct 2025 06:48:43 -0500 Subject: [PATCH 4/8] Update social wheel category hydration (#26) --- Systems/RetroCamera.cs | 113 +++++++++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 38 deletions(-) diff --git a/Systems/RetroCamera.cs b/Systems/RetroCamera.cs index 7995be5..6ef77f9 100644 --- a/Systems/RetroCamera.cs +++ b/Systems/RetroCamera.cs @@ -1,5 +1,6 @@ using ProjectM; using ProjectM.Sequencer; +using System.Collections.Generic; using ProjectM.UI; using RetroCamera.Behaviours; using RetroCamera.Configuration; @@ -123,19 +124,7 @@ static void SocialWheelKeyPressed() if (!_socialWheelInitialized && _rootPrefabCollection.TryGetComponent(out RootPrefabCollection rootPrefabCollection) && rootPrefabCollection.GeneralGameplayCollectionPrefab.TryGetComponent(out GeneralGameplayCollection generalGameplayCollection)) { - foreach (var commandQuip in CommandQuips) - { - if (string.IsNullOrEmpty(commandQuip.Value.Name) - || string.IsNullOrEmpty(commandQuip.Value.Command)) - continue; - - ChatQuip chatQuip = generalGameplayCollection.ChatQuips[commandQuip.Key]; - chatQuip.Text = commandQuip.Value.NameKey; - - // Core.Log.LogWarning($"[RetroCamera] QuipData - {commandQuip.Value.Name} | {commandQuip.Value.Command} | {chatQuip.Sequence} | {chatQuip.Sequence.ToPrefabGUID()}"); - - generalGameplayCollection.ChatQuips[commandQuip.Key] = chatQuip; - } + UpdateSocialWheelQuips(generalGameplayCollection); ActionWheelSystem.InitializeSocialWheel(true, generalGameplayCollection); _socialWheelInitialized = true; @@ -148,31 +137,6 @@ static void SocialWheelKeyPressed() { Core.Log.LogError($"[RetroCamera.Update] Failed to localize keys - {ex.Message}"); } - - try - { - var chatQuips = generalGameplayCollection.ChatQuips; - var socialWheelData = ActionWheelSystem._SocialWheelDataList; - var socialWheelShortcuts = ActionWheelSystem._SocialWheelShortcutList; - - // Core.Log.LogWarning($"[RetroCamera] SocialWheelData count - {socialWheelData.Count} | {chatQuips.Length}"); - - foreach (var commandQuip in CommandQuips) - { - if (string.IsNullOrEmpty(commandQuip.Value.Name) - || string.IsNullOrEmpty(commandQuip.Value.Command)) - continue; - - ActionWheelData wheelData = socialWheelData[commandQuip.Key]; - - // Core.Log.LogWarning($"[RetroCamera] WheelData - {commandQuip.Value.Name} | {commandQuip.Value.Command} | {wheelData.Name}"); - wheelData.Name = commandQuip.Value.NameKey; - } - } - catch (Exception ex) - { - Core.Log.LogError(ex); - } } _socialWheel = ActionWheelSystem?._SocialWheel; @@ -194,6 +158,79 @@ static void SocialWheelKeyPressed() // Core.Log.LogWarning($"[RetroCamera] Activating wheel"); } } + static void UpdateSocialWheelQuips(GeneralGameplayCollection generalGameplayCollection) + { + try + { + ClearActiveCategory(); + + var categories = GetCategories(); + var processedSlots = new HashSet(); + + foreach (var categoryPair in categories) + { + byte categorySlot = categoryPair.Key; + var category = categoryPair.Value; + + UpdateSocialWheelSlot(generalGameplayCollection, categorySlot, category.NameKey, true); + processedSlots.Add(categorySlot); + + foreach (var entry in category.Entries) + { + byte quipSlot = entry.Key; + var commandQuip = entry.Value; + + if (commandQuip.IsEmpty) + continue; + + UpdateSocialWheelSlot(generalGameplayCollection, quipSlot, commandQuip.NameKey, false); + processedSlots.Add(quipSlot); + } + } + + foreach (var commandPair in CommandQuips) + { + byte slot = commandPair.Key; + var commandQuip = commandPair.Value; + + if (!processedSlots.Add(slot) || commandQuip.IsEmpty) + continue; + + UpdateSocialWheelSlot(generalGameplayCollection, slot, commandQuip.NameKey, false); + } + } + catch (Exception ex) + { + Core.Log.LogError(ex); + } + } + + static void UpdateSocialWheelSlot(GeneralGameplayCollection generalGameplayCollection, byte slot, Stunlock.Localization.LocalizationKey nameKey, bool isCategory) + { + if (slot < generalGameplayCollection.ChatQuips.Length) + { + ChatQuip chatQuip = generalGameplayCollection.ChatQuips[slot]; + chatQuip.Text = nameKey; + + if (isCategory) + { + chatQuip.Sequence = default; + } + + generalGameplayCollection.ChatQuips[slot] = chatQuip; + } + + var socialWheelData = ActionWheelSystem._SocialWheelDataList; + + if (slot < socialWheelData.Count) + { + ActionWheelData wheelData = socialWheelData[slot]; + wheelData.Name = nameKey; + socialWheelData[slot] = wheelData; + } + } + + static void SocialWheelKeyUp() { if (!Settings.CommandWheelEnabled) return; From 7d00254d37033b29b81a6e87970fc138f886841d Mon Sep 17 00:00:00 2001 From: zfolmt Date: Fri, 24 Oct 2025 07:32:08 -0500 Subject: [PATCH 5/8] Add social wheel category navigation (#27) * Add category navigation for social wheel quips * Update Systems/RetroCamera.cs Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --------- Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --- Patches/ActionWheelSystemPatch.cs | 44 +++++++++- Systems/RetroCamera.cs | 141 ++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 3 deletions(-) diff --git a/Patches/ActionWheelSystemPatch.cs b/Patches/ActionWheelSystemPatch.cs index 3268b8f..2ed2e49 100644 --- a/Patches/ActionWheelSystemPatch.cs +++ b/Patches/ActionWheelSystemPatch.cs @@ -57,11 +57,49 @@ static bool SendQuipChatMessagePrefix(byte index) if ((now - _lastQuipSendTime).TotalSeconds < QUIP_COOLDOWN_SECONDS) return false; - _lastQuipSendTime = now; + if (ActiveCategory.HasValue) + { + if (index == 0) + { + ClearActiveCategory(); + ShowCategoryMenu(); + return false; + } + + if (index > 0) + { + byte quipIndex = (byte)(index - 1); + + if (TryGetQuip(quipIndex, out CommandQuip commandQuip)) + { + _lastQuipSendTime = now; + SendCommandQuip(commandQuip); + return false; + } + + ClearActiveCategory(); + ShowCategoryMenu(); + return false; + } + } + else if (TryGetCategory(index, out CommandCategory category) && category.HasEntries) + { + if (SetActiveCategory(index)) + { + if (!ShowCategoryQuips(index)) + { + ClearActiveCategory(); + ShowCategoryMenu(); + } + + return false; + } + } - if (TryGetQuip(index, out CommandQuip commandQuip)) + if (TryGetQuip(index, out CommandQuip fallbackQuip)) { - SendCommandQuip(commandQuip); + _lastQuipSendTime = now; + SendCommandQuip(fallbackQuip); return false; } diff --git a/Systems/RetroCamera.cs b/Systems/RetroCamera.cs index 6ef77f9..bd28cde 100644 --- a/Systems/RetroCamera.cs +++ b/Systems/RetroCamera.cs @@ -11,6 +11,7 @@ using static RetroCamera.Utilities.CameraState; using static RetroCamera.Patches.MoodManagerComponentPatch; using RetroCamera.Utilities; +using Stunlock.Localization; namespace RetroCamera.Systems; public class RetroCamera : MonoBehaviour @@ -24,6 +25,9 @@ public class RetroCamera : MonoBehaviour public static Camera GameCamera => _gameCamera; static Camera _gameCamera; + static GeneralGameplayCollection? _generalGameplayCollection; + static readonly LocalizationKey EmptyLocalizationKey = LocalizationManager.GetLocalizationKey(string.Empty); + static bool _gameFocused = true; static bool _listening = false; static bool HideCharacterInfoPanel => Settings.HideCharacterInfoPanel; @@ -124,6 +128,8 @@ static void SocialWheelKeyPressed() if (!_socialWheelInitialized && _rootPrefabCollection.TryGetComponent(out RootPrefabCollection rootPrefabCollection) && rootPrefabCollection.GeneralGameplayCollectionPrefab.TryGetComponent(out GeneralGameplayCollection generalGameplayCollection)) { + _generalGameplayCollection = generalGameplayCollection; + UpdateSocialWheelQuips(generalGameplayCollection); ActionWheelSystem.InitializeSocialWheel(true, generalGameplayCollection); @@ -140,6 +146,7 @@ static void SocialWheelKeyPressed() } _socialWheel = ActionWheelSystem?._SocialWheel; + TryEnsureGeneralGameplayCollection(); var shortcuts = _socialWheel.ActionWheelShortcuts; foreach (var shortcut in shortcuts) @@ -162,6 +169,7 @@ static void UpdateSocialWheelQuips(GeneralGameplayCollection generalGameplayColl { try { + _generalGameplayCollection = generalGameplayCollection; ClearActiveCategory(); var categories = GetCategories(); @@ -205,6 +213,138 @@ static void UpdateSocialWheelQuips(GeneralGameplayCollection generalGameplayColl } } + internal static bool ShowCategoryQuips(byte categorySlot) + { + if (!TryEnsureGeneralGameplayCollection()) + return false; + + var actionWheelSystem = ActionWheelSystem; + var generalGameplayCollection = _generalGameplayCollection; + + if (actionWheelSystem == null || !generalGameplayCollection.HasValue) + return false; + + var socialWheelData = ActionWheelSystem._SocialWheelDataList; + if (socialWheelData == null || socialWheelData.Count == 0) + return false; + + if (!TryGetCategory(categorySlot, out var category) || !category.HasEntries) + return false; + + var generalGameplayCollectionValue = generalGameplayCollection.Value; + + int slotLimit = Math.Min(generalGameplayCollectionValue.ChatQuips.Length, socialWheelData.Count); + + if (slotLimit == 0) + return false; + + UpdateSocialWheelSlot(generalGameplayCollectionValue, 0, BackToCategoriesLabelKey, true); + + var usedSlots = new HashSet + { + 0 + }; + + int displaySlot = 1; + + foreach (var entry in category.Entries) + { + if (displaySlot >= slotLimit) + break; + + UpdateSocialWheelSlot(generalGameplayCollectionValue, (byte)displaySlot, entry.Value.NameKey, false); + usedSlots.Add((byte)displaySlot); + displaySlot++; + } + + if (usedSlots.Count <= 1) + return false; + + ClearUnusedSocialWheelSlots(usedSlots); + RefreshSocialWheelDisplay(); + return true; + } + + static void ClearUnusedSocialWheelSlots(ISet usedSlots) + { + var generalGameplayCollection = _generalGameplayCollection; + + if (!generalGameplayCollection.HasValue) + return; + + var actionWheelSystem = ActionWheelSystem; + if (actionWheelSystem == null) + return; + + var socialWheelData = ActionWheelSystem._SocialWheelDataList; + + if (socialWheelData == null) + return; + + var generalGameplayCollectionValue = generalGameplayCollection.Value; + + int slotLimit = Math.Min(generalGameplayCollectionValue.ChatQuips.Length, socialWheelData.Count); + + for (int slotIndex = 0; slotIndex < slotLimit; slotIndex++) + { + byte slot = (byte)slotIndex; + + if (usedSlots != null && usedSlots.Contains(slot)) + continue; + + bool restored = false; + + if (_originalChatQuips.TryGetValue(slot, out var originalQuip)) + { + generalGameplayCollectionValue.ChatQuips[slot] = originalQuip; + restored = true; + } + + if (_originalActionWheelData.TryGetValue(slot, out var originalWheelData)) + { + socialWheelData[slot] = originalWheelData; + restored = true; + } + + if (restored) + continue; + + UpdateSocialWheelSlot(generalGameplayCollectionValue, slot, EmptyLocalizationKey, true); + } + } + + static void UpdateSocialWheelSlot(GeneralGameplayCollection generalGameplayCollection, byte slot, LocalizationKey nameKey, bool isCategory) + { + if (slot < generalGameplayCollection.ChatQuips.Length) + { + if (!_originalChatQuips.ContainsKey(slot)) + _originalChatQuips[slot] = generalGameplayCollection.ChatQuips[slot]; + + ChatQuip chatQuip = generalGameplayCollection.ChatQuips[slot]; + chatQuip.Text = nameKey; + + if (isCategory) + { + chatQuip.Sequence = default; + } + + generalGameplayCollection.ChatQuips[slot] = chatQuip; + } + + var socialWheelData = ActionWheelSystem._SocialWheelDataList; + + if (slot < socialWheelData.Count) + { + if (!_originalActionWheelData.ContainsKey(slot)) + _originalActionWheelData[slot] = socialWheelData[slot]; + + ActionWheelData wheelData = socialWheelData[slot]; + wheelData.Name = nameKey; + socialWheelData[slot] = wheelData; + } + } + } + static void UpdateSocialWheelSlot(GeneralGameplayCollection generalGameplayCollection, byte slot, Stunlock.Localization.LocalizationKey nameKey, bool isCategory) { if (slot < generalGameplayCollection.ChatQuips.Length) @@ -404,6 +544,7 @@ public static void ResetState() _socialWheelInitialized = false; _shouldActivateWheel = false; _rootPrefabCollection = Entity.Null; + _generalGameplayCollection = null; } } From 8679ad8c6f5933adf260caaea4015af5e693e6e5 Mon Sep 17 00:00:00 2001 From: zfolmt Date: Fri, 24 Oct 2025 09:14:03 -0500 Subject: [PATCH 6/8] Reset social wheel to categories on close (#28) * Reset social wheel to categories on close * Restore social wheel helpers and reset behavior --- Patches/ActionWheelSystemPatch.cs | 16 ++++- Systems/RetroCamera.cs | 100 ++++++++++++++++++++++-------- 2 files changed, 86 insertions(+), 30 deletions(-) diff --git a/Patches/ActionWheelSystemPatch.cs b/Patches/ActionWheelSystemPatch.cs index 2ed2e49..bb80996 100644 --- a/Patches/ActionWheelSystemPatch.cs +++ b/Patches/ActionWheelSystemPatch.cs @@ -110,15 +110,25 @@ static bool SendQuipChatMessagePrefix(byte index) [HarmonyPrefix] static bool HideCurrentWheelPrefix(ActionWheelSystem __instance) { - if (SocialWheelActive) + bool closingSocialWheel = SocialWheel != null && __instance?._CurrentActiveWheel == SocialWheel; + bool shouldResetWheel = SocialWheelActive || closingSocialWheel || ActiveCategory.HasValue; + + if (shouldResetWheel) { - return false; + ClearActiveCategory(); + ShowCategoryMenu(); } - else if (!_wheelOpened.Equals(DateTime.MinValue)) + + if (!_wheelOpened.Equals(DateTime.MinValue)) { _wheelOpened = DateTime.MinValue; } + if (SocialWheelActive) + { + return false; + } + return true; } diff --git a/Systems/RetroCamera.cs b/Systems/RetroCamera.cs index bd28cde..df9abc4 100644 --- a/Systems/RetroCamera.cs +++ b/Systems/RetroCamera.cs @@ -26,6 +26,8 @@ public class RetroCamera : MonoBehaviour static Camera _gameCamera; static GeneralGameplayCollection? _generalGameplayCollection; + static readonly Dictionary _originalChatQuips = new(); + static readonly Dictionary _originalActionWheelData = new(); static readonly LocalizationKey EmptyLocalizationKey = LocalizationManager.GetLocalizationKey(string.Empty); static bool _gameFocused = true; @@ -170,6 +172,8 @@ static void UpdateSocialWheelQuips(GeneralGameplayCollection generalGameplayColl try { _generalGameplayCollection = generalGameplayCollection; + _originalChatQuips.Clear(); + _originalActionWheelData.Clear(); ClearActiveCategory(); var categories = GetCategories(); @@ -213,6 +217,70 @@ static void UpdateSocialWheelQuips(GeneralGameplayCollection generalGameplayColl } } + static bool TryEnsureGeneralGameplayCollection() + { + if (_generalGameplayCollection.HasValue) + return true; + + if (!_rootPrefabCollection.Exists()) + return false; + + if (_rootPrefabCollection.TryGetComponent(out RootPrefabCollection rootPrefabCollection) + && rootPrefabCollection.GeneralGameplayCollectionPrefab.TryGetComponent(out GeneralGameplayCollection generalGameplayCollection)) + { + _generalGameplayCollection = generalGameplayCollection; + return true; + } + + return false; + } + + internal static void ShowCategoryMenu() + { + if (!TryEnsureGeneralGameplayCollection()) + return; + + var actionWheelSystem = ActionWheelSystem; + var generalGameplayCollection = _generalGameplayCollection; + + if (actionWheelSystem == null || !generalGameplayCollection.HasValue) + return; + + var socialWheelData = ActionWheelSystem._SocialWheelDataList; + + if (socialWheelData == null || socialWheelData.Count == 0) + return; + + var generalGameplayCollectionValue = generalGameplayCollection.Value; + + int slotLimit = Math.Min(generalGameplayCollectionValue.ChatQuips.Length, socialWheelData.Count); + + if (slotLimit == 0) + return; + + var categories = GetCategories(); + var usedSlots = new HashSet(); + + foreach (var categoryPair in categories) + { + byte slot = categoryPair.Key; + + if (slot >= slotLimit) + continue; + + var category = categoryPair.Value; + UpdateSocialWheelSlot(generalGameplayCollectionValue, slot, category.NameKey, true); + usedSlots.Add(slot); + } + + ClearUnusedSocialWheelSlots(usedSlots); + RefreshSocialWheelDisplay(); + } + + static void RefreshSocialWheelDisplay() + { + } + internal static bool ShowCategoryQuips(byte categorySlot) { if (!TryEnsureGeneralGameplayCollection()) @@ -343,33 +411,6 @@ static void UpdateSocialWheelSlot(GeneralGameplayCollection generalGameplayColle socialWheelData[slot] = wheelData; } } - } - - static void UpdateSocialWheelSlot(GeneralGameplayCollection generalGameplayCollection, byte slot, Stunlock.Localization.LocalizationKey nameKey, bool isCategory) - { - if (slot < generalGameplayCollection.ChatQuips.Length) - { - ChatQuip chatQuip = generalGameplayCollection.ChatQuips[slot]; - chatQuip.Text = nameKey; - - if (isCategory) - { - chatQuip.Sequence = default; - } - - generalGameplayCollection.ChatQuips[slot] = chatQuip; - } - - var socialWheelData = ActionWheelSystem._SocialWheelDataList; - - if (slot < socialWheelData.Count) - { - ActionWheelData wheelData = socialWheelData[slot]; - wheelData.Name = nameKey; - socialWheelData[slot] = wheelData; - } - } - static void SocialWheelKeyUp() { @@ -377,6 +418,9 @@ static void SocialWheelKeyUp() if (_socialWheelActive) { + ClearActiveCategory(); + ShowCategoryMenu(); + _socialWheelActive = false; ActionWheelSystem.HideCurrentWheel(); _socialWheel.gameObject.SetActive(false); @@ -545,6 +589,8 @@ public static void ResetState() _shouldActivateWheel = false; _rootPrefabCollection = Entity.Null; _generalGameplayCollection = null; + _originalChatQuips.Clear(); + _originalActionWheelData.Clear(); } } From cb16f9b12ab18d95904aa6b814797d572c2c75b8 Mon Sep 17 00:00:00 2001 From: mfoltz Date: Mon, 18 May 2026 16:59:45 -0500 Subject: [PATCH 7/8] ci: add RetroCamera release adapter --- .codex/scripts/bump-version.ps1 | 66 ++++ .codex/scripts/prerelease-notes.sh | 155 +++++++++ .codex/scripts/prerelease-notes.tests.ps1 | 160 +++++++++ .codex/scripts/release-hygiene.tests.ps1 | 195 +++++++++++ .codex/scripts/release-nudge.ps1 | 161 +++++++++ .codex/scripts/version-metadata.sh | 87 +++++ .github/assets/ts_badge.png | Bin 0 -> 15058 bytes .github/workflows/build.yml | 400 +++++++++++++++++++--- .github/workflows/release.yml | 115 ++++++- .gitignore | 4 +- RetroCamera.csproj | 6 +- 11 files changed, 1276 insertions(+), 73 deletions(-) create mode 100644 .codex/scripts/bump-version.ps1 create mode 100644 .codex/scripts/prerelease-notes.sh create mode 100644 .codex/scripts/prerelease-notes.tests.ps1 create mode 100644 .codex/scripts/release-hygiene.tests.ps1 create mode 100644 .codex/scripts/release-nudge.ps1 create mode 100644 .codex/scripts/version-metadata.sh create mode 100644 .github/assets/ts_badge.png diff --git a/.codex/scripts/bump-version.ps1 b/.codex/scripts/bump-version.ps1 new file mode 100644 index 0000000..018df48 --- /dev/null +++ b/.codex/scripts/bump-version.ps1 @@ -0,0 +1,66 @@ +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string]$Version +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptRoot) + +$ProjectPath = Join-Path $RepoRoot "RetroCamera.csproj" +$ThunderstorePath = Join-Path $RepoRoot "thunderstore.toml" + +function Set-TextPreservingUtf8Bom { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Value + ) + + $Bytes = [System.IO.File]::ReadAllBytes($Path) + $HasUtf8Bom = $Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF + $Encoding = [System.Text.UTF8Encoding]::new($HasUtf8Bom) + [System.IO.File]::WriteAllText($Path, $Value, $Encoding) +} + +function Update-FirstMatch { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Pattern, + [Parameter(Mandatory = $true)] + [string]$Replacement, + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $Text = Get-Content -Raw -Path $Path + $Regex = [regex]::new($Pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline) + $Match = $Regex.Match($Text) + if (-not $Match.Success) { + throw "Unable to update $Description in $Path." + } + + $Updated = $Text.Substring(0, $Match.Index) + $Regex.Replace($Match.Value, $Replacement, 1) + $Text.Substring($Match.Index + $Match.Length) + Set-TextPreservingUtf8Bom -Path $Path -Value $Updated +} + +Update-FirstMatch ` + -Path $ProjectPath ` + -Pattern '[^<]+' ` + -Replacement "$Version" ` + -Description "project version" + +Update-FirstMatch ` + -Path $ThunderstorePath ` + -Pattern '^versionNumber = "[^"]+"' ` + -Replacement "versionNumber = `"$Version`"" ` + -Description "Thunderstore version" + +Write-Host "Updated RetroCamera project and Thunderstore version metadata to $Version." +Write-Host "CHANGELOG.md was not changed; add the matching version entry manually before release validation." diff --git a/.codex/scripts/prerelease-notes.sh b/.codex/scripts/prerelease-notes.sh new file mode 100644 index 0000000..3433783 --- /dev/null +++ b/.codex/scripts/prerelease-notes.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")" + +CHANGELOG_PATH="$REPO_ROOT/CHANGELOG.md" +VERSION="" +TAG="" +BRANCH="" +COMMIT="" +RUN_ID="" +OUTPUT_PATH="" +CHECK_ONLY="false" + +fail() { + echo "Error: $1" >&2 + exit 1 +} + +usage() { + cat >&2 <<'EOF' +Usage: prerelease-notes.sh --version X.Y.Z [options] + +Options: + --changelog PATH CHANGELOG.md path to read. + --tag TAG GitHub Release tag. Defaults to vX.Y.Z-pre. + --branch NAME Source branch name for the notes card. + --commit SHA Source commit SHA for the notes card. + --run-id ID GitHub Actions run id for the notes card. + --output PATH Markdown file to write. + --check-only Validate changelog entry without writing notes. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --changelog) + CHANGELOG_PATH="${2:-}" + shift 2 + ;; + --version) + VERSION="${2:-}" + shift 2 + ;; + --tag) + TAG="${2:-}" + shift 2 + ;; + --branch) + BRANCH="${2:-}" + shift 2 + ;; + --commit) + COMMIT="${2:-}" + shift 2 + ;; + --run-id) + RUN_ID="${2:-}" + shift 2 + ;; + --output) + OUTPUT_PATH="${2:-}" + shift 2 + ;; + --check-only) + CHECK_ONLY="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + fail "Unknown argument '$1'." + ;; + esac +done + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + fail "--version must use canonical X.Y.Z format." +fi + +if [ -z "$TAG" ]; then + TAG="v${VERSION}-pre" +fi + +if [ ! -f "$CHANGELOG_PATH" ]; then + fail "Unable to locate changelog at '$CHANGELOG_PATH'." +fi + +version_body=$( + awk -v version="$VERSION" ' + $0 == "`" version "`" { in_version = 1; next } + in_version && /^`[^`]+`[[:space:]]*$/ { exit } + in_version { print } + ' "$CHANGELOG_PATH" +) + +if ! printf '%s' "$version_body" | grep -q '[^[:space:]]'; then + fail "CHANGELOG.md does not contain notes for '$VERSION'." +fi + +if [ "$CHECK_ONLY" = "true" ]; then + echo "prerelease-notes: changelog entry validated for $VERSION." + exit 0 +fi + +if [ -z "$OUTPUT_PATH" ]; then + fail "--output is required unless --check-only is used." +fi + +BRANCH="${BRANCH:-unknown}" +COMMIT="${COMMIT:-unknown}" +RUN_ID="${RUN_ID:-unknown}" +short_commit="$COMMIT" +if [ ${#short_commit} -gt 12 ]; then + short_commit="${short_commit:0:12}" +fi + +run_detail="\`$RUN_ID\`" +if [ -n "${GITHUB_REPOSITORY:-}" ] && [ "$RUN_ID" != "unknown" ]; then + run_detail="[${RUN_ID}](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID})" +fi + +handoff_heading="### Thunderstore handoff" +if [ -n "${GITHUB_REPOSITORY:-}" ] && [ "$COMMIT" != "unknown" ]; then + handoff_heading="

\"Thunderstore

" +fi + +mkdir -p "$(dirname "$OUTPUT_PATH")" +cat > "$OUTPUT_PATH" < [!NOTE] +> This GitHub pre-release is the source artifact for the matching Thunderstore publish. Thunderstore receives package version \`${VERSION}\` from tag \`${TAG}\`. + +${handoff_heading} + +| Signal | Detail | +| --- | --- | +| Changelog | Notes below come from the \`${VERSION}\` entry. | +| Branch | \`${BRANCH}\` | +| Commit | \`${short_commit}\` | +| Run | ${run_detail} | +| Tag | \`${TAG}\` | +| Package | \`${VERSION}\` | + +### Changes + +${version_body} +EOF + +echo "prerelease-notes: wrote $OUTPUT_PATH" diff --git a/.codex/scripts/prerelease-notes.tests.ps1 b/.codex/scripts/prerelease-notes.tests.ps1 new file mode 100644 index 0000000..9898966 --- /dev/null +++ b/.codex/scripts/prerelease-notes.tests.ps1 @@ -0,0 +1,160 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$PrereleaseNotesPath = Join-Path $ScriptRoot "prerelease-notes.sh" +$BashPath = "C:\Program Files\Git\bin\bash.exe" + +if (-not (Test-Path -LiteralPath $BashPath)) { + throw "Git Bash was not found at $BashPath." +} + +function Assert-Match { + param( + [Parameter(Mandatory = $true)] + [string]$Text, + [Parameter(Mandatory = $true)] + [string]$Pattern, + [Parameter(Mandatory = $true)] + [string]$Message + ) + + if ($Text -notmatch $Pattern) { + throw $Message + } +} + +function New-Fixture { + $FixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("retrocamera-prerelease-notes-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $FixtureRoot | Out-Null + return $FixtureRoot +} + +function Invoke-PrereleaseNotes { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments + ) + + & $BashPath $PrereleaseNotesPath @Arguments 2>&1 | Out-String +} + +function Test-PrereleaseNotesIncludesChangelogAndDetailsCard { + $FixtureRoot = New-Fixture + $PreviousRepository = $env:GITHUB_REPOSITORY + try { + $env:GITHUB_REPOSITORY = "mfoltz/RetroCamera" + $ChangelogPath = Join-Path $FixtureRoot "CHANGELOG.md" + $OutputPath = Join-Path $FixtureRoot "prerelease-notes.md" + Set-Content -Path $ChangelogPath -Value @' +`1.2.3` +- fixed the camera timing +- added release validation + +`1.2.2` +- previous release +'@ + + $Output = Invoke-PrereleaseNotes -Arguments @( + "--changelog", $ChangelogPath, + "--version", "1.2.3", + "--tag", "v1.2.3-pre", + "--branch", "main", + "--commit", "1234567890abcdef", + "--run-id", "42", + "--output", $OutputPath) + if ($LASTEXITCODE -ne 0) { + throw "prerelease-notes.sh exited with $LASTEXITCODE`n$Output" + } + + $Notes = Get-Content -Raw -Path $OutputPath + if ($Notes -match ' + + 1.2.3 + + +"@ -NoNewline + + Set-Content -Path (Join-Path $FixtureRoot "thunderstore.toml") -Value @" +[package] +name = "RetroCamera" +versionNumber = "1.2.3" +"@ -NoNewline + + Set-Content -Path (Join-Path $FixtureRoot "CHANGELOG.md") -Value @' +`1.2.3` +- current release + +`1.2.2` +- previous release +'@ -NoNewline + + Set-Content -Path (Join-Path $FixtureRoot "Systems/RetroCamera.cs") -Value "class RetroCamera {}" -NoNewline + + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("init", "-b", "main") | Out-Null + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("config", "user.email", "codex@example.invalid") | Out-Null + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("config", "user.name", "Codex") | Out-Null + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("add", ".") | Out-Null + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("commit", "-m", "baseline") | Out-Null + + return $FixtureRoot +} + +function Test-BumpVersionUpdatesMetadataButLeavesChangelog { + $FixtureRoot = New-FixtureRepo + try { + & pwsh -NoProfile -File (Join-Path $FixtureRoot ".codex/scripts/bump-version.ps1") -Version "1.2.4" + if ($LASTEXITCODE -ne 0) { + throw "bump-version.ps1 exited with $LASTEXITCODE" + } + + $ProjectText = Get-Content -Raw -Path (Join-Path $FixtureRoot "RetroCamera.csproj") + $ThunderstoreText = Get-Content -Raw -Path (Join-Path $FixtureRoot "thunderstore.toml") + $ChangelogText = Get-Content -Raw -Path (Join-Path $FixtureRoot "CHANGELOG.md") + + Assert-Match -Text $ProjectText -Pattern '1\.2\.4' -Message "Project version was not updated." + Assert-Match -Text $ThunderstoreText -Pattern 'versionNumber = "1\.2\.4"' -Message "Thunderstore version was not updated." + Assert-NotMatch -Text $ChangelogText -Pattern '`1\.2\.4`' -Message "Changelog should remain human-owned and unchanged by bump-version." + } + finally { + Remove-Item -LiteralPath $FixtureRoot -Recurse -Force + } +} + +function Test-ReleaseNudgeAllowsCurrentChangelogEntry { + $FixtureRoot = New-FixtureRepo + try { + Set-Content -Path (Join-Path $FixtureRoot "Systems/RetroCamera.cs") -Value "class RetroCamera { void Changed() {} }" -NoNewline + + $Output = & pwsh -NoProfile -File (Join-Path $FixtureRoot ".codex/scripts/release-nudge.ps1") -BaseRef "main" 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + throw "release-nudge.ps1 exited with $LASTEXITCODE`n$Output" + } + + Assert-Match -Text $Output -Pattern 'no changelog or version metadata nudge needed|human-owned with latest entry 1\.2\.3' -Message "Release nudge should accept the current version changelog entry." + } + finally { + Remove-Item -LiteralPath $FixtureRoot -Recurse -Force + } +} + +function Test-ReleaseNudgeBlocksWhenVersionMissingFromChangelog { + $FixtureRoot = New-FixtureRepo + try { + & pwsh -NoProfile -File (Join-Path $FixtureRoot ".codex/scripts/bump-version.ps1") -Version "1.2.4" | Out-Null + + $Output = & pwsh -NoProfile -File (Join-Path $FixtureRoot ".codex/scripts/release-nudge.ps1") -BaseRef "main" 2>&1 | Out-String + Assert-Equal -Actual "$LASTEXITCODE" -Expected "1" -Message "Release nudge should block when version metadata outruns the changelog." + Assert-Match -Text $Output -Pattern 'latest CHANGELOG\.md entry is 1\.2\.3' -Message "Release nudge output should name the stale changelog entry." + } + finally { + Remove-Item -LiteralPath $FixtureRoot -Recurse -Force + } +} + +function Test-ReleaseNudgeUsesGitHubEventBeforeWhenBaseRefOmitted { + $FixtureRoot = New-FixtureRepo + $OriginalEventBefore = $env:GITHUB_EVENT_BEFORE + try { + $BeforeSha = (Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("rev-parse", "HEAD")).Trim() + Set-Content -Path (Join-Path $FixtureRoot "Systems/RetroCamera.cs") -Value "class RetroCamera { void Changed() {} }" -NoNewline + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("add", ".") | Out-Null + Invoke-Git -WorkingDirectory $FixtureRoot -Arguments @("commit", "-m", "change system") | Out-Null + + $env:GITHUB_EVENT_BEFORE = $BeforeSha + $Output = & pwsh -NoProfile -File (Join-Path $FixtureRoot ".codex/scripts/release-nudge.ps1") 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + throw "release-nudge.ps1 exited with $LASTEXITCODE`n$Output" + } + + Assert-Match -Text $Output -Pattern 'no changelog or version metadata nudge needed|human-owned with latest entry 1\.2\.3' -Message "Release nudge did not use GITHUB_EVENT_BEFORE when BaseRef was omitted." + } + finally { + $env:GITHUB_EVENT_BEFORE = $OriginalEventBefore + Remove-Item -LiteralPath $FixtureRoot -Recurse -Force + } +} + +Test-BumpVersionUpdatesMetadataButLeavesChangelog +Test-ReleaseNudgeAllowsCurrentChangelogEntry +Test-ReleaseNudgeBlocksWhenVersionMissingFromChangelog +Test-ReleaseNudgeUsesGitHubEventBeforeWhenBaseRefOmitted + +Write-Host "release-hygiene tests passed" diff --git a/.codex/scripts/release-nudge.ps1 b/.codex/scripts/release-nudge.ps1 new file mode 100644 index 0000000..b2b5312 --- /dev/null +++ b/.codex/scripts/release-nudge.ps1 @@ -0,0 +1,161 @@ +param( + [string]$BaseRef, + + [int]$LineThreshold = 120, + + [switch]$WarnOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptRoot) +$ProjectPath = Join-Path $RepoRoot "RetroCamera.csproj" +$ChangelogPath = Join-Path $RepoRoot "CHANGELOG.md" + +function Invoke-Git { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments + ) + + Push-Location $RepoRoot + try { + & git -c core.autocrlf=false @Arguments + } + finally { + Pop-Location + } +} + +function Write-Nudge { + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + if ($env:GITHUB_ACTIONS -eq "true") { + Write-Host "::warning title=Release hygiene nudge::$Message" + } + else { + Write-Warning $Message + } + + $script:NudgeCount++ +} + +function Get-ProjectVersion { + $Text = Get-Content -Raw -Path $ProjectPath + $Match = [regex]::Match($Text, '(?[^<]+)') + + if (-not $Match.Success) { + return $null + } + + return $Match.Groups["version"].Value.Trim() +} + +function Get-LatestChangelogEntry { + foreach ($Line in Get-Content -Path $ChangelogPath) { + if ($Line -match '^`(?\d+\.\d+\.\d+)`$') { + return $Matches["version"] + } + } + + return $null +} + +function Test-MeaningfulPath { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $NormalizedPath = $Path -replace '\\', '/' + + return $NormalizedPath -match '^(Behaviours|Configuration|Patches|Systems|Utilities)/' ` + -or $NormalizedPath -match '^Resources/[^/]+\.(cs|json|png)$' ` + -or $NormalizedPath -match '^\.codex/scripts/' ` + -or $NormalizedPath -match '^\.github/workflows/' ` + -or $NormalizedPath -match '^[^/]+\.(cs|csproj)$' +} + +function Test-UsableGitRef { + param( + [string]$Ref + ) + + return -not [string]::IsNullOrWhiteSpace($Ref) -and $Ref -notmatch '^0+$' +} + +if ([string]::IsNullOrWhiteSpace($BaseRef)) { + $BaseRef = if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_BASE_REF)) { + "origin/$($env:GITHUB_BASE_REF)" + } + elseif (Test-UsableGitRef $env:GITHUB_EVENT_BEFORE) { + $env:GITHUB_EVENT_BEFORE + } + else { + "HEAD^" + } +} + +$MergeBase = Invoke-Git -Arguments @("merge-base", "HEAD", $BaseRef) 2>$null +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($MergeBase)) { + Write-Host "release-nudge: unable to resolve merge-base for '$BaseRef'; skipping release hygiene gate." + exit 0 +} + +$ChangedFiles = @(Invoke-Git -Arguments @("diff", "--name-only", $MergeBase) 2>$null) +$MeaningfulFiles = @($ChangedFiles | Where-Object { Test-MeaningfulPath $_ }) + +if ($MeaningfulFiles.Count -eq 0) { + Write-Host "release-nudge: no meaningful source, script, or workflow changes detected." + exit 0 +} + +$NumstatArguments = @("diff", "--numstat", $MergeBase, "--") + $MeaningfulFiles +$Numstat = @(Invoke-Git -Arguments $NumstatArguments 2>$null) +$ChangedLines = 0 +foreach ($Line in $Numstat) { + $Parts = $Line -split "`t" + if ($Parts.Length -lt 2) { + continue + } + + $AddedLines = 0 + if ([int]::TryParse($Parts[0], [ref]$AddedLines)) { + $ChangedLines += $AddedLines + } + + $DeletedLines = 0 + if ([int]::TryParse($Parts[1], [ref]$DeletedLines)) { + $ChangedLines += $DeletedLines + } +} + +$ProjectVersion = Get-ProjectVersion +$LatestChangelogEntry = Get-LatestChangelogEntry +$ChangelogChanged = @($ChangedFiles | Where-Object { $_ -eq "CHANGELOG.md" }).Count -gt 0 +$script:NudgeCount = 0 + +if ([string]::IsNullOrWhiteSpace($ProjectVersion)) { + Write-Nudge "RetroCamera.csproj does not expose a readable Version value." +} +elseif ([string]::IsNullOrWhiteSpace($LatestChangelogEntry)) { + Write-Nudge "RetroCamera CHANGELOG.md does not contain a readable latest version entry." +} +elseif ($ProjectVersion -ne $LatestChangelogEntry -and -not $ChangelogChanged) { + Write-Nudge "RetroCamera version metadata is $ProjectVersion, but the latest CHANGELOG.md entry is $LatestChangelogEntry. Add the matching human-owned changelog entry before release." +} +elseif ($ChangedLines -ge $LineThreshold -and -not $ChangelogChanged) { + Write-Host "release-nudge: meaningful changes detected, and CHANGELOG.md remains human-owned with latest entry $LatestChangelogEntry." +} + +if ($script:NudgeCount -eq 0) { + Write-Host "release-nudge: no changelog or version metadata nudge needed." +} +elseif (-not $WarnOnly.IsPresent) { + exit 1 +} diff --git a/.codex/scripts/version-metadata.sh b/.codex/scripts/version-metadata.sh new file mode 100644 index 0000000..897893c --- /dev/null +++ b/.codex/scripts/version-metadata.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")" +CSPROJ_PATH="$REPO_ROOT/RetroCamera.csproj" +THUNDERSTORE_PATH="$REPO_ROOT/thunderstore.toml" +CHANGELOG_PATH="$REPO_ROOT/CHANGELOG.md" +CANONICAL_VERSION_PATTERN='^[0-9]+\.[0-9]+\.[0-9]+$' + +fail() { + local message="$1" + echo "Error: $message" >&2 + exit 1 +} + +read_csproj_version() { + local version + version=$(sed -n 's:.*\(.*\).*:\1:p' "$CSPROJ_PATH" | head -n 1 | tr -d '[:space:]') + + if [ -z "$version" ]; then + fail "Unable to determine canonical version from $CSPROJ_PATH." + fi + + printf '%s\n' "$version" +} + +read_thunderstore_version() { + local version + version=$(sed -n 's/^versionNumber = "\([^"]*\)"$/\1/p' "$THUNDERSTORE_PATH" | head -n 1 | tr -d '[:space:]') + + if [ -z "$version" ]; then + fail "Unable to determine Thunderstore version from $THUNDERSTORE_PATH." + fi + + printf '%s\n' "$version" +} + +read_latest_changelog_entry() { + local entry + entry=$(sed -n '/^`[^`][^`]*`$/ { s/^`//; s/`$//; p; q; }' "$CHANGELOG_PATH" | tr -d '\r') + + if [ -z "$entry" ]; then + fail "Unable to determine the latest CHANGELOG entry from $CHANGELOG_PATH." + fi + + printf '%s\n' "$entry" +} + +validate_canonical_version_format() { + local version="$1" + local source_name="$2" + + if [[ ! "$version" =~ $CANONICAL_VERSION_PATTERN ]]; then + fail "$source_name version '$version' must match canonical format X.Y.Z." + fi +} + +require_matching_version() { + local source_name="$1" + local source_version="$2" + local canonical_version="$3" + + validate_canonical_version_format "$source_version" "$source_name" + + if [ "$source_version" != "$canonical_version" ]; then + fail "$source_name version '$source_version' does not match canonical version '$canonical_version' from $CSPROJ_PATH." + fi +} + +emit_canonical_version() { + local canonical_version="$1" + + if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "canonical_version=$canonical_version" >> "$GITHUB_OUTPUT" + fi + + echo "canonical_version=$canonical_version" +} + +canonical_version=$(read_csproj_version) +validate_canonical_version_format "$canonical_version" "$CSPROJ_PATH" + +require_matching_version "$THUNDERSTORE_PATH" "$(read_thunderstore_version)" "$canonical_version" +require_matching_version "$CHANGELOG_PATH latest entry" "$(read_latest_changelog_entry)" "$canonical_version" + +emit_canonical_version "$canonical_version" diff --git a/.github/assets/ts_badge.png b/.github/assets/ts_badge.png new file mode 100644 index 0000000000000000000000000000000000000000..36c1e096ec52b38b9bad0704b48bb781e3447e9a GIT binary patch literal 15058 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 $GITHUB_OUTPUT - - - name: Get DLL name - id: get_dll_name + -not -path '*/obj/*' \ + -not -path '*/.codex/*' | head -n 1) + + if [ -z "$csproj_file" ]; then + echo "Unable to locate a .csproj file." >&2 + exit 1 + fi + + echo "csproj_file=$csproj_file" >> "$GITHUB_OUTPUT" + + - name: Validate canonical version metadata + id: version_metadata + shell: bash + run: bash .codex/scripts/version-metadata.sh + + - name: Check release tags for current version + id: check_release + uses: actions/github-script@v7 + env: + CURRENT_VERSION: ${{ steps.version_metadata.outputs.canonical_version }} + with: + script: | + const currentVersion = process.env.CURRENT_VERSION; + + if (!currentVersion) { + core.setFailed('CURRENT_VERSION was not provided.'); + return; + } + + if (context.eventName !== 'push') { + const reason = `Running validation-only workflow for ${context.eventName}; allowing build verification for canonical version ${currentVersion}.`; + core.info(reason); + core.setOutput('should_build', 'true'); + core.setOutput('reason', reason); + core.setOutput('matched_tag', ''); + return; + } + + if (context.ref === 'refs/heads/codex/feature-testing') { + const reason = `Feature-testing branch snapshot derived from canonical version ${currentVersion}; allowing build.`; + core.info(reason); + core.setOutput('should_build', 'true'); + core.setOutput('reason', reason); + core.setOutput('matched_tag', ''); + return; + } + + const releases = await github.paginate(github.rest.repos.listReleases, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + + const normalizeTag = (tag) => tag.replace(/^v/, '').trim(); + const normalizeReleaseVersion = (tag) => normalizeTag(tag).replace(/-pre$/, ''); + const matchedRelease = releases.find((release) => !release.draft && normalizeReleaseVersion(release.tag_name) === currentVersion); + const matchedTag = matchedRelease?.tag_name ?? ''; + + core.info(`Normalized current version: ${currentVersion}`); + core.info(`Matched release tag: ${matchedTag || 'none'}`); + + if (matchedRelease) { + const reason = `Skipping build because version ${currentVersion} already exists as release tag ${matchedTag}.`; + core.info(reason); + core.setOutput('should_build', 'false'); + core.setOutput('reason', reason); + core.setOutput('matched_tag', matchedTag); + return; + } + + const reason = `Proceeding with build because version ${currentVersion} does not exist in repository releases.`; + core.info(reason); + core.setOutput('should_build', 'true'); + core.setOutput('reason', reason); + core.setOutput('matched_tag', ''); + + build_verification: + needs: + - validate_build_inputs + if: needs.validate_build_inputs.outputs.should_build == 'true' + permissions: + contents: read + runs-on: ubuntu-latest + + steps: + - name: Log validation decision + shell: bash + run: | + echo "Validation decision: ${{ needs.validate_build_inputs.outputs.should_build }}" + echo "Reason: ${{ needs.validate_build_inputs.outputs.reason }}" + echo "Canonical version: ${{ needs.validate_build_inputs.outputs.canonical_version }}" + echo "Matched release tag: ${{ needs.validate_build_inputs.outputs.matched_tag || 'none' }}" + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: false + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Validate canonical version metadata + shell: bash + run: bash .codex/scripts/version-metadata.sh + + - name: Run release hygiene gate + shell: pwsh + env: + GITHUB_EVENT_BEFORE: ${{ github.event.before }} + run: ./.codex/scripts/release-nudge.ps1 + + - name: Verify canonical files remain unchanged + shell: bash + run: | + csproj='${{ needs.validate_build_inputs.outputs.csproj_file }}' + git diff --exit-code -- "$csproj" thunderstore.toml CHANGELOG.md + + - name: Restore dependencies + shell: bash + run: | + dotnet restore '${{ needs.validate_build_inputs.outputs.csproj_file }}' \ + --source https://api.nuget.org/v3/index.json \ + --source https://nuget.bepinex.dev/v3/index.json + + - name: Build validation artifact + shell: bash + run: | + version='${{ needs.validate_build_inputs.outputs.canonical_version }}' + dotnet build '${{ needs.validate_build_inputs.outputs.csproj_file }}' \ + --configuration Release \ + --no-restore \ + -p:Version="$version" \ + -p:DeployToClient=false \ + -p:RunGenerateREADME=false + + prepare_prerelease: + needs: + - validate_build_inputs + - build_verification + if: >- + github.event_name == 'push' && + needs.validate_build_inputs.outputs.should_build == 'true' && + github.ref == 'refs/heads/main' + permissions: + contents: read + runs-on: ubuntu-latest + outputs: + prerelease_version: ${{ steps.prepare_version.outputs.prerelease_version }} + prerelease_tag: ${{ steps.prepare_version.outputs.prerelease_tag }} + prerelease_name: ${{ steps.prepare_version.outputs.prerelease_name }} + + steps: + - name: Prepare prerelease metadata + id: prepare_version + shell: bash run: | - csproj="${{ steps.discover_csproj.outputs.csproj_file }}" - dll_name=$(basename "$csproj" .csproj) - echo "dll_name=$dll_name" >> $GITHUB_OUTPUT + canonical_version='${{ needs.validate_build_inputs.outputs.canonical_version }}' - - name: Install xmllint - run: sudo apt-get update && sudo apt-get install -y libxml2-utils + if [ -z "$canonical_version" ]; then + echo "Canonical version is empty; cannot prepare prerelease metadata." >&2 + exit 1 + fi + + echo "prerelease_version=$canonical_version" >> "$GITHUB_OUTPUT" + echo "prerelease_tag=v${canonical_version}-pre" >> "$GITHUB_OUTPUT" + echo "prerelease_name=Pre-release v${canonical_version}" >> "$GITHUB_OUTPUT" + + publish_prerelease: + needs: + - validate_build_inputs + - build_verification + - prepare_prerelease + if: >- + github.event_name == 'push' && + needs.validate_build_inputs.outputs.should_build == 'true' && + github.ref == 'refs/heads/main' + permissions: + contents: write + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + fetch-tags: false + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Validate canonical version metadata + shell: bash + run: bash .codex/scripts/version-metadata.sh + + - name: Restore dependencies + shell: bash + run: | + dotnet restore '${{ needs.validate_build_inputs.outputs.csproj_file }}' \ + --source https://api.nuget.org/v3/index.json \ + --source https://nuget.bepinex.dev/v3/index.json - - name: Extract version from .csproj - id: extract_version + - name: Build prerelease artifact + shell: bash run: | - version=$(xmllint --xpath "string(//Project/PropertyGroup/Version)" "${{ steps.discover_csproj.outputs.csproj_file }}") - echo "version=$version" >> $GITHUB_ENV + version='${{ needs.prepare_prerelease.outputs.prerelease_version }}' + dotnet build '${{ needs.validate_build_inputs.outputs.csproj_file }}' \ + --configuration Release \ + --no-restore \ + -p:Version="$version" \ + -p:DeployToClient=false \ + -p:RunGenerateREADME=false - - name: Update thunderstore.toml + - name: Prepare prerelease notes + shell: bash run: | - sed -i "s/versionNumber = \".*\"/versionNumber = \"${{ env.version }}\"/" thunderstore.toml + bash .codex/scripts/prerelease-notes.sh \ + --version '${{ needs.prepare_prerelease.outputs.prerelease_version }}' \ + --tag '${{ needs.prepare_prerelease.outputs.prerelease_tag }}' \ + --branch '${{ github.ref_name }}' \ + --commit '${{ github.sha }}' \ + --run-id '${{ github.run_id }}' \ + --output ./dist/prerelease-notes.md + + - name: GH Release (pre-release) + uses: softprops/action-gh-release@v2.6.2 + if: needs.prepare_prerelease.outputs.prerelease_version != '' + with: + body_path: ./dist/prerelease-notes.md + name: ${{ needs.prepare_prerelease.outputs.prerelease_name }} + fail_on_unmatched_files: true + prerelease: true + tag_name: ${{ needs.prepare_prerelease.outputs.prerelease_tag }} + files: | + ./bin/Release/net6.0/RetroCamera.dll + CHANGELOG.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + prepare_feature_testing_prerelease: + needs: + - validate_build_inputs + - build_verification + if: >- + github.event_name == 'push' && + needs.validate_build_inputs.outputs.should_build == 'true' && + github.ref == 'refs/heads/codex/feature-testing' + permissions: + contents: read + runs-on: ubuntu-latest + outputs: + feature_testing_version: ${{ steps.derive_version.outputs.feature_testing_version }} - git config user.name "github-actions" - git config user.email "github-actions@github.com" + steps: + - name: Derive feature-testing version + id: derive_version + shell: bash + run: | + canonical_version='${{ needs.validate_build_inputs.outputs.canonical_version }}' - if [ -n "$(git status --porcelain thunderstore.toml)" ]; then - git add thunderstore.toml - git commit -m "chore: Update thunderstore.toml version to ${{ env.version }}" - git push - else - echo "No changes to commit in thunderstore.toml" + if [ -z "$canonical_version" ]; then + echo "Canonical version is empty; cannot derive feature-testing version." >&2 + exit 1 fi - - - name: Build (Release) - run: dotnet build RetroCamera.csproj --configuration Release --no-restore -p:Version=${{ env.version }} -p:RunGenerateREADME=false - - name: GH Release - uses: softprops/action-gh-release@v1 - if: github.event_name == 'workflow_dispatch' + feature_testing_version="${canonical_version}-ft.${GITHUB_RUN_NUMBER}" + + echo "Derived feature-testing version: $feature_testing_version" + echo "feature_testing_version=$feature_testing_version" >> "$GITHUB_OUTPUT" + + publish_feature_testing_prerelease: + needs: + - validate_build_inputs + - build_verification + - prepare_feature_testing_prerelease + if: >- + github.event_name == 'push' && + needs.validate_build_inputs.outputs.should_build == 'true' && + github.ref == 'refs/heads/codex/feature-testing' + permissions: + contents: write + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + fetch-tags: false + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore dependencies + shell: bash + run: | + dotnet restore '${{ needs.validate_build_inputs.outputs.csproj_file }}' \ + --source https://api.nuget.org/v3/index.json \ + --source https://nuget.bepinex.dev/v3/index.json + + - name: Build feature-testing artifact + shell: bash + run: | + feature_testing_version='${{ needs.prepare_feature_testing_prerelease.outputs.feature_testing_version }}' + dotnet build '${{ needs.validate_build_inputs.outputs.csproj_file }}' \ + --configuration Release \ + --no-restore \ + -p:Version="$feature_testing_version" \ + -p:DeployToClient=false \ + -p:RunGenerateREADME=false + + - name: GH Release (feature-testing snapshot) + uses: softprops/action-gh-release@v2.6.2 + if: needs.prepare_feature_testing_prerelease.outputs.feature_testing_version != '' with: - body: Manual pre-release of ${{ env.version }} - name: v${{ env.version }} + body: | + Disposable feature-testing branch snapshot for validation and smoke testing only. + - Snapshot version: ${{ needs.prepare_feature_testing_prerelease.outputs.feature_testing_version }} + - Canonical version: ${{ needs.validate_build_inputs.outputs.canonical_version }} + - Branch: ${{ github.ref_name }} + - Commit: ${{ github.sha }} + - Run: ${{ github.run_id }} + name: feature-testing prerelease v${{ needs.prepare_feature_testing_prerelease.outputs.feature_testing_version }} fail_on_unmatched_files: true prerelease: true - tag_name: v${{ env.version }} + tag_name: v${{ needs.prepare_feature_testing_prerelease.outputs.feature_testing_version }} files: | - ./bin/Release/net6.0/${{ steps.get_dll_name.outputs.dll_name }}.dll + ./bin/Release/net6.0/RetroCamera.dll CHANGELOG.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3e5800..a8b8627 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,44 +2,123 @@ name: Release on: workflow_dispatch: + inputs: + release_tag: + description: Existing GitHub Release tag to publish to Thunderstore, such as v1.5.4-pre. Thunderstore receives X.Y.Z. + required: true + type: string jobs: release_on_thunderstore: + permissions: + contents: read runs-on: ubuntu-latest steps: - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '6.0.x' - dotnet-quality: 'preview' - - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - - name: Extract Latest Tag - id: extract_tag - run: | - latest_tag=$(git describe --tags `git rev-list --tags --max-count=1`) - echo "latest_tag=$latest_tag" >> $GITHUB_ENV + - name: Validate canonical version metadata + id: version_metadata + shell: bash + run: bash .codex/scripts/version-metadata.sh + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Select eligible release tag + id: select_tag shell: bash + run: | + canonical_version='${{ steps.version_metadata.outputs.canonical_version }}' + requested_tag='${{ inputs.release_tag }}' - - name: Set Release Tag - run: echo "RELEASE_TAG=${{ env.latest_tag }}" >> $GITHUB_ENV + allowed_tag_pattern='^v[0-9]+\.[0-9]+\.[0-9]+(-pre)?$' + allowed_package_version_pattern='^[0-9]+\.[0-9]+\.[0-9]+$' - - name: Download Release + if [[ -z "$requested_tag" ]]; then + echo "Error: release_tag input is required." >&2 + exit 1 + fi + + if [[ ! "$requested_tag" =~ $allowed_tag_pattern ]]; then + echo "Error: Release tag '$requested_tag' is not an allowed Thunderstore source tag." >&2 + echo "Expected one of: vX.Y.Z or vX.Y.Z-pre." >&2 + exit 1 + fi + + if ! git rev-parse -q --verify "refs/tags/$requested_tag" >/dev/null; then + echo "Error: Release tag '$requested_tag' was not found in this checkout." >&2 + exit 1 + fi + + release_version="${requested_tag#v}" + package_version="${release_version%-pre}" + + if [[ "$release_version" != "$canonical_version" && "$release_version" != "$canonical_version-pre" ]]; then + echo "Error: Release tag '$requested_tag' does not align with canonical version '$canonical_version'." >&2 + exit 1 + fi + + echo "Selected Thunderstore tag: $requested_tag" + echo "GitHub release version: $release_version" + echo "Thunderstore package version: $package_version" + + echo "RELEASE_TAG=$requested_tag" >> "$GITHUB_ENV" + echo "RELEASE_VERSION=$release_version" >> "$GITHUB_ENV" + echo "PACKAGE_VERSION=$package_version" >> "$GITHUB_ENV" + echo "ALLOWED_TAG_PATTERN=$allowed_tag_pattern" >> "$GITHUB_ENV" + echo "ALLOWED_PACKAGE_VERSION_PATTERN=$allowed_package_version_pattern" >> "$GITHUB_ENV" + + - name: Preserve release helper scripts + shell: bash run: | - gh release download ${{ env.RELEASE_TAG }} -D ./dist + cp .codex/scripts/prerelease-notes.sh "$RUNNER_TEMP/prerelease-notes.sh" + chmod +x "$RUNNER_TEMP/prerelease-notes.sh" + + - name: Download Release + run: gh release download "$RELEASE_TAG" -D ./dist env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ env.RELEASE_TAG }} - - name: Install Thunderstore CLI (tcli) + - name: Validate downloaded release changelog + shell: bash + run: | + bash "$RUNNER_TEMP/prerelease-notes.sh" \ + --changelog ./dist/CHANGELOG.md \ + --version "$PACKAGE_VERSION" \ + --tag "$RELEASE_TAG" \ + --check-only + env: + RELEASE_TAG: ${{ env.RELEASE_TAG }} + PACKAGE_VERSION: ${{ env.PACKAGE_VERSION }} + + - name: Install Thunderstore CLI run: dotnet tool install --global tcli - name: Publish build to Thunderstore + shell: bash run: | - trimmed_tag=${RELEASE_TAG:1} - tcli publish --token ${{ secrets.THUNDERSTORE_KEY }} --package-version $trimmed_tag + if [[ ! $RELEASE_TAG =~ $ALLOWED_TAG_PATTERN ]]; then + echo "Error: Release tag '$RELEASE_TAG' is not an allowed Thunderstore source tag." >&2 + exit 1 + fi + + if [[ ! $PACKAGE_VERSION =~ $ALLOWED_PACKAGE_VERSION_PATTERN ]]; then + echo "Error: Package version '$PACKAGE_VERSION' derived from '$RELEASE_TAG' is not an allowed Thunderstore package version." >&2 + exit 1 + fi + + echo "Publishing Thunderstore package version: $PACKAGE_VERSION" + tcli publish --token "$THUNDERSTORE_KEY" --package-version "$PACKAGE_VERSION" env: RELEASE_TAG: ${{ env.RELEASE_TAG }} + PACKAGE_VERSION: ${{ env.PACKAGE_VERSION }} + ALLOWED_TAG_PATTERN: ${{ env.ALLOWED_TAG_PATTERN }} + ALLOWED_PACKAGE_VERSION_PATTERN: ${{ env.ALLOWED_PACKAGE_VERSION_PATTERN }} + THUNDERSTORE_KEY: ${{ secrets.THUNDERSTORE_KEY }} diff --git a/.gitignore b/.gitignore index 2d2e622..7ad6edb 100644 --- a/.gitignore +++ b/.gitignore @@ -296,6 +296,8 @@ build/ dist/ third_party/ /.dotnet/ +.codex/artifacts/ +.codex/runs/ /Bloodcraft.sln /*.sln -/TODO.txt \ No newline at end of file +/TODO.txt diff --git a/RetroCamera.csproj b/RetroCamera.csproj index a93619c..3f3e397 100644 --- a/RetroCamera.csproj +++ b/RetroCamera.csproj @@ -12,6 +12,8 @@ + false + C:\Program Files (x86)\Steam\steamapps\common\VRising\BepInEx\plugins https://nuget.bepinex.dev/v3/index.json true false @@ -34,7 +36,7 @@
- - + + From dea5767a39f3f6fa1ce0161be4b7615358901ac3 Mon Sep 17 00:00:00 2001 From: mfoltz Date: Mon, 18 May 2026 18:23:06 -0500 Subject: [PATCH 8/8] ci: standardize release changelog contract --- .codex/scripts/bump-version.ps1 | 48 +++++++++++++++++++++-- .codex/scripts/prerelease-notes.sh | 26 +++++++++--- .codex/scripts/prerelease-notes.tests.ps1 | 19 +++++++-- .codex/scripts/release-hygiene.tests.ps1 | 29 ++++++++++++-- .codex/scripts/release-nudge.ps1 | 23 +++++++++-- .codex/scripts/version-metadata.sh | 2 +- CHANGELOG.md | 25 ++++++++---- 7 files changed, 145 insertions(+), 27 deletions(-) diff --git a/.codex/scripts/bump-version.ps1 b/.codex/scripts/bump-version.ps1 index 018df48..dd7cfd3 100644 --- a/.codex/scripts/bump-version.ps1 +++ b/.codex/scripts/bump-version.ps1 @@ -1,7 +1,9 @@ param( [Parameter(Mandatory = $true)] [ValidatePattern('^\d+\.\d+\.\d+$')] - [string]$Version + [string]$Version, + + [switch]$AllowEmptyChangelog ) Set-StrictMode -Version Latest @@ -12,6 +14,7 @@ $RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptRoot) $ProjectPath = Join-Path $RepoRoot "RetroCamera.csproj" $ThunderstorePath = Join-Path $RepoRoot "thunderstore.toml" +$ChangelogPath = Join-Path $RepoRoot "CHANGELOG.md" function Set-TextPreservingUtf8Bom { param( @@ -27,6 +30,44 @@ function Set-TextPreservingUtf8Bom { [System.IO.File]::WriteAllText($Path, $Value, $Encoding) } +function Update-Changelog { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Version, + [Parameter(Mandatory = $true)] + [bool]$AllowEmpty + ) + + $Text = Get-Content -Raw -Path $Path + + if ($Text -notmatch '(?m)^# Changelog\s*$') { + throw "CHANGELOG.md must start with a '# Changelog' heading." + } + + $VersionHeadingPattern = '(?m)^## v' + [regex]::Escape($Version) + '$' + if ($Text -match $VersionHeadingPattern) { + throw "CHANGELOG.md already contains a v$Version entry." + } + + $UnreleasedPattern = '(?ms)^## Unreleased\s*(?.*?)(?=^## |\z)' + $Match = [regex]::Match($Text, $UnreleasedPattern) + if (-not $Match.Success) { + throw "CHANGELOG.md must contain an '## Unreleased' section before bumping." + } + + $Body = $Match.Groups["body"].Value.Trim() + if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($Body)) { + throw "CHANGELOG.md '## Unreleased' is empty. Use -AllowEmptyChangelog to bump anyway." + } + + $ReleasedBody = if ([string]::IsNullOrWhiteSpace($Body)) { "- No user-facing changes recorded." } else { $Body } + $Replacement = "## Unreleased`r`n`r`n## v$Version`r`n`r`n$ReleasedBody`r`n`r`n" + $Updated = $Text.Substring(0, $Match.Index) + $Replacement + $Text.Substring($Match.Index + $Match.Length) + Set-TextPreservingUtf8Bom -Path $Path -Value $Updated +} + function Update-FirstMatch { param( [Parameter(Mandatory = $true)] @@ -62,5 +103,6 @@ Update-FirstMatch ` -Replacement "versionNumber = `"$Version`"" ` -Description "Thunderstore version" -Write-Host "Updated RetroCamera project and Thunderstore version metadata to $Version." -Write-Host "CHANGELOG.md was not changed; add the matching version entry manually before release validation." +Update-Changelog -Path $ChangelogPath -Version $Version -AllowEmpty:$AllowEmptyChangelog.IsPresent + +Write-Host "Updated RetroCamera version metadata to $Version." diff --git a/.codex/scripts/prerelease-notes.sh b/.codex/scripts/prerelease-notes.sh index 3433783..3b86eeb 100644 --- a/.codex/scripts/prerelease-notes.sh +++ b/.codex/scripts/prerelease-notes.sh @@ -29,7 +29,7 @@ Options: --commit SHA Source commit SHA for the notes card. --run-id ID GitHub Actions run id for the notes card. --output PATH Markdown file to write. - --check-only Validate changelog entry without writing notes. + --check-only Validate changelog turnover without writing notes. EOF } @@ -90,10 +90,26 @@ if [ ! -f "$CHANGELOG_PATH" ]; then fail "Unable to locate changelog at '$CHANGELOG_PATH'." fi +if ! grep -Eq '^##[[:space:]]*Unreleased[[:space:]]*$' "$CHANGELOG_PATH"; then + fail "CHANGELOG.md must contain a ## Unreleased section before creating or publishing a prerelease." +fi + +unreleased_body=$( + awk ' + /^##[[:space:]]*Unreleased[[:space:]]*$/ { in_unreleased = 1; next } + in_unreleased && /^## / { exit } + in_unreleased { print } + ' "$CHANGELOG_PATH" +) + +if printf '%s' "$unreleased_body" | grep -q '[^[:space:]]'; then + fail "CHANGELOG.md ## Unreleased must be empty before creating or publishing a prerelease." +fi + version_body=$( awk -v version="$VERSION" ' - $0 == "`" version "`" { in_version = 1; next } - in_version && /^`[^`]+`[[:space:]]*$/ { exit } + $0 == "## v" version { in_version = 1; next } + in_version && /^## / { exit } in_version { print } ' "$CHANGELOG_PATH" ) @@ -103,7 +119,7 @@ if ! printf '%s' "$version_body" | grep -q '[^[:space:]]'; then fi if [ "$CHECK_ONLY" = "true" ]; then - echo "prerelease-notes: changelog entry validated for $VERSION." + echo "prerelease-notes: changelog turnover validated for $VERSION." exit 0 fi @@ -140,7 +156,7 @@ ${handoff_heading} | Signal | Detail | | --- | --- | -| Changelog | Notes below come from the \`${VERSION}\` entry. | +| Changelog | \`## Unreleased\` is empty; notes below come from \`v${VERSION}\`. | | Branch | \`${BRANCH}\` | | Commit | \`${short_commit}\` | | Run | ${run_detail} | diff --git a/.codex/scripts/prerelease-notes.tests.ps1 b/.codex/scripts/prerelease-notes.tests.ps1 index 9898966..70243ec 100644 --- a/.codex/scripts/prerelease-notes.tests.ps1 +++ b/.codex/scripts/prerelease-notes.tests.ps1 @@ -47,11 +47,17 @@ function Test-PrereleaseNotesIncludesChangelogAndDetailsCard { $ChangelogPath = Join-Path $FixtureRoot "CHANGELOG.md" $OutputPath = Join-Path $FixtureRoot "prerelease-notes.md" Set-Content -Path $ChangelogPath -Value @' -`1.2.3` +# Changelog + +## Unreleased + +## v1.2.3 + - fixed the camera timing - added release validation -`1.2.2` +## v1.2.2 + - previous release '@ @@ -73,7 +79,7 @@ function Test-PrereleaseNotesIncludesChangelogAndDetailsCard { } Assert-Match -Text $Notes -Pattern '1\.2\.4' -Message "Project version was not updated." Assert-Match -Text $ThunderstoreText -Pattern 'versionNumber = "1\.2\.4"' -Message "Thunderstore version was not updated." - Assert-NotMatch -Text $ChangelogText -Pattern '`1\.2\.4`' -Message "Changelog should remain human-owned and unchanged by bump-version." + Assert-Match -Text $ChangelogText -Pattern '(?m)^## Unreleased\s+## v1\.2\.4\s+- current release' -Message "Changelog release entry was not created." } finally { Remove-Item -LiteralPath $FixtureRoot -Recurse -Force @@ -153,7 +171,10 @@ function Test-ReleaseNudgeAllowsCurrentChangelogEntry { function Test-ReleaseNudgeBlocksWhenVersionMissingFromChangelog { $FixtureRoot = New-FixtureRepo try { - & pwsh -NoProfile -File (Join-Path $FixtureRoot ".codex/scripts/bump-version.ps1") -Version "1.2.4" | Out-Null + $ProjectPath = Join-Path $FixtureRoot "RetroCamera.csproj" + $ThunderstorePath = Join-Path $FixtureRoot "thunderstore.toml" + (Get-Content -Raw -Path $ProjectPath).Replace("1.2.3", "1.2.4") | Set-Content -Path $ProjectPath -NoNewline + (Get-Content -Raw -Path $ThunderstorePath).Replace('versionNumber = "1.2.3"', 'versionNumber = "1.2.4"') | Set-Content -Path $ThunderstorePath -NoNewline $Output = & pwsh -NoProfile -File (Join-Path $FixtureRoot ".codex/scripts/release-nudge.ps1") -BaseRef "main" 2>&1 | Out-String Assert-Equal -Actual "$LASTEXITCODE" -Expected "1" -Message "Release nudge should block when version metadata outruns the changelog." diff --git a/.codex/scripts/release-nudge.ps1 b/.codex/scripts/release-nudge.ps1 index b2b5312..a1af4d5 100644 --- a/.codex/scripts/release-nudge.ps1 +++ b/.codex/scripts/release-nudge.ps1 @@ -58,7 +58,7 @@ function Get-ProjectVersion { function Get-LatestChangelogEntry { foreach ($Line in Get-Content -Path $ChangelogPath) { - if ($Line -match '^`(?\d+\.\d+\.\d+)`$') { + if ($Line -match '^## v(?\d+\.\d+\.\d+)$') { return $Matches["version"] } } @@ -66,6 +66,17 @@ function Get-LatestChangelogEntry { return $null } +function Get-UnreleasedBody { + $Text = Get-Content -Raw -Path $ChangelogPath + $Match = [regex]::Match($Text, '(?ms)^## Unreleased\s*(?.*?)(?=^## |\z)') + + if (-not $Match.Success) { + return $null + } + + return $Match.Groups["body"].Value.Trim() +} + function Test-MeaningfulPath { param( [Parameter(Mandatory = $true)] @@ -138,6 +149,8 @@ foreach ($Line in $Numstat) { $ProjectVersion = Get-ProjectVersion $LatestChangelogEntry = Get-LatestChangelogEntry $ChangelogChanged = @($ChangedFiles | Where-Object { $_ -eq "CHANGELOG.md" }).Count -gt 0 +$UnreleasedBody = Get-UnreleasedBody +$HasUnreleasedNotes = -not [string]::IsNullOrWhiteSpace($UnreleasedBody) $script:NudgeCount = 0 if ([string]::IsNullOrWhiteSpace($ProjectVersion)) { @@ -149,8 +162,12 @@ elseif ([string]::IsNullOrWhiteSpace($LatestChangelogEntry)) { elseif ($ProjectVersion -ne $LatestChangelogEntry -and -not $ChangelogChanged) { Write-Nudge "RetroCamera version metadata is $ProjectVersion, but the latest CHANGELOG.md entry is $LatestChangelogEntry. Add the matching human-owned changelog entry before release." } -elseif ($ChangedLines -ge $LineThreshold -and -not $ChangelogChanged) { - Write-Host "release-nudge: meaningful changes detected, and CHANGELOG.md remains human-owned with latest entry $LatestChangelogEntry." +elseif (($ChangedLines -ge $LineThreshold) -and -not $ChangelogChanged -and -not $HasUnreleasedNotes) { + Write-Nudge "Meaningful RetroCamera changes detected ($ChangedLines changed lines across $($MeaningfulFiles.Count) files). Consider adding CHANGELOG.md notes before release." +} + +if ($HasUnreleasedNotes) { + Write-Nudge "RetroCamera CHANGELOG.md has Unreleased notes. Before a release-bound merge, consider running .codex/scripts/bump-version.ps1 so version metadata and changelog stay aligned." } if ($script:NudgeCount -eq 0) { diff --git a/.codex/scripts/version-metadata.sh b/.codex/scripts/version-metadata.sh index 897893c..a36e527 100644 --- a/.codex/scripts/version-metadata.sh +++ b/.codex/scripts/version-metadata.sh @@ -38,7 +38,7 @@ read_thunderstore_version() { read_latest_changelog_entry() { local entry - entry=$(sed -n '/^`[^`][^`]*`$/ { s/^`//; s/`$//; p; q; }' "$CHANGELOG_PATH" | tr -d '\r') + entry=$(sed -n 's/^## v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$/\1/p' "$CHANGELOG_PATH" | head -n 1 | tr -d '\r') if [ -z "$entry" ]; then fail "Unable to determine the latest CHANGELOG entry from $CHANGELOG_PATH." diff --git a/CHANGELOG.md b/CHANGELOG.md index cd8c158..f648d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,26 @@ -`1.5.4` +# Changelog + +## Unreleased + +## v1.5.4 + - added menu toggle to show/hide vignette - added menu toggle to show/skip intro -`1.4.4` +## v1.4.4 + - mouse hides when moving camera when inventory menu, crafting menus, etc. are open - command wheel configuration persists when changing worlds, should generally feel a bit smoother to use with slight delay to prevent accidental command usage after wheel immediately opened and tuned forced delay between commands - added some checks to make sure mod doesn't touch some things until game won't get mad and input state is valid after loading fully into world -`1.4.3` +## v1.4.3 + - added command wheel (right alt default key), must be enabled from menu and commands set in config file commands.json (first spot for name of command you want showing in wheel, second spot for raw command string) - can set size scaling of crosshair in menu - mouse should show when building during action mode again -`1.3.2` +## v1.3.2 + - vertical aim offset appears to now be functioning as expected - option to hide character info panel at the top of the screen during action mode - generally more state-aware and shouldn't require as much fiddling with options when changing modes (heavy refactor pending, still too much pasta) @@ -20,13 +28,16 @@ - crash prevention (I'm sure if you're creative enough can still manage but seems pretty stable now :p) for server pause in singleplayer (if you choose to go in the menu while the server is paused and enable RetroCamera after the mod has disabled itself for safety it will probably crash, so don't) - localizationKeys set in dictionary every time menu is opened instead of just once -`1.2.1` +## v1.2.1 + - mouse unlocks when exiting first person or action mode without further user input - added check to prevent memory access issues when escape menu is open -`1.1.0` +## v1.1.0 + - Default mouse wheel buttons (emotes, shapeshifts) will temporarily override mouse lock in first person/action mode while pressed - Added keybind for completing journal quests (minus default) -`1.0.0` +## v1.0.0 + - Initial release