diff --git a/.codex/scripts/bump-version.ps1 b/.codex/scripts/bump-version.ps1 new file mode 100644 index 0000000..dd7cfd3 --- /dev/null +++ b/.codex/scripts/bump-version.ps1 @@ -0,0 +1,108 @@ +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string]$Version, + + [switch]$AllowEmptyChangelog +) + +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" +$ChangelogPath = Join-Path $RepoRoot "CHANGELOG.md" + +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-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)] + [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" + +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 new file mode 100644 index 0000000..3b86eeb --- /dev/null +++ b/.codex/scripts/prerelease-notes.sh @@ -0,0 +1,171 @@ +#!/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 turnover 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 + +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 == "## v" version { in_version = 1; next } + in_version && /^## / { 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 turnover 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 | \`## Unreleased\` is empty; notes below come from \`v${VERSION}\`. | +| 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..70243ec --- /dev/null +++ b/.codex/scripts/prerelease-notes.tests.ps1 @@ -0,0 +1,171 @@ +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 @' +# Changelog + +## Unreleased + +## v1.2.3 + +- fixed the camera timing +- added release validation + +## v1.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 @' +# Changelog + +## Unreleased + +## v1.2.3 + +- current release + +## v1.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 { + Set-Content -Path (Join-Path $FixtureRoot "CHANGELOG.md") -Value @' +# Changelog + +## Unreleased + +- current release + +## v1.2.3 + +- previous release +'@ -NoNewline + + & 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-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 + } +} + +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 { + $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." + 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..a1af4d5 --- /dev/null +++ b/.codex/scripts/release-nudge.ps1 @@ -0,0 +1,178 @@ +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 '^## v(?\d+\.\d+\.\d+)$') { + return $Matches["version"] + } + } + + 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)] + [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 +$UnreleasedBody = Get-UnreleasedBody +$HasUnreleasedNotes = -not [string]::IsNullOrWhiteSpace($UnreleasedBody) +$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 -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) { + 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..a36e527 --- /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/^## 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." + 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 0000000..36c1e09 Binary files /dev/null and b/.github/assets/ts_badge.png differ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index db60cda..00cfb6e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,86 +1,382 @@ name: Build on: + pull_request: + branches: + - main + push: + branches: + - main + - codex/feature-testing workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build: + validate_build_inputs: permissions: - contents: write + contents: read runs-on: ubuntu-latest + outputs: + should_build: ${{ steps.check_release.outputs.should_build }} + reason: ${{ steps.check_release.outputs.reason }} + canonical_version: ${{ steps.version_metadata.outputs.canonical_version }} + matched_tag: ${{ steps.check_release.outputs.matched_tag }} + csproj_file: ${{ steps.discover_csproj.outputs.csproj_file }} steps: - - uses: actions/checkout@v3 - - - name: Setup .NET - uses: actions/setup-dotnet@v2 - with: - dotnet-version: 7.0.x - - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: - fetch-depth: 0 - - - name: Restore dependencies - run: dotnet restore - + fetch-depth: 1 + fetch-tags: false + persist-credentials: false + - name: Discover .csproj id: discover_csproj + shell: bash run: | - # Find the first .csproj outside bin/obj directories. - # Adjust if you have multiple .csproj files. csproj_file=$(find . -type f -name '*.csproj' \ -not -path '*/bin/*' \ - -not -path '*/obj/*' | head -n 1) - - echo "csproj_file=$csproj_file" >> $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 . --configuration Release -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/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/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 diff --git a/Configuration/QuipManager.cs b/Configuration/QuipManager.cs index b19719c..7073bbf 100644 --- a/Configuration/QuipManager.cs +++ b/Configuration/QuipManager.cs @@ -1,32 +1,293 @@ -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(); + Dictionary loadedCommands = Persistence.LoadCommands(); + + if (loadedCommands == null) + { + return; + } + + Dictionary loadedCategories = Persistence.LoadCommandCategories() ?? new Dictionary(); + + _commandQuips.Clear(); + + foreach (KeyValuePair commandPair in loadedCommands) + { + 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(); + } + + 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; + } - if (loaded != null) + 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..bb80996 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; + } - if (CommandQuips.TryGetValue(index, out CommandQuip commandQuip)) + ClearActiveCategory(); + ShowCategoryMenu(); + return false; + } + } + else if (TryGetCategory(index, out CommandCategory category) && category.HasEntries) { - SendCommandQuip(commandQuip); + if (SetActiveCategory(index)) + { + if (!ShowCategoryQuips(index)) + { + ClearActiveCategory(); + ShowCategoryMenu(); + } + + return false; + } + } + + if (TryGetQuip(index, out CommandQuip fallbackQuip)) + { + _lastQuipSendTime = now; + SendCommandQuip(fallbackQuip); return false; } @@ -72,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/README.md b/README.md index 3a25164..a048059 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,23 @@ 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. + + > **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 + +- 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..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 @@ -20,13 +22,7 @@ - - - - - third_party/VRising.GameData.dll - false - + @@ -40,7 +36,7 @@ - - + + 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..df9abc4 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; @@ -10,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 @@ -23,6 +25,11 @@ public class RetroCamera : MonoBehaviour public static Camera GameCamera => _gameCamera; 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; static bool _listening = false; static bool HideCharacterInfoPanel => Settings.HideCharacterInfoPanel; @@ -41,7 +48,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) { @@ -123,19 +130,9 @@ 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; + _generalGameplayCollection = generalGameplayCollection; - 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,34 +145,10 @@ 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; + TryEnsureGeneralGameplayCollection(); var shortcuts = _socialWheel.ActionWheelShortcuts; foreach (var shortcut in shortcuts) @@ -194,12 +167,260 @@ static void SocialWheelKeyPressed() // Core.Log.LogWarning($"[RetroCamera] Activating wheel"); } } + static void UpdateSocialWheelQuips(GeneralGameplayCollection generalGameplayCollection) + { + try + { + _generalGameplayCollection = generalGameplayCollection; + _originalChatQuips.Clear(); + _originalActionWheelData.Clear(); + 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 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()) + 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 SocialWheelKeyUp() { if (!Settings.CommandWheelEnabled) return; if (_socialWheelActive) { + ClearActiveCategory(); + ShowCategoryMenu(); + _socialWheelActive = false; ActionWheelSystem.HideCurrentWheel(); _socialWheel.gameObject.SetActive(false); @@ -256,10 +477,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 +494,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 +516,7 @@ static void UpdateCrosshair() _canvasScaler = uiCanvas.GetComponent(); _crosshair = Instantiate(_crosshairPrefab, uiCanvas.transform); - _crosshair.active = true; + _crosshair.SetActive(true); } bool rotatingCamera = false; @@ -306,7 +525,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 +549,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); @@ -369,6 +588,9 @@ public static void ResetState() _socialWheelInitialized = false; _shouldActivateWheel = false; _rootPrefabCollection = Entity.Null; + _generalGameplayCollection = null; + _originalChatQuips.Clear(); + _originalActionWheelData.Clear(); } } diff --git a/Utilities/Persistence.cs b/Utilities/Persistence.cs index 009d298..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 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; @@ -52,12 +104,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)); @@ -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"; @@ -125,4 +195,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 <