diff --git a/.github/api-compat-suppressions.xml b/.github/api-compat-suppressions.xml
new file mode 100644
index 00000000..6d073541
--- /dev/null
+++ b/.github/api-compat-suppressions.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ CP0002
+ M:S1API.PhoneApp.PhoneApp.Exit(ScheduleOne.ExitAction)
+
+
diff --git a/.github/release-notes/3.1.15.md b/.github/release-notes/3.1.15.md
new file mode 100644
index 00000000..1cd96aeb
--- /dev/null
+++ b/.github/release-notes/3.1.15.md
@@ -0,0 +1,27 @@
+## Custom NPC Inventory Persistence
+
+- Custom NPC primary inventories now initialize their native slot collection before saved items are restored, so items given to custom customers and dealers retain their identity, quantity, and slot position across save and reload ([#261](https://github.com/ifBars/S1API/pull/261)).
+- Dealer overflow restoration remains separate from primary inventory hydration, and base-game dealers keep their existing load behavior ([#261](https://github.com/ifBars/S1API/pull/261)).
+
+## Custom NPC Relationships and Deals
+
+- Custom NPCs now derive a stable native GUID from their existing persistent identity and register before accepted contracts are loaded. Newly scheduled custom-customer deals therefore resolve to the same NPC after a reload ([#262](https://github.com/ifBars/S1API/pull/262)).
+- Saved relationship values and unlock state now survive deferred custom-NPC spawning, including explicit zero relationship values and both direct and recommendation unlock paths ([#262](https://github.com/ifBars/S1API/pull/262)).
+- Loader phases reuse the prepared custom NPC instance and initialize inactive customer deal-attendance state before assigning restored contracts, preventing prefab defaults or incomplete runtime caches from replacing saved state ([#262](https://github.com/ifBars/S1API/pull/262)).
+
+## Compatibility and Validation
+
+- Preserved public API shape, existing custom NPC identity IDs, save schemas, network payloads, dealer overflow behavior, and base-game NPC loading.
+- Validated 3.1.15 with zero-warning Mono and IL2CPP builds, 575 passing Mono tests, 564 passing IL2CPP tests, documentation coverage, ApiCompat, and the hosted coverage analyzer.
+- Verified live Mono and IL2CPP save-to-menu-to-reload flows for custom inventory contents, customer and dealer relationships, unlock state, and accepted-contract ownership.
+- Contracts already saved with a pre-3.1.15 random custom-NPC GUID cannot be migrated because the native contract payload contains the customer GUID but not the custom NPC identity ID. Newly saved and future contracts use the stable identity.
+
+## Contributors
+
+- @Diffuin: [custom NPC inventory restoration](https://github.com/ifBars/S1API/pull/261) and [custom NPC deal and relationship persistence](https://github.com/ifBars/S1API/pull/262).
+- @ifBars: dual-runtime remediation, live persistence validation, and 3.1.15 release preparation for [#261](https://github.com/ifBars/S1API/pull/261) and [#262](https://github.com/ifBars/S1API/pull/262).
+
+## Release Links
+
+- [Download S1API-Forked-3.1.15.zip](https://github.com/ifBars/S1API/releases/download/v3.1.15/S1API-Forked-3.1.15.zip)
+- [Full changelog](https://github.com/ifBars/S1API/compare/v3.1.14...v3.1.15)
diff --git a/.github/release-notes/3.2.0.md b/.github/release-notes/3.2.0.md
new file mode 100644
index 00000000..d5d1cfb4
--- /dev/null
+++ b/.github/release-notes/3.2.0.md
@@ -0,0 +1,34 @@
+## New Managed APIs
+
+- Build customizable variants of native grid and surface furniture while preserving placement defaults, isolated materials, stored and ghost visuals, and generated icons ([#264](https://github.com/ifBars/S1API/pull/264)).
+- Inspect an NPC's current building, awareness changes, and vehicle entry or exit events, and read native vehicle color metadata through managed wrappers ([#265](https://github.com/ifBars/S1API/pull/265), [#266](https://github.com/ifBars/S1API/pull/266), [#267](https://github.com/ifBars/S1API/pull/267), [#268](https://github.com/ifBars/S1API/pull/268)).
+- Declare customer, dealer, and supplier roles through NPC properties so prefab configuration follows the selected capabilities ([#280](https://github.com/ifBars/S1API/pull/280)).
+- Discover blackjack, Ride the Bus, and slot-machine games; read immutable state snapshots; and subscribe to round and spin lifecycle events ([#281](https://github.com/ifBars/S1API/pull/281)).
+- Create and query native temperature emitters with cross-runtime managed events, validation, snapshots, and unit helpers ([#283](https://github.com/ifBars/S1API/pull/283)).
+- Inspect native trash containers, subscribe to content and level changes, and invoke the server-authoritative native bagging flow ([#284](https://github.com/ifBars/S1API/pull/284)).
+- Attach configurable native interaction prompts to mod-owned objects with managed lifecycle callbacks, runtime setters, and disposal ([#285](https://github.com/ifBars/S1API/pull/285)).
+- Discover placed jukeboxes, inspect immutable track and playback state, subscribe to changes, and use the native playback, volume, shuffle, repeat, synchronization, and track-selection controls ([#286](https://github.com/ifBars/S1API/pull/286)).
+
+## Fixes and Runtime Compatibility
+
+- Hide dealer conversations until their relationship requirements are met, while keeping conversation hooks idempotent ([#271](https://github.com/ifBars/S1API/pull/271)).
+- Remove the unusable Casino building identifier from the NPC building registry ([#276](https://github.com/ifBars/S1API/pull/276)).
+- Finalize custom NPCs correctly on multiplayer clients, bridge customer contract events on IL2CPP, and tolerate the base game's removed region-unlock member ([#277](https://github.com/ifBars/S1API/pull/277), [#278](https://github.com/ifBars/S1API/pull/278), [#279](https://github.com/ifBars/S1API/pull/279)).
+- Harden cross-wrapper event ownership and cleanup for awareness, vehicle, trash-container, and jukebox APIs, including IL2CPP delegate lifecycles and destroyed native objects ([#266](https://github.com/ifBars/S1API/pull/266), [#267](https://github.com/ifBars/S1API/pull/267), [#284](https://github.com/ifBars/S1API/pull/284), [#286](https://github.com/ifBars/S1API/pull/286)).
+
+## Compatibility and Validation
+
+- The 3.2.0 surface is additive relative to 3.1.15. Existing public contracts, stable identifiers, save schemas, and network payloads remain unchanged.
+- New controls continue to use the game's native authority, replication, synchronization, and persistence paths; consumer mods remain responsible for their own multiplayer authorization where the native API does not provide it.
+- Validated the release candidate with zero-warning Mono and IL2CPP builds, 707 passing Mono tests, 693 passing IL2CPP tests, 82.31% public API documentation coverage, and no ApiCompat breaks against 3.1.15. The hosted coverage analyzer remains a required PR gate.
+- The milestone's furniture, NPC, casino, temperature, trash, interaction-prompt, and jukebox features received targeted Mono and IL2CPP runtime validation during development. No proprietary game assemblies, generated wrappers, saves, logs, or test probes are included in this release.
+
+## Contributors
+
+- @Diffuin: native furniture variants, NPC building and event APIs, vehicle color metadata, temperature emitters, trash containers, jukebox controls, and the Casino building correction in [#264](https://github.com/ifBars/S1API/pull/264), [#265](https://github.com/ifBars/S1API/pull/265), [#266](https://github.com/ifBars/S1API/pull/266), [#267](https://github.com/ifBars/S1API/pull/267), [#268](https://github.com/ifBars/S1API/pull/268), [#276](https://github.com/ifBars/S1API/pull/276), [#283](https://github.com/ifBars/S1API/pull/283), [#284](https://github.com/ifBars/S1API/pull/284), and [#286](https://github.com/ifBars/S1API/pull/286).
+- @ifBars: integration and dual-runtime remediation across the milestone, dealer and custom-NPC compatibility fixes, NPC role properties, casino state APIs, and interaction prompts in [#271](https://github.com/ifBars/S1API/pull/271), [#277](https://github.com/ifBars/S1API/pull/277), [#278](https://github.com/ifBars/S1API/pull/278), [#279](https://github.com/ifBars/S1API/pull/279), [#280](https://github.com/ifBars/S1API/pull/280), [#281](https://github.com/ifBars/S1API/pull/281), and [#285](https://github.com/ifBars/S1API/pull/285).
+
+## Release Links
+
+- [Download S1API-Forked-3.2.0.zip](https://github.com/ifBars/S1API/releases/download/v3.2.0/S1API-Forked-3.2.0.zip)
+- [Full changelog](https://github.com/ifBars/S1API/compare/v3.1.15...v3.2.0)
diff --git a/.github/release-notes/3.2.1-beta.1.md b/.github/release-notes/3.2.1-beta.1.md
new file mode 100644
index 00000000..0021e7de
--- /dev/null
+++ b/.github/release-notes/3.2.1-beta.1.md
@@ -0,0 +1,16 @@
+## Schedule I 0.4.7 beta compatibility
+
+- Restored S1API startup on both the Mono `alternate-beta` and IL2CPP `beta` game branches.
+- Migrated dialogue, sleep/time, player-data synchronization, NPC movement, clothing, and character-creator integrations to the 0.4.7 native APIs.
+- Adapted legacy avatar settings through the game's new naked-appearance and outfit pipeline.
+- Kept removed avatar preview and accessory-mugshot helpers fail-safe while the replacement renderer is still in beta.
+
+## Compatibility and validation
+
+- Built against Schedule I 0.4.7f5 Mono and IL2CPP assemblies.
+- This is a GitHub-only prerelease for beta testing. It is not published to NuGet, Nexus Mods, or Thunderstore.
+- Tracks [issue #305](https://github.com/ifBars/S1API/issues/305).
+
+## Downloads
+
+- [Download S1API-Forked-3.2.1-beta.1.zip](https://github.com/ifBars/S1API/releases/download/v3.2.1-beta.1/S1API-Forked-3.2.1-beta.1.zip)
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 0eb020ac..b8ceacac 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -3,6 +3,9 @@ name: API Coverage Analysis
on:
push:
branches: [ master, main, stable ]
+ paths-ignore:
+ - README.md
+ - tools/S1APICoverageAnalyzer/coverage-history.json
pull_request:
branches: [ master, main, stable ]
workflow_dispatch:
@@ -21,6 +24,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
+ pull-requests: write
steps:
- name: Checkout S1API
uses: actions/checkout@v4
@@ -56,7 +60,7 @@ jobs:
# Try to restore assemblies from cache first (for external PRs)
# Cache key includes assembly branch to separate beta from main
- # v3 suffix allows cache invalidation by bumping version
+ # v4 suffix allows cache invalidation by bumping version
# Only use cache for PR events to avoid stale assemblies after merges
- name: Restore Game Assemblies from Cache
id: cache-assemblies
@@ -64,9 +68,9 @@ jobs:
uses: actions/cache/restore@v4
with:
path: S1API/ScheduleOneAssemblies
- key: game-assemblies-v3-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }}
+ key: game-assemblies-v4-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }}
restore-keys: |
- game-assemblies-v3-${{ steps.assembly-branch.outputs.branch }}-
+ game-assemblies-v4-${{ steps.assembly-branch.outputs.branch }}-
# Only checkout game assemblies if cache miss AND we have access to secrets
- name: Checkout Game Assemblies
@@ -220,76 +224,37 @@ jobs:
echo "Covered Classes: $(jq -r '.classCoverage.covered' coverage-report.json) / $(jq -r '.classCoverage.total' coverage-report.json)"
fi
- - name: Update README Badge and Chart
+ - name: Generate README Badge and Chart Updates
+ id: coverage-files
if: steps.verify-assemblies.outputs.has_assemblies == 'true' && github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/stable')
run: |
COVERAGE_CHANGED="${{ steps.coverage.outputs.coverage_changed }}"
echo "Coverage changed status: $COVERAGE_CHANGED"
-
- # Configure git early for potential commits
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
-
- # Check if history was deduplicated (has changes)
- HISTORY_DEDUPLICATED=false
- if ! git diff --quiet tools/S1APICoverageAnalyzer/coverage-history.json; then
- echo "History file was deduplicated"
- HISTORY_DEDUPLICATED=true
- fi
-
- # If coverage hasn't changed, only commit history deduplication and chart update if needed
+
+ # The analyzer refreshes the latest history timestamp on every run.
+ # Discard that metadata churn unless the measured coverage changed.
if [ "$COVERAGE_CHANGED" != "true" ]; then
- echo "Coverage percentage unchanged - skipping badge update"
-
- if [ "$HISTORY_DEDUPLICATED" = true ]; then
- echo "Updating chart with deduplicated history..."
-
- # Update coverage chart in README if chart file exists
- if [ -f coverage-chart.md ]; then
- echo "Updating coverage chart in README..."
-
- # Extract the chart image URL from coverage-chart.md (line 3)
- CHART_URL=$(sed -n '3p' coverage-chart.md)
-
- # Find the line number with the existing chart in README
- CHART_LINE=$(grep -n "!\[Coverage Chart\]" README.md | head -1 | cut -d: -f1)
-
- if [ -n "$CHART_LINE" ]; then
- echo "Found chart at line $CHART_LINE, updating..."
- # Replace the chart line
- awk -v line="$CHART_LINE" -v new_chart="$CHART_URL" 'NR==line {print new_chart; next} {print}' README.md > README.md.tmp
- mv README.md.tmp README.md
- else
- echo "Chart line not found in README"
- fi
- fi
-
- echo "Committing deduplicated history and updated chart..."
- git add README.md tools/S1APICoverageAnalyzer/coverage-history.json
- git commit -m "chore: deduplicate coverage history and update chart [skip ci]"
- git push
- else
- echo "No changes to commit"
- fi
-
+ echo "Coverage percentage unchanged - discarding generated metadata churn"
+ git restore -- README.md tools/S1APICoverageAnalyzer/coverage-history.json
+ echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
-
+
# Coverage changed - update README badge and chart
if [ -f coverage-badge.md ]; then
# Read the new badge markdown (trim whitespace)
NEW_BADGE=$(cat coverage-badge.md | tr -d '\n\r')
-
+
# Replace the link to point to the GitHub Actions workflow
# Extract the badge image URL and replace the link URL
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/workflows/coverage.yml"
NEW_BADGE=$(echo "$NEW_BADGE" | sed "s|](docs/coverage-report.json)|]($WORKFLOW_URL)|g")
-
+
echo "New badge: $NEW_BADGE"
-
+
# Find the line number with the API Coverage badge in README
BADGE_LINE=$(grep -n "\[!\[API Coverage\]" README.md | head -1 | cut -d: -f1)
-
+
if [ -n "$BADGE_LINE" ]; then
echo "Found API Coverage badge at line $BADGE_LINE, updating..."
# Update the badge line in README.md
@@ -299,51 +264,69 @@ jobs:
else
echo "Warning: API Coverage badge not found in README.md"
fi
-
- # Update coverage chart in README if chart file exists
- if [ -f coverage-chart.md ]; then
- echo "Updating coverage chart in README..."
-
- # Extract the chart image URL from coverage-chart.md (line 3)
- CHART_URL=$(sed -n '3p' coverage-chart.md)
-
- # Find the line number with the existing chart in README
- CHART_LINE=$(grep -n "!\[Coverage Chart\]" README.md | head -1 | cut -d: -f1)
-
- if [ -n "$CHART_LINE" ]; then
- echo "Found chart at line $CHART_LINE, updating..."
- # Replace the chart line
- awk -v line="$CHART_LINE" -v new_chart="$CHART_URL" 'NR==line {print new_chart; next} {print}' README.md > README.md.tmp
- mv README.md.tmp README.md
- else
- echo "Chart line not found in README"
- fi
- fi
-
- # Check if there are changes to commit
- CHANGES_EXIST=false
-
- if ! git diff --quiet README.md; then
- echo "README.md has changes"
- CHANGES_EXIST=true
- fi
-
- if ! git diff --quiet tools/S1APICoverageAnalyzer/coverage-history.json; then
- echo "coverage-history.json has changes"
- CHANGES_EXIST=true
- fi
-
- if [ "$CHANGES_EXIST" = true ]; then
- echo "Committing changes..."
- git diff README.md
- git add README.md tools/S1APICoverageAnalyzer/coverage-history.json
- git commit -m "chore: update API coverage badge and history [skip ci]"
- git push
+ else
+ echo "Coverage badge file unavailable - skipping badge update"
+ fi
+
+ if [ -f coverage-chart.md ]; then
+ echo "Updating coverage chart in README..."
+ CHART_URL=$(sed -n '3p' coverage-chart.md)
+ CHART_LINE=$(grep -n "!\[Coverage Chart\]" README.md | head -1 | cut -d: -f1)
+
+ if [ -n "$CHART_LINE" ]; then
+ echo "Found chart at line $CHART_LINE, updating..."
+ awk -v line="$CHART_LINE" -v new_chart="$CHART_URL" 'NR==line {print new_chart; next} {print}' README.md > README.md.tmp
+ mv README.md.tmp README.md
else
- echo "No changes to commit"
+ echo "Chart line not found in README"
fi
+ fi
+
+ if git diff --quiet -- README.md tools/S1APICoverageAnalyzer/coverage-history.json; then
+ echo "No coverage files changed"
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "Coverage files changed"
+ git diff -- README.md tools/S1APICoverageAnalyzer/coverage-history.json
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Open Coverage Update Pull Request
+ if: steps.coverage-files.outputs.changed == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ BASE_BRANCH: ${{ github.ref_name }}
+ UPDATE_BRANCH: automation/coverage-${{ github.ref_name }}
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add README.md tools/S1APICoverageAnalyzer/coverage-history.json
+ git commit -m "chore: update API coverage badge and history"
+
+ REMOTE_SHA=$(git ls-remote --heads origin "refs/heads/$UPDATE_BRANCH" | cut -f1)
+ if [ -n "$REMOTE_SHA" ]; then
+ git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$REMOTE_SHA" origin "HEAD:$UPDATE_BRANCH"
+ else
+ git push origin "HEAD:$UPDATE_BRANCH"
+ fi
+
+ EXISTING_PR=$(gh pr list \
+ --repo "${{ github.repository }}" \
+ --base "$BASE_BRANCH" \
+ --head "$UPDATE_BRANCH" \
+ --state open \
+ --json url \
+ --jq '.[0].url')
+
+ if [ -n "$EXISTING_PR" ]; then
+ echo "Updated existing coverage pull request: $EXISTING_PR"
else
- echo "coverage-badge.md not found, skipping README update"
+ gh pr create \
+ --repo "${{ github.repository }}" \
+ --base "$BASE_BRANCH" \
+ --head "$UPDATE_BRANCH" \
+ --title "chore: update API coverage badge and history" \
+ --body "Automated coverage metadata update generated by [workflow run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
fi
# Save cache even if build fails (to enable beta cache priming)
@@ -353,7 +336,7 @@ jobs:
if: always() && (github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/stable'))) && steps.cache-assemblies.outputs.cache-hit != 'true'
with:
path: S1API/ScheduleOneAssemblies
- key: game-assemblies-v3-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }}
+ key: game-assemblies-v4-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }}
- name: Coverage Skipped Notice
if: steps.verify-assemblies.outputs.has_assemblies != 'true'
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index cf215460..8599a80d 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -85,9 +85,9 @@ jobs:
uses: actions/cache/restore@v4
with:
path: S1API/ScheduleOneAssemblies
- key: game-assemblies-v4-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }}
+ key: game-assemblies-v5-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }}
restore-keys: |
- game-assemblies-v4-${{ steps.assembly-branch.outputs.branch }}-
+ game-assemblies-v5-${{ steps.assembly-branch.outputs.branch }}-
# Only checkout game assemblies if cache miss AND we have access to secrets
- name: Checkout Game Assemblies
@@ -212,7 +212,7 @@ jobs:
if: always() && steps.cache-assemblies.outputs.cache-hit != 'true' && hashFiles('S1API/ScheduleOneAssemblies/Managed/Assembly-CSharp.dll') != '' && hashFiles('S1API/ScheduleOneAssemblies/Managed/Unity.Burst.dll') != '' && hashFiles('S1API/ScheduleOneAssemblies/MelonLoader/0Harmony.dll') != '' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/npc-prefabs')))
with:
path: S1API/ScheduleOneAssemblies
- key: game-assemblies-v4-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }}
+ key: game-assemblies-v5-${{ steps.assembly-branch.outputs.branch }}-${{ hashFiles('S1API/S1API.csproj') }}
- name: Create CI Build Properties
run: |
@@ -311,18 +311,67 @@ jobs:
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
+ GH_TOKEN: ${{ github.token }}
run: |
+ set -euo pipefail
+
git checkout --detach --force "$BASE_SHA"
- dotnet restore S1API/S1API.csproj -p:Configuration=MonoMelon
- dotnet build \
- S1API/S1API.csproj \
- --no-restore \
- --configuration MonoMelon \
- --verbosity minimal \
- --property:AutomateLocalDeployment=false
- cp \
- S1API/bin/MonoMelon/netstandard2.1/S1API.dll \
- "$RUNNER_TEMP/s1api-api-compat/baseline.dll"
+
+ use_release_baseline=false
+ if [[ "$BASE_REF" == "beta" ]]; then
+ baseline_tag="$(gh release list \
+ --repo "$GITHUB_REPOSITORY" \
+ --limit 100 \
+ --json tagName,isPrerelease,publishedAt \
+ --jq '[.[] | select(.isPrerelease and (.tagName | contains("-beta.")))] | sort_by(.publishedAt) | last | .tagName // ""')"
+ if [[ -z "$baseline_tag" ]]; then
+ baseline_tag="$(gh release view \
+ --repo "$GITHUB_REPOSITORY" \
+ --json tagName \
+ --jq '.tagName')"
+ fi
+ use_release_baseline=true
+ else
+ baseline_tag="$(git describe --tags --abbrev=0 --match 'v[0-9]*' "$BASE_SHA" 2>/dev/null || true)"
+ if [[ -n "$baseline_tag" ]] && git diff --quiet "$baseline_tag" "$BASE_SHA" -- \
+ ':(glob)S1API/**/*.cs' \
+ S1API/S1API.csproj \
+ S1API/Directory.Build.props \
+ Directory.Build.props; then
+ use_release_baseline=true
+ fi
+ fi
+
+ if [[ "$use_release_baseline" == "true" ]]; then
+ baseline_version="${baseline_tag#v}"
+ release_dir="$RUNNER_TEMP/s1api-api-compat/release"
+ release_zip="$release_dir/S1API-Forked-${baseline_version}.zip"
+
+ mkdir -p "$release_dir"
+ gh release download "$baseline_tag" \
+ --repo "$GITHUB_REPOSITORY" \
+ --pattern "S1API-Forked-${baseline_version}.zip" \
+ --dir "$release_dir"
+ unzip -p "$release_zip" \
+ Mods/S1API.Mono.MelonLoader.dll \
+ > "$RUNNER_TEMP/s1api-api-compat/baseline.dll"
+ echo "Using shipped ${baseline_tag} Mono assembly as the API baseline"
+ else
+ dotnet restore S1API/S1API.csproj -p:Configuration=MonoMelon
+ dotnet build \
+ S1API/S1API.csproj \
+ --no-restore \
+ --configuration MonoMelon \
+ --verbosity minimal \
+ --property:AutomateLocalDeployment=false
+ cp \
+ S1API/bin/MonoMelon/netstandard2.1/S1API.dll \
+ "$RUNNER_TEMP/s1api-api-compat/baseline.dll"
+ echo "Using target-branch source build as the API baseline"
+ fi
+
+ test -s "$RUNNER_TEMP/s1api-api-compat/baseline.dll"
git checkout --detach --force "$GITHUB_SHA"
- name: Restore ApiCompat tool cache
@@ -348,6 +397,7 @@ jobs:
"$RUNNER_TEMP/apicompat/apicompat" \
--left "$RUNNER_TEMP/s1api-api-compat/baseline.dll" \
--right "$RUNNER_TEMP/s1api-api-compat/current.dll" \
+ --suppression-file .github/api-compat-suppressions.xml \
--enable-rule-cannot-change-parameter-name \
--enable-rule-attributes-must-match
diff --git a/.github/workflows/il2cpp-build-check.yml b/.github/workflows/il2cpp-build-check.yml
index 42118e75..02c019da 100644
--- a/.github/workflows/il2cpp-build-check.yml
+++ b/.github/workflows/il2cpp-build-check.yml
@@ -1,7 +1,7 @@
name: IL2CPP Build Check
env:
- IL2CPP_ASSEMBLIES_CACHE_VERSION: v5
+ IL2CPP_ASSEMBLIES_CACHE_VERSION: v6
on:
push:
diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml
index cc77ca30..b01edbd5 100644
--- a/.github/workflows/publish-github-release.yml
+++ b/.github/workflows/publish-github-release.yml
@@ -27,6 +27,7 @@ permissions:
env:
NEXUSMODS_API_KEY: ${{ secrets.NEXUSMODS_API_KEY }}
NEXUSMODS_FILE_GROUP_ID: ${{ secrets.NEXUSMODS_FILE_GROUP_ID || vars.NEXUSMODS_FILE_GROUP_ID }}
+ NEXUSMODS_MOD_ID: ${{ secrets.NEXUSMODS_MOD_ID || vars.NEXUSMODS_MOD_ID }}
RELEASE_DESCRIPTION: S1API is an open source collaboration project to help standardize Schedule One modding processes. The goal is to provide a standard place for common functionalities so you can focus on making content versus reverse engineering the game.
THUNDERSTORE_COMMUNITY: schedule-i
THUNDERSTORE_TOKEN: ${{ secrets.THUNDERSTORE_TOKEN }}
@@ -320,18 +321,23 @@ jobs:
echo "thunderstore_zip_path=${thunderstore_zip_path}" >> "$GITHUB_OUTPUT"
- name: Publish or update GitHub release
+ id: github-release
uses: actions/github-script@v7
env:
RELEASE_TAG: ${{ steps.metadata.outputs.tag }}
RELEASE_NAME: ${{ steps.metadata.outputs.release_name }}
+ RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }}
RELEASE_BRANCH: ${{ steps.metadata.outputs.release_branch }}
PRERELEASE: ${{ steps.metadata.outputs.prerelease }}
with:
script: |
+ const fs = require("fs");
+ const path = require("path");
const owner = context.repo.owner;
const repo = context.repo.repo;
const tag = process.env.RELEASE_TAG;
const releaseName = process.env.RELEASE_NAME;
+ const releaseVersion = process.env.RELEASE_VERSION;
const releaseBranch = process.env.RELEASE_BRANCH;
const prerelease = process.env.PRERELEASE === "true";
@@ -369,12 +375,32 @@ jobs:
};
const targetCommitish = await resolveTargetCommitish();
- const notes = await github.rest.repos.generateReleaseNotes({
- owner,
- repo,
- tag_name: tag,
- target_commitish: targetCommitish
- });
+ const curatedNotesPath = path.join(
+ process.env.GITHUB_WORKSPACE,
+ ".github",
+ "release-notes",
+ `${releaseVersion}.md`
+ );
+ let releaseNotes;
+ if (fs.existsSync(curatedNotesPath)) {
+ releaseNotes = fs.readFileSync(curatedNotesPath, "utf8").trim();
+ if (!releaseNotes) {
+ throw new Error(`Curated release notes are empty: ${curatedNotesPath}`);
+ }
+ core.notice(`Using curated release notes from ${curatedNotesPath}`);
+ } else {
+ core.warning(
+ `No curated release notes found for ${releaseVersion}; using GitHub-generated notes.`
+ );
+ const generatedNotes = await github.rest.repos.generateReleaseNotes({
+ owner,
+ repo,
+ tag_name: tag,
+ target_commitish: targetCommitish
+ });
+ releaseNotes = generatedNotes.data.body;
+ }
+ core.setOutput("release_notes", releaseNotes);
const payload = {
owner,
@@ -382,7 +408,7 @@ jobs:
tag_name: tag,
target_commitish: targetCommitish,
name: releaseName,
- body: notes.data.body,
+ body: releaseNotes,
draft: false,
prerelease,
make_latest: prerelease ? "false" : "legacy"
@@ -422,25 +448,31 @@ jobs:
gh release upload "${{ steps.metadata.outputs.tag }}" "${{ steps.package.outputs.zip_path }}" --clobber
- name: Upload Nexus Mods release
- if: ${{ steps.metadata.outputs.prerelease != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_nexus) && env.NEXUSMODS_API_KEY != '' && env.NEXUSMODS_FILE_GROUP_ID != '' }}
- uses: Nexus-Mods/upload-action@v1.0.0-beta.7
+ if: ${{ steps.metadata.outputs.prerelease != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_nexus) && env.NEXUSMODS_API_KEY != '' && env.NEXUSMODS_FILE_GROUP_ID != '' && env.NEXUSMODS_MOD_ID != '' }}
+ uses: Nexus-Mods/upload-action@v1.0.0-beta.10
with:
api_key: ${{ env.NEXUSMODS_API_KEY }}
- file_group_id: ${{ env.NEXUSMODS_FILE_GROUP_ID }}
+ file_id: ${{ env.NEXUSMODS_FILE_GROUP_ID }}
+ mod_id: ${{ env.NEXUSMODS_MOD_ID }}
filename: ${{ steps.package.outputs.zip_path }}
version: ${{ steps.metadata.outputs.release_version }}
display_name: S1API Forked ${{ steps.metadata.outputs.release_version }}
description: ${{ env.RELEASE_DESCRIPTION }}
- file_category: main
- archive_existing_file: true
+ # Nexus file publishing and changelog publishing use separate API resources.
+ # The changelog endpoint currently rejects this mod even after creating the
+ # file version, which turns a successful upload into a failed release run.
+ # Keep release notes on GitHub and omit the optional Nexus changelog input.
+ category: main
+ archive_existing_version: true
+ update_mod_version: true
primary_mod_manager_download: true
allow_mod_manager_download: true
show_requirements_pop_up: false
- name: Skip Nexus Mods upload
- if: ${{ steps.metadata.outputs.prerelease != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_nexus) && (env.NEXUSMODS_API_KEY == '' || env.NEXUSMODS_FILE_GROUP_ID == '') }}
+ if: ${{ steps.metadata.outputs.prerelease != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_nexus) && (env.NEXUSMODS_API_KEY == '' || env.NEXUSMODS_FILE_GROUP_ID == '' || env.NEXUSMODS_MOD_ID == '') }}
shell: bash
- run: echo "::notice::Skipping Nexus Mods upload because NEXUSMODS_API_KEY or NEXUSMODS_FILE_GROUP_ID is not configured."
+ run: echo "::notice::Skipping Nexus Mods upload because NEXUSMODS_API_KEY, NEXUSMODS_FILE_GROUP_ID, or NEXUSMODS_MOD_ID is not configured."
- name: Upload Thunderstore release
if: ${{ steps.metadata.outputs.prerelease != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_thunderstore) && env.THUNDERSTORE_TOKEN != '' }}
diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml
index 697d28c7..048351f5 100644
--- a/.github/workflows/publish-nuget.yml
+++ b/.github/workflows/publish-nuget.yml
@@ -2,63 +2,63 @@ name: Publish NuGet Package
on:
push:
- branches: [ 'releases/**' ]
- paths:
- - S1API/S1API.csproj
+ tags:
+ - 'v*'
workflow_dispatch:
+ inputs:
+ tag:
+ description: Existing stable release tag to publish (for example v3.1.3)
+ required: true
+ type: string
permissions:
contents: read
jobs:
publish:
+ if: ${{ github.event_name == 'workflow_dispatch' || !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- - name: Checkout S1API
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Detect Version Bump
+ - name: Resolve stable release version
id: version
shell: bash
run: |
- NEW_VERSION=$(grep -oPm1 '(?<=)[^<]+' S1API/S1API.csproj)
-
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
- echo "changed=true" >> "$GITHUB_OUTPUT"
- echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
- echo "Publishing version $NEW_VERSION from manual dispatch"
- exit 0
+ tag="${{ inputs.tag }}"
+ else
+ tag="${GITHUB_REF_NAME}"
fi
- OLD_VERSION=$(git show "${{ github.event.before }}:S1API/S1API.csproj" 2>/dev/null | grep -oPm1 '(?<=)[^<]+' || true)
-
- if [[ -z "$NEW_VERSION" ]]; then
- echo "Unable to read current package version"
+ if [[ ! "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
+ echo "::error::Tag '$tag' is not a stable semantic version tag"
exit 1
fi
- if [[ "$OLD_VERSION" == "$NEW_VERSION" ]]; then
- echo "changed=false" >> "$GITHUB_OUTPUT"
- echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
- echo "Version unchanged at $NEW_VERSION; skipping publish"
- exit 0
- fi
+ echo "tag=${tag}" >> "$GITHUB_OUTPUT"
+ echo "new_version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
- echo "changed=true" >> "$GITHUB_OUTPUT"
- echo "old_version=$OLD_VERSION" >> "$GITHUB_OUTPUT"
- echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
- echo "Version bump detected: ${OLD_VERSION:-none} -> $NEW_VERSION"
+ - name: Checkout S1API
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ steps.version.outputs.tag }}
+ fetch-depth: 0
+
+ - name: Verify source version matches tag
+ shell: bash
+ run: |
+ source_version=$(grep -oPm1 '(?<=)[^<]+' S1API/S1API.csproj)
+
+ if [[ "$source_version" != "${{ steps.version.outputs.new_version }}" ]]; then
+ echo "::error::S1API project version '$source_version' does not match tag '${{ steps.version.outputs.tag }}'"
+ exit 1
+ fi
- name: Setup .NET
- if: steps.version.outputs.changed == 'true'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Checkout Game Assemblies
- if: steps.version.outputs.changed == 'true'
uses: actions/checkout@v4
with:
repository: ${{ secrets.GAME_ASSEMBLIES_REPO }}
@@ -67,7 +67,6 @@ jobs:
fetch-depth: 1
- name: Prepare Build Inputs
- if: steps.version.outputs.changed == 'true'
shell: bash
run: |
mkdir -p S1API/ScheduleOneAssemblies/Managed
@@ -111,19 +110,15 @@ jobs:
EOF
- name: Restore Dependencies
- if: steps.version.outputs.changed == 'true'
run: dotnet restore S1API/S1API.csproj -p:Configuration=MonoMelon
- name: Build Package Assembly
- if: steps.version.outputs.changed == 'true'
run: dotnet build S1API/S1API.csproj --no-restore -c MonoMelon -v minimal
- name: Pack NuGet Package
- if: steps.version.outputs.changed == 'true'
run: dotnet pack S1API/S1API.csproj --no-build --no-restore -c MonoMelon /p:ContinuousIntegrationBuild=true
- name: Publish to NuGet
- if: steps.version.outputs.changed == 'true'
shell: bash
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
diff --git a/AGENTS.md b/AGENTS.md
index f662340e..f0342ad2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -53,7 +53,7 @@ and document the migration impact in the PR.
`S1API.Tests/` is the only test implementation that should be committed to this repository. Keep runtime and in-game smoke mods, launchers, harnesses, disposable saves or installs, logs, screenshots, and generated evidence local and ignored, including everything under `tests/Smoke/`. Do not add `.gitignore` exceptions for smoke-test sources. Record the scenario, commands, runtime matrix, and observed pass/fail evidence in the PR description without committing the smoke implementation or game-derived artifacts.
## Commit & Pull Request Guidelines
-Write imperative, single-purpose commits; lightweight prefixes such as `fix:` or `feat:` appear in history and are encouraged. Target PRs at `bleeding-edge`, include a short change narrative, reproduction or validation notes, and link any external issue. Screenshots or logs are helpful for UI or networking work. Never modify CI workflows without prior discussion.
+Write imperative, single-purpose commits; lightweight prefixes such as `fix:` or `feat:` appear in history and are encouraged. Target regular-game PRs at `stable` and beta-game PRs at `beta`. Include a short change narrative, reproduction or validation notes, and link any external issue. Screenshots or logs are helpful for UI or networking work. Never modify CI workflows without prior discussion.
## Release & Versioning Workflow
Always follow [`VERSIONING.md`](VERSIONING.md) for any release, hotfix, tagging, branch-planning, or version-bump work. Treat it as the authoritative release policy.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d4ccae4a..19806900 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -8,6 +8,22 @@ Please read over the below in full to help you get started and set expectations
- Do **NOT** alter my GitHub actions unless you have a good reason.
I will close your PR and ban you from the project if malicious intent is found.
+## Prerequisites
+S1API is available to mod developers of all experience levels, but contributing
+game-facing changes assumes working familiarity with Schedule I mod development
+across both the public IL2CPP and alternate Mono branches. If you are new to
+Schedule I modding, start with the
+[Schedule I Modding Wiki](https://s1modding.github.io/docs/moddevs/) and build a
+mod before proposing game-facing changes to S1API.
+
+Before building S1API:
+
+1. Install the [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0).
+2. Prepare working MelonLoader environments for the public IL2CPP and alternate
+ Mono branches.
+3. Configure both environments in `local.build.props` using
+ `example.build.props` as the template.
+
## How to Build the Project
1. Clone the project using `git clone https://github.com/ifBars/S1API.git`
2. Copy the `example.build.props` file to a new file named `local.build.props`. This file located in the base repository directory.
diff --git a/README.md b/README.md
index 168f442b..911db707 100644
--- a/README.md
+++ b/README.md
@@ -18,9 +18,9 @@ The goal is to provide a standard place for common functionalities so you can fo
Track S1API's progress in wrapping Schedule One's game types:
-[](https://github.com/ifBars/S1API/actions/workflows/coverage.yml)
+[](https://github.com/ifBars/S1API/actions/workflows/coverage.yml)
-
+
*View detailed coverage reports in the [Coverage Analysis workflow](https://github.com/ifBars/S1API/actions/workflows/coverage.yml)*
diff --git a/S1API.Tests/Audio/JukeboxApiCompatibilityTests.cs b/S1API.Tests/Audio/JukeboxApiCompatibilityTests.cs
new file mode 100644
index 00000000..cf6d19ca
--- /dev/null
+++ b/S1API.Tests/Audio/JukeboxApiCompatibilityTests.cs
@@ -0,0 +1,107 @@
+using System.Reflection;
+using S1API.Audio;
+using UnityEngine;
+
+namespace S1API.Tests.Audio;
+
+public sealed class JukeboxApiCompatibilityTests
+{
+ [Fact]
+ public void WrapperExposesTheExpectedManagedContract()
+ {
+ Assert.True(typeof(Jukebox).IsSealed);
+ Assert.Empty(typeof(Jukebox).GetConstructors(BindingFlags.Public | BindingFlags.Instance));
+
+ AssertProperty(nameof(Jukebox.GUID), typeof(string));
+ AssertProperty(nameof(Jukebox.GameObject), typeof(GameObject));
+ AssertProperty(nameof(Jukebox.Tracks), typeof(IReadOnlyList));
+ AssertProperty(nameof(Jukebox.CurrentTrack), typeof(JukeboxTrack));
+ AssertProperty(nameof(Jukebox.Volume), typeof(int));
+ AssertProperty(nameof(Jukebox.NormalizedVolume), typeof(float));
+ AssertProperty(nameof(Jukebox.IsPlaying), typeof(bool));
+ AssertProperty(nameof(Jukebox.CurrentTrackTime), typeof(float));
+ AssertProperty(nameof(Jukebox.CurrentTrackOrderIndex), typeof(int));
+ AssertProperty(nameof(Jukebox.Shuffle), typeof(bool));
+ AssertProperty(nameof(Jukebox.RepeatMode), typeof(JukeboxRepeatMode));
+ AssertProperty(nameof(Jukebox.Sync), typeof(bool));
+ AssertProperty(nameof(Jukebox.State), typeof(JukeboxState));
+
+ EventInfo stateChanged = typeof(Jukebox).GetEvent(nameof(Jukebox.OnStateChanged))!;
+ Assert.Equal(typeof(Action), stateChanged.EventHandlerType);
+
+ AssertMethod(nameof(Jukebox.TogglePlay));
+ AssertMethod(nameof(Jukebox.PreviousTrack));
+ AssertMethod(nameof(Jukebox.NextTrack));
+ AssertMethod(nameof(Jukebox.ChangeVolume), typeof(int));
+ AssertMethod(nameof(Jukebox.SetVolume), typeof(int));
+ AssertMethod(nameof(Jukebox.ToggleShuffle));
+ AssertMethod(nameof(Jukebox.ToggleRepeatMode));
+ AssertMethod(nameof(Jukebox.ToggleSync));
+ AssertMethod(nameof(Jukebox.SelectTrack), typeof(int));
+ }
+
+ [Fact]
+ public void DiscoveryExposesTheExpectedManagedContract()
+ {
+ MethodInfo fromGameObject = typeof(Jukebox).GetMethod(
+ nameof(Jukebox.FromGameObject),
+ new[] { typeof(GameObject) })!;
+ Assert.True(fromGameObject.IsStatic);
+ Assert.Equal(typeof(Jukebox), fromGameObject.ReturnType);
+
+ MethodInfo getAll = typeof(JukeboxManager).GetMethod(
+ nameof(JukeboxManager.GetAll),
+ Type.EmptyTypes)!;
+ Assert.Equal(typeof(IReadOnlyList), getAll.ReturnType);
+
+ MethodInfo getByGuid = typeof(JukeboxManager).GetMethod(
+ nameof(JukeboxManager.GetByGUID),
+ new[] { typeof(string) })!;
+ Assert.Equal(typeof(Jukebox), getByGuid.ReturnType);
+ }
+
+ [Fact]
+ public void SnapshotTypesRemainSealedAndInternallyConstructed()
+ {
+ foreach (Type type in new[] { typeof(JukeboxTrack), typeof(JukeboxState) })
+ {
+ Assert.True(type.IsSealed);
+ Assert.Empty(type.GetConstructors(BindingFlags.Public | BindingFlags.Instance));
+ }
+ }
+
+ [Theory]
+ [InlineData(typeof(JukeboxTrack), "Index", typeof(int))]
+ [InlineData(typeof(JukeboxTrack), "Name", typeof(string))]
+ [InlineData(typeof(JukeboxTrack), "Artist", typeof(string))]
+ [InlineData(typeof(JukeboxState), "Volume", typeof(int))]
+ [InlineData(typeof(JukeboxState), "NormalizedVolume", typeof(float))]
+ [InlineData(typeof(JukeboxState), "IsPlaying", typeof(bool))]
+ [InlineData(typeof(JukeboxState), "CurrentTrackTime", typeof(float))]
+ [InlineData(typeof(JukeboxState), "CurrentTrackOrderIndex", typeof(int))]
+ [InlineData(typeof(JukeboxState), "Shuffle", typeof(bool))]
+ [InlineData(typeof(JukeboxState), "RepeatMode", typeof(JukeboxRepeatMode))]
+ [InlineData(typeof(JukeboxState), "Sync", typeof(bool))]
+ [InlineData(typeof(JukeboxState), "CurrentTrack", typeof(JukeboxTrack))]
+ public void SnapshotPropertiesRemainReadOnly(Type type, string propertyName, Type propertyType)
+ {
+ PropertyInfo property = type.GetProperty(propertyName)!;
+ Assert.Equal(propertyType, property.PropertyType);
+ Assert.NotNull(property.GetMethod);
+ Assert.Null(property.SetMethod);
+ }
+
+ private static void AssertProperty(string propertyName, Type propertyType)
+ {
+ PropertyInfo property = typeof(Jukebox).GetProperty(propertyName)!;
+ Assert.Equal(propertyType, property.PropertyType);
+ Assert.NotNull(property.GetMethod);
+ Assert.Null(property.SetMethod);
+ }
+
+ private static void AssertMethod(string methodName, params Type[] parameterTypes)
+ {
+ MethodInfo method = typeof(Jukebox).GetMethod(methodName, parameterTypes)!;
+ Assert.Equal(typeof(void), method.ReturnType);
+ }
+}
diff --git a/S1API.Tests/Audio/JukeboxApiCompileFixture.cs b/S1API.Tests/Audio/JukeboxApiCompileFixture.cs
new file mode 100644
index 00000000..f79980d1
--- /dev/null
+++ b/S1API.Tests/Audio/JukeboxApiCompileFixture.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+using S1API.Audio;
+using UnityEngine;
+
+namespace S1API.Tests.Audio;
+
+internal static class JukeboxApiCompileFixture
+{
+ internal static void InspectAndControl(GameObject gameObject, string guid)
+ {
+ Jukebox? jukebox = Jukebox.FromGameObject(gameObject);
+ IReadOnlyList allJukeboxes = JukeboxManager.GetAll();
+ Jukebox? byGuid = JukeboxManager.GetByGUID(guid);
+ _ = allJukeboxes;
+ _ = byGuid;
+
+ if (jukebox == null)
+ return;
+
+ IReadOnlyList tracks = jukebox.Tracks;
+ JukeboxTrack? currentTrack = jukebox.CurrentTrack;
+ JukeboxState state = jukebox.State;
+ Action handler = changedState =>
+ {
+ _ = changedState.CurrentTrack;
+ };
+ jukebox.OnStateChanged += handler;
+ jukebox.OnStateChanged -= handler;
+
+ _ = jukebox.GUID;
+ _ = jukebox.GameObject;
+ _ = tracks;
+ _ = currentTrack;
+ _ = currentTrack?.Index;
+ _ = currentTrack?.Name;
+ _ = currentTrack?.Artist;
+ _ = state.Volume;
+ _ = state.NormalizedVolume;
+ _ = state.IsPlaying;
+ _ = state.CurrentTrackTime;
+ _ = state.CurrentTrackOrderIndex;
+ _ = state.Shuffle;
+ _ = state.RepeatMode;
+ _ = state.Sync;
+ _ = jukebox.NormalizedVolume;
+ _ = jukebox.IsPlaying;
+ _ = jukebox.CurrentTrackTime;
+ _ = jukebox.CurrentTrackOrderIndex;
+ _ = jukebox.Shuffle;
+ _ = jukebox.RepeatMode;
+ _ = jukebox.Sync;
+ jukebox.TogglePlay();
+ jukebox.PreviousTrack();
+ jukebox.NextTrack();
+ jukebox.ChangeVolume(1);
+ jukebox.SetVolume(4);
+ jukebox.ToggleShuffle();
+ jukebox.ToggleRepeatMode();
+ jukebox.ToggleSync();
+ jukebox.SelectTrack(0);
+ }
+}
diff --git a/S1API.Tests/Audio/JukeboxRepeatModeTests.cs b/S1API.Tests/Audio/JukeboxRepeatModeTests.cs
new file mode 100644
index 00000000..943732d7
--- /dev/null
+++ b/S1API.Tests/Audio/JukeboxRepeatModeTests.cs
@@ -0,0 +1,14 @@
+using S1API.Audio;
+
+namespace S1API.Tests.Audio;
+
+public sealed class JukeboxRepeatModeTests
+{
+ [Fact]
+ public void ValuesMatchTheNativeRepeatModes()
+ {
+ Assert.Equal(0, (int)JukeboxRepeatMode.None);
+ Assert.Equal(1, (int)JukeboxRepeatMode.RepeatQueue);
+ Assert.Equal(2, (int)JukeboxRepeatMode.RepeatTrack);
+ }
+}
diff --git a/S1API.Tests/Audio/JukeboxValidationTests.cs b/S1API.Tests/Audio/JukeboxValidationTests.cs
new file mode 100644
index 00000000..b2aa7444
--- /dev/null
+++ b/S1API.Tests/Audio/JukeboxValidationTests.cs
@@ -0,0 +1,27 @@
+using S1API.Audio;
+
+namespace S1API.Tests.Audio;
+
+public sealed class JukeboxValidationTests
+{
+ [Theory]
+ [InlineData(0, 1)]
+ [InlineData(26, 27)]
+ public void ConfiguredTrackIndexesAreAccepted(int trackIndex, int trackCount)
+ {
+ Jukebox.ValidateTrackIndex(trackIndex, trackCount);
+ }
+
+ [Theory]
+ [InlineData(-1, 27)]
+ [InlineData(27, 27)]
+ [InlineData(0, 0)]
+ public void UnconfiguredTrackIndexesAreRejected(int trackIndex, int trackCount)
+ {
+ ArgumentOutOfRangeException exception = Assert.Throws(
+ () => Jukebox.ValidateTrackIndex(trackIndex, trackCount));
+
+ Assert.Equal("trackIndex", exception.ParamName);
+ Assert.Equal(trackIndex, exception.ActualValue);
+ }
+}
diff --git a/S1API.Tests/Casino/CasinoApiContractTests.cs b/S1API.Tests/Casino/CasinoApiContractTests.cs
new file mode 100644
index 00000000..01b726b7
--- /dev/null
+++ b/S1API.Tests/Casino/CasinoApiContractTests.cs
@@ -0,0 +1,213 @@
+using System.ComponentModel;
+using System.Reflection;
+using S1API.Casino;
+
+#if IL2CPPMELON
+using S1Casino = Il2CppScheduleOne.Casino;
+#elif MONOMELON
+using S1Casino = ScheduleOne.Casino;
+#endif
+
+namespace S1API.Tests.Casino;
+
+public sealed class CasinoApiContractTests
+{
+ [Fact]
+ public void NativeLifecyclePatchPointsExistInTargetRuntime()
+ {
+ Assert.NotNull(typeof(S1Casino.BlackjackGameController).GetMethod(
+ "set_CurrentStage",
+ BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public));
+ Assert.NotNull(typeof(S1Casino.RTBGameController).GetMethod(
+ "set_CurrentStage",
+ BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public));
+ MethodBase? slotStartMethod = global::S1API.Internal.Patches.CasinoGamePatches
+ .FindSlotStartLogicMethod(typeof(S1Casino.SlotMachine));
+ Assert.NotNull(slotStartMethod);
+ Assert.StartsWith("RpcLogic___StartSpin_", slotStartMethod!.Name);
+ Assert.NotNull(typeof(S1Casino.SlotMachine).GetMethod(
+ "DisplayOutcome",
+ BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public));
+ }
+
+ [Fact]
+ public void ManagedEnumsPreserveNativeWireValues()
+ {
+ Assert.Equal((int)S1Casino.BlackjackGameController.EStage.WaitingForPlayers, (int)BlackjackStage.WaitingForPlayers);
+ Assert.Equal((int)S1Casino.BlackjackGameController.EStage.Ending, (int)BlackjackStage.Ending);
+ Assert.Equal((int)S1Casino.RTBGameController.EStage.RedOrBlack, (int)RideTheBusStage.RedOrBlack);
+ Assert.Equal((int)S1Casino.RTBGameController.EStage.Suit, (int)RideTheBusStage.Suit);
+ Assert.Equal((int)S1Casino.PlayingCard.ECardSuit.Clubs, (int)CasinoCardSuit.Clubs);
+ Assert.Equal((int)S1Casino.PlayingCard.ECardValue.King, (int)CasinoCardValue.King);
+ Assert.Equal((int)S1Casino.SlotMachine.ESymbol.Seven, (int)SlotSymbol.Seven);
+ Assert.Equal((int)S1Casino.SlotMachine.EOutcome.NoWin, (int)SlotOutcome.NoWin);
+ }
+
+ [Theory]
+ [InlineData(typeof(CasinoPlayerSnapshot))]
+ [InlineData(typeof(SlotSpinSnapshot))]
+ public void SnapshotReferenceTypesExposeNoPublicSetters(Type snapshotType)
+ {
+ Assert.All(
+ snapshotType.GetProperties(BindingFlags.Public | BindingFlags.Instance),
+ property => Assert.Null(property.SetMethod));
+ AssertPublicInstanceFieldsAreReadonly(snapshotType);
+ Assert.Empty(snapshotType.GetConstructors(BindingFlags.Public | BindingFlags.Instance));
+ }
+
+ [Fact]
+ public void CasinoWrappersCannotBePubliclyConstructedOrMutated()
+ {
+ Type[] wrapperTypes =
+ {
+ typeof(BlackjackGame),
+ typeof(RideTheBusGame),
+ typeof(SlotMachine)
+ };
+
+ foreach (Type wrapperType in wrapperTypes)
+ {
+ Assert.Empty(wrapperType.GetConstructors(BindingFlags.Public | BindingFlags.Instance));
+ Assert.All(
+ wrapperType.GetProperties(BindingFlags.Public | BindingFlags.Instance),
+ property => Assert.Null(property.SetMethod));
+ AssertPublicInstanceFieldsAreReadonly(wrapperType);
+ }
+ }
+
+ [Fact]
+ public void RegistrySurfaceIsReadOnlyDiscoveryAndQueriesOnly()
+ {
+ MethodInfo[] publicMethods = typeof(CasinoGameRegistry)
+ .GetMethods(BindingFlags.Public | BindingFlags.Static)
+ .Where(method => !method.IsSpecialName)
+ .ToArray();
+
+ Assert.NotEmpty(publicMethods);
+ Assert.All(publicMethods, method =>
+ Assert.True(
+ method.Name.StartsWith("Get", StringComparison.Ordinal) ||
+ method.Name.StartsWith("Find", StringComparison.Ordinal),
+ $"Unexpected registry method: {method.Name}"));
+ Assert.DoesNotContain(publicMethods, method => method.ReturnType == typeof(void));
+ }
+
+ [Fact]
+ public void LegacyNativeSlotLookupRemainsAsAnObsoleteCompatibilityShim()
+ {
+ MethodInfo method = typeof(SlotMachineHelper).GetMethod(
+ nameof(SlotMachineHelper.FindNearestSlotMachine),
+ BindingFlags.Public | BindingFlags.Static,
+ binder: null,
+ types: new[] { typeof(UnityEngine.Vector3), typeof(float) },
+ modifiers: null)!;
+
+ Assert.NotNull(method);
+ ObsoleteAttribute obsolete = Assert.IsType(
+ method.GetCustomAttribute());
+ Assert.False(obsolete.IsError);
+ EditorBrowsableAttribute editorBrowsable = Assert.IsType(
+ method.GetCustomAttribute());
+ Assert.Equal(EditorBrowsableState.Never, editorBrowsable.State);
+ Assert.Equal(typeof(S1Casino.SlotMachine), method.ReturnType);
+
+ MethodInfo managedMethod = typeof(CasinoGameRegistry).GetMethod(
+ nameof(CasinoGameRegistry.FindNearestSlotMachine),
+ BindingFlags.Public | BindingFlags.Static,
+ binder: null,
+ types: new[] { typeof(UnityEngine.Vector3), typeof(float) },
+ modifiers: null)!;
+
+ Assert.NotNull(managedMethod);
+ Assert.Equal(typeof(SlotMachine), managedMethod.ReturnType);
+ }
+
+ [Fact]
+ public void PublicCasinoApiDoesNotExposeNativeCasinoTypes()
+ {
+ Type[] publicCasinoTypes =
+ {
+ typeof(CasinoGameRegistry),
+ typeof(CasinoGameTable),
+ typeof(BlackjackGame),
+ typeof(RideTheBusGame),
+ typeof(SlotMachine),
+ typeof(CasinoPlayerSnapshot),
+ typeof(CasinoCardSnapshot),
+ typeof(SlotSpinSnapshot)
+ };
+
+ foreach (Type type in publicCasinoTypes)
+ {
+ IEnumerable exposedTypes = type
+ .GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
+ .SelectMany(GetExposedTypes);
+
+ Assert.DoesNotContain(exposedTypes, exposed =>
+ exposed.Namespace?.Contains("ScheduleOne.Casino", StringComparison.Ordinal) == true);
+ }
+ }
+
+ private static IEnumerable GetExposedTypes(MemberInfo member)
+ {
+ IEnumerable declaredTypes;
+ switch (member)
+ {
+ case PropertyInfo property:
+ declaredTypes = new[] { property.PropertyType };
+ break;
+ case FieldInfo field:
+ declaredTypes = new[] { field.FieldType };
+ break;
+ case EventInfo eventInfo when eventInfo.EventHandlerType != null:
+ declaredTypes = new[] { eventInfo.EventHandlerType };
+ break;
+ case MethodInfo method:
+ declaredTypes = new[] { method.ReturnType }
+ .Concat(method.GetParameters().Select(parameter => parameter.ParameterType));
+ break;
+ case ConstructorInfo constructor:
+ declaredTypes = constructor.GetParameters().Select(parameter => parameter.ParameterType);
+ break;
+ default:
+ return Array.Empty();
+ }
+
+ return declaredTypes.SelectMany(ExpandCompositeType);
+ }
+
+ private static IEnumerable ExpandCompositeType(Type root)
+ {
+ var pending = new Stack();
+ var visited = new HashSet();
+ pending.Push(root);
+
+ while (pending.Count > 0)
+ {
+ Type current = pending.Pop();
+ if (!visited.Add(current))
+ continue;
+
+ yield return current;
+
+ if (current.HasElementType && current.GetElementType() is Type elementType)
+ pending.Push(elementType);
+
+ foreach (Type argument in current.GetGenericArguments())
+ pending.Push(argument);
+
+ if (current.BaseType != null)
+ pending.Push(current.BaseType);
+
+ foreach (Type implementedInterface in current.GetInterfaces())
+ pending.Push(implementedInterface);
+ }
+ }
+
+ private static void AssertPublicInstanceFieldsAreReadonly(Type type)
+ {
+ Assert.All(
+ type.GetFields(BindingFlags.Public | BindingFlags.Instance),
+ field => Assert.True(field.IsInitOnly, $"{type.Name}.{field.Name} must be readonly."));
+ }
+}
diff --git a/S1API.Tests/Coverage/CoverageAnalyzerTests.cs b/S1API.Tests/Coverage/CoverageAnalyzerTests.cs
new file mode 100644
index 00000000..54c944bd
--- /dev/null
+++ b/S1API.Tests/Coverage/CoverageAnalyzerTests.cs
@@ -0,0 +1,186 @@
+using System.Text.Json;
+using S1APICoverageAnalyzer.Analysis;
+using S1APICoverageAnalyzer.Models;
+using S1APICoverageAnalyzer.Output;
+using Xunit;
+
+namespace S1API.Tests.Coverage;
+
+public sealed class CoverageAnalyzerTests
+{
+ [Fact]
+ public void ApiAnalyzer_RecordsExplicitMappingsAndUnwrapsElementTypes()
+ {
+ var apiAssembly = typeof(global::S1API.Temperature.TemperatureUtility).Assembly;
+ var analyzer = new ApiAssemblyAnalyzer(apiAssembly, apiAssembly.Location);
+
+ analyzer.Analyze();
+
+ IReadOnlyDictionary explicitMappings =
+ analyzer.GetExplicitCoverageMappings();
+ Assert.Equal(
+ "S1API.Temperature.TemperatureEmitterInfo",
+ explicitMappings["ScheduleOne.Temperature.TemperatureEmitterInfo"]);
+ Assert.Equal(
+ "S1API.Temperature.TemperatureUtility",
+ explicitMappings["ScheduleOne.Temperature.TemperatureUtility"]);
+
+ Assert.Contains(
+ "ScheduleOne.Temperature.TemperatureEmitterInfo",
+ analyzer.GetWrappedGameTypes());
+ Assert.DoesNotContain(
+ "ScheduleOne.Temperature.TemperatureEmitterInfo[]",
+ analyzer.GetWrappedGameTypes());
+ }
+
+ [Fact]
+ public void Calculate_ReportsProvenanceAndMatchStrategyForEveryCoveredType()
+ {
+ var gameTypes = new List
+ {
+ GameType("ScheduleOne.Temperature.TemperatureUtility"),
+ GameType("ScheduleOne.Items.ItemDefinition"),
+ GameType("ScheduleOne.Casino.BlackjackGameController+EStage"),
+ GameType("ScheduleOne.Dialogue.DialogueController.Node"),
+ GameType("ScheduleOne.Vehicles.Modification.EVehicleColor")
+ };
+ var apiTypes = new List
+ {
+ ApiType(
+ "S1API.Casino.BlackjackGame",
+ "BlackjackGame",
+ "ScheduleOne.Casino.BlackjackGameController"),
+ ApiType(
+ "S1API.Dialogue.DialogueNode",
+ "DialogueNode",
+ "ScheduleOne.Dialogue.DialogueController+Node"),
+ ApiType(
+ "S1API.Items.ItemDefinition",
+ "ItemDefinition",
+ "ScheduleOne.Items.ItemDefinition"),
+ ApiType(
+ "S1API.Temperature.TemperatureUtility",
+ "TemperatureUtility",
+ "ScheduleOne.Temperature.TemperatureUtility"),
+ ApiType(
+ "S1API.Vehicles.VehicleColor",
+ "VehicleColor",
+ "ScheduleOne.Vehicles.Modification.VehicleColors")
+ };
+ var explicitMappings = new Dictionary(StringComparer.Ordinal)
+ {
+ ["ScheduleOne.Temperature.TemperatureUtility"] =
+ "S1API.Temperature.TemperatureUtility"
+ };
+
+ CoverageResult result = Calculate(gameTypes, apiTypes, explicitMappings);
+
+ Assert.Collection(
+ result.CoveredTypes.OrderBy(type => type.FullName, StringComparer.Ordinal),
+ type => AssertMatch(type, "S1API.Casino.BlackjackGame", CoverageMatchStrategy.Nested),
+ type => AssertMatch(type, "S1API.Dialogue.DialogueNode", CoverageMatchStrategy.Normalized),
+ type => AssertMatch(type, "S1API.Items.ItemDefinition", CoverageMatchStrategy.Exact),
+ type => AssertMatch(type, "S1API.Temperature.TemperatureUtility", CoverageMatchStrategy.Explicit),
+ type => AssertMatch(type, "S1API.Vehicles.VehicleColor", CoverageMatchStrategy.Fuzzy));
+ Assert.Empty(result.UncoveredTypes);
+
+ using JsonDocument report = JsonDocument.Parse(ReportGenerator.GenerateJsonReport(result));
+ foreach (JsonElement coveredType in report.RootElement.GetProperty("coveredTypes").EnumerateArray())
+ {
+ Assert.False(string.IsNullOrWhiteSpace(coveredType.GetProperty("coveredBy").GetString()));
+ Assert.False(string.IsNullOrWhiteSpace(coveredType.GetProperty("matchStrategy").GetString()));
+ }
+ }
+
+ [Fact]
+ public void Calculate_DoesNotFuzzyMatchSimilarUnrelatedType()
+ {
+ GameType unrelatedGameType =
+ GameType("ScheduleOne.Vehicles.VehicleSeatSnapshot");
+ ApiTypeInfo similarlyNamedApiType = ApiType(
+ "S1API.Items.VehicleSeat",
+ "VehicleSeat",
+ "ScheduleOne.ItemFramework.ItemSlot");
+
+ CoverageResult result = Calculate(
+ new List { unrelatedGameType },
+ new List { similarlyNamedApiType },
+ new Dictionary());
+
+ Assert.Empty(result.CoveredTypes);
+ Assert.Same(unrelatedGameType, Assert.Single(result.UncoveredTypes));
+ }
+
+ [Fact]
+ public void Calculate_UsesDeterministicApiTypeForEquivalentMatches()
+ {
+ GameType gameType = GameType("ScheduleOne.Items.ItemDefinition");
+ var apiTypes = new List
+ {
+ ApiType(
+ "S1API.Zeta.ItemDefinition",
+ "ItemDefinition",
+ gameType.FullName),
+ ApiType(
+ "S1API.Alpha.ItemDefinition",
+ "ItemDefinition",
+ gameType.FullName)
+ };
+
+ CoverageResult result = Calculate(
+ new List { gameType },
+ apiTypes,
+ new Dictionary());
+
+ AssertMatch(
+ Assert.Single(result.CoveredTypes),
+ "S1API.Alpha.ItemDefinition",
+ CoverageMatchStrategy.Exact);
+ }
+
+ private static CoverageResult Calculate(
+ List gameTypes,
+ List apiTypes,
+ IReadOnlyDictionary explicitMappings)
+ {
+ var calculator = new CoverageCalculator(
+ gameTypes,
+ new Dictionary>(StringComparer.Ordinal),
+ apiTypes,
+ explicitMappings,
+ excludedTypeCount: 0);
+ return calculator.Calculate();
+ }
+
+ private static GameType GameType(string fullName)
+ {
+ int separatorIndex = fullName.LastIndexOfAny(['.', '+']);
+ return new GameType
+ {
+ FullName = fullName,
+ Namespace = separatorIndex < 0 ? string.Empty : fullName[..separatorIndex],
+ Name = separatorIndex < 0 ? fullName : fullName[(separatorIndex + 1)..],
+ Kind = GameTypeKind.Class
+ };
+ }
+
+ private static ApiTypeInfo ApiType(
+ string fullName,
+ string name,
+ params string[] wrappedGameTypes) =>
+ new()
+ {
+ FullName = fullName,
+ Name = name,
+ WrappedGameTypes = wrappedGameTypes.ToList()
+ };
+
+ private static void AssertMatch(
+ GameType gameType,
+ string expectedApiType,
+ CoverageMatchStrategy expectedStrategy)
+ {
+ Assert.Equal(expectedApiType, gameType.CoveredByApiType);
+ Assert.Equal(expectedStrategy, gameType.MatchStrategy);
+ }
+}
diff --git a/S1API.Tests/Deliveries/LoadingDockApiTests.cs b/S1API.Tests/Deliveries/LoadingDockApiTests.cs
new file mode 100644
index 00000000..37f99c13
--- /dev/null
+++ b/S1API.Tests/Deliveries/LoadingDockApiTests.cs
@@ -0,0 +1,196 @@
+using System.Reflection;
+using S1API.Deliveries;
+using S1API.Items;
+using S1API.Property;
+using S1API.Vehicles;
+
+#if IL2CPPMELON
+using S1Delivery = Il2CppScheduleOne.Delivery;
+#elif MONOMELON
+using S1Delivery = ScheduleOne.Delivery;
+#endif
+
+namespace S1API.Tests.Deliveries;
+
+public sealed class LoadingDockApiTests
+{
+ [Fact]
+ public void NativeTransitionPatchPointsExistInTargetRuntime()
+ {
+ const BindingFlags flags =
+ BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
+
+ Assert.NotNull(typeof(S1Delivery.LoadingDock).GetMethod(
+ "SetOccupant",
+ flags));
+ Assert.NotNull(typeof(S1Delivery.LoadingDock).GetMethod(
+ nameof(S1Delivery.LoadingDock.SetStaticOccupant),
+ flags));
+ Assert.NotNull(typeof(S1Delivery.LoadingDock).GetMethod(
+ "set_IsAcceptingItems",
+ flags));
+ }
+
+ [Fact]
+ public void WrapperSurfaceIsReadOnlyAndManaged()
+ {
+ Assert.Empty(typeof(LoadingDock).GetConstructors(
+ BindingFlags.Public | BindingFlags.Instance));
+ Assert.All(
+ typeof(LoadingDock).GetProperties(BindingFlags.Public | BindingFlags.Instance),
+ property => Assert.Null(property.SetMethod));
+
+ Assert.Equal(typeof(string), GetProperty(nameof(LoadingDock.GUID)).PropertyType);
+ Assert.Equal(typeof(string), GetProperty(nameof(LoadingDock.Name)).PropertyType);
+ Assert.Equal(typeof(PropertyWrapper), GetProperty(nameof(LoadingDock.Property)).PropertyType);
+ Assert.Equal(
+ typeof(IReadOnlyList),
+ GetProperty(nameof(LoadingDock.InputSlots)).PropertyType);
+ Assert.Equal(
+ typeof(IReadOnlyList),
+ GetProperty(nameof(LoadingDock.OutputSlots)).PropertyType);
+ Assert.Equal(typeof(LandVehicle), GetProperty(nameof(LoadingDock.DynamicOccupant)).PropertyType);
+ Assert.Equal(typeof(LandVehicle), GetProperty(nameof(LoadingDock.StaticOccupant)).PropertyType);
+
+ Assert.DoesNotContain(
+ typeof(LoadingDock).GetMembers(BindingFlags.Public | BindingFlags.Instance),
+ ExposesNativeDeliveryType);
+ }
+
+ [Fact]
+ public void PropertyAndDeliveryExposeLoadingDockNavigation()
+ {
+ Assert.Equal(
+ typeof(IReadOnlyList),
+ typeof(PropertyWrapper).GetProperty(nameof(PropertyWrapper.LoadingDocks))!.PropertyType);
+ Assert.Equal(
+ typeof(LoadingDock),
+ typeof(Delivery).GetProperty(nameof(Delivery.LoadingDock))!.PropertyType);
+ }
+
+ [Fact]
+ public void EventsExposeManagedPreviousAndCurrentValues()
+ {
+ Assert.Equal(
+ typeof(Action),
+ GetEvent(nameof(LoadingDock.DynamicOccupantChanged)).EventHandlerType);
+ Assert.Equal(
+ typeof(Action),
+ GetEvent(nameof(LoadingDock.StaticOccupantChanged)).EventHandlerType);
+ Assert.Equal(
+ typeof(Action),
+ GetEvent(nameof(LoadingDock.AcceptingItemsChanged)).EventHandlerType);
+ }
+
+ [Fact]
+ public void ManagedNotificationsSuppressNoOpsAndIsolateSubscribers()
+ {
+ var nativeDock = TestObjectFactory.CreateUninitialized();
+ var dock = new LoadingDock(nativeDock);
+ var previous = TestObjectFactory.CreateUninitialized();
+ var current = TestObjectFactory.CreateUninitialized();
+ int dynamicCalls = 0;
+ int staticCalls = 0;
+ int acceptingCalls = 0;
+
+ dock.DynamicOccupantChanged += (_, _) => throw new InvalidOperationException("expected");
+ dock.DynamicOccupantChanged += (observedPrevious, observedCurrent) =>
+ {
+ Assert.Same(previous, observedPrevious);
+ Assert.Same(current, observedCurrent);
+ dynamicCalls++;
+ };
+ dock.StaticOccupantChanged += (_, _) => staticCalls++;
+ dock.AcceptingItemsChanged += (observedPrevious, observedCurrent) =>
+ {
+ Assert.False(observedPrevious);
+ Assert.True(observedCurrent);
+ acceptingCalls++;
+ };
+
+ dock.NotifyDynamicOccupantChanged(previous, previous);
+ dock.NotifyDynamicOccupantChanged(previous, current);
+ dock.NotifyStaticOccupantChanged(current, current);
+ dock.NotifyStaticOccupantChanged(previous, current);
+ dock.NotifyAcceptingItemsChanged(false, false);
+ dock.NotifyAcceptingItemsChanged(false, true);
+
+ Assert.Equal(1, dynamicCalls);
+ Assert.Equal(1, staticCalls);
+ Assert.Equal(1, acceptingCalls);
+ }
+
+ private static PropertyInfo GetProperty(string name) =>
+ typeof(LoadingDock).GetProperty(name, BindingFlags.Public | BindingFlags.Instance)!;
+
+ private static EventInfo GetEvent(string name) =>
+ typeof(LoadingDock).GetEvent(name, BindingFlags.Public | BindingFlags.Instance)!;
+
+ private static bool ExposesNativeDeliveryType(MemberInfo member)
+ {
+ IEnumerable types = member switch
+ {
+ PropertyInfo property => new[] { property.PropertyType },
+ EventInfo eventInfo when eventInfo.EventHandlerType != null =>
+ new[] { eventInfo.EventHandlerType },
+ MethodInfo method => new[] { method.ReturnType }
+ .Concat(method.GetParameters().Select(parameter => parameter.ParameterType)),
+ _ => Array.Empty()
+ };
+
+ return types
+ .SelectMany(ExpandType)
+ .Any(type => type.Namespace?.Contains(
+ "ScheduleOne.Delivery",
+ StringComparison.Ordinal) == true);
+ }
+
+ private static IEnumerable ExpandType(Type root)
+ {
+ yield return root;
+
+ if (root.HasElementType && root.GetElementType() is Type elementType)
+ {
+ foreach (Type nested in ExpandType(elementType))
+ yield return nested;
+ }
+
+ foreach (Type argument in root.GetGenericArguments())
+ {
+ foreach (Type nested in ExpandType(argument))
+ yield return nested;
+ }
+ }
+}
+
+internal static class LoadingDockApiCompileFixture
+{
+ internal static void Observe(
+ PropertyWrapper property,
+ Delivery delivery,
+ LoadingDock dock)
+ {
+ IReadOnlyList propertyDocks = property.LoadingDocks;
+ LoadingDock? selectedDock = delivery.LoadingDock;
+ IReadOnlyList inputSlots = dock.InputSlots;
+ IReadOnlyList outputSlots = dock.OutputSlots;
+ LandVehicle? dynamicOccupant = dock.DynamicOccupant;
+ LandVehicle? staticOccupant = dock.StaticOccupant;
+
+ Action vehicleHandler = (_, _) => { };
+ Action acceptingHandler = (_, _) => { };
+ dock.DynamicOccupantChanged += vehicleHandler;
+ dock.StaticOccupantChanged += vehicleHandler;
+ dock.AcceptingItemsChanged += acceptingHandler;
+ dock.DynamicOccupantChanged -= vehicleHandler;
+ dock.StaticOccupantChanged -= vehicleHandler;
+ dock.AcceptingItemsChanged -= acceptingHandler;
+
+ _ = propertyDocks;
+ _ = selectedDock;
+ _ = inputSlots;
+ _ = outputSlots;
+ _ = dynamicOccupant;
+ _ = staticOccupant;
+ }
+}
diff --git a/S1API.Tests/Entities/AvatarAccessoryDiagnosticTests.cs b/S1API.Tests/Entities/AvatarAccessoryDiagnosticTests.cs
new file mode 100644
index 00000000..2b9590e6
--- /dev/null
+++ b/S1API.Tests/Entities/AvatarAccessoryDiagnosticTests.cs
@@ -0,0 +1,51 @@
+using S1API.Internal.Rendering;
+
+namespace S1API.Tests.Entities;
+
+public sealed class AvatarAccessoryDiagnosticTests
+{
+ [Fact]
+ public void OwnerDescriptionIncludesStableNpcContext()
+ {
+ string description = AvatarAccessoryDiagnostics.FormatOwnerDescription(
+ "Bobby Cooley",
+ "bobby_cooley",
+ "S1API_BobbyCooley(Clone)");
+
+ Assert.Equal(
+ "'Bobby Cooley' (ID='bobby_cooley', GameObject='S1API_BobbyCooley')",
+ description);
+ }
+
+ [Fact]
+ public void OwnerDescriptionUsesExplicitPlaceholdersForMissingIdentity()
+ {
+ string description = AvatarAccessoryDiagnostics.FormatOwnerDescription(null, null, null);
+
+ Assert.Equal(
+ "'' (ID='', GameObject='')",
+ description);
+ }
+
+ [Fact]
+ public void StableOwnerKeyPrefersIdOverMutableObjectContext()
+ {
+ string prefabKey = AvatarAccessoryDiagnostics.SelectStableOwnerKey(
+ "s1api_smoke:accessory_diagnostic",
+ "Accessory",
+ "runtime description");
+
+ Assert.Equal("id:s1api_smoke:accessory_diagnostic", prefabKey);
+ }
+
+ [Fact]
+ public void StableOwnerKeyNormalizesPrefabCloneNameWithoutId()
+ {
+ string prefabKey = AvatarAccessoryDiagnostics.SelectStableOwnerKey(
+ null,
+ "S1API_DiagnosticNpc(Clone)",
+ "runtime description");
+
+ Assert.Equal("prefab:S1API_DiagnosticNpc", prefabKey);
+ }
+}
diff --git a/S1API.Tests/Entities/BuildingLookupPolicyTests.cs b/S1API.Tests/Entities/BuildingLookupPolicyTests.cs
new file mode 100644
index 00000000..d6d1f16e
--- /dev/null
+++ b/S1API.Tests/Entities/BuildingLookupPolicyTests.cs
@@ -0,0 +1,97 @@
+namespace S1API.Tests.Entities;
+
+public sealed class BuildingLookupPolicyTests
+{
+ private const string CasinoObsoleteMessage =
+ "Casino is not an enterable building and cannot be resolved. This compatibility identifier may be removed in a future S1API version.";
+
+ [Fact]
+ public void CasinoIdentifier_RemainsAnObsoleteCompatibilityShim()
+ {
+#pragma warning disable CS0618 // Verify legacy callers can still use the typed building lookup.
+ System.Type casinoType = typeof(global::S1API.Map.Buildings.Casino);
+ System.Func getCasino = global::S1API.Map.Building.Get;
+#pragma warning restore CS0618
+
+ var obsolete = Assert.Single(casinoType.GetCustomAttributes(typeof(System.ObsoleteAttribute), inherit: false));
+ var obsoleteAttribute = Assert.IsType(obsolete);
+
+ Assert.Equal(CasinoObsoleteMessage, obsoleteAttribute.Message);
+ Assert.False(obsoleteAttribute.IsError);
+ Assert.Contains(typeof(global::S1API.Map.Buildings.IBuildingIdentifier), casinoType.GetInterfaces());
+ Assert.NotNull(getCasino);
+ }
+
+ [Theory]
+ [InlineData(true, false, true)]
+ [InlineData(true, true, true)]
+ [InlineData(false, false, true)]
+ [InlineData(false, true, false)]
+ public void TypedBuildingLookup_DefersUntilTheMapIsReady(
+ bool isMenuScene,
+ bool isMainSceneReady,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ global::S1API.Map.Building.ShouldDeferTypedLookup(isMenuScene, isMainSceneReady));
+ }
+}
+
+public sealed class CustomNpcResidenceSummonPolicyTests
+{
+ [Theory]
+ [InlineData(true, true, true, true)]
+ [InlineData(false, true, true, false)]
+ [InlineData(true, false, true, false)]
+ [InlineData(true, true, false, false)]
+ public void SummonCompletion_OnlyExitsAnAuthoritativeCustomNpcThatIsInside(
+ bool isServer,
+ bool isCustomNpc,
+ bool isInsideBuilding,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ global::S1API.Internal.Patches.NPCPatches.ShouldExitCustomNpcAfterSummon(
+ isServer,
+ isCustomNpc,
+ isInsideBuilding));
+ }
+
+ [Theory]
+ [InlineData(true, true, true, true)]
+ [InlineData(false, true, true, false)]
+ [InlineData(true, false, true, false)]
+ [InlineData(true, true, false, false)]
+ public void ResidenceReentry_IsSuppressedOnlyDuringAnAuthoritativeCustomNpcSummon(
+ bool isServer,
+ bool isCustomNpc,
+ bool isSummonBehaviourEnabled,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ global::S1API.Internal.Patches.NPCPatches.ShouldSuppressResidenceReentry(
+ isServer,
+ isCustomNpc,
+ isSummonBehaviourEnabled));
+ }
+
+ [Fact]
+ public void SummonLogicLookup_UsesGeneratedNamePrefixAndNativeSignature()
+ {
+ var method = global::S1API.Internal.Patches.NPCPatches.FindSummonLogicMethod(
+ typeof(SummonLogicFixture));
+
+ Assert.NotNull(method);
+ Assert.Equal(nameof(SummonLogicFixture.RpcLogic___Summon_123), method!.Name);
+ }
+
+ private sealed class SummonLogicFixture
+ {
+ public void RpcLogic___Summon_123(string buildingGuid, int doorIndex, float duration) { }
+
+ public void RpcLogic___Summon_456(string buildingGuid, int doorIndex) { }
+ }
+}
diff --git a/S1API.Tests/Entities/CustomNpcPreparationPolicyTests.cs b/S1API.Tests/Entities/CustomNpcPreparationPolicyTests.cs
new file mode 100644
index 00000000..d1fd10ee
--- /dev/null
+++ b/S1API.Tests/Entities/CustomNpcPreparationPolicyTests.cs
@@ -0,0 +1,40 @@
+using S1API.Internal.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class CustomNpcPreparationPolicyTests
+{
+ [Fact]
+ public void PreparedInstanceIsReusedOnlyForItsExactCustomType()
+ {
+ var first = new FirstCustomNpc();
+ var second = new SecondCustomNpc();
+ object[] instances = { first, second };
+
+ object? result = CustomNpcPreparationPolicy.FindExactType(
+ instances,
+ typeof(SecondCustomNpc));
+
+ Assert.Same(second, result);
+ }
+
+ [Fact]
+ public void MissingPreparedTypeRequiresNewConstruction()
+ {
+ object[] instances = { new FirstCustomNpc() };
+
+ object? result = CustomNpcPreparationPolicy.FindExactType(
+ instances,
+ typeof(SecondCustomNpc));
+
+ Assert.Null(result);
+ }
+
+ private sealed class FirstCustomNpc
+ {
+ }
+
+ private sealed class SecondCustomNpc
+ {
+ }
+}
diff --git a/S1API.Tests/Entities/CustomNpcReadinessCollection.cs b/S1API.Tests/Entities/CustomNpcReadinessCollection.cs
new file mode 100644
index 00000000..9d823238
--- /dev/null
+++ b/S1API.Tests/Entities/CustomNpcReadinessCollection.cs
@@ -0,0 +1,7 @@
+namespace S1API.Tests.Entities;
+
+[CollectionDefinition(Name, DisableParallelization = true)]
+public sealed class CustomNpcReadinessCollection
+{
+ public const string Name = "Custom NPC readiness";
+}
diff --git a/S1API.Tests/Entities/CustomNpcReadinessPolicyTests.cs b/S1API.Tests/Entities/CustomNpcReadinessPolicyTests.cs
new file mode 100644
index 00000000..37d0ea21
--- /dev/null
+++ b/S1API.Tests/Entities/CustomNpcReadinessPolicyTests.cs
@@ -0,0 +1,100 @@
+using S1API.Entities;
+using S1API.Internal.Entities;
+using S1API.Internal.Patches;
+using S1API.Internal.Utils;
+
+namespace S1API.Tests.Entities;
+
+[Collection(CustomNpcReadinessCollection.Name)]
+public sealed class CustomNpcReadinessPolicyTests
+{
+ [Fact]
+ public void ClientHydrationSignalsReadyOnlyAfterEveryCustomNpcTypeCompletes()
+ {
+ NPC.FinalizedCustomNpcTypes.Clear();
+ NPCPatches.CustomNpcsReady = false;
+
+ try
+ {
+ foreach (Type npcType in ReflectionUtils.GetDerivedClasses())
+ {
+ if (npcType.Assembly != typeof(NPC).Assembly
+ && npcType != typeof(DealerNpc)
+ && npcType != typeof(CustomerNpc))
+ {
+ NPC.FinalizedCustomNpcTypes.Add(npcType);
+ }
+ }
+
+ var dealer = TestObjectFactory.CreateUninitialized();
+ var customer = TestObjectFactory.CreateUninitialized();
+
+ dealer.CreateFromClientNetworkSpawn();
+
+ Assert.False(NPC.CustomNpcsReady);
+
+ customer.CreateFromClientNetworkSpawn();
+
+ Assert.True(NPC.CustomNpcsReady);
+ }
+ finally
+ {
+ NPC.FinalizedCustomNpcTypes.Clear();
+ NPCPatches.CustomNpcsReady = false;
+ }
+ }
+
+ [Fact]
+ public void ClientRemainsNotReadyWhileARegisteredTypeIsMissing()
+ {
+ Type[] registeredTypes = { typeof(DealerNpc), typeof(CustomerNpc) };
+ HashSet finalizedTypes = new() { typeof(DealerNpc) };
+
+ bool ready = CustomNpcReadinessPolicy.AreAllTypesFinalized(
+ registeredTypes,
+ finalizedTypes);
+
+ Assert.False(ready);
+ }
+
+ [Fact]
+ public void ClientBecomesReadyAfterEveryRegisteredTypeIsHydrated()
+ {
+ Type[] registeredTypes = { typeof(DealerNpc), typeof(CustomerNpc) };
+ HashSet finalizedTypes = new() { typeof(DealerNpc) };
+
+ CustomNpcReadinessPolicy.MarkFinalized(
+ typeof(CustomerNpc),
+ finalizedTypes);
+
+ bool ready = CustomNpcReadinessPolicy.AreAllTypesFinalized(
+ registeredTypes,
+ finalizedTypes);
+
+ Assert.True(ready);
+ }
+
+ [Fact]
+ public void NoRegisteredTypesDoesNotSignalReady()
+ {
+ bool ready = CustomNpcReadinessPolicy.AreAllTypesFinalized(
+ Array.Empty(),
+ new HashSet());
+
+ Assert.False(ready);
+ }
+
+ private sealed class DealerNpc : NPC
+ {
+ internal override void CreateInternal()
+ {
+ }
+ }
+
+ private sealed class CustomerNpc : NPC
+ {
+ internal override void CreateInternal()
+ {
+ }
+ }
+}
diff --git a/S1API.Tests/Entities/CustomNpcRequestProductPolicyTests.cs b/S1API.Tests/Entities/CustomNpcRequestProductPolicyTests.cs
new file mode 100644
index 00000000..5df0ea41
--- /dev/null
+++ b/S1API.Tests/Entities/CustomNpcRequestProductPolicyTests.cs
@@ -0,0 +1,200 @@
+using System.Reflection;
+using NumericsVector3 = System.Numerics.Vector3;
+using UnityEngine;
+
+namespace S1API.Tests.Entities;
+
+public sealed class CustomNpcRequestProductPolicyTests
+{
+ [Fact]
+ public void BaseEmployeeNavigation_ExcludesPropertyInteriorOnly()
+ {
+ const int baseEmployeeMask = 57;
+ const int propertyInteriorArea = 5;
+
+ int normalizedMask = global::S1API.Entities.NPC.ExcludeNavMeshArea(
+ baseEmployeeMask,
+ propertyInteriorArea);
+
+ int civilianMask = global::S1API.Entities.NPC.IncludeNavMeshArea(normalizedMask, 7);
+
+ Assert.Equal(153, civilianMask);
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(32)]
+ public void BaseEmployeeNavigation_IgnoresInvalidAreaIndices(int areaIndex)
+ {
+ Assert.Equal(57, global::S1API.Entities.NPC.ExcludeNavMeshArea(57, areaIndex));
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(32)]
+ public void BaseEmployeeNavigation_IncludeIgnoresInvalidAreaIndices(int areaIndex)
+ {
+ Assert.Equal(57, global::S1API.Entities.NPC.IncludeNavMeshArea(57, areaIndex));
+ }
+
+ [Fact]
+ public void FollowDestination_KeepsCustomNpcOutsidePlayerSpace()
+ {
+ bool overridden = global::S1API.Internal.Patches.NPCPatches.TryCalculateCustomNpcFollowDestination(
+ isCustomNpc: true,
+ isFollowingPlayer: true,
+ playerPosition: NumericsVector3.Zero,
+ npcPosition: new NumericsVector3(4f, 0f, 0f),
+ fallbackDirection: -NumericsVector3.UnitZ,
+ out NumericsVector3 destination);
+
+ Assert.True(overridden);
+ Assert.Equal(new NumericsVector3(2.5f, 0f, 0f), destination);
+ }
+
+ [Fact]
+ public void FollowDestination_UsesFallbackWhenNpcOverlapsPlayer()
+ {
+ bool overridden = global::S1API.Internal.Patches.NPCPatches.TryCalculateCustomNpcFollowDestination(
+ isCustomNpc: true,
+ isFollowingPlayer: true,
+ playerPosition: NumericsVector3.Zero,
+ npcPosition: NumericsVector3.Zero,
+ fallbackDirection: -NumericsVector3.UnitZ,
+ out NumericsVector3 destination);
+
+ Assert.True(overridden);
+ Assert.Equal(new NumericsVector3(0f, 0f, -2.5f), destination);
+ }
+
+ [Fact]
+ public void FollowDestination_UsesNegativeZWhenNpcAndFallbackDirectionsAreZero()
+ {
+ bool overridden = global::S1API.Internal.Patches.NPCPatches.TryCalculateCustomNpcFollowDestination(
+ isCustomNpc: true,
+ isFollowingPlayer: true,
+ playerPosition: NumericsVector3.Zero,
+ npcPosition: NumericsVector3.Zero,
+ fallbackDirection: NumericsVector3.Zero,
+ out NumericsVector3 destination);
+
+ Assert.True(overridden);
+ Assert.Equal(new NumericsVector3(0f, 0f, -2.5f), destination);
+ }
+
+ [Fact]
+ public void PropertyApproachDestination_UsesOwnedPropertyExteriorSpawnForCustomInitialApproach()
+ {
+ var propertyExteriorSpawn = new NumericsVector3(-67f, 0.7f, 81.5f);
+
+ bool overridden = global::S1API.Internal.Patches.NPCPatches
+ .TryCalculateCustomNpcPropertyApproachDestination(
+ isCustomNpc: true,
+ isInitialApproach: true,
+ playerInsideOwnedProperty: true,
+ propertyExteriorSpawn,
+ out NumericsVector3 destination);
+
+ Assert.True(overridden);
+ Assert.Equal(propertyExteriorSpawn, destination);
+ }
+
+ [Theory]
+ [InlineData(false, true, true, true)]
+ [InlineData(true, false, true, true)]
+ [InlineData(true, true, false, true)]
+ [InlineData(true, true, true, false)]
+ public void PropertyApproachDestination_PreservesNativeDestinationOutsideCustomOwnedPropertyApproach(
+ bool isCustomNpc,
+ bool isInitialApproach,
+ bool playerInsideOwnedProperty,
+ bool hasExteriorSpawnPoint)
+ {
+ NumericsVector3? propertyExteriorSpawn = hasExteriorSpawnPoint
+ ? new NumericsVector3(-67f, 0.7f, 81.5f)
+ : null;
+
+ bool overridden = global::S1API.Internal.Patches.NPCPatches
+ .TryCalculateCustomNpcPropertyApproachDestination(
+ isCustomNpc,
+ isInitialApproach,
+ playerInsideOwnedProperty,
+ propertyExteriorSpawn,
+ out _);
+
+ Assert.False(overridden);
+ }
+
+ [Theory]
+ [InlineData(float.NaN, 0f, 0f)]
+ [InlineData(float.PositiveInfinity, 0f, 0f)]
+ [InlineData(10001f, 0f, 0f)]
+ public void PropertyApproachDestination_RejectsInvalidExteriorSpawn(
+ float x,
+ float y,
+ float z)
+ {
+ bool overridden = global::S1API.Internal.Patches.NPCPatches
+ .TryCalculateCustomNpcPropertyApproachDestination(
+ isCustomNpc: true,
+ isInitialApproach: true,
+ playerInsideOwnedProperty: true,
+ new NumericsVector3(x, y, z),
+ out _);
+
+ Assert.False(overridden);
+ }
+
+ [Theory]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ public void FollowDestination_PreservesNativeBehaviourOutsideCustomFollowPhase(
+ bool isCustomNpc,
+ bool isFollowingPlayer)
+ {
+ bool overridden = global::S1API.Internal.Patches.NPCPatches.TryCalculateCustomNpcFollowDestination(
+ isCustomNpc,
+ isFollowingPlayer,
+ NumericsVector3.Zero,
+ NumericsVector3.UnitX,
+ -NumericsVector3.UnitZ,
+ out _);
+
+ Assert.False(overridden);
+ }
+
+ [Fact]
+ public void DestinationPatchLookup_UsesTheByValueVectorSignature()
+ {
+ MethodBase? method = global::S1API.Internal.Patches.NPCPatches
+ .FindNpcMovementDestinationMethod(typeof(NpcMovementFixture));
+
+ Assert.NotNull(method);
+ Assert.Equal(nameof(NpcMovementFixture.SetDestination), method!.Name);
+ Assert.False(method.GetParameters()[0].ParameterType.IsByRef);
+ }
+
+ [Fact]
+ public void DestinationPatch_AvoidsIl2CppOutParameterMethods()
+ {
+ Type? patchType = typeof(global::S1API.Internal.Patches.NPCPatches).GetNestedType(
+ "RequestProductMovementDestinationPatch",
+ BindingFlags.NonPublic);
+ MethodInfo? prefix = patchType?.GetMethod(
+ "Prefix",
+ BindingFlags.Static | BindingFlags.NonPublic);
+
+ Assert.NotNull(prefix);
+ Assert.Contains(
+ prefix!.GetCustomAttributesData(),
+ attribute => attribute.AttributeType.FullName == "HarmonyLib.HarmonyPrefix");
+ Assert.Null(patchType!.GetMethod("Postfix", BindingFlags.Static | BindingFlags.NonPublic));
+ }
+
+ private sealed class NpcMovementFixture
+ {
+ public void SetDestination(Vector3 destination) { }
+
+ public void SetDestination(Vector3 destination, Action callback) { }
+ }
+}
diff --git a/S1API.Tests/Entities/DealerLifecyclePolicyTests.cs b/S1API.Tests/Entities/DealerLifecyclePolicyTests.cs
new file mode 100644
index 00000000..1ec32d12
--- /dev/null
+++ b/S1API.Tests/Entities/DealerLifecyclePolicyTests.cs
@@ -0,0 +1,148 @@
+using S1API.Entities;
+using S1API.Entities.Relation;
+using S1API.Internal.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class DealerLifecyclePolicyTests
+{
+ [Theory]
+ [InlineData(true, true, true, true)]
+ [InlineData(false, true, true, false)]
+ [InlineData(true, false, true, false)]
+ [InlineData(true, true, false, false)]
+ public void RecruitmentRequiresTheCompleteNativeDealerDialogueSet(
+ bool hasRecruitDialogue,
+ bool hasCollectCashDialogue,
+ bool hasAssignCustomersDialogue,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ NPCDataAccess.HasCompleteDealerDialogueSet(
+ hasRecruitDialogue,
+ hasCollectCashDialogue,
+ hasAssignCustomersDialogue));
+ }
+
+ [Fact]
+ public void DealerDialogueFallbackUsesTheNative046AssetNames()
+ {
+ Assert.Equal("Supplier_Recruitment", NPCDataAccess.DealerRecruitDialogueName);
+ Assert.Equal("Dealer_CollectCash", NPCDataAccess.DealerCollectCashDialogueName);
+ Assert.Equal("Dealer_AssignCustomers", NPCDataAccess.DealerAssignCustomersDialogueName);
+ }
+
+ [Fact]
+ public void DealerDealBehaviourMatchesNativePriority()
+ {
+ Assert.Equal(5, NPCPrefabBuilder.DealerAttendDealPriority);
+ }
+
+ [Fact]
+ public void BehaviourStackObjectsRemainActiveWhileTheirInternalStateIsManaged()
+ {
+ Assert.True(NPCPrefabBuilder.BehaviourObjectsRemainActive);
+ }
+
+ [Theory]
+ [InlineData("DealerHomeEvent", true)]
+ [InlineData("HomeEvent", true)]
+ [InlineData("StayInBuilding", false)]
+ [InlineData("StayInBuilding_1", false)]
+ public void DealerHomeEventNeverAliasesAConsumerScheduleAction(string name, bool expected)
+ {
+ Assert.Equal(expected, NPCPrefabBuilder.IsDealerHomeEventName(name));
+ }
+
+ [Theory]
+ [InlineData(false, true, 0, 0, 0, true)]
+ [InlineData(false, false, 0, 0, 0, false)]
+ [InlineData(true, true, 0, 0, 0, false)]
+ [InlineData(false, true, 1, 0, 0, false)]
+ [InlineData(false, true, 0, 1, 0, false)]
+ [InlineData(false, true, 0, 0, 1, false)]
+ public void OnlyCreatedEmptyLockedDealerConversationsAreHidden(
+ bool relationshipUnlocked,
+ bool uiCreated,
+ int messageCount,
+ int messageChainCount,
+ int responseCount,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ NPCDealer.ShouldHideLockedConversation(
+ relationshipUnlocked,
+ uiCreated,
+ messageCount,
+ messageChainCount,
+ responseCount));
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, true)]
+ public void DealerConversationUiIsCreatedOnlyForUnlockedRelationships(
+ bool relationshipUnlocked,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ NPCDealer.ShouldEnsureConversationUi(relationshipUnlocked));
+ }
+
+ [Fact]
+ public void ConnectionIdsAreStableAcrossSpawnOrderReconciliation()
+ {
+ IReadOnlyList ids = NPCRelationshipDataBuilder.NormalizeConnectionIds(
+ new[] { " thomas_elis ", "", "THOMAS_ELIS", "gennaro_salvadore" });
+
+ Assert.Equal(new[] { "thomas_elis", "gennaro_salvadore" }, ids);
+ }
+
+ [Fact]
+ public void RelationshipReconciliationTreatsOneSidedDeclarationsAsUndirected()
+ {
+ var declarations = new Dictionary>(
+ StringComparer.OrdinalIgnoreCase)
+ {
+ ["dealer_a"] = new[] { "dealer_b" },
+ ["dealer_b"] = new[] { "customer_c" },
+ ["customer_c"] = Array.Empty()
+ };
+
+ Assert.Equal(
+ new[] { "customer_c", "dealer_a" },
+ NPCRelationshipGraphPolicy.BuildUndirectedConnectionIds("dealer_b", declarations));
+ Assert.Equal(
+ new[] { "dealer_b" },
+ NPCRelationshipGraphPolicy.BuildUndirectedConnectionIds("customer_c", declarations));
+ }
+
+ [Fact]
+ public void ExplicitlyEmptyConnectionsRemainConfiguredForStaleGraphRemoval()
+ {
+ var builder = new NPCRelationshipDataBuilder()
+ .WithConnectionsById(Array.Empty());
+
+ NPCRelationshipDataBuilder.RelationshipDefaultsData snapshot = builder.CaptureData();
+
+ Assert.True(snapshot.ConnectionsConfigured);
+ Assert.Empty(snapshot.ConnectionIDs!);
+ }
+
+ [Theory]
+ [InlineData(true, false, true)]
+ [InlineData(false, true, true)]
+ [InlineData(false, false, false)]
+ public void ContactIconReadinessIsTrackedPerNpc(
+ bool hasExplicitIcon,
+ bool generationCompleted,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ NPCAppearance.IsMugshotReady(hasExplicitIcon, generationCompleted));
+ }
+}
diff --git a/S1API.Tests/Entities/MugshotCapturePolicyTests.cs b/S1API.Tests/Entities/MugshotCapturePolicyTests.cs
new file mode 100644
index 00000000..a4f50ed5
--- /dev/null
+++ b/S1API.Tests/Entities/MugshotCapturePolicyTests.cs
@@ -0,0 +1,29 @@
+using S1API.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class MugshotCapturePolicyTests
+{
+ [Theory]
+ [InlineData(500, 4096, 0.60f, 0.90f, true)]
+ [InlineData(500, 4096, 0.60f, 0.80f, false)]
+ [InlineData(200, 4096, 0.60f, 0.90f, false)]
+ [InlineData(500, 4096, 0.30f, 0.90f, false)]
+ [InlineData(500, 4096, 0.60f, 0.40f, false)]
+ [InlineData(500, 0, 0.60f, 0.80f, false)]
+ public void PortraitCoverageRequiresSubstantialVisibleBounds(
+ int visibleSamples,
+ int totalSamples,
+ float contentWidth,
+ float contentHeight,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ NPCAppearance.IsPortraitCoverageSufficient(
+ visibleSamples,
+ totalSamples,
+ contentWidth,
+ contentHeight));
+ }
+}
diff --git a/S1API.Tests/Entities/NPCAwarenessApiTests.cs b/S1API.Tests/Entities/NPCAwarenessApiTests.cs
new file mode 100644
index 00000000..d601461e
--- /dev/null
+++ b/S1API.Tests/Entities/NPCAwarenessApiTests.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Reflection;
+using S1API.Entities;
+using S1API.Vehicles;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCAwarenessApiTests
+{
+ [Theory]
+ [InlineData(nameof(NPCNoiseEvent.Origin), "UnityEngine.Vector3")]
+ [InlineData(nameof(NPCNoiseEvent.Range), "System.Single")]
+ [InlineData(nameof(NPCNoiseEvent.Type), "S1API.Entities.NPCNoiseType")]
+ [InlineData(nameof(NPCNoiseEvent.Source), "UnityEngine.GameObject")]
+ [InlineData(nameof(NPCNoiseEvent.OriginInSewer), "System.Boolean")]
+ public void NoiseSnapshotPropertiesAreReadOnly(string propertyName, string propertyTypeName)
+ {
+ PropertyInfo? property = typeof(NPCNoiseEvent).GetProperty(propertyName);
+
+ Assert.NotNull(property);
+ Assert.Equal(propertyTypeName, property!.PropertyType.FullName);
+ Assert.False(property.CanWrite);
+ }
+
+ [Fact]
+ public void NoiseTypesRetainNativeValues()
+ {
+ Assert.Equal(0, (int)NPCNoiseType.Footstep);
+ Assert.Equal(1, (int)NPCNoiseType.Gunshot);
+ Assert.Equal(2, (int)NPCNoiseType.Explosion);
+ }
+
+ [Theory]
+ [InlineData(nameof(NPC.OnNoticedDrugDealing), typeof(Player))]
+ [InlineData(nameof(NPC.OnNoticedGeneralCrime), typeof(Player))]
+ [InlineData(nameof(NPC.OnNoticedPettyCrime), typeof(Player))]
+ [InlineData(nameof(NPC.OnNoticedPlayerViolatingCurfew), typeof(Player))]
+ [InlineData(nameof(NPC.OnNoticedSuspiciousPlayer), typeof(Player))]
+ [InlineData(nameof(NPC.OnGunshotHeard), typeof(NPCNoiseEvent))]
+ [InlineData(nameof(NPC.OnExplosionHeard), typeof(NPCNoiseEvent))]
+ [InlineData(nameof(NPC.OnHitByCar), typeof(LandVehicle))]
+ public void AwarenessEventsExposeManagedArguments(string eventName, Type argumentType)
+ {
+ EventInfo? eventInfo = typeof(NPC).GetEvent(eventName);
+
+ Assert.NotNull(eventInfo);
+ Assert.Equal(typeof(Action<>).MakeGenericType(argumentType), eventInfo!.EventHandlerType);
+ }
+}
+
+internal static class NPCAwarenessApiCompileFixture
+{
+ internal static void SubscribeAndUnsubscribe(NPC npc)
+ {
+ Action playerHandler = _ => { };
+ Action noiseHandler = _ => { };
+ Action vehicleHandler = _ => { };
+
+ npc.OnNoticedDrugDealing += playerHandler;
+ npc.OnNoticedGeneralCrime += playerHandler;
+ npc.OnNoticedPettyCrime += playerHandler;
+ npc.OnNoticedPlayerViolatingCurfew += playerHandler;
+ npc.OnNoticedSuspiciousPlayer += playerHandler;
+ npc.OnGunshotHeard += noiseHandler;
+ npc.OnExplosionHeard += noiseHandler;
+ npc.OnHitByCar += vehicleHandler;
+
+ npc.OnNoticedDrugDealing -= playerHandler;
+ npc.OnNoticedGeneralCrime -= playerHandler;
+ npc.OnNoticedPettyCrime -= playerHandler;
+ npc.OnNoticedPlayerViolatingCurfew -= playerHandler;
+ npc.OnNoticedSuspiciousPlayer -= playerHandler;
+ npc.OnGunshotHeard -= noiseHandler;
+ npc.OnExplosionHeard -= noiseHandler;
+ npc.OnHitByCar -= vehicleHandler;
+ }
+}
diff --git a/S1API.Tests/Entities/NPCDiagnosticCompatibilityTests.cs b/S1API.Tests/Entities/NPCDiagnosticCompatibilityTests.cs
new file mode 100644
index 00000000..34761681
--- /dev/null
+++ b/S1API.Tests/Entities/NPCDiagnosticCompatibilityTests.cs
@@ -0,0 +1,73 @@
+using System.Reflection;
+using S1API.Entities;
+using S1API.Entities.Dealer;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCDiagnosticCompatibilityTests
+{
+ private const string RegistrationObsoleteMessage =
+ "S1API automatically pre-registers NPC prefabs. Remove this call.";
+
+ [Theory]
+ [InlineData("PreRegisterAllNpcPrefabs")]
+ [InlineData("PreRegisterPrefabForType")]
+ public void ManualPrefabRegistrationApisRetainTheirPublicShape(string methodName)
+ {
+ MethodInfo? method = typeof(NPC).GetMethod(methodName, BindingFlags.Public | BindingFlags.Static);
+
+ Assert.NotNull(method);
+ Assert.True(method.IsStatic);
+ Assert.Equal(typeof(void), method.ReturnType);
+ ObsoleteAttribute obsolete = Assert.Single(method.GetCustomAttributes());
+ Assert.Equal(RegistrationObsoleteMessage, obsolete.Message);
+ Assert.False(obsolete.IsError);
+
+ ParameterInfo[] parameters = method.GetParameters();
+ if (methodName == "PreRegisterAllNpcPrefabs")
+ {
+ Assert.Empty(parameters);
+ }
+ else
+ {
+ ParameterInfo parameter = Assert.Single(parameters);
+ Assert.Equal("npcType", parameter.Name);
+ Assert.Equal(typeof(Type), parameter.ParameterType);
+ }
+ }
+
+ [Fact]
+ public void DealerDefaultsDoNotReportUnsupportedOptionsWhenTheyWereOmitted()
+ {
+ var data = new DealerDataBuilder().BuildInternal();
+
+ Assert.False(data.InsufficientQualityConfigured);
+ Assert.False(data.ExcessQualityConfigured);
+ Assert.False(data.CompletedDealsVariableConfigured);
+ }
+
+ [Fact]
+ public void ReviveRetainsItsPublicShape()
+ {
+ MethodInfo? method = typeof(NPC).GetMethod(nameof(NPC.Revive), BindingFlags.Public | BindingFlags.Instance);
+
+ Assert.NotNull(method);
+ Assert.Equal(typeof(void), method.ReturnType);
+ Assert.Empty(method.GetParameters());
+ }
+
+ [Fact]
+ public void DealerDefaultsRememberEveryExplicitUnsupportedOption()
+ {
+ var data = new DealerDataBuilder()
+ .AllowInsufficientQuality(false)
+ .AllowExcessQuality(true)
+ .WithCompletedDealsVariable(null!)
+ .BuildInternal();
+
+ Assert.True(data.InsufficientQualityConfigured);
+ Assert.True(data.ExcessQualityConfigured);
+ Assert.True(data.CompletedDealsVariableConfigured);
+ Assert.Equal(string.Empty, data.CompletedDealsVariable);
+ }
+}
diff --git a/S1API.Tests/Entities/NPCDialogueApiCompatibilityTests.cs b/S1API.Tests/Entities/NPCDialogueApiCompatibilityTests.cs
new file mode 100644
index 00000000..3a021cce
--- /dev/null
+++ b/S1API.Tests/Entities/NPCDialogueApiCompatibilityTests.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Linq;
+using System.Reflection;
+using S1API.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCDialogueApiCompatibilityTests
+{
+ [Fact]
+ public void DialogueCompletionCallbackIsAdditiveAndFluent()
+ {
+ MethodInfo? method = typeof(NPCDialogue).GetMethod(
+ nameof(NPCDialogue.OnDialogueEnded),
+ BindingFlags.Public | BindingFlags.Instance,
+ null,
+ new[] { typeof(Action) },
+ null);
+
+ Assert.NotNull(method);
+ Assert.Equal(typeof(NPCDialogue), method!.ReturnType);
+ Assert.Equal("callback", method.GetParameters()[0].Name);
+ }
+
+ [Fact]
+ public void NamedChoiceStateSetterUsesOnlyManagedArguments()
+ {
+ MethodInfo? method = typeof(NPCDialogue).GetMethod(
+ nameof(NPCDialogue.SetChoiceEnabled),
+ BindingFlags.Public | BindingFlags.Instance,
+ null,
+ new[] { typeof(string), typeof(bool) },
+ null);
+
+ Assert.NotNull(method);
+ Assert.Equal(typeof(bool), method!.ReturnType);
+ Assert.Equal(
+ new[] { "dialogueContainerName", "enabled" },
+ method.GetParameters().Select(parameter => parameter.Name).ToArray());
+ Assert.DoesNotContain(
+ method.GetParameters(),
+ parameter => parameter.ParameterType.FullName?.Contains("ScheduleOne", StringComparison.Ordinal) == true);
+ }
+
+ [Fact]
+ public void ExistingDialogueCallbackMembersRemainAvailable()
+ {
+ Assert.NotNull(typeof(NPCDialogue).GetMethod(nameof(NPCDialogue.OnChoiceSelected)));
+ Assert.NotNull(typeof(NPCDialogue).GetMethod(nameof(NPCDialogue.OnNodeDisplayed)));
+ Assert.NotNull(typeof(NPCDialogue).GetMethod(nameof(NPCDialogue.OnConversationStart)));
+ Assert.NotNull(typeof(NPCDialogue).GetMethod(nameof(NPCDialogue.ClearCallbacks)));
+ }
+}
+
+internal static class NPCDialogueApiCompileFixture
+{
+ internal static NPCDialogue RegisterCallbacks(NPCDialogue dialogue, Action callback)
+ {
+ return dialogue
+ .OnChoiceSelected("CHOICE", callback)
+ .OnNodeDisplayed("NODE", callback)
+ .OnConversationStart(callback)
+ .OnDialogueEnded(callback);
+ }
+
+ internal static bool SetChoiceState(NPCDialogue dialogue, bool enabled) =>
+ dialogue.SetChoiceEnabled("ShopDialogue", enabled);
+}
diff --git a/S1API.Tests/Entities/NPCDialogueDatabasePolicyTests.cs b/S1API.Tests/Entities/NPCDialogueDatabasePolicyTests.cs
new file mode 100644
index 00000000..6f57e659
--- /dev/null
+++ b/S1API.Tests/Entities/NPCDialogueDatabasePolicyTests.cs
@@ -0,0 +1,18 @@
+using S1API.Internal.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCDialogueDatabasePolicyTests
+{
+ [Fact]
+ public void EmployeeFallbackDoesNotReuseEmployeeDialogueDatabase()
+ {
+ Assert.False(NPCDataAccess.ShouldReuseSourceDialogueDatabase(sourceIsEmployee: true));
+ }
+
+ [Fact]
+ public void NonEmployeeSourceRetainsItsDialogueDatabase()
+ {
+ Assert.True(NPCDataAccess.ShouldReuseSourceDialogueDatabase(sourceIsEmployee: false));
+ }
+}
diff --git a/S1API.Tests/Entities/NPCDialogueEventPolicyTests.cs b/S1API.Tests/Entities/NPCDialogueEventPolicyTests.cs
new file mode 100644
index 00000000..819e51a0
--- /dev/null
+++ b/S1API.Tests/Entities/NPCDialogueEventPolicyTests.cs
@@ -0,0 +1,53 @@
+using S1API.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCDialogueEventPolicyTests
+{
+ [Theory]
+ [InlineData("ShopDialogue", "shopdialogue", true)]
+ [InlineData("ShopDialogue", "OtherDialogue", false)]
+ [InlineData("", "ShopDialogue", false)]
+ [InlineData("ShopDialogue", "", false)]
+ [InlineData(null, "ShopDialogue", false)]
+ public void NamedChoiceContainerMatchingIsCaseInsensitiveAndSafe(
+ string? candidateName,
+ string? requestedName,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ NPCDialoguePolicy.MatchesChoiceContainer(candidateName, requestedName));
+ }
+
+ [Fact]
+ public void KeyedCallbacksAllowDuplicateRegistrationAndClearAllCallbacks()
+ {
+ var callbacks = new NPCDialogueCallbackRegistry();
+ int invocationCount = 0;
+ Action callback = () => invocationCount++;
+
+ callbacks.Add("NODE", callback);
+ callbacks.Add("node", callback);
+ callbacks.Invoke("NoDe");
+
+ Assert.Equal(2, invocationCount);
+
+ callbacks.Clear();
+ callbacks.Invoke("NODE");
+ Assert.Equal(2, invocationCount);
+ }
+
+ [Fact]
+ public void OneCallbackFailureDoesNotSuppressLaterCallbacks()
+ {
+ var callbacks = new NPCDialogueCallbackRegistry();
+ int invocationCount = 0;
+
+ callbacks.Add("NODE", () => throw new InvalidOperationException("expected"));
+ callbacks.Add("NODE", () => invocationCount++);
+ callbacks.Invoke("NODE");
+
+ Assert.Equal(1, invocationCount);
+ }
+}
diff --git a/S1API.Tests/Entities/NPCHealthRevivePolicyTests.cs b/S1API.Tests/Entities/NPCHealthRevivePolicyTests.cs
new file mode 100644
index 00000000..6b38951a
--- /dev/null
+++ b/S1API.Tests/Entities/NPCHealthRevivePolicyTests.cs
@@ -0,0 +1,42 @@
+using S1API.Internal.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCHealthRevivePolicyTests
+{
+ [Theory]
+ [InlineData(true, true, false, true)]
+ [InlineData(true, true, true, false)]
+ [InlineData(true, false, false, false)]
+ [InlineData(false, true, false, false)]
+ public void PreSpawnFallbackOnlyAppliesToUnspawnedCustomNPCsInMain(
+ bool isInMainScene,
+ bool isCustomNpc,
+ bool isSpawned,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ NPCHealthRevivePolicy.ShouldUsePreSpawnFallback(
+ isInMainScene,
+ isCustomNpc,
+ isSpawned));
+ }
+
+ [Theory]
+ [InlineData(true, true, false)]
+ [InlineData(true, false, true)]
+ [InlineData(false, true, false)]
+ [InlineData(false, false, false)]
+ public void SpawnedClientReviveIsSuppressed(
+ bool isSpawned,
+ bool isServer,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ NPCHealthRevivePolicy.ShouldSuppressSpawnedClientRevive(
+ isSpawned,
+ isServer));
+ }
+}
diff --git a/S1API.Tests/Entities/NPCInventoryPersistencePolicyTests.cs b/S1API.Tests/Entities/NPCInventoryPersistencePolicyTests.cs
new file mode 100644
index 00000000..cd7c6111
--- /dev/null
+++ b/S1API.Tests/Entities/NPCInventoryPersistencePolicyTests.cs
@@ -0,0 +1,115 @@
+using S1API.Internal.Patches;
+using S1API.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCInventoryPersistencePolicyTests
+{
+ [Fact]
+ public void SavedInventoryRestoresAfterSlotInitialization()
+ {
+ var calls = new List();
+
+ NPCPatches.RestoreInventoryAfterInitialization(
+ () => calls.Add("initialize"),
+ () => calls.Add("restore"));
+
+ Assert.Equal(new[] { "initialize", "restore" }, calls);
+ }
+
+ [Fact]
+ public void AlreadyAwakeInventoryStillRestoresExactlyOnce()
+ {
+ bool awakeCompleted = true;
+ int initializationCount = 0;
+ int restoreCount = 0;
+
+ NPCPatches.RestoreInventoryAfterInitialization(
+ () =>
+ {
+ Assert.True(awakeCompleted);
+ initializationCount++;
+ },
+ () =>
+ {
+ Assert.True(awakeCompleted);
+ restoreCount++;
+ });
+
+ Assert.Equal(1, initializationCount);
+ Assert.Equal(1, restoreCount);
+ }
+
+ [Fact]
+ public void FailedInitializationDoesNotAttemptRestore()
+ {
+ bool restoreAttempted = false;
+
+ Assert.Throws(() =>
+ NPCPatches.RestoreInventoryAfterInitialization(
+ () => throw new InvalidOperationException("initialization failed"),
+ () => restoreAttempted = true));
+
+ Assert.False(restoreAttempted);
+ }
+
+ [Fact]
+ public void CurrentNpcDataSlotCountIsUsedWhenLegacyMemberIsMissing()
+ {
+ int result = NPCInventory.ResolveTargetSlotCount(
+ legacySlotCount: null,
+ npcDataSlotCount: 5,
+ fallback: 0,
+ isCustomNpc: true);
+
+ Assert.Equal(5, result);
+ }
+
+ [Fact]
+ public void LegacySlotCountRemainsPreferredForOlderGameVersions()
+ {
+ int result = NPCInventory.ResolveTargetSlotCount(
+ legacySlotCount: 6,
+ npcDataSlotCount: 5,
+ fallback: 0,
+ isCustomNpc: true);
+
+ Assert.Equal(6, result);
+ }
+
+ [Fact]
+ public void ExistingCollectionCountIsFinalFallback()
+ {
+ int result = NPCInventory.ResolveTargetSlotCount(
+ legacySlotCount: -1,
+ npcDataSlotCount: null,
+ fallback: 4,
+ isCustomNpc: true);
+
+ Assert.Equal(4, result);
+ }
+
+ [Fact]
+ public void CustomNpcUsesVanillaFiveSlotDefaultWhenNativeCountsAreZero()
+ {
+ int result = NPCInventory.ResolveTargetSlotCount(
+ legacySlotCount: null,
+ npcDataSlotCount: 0,
+ fallback: 0,
+ isCustomNpc: true);
+
+ Assert.Equal(5, result);
+ }
+
+ [Fact]
+ public void BaseNpcCanRetainAnIntentionallyEmptyInventory()
+ {
+ int result = NPCInventory.ResolveTargetSlotCount(
+ legacySlotCount: null,
+ npcDataSlotCount: 0,
+ fallback: 0,
+ isCustomNpc: false);
+
+ Assert.Equal(0, result);
+ }
+}
diff --git a/S1API.Tests/Entities/NPCPanicApiCompatibilityTests.cs b/S1API.Tests/Entities/NPCPanicApiCompatibilityTests.cs
new file mode 100644
index 00000000..ec0592a5
--- /dev/null
+++ b/S1API.Tests/Entities/NPCPanicApiCompatibilityTests.cs
@@ -0,0 +1,43 @@
+#if IL2CPPMELON
+using NativeNpc = Il2CppScheduleOne.NPCs.NPC;
+#elif MONOMELON
+using NativeNpc = ScheduleOne.NPCs.NPC;
+#endif
+
+using System.Reflection;
+using S1API.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCPanicApiCompatibilityTests
+{
+ [Fact]
+ public void PanicRetainsItsParameterlessManagedSurface()
+ {
+ MethodInfo? method = typeof(NPC).GetMethod(
+ nameof(NPC.Panic),
+ BindingFlags.Public | BindingFlags.Instance,
+ binder: null,
+ types: Type.EmptyTypes,
+ modifiers: null);
+
+ Assert.NotNull(method);
+ Assert.Equal(typeof(void), method!.ReturnType);
+ Assert.Empty(method.GetParameters());
+ }
+
+ [Fact]
+ public void NativePanicRpcRemainsParameterless()
+ {
+ MethodInfo? method = typeof(NativeNpc).GetMethod(
+ "SetPanicked_Server",
+ BindingFlags.Public | BindingFlags.Instance,
+ binder: null,
+ types: Type.EmptyTypes,
+ modifiers: null);
+
+ Assert.NotNull(method);
+ Assert.Equal(typeof(void), method!.ReturnType);
+ Assert.Empty(method.GetParameters());
+ }
+}
diff --git a/S1API.Tests/Entities/NPCPersistentIdsTests.cs b/S1API.Tests/Entities/NPCPersistentIdsTests.cs
new file mode 100644
index 00000000..944478a8
--- /dev/null
+++ b/S1API.Tests/Entities/NPCPersistentIdsTests.cs
@@ -0,0 +1,34 @@
+using S1API.Internal.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCPersistentIdsTests
+{
+ [Fact]
+ public void IdentityIdProducesTheSameGuidAcrossConstructionPaths()
+ {
+ Assert.True(NPCPersistentIds.TryGetGuid("mod.author:custom_npc", out Guid first));
+ Assert.True(NPCPersistentIds.TryGetGuid(" MOD.AUTHOR:CUSTOM_NPC ", out Guid second));
+
+ Assert.Equal(first, second);
+ Assert.NotEqual(Guid.Empty, first);
+ }
+
+ [Fact]
+ public void DifferentIdentityIdsProduceDifferentGuids()
+ {
+ NPCPersistentIds.TryGetGuid("mod.author:customer_a", out Guid first);
+ NPCPersistentIds.TryGetGuid("mod.author:customer_b", out Guid second);
+
+ Assert.NotEqual(first, second);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void MissingIdentityIdDoesNotProducePersistentGuid(string id)
+ {
+ Assert.False(NPCPersistentIds.TryGetGuid(id, out Guid guid));
+ Assert.Equal(Guid.Empty, guid);
+ }
+}
diff --git a/S1API.Tests/Entities/NPCRegionUnlockCompatibilityTests.cs b/S1API.Tests/Entities/NPCRegionUnlockCompatibilityTests.cs
new file mode 100644
index 00000000..a2902127
--- /dev/null
+++ b/S1API.Tests/Entities/NPCRegionUnlockCompatibilityTests.cs
@@ -0,0 +1,56 @@
+using System.Reflection;
+using S1API.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCRegionUnlockCompatibilityTests
+{
+ [Fact]
+ public void RequiresRegionUnlockedRetainsItsPublicSurface()
+ {
+ PropertyInfo? property = typeof(NPC).GetProperty(
+ nameof(NPC.RequiresRegionUnlocked),
+ BindingFlags.Public | BindingFlags.Instance);
+
+ Assert.NotNull(property);
+ Assert.Equal(typeof(bool), property!.PropertyType);
+ Assert.True(property.CanRead);
+ Assert.True(property.CanWrite);
+ }
+
+ [Fact]
+ public void OptionalRegionUnlockMemberSupportsFieldAndPropertyShapes()
+ {
+ var fieldShape = new FieldShape();
+ var propertyShape = new PropertyShape();
+
+ Assert.True(NPC.TrySetRequiresRegionUnlocked(fieldShape, false));
+ Assert.True(NPC.TrySetRequiresRegionUnlocked(propertyShape, false));
+ Assert.False(NPC.ResolveRequiresRegionUnlocked(fieldShape));
+ Assert.False(NPC.ResolveRequiresRegionUnlocked(propertyShape));
+ }
+
+ [Fact]
+ public void MissingRegionUnlockMemberUsesDefaultAndIgnoresWrites()
+ {
+ var missingShape = new MissingShape();
+
+ Assert.True(NPC.ResolveRequiresRegionUnlocked(missingShape));
+ Assert.False(NPC.TrySetRequiresRegionUnlocked(missingShape, false));
+ Assert.True(NPC.ResolveRequiresRegionUnlocked(missingShape));
+ }
+
+ private sealed class FieldShape
+ {
+ public bool RequiresRegionUnlocked = true;
+ }
+
+ private sealed class PropertyShape
+ {
+ public bool RequiresRegionUnlocked { get; set; } = true;
+ }
+
+ private sealed class MissingShape
+ {
+ }
+}
diff --git a/S1API.Tests/Entities/NPCRelationshipPersistencePolicyTests.cs b/S1API.Tests/Entities/NPCRelationshipPersistencePolicyTests.cs
new file mode 100644
index 00000000..7e5e2e3e
--- /dev/null
+++ b/S1API.Tests/Entities/NPCRelationshipPersistencePolicyTests.cs
@@ -0,0 +1,42 @@
+using S1API.Internal.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCRelationshipPersistencePolicyTests
+{
+ [Theory]
+ [InlineData(0f)]
+ [InlineData(1.5f)]
+ [InlineData(2f)]
+ [InlineData(3.5f)]
+ [InlineData(5f)]
+ public void FiniteSavedDeltasAreAuthoritative(float relationDelta)
+ {
+ Assert.True(
+ NPCRelationshipPersistencePolicy.IsValidSavedDelta(relationDelta));
+ }
+
+ [Fact]
+ public void NonFiniteSavedDeltasAreRejected()
+ {
+ Assert.False(
+ NPCRelationshipPersistencePolicy.IsValidSavedDelta(float.NaN));
+ Assert.False(
+ NPCRelationshipPersistencePolicy.IsValidSavedDelta(float.PositiveInfinity));
+ Assert.False(
+ NPCRelationshipPersistencePolicy.IsValidSavedDelta(float.NegativeInfinity));
+ }
+
+ [Theory]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ public void DefaultsApplyOnlyWithoutSavedRelationshipState(
+ bool relationshipLoadedFromSave,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ NPCRelationshipPersistencePolicy.ShouldApplyDefaults(
+ relationshipLoadedFromSave));
+ }
+}
diff --git a/S1API.Tests/Entities/NPCRoleDeclarationTests.cs b/S1API.Tests/Entities/NPCRoleDeclarationTests.cs
new file mode 100644
index 00000000..05b1e700
--- /dev/null
+++ b/S1API.Tests/Entities/NPCRoleDeclarationTests.cs
@@ -0,0 +1,160 @@
+using System.Reflection;
+using S1API.Entities;
+using S1API.Internal.Entities;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCRoleDeclarationTests
+{
+ [Fact]
+ public void IsCustomerIsVirtualReadOnlyBooleanDefaultingToFalse()
+ {
+ PropertyInfo? property = typeof(NPC).GetProperty(nameof(NPC.IsCustomer));
+ MethodInfo? getter = property?.GetMethod;
+ var npc = (NPC)System.Runtime.CompilerServices.RuntimeHelpers
+ .GetUninitializedObject(typeof(RoleTestNpc));
+
+ Assert.NotNull(property);
+ Assert.Equal(typeof(bool), property!.PropertyType);
+ Assert.True(getter!.IsVirtual);
+ Assert.False(getter.IsFinal);
+ Assert.Null(property.SetMethod);
+ Assert.False(npc.IsCustomer);
+ }
+
+ [Fact]
+ public void DeclaredPropertiesRejectsNullType()
+ {
+ ArgumentNullException exception = Assert.Throws(
+ () => NpcRoleDeclarationResolver.GetDeclaredProperties(null!));
+
+ Assert.Equal("npcType", exception.ParamName);
+ }
+
+ [Fact]
+ public void DeclaredPropertiesRejectsNonNpcType()
+ {
+ ArgumentException exception = Assert.Throws(
+ () => NpcRoleDeclarationResolver.GetDeclaredProperties(typeof(string)));
+
+ Assert.Equal("npcType", exception.ParamName);
+ Assert.Contains("does not derive from", exception.Message);
+ }
+
+ [Theory]
+ [InlineData(false, false, false, false)]
+ [InlineData(false, true, false, false)]
+ [InlineData(true, false, false, false)]
+ [InlineData(true, true, false, false)]
+ [InlineData(true, false, true, false)]
+ [InlineData(true, true, true, false)]
+ [InlineData(true, false, false, true)]
+ [InlineData(true, true, false, true)]
+ public void ValidRoleCombinationsRemainComposable(
+ bool isPhysical,
+ bool isCustomer,
+ bool isDealer,
+ bool isSupplier)
+ {
+ var declaration = new NpcRoleDeclaration(
+ isPhysical,
+ isCustomer,
+ isDealer,
+ isSupplier);
+
+ NpcRoleDeclaration validated = declaration.Validate(typeof(NPC));
+
+ Assert.Equal(isCustomer, validated.IsCustomer);
+ Assert.Equal(isDealer, validated.IsDealer);
+ Assert.Equal(isSupplier, validated.IsSupplier);
+ }
+
+ [Fact]
+ public void DealerAndSupplierDeclarationFailsEarly()
+ {
+ var declaration = new NpcRoleDeclaration(
+ isPhysical: true,
+ isCustomer: false,
+ isDealer: true,
+ isSupplier: true);
+
+ InvalidOperationException exception = Assert.Throws(
+ () => declaration.Validate(typeof(NPC)));
+
+ Assert.Contains("cannot be both a dealer and a supplier", exception.Message);
+ }
+
+ [Fact]
+ public void NonPhysicalSupplierDeclarationFailsEarly()
+ {
+ var declaration = new NpcRoleDeclaration(
+ isPhysical: false,
+ isCustomer: false,
+ isDealer: false,
+ isSupplier: true);
+
+ InvalidOperationException exception = Assert.Throws(
+ () => declaration.Validate(typeof(NPC)));
+
+ Assert.Contains("must override IsPhysical to return true", exception.Message);
+ }
+
+ [Fact]
+ public void CompatibilityDeclarationsOnlyAddLegacyCapabilities()
+ {
+ var properties = new NpcRoleDeclaration(
+ isPhysical: true,
+ isCustomer: false,
+ isDealer: false,
+ isSupplier: false);
+
+ NpcRoleDeclaration effective = properties
+ .WithCompatibilityRoles(
+ isCustomer: true,
+ isDealer: true,
+ isSupplier: false)
+ .Validate(typeof(NPC));
+
+ Assert.True(effective.IsPhysical);
+ Assert.True(effective.IsCustomer);
+ Assert.True(effective.IsDealer);
+ Assert.False(effective.IsSupplier);
+ Assert.Equal(NpcRootRole.Dealer, effective.RootRole);
+ }
+
+ [Theory]
+ [InlineData(nameof(NPCPrefabBuilder.EnsureCustomer), nameof(NPC.IsCustomer))]
+ [InlineData(nameof(NPCPrefabBuilder.EnsureDealer), nameof(NPC.IsDealer))]
+ [InlineData(nameof(NPCPrefabBuilder.EnsureSupplier), nameof(NPC.IsSupplier))]
+ public void LegacyEnsureMethodsRemainFluentNonErrorObsoleteShims(
+ string methodName,
+ string replacementProperty)
+ {
+ MethodInfo? method = typeof(NPCPrefabBuilder).GetMethod(
+ methodName,
+ BindingFlags.Public | BindingFlags.Instance,
+ binder: null,
+ types: Type.EmptyTypes,
+ modifiers: null);
+ ObsoleteAttribute? obsolete = method?.GetCustomAttribute();
+
+ Assert.NotNull(method);
+ Assert.Equal(typeof(NPCPrefabBuilder), method!.ReturnType);
+ Assert.Empty(method.GetParameters());
+ Assert.NotNull(obsolete);
+ Assert.False(obsolete!.IsError);
+ Assert.Contains(replacementProperty, obsolete.Message);
+ }
+
+#pragma warning disable CS0618
+ private static NPCPrefabBuilder CompileLegacyFluentCalls(NPCPrefabBuilder builder) =>
+ builder.EnsureCustomer().EnsureDealer().EnsureSupplier();
+#pragma warning restore CS0618
+
+ private sealed class RoleTestNpc : NPC
+ {
+ internal override void CreateInternal()
+ {
+ }
+ }
+}
diff --git a/S1API.Tests/Entities/NPCVehicleLifecycleApiTests.cs b/S1API.Tests/Entities/NPCVehicleLifecycleApiTests.cs
new file mode 100644
index 00000000..12e88faf
--- /dev/null
+++ b/S1API.Tests/Entities/NPCVehicleLifecycleApiTests.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Reflection;
+using S1API.Entities;
+using S1API.Vehicles;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NPCVehicleLifecycleApiTests
+{
+ [Theory]
+ [InlineData(nameof(NPC.OnEnterVehicle))]
+ [InlineData(nameof(NPC.OnExitVehicle))]
+ public void VehicleLifecycleEventsExposeManagedVehicleArguments(string eventName)
+ {
+ EventInfo? eventInfo = typeof(NPC).GetEvent(eventName);
+
+ Assert.NotNull(eventInfo);
+ Assert.Equal(typeof(Action), eventInfo!.EventHandlerType);
+ }
+}
+
+internal static class NPCVehicleLifecycleApiCompileFixture
+{
+ internal static void SubscribeAndUnsubscribe(NPC npc)
+ {
+ Action handler = _ => { };
+
+ npc.OnEnterVehicle += handler;
+ npc.OnExitVehicle += handler;
+ npc.OnEnterVehicle -= handler;
+ npc.OnExitVehicle -= handler;
+ }
+}
diff --git a/S1API.Tests/Entities/NpcCustomerContractAssignedBridgeTests.cs b/S1API.Tests/Entities/NpcCustomerContractAssignedBridgeTests.cs
new file mode 100644
index 00000000..e84fb71e
--- /dev/null
+++ b/S1API.Tests/Entities/NpcCustomerContractAssignedBridgeTests.cs
@@ -0,0 +1,52 @@
+#if IL2CPPMELON
+using S1Quests = Il2CppScheduleOne.Quests;
+#else
+using S1Quests = ScheduleOne.Quests;
+#endif
+
+using System.Reflection;
+using S1API.Entities;
+using UnityEngine.Events;
+
+namespace S1API.Tests.Entities;
+
+public sealed class NpcCustomerContractAssignedBridgeTests
+{
+ [Fact]
+ public void BridgeMatchesRuntimeUnityEventSignature()
+ {
+ Type customerType = typeof(NPCCustomer);
+ FieldInfo bridgeField = customerType.GetField(
+ "_contractAssignedBridge",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+ FieldInfo eventField = customerType.GetField(
+ "_contractAssignedUnityEvent",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+ MethodInfo handler = customerType.GetMethod(
+ "HandleContractAssigned",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ Assert.Equal(
+ typeof(UnityAction),
+ bridgeField.FieldType);
+ Assert.Equal(
+ typeof(UnityEvent),
+ eventField.FieldType);
+ Assert.Equal(
+ typeof(S1Quests.Contract),
+ Assert.Single(handler.GetParameters()).ParameterType);
+ }
+
+ [Fact]
+ public void UnityActionDelegateInheritanceMatchesRuntime()
+ {
+ bool derivesFromManagedDelegate = typeof(Delegate).IsAssignableFrom(
+ typeof(UnityAction));
+
+#if IL2CPPMELON
+ Assert.False(derivesFromManagedDelegate);
+#else
+ Assert.True(derivesFromManagedDelegate);
+#endif
+ }
+}
diff --git a/S1API.Tests/Entities/NpcVisibilityPolicyTests.cs b/S1API.Tests/Entities/NpcVisibilityPolicyTests.cs
index fb95b36d..f6c75e2f 100644
--- a/S1API.Tests/Entities/NpcVisibilityPolicyTests.cs
+++ b/S1API.Tests/Entities/NpcVisibilityPolicyTests.cs
@@ -26,14 +26,19 @@ public void ResolveSpawnVisibility_PreservesNativeSupplierVisibility(
}
[Theory]
- [InlineData(false, true)]
- [InlineData(true, false)]
- public void LoadedVisibilityIsDeferredForSuppliersUntilAfterSpawn(
+ [InlineData(false, false, false)]
+ [InlineData(false, true, false)]
+ [InlineData(true, false, true)]
+ [InlineData(true, true, false)]
+ public void LoadedVisibilityIsAppliedBeforeSpawnOnlyForPhysicalNonSuppliers(
+ bool isPhysical,
bool isSupplier,
bool expected)
{
Assert.Equal(
expected,
- NPC.ShouldApplyLoadedVisibilityBeforeSpawn(isSupplier));
+ NPC.ShouldApplyLoadedVisibilityBeforeSpawn(
+ isPhysical,
+ isSupplier));
}
}
diff --git a/S1API.Tests/Entities/SupplierPersistentIdentityTests.cs b/S1API.Tests/Entities/SupplierPersistentIdentityTests.cs
new file mode 100644
index 00000000..cbb1fc35
--- /dev/null
+++ b/S1API.Tests/Entities/SupplierPersistentIdentityTests.cs
@@ -0,0 +1,106 @@
+using S1API.Entities.Supplier;
+using S1API.Internal.Entities.Suppliers;
+using System.Reflection;
+
+namespace S1API.Tests.Entities;
+
+public sealed class SupplierPersistentIdentityTests
+{
+ [Fact]
+ public void PersistentIdIsPublicFluentApi()
+ {
+ MethodInfo? method = typeof(SupplierDataBuilder).GetMethod(
+ nameof(SupplierDataBuilder.WithPersistentId),
+ BindingFlags.Instance | BindingFlags.Public,
+ binder: null,
+ types: [typeof(string)],
+ modifiers: null);
+
+ Assert.NotNull(method);
+ Assert.Equal(typeof(SupplierDataBuilder), method.ReturnType);
+ }
+
+ [Fact]
+ public void PersistentIdIsOptInAndPreservesTheConfiguredValue()
+ {
+ var defaults = new SupplierDataBuilder().BuildInternal();
+ var builder = new SupplierDataBuilder();
+
+ SupplierDataBuilder result = builder.WithPersistentId(
+ " ifbars.moredrugs:npcs/disco-davey ");
+
+ Assert.Same(builder, result);
+ Assert.Null(defaults.PersistentId);
+ Assert.Equal(
+ "ifbars.moredrugs:npcs/disco-davey",
+ builder.BuildInternal().PersistentId);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void PersistentIdRejectsEmptyValues(string persistentId)
+ {
+ var builder = new SupplierDataBuilder();
+
+ Assert.Throws(() =>
+ builder.WithPersistentId(persistentId));
+ }
+
+ [Fact]
+ public void PersistentIdRejectsNullValue()
+ {
+ var builder = new SupplierDataBuilder();
+
+ Assert.Throws(() =>
+ builder.WithPersistentId(null!));
+ }
+
+ [Fact]
+ public void PersistentIdKeepsSupplierInfrastructureIdentitiesStable()
+ {
+ const string runtimeId = "disco_davey";
+ const string persistentId = "ifbars.moredrugs:npcs/disco-davey";
+ var configured = new SupplierDataBuilder()
+ .WithPersistentId(persistentId)
+ .BuildInternal();
+
+ SupplierInfrastructureIdentity migrated = GetInfrastructureIdentity(
+ configured.PersistentId ?? runtimeId);
+ SupplierInfrastructureIdentity legacy = GetInfrastructureIdentity(persistentId);
+ SupplierInfrastructureIdentity currentRuntime = GetInfrastructureIdentity(runtimeId);
+
+ Assert.Equal(legacy, migrated);
+ Assert.NotEqual(currentRuntime, migrated);
+ Assert.Equal(migrated, GetInfrastructureIdentity(configured.PersistentId ?? runtimeId));
+ }
+
+ [Fact]
+ public void OmittedPersistentIdFallsBackToRuntimeInfrastructureIdentities()
+ {
+ const string runtimeId = "disco_davey";
+ var configured = new SupplierDataBuilder().BuildInternal();
+
+ Assert.Null(configured.PersistentId);
+ SupplierInfrastructureIdentity fallback = GetInfrastructureIdentity(
+ configured.PersistentId ?? runtimeId);
+
+ Assert.Equal(GetInfrastructureIdentity(runtimeId), fallback);
+ Assert.Equal(fallback, GetInfrastructureIdentity(configured.PersistentId ?? runtimeId));
+ }
+
+ private static SupplierInfrastructureIdentity GetInfrastructureIdentity(string stableId)
+ {
+ return new SupplierInfrastructureIdentity(
+ SupplierRuntimeIds.GetShopName(stableId),
+ SupplierRuntimeIds.GetDeliveryVehiclePrefabName(stableId),
+ SupplierRuntimeIds.GetDeliveryVehicleGuid(stableId),
+ SupplierRuntimeIds.GetStashGuid(stableId));
+ }
+
+ private readonly record struct SupplierInfrastructureIdentity(
+ string ShopName,
+ string DeliveryVehiclePrefabName,
+ Guid DeliveryVehicleGuid,
+ Guid StashGuid);
+}
diff --git a/S1API.Tests/Interaction/InteractionPromptApiCompatibilityTests.cs b/S1API.Tests/Interaction/InteractionPromptApiCompatibilityTests.cs
new file mode 100644
index 00000000..14cb0a17
--- /dev/null
+++ b/S1API.Tests/Interaction/InteractionPromptApiCompatibilityTests.cs
@@ -0,0 +1,109 @@
+using System.Reflection;
+using S1API.Interaction;
+using UnityEngine;
+
+namespace S1API.Tests.Interaction;
+
+public sealed class InteractionPromptApiCompatibilityTests
+{
+ [Fact]
+ public void BuilderFactoriesPreserveSourceAndBinaryShape()
+ {
+ AssertStaticFactory(
+ typeof(InteractionPromptBuilder),
+ nameof(InteractionPromptBuilder.Create));
+ AssertStaticFactory(
+ typeof(InteractionPrompt),
+ nameof(InteractionPrompt.CreateBuilder));
+ }
+
+ [Fact]
+ public void BuilderExposesExpectedFluentShape()
+ {
+ AssertBuilderMethod(nameof(InteractionPromptBuilder.WithMessage), typeof(string));
+ AssertBuilderMethod(nameof(InteractionPromptBuilder.WithInput), typeof(InteractionPromptInput));
+ AssertBuilderMethod(nameof(InteractionPromptBuilder.WithState), typeof(InteractionPromptState));
+ AssertBuilderMethod(nameof(InteractionPromptBuilder.WithRange), typeof(float));
+ AssertBuilderMethod(nameof(InteractionPromptBuilder.WithPriority), typeof(int));
+ AssertBuilderMethod(nameof(InteractionPromptBuilder.WithAngleLimit), typeof(float));
+ AssertBuilderMethod(nameof(InteractionPromptBuilder.WithoutAngleLimit));
+ AssertBuilderMethod(nameof(InteractionPromptBuilder.WithDisplayLocation), typeof(Transform));
+ AssertBuilderMethod(nameof(InteractionPromptBuilder.WithDisplayLocation), typeof(Collider));
+ AssertBuilderMethod(nameof(InteractionPromptBuilder.OnHovered), typeof(Action));
+ AssertBuilderMethod(nameof(InteractionPromptBuilder.OnInteractionStarted), typeof(Action));
+ AssertBuilderMethod(nameof(InteractionPromptBuilder.OnInteractionEnded), typeof(Action));
+
+ MethodInfo build = typeof(InteractionPromptBuilder).GetMethod(
+ nameof(InteractionPromptBuilder.Build),
+ Type.EmptyTypes)!;
+ Assert.Equal(typeof(InteractionPrompt), build.ReturnType);
+ }
+
+ [Fact]
+ public void HandleExposesExpectedRuntimeShape()
+ {
+ AssertHandleMethod(nameof(InteractionPrompt.SetMessage), typeof(string));
+ AssertHandleMethod(nameof(InteractionPrompt.SetInput), typeof(InteractionPromptInput));
+ AssertHandleMethod(nameof(InteractionPrompt.SetState), typeof(InteractionPromptState));
+ AssertHandleMethod(nameof(InteractionPrompt.SetRange), typeof(float));
+ AssertHandleMethod(nameof(InteractionPrompt.SetPriority), typeof(int));
+ AssertHandleMethod(nameof(InteractionPrompt.SetAngleLimit), typeof(float));
+ AssertHandleMethod(nameof(InteractionPrompt.WithoutAngleLimit));
+ AssertHandleMethod(nameof(InteractionPrompt.SetDisplayLocation), typeof(Transform));
+ AssertHandleMethod(nameof(InteractionPrompt.SetDisplayLocation), typeof(Collider));
+ AssertHandleMethod(nameof(InteractionPrompt.ClearDisplayLocation));
+
+ MethodInfo remove = typeof(InteractionPrompt).GetMethod(
+ nameof(InteractionPrompt.Remove),
+ Type.EmptyTypes)!;
+ Assert.Equal(typeof(bool), remove.ReturnType);
+ Assert.Contains(typeof(IDisposable), typeof(InteractionPrompt).GetInterfaces());
+
+ AssertProperty(nameof(InteractionPrompt.Target), typeof(GameObject));
+ AssertProperty(nameof(InteractionPrompt.Message), typeof(string));
+ AssertProperty(nameof(InteractionPrompt.Input), typeof(InteractionPromptInput));
+ AssertProperty(nameof(InteractionPrompt.State), typeof(InteractionPromptState));
+ AssertProperty(nameof(InteractionPrompt.Range), typeof(float));
+ AssertProperty(nameof(InteractionPrompt.Priority), typeof(int));
+ AssertProperty(nameof(InteractionPrompt.IsAngleLimited), typeof(bool));
+ AssertProperty(nameof(InteractionPrompt.AngleLimit), typeof(float));
+ AssertProperty(nameof(InteractionPrompt.IsRemoved), typeof(bool));
+
+ AssertEvent(nameof(InteractionPrompt.Hovered));
+ AssertEvent(nameof(InteractionPrompt.InteractionStarted));
+ AssertEvent(nameof(InteractionPrompt.InteractionEnded));
+ }
+
+ private static void AssertStaticFactory(Type declaringType, string methodName)
+ {
+ MethodInfo method = declaringType.GetMethod(methodName, new[] { typeof(GameObject) })!;
+ Assert.True(method.IsStatic);
+ Assert.Equal(typeof(InteractionPromptBuilder), method.ReturnType);
+ Assert.Equal("target", Assert.Single(method.GetParameters()).Name);
+ }
+
+ private static void AssertBuilderMethod(string methodName, params Type[] parameterTypes)
+ {
+ MethodInfo method = typeof(InteractionPromptBuilder).GetMethod(methodName, parameterTypes)!;
+ Assert.Equal(typeof(InteractionPromptBuilder), method.ReturnType);
+ }
+
+ private static void AssertHandleMethod(string methodName, params Type[] parameterTypes)
+ {
+ MethodInfo method = typeof(InteractionPrompt).GetMethod(methodName, parameterTypes)!;
+ Assert.Equal(typeof(InteractionPrompt), method.ReturnType);
+ }
+
+ private static void AssertProperty(string propertyName, Type propertyType)
+ {
+ PropertyInfo property = typeof(InteractionPrompt).GetProperty(propertyName)!;
+ Assert.Equal(propertyType, property.PropertyType);
+ Assert.NotNull(property.GetMethod);
+ }
+
+ private static void AssertEvent(string eventName)
+ {
+ EventInfo eventInfo = typeof(InteractionPrompt).GetEvent(eventName)!;
+ Assert.Equal(typeof(Action), eventInfo.EventHandlerType);
+ }
+}
diff --git a/S1API.Tests/Interaction/InteractionPromptApiCompileFixture.cs b/S1API.Tests/Interaction/InteractionPromptApiCompileFixture.cs
new file mode 100644
index 00000000..9f69db63
--- /dev/null
+++ b/S1API.Tests/Interaction/InteractionPromptApiCompileFixture.cs
@@ -0,0 +1,60 @@
+using System;
+using S1API.Interaction;
+using UnityEngine;
+
+namespace S1API.Tests.Interaction;
+
+internal static class InteractionPromptApiCompileFixture
+{
+ internal static InteractionPrompt Configure(
+ GameObject target,
+ Collider displayCollider,
+ Transform displayPoint,
+ Action onHovered,
+ Action onStarted,
+ Action onEnded)
+ {
+ return InteractionPrompt
+ .CreateBuilder(target)
+ .WithMessage("Use machine")
+ .WithInput(InteractionPromptInput.Interact)
+ .WithState(InteractionPromptState.Default)
+ .WithRange(3f)
+ .WithPriority(5)
+ .WithAngleLimit(75f)
+ .WithoutAngleLimit()
+ .WithDisplayLocation(displayPoint)
+ .WithDisplayLocation(displayCollider)
+ .OnHovered(onHovered)
+ .OnInteractionStarted(onStarted)
+ .OnInteractionEnded(onEnded)
+ .Build();
+ }
+
+ internal static void ConfigureRuntime(InteractionPrompt prompt, Collider displayCollider, Transform displayPoint)
+ {
+ prompt
+ .SetMessage("Stop machine")
+ .SetInput(InteractionPromptInput.PrimaryClick)
+ .SetState(InteractionPromptState.Invalid)
+ .SetRange(2f)
+ .SetPriority(10)
+ .SetAngleLimit(45f)
+ .WithoutAngleLimit()
+ .SetDisplayLocation(displayPoint)
+ .SetDisplayLocation(displayCollider)
+ .ClearDisplayLocation();
+
+ _ = prompt.Target;
+ _ = prompt.Message;
+ _ = prompt.Input;
+ _ = prompt.State;
+ _ = prompt.Range;
+ _ = prompt.Priority;
+ _ = prompt.IsAngleLimited;
+ _ = prompt.AngleLimit;
+ _ = prompt.IsRemoved;
+ prompt.Remove();
+ prompt.Dispose();
+ }
+}
diff --git a/S1API.Tests/Interaction/InteractionPromptContractTests.cs b/S1API.Tests/Interaction/InteractionPromptContractTests.cs
new file mode 100644
index 00000000..929c3023
--- /dev/null
+++ b/S1API.Tests/Interaction/InteractionPromptContractTests.cs
@@ -0,0 +1,185 @@
+using System;
+using System.Reflection;
+using S1API.Interaction;
+using UnityEngine;
+
+namespace S1API.Tests.Interaction;
+
+public sealed class InteractionPromptContractTests
+{
+ [Fact]
+ public void ExplicitBuilderConfigurationReplacesDefaults()
+ {
+ InteractionPromptBuilder builder = CreateManagedBuilderFixture();
+ Action hovered = () => { };
+ Action started = () => { };
+ Action ended = () => { };
+
+ InteractionPromptBuilder result = builder
+ .WithMessage("Use")
+ .WithInput(InteractionPromptInput.PrimaryClick)
+ .WithState(InteractionPromptState.Label)
+ .WithRange(2.5f)
+ .WithPriority(8)
+ .WithAngleLimit(60f)
+ .OnHovered(hovered)
+ .OnHovered(hovered)
+ .OnInteractionStarted(started)
+ .OnInteractionStarted(started)
+ .OnInteractionEnded(ended)
+ .OnInteractionEnded(ended);
+
+ Assert.Same(builder, result);
+ Assert.Equal("Use", builder.Message);
+ Assert.Equal(InteractionPromptInput.PrimaryClick, builder.Input);
+ Assert.Equal(InteractionPromptState.Label, builder.State);
+ Assert.Equal(2.5f, builder.Range);
+ Assert.Equal(8, builder.Priority);
+ Assert.True(builder.LimitAngle);
+ Assert.Equal(60f, builder.AngleLimit);
+ Assert.Same(hovered, Assert.Single(builder.HoveredCallbacks));
+ Assert.Same(started, Assert.Single(builder.InteractionStartedCallbacks));
+ Assert.Same(ended, Assert.Single(builder.InteractionEndedCallbacks));
+ }
+
+ [Fact]
+ public void FailedMessageValidationLeavesBuilderMutableForImmediateRetry()
+ {
+ InteractionPromptBuilder builder = CreateManagedBuilderFixture();
+
+ Assert.Throws(() => builder.Build());
+
+ Assert.Same(builder, builder.WithMessage("Retry"));
+ Assert.Equal("Retry", builder.Message);
+ }
+
+ [Fact]
+ public void BuiltBuilderReturnsCachedHandleAndRejectsFurtherMutation()
+ {
+ InteractionPromptBuilder builder = CreateManagedBuilderFixture();
+ InteractionPrompt prompt = TestObjectFactory.CreateUninitialized();
+ SetBuiltPrompt(builder, prompt);
+
+ Assert.Same(prompt, builder.Build());
+ Assert.Throws(() => builder.WithMessage("Changed"));
+ Assert.Throws(() => builder.WithPriority(1));
+ Assert.Throws(() => builder.OnHovered(() => { }));
+ }
+
+ [Fact]
+ public void BuilderRejectsNullCallbacksAndUndefinedEnums()
+ {
+ InteractionPromptBuilder builder = CreateManagedBuilderFixture();
+
+ Assert.Throws(() => builder.OnHovered(null!));
+ Assert.Throws(() => builder.OnInteractionStarted(null!));
+ Assert.Throws(() => builder.OnInteractionEnded(null!));
+ Assert.Throws(
+ () => builder.WithDisplayLocation((Transform)null!));
+ Assert.Throws(
+ () => builder.WithDisplayLocation((Collider)null!));
+ Assert.Throws(
+ () => builder.WithInput((InteractionPromptInput)99));
+ Assert.Throws(
+ () => builder.WithState((InteractionPromptState)99));
+ }
+
+ [Fact]
+ public void PublicFactoriesRejectNullTargets()
+ {
+ Assert.Throws(() => InteractionPromptBuilder.Create(null!));
+ Assert.Throws(() => InteractionPrompt.CreateBuilder(null!));
+ }
+
+ [Fact]
+ public void NativeRangeIsBoundedByInteractionManagerCast()
+ {
+ Assert.Equal(4f, InteractionPromptContract.NativeMaxInteractionRange);
+ Assert.Equal(90f, InteractionPromptContract.DefaultAngleLimit);
+ Assert.Equal(0.1f, InteractionPromptContract.NormalizeRange(0.1f));
+ Assert.Equal(4f, InteractionPromptContract.NormalizeRange(4f));
+ }
+
+ [Theory]
+ [InlineData(0f)]
+ [InlineData(-1f)]
+ [InlineData(4.01f)]
+ [InlineData(float.NaN)]
+ [InlineData(float.PositiveInfinity)]
+ [InlineData(float.NegativeInfinity)]
+ public void RangeRejectsUnsupportedValues(float range)
+ {
+ Assert.Throws(
+ () => InteractionPromptContract.NormalizeRange(range));
+ }
+
+ [Theory]
+ [InlineData(0f)]
+ [InlineData(-1f)]
+ [InlineData(180.01f)]
+ [InlineData(float.NaN)]
+ [InlineData(float.PositiveInfinity)]
+ [InlineData(float.NegativeInfinity)]
+ public void AngleLimitRejectsUnsupportedValues(float angleLimit)
+ {
+ Assert.Throws(
+ () => InteractionPromptContract.NormalizeAngleLimit(angleLimit));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void MessageRejectsMissingText(string? message)
+ {
+ Assert.ThrowsAny(
+ () => InteractionPromptContract.NormalizeMessage(message!));
+ }
+
+ [Fact]
+ public void PublicEnumsRemainStableAndNativeOrdered()
+ {
+ Assert.Equal(0, (int)InteractionPromptInput.Interact);
+ Assert.Equal(1, (int)InteractionPromptInput.PrimaryClick);
+ Assert.Equal(0, (int)InteractionPromptState.Default);
+ Assert.Equal(1, (int)InteractionPromptState.Invalid);
+ Assert.Equal(2, (int)InteractionPromptState.Disabled);
+ Assert.Equal(3, (int)InteractionPromptState.Label);
+ }
+
+ [Fact]
+ public void UndefinedEnumValuesAreRejected()
+ {
+ Assert.Throws(
+ () => InteractionPromptContract.ValidateEnum(
+ (InteractionPromptInput)99,
+ "input"));
+ Assert.Throws(
+ () => InteractionPromptContract.ValidateEnum(
+ (InteractionPromptState)99,
+ "state"));
+ }
+
+ private static InteractionPromptBuilder CreateManagedBuilderFixture()
+ {
+ InteractionPromptBuilder builder =
+ TestObjectFactory.CreateUninitialized();
+ SetPrivateField(builder, "_hoveredCallbacks", new List());
+ SetPrivateField(builder, "_interactionStartedCallbacks", new List());
+ SetPrivateField(builder, "_interactionEndedCallbacks", new List());
+ return builder;
+ }
+
+ private static void SetBuiltPrompt(InteractionPromptBuilder builder, InteractionPrompt prompt)
+ {
+ SetPrivateField(builder, "_builtPrompt", prompt);
+ }
+
+ private static void SetPrivateField(InteractionPromptBuilder builder, string name, TValue value)
+ {
+ FieldInfo field = typeof(InteractionPromptBuilder).GetField(
+ name,
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+ field.SetValue(builder, value);
+ }
+}
diff --git a/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs b/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs
new file mode 100644
index 00000000..cee6d1cf
--- /dev/null
+++ b/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs
@@ -0,0 +1,50 @@
+using S1API.Internal.Utils;
+
+namespace S1API.Tests.Internal.Utils;
+
+public sealed class ManagedEventRegistrationTrackerTests
+{
+ [Fact]
+ public void DuplicateAddsAreRemovedOneAtATimeInReverseRegistrationOrder()
+ {
+ var tracker = new ManagedEventRegistrationTracker();
+ Action handler = () => { };
+
+ tracker.Add(handler, "first");
+ tracker.Add(handler, "second");
+ Assert.False(tracker.IsEmpty);
+
+ Assert.True(tracker.TryTakeLast(handler, out string? second));
+ Assert.Equal("second", second);
+ Assert.True(tracker.TryTakeLast(handler, out string? first));
+ Assert.Equal("first", first);
+ Assert.False(tracker.TryTakeLast(handler, out _));
+ Assert.True(tracker.IsEmpty);
+ }
+
+ [Fact]
+ public void TakeAllReturnsEveryRegistrationAndClearsTheTracker()
+ {
+ var tracker = new ManagedEventRegistrationTracker();
+ Action firstHandler = () => { };
+ Action secondHandler = _ => { };
+
+ tracker.Add(firstHandler, "first");
+ tracker.Add(firstHandler, "second");
+ tracker.Add(secondHandler, "third");
+ Assert.False(tracker.IsEmpty);
+
+ var registrations = tracker.TakeAll();
+
+ Assert.Equal(3, registrations.Count);
+ Assert.Contains(registrations, registration =>
+ registration.ManagedHandler.Equals(firstHandler) && registration.NativeHandler == "first");
+ Assert.Contains(registrations, registration =>
+ registration.ManagedHandler.Equals(firstHandler) && registration.NativeHandler == "second");
+ Assert.Contains(registrations, registration =>
+ registration.ManagedHandler.Equals(secondHandler) && registration.NativeHandler == "third");
+ Assert.False(tracker.TryTakeLast(firstHandler, out _));
+ Assert.False(tracker.TryTakeLast(secondHandler, out _));
+ Assert.True(tracker.IsEmpty);
+ }
+}
diff --git a/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs
new file mode 100644
index 00000000..3dfc4a81
--- /dev/null
+++ b/S1API.Tests/Internal/Utils/ReflectionUtilsTests.cs
@@ -0,0 +1,180 @@
+using S1API.Internal.Utils;
+using S1API.Logging;
+using System.Reflection;
+using System.Reflection.Emit;
+
+namespace S1API.Tests.Internal.Utils;
+
+public sealed class ReflectionUtilsTests
+{
+ [Fact]
+ public void InstanceAccessSupportsMonoFieldAndIl2CppPropertyShapes()
+ {
+ var mono = new MonoShape();
+ var il2Cpp = new Il2CppShape();
+
+ Assert.True(ReflectionUtils.TrySetFieldOrProperty(mono, "runtimeMember", 14));
+ Assert.True(ReflectionUtils.TrySetFieldOrProperty(il2Cpp, "runtimeMember", 14));
+ Assert.Equal(14, ReflectionUtils.TryGetFieldOrProperty(mono, "runtimeMember"));
+ Assert.Equal(14, ReflectionUtils.TryGetFieldOrProperty(il2Cpp, "runtimeMember"));
+ }
+
+ [Fact]
+ public void StaticAccessSupportsMonoFieldAndIl2CppPropertyShapes()
+ {
+ ReflectionUtils.TrySetStaticFieldOrProperty(typeof(MonoStaticShape), "RuntimeMember", 31);
+ ReflectionUtils.TrySetStaticFieldOrProperty(typeof(Il2CppStaticShape), "RuntimeMember", 31);
+
+ Assert.Equal(31, ReflectionUtils.TryGetStaticFieldOrProperty(typeof(MonoStaticShape), "RuntimeMember"));
+ Assert.Equal(31, ReflectionUtils.TryGetStaticFieldOrProperty(typeof(Il2CppStaticShape), "RuntimeMember"));
+ }
+
+ [Fact]
+ public void StaticAccessWalksBaseTypesForNonPublicMembers()
+ {
+ ReflectionUtils.TrySetStaticFieldOrProperty(typeof(DerivedStaticShape), "RuntimeMember", 47);
+
+ Assert.Equal(
+ 47,
+ ReflectionUtils.TryGetStaticFieldOrProperty(typeof(DerivedStaticShape), "RuntimeMember"));
+ }
+
+ [Fact]
+ public void DerivedTypeScanIncludesAssembliesThatReferenceTheBaseAssembly()
+ {
+ Assembly[] loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
+
+ Assert.True(ReflectionUtils.CanContainTypesDerivedFrom(
+ typeof(ReflectionUtilsTests).Assembly,
+ typeof(ReflectionUtils).Assembly,
+ loadedAssemblies));
+ }
+
+ [Fact]
+ public void GetDerivedClassesFindsTypesInReferencingAssemblies()
+ {
+ Assert.Contains(
+ typeof(DerivedLogShape),
+ ReflectionUtils.GetDerivedClasses());
+ }
+
+ [Fact]
+ public void DerivedTypeScanExcludesAssembliesWithoutAReferencePathToTheBaseAssembly()
+ {
+ Assembly[] loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
+
+ Assert.False(ReflectionUtils.CanContainTypesDerivedFrom(
+ typeof(string).Assembly,
+ typeof(ReflectionUtils).Assembly,
+ loadedAssemblies));
+ }
+
+ [Fact]
+ public void DerivedTypeScanFollowsTransitiveAssemblyReferences()
+ {
+ var assemblyName = new AssemblyName($"S1API.ReflectionUtilsTests.Dynamic.{Guid.NewGuid():N}");
+ AssemblyBuilder assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(
+ assemblyName,
+ AssemblyBuilderAccess.Run);
+ ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule(assemblyName.Name!);
+ moduleBuilder.DefineType(
+ "DynamicReflectionCandidate",
+ TypeAttributes.Public,
+ typeof(ReflectionCandidateBridge))
+ .CreateType();
+
+ Assembly[] loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
+
+ Assert.True(ReflectionUtils.CanContainTypesDerivedFrom(
+ assemblyBuilder,
+ typeof(ReflectionUtils).Assembly,
+ loadedAssemblies));
+ }
+
+ [Fact]
+ public void DerivedTypeScanDoesNotFollowSameNameAssembliesWithDifferentIdentities()
+ {
+ string assemblyName = $"S1API.ReflectionUtilsTests.Duplicate.{Guid.NewGuid():N}";
+ AssemblyBuilder unrelatedAssembly = CreateDynamicAssembly(assemblyName, new Version(1, 0, 0, 0));
+ Type unrelatedType = unrelatedAssembly
+ .DefineDynamicModule(assemblyName)
+ .DefineType("UnrelatedType", TypeAttributes.Public)
+ .CreateType()!;
+
+ AssemblyBuilder relatedAssembly = CreateDynamicAssembly(assemblyName, new Version(2, 0, 0, 0));
+ relatedAssembly
+ .DefineDynamicModule(assemblyName)
+ .DefineType("RelatedType", TypeAttributes.Public, typeof(ReflectionCandidateBridge))
+ .CreateType();
+
+ AssemblyBuilder candidateAssembly = CreateDynamicAssembly(
+ $"S1API.ReflectionUtilsTests.Candidate.{Guid.NewGuid():N}",
+ new Version(1, 0, 0, 0));
+ ModuleBuilder candidateModule = candidateAssembly.DefineDynamicModule(candidateAssembly.GetName().Name!);
+ candidateModule
+ .DefineType("CandidateType", TypeAttributes.Public, unrelatedType)
+ .CreateType();
+
+ Assert.False(ReflectionUtils.CanContainTypesDerivedFrom(
+ candidateAssembly,
+ typeof(ReflectionUtils).Assembly,
+ AppDomain.CurrentDomain.GetAssemblies()));
+ }
+
+ private static AssemblyBuilder CreateDynamicAssembly(string name, Version version)
+ {
+ var assemblyName = new AssemblyName(name)
+ {
+ Version = version
+ };
+
+ return AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
+ }
+
+ private sealed class MonoShape
+ {
+#pragma warning disable CS0169
+ private int runtimeMember;
+#pragma warning restore CS0169
+ }
+
+ private sealed class Il2CppShape
+ {
+ public int runtimeMember { get; set; }
+ }
+
+ private static class MonoStaticShape
+ {
+#pragma warning disable CS0649
+ public static int RuntimeMember;
+#pragma warning restore CS0649
+ }
+
+ private static class Il2CppStaticShape
+ {
+ public static int RuntimeMember { get; set; }
+ }
+
+ private class BaseStaticShape
+ {
+#pragma warning disable CS0169, CS0649
+ private static int RuntimeMember;
+#pragma warning restore CS0169, CS0649
+ }
+
+ private sealed class DerivedStaticShape : BaseStaticShape
+ {
+ }
+
+ public class ReflectionCandidateBridge
+ {
+ }
+
+ private sealed class DerivedLogShape : Log
+ {
+ public DerivedLogShape()
+ : base(nameof(DerivedLogShape))
+ {
+ }
+ }
+}
diff --git a/S1API.Tests/Items/BuildableGhostApiCompatibilityTests.cs b/S1API.Tests/Items/BuildableGhostApiCompatibilityTests.cs
new file mode 100644
index 00000000..b3b53a45
--- /dev/null
+++ b/S1API.Tests/Items/BuildableGhostApiCompatibilityTests.cs
@@ -0,0 +1,34 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using S1API.Items.Buildable;
+using UnityEngine;
+
+namespace S1API.Tests.Items;
+
+public sealed class BuildableGhostApiCompatibilityTests
+{
+ [Fact]
+ public void BuildableBuilderExposesOptInGhostVisualFactory()
+ {
+ MethodInfo? method = typeof(BuildableItemDefinitionBuilder).GetMethod(
+ nameof(BuildableItemDefinitionBuilder.WithGhostVisual),
+ new[] { typeof(Func), typeof(bool) });
+
+ Assert.NotNull(method);
+ Assert.Equal(typeof(BuildableItemDefinitionBuilder), method!.ReturnType);
+ ParameterInfo[] parameters = method.GetParameters();
+ Assert.Equal("visualFactory", parameters[0].Name);
+ Assert.Equal("replaceExistingVisual", parameters[1].Name);
+ Assert.True(parameters[1].HasDefaultValue);
+ Assert.Equal(false, parameters[1].DefaultValue);
+ }
+
+ [Fact]
+ public void GhostVisualFactoryRejectsNull()
+ {
+ var builder = (BuildableItemDefinitionBuilder)RuntimeHelpers.GetUninitializedObject(
+ typeof(BuildableItemDefinitionBuilder));
+
+ Assert.Throws(() => builder.WithGhostVisual(null!));
+ }
+}
diff --git a/S1API.Tests/Items/ClothingMetadataCatalogCollection.cs b/S1API.Tests/Items/ClothingMetadataCatalogCollection.cs
new file mode 100644
index 00000000..dc4dd80f
--- /dev/null
+++ b/S1API.Tests/Items/ClothingMetadataCatalogCollection.cs
@@ -0,0 +1,7 @@
+namespace S1API.Tests.Items;
+
+[CollectionDefinition(Name, DisableParallelization = true)]
+public sealed class ClothingMetadataCatalogCollection
+{
+ public const string Name = "Clothing metadata catalog";
+}
diff --git a/S1API.Tests/Items/ClothingMetadataCatalogTests.cs b/S1API.Tests/Items/ClothingMetadataCatalogTests.cs
new file mode 100644
index 00000000..168d0a2e
--- /dev/null
+++ b/S1API.Tests/Items/ClothingMetadataCatalogTests.cs
@@ -0,0 +1,154 @@
+using S1API.Items.Clothing;
+using UnityEngine;
+
+namespace S1API.Tests.Items;
+
+[Collection(ClothingMetadataCatalogCollection.Name)]
+public sealed class ClothingMetadataCatalogTests : IDisposable
+{
+ private readonly FakeClothingMetadataProvider _provider = new();
+
+ public ClothingMetadataCatalogTests()
+ {
+ ClothingMetadataCatalog.ResetForTesting(_provider);
+ }
+
+ public void Dispose()
+ {
+ ClothingMetadataCatalog.RestoreProviderForTesting();
+ }
+
+ [Fact]
+ public void LookupsExposeS1ApiOwnedMetadata()
+ {
+ Color actualColor = CreateColor(0.2f, 0.4f, 0.6f);
+ Color labelColor = CreateColor(1f, 1f, 1f);
+ var slotMetadata = new ClothingSlotMetadata(
+ ClothingSlot.Head,
+ "Headwear",
+ null);
+ var colorMetadata = new ClothingColorMetadata(
+ ClothingColor.Blue,
+ "Blue",
+ actualColor,
+ labelColor);
+ _provider.Slots[ClothingSlot.Head] = slotMetadata;
+ _provider.Colors[ClothingColor.Blue] = colorMetadata;
+
+ Assert.Same(slotMetadata, ClothingMetadataCatalog.GetSlot(ClothingSlot.Head));
+ Assert.True(
+ ClothingMetadataCatalog.TryGetColor(
+ ClothingColor.Blue,
+ out ClothingColorMetadata? resolvedColor));
+ Assert.Same(colorMetadata, resolvedColor);
+ AssertColor(actualColor, resolvedColor!.ActualColor);
+ AssertColor(labelColor, resolvedColor.LabelColor);
+ }
+
+ [Fact]
+ public void CatalogPropertiesReturnOrderedReadOnlySnapshots()
+ {
+ var feet = new ClothingSlotMetadata(ClothingSlot.Feet, "Shoes", null);
+ var head = new ClothingSlotMetadata(ClothingSlot.Head, "Headwear", null);
+ _provider.Slots[ClothingSlot.Head] = head;
+ _provider.Slots[ClothingSlot.Feet] = feet;
+
+ IReadOnlyList snapshot =
+ ClothingMetadataCatalog.Slots;
+ _provider.Slots.Remove(ClothingSlot.Head);
+
+ Assert.Equal(new[] { feet, head }, snapshot);
+ Assert.Equal(new[] { feet }, ClothingMetadataCatalog.Slots);
+
+ IList mutableView =
+ Assert.IsAssignableFrom>(snapshot);
+ Assert.True(mutableView.IsReadOnly);
+ Assert.Throws(() => mutableView.Add(feet));
+ }
+
+ [Fact]
+ public void InvalidValuesDoNotReachNativeProvider()
+ {
+ Assert.False(
+ ClothingMetadataCatalog.TryGetSlot(
+ (ClothingSlot)(-1),
+ out ClothingSlotMetadata? slotMetadata));
+ Assert.False(
+ ClothingMetadataCatalog.TryGetColor(
+ (ClothingColor)999,
+ out ClothingColorMetadata? colorMetadata));
+
+ Assert.Null(slotMetadata);
+ Assert.Null(colorMetadata);
+ Assert.Equal(0, _provider.SlotLookupCount);
+ Assert.Equal(0, _provider.ColorLookupCount);
+ }
+
+ [Fact]
+ public void UnavailableRuntimeReturnsEmptyAndNullResults()
+ {
+ Assert.Empty(ClothingMetadataCatalog.Slots);
+ Assert.Empty(ClothingMetadataCatalog.Colors);
+ Assert.Null(ClothingMetadataCatalog.GetSlot(ClothingSlot.Head));
+ Assert.Null(ClothingMetadataCatalog.GetColor(ClothingColor.Blue));
+ }
+
+ [Fact]
+ public void MetadataPropertiesAreReadOnly()
+ {
+ Assert.All(
+ typeof(ClothingSlotMetadata).GetProperties(),
+ property => Assert.False(property.CanWrite));
+ Assert.All(
+ typeof(ClothingColorMetadata).GetProperties(),
+ property => Assert.False(property.CanWrite));
+ }
+
+ private static Color CreateColor(float red, float green, float blue)
+ {
+ Color color = default;
+ color.r = red;
+ color.g = green;
+ color.b = blue;
+ color.a = 1f;
+ return color;
+ }
+
+ private static void AssertColor(Color expected, Color actual)
+ {
+ Assert.Equal(expected.r, actual.r);
+ Assert.Equal(expected.g, actual.g);
+ Assert.Equal(expected.b, actual.b);
+ Assert.Equal(expected.a, actual.a);
+ }
+
+ private sealed class FakeClothingMetadataProvider :
+ IClothingMetadataProvider
+ {
+ internal Dictionary Slots { get; } =
+ new();
+
+ internal Dictionary Colors { get; } =
+ new();
+
+ internal int SlotLookupCount { get; private set; }
+
+ internal int ColorLookupCount { get; private set; }
+
+ public bool TryGetSlot(
+ ClothingSlot slot,
+ out ClothingSlotMetadata? metadata)
+ {
+ SlotLookupCount++;
+ return Slots.TryGetValue(slot, out metadata);
+ }
+
+ public bool TryGetColor(
+ ClothingColor color,
+ out ClothingColorMetadata? metadata)
+ {
+ ColorLookupCount++;
+ return Colors.TryGetValue(color, out metadata);
+ }
+ }
+}
diff --git a/S1API.Tests/Items/FurnitureApiCompatibilityTests.cs b/S1API.Tests/Items/FurnitureApiCompatibilityTests.cs
new file mode 100644
index 00000000..07c69235
--- /dev/null
+++ b/S1API.Tests/Items/FurnitureApiCompatibilityTests.cs
@@ -0,0 +1,156 @@
+using System.Reflection;
+using S1API.Internal.Building;
+using S1API.Items.Buildable;
+using UnityEngine;
+
+namespace S1API.Tests.Items;
+
+public sealed class FurnitureApiCompatibilityTests
+{
+ [Fact]
+ public void FurnitureBuilderExposesRuntimeAgnosticFluentSurface()
+ {
+ MethodInfo? createBuilder = typeof(FurnitureCreator).GetMethod(
+ nameof(FurnitureCreator.CreateBuilder),
+ Type.EmptyTypes);
+
+ Assert.NotNull(createBuilder);
+ Assert.Equal(typeof(FurnitureDefinitionBuilder), createBuilder!.ReturnType);
+ AssertCreatorCloneOverload(typeof(string), "sourceItemId");
+ AssertCreatorCloneOverload(typeof(BuildableItemDefinition), "source");
+ AssertFluent(nameof(FurnitureDefinitionBuilder.WithBasicInfo), typeof(string), typeof(string), typeof(string));
+ AssertFluent(nameof(FurnitureDefinitionBuilder.WithModel), typeof(GameObject));
+ AssertFluent(nameof(FurnitureDefinitionBuilder.ConfigureModel), typeof(Action));
+ AssertFluent(nameof(FurnitureDefinitionBuilder.WithPlacement), typeof(FurniturePlacementMode));
+ AssertFluent(nameof(FurnitureDefinitionBuilder.WithFootprint), typeof(int), typeof(int));
+ AssertFluent(nameof(FurnitureDefinitionBuilder.WithSurfacePlacement), typeof(FurnitureSurfaceType), typeof(bool));
+ AssertFluent(nameof(FurnitureDefinitionBuilder.WithBuildSound), typeof(BuildSoundType));
+ AssertFluent(nameof(FurnitureDefinitionBuilder.WithPricing), typeof(float), typeof(float));
+ AssertFluent(nameof(FurnitureDefinitionBuilder.WithStackLimit), typeof(int));
+ AssertFluent(nameof(FurnitureDefinitionBuilder.WithIcon), typeof(Sprite));
+ AssertFluent(nameof(FurnitureDefinitionBuilder.WithGeneratedIcon), typeof(int));
+
+ MethodInfo? build = typeof(FurnitureDefinitionBuilder).GetMethod(
+ nameof(FurnitureDefinitionBuilder.Build),
+ Type.EmptyTypes);
+ Assert.NotNull(build);
+ Assert.Equal(typeof(BuildableItemDefinition), build!.ReturnType);
+ }
+
+ [Fact]
+ public void PlacementEnumsExposeOnlySupportedNativeFamilies()
+ {
+ Assert.Equal(
+ new[] { FurniturePlacementMode.Grid, FurniturePlacementMode.Surface },
+ Enum.GetValues());
+ Assert.Equal(
+ FurnitureSurfaceType.Wall | FurnitureSurfaceType.Roof,
+ FurnitureSurfaceType.All);
+ }
+
+ [Theory]
+ [InlineData(0, 1)]
+ [InlineData(1, 0)]
+ [InlineData(-1, 1)]
+ public void FootprintRejectsNonPositiveDimensions(int width, int depth)
+ {
+ FurnitureDefinitionBuilder builder = FurnitureCreator.CreateBuilder();
+ Assert.Throws(() => builder.WithFootprint(width, depth));
+ }
+
+ [Theory]
+ [InlineData(FurnitureSurfaceType.None)]
+ [InlineData((FurnitureSurfaceType)8)]
+ public void SurfacePlacementRejectsEmptyOrUnknownFlags(FurnitureSurfaceType surfaceTypes)
+ {
+ FurnitureDefinitionBuilder builder = FurnitureCreator.CreateBuilder();
+ Assert.Throws(
+ () => builder.WithSurfacePlacement(surfaceTypes));
+ }
+
+ [Fact]
+ public void ModelAndIconRejectNull()
+ {
+ FurnitureDefinitionBuilder builder = FurnitureCreator.CreateBuilder();
+
+ Assert.Throws(() => builder.WithModel(null!));
+ Assert.Throws(() => builder.WithIcon(null!));
+ Assert.Throws(() => builder.ConfigureModel(null!));
+ }
+
+ [Fact]
+ public void ConfigureModelRejectsCreateBuilderPath()
+ {
+ FurnitureDefinitionBuilder builder = FurnitureCreator.CreateBuilder();
+
+ Assert.Throws(
+ () => builder.ConfigureModel(_ => { }));
+ }
+
+ [Fact]
+ public void CloneFromRejectsInvalidPublicInputsBeforeNativeResolution()
+ {
+ Assert.Throws(() => FurnitureCreator.CloneFrom(" "));
+ Assert.Throws(
+ () => FurnitureCreator.CloneFrom((BuildableItemDefinition)null!));
+ }
+
+ [Fact]
+ public void DefaultBuildSoundIsWood()
+ {
+ Assert.Equal(BuildSoundType.Wood, FurnitureBuildSoundMapper.Default);
+ }
+
+ [Theory]
+ [InlineData(BuildSoundType.Cardboard, 0)]
+ [InlineData(BuildSoundType.Wood, 1)]
+ [InlineData(BuildSoundType.Metal, 2)]
+ [InlineData(BuildSoundType.Plastic, 2)]
+ public void BuildSoundsMapToNativeValues(BuildSoundType soundType, int nativeValue)
+ {
+ Assert.Equal(
+ nativeValue,
+ Convert.ToInt32(FurnitureBuildSoundMapper.ToNative(soundType)));
+ }
+
+ [Fact]
+ public void BuildSoundRejectsUnknownValue()
+ {
+ FurnitureDefinitionBuilder builder = FurnitureCreator.CreateBuilder();
+
+ Assert.Throws(
+ () => builder.WithBuildSound((BuildSoundType)int.MaxValue));
+ }
+
+ [Theory]
+ [InlineData(0, BuildSoundType.Cardboard)]
+ [InlineData(1, BuildSoundType.Wood)]
+ [InlineData(2, BuildSoundType.Metal)]
+ public void NativeBuildSoundsMapBackToPublicValues(int nativeValue, BuildSoundType soundType)
+ {
+#if IL2CPPMELON
+ var native = (Il2CppScheduleOne.ItemFramework.BuildableItemDefinition.EBuildSoundType)nativeValue;
+#else
+ var native = (ScheduleOne.ItemFramework.BuildableItemDefinition.EBuildSoundType)nativeValue;
+#endif
+ Assert.Equal(soundType, FurnitureBuildSoundMapper.FromNative(native));
+ }
+
+ private static void AssertCreatorCloneOverload(Type parameterType, string parameterName)
+ {
+ MethodInfo? method = typeof(FurnitureCreator).GetMethod(
+ nameof(FurnitureCreator.CloneFrom),
+ new[] { parameterType });
+
+ Assert.NotNull(method);
+ Assert.Equal(typeof(FurnitureDefinitionBuilder), method!.ReturnType);
+ Assert.Equal(parameterName, Assert.Single(method.GetParameters()).Name);
+ }
+
+ private static void AssertFluent(string name, params Type[] parameterTypes)
+ {
+ MethodInfo? method = typeof(FurnitureDefinitionBuilder).GetMethod(name, parameterTypes);
+ Assert.NotNull(method);
+ Assert.Equal(typeof(FurnitureDefinitionBuilder), method!.ReturnType);
+ }
+}
diff --git a/S1API.Tests/Items/FurnitureApiCompileFixture.cs b/S1API.Tests/Items/FurnitureApiCompileFixture.cs
new file mode 100644
index 00000000..85d7e605
--- /dev/null
+++ b/S1API.Tests/Items/FurnitureApiCompileFixture.cs
@@ -0,0 +1,39 @@
+using S1API.Items.Buildable;
+using UnityEngine;
+
+namespace S1API.Tests.Items;
+
+internal static class FurnitureApiCompileFixture
+{
+ internal static FurnitureDefinitionBuilder Configure(GameObject model, Sprite icon)
+ {
+ return FurnitureCreator.CreateBuilder()
+ .WithBasicInfo("example.mod:sofa-chair", "Sofa Chair", "A compact chair.")
+ .WithModel(model)
+ .WithPlacement(FurniturePlacementMode.Grid)
+ .WithFootprint(2, 2)
+ .WithBuildSound(BuildSoundType.Wood)
+ .WithPricing(175f)
+ .WithStackLimit(4)
+ .WithIcon(icon);
+ }
+
+ internal static FurnitureDefinitionBuilder ConfigureNativeVariant(
+ string donorId,
+ Action configure)
+ {
+ return FurnitureCreator.CloneFrom(donorId)
+ .WithBasicInfo("example.mod:blue-chair", "Blue Chair", "A recolored chair.")
+ .ConfigureModel(configure)
+ .WithGeneratedIcon();
+ }
+
+ internal static FurnitureDefinitionBuilder ConfigureNativeVariant(
+ BuildableItemDefinition donor,
+ Action configure)
+ {
+ return FurnitureCreator.CloneFrom(donor)
+ .WithBasicInfo("example.mod:green-chair", "Green Chair", "Another recolored chair.")
+ .ConfigureModel(configure);
+ }
+}
diff --git a/S1API.Tests/Items/FurnitureClonePolicyTests.cs b/S1API.Tests/Items/FurnitureClonePolicyTests.cs
new file mode 100644
index 00000000..d2f022a2
--- /dev/null
+++ b/S1API.Tests/Items/FurnitureClonePolicyTests.cs
@@ -0,0 +1,109 @@
+using S1API.Internal.Building;
+using S1API.Items.Buildable;
+using UnityEngine;
+
+namespace S1API.Tests.Items;
+
+public sealed class FurnitureClonePolicyTests
+{
+ [Theory]
+ [InlineData("couch", "couch")]
+ [InlineData("Couch", "couch")]
+ public void VariantRejectsDonorIdReuse(string itemId, string donorId)
+ {
+ Assert.Throws(
+ () => FurnitureClonePolicy.ValidateNewId(itemId, donorId));
+ }
+
+ [Fact]
+ public void VariantAcceptsNewStableId()
+ {
+ FurnitureClonePolicy.ValidateNewId("example.mod:blue-couch", "couch");
+ }
+
+ [Fact]
+ public void CreateBuilderHasNoDonorIdentityConstraint()
+ {
+ FurnitureClonePolicy.ValidateNewId("example.mod:chair", donorId: null);
+ }
+
+ [Fact]
+ public void CloneBuilderLeavesIdentityUnsetAndCopiesSafeDefaults()
+ {
+ var footprint = new[]
+ {
+ new FurnitureFootprintCoordinate(0, 0),
+ new FurnitureFootprintCoordinate(1, 0),
+ };
+ Sprite icon = TestObjectFactory.CreateUninitialized();
+ var source = new FurnitureCloneSource(
+ "native-chair",
+ model: null!,
+ FurniturePlacementMode.Grid,
+ footprint,
+ FurnitureSurfaceType.Roof,
+ allowSurfaceRotation: false,
+ BuildSoundType.Metal,
+ stackLimit: 4,
+ purchasePrice: 125f,
+ resellMultiplier: 0.25f,
+ icon);
+
+ var builder = new FurnitureDefinitionBuilder(source);
+
+ Assert.Null(GetField(builder, "_id"));
+ Assert.Equal("native-chair", GetField(builder, "_donorId"));
+ Assert.Same(footprint, GetField