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: -[![API Coverage](https://img.shields.io/badge/API%20Coverage-31.6%25-orange)](https://github.com/ifBars/S1API/actions/workflows/coverage.yml) +[![API Coverage](https://img.shields.io/badge/API%20Coverage-37.3%25-orange)](https://github.com/ifBars/S1API/actions/workflows/coverage.yml) -![Coverage Chart](https://quickchart.io/chart?c=%7B%22type%22%3A%22line%22%2C%22data%22%3A%7B%22labels%22%3A%5B%222025-12-30%22%2C%222025-12-30%22%2C%222026-01-02%22%2C%222026-01-02%22%2C%222026-01-03%22%2C%222026-01-08%22%2C%222026-01-08%22%2C%222026-01-11%22%2C%222026-01-27%22%2C%222026-01-28%22%2C%222026-01-28%22%2C%222026-01-29%22%2C%222026-02-03%22%2C%222026-02-10%22%2C%222026-02-20%22%2C%222026-04-02%22%2C%222026-06-04%22%2C%222026-06-09%22%2C%222026-06-21%22%2C%222026-07-06%22%2C%222026-07-06%22%5D%2C%22datasets%22%3A%5B%7B%22label%22%3A%22Class%20Coverage%20%25%22%2C%22data%22%3A%5B25%2C27.491785323110623%2C28.039430449069002%2C28.148959474260675%2C28.258488499452355%2C27.854855923159015%2C27.628865979381445%2C27.938144329896907%2C28.24742268041237%2C28.45360824742268%2C29.07216494845361%2C29.175257731958766%2C29.303278688524593%2C29.815573770491806%2C30.43032786885246%2C30.417495029821072%2C30.61630218687873%2C30.91451292246521%2C31.21272365805169%2C31.610337972166995%2C31.610337972166995%5D%2C%22borderColor%22%3A%22rgb%2875%2C%20192%2C%20192%29%22%2C%22backgroundColor%22%3A%22rgba%2875%2C%20192%2C%20192%2C%200.1%29%22%2C%22fill%22%3Afalse%2C%22tension%22%3A0.1%7D%5D%7D%2C%22options%22%3A%7B%22responsive%22%3Atrue%2C%22plugins%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22S1API%20Coverage%20Over%20Time%22%7D%2C%22legend%22%3A%7B%22display%22%3Atrue%2C%22position%22%3A%22top%22%7D%7D%2C%22scales%22%3A%7B%22y%22%3A%7B%22beginAtZero%22%3Atrue%2C%22max%22%3A100%2C%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22Coverage%20%25%22%7D%7D%2C%22x%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22Date%22%7D%7D%7D%7D%7D&width=800&height=400) +![Coverage Chart](https://quickchart.io/chart?c=%7B%22type%22%3A%22line%22%2C%22data%22%3A%7B%22labels%22%3A%5B%222025-12-30%22%2C%222025-12-30%22%2C%222026-01-02%22%2C%222026-01-02%22%2C%222026-01-03%22%2C%222026-01-08%22%2C%222026-01-08%22%2C%222026-01-11%22%2C%222026-01-27%22%2C%222026-01-28%22%2C%222026-01-28%22%2C%222026-01-29%22%2C%222026-02-03%22%2C%222026-02-10%22%2C%222026-02-20%22%2C%222026-04-02%22%2C%222026-06-04%22%2C%222026-06-09%22%2C%222026-06-21%22%2C%222026-07-06%22%2C%222026-08-01%22%2C%222026-08-06%22%2C%222026-08-08%22%2C%222026-08-09%22%2C%222026-08-14%22%2C%222026-08-16%22%2C%222026-08-16%22%5D%2C%22datasets%22%3A%5B%7B%22label%22%3A%22Class%20Coverage%20%25%22%2C%22data%22%3A%5B25%2C27.491785323110623%2C28.039430449069002%2C28.148959474260675%2C28.258488499452355%2C27.854855923159015%2C27.628865979381445%2C27.938144329896907%2C28.24742268041237%2C28.45360824742268%2C29.07216494845361%2C29.175257731958766%2C29.303278688524593%2C29.815573770491806%2C30.43032786885246%2C30.417495029821072%2C30.61630218687873%2C30.91451292246521%2C31.21272365805169%2C31.610337972166995%2C32.01376936316696%2C32.44406196213425%2C35.03649635036496%2C35.21897810218978%2C35.583941605839414%2C36.77007299270073%2C37.31751824817518%5D%2C%22borderColor%22%3A%22rgb%2875%2C%20192%2C%20192%29%22%2C%22backgroundColor%22%3A%22rgba%2875%2C%20192%2C%20192%2C%200.1%29%22%2C%22fill%22%3Afalse%2C%22tension%22%3A0.1%7D%5D%7D%2C%22options%22%3A%7B%22responsive%22%3Atrue%2C%22plugins%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22S1API%20Coverage%20Over%20Time%22%7D%2C%22legend%22%3A%7B%22display%22%3Atrue%2C%22position%22%3A%22top%22%7D%7D%2C%22scales%22%3A%7B%22y%22%3A%7B%22beginAtZero%22%3Atrue%2C%22max%22%3A100%2C%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22Coverage%20%25%22%7D%7D%2C%22x%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22Date%22%7D%7D%7D%7D%7D&width=800&height=400) *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(builder, "_donorFootprint")); + Assert.Equal(FurniturePlacementMode.Grid, GetField(builder, "_placementMode")); + Assert.Equal(2, GetField(builder, "_footprintWidth")); + Assert.Equal(1, GetField(builder, "_footprintDepth")); + Assert.Equal(FurnitureSurfaceType.Roof, GetField(builder, "_surfaceTypes")); + Assert.False(GetField(builder, "_allowSurfaceRotation")); + Assert.Equal(BuildSoundType.Metal, GetField(builder, "_buildSound")); + Assert.Equal(4, GetField(builder, "_stackLimit")); + Assert.Equal(125f, GetField(builder, "_purchasePrice")); + Assert.Equal(0.25f, GetField(builder, "_resellMultiplier")); + Assert.Same(icon, GetField(builder, "_fallbackIcon")); + Assert.True(GetField(builder, "_generateIcon")); + Assert.False(GetField(builder, "_centerModelOnFootprint")); + Assert.True(GetField(builder, "_isolateRepresentationMaterials")); + } + + [Fact] + public void ExplicitPlacementOverridesReplaceDonorDefaults() + { + var footprint = new[] { new FurnitureFootprintCoordinate(0, 0) }; + var source = new FurnitureCloneSource( + "native-picture", + model: null!, + FurniturePlacementMode.Surface, + footprint, + FurnitureSurfaceType.Wall, + allowSurfaceRotation: true, + BuildSoundType.Wood, + stackLimit: 1, + purchasePrice: 1f, + resellMultiplier: 0.5f, + icon: null); + var builder = new FurnitureDefinitionBuilder(source) + .WithPlacement(FurniturePlacementMode.Grid) + .WithFootprint(3, 2) + .WithFootprint(4, 3) + .WithSurfacePlacement(FurnitureSurfaceType.Roof, allowRotation: false); + + Assert.Equal(FurniturePlacementMode.Grid, GetField(builder, "_placementMode")); + Assert.Null(GetField(builder, "_donorFootprint")); + Assert.Equal(4, GetField(builder, "_footprintWidth")); + Assert.Equal(3, GetField(builder, "_footprintDepth")); + Assert.Equal(FurnitureSurfaceType.Roof, GetField(builder, "_surfaceTypes")); + Assert.False(GetField(builder, "_allowSurfaceRotation")); + } + + private static T GetField(FurnitureDefinitionBuilder builder, string name) + { + object? value = typeof(FurnitureDefinitionBuilder) + .GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(builder); + return (T)value!; + } +} diff --git a/S1API.Tests/Items/FurnitureNativeCompositionContractTests.cs b/S1API.Tests/Items/FurnitureNativeCompositionContractTests.cs new file mode 100644 index 00000000..f186a4b6 --- /dev/null +++ b/S1API.Tests/Items/FurnitureNativeCompositionContractTests.cs @@ -0,0 +1,28 @@ +using S1API.Internal.Building; + +namespace S1API.Tests.Items; + +public sealed class FurnitureNativeCompositionContractTests +{ + [Fact] + public void ComposerUsesVerifiedNativeGridAndSurfaceTemplates() + { + Assert.Equal("grandfatherclock", FurnitureTemplateCatalog.GridItemId); + Assert.Equal("wallclock", FurnitureTemplateCatalog.SurfaceItemId); + } + + [Fact] + public void ComposerDoesNotExposeProceduralGridWithoutGenericNativeTemplate() + { + Assert.DoesNotContain( + Enum.GetNames(), + name => name.StartsWith("Procedural", StringComparison.Ordinal)); + } + + [Fact] + public void ComposerAndGhostRuntimeShareAStableVisualMarker() + { + Assert.Equal("FurnitureVisual", BuildableGhostRuntime.FurnitureVisualName); + Assert.Equal("FurnitureGhostVisual", BuildableGhostRuntime.FurnitureGhostVisualName); + } +} diff --git a/S1API.Tests/Items/ItemCreatorApiCompatibilityTests.cs b/S1API.Tests/Items/ItemCreatorApiCompatibilityTests.cs new file mode 100644 index 00000000..b3d97a00 --- /dev/null +++ b/S1API.Tests/Items/ItemCreatorApiCompatibilityTests.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using S1API.Items; +using UnityEngine; + +namespace S1API.Tests.Items; + +#pragma warning disable CS0618 +public sealed class ItemCreatorApiCompatibilityTests +{ + [Fact] + public void LegacyCreateItemOverloadsPreserveSourceAndBinaryShapes() + { + MethodInfo? nineArgumentOverload = typeof(ItemCreator).GetMethod( + nameof(ItemCreator.CreateItem), + new[] + { + typeof(string), typeof(string), typeof(string), typeof(ItemCategory), + typeof(int), typeof(float), typeof(float), typeof(LegalStatus), typeof(Sprite) + }); + MethodInfo? tenArgumentOverload = typeof(ItemCreator).GetMethod( + nameof(ItemCreator.CreateItem), + new[] + { + typeof(string), typeof(string), typeof(string), typeof(ItemCategory), + typeof(int), typeof(float), typeof(float), typeof(LegalStatus), typeof(Sprite), + typeof(Equippable) + }); + + Assert.NotNull(nineArgumentOverload); + Assert.NotNull(tenArgumentOverload); + Assert.Equal("legacyIcon", nineArgumentOverload.GetParameters()[8].Name); + Assert.Equal("icon", tenArgumentOverload.GetParameters()[8].Name); + Assert.Equal("equippable", tenArgumentOverload.GetParameters()[9].Name); + Assert.All(nineArgumentOverload.GetParameters(), parameter => Assert.False(parameter.IsOptional)); + Assert.All(tenArgumentOverload.GetParameters(), parameter => Assert.False(parameter.IsOptional)); + Assert.Single(nineArgumentOverload.GetCustomAttributes()); + Assert.Single(tenArgumentOverload.GetCustomAttributes()); + } +} +#pragma warning restore CS0618 diff --git a/S1API.Tests/Items/ItemCreatorApiCompileFixture.cs b/S1API.Tests/Items/ItemCreatorApiCompileFixture.cs new file mode 100644 index 00000000..cd064116 --- /dev/null +++ b/S1API.Tests/Items/ItemCreatorApiCompileFixture.cs @@ -0,0 +1,55 @@ +using S1API.Items; +using UnityEngine; + +namespace S1API.Tests.Items; + +#pragma warning disable CS0618 +internal static class ItemCreatorApiCompileFixture +{ + internal static void CompileLegacyAndCurrentCallers(Sprite? icon, Equippable? equippable) + { + _ = ItemCreator.CreateItem( + "examplemod:legacy-nine", + "Legacy Nine", + "Legacy positional call without an equippable.", + ItemCategory.Tools, + 1, + 10f, + 0.5f, + LegalStatus.Legal, + icon); + + _ = ItemCreator.CreateItem( + "examplemod:legacy-ten", + "Legacy Ten", + "Legacy positional call with an equippable.", + ItemCategory.Tools, + 1, + 10f, + 0.5f, + LegalStatus.Legal, + icon, + equippable); + + _ = ItemCreator.CreateItem( + "examplemod:current-named-icon", + "Current Named Icon", + "Current call that names the icon parameter.", + ItemCategory.Tools, + legalStatus: LegalStatus.Legal, + icon: icon); + + _ = ItemCreator.CreateItem( + "examplemod:current-rank", + "Current Rank", + "Current positional rank-gating call.", + ItemCategory.Tools, + 1, + 10f, + 0.5f, + LegalStatus.Legal, + false, + null); + } +} +#pragma warning restore CS0618 diff --git a/S1API.Tests/Items/RuntimeItemDefinitionRegistryTests.cs b/S1API.Tests/Items/RuntimeItemDefinitionRegistryTests.cs index 1700323e..6a4ac933 100644 --- a/S1API.Tests/Items/RuntimeItemDefinitionRegistryTests.cs +++ b/S1API.Tests/Items/RuntimeItemDefinitionRegistryTests.cs @@ -4,7 +4,6 @@ using S1ItemFramework = ScheduleOne.ItemFramework; #endif -using System.Runtime.CompilerServices; using S1API.Internal.Items; namespace S1API.Tests.Items; @@ -63,7 +62,7 @@ public void ForgottenDefinitionIsNotReapplied() private static S1ItemFramework.ItemDefinition CreateDefinition() { return (S1ItemFramework.ItemDefinition) - RuntimeHelpers.GetUninitializedObject( + TestObjectFactory.CreateUninitialized( typeof(S1ItemFramework.StorableItemDefinition)); } diff --git a/S1API.Tests/PhoneApp/ExitActionTests.cs b/S1API.Tests/PhoneApp/ExitActionTests.cs new file mode 100644 index 00000000..b5045148 --- /dev/null +++ b/S1API.Tests/PhoneApp/ExitActionTests.cs @@ -0,0 +1,94 @@ +using System.Reflection; +using UnityEngine; +using ExitAction = S1API.PhoneApp.ExitAction; +using PhoneAppBase = S1API.PhoneApp.PhoneApp; + +namespace S1API.Tests.PhoneApp; + +public sealed class ExitActionTests +{ + [Fact] + public void UsedForwardsReadsAndWritesToTheNativeAdapter() + { + bool nativeUsed = false; + var exit = new ExitAction( + () => nativeUsed, + used => nativeUsed = used); + + Assert.False(exit.Used); + + exit.Used = true; + + Assert.True(nativeUsed); + Assert.True(exit.Used); + } + + [Fact] + public void ConstructorRejectsMissingNativeAccessors() + { + Assert.Throws(() => + new ExitAction(null!, _ => { })); + Assert.Throws(() => + new ExitAction(() => false, null!)); + } + + [Fact] + public void PhoneAppExitUsesOnlyTheS1ApiOwnedActionType() + { + MethodInfo exit = typeof(PhoneAppBase).GetMethod( + nameof(PhoneAppBase.Exit), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: [typeof(ExitAction)], + modifiers: null)!; + + Assert.NotNull(exit); + Assert.True(exit.IsVirtual); + Assert.False(exit.IsFinal); + Assert.Equal(typeof(void), exit.ReturnType); + + ParameterInfo parameter = Assert.Single(exit.GetParameters()); + Assert.Equal("exit", parameter.Name); + Assert.Equal("S1API", parameter.ParameterType.Assembly.GetName().Name); + Assert.Equal("S1API.PhoneApp.ExitAction", parameter.ParameterType.FullName); + } + + [Fact] + public void ExitActionCannotBeConstructedByMods() + { + ConstructorInfo[] publicConstructors = typeof(ExitAction).GetConstructors( + BindingFlags.Instance | BindingFlags.Public); + + Assert.Empty(publicConstructors); + } + + [Fact] + public void ModsCanOverrideExitUsingTheS1ApiOwnedAction() + { + MethodInfo exit = typeof(ExitOverrideCompileFixture).GetMethod( + nameof(PhoneAppBase.Exit), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: [typeof(ExitAction)], + modifiers: null)!; + + Assert.Equal(typeof(ExitOverrideCompileFixture), exit.DeclaringType); + } + + private sealed class ExitOverrideCompileFixture : PhoneAppBase + { + protected override string AppName => "exit-contract"; + protected override string AppTitle => "Exit Contract"; + protected override string IconLabel => "Exit"; + protected override string IconFileName => "exit.png"; + + protected override void OnCreatedUI(GameObject container) + { + } + + public override void Exit(ExitAction exit) + { + exit.Used = true; + } + } +} diff --git a/S1API.Tests/Products/CustomProductDefinitionBuilderContractTests.cs b/S1API.Tests/Products/CustomProductDefinitionBuilderContractTests.cs index 1f7391e1..34f6acff 100644 --- a/S1API.Tests/Products/CustomProductDefinitionBuilderContractTests.cs +++ b/S1API.Tests/Products/CustomProductDefinitionBuilderContractTests.cs @@ -4,7 +4,6 @@ using NativeProductDefinition = ScheduleOne.Product.ProductDefinition; #endif -using System.Runtime.CompilerServices; using S1API.Products; namespace S1API.Tests.Products; @@ -185,10 +184,7 @@ public void ProductKindCompatibilityMappingIsRequiredBeforeNativeCreation() private static ProductDefinition CreateUninitializedTemplate() { - var native = - (NativeProductDefinition)RuntimeHelpers.GetUninitializedObject( - typeof(NativeProductDefinition)); - GC.SuppressFinalize(native); + var native = TestObjectFactory.CreateUninitialized(); return new ProductDefinition(native); } } diff --git a/S1API.Tests/Products/CustomProductDefinitionRegistryTests.cs b/S1API.Tests/Products/CustomProductDefinitionRegistryTests.cs index 0d2e719f..520fa6c0 100644 --- a/S1API.Tests/Products/CustomProductDefinitionRegistryTests.cs +++ b/S1API.Tests/Products/CustomProductDefinitionRegistryTests.cs @@ -6,7 +6,6 @@ using NativeProductDefinition = ScheduleOne.Product.ProductDefinition; #endif -using System.Runtime.CompilerServices; using S1API.Internal.Products; using S1API.Items; using S1API.Products; @@ -76,7 +75,12 @@ public void SaveDescriptorsAreStableScalarSnapshotsOrderedByProductId() RepresentationTemplateId = "weed", ProviderId = "examplemod:provider", ProviderVersion = 1, - ProviderData = "v1" + ProviderData = "v1", + HasGeneratedMixColor = true, + GeneratedMixColorR = 0x12, + GeneratedMixColorG = 0x34, + GeneratedMixColorB = 0x56, + GeneratedMixColorA = 0xFF }); CustomProductDefinitionRegistry.Register( "examplemod", firstId, "Alpha", 50f, CreateDefinition(), metadata, @@ -97,6 +101,11 @@ public void SaveDescriptorsAreStableScalarSnapshotsOrderedByProductId() Assert.Equal(new[] { firstId, secondId }, descriptors.Select(item => item.ProductId)); Assert.Equal("examplemod:provider", descriptors[1].ProviderId); Assert.Equal("v1", descriptors[1].ProviderData); + Assert.True(descriptors[1].HasGeneratedMixColor); + Assert.Equal(0x12, descriptors[1].GeneratedMixColorR); + Assert.Equal(0x34, descriptors[1].GeneratedMixColorG); + Assert.Equal(0x56, descriptors[1].GeneratedMixColorB); + Assert.Equal(0xFF, descriptors[1].GeneratedMixColorA); Assert.DoesNotContain(descriptors, item => item.GetType().GetFields() .Any(field => typeof(UnityEngine.Object).IsAssignableFrom(field.FieldType))); } @@ -484,9 +493,7 @@ public void ValidPackagingReturnsOneImmutableMetadataSnapshot() string productId = CreateProductId(); NativeProductDefinition definition = CreateDefinition(); var nativePackaging = - (NativePackagingDefinition)RuntimeHelpers.GetUninitializedObject( - typeof(NativePackagingDefinition)); - GC.SuppressFinalize(nativePackaging); + TestObjectFactory.CreateUninitialized(); var packaging = new PackagingDefinition(nativePackaging); CustomProductDefinitionMetadata metadata = CreateMetadata(productId, new[] { packaging }); @@ -575,9 +582,7 @@ private static CustomProductDefinitionMetadata CreateMetadata( private static NativeProductDefinition CreateDefinition( string? productId = null) { - var definition = (NativeProductDefinition)RuntimeHelpers.GetUninitializedObject( - typeof(NativeProductDefinition)); - GC.SuppressFinalize(definition); + var definition = TestObjectFactory.CreateUninitialized(); #if MONOMELON if (productId != null) definition.ID = productId; diff --git a/S1API.Tests/Products/CustomProductManifestClientGateTests.cs b/S1API.Tests/Products/CustomProductManifestClientGateTests.cs index 5a2f8810..33c3a553 100644 --- a/S1API.Tests/Products/CustomProductManifestClientGateTests.cs +++ b/S1API.Tests/Products/CustomProductManifestClientGateTests.cs @@ -6,6 +6,12 @@ namespace S1API.Tests.Products; public sealed class CustomProductManifestClientGateTests { + [Fact] + public void HostAcknowledgementTimeoutAllowsForClientWorldLoading() + { + Assert.Equal(60, CustomProductManifestRuntime.HostAcknowledgementTimeoutSeconds); + } + [Fact] public void PlayerDataRequestRunsOnlyAfterMatchingManifestIsAccepted() { diff --git a/S1API.Tests/Products/CustomProductManifestRuntimeContractTests.cs b/S1API.Tests/Products/CustomProductManifestRuntimeContractTests.cs new file mode 100644 index 00000000..866ddbd7 --- /dev/null +++ b/S1API.Tests/Products/CustomProductManifestRuntimeContractTests.cs @@ -0,0 +1,67 @@ +using S1API.Internal.Products; + +namespace S1API.Tests.Products; + +public sealed class CustomProductManifestRuntimeContractTests +{ + [Fact] + public void ClientManifestDeadlineStartsOnlyForFirstDeferredRequest() + { + Assert.True(CustomProductManifestRuntime.ShouldStartClientManifestDeadline( + authorized: false, + currentDeadline: DateTime.MaxValue)); + Assert.False(CustomProductManifestRuntime.ShouldStartClientManifestDeadline( + authorized: false, + currentDeadline: DateTime.UtcNow)); + Assert.False(CustomProductManifestRuntime.ShouldStartClientManifestDeadline( + authorized: true, + currentDeadline: DateTime.MaxValue)); + } + + [Fact] + public void QueuedManifestExemptsClientFromMissingManifestTimeout() + { + DateTime deadline = DateTime.UtcNow; + + Assert.False(CustomProductManifestRuntime.ShouldRejectClientForMissingManifest( + isWaiting: true, + manifestReceived: true, + now: deadline, + deadline: deadline)); + Assert.True(CustomProductManifestRuntime.ShouldRejectClientForMissingManifest( + isWaiting: true, + manifestReceived: false, + now: deadline, + deadline: deadline)); + } + + [Fact] + public void EmptyManifestDoesNotRequireValidation() + { + Assert.False(CustomProductManifestRuntime.RequiresValidation( + new CustomProductManifestData())); + } + + [Fact] + public void DescriptorBackedProductRequiresValidation() + { + var manifest = new CustomProductManifestData + { + Entries = [new CustomProductManifestEntryData()] + }; + + Assert.True(CustomProductManifestRuntime.RequiresValidation(manifest)); + } + + [Fact] + public void MixingOnlyManifestRequiresValidation() + { + var manifest = new CustomProductManifestData + { + MixingProfiles = + [new CustomProductMixingProfileManifestEntryData()] + }; + + Assert.True(CustomProductManifestRuntime.RequiresValidation(manifest)); + } +} diff --git a/S1API.Tests/Products/CustomProductMixingIdentityTests.cs b/S1API.Tests/Products/CustomProductMixingIdentityTests.cs index 4b6b47ce..2224326c 100644 --- a/S1API.Tests/Products/CustomProductMixingIdentityTests.cs +++ b/S1API.Tests/Products/CustomProductMixingIdentityTests.cs @@ -19,6 +19,7 @@ public void PostSanitizationNativeIdGetsStableNamespacedGeneratedIdentity() nativePostSanitizationId); Assert.StartsWith("moredrugs:mix/mdma/", generated); + Assert.Equal(83, generated.Length); Assert.Equal("moredrugs", CustomProductDefinitionBuilderContract.GetOwnerId(generated)); Assert.Equal(generated, CustomProductMixingIdentity.CreateGeneratedProductId( @@ -26,4 +27,53 @@ public void PostSanitizationNativeIdGetsStableNamespacedGeneratedIdentity() Assert.True(CustomProductMixingIdentity.IsGeneratedIdForSource( "moredrugs:mdma", generated)); } + + [Fact] + public void FourthAndDeeperGeneratedMixesStayWithinManifestIdentifierLimit() + { + string source = "ifbars.moredrugs:products/mdma"; + + for (int depth = 1; depth <= 16; depth++) + { + string generated = CustomProductMixingIdentity.CreateGeneratedProductId( + source, + "native-mix-" + depth); + + Assert.True(CustomProductManifestData.IsBoundedIdentifier(generated)); + if (depth == 4) + Assert.Equal(150, generated.Length); + Assert.True(CustomProductMixingIdentity.IsGeneratedIdForSource( + source, + generated)); + source = generated; + } + } + + [Fact] + public void LongManifestValidSourceUsesBoundedFrameworkFallbackOwner() + { + string source = new string('a', 250) + ":x"; + + string generated = CustomProductMixingIdentity.CreateGeneratedProductId( + source, + "native-mix"); + + Assert.True(CustomProductManifestData.IsBoundedIdentifier(generated)); + Assert.StartsWith("s1api:mix/", generated); + Assert.True(CustomProductMixingIdentity.IsGeneratedIdForSource( + source, + generated)); + } + + [Fact] + public void LegacyGeneratedMixIdRemainsRecognizedForItsSource() + { + const string source = "moredrugs:mdma"; + const string legacyGenerated = "moredrugs:mix/mdma/" + + "7b1f0cb91f2bb6ca444caa221377fcc14c1354e500f8d0b1a2b039d0f73fe78b"; + + Assert.True(CustomProductMixingIdentity.IsGeneratedIdForSource( + source, + legacyGenerated)); + } } diff --git a/S1API.Tests/Products/CustomProductMixingIngredientContractTests.cs b/S1API.Tests/Products/CustomProductMixingIngredientContractTests.cs new file mode 100644 index 00000000..009ade39 --- /dev/null +++ b/S1API.Tests/Products/CustomProductMixingIngredientContractTests.cs @@ -0,0 +1,20 @@ +using S1API.Internal.Patches; +using Xunit; + +namespace S1API.Tests.Products; + +public sealed class CustomProductMixingIngredientContractTests +{ + [Theory] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(2, true)] + public void NativeMixerContractAcceptsAnyNonEmptyPropertyList( + int propertyCount, + bool expected) + { + Assert.Equal( + expected, + CustomProductMixingIngredientContract.HasUsableProperty(propertyCount)); + } +} diff --git a/S1API.Tests/Products/ProductApiCompatibilityTests.cs b/S1API.Tests/Products/ProductApiCompatibilityTests.cs index 81f7b520..1be451c5 100644 --- a/S1API.Tests/Products/ProductApiCompatibilityTests.cs +++ b/S1API.Tests/Products/ProductApiCompatibilityTests.cs @@ -94,11 +94,20 @@ public void NewRuntimeAgnosticMembersAreAdditive() { var primaryDrugType = typeof(ProductDefinition).GetProperty(nameof(ProductDefinition.PrimaryDrugType)); var drugTypeValues = typeof(ProductDefinition).GetProperty(nameof(ProductDefinition.DrugTypeValues)); + var propertyColorMixing = typeof(ProductMixingProfileBuilder).GetMethod( + nameof(ProductMixingProfileBuilder.WithPropertyColorMixing), + Type.EmptyTypes); + var usesPropertyColorMixing = typeof(ProductMixingProfile).GetProperty( + nameof(ProductMixingProfile.UsePropertyColorMixing)); Assert.NotNull(primaryDrugType); Assert.Equal(typeof(DrugType), primaryDrugType.PropertyType); Assert.NotNull(drugTypeValues); Assert.Equal(typeof(IReadOnlyList), drugTypeValues.PropertyType); + Assert.NotNull(propertyColorMixing); + Assert.Equal(typeof(ProductMixingProfileBuilder), propertyColorMixing.ReturnType); + Assert.NotNull(usesPropertyColorMixing); + Assert.Equal(typeof(bool), usesPropertyColorMixing.PropertyType); } [Fact] diff --git a/S1API.Tests/Products/ProductDefinitionWrapperTests.cs b/S1API.Tests/Products/ProductDefinitionWrapperTests.cs index 40127d17..06dc1321 100644 --- a/S1API.Tests/Products/ProductDefinitionWrapperTests.cs +++ b/S1API.Tests/Products/ProductDefinitionWrapperTests.cs @@ -1,7 +1,6 @@ #if IL2CPPMELON using NativeProductDefinition = Il2CppScheduleOne.Product.ProductDefinition; #elif MONOMELON -using System.Runtime.CompilerServices; using NativeProductDefinition = ScheduleOne.Product.ProductDefinition; #endif @@ -57,7 +56,8 @@ public void NativeAndExistingWrapperPathsReturnTheSameTypedWrapper( Type nativeDefinitionType, Type expectedWrapperType) { - var nativeDefinition = (NativeProductDefinition)RuntimeHelpers.GetUninitializedObject(nativeDefinitionType); + var nativeDefinition = (NativeProductDefinition) + TestObjectFactory.CreateUninitialized(nativeDefinitionType); var wrappedFromNative = ProductDefinitionWrapper.Wrap(nativeDefinition); var wrappedFromExistingWrapper = ProductDefinitionWrapper.Wrap(new ProductDefinition(nativeDefinition)); @@ -69,8 +69,7 @@ public void NativeAndExistingWrapperPathsReturnTheSameTypedWrapper( [Fact] public void ExistingGenericWrapperRemainsTheFallbackInstance() { - var nativeDefinition = (NativeProductDefinition)RuntimeHelpers.GetUninitializedObject( - typeof(NativeProductDefinition)); + var nativeDefinition = TestObjectFactory.CreateUninitialized(); var existingWrapper = new ProductDefinition(nativeDefinition); var wrapped = ProductDefinitionWrapper.Wrap(existingWrapper); diff --git a/S1API.Tests/Products/ProductEffectCallbackTests.cs b/S1API.Tests/Products/ProductEffectCallbackTests.cs index 64173229..86393070 100644 --- a/S1API.Tests/Products/ProductEffectCallbackTests.cs +++ b/S1API.Tests/Products/ProductEffectCallbackTests.cs @@ -1,5 +1,4 @@ using System; -using System.Runtime.CompilerServices; using S1API.Entities; using S1API.Entities.NPCs; using S1API.Products; @@ -61,7 +60,7 @@ public void PlayerClearCallbackInvokesRepeatedlyAndCanAllowNativeFallthrough() [Fact] public void NpcClearCallbackInvokesAndCanAllowNativeFallthrough() { - var npc = (NPC)RuntimeHelpers.GetUninitializedObject(typeof(DanSamwell)); + var npc = (NPC)TestObjectFactory.CreateUninitialized(typeof(DanSamwell)); var clearCount = 0; ProductManager.SetNpcEffectClearCallback("test_npc_clear", _ => clearCount++, allowDefaultEffect: true); @@ -75,7 +74,7 @@ public void NpcClearCallbackInvokesAndCanAllowNativeFallthrough() public void MissingClearCallbackUsesNativeFallthrough() { var player = CreatePlayer(); - var npc = (NPC)RuntimeHelpers.GetUninitializedObject(typeof(DanSamwell)); + var npc = (NPC)TestObjectFactory.CreateUninitialized(typeof(DanSamwell)); Assert.False(ProductManager.TryInvokeEffectClearCallback("missing", player, out var playerAllowDefault)); Assert.False(ProductManager.TryInvokeNpcEffectClearCallback("missing", npc, out var npcAllowDefault)); @@ -106,7 +105,7 @@ public void ClearCallbackRegistrationReplacesPriorCallbackAndRemovesCleanly() public void ResetClearCallbacksRemovesPlayerAndNpcRegistrations() { var player = CreatePlayer(); - var npc = (NPC)RuntimeHelpers.GetUninitializedObject(typeof(DanSamwell)); + var npc = (NPC)TestObjectFactory.CreateUninitialized(typeof(DanSamwell)); ProductManager.SetEffectClearCallback("test_reset_player", _ => { }); ProductManager.SetNpcEffectClearCallback("test_reset_npc", _ => { }); @@ -169,5 +168,5 @@ public void ClearCallbackRegistrationRejectsNullInputs() } private static Player CreatePlayer() => - (Player)RuntimeHelpers.GetUninitializedObject(typeof(Player)); + TestObjectFactory.CreateUninitialized(); } diff --git a/S1API.Tests/Products/ProductIconRenderRigArbiterTests.cs b/S1API.Tests/Products/ProductIconRenderRigArbiterTests.cs new file mode 100644 index 00000000..ebd6943a --- /dev/null +++ b/S1API.Tests/Products/ProductIconRenderRigArbiterTests.cs @@ -0,0 +1,51 @@ +using S1API.Internal.Products; + +namespace S1API.Tests.Products; + +public sealed class ProductIconRenderRigArbiterTests : IDisposable +{ + public ProductIconRenderRigArbiterTests() + { + ProductIconRenderRigArbiter.ResetForTesting(); + } + + [Fact] + public void CaptureLeasesAreGrantedOneAtATimeInQueueOrder() + { + ProductIconRenderRigArbiter.CaptureLease first = + ProductIconRenderRigArbiter.Enqueue(); + ProductIconRenderRigArbiter.CaptureLease second = + ProductIconRenderRigArbiter.Enqueue(); + + Assert.False(ProductIconRenderRigArbiter.TryAcquire(second)); + Assert.True(ProductIconRenderRigArbiter.TryAcquire(first)); + Assert.False(ProductIconRenderRigArbiter.TryAcquire(second)); + + ProductIconRenderRigArbiter.Release(first); + + Assert.True(ProductIconRenderRigArbiter.TryAcquire(second)); + } + + [Fact] + public void CancellingAWaitingLeaseUnblocksTheNextCapture() + { + ProductIconRenderRigArbiter.CaptureLease first = + ProductIconRenderRigArbiter.Enqueue(); + ProductIconRenderRigArbiter.CaptureLease cancelled = + ProductIconRenderRigArbiter.Enqueue(); + ProductIconRenderRigArbiter.CaptureLease last = + ProductIconRenderRigArbiter.Enqueue(); + + Assert.True(ProductIconRenderRigArbiter.TryAcquire(first)); + ProductIconRenderRigArbiter.Cancel(cancelled); + ProductIconRenderRigArbiter.Release(first); + + Assert.False(ProductIconRenderRigArbiter.TryAcquire(cancelled)); + Assert.True(ProductIconRenderRigArbiter.TryAcquire(last)); + } + + public void Dispose() + { + ProductIconRenderRigArbiter.ResetForTesting(); + } +} diff --git a/S1API.Tests/Products/ProductKindMetadataRegistryTests.cs b/S1API.Tests/Products/ProductKindMetadataRegistryTests.cs index 10be1430..92ff8b08 100644 --- a/S1API.Tests/Products/ProductKindMetadataRegistryTests.cs +++ b/S1API.Tests/Products/ProductKindMetadataRegistryTests.cs @@ -1,4 +1,3 @@ -using System.Runtime.CompilerServices; using S1API.Internal.Products; using S1API.Products; using UnityEngine; @@ -282,8 +281,7 @@ public void IconValidationUsesUnityLifetimeSemantics() Assert.True(ProductKindIconLifetime.IsNullOrDestroyed(null)); #if MONOMELON - Sprite destroyedIcon = - (Sprite)RuntimeHelpers.GetUninitializedObject(typeof(Sprite)); + Sprite destroyedIcon = TestObjectFactory.CreateUninitialized(); Assert.True(ProductKindIconLifetime.IsNullOrDestroyed(destroyedIcon)); Assert.Throws( () => new ProductKindMetadataBuilder(kind) @@ -405,8 +403,7 @@ private static ProductKind CreateKind(DrugType drugType = DrugType.Marijuana) private static Sprite CreateSprite() { - var sprite = - (Sprite)RuntimeHelpers.GetUninitializedObject(typeof(Sprite)); + var sprite = TestObjectFactory.CreateUninitialized(); #if MONOMELON SetCachedPointer(sprite, new IntPtr(1)); #endif diff --git a/S1API.Tests/Products/ProductManagerUiRuntimeContractTests.cs b/S1API.Tests/Products/ProductManagerUiRuntimeContractTests.cs index d657d3c2..adee44e6 100644 --- a/S1API.Tests/Products/ProductManagerUiRuntimeContractTests.cs +++ b/S1API.Tests/Products/ProductManagerUiRuntimeContractTests.cs @@ -1,4 +1,3 @@ -using System.Runtime.CompilerServices; using S1API.Internal.Products; using UnityEngine; @@ -74,8 +73,7 @@ public void MissingSectionIconClearsClonedSpriteAndUsesNeutralTint() [Fact] public void LiveSectionIconUsesTheRegisteredSpriteAndNeutralTint() { - Sprite sprite = - (Sprite)RuntimeHelpers.GetUninitializedObject(typeof(Sprite)); + Sprite sprite = TestObjectFactory.CreateUninitialized(); #if MONOMELON typeof(UnityEngine.Object) .GetField( diff --git a/S1API.Tests/Products/ProductMixingColorContractTests.cs b/S1API.Tests/Products/ProductMixingColorContractTests.cs new file mode 100644 index 00000000..09c81811 --- /dev/null +++ b/S1API.Tests/Products/ProductMixingColorContractTests.cs @@ -0,0 +1,46 @@ +using S1API.Internal.Products; +using S1API.Products; +using Xunit; + +namespace S1API.Tests.Products; + +public sealed class ProductMixingColorContractTests +{ + [Fact] + public void CocaineStrategyMatchesNativePrimaryColorFormula() + { + var lowerTier = new ProductMixingColorSample( + 3, + new ProductMixingColorValue(255, 0, 0, 255)); + var higherTier = new ProductMixingColorSample( + 5, + new ProductMixingColorValue(0, 0, 255, 255)); + + ProductMixingColorValue actual = + ProductMixingColorContract.CalculatePrimaryColor( + ProductMixingMap.Cocaine, + new[] { higherTier, lowerTier }); + + Assert.Equal( + new ProductMixingColorValue(255, 155, 155, 255), + actual); + } + + [Theory] + [InlineData(ProductMixingMap.Marijuana, 90, 100, 70)] + [InlineData(ProductMixingMap.Methamphetamine, 255, 255, 255)] + [InlineData(ProductMixingMap.Cocaine, 255, 255, 255)] + [InlineData(ProductMixingMap.Shrooms, 168, 125, 43)] + public void EmptyPropertiesUseNativePrimaryBaseColor( + ProductMixingMap map, + byte red, + byte green, + byte blue) + { + Assert.Equal( + new ProductMixingColorValue(red, green, blue, 255), + ProductMixingColorContract.CalculatePrimaryColor( + map, + Array.Empty())); + } +} diff --git a/S1API.Tests/Products/ProductMixingProfileTests.cs b/S1API.Tests/Products/ProductMixingProfileTests.cs index 75a4f4f1..1c04aba6 100644 --- a/S1API.Tests/Products/ProductMixingProfileTests.cs +++ b/S1API.Tests/Products/ProductMixingProfileTests.cs @@ -1,4 +1,5 @@ using System; +using S1API.Internal.Products; using S1API.Products; using Xunit; @@ -20,6 +21,7 @@ public void ProfileRegistrationRetainsKindAndInvokesDeterministicFactory() Assert.Same(profile, ProductMixingProfiles.Get(kind)); Assert.Equal(ProductMixingMap.Marijuana, profile.MixerMap); + Assert.False(profile.UsePropertyColorMixing); ProductMixingOutputDefinition output = profile.OutputFactory( new ProductMixingOutput("example:output", "Named Mix", "example:source", kind, 20f)); Assert.Equal("Named Mix Output", output.Name); @@ -27,6 +29,55 @@ public void ProfileRegistrationRetainsKindAndInvokesDeterministicFactory() Assert.Equal(30f, output.Price); } + [Fact] + public void PropertyColorMixingIsExplicitlyOptIn() + { + ProductKind kind = CreateKind(); + + ProductMixingProfile profile = new ProductMixingProfileBuilder(kind) + .WithMixerMap(ProductMixingMap.Cocaine) + .WithPropertyColorMixing() + .WithOutputFactory(input => new ProductMixingOutputDefinition( + input.MixName, + input.SourceKind, + input.SourcePrice)) + .Build(); + + Assert.True(profile.UsePropertyColorMixing); + } + + [Fact] + public void PropertyColorMixingParticipatesInMultiplayerCompatibility() + { + ProductKind kind = CreateKind(); + ProductMixingProfile baseline = new ProductMixingProfileBuilder(kind) + .WithMixerMap(ProductMixingMap.Cocaine) + .WithOutputFactoryCompatibility("mixingtests:factory", 1) + .WithOutputFactory(input => new ProductMixingOutputDefinition( + input.MixName, + input.SourceKind, + input.SourcePrice)) + .Build(); + ProductKind coloredKind = CreateKind(); + ProductMixingProfile colored = new ProductMixingProfileBuilder(coloredKind) + .WithMixerMap(ProductMixingMap.Cocaine) + .WithPropertyColorMixing() + .WithOutputFactoryCompatibility("mixingtests:factory", 1) + .WithOutputFactory(input => new ProductMixingOutputDefinition( + input.MixName, + input.SourceKind, + input.SourcePrice)) + .Build(); + + var baselineEntry = CustomProductMixingProfileManifestEntryData.Create(baseline); + var coloredEntry = CustomProductMixingProfileManifestEntryData.Create(colored); + coloredEntry.ProductKindId = baselineEntry.ProductKindId; + + Assert.Equal( + "property-color mixing strategy differs", + baselineEntry.DescribeMismatch(coloredEntry)); + } + [Fact] public void MissingOutputFactoryFailsBeforeRegistration() { diff --git a/S1API.Tests/Products/ProductPackagingContentFallbackTests.cs b/S1API.Tests/Products/ProductPackagingContentFallbackTests.cs index b27c7a8c..eaf8d1be 100644 --- a/S1API.Tests/Products/ProductPackagingContentFallbackTests.cs +++ b/S1API.Tests/Products/ProductPackagingContentFallbackTests.cs @@ -5,7 +5,6 @@ #endif using System; -using System.Runtime.CompilerServices; using S1API.Internal.Products; using S1API.Products; using Xunit; @@ -92,11 +91,7 @@ public void ProductSpecificBrickProfilePrecedesLogicalKindFallback() private static NativeProductDefinition CreateDefinition() { - var definition = - (NativeProductDefinition)RuntimeHelpers.GetUninitializedObject( - typeof(NativeProductDefinition)); - GC.SuppressFinalize(definition); - return definition; + return TestObjectFactory.CreateUninitialized(); } private sealed class FakeProductRuntime : diff --git a/S1API.Tests/Products/ProductPackagingContentProfileTests.cs b/S1API.Tests/Products/ProductPackagingContentProfileTests.cs index 4e188b6a..ae08c4bd 100644 --- a/S1API.Tests/Products/ProductPackagingContentProfileTests.cs +++ b/S1API.Tests/Products/ProductPackagingContentProfileTests.cs @@ -1,5 +1,4 @@ using S1API.Products; -using System.Runtime.CompilerServices; using UnityEngine; namespace S1API.Tests.Products; @@ -204,8 +203,6 @@ public void NullPlacementInsideArrayIsRejected() private static ProductPresentationTransform CreatePlacementWithoutUnityRuntime() { - return - (ProductPresentationTransform)RuntimeHelpers.GetUninitializedObject( - typeof(ProductPresentationTransform)); + return TestObjectFactory.CreateUninitialized(); } } diff --git a/S1API.Tests/Products/ProductPresentationProfileRegistryTests.cs b/S1API.Tests/Products/ProductPresentationProfileRegistryTests.cs index 65c3d007..fa35c18b 100644 --- a/S1API.Tests/Products/ProductPresentationProfileRegistryTests.cs +++ b/S1API.Tests/Products/ProductPresentationProfileRegistryTests.cs @@ -4,7 +4,6 @@ using NativeProductDefinition = ScheduleOne.Product.ProductDefinition; #endif -using System.Runtime.CompilerServices; using S1API.Internal.Products; using S1API.Products; @@ -282,11 +281,7 @@ private static ProductKind CreateKind() private static NativeProductDefinition CreateDefinition() { - var definition = - (NativeProductDefinition)RuntimeHelpers.GetUninitializedObject( - typeof(NativeProductDefinition)); - GC.SuppressFinalize(definition); - return definition; + return TestObjectFactory.CreateUninitialized(); } private static string CreateId() diff --git a/S1API.Tests/Rendering/PresentationWorkbenchTests.cs b/S1API.Tests/Rendering/PresentationWorkbenchTests.cs index 7554883b..70a360de 100644 --- a/S1API.Tests/Rendering/PresentationWorkbenchTests.cs +++ b/S1API.Tests/Rendering/PresentationWorkbenchTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using S1API.Internal.Console; using S1API.Internal.Rendering; using S1API.Rendering; using UnityEngine; @@ -7,6 +8,14 @@ namespace S1API.Tests.Rendering; public sealed class PresentationWorkbenchTests { + [Fact] + public void ConsoleCommandUsesTheCompactCommandWord() + { + Assert.Equal( + "presentationworkbench", + new PresentationWorkbenchCommand().CommandWord); + } + [Theory] [InlineData("product")] [InlineData("PRODUCT")] diff --git a/S1API.Tests/S1API.Tests.csproj b/S1API.Tests/S1API.Tests.csproj index 7c5fa730..87670586 100644 --- a/S1API.Tests/S1API.Tests.csproj +++ b/S1API.Tests/S1API.Tests.csproj @@ -28,6 +28,7 @@ all + @@ -43,6 +44,9 @@ $(MonoAssembliesPath)/UnityEngine.CoreModule.dll + + $(MonoAssembliesPath)/UnityEngine.PhysicsModule.dll + @@ -55,5 +59,6 @@ + diff --git a/S1API.Tests/Storage/StorageRestorePolicyTests.cs b/S1API.Tests/Storage/StorageRestorePolicyTests.cs new file mode 100644 index 00000000..50e798a0 --- /dev/null +++ b/S1API.Tests/Storage/StorageRestorePolicyTests.cs @@ -0,0 +1,18 @@ +using S1API.Internal.Patches; + +namespace S1API.Tests.Storage; + +public sealed class StorageRestorePolicyTests +{ + [Fact] + public void PersistedSlotCountCanExceedTheDefaultWrapperLimit() + { + Assert.Equal(24, StoragePatches.ResolveRestoreMaxSlots(20, 24)); + } + + [Fact] + public void PersistedSlotCountDoesNotLowerAnExplicitLimit() + { + Assert.Equal(32, StoragePatches.ResolveRestoreMaxSlots(32, 24)); + } +} diff --git a/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs b/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs new file mode 100644 index 00000000..548db2b2 --- /dev/null +++ b/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs @@ -0,0 +1,37 @@ +using System; +using S1API.Temperature; +using UnityEngine; + +namespace S1API.Tests.Temperature; + +internal static class TemperatureApiCompileFixture +{ + internal static void ConfigureAndQuery(GameObject gameObject) + { + TemperatureEmitter emitter = TemperatureEmitter.GetOrAddComponent(gameObject); + emitter.SetTemperature(20f); + emitter.SetRange(5f); + emitter.SetPosition(Vector3.zero); + + Action changed = () => { }; + emitter.OnChanged += changed; + emitter.OnChanged -= changed; + + TemperatureEmitter? existing = TemperatureEmitter.FromGameObject(gameObject); + TemperatureEmitterInfo[] emitters = { emitter.ToInfo() }; + float temperature = TemperatureAlgorithm.GetTemperatureAtPoint( + ambientTemperature: 20f, + originPoint: Vector3.zero, + point: Vector3.one, + emitters: emitters); + + _ = existing; + _ = temperature; + _ = TemperatureUtility.TemperatureSystemEnabled; + _ = TemperatureUtility.ToFahrenheit(20f); + _ = TemperatureUtility.FormatCelsiusTemperature(20f, decimalPoints: 1); + _ = TemperatureUtility.FormatFahrenheitTemperature(68f, decimalPoints: 1); + _ = TemperatureUtility.FormatTemperatureWithAppropriateUnit(20f); + _ = TemperatureUtility.NormalizeTemperature(20f); + } +} diff --git a/S1API.Tests/Temperature/TemperatureContractTests.cs b/S1API.Tests/Temperature/TemperatureContractTests.cs new file mode 100644 index 00000000..01685252 --- /dev/null +++ b/S1API.Tests/Temperature/TemperatureContractTests.cs @@ -0,0 +1,128 @@ +using System.Reflection; +using S1API.Internal.Temperature; +using S1API.Temperature; +using UnityEngine; + +namespace S1API.Tests.Temperature; + +public sealed class TemperatureContractTests +{ +#if MONOMELON + [Fact] + public void SnapshotUsesLinearRangeAndClampsToNativeEmitterBounds() + { + var minimum = new TemperatureEmitterInfo(-10f, -5f, Vector3.zero); + var maximum = new TemperatureEmitterInfo(50f, 200f, Vector3.one); + + Assert.Equal(TemperatureEmitter.MinTemperature, minimum.Temperature); + Assert.Equal(TemperatureEmitter.MinRange, minimum.Range); + Assert.Equal(TemperatureEmitter.MaxTemperature, maximum.Temperature); + Assert.Equal(TemperatureEmitter.MaxRange, maximum.Range); + Assert.Null(typeof(TemperatureEmitterInfo).GetProperty("SqrRange", BindingFlags.Instance | BindingFlags.Public)); + } + + [Fact] + public void SnapshotRejectsNonFiniteValues() + { + foreach (float value in new[] { float.NaN, float.NegativeInfinity, float.PositiveInfinity }) + { + Assert.Throws(() => + new TemperatureEmitterInfo(value, TemperatureEmitter.DefaultRange, Vector3.zero)); + Assert.Throws(() => + new TemperatureEmitterInfo(TemperatureEmitter.DefaultAmbientTemperature, value, Vector3.zero)); + } + + Assert.Throws(() => + new TemperatureEmitterInfo( + TemperatureEmitter.DefaultAmbientTemperature, + TemperatureEmitter.DefaultRange, + new Vector3(float.NaN, 0f, 0f))); + } + + [Fact] + public void QueryRejectsNullSnapshotsBeforeCallingTheNativeRuntime() + { + Assert.Throws(() => TemperatureAlgorithm.GetTemperatureAtPoint( + TemperatureEmitter.DefaultAmbientTemperature, + Vector3.zero, + Vector3.zero, + null!)); + } +#endif + + [Fact] + public void ManagedScalarValidationMatchesNativeEmitterBounds() + { + Assert.Equal( + TemperatureValidation.MinTemperature, + TemperatureValidation.ClampTemperature(-10f, "temperature")); + Assert.Equal( + TemperatureValidation.MaxTemperature, + TemperatureValidation.ClampTemperature(50f, "temperature")); + Assert.Equal( + TemperatureValidation.MinRange, + TemperatureValidation.ClampRange(-5f, "range")); + Assert.Equal( + TemperatureValidation.MaxRange, + TemperatureValidation.ClampRange(200f, "range")); + + foreach (float value in new[] { float.NaN, float.NegativeInfinity, float.PositiveInfinity }) + { + Assert.Throws(() => + TemperatureValidation.ClampTemperature(value, "temperature")); + Assert.Throws(() => + TemperatureValidation.ClampRange(value, "range")); + } + } + + [Fact] + public void PublicSurfaceExposesManagedTemperatureContractsOnly() + { + Assert.NotNull(typeof(TemperatureUtility).GetProperty( + nameof(TemperatureUtility.TemperatureSystemEnabled), + BindingFlags.Public | BindingFlags.Static)); + Assert.NotNull(typeof(TemperatureEmitter).GetMethod( + nameof(TemperatureEmitter.ToInfo), + BindingFlags.Public | BindingFlags.Instance)); + + Type[] publicTypes = + { + typeof(TemperatureEmitter), + typeof(TemperatureEmitterInfo), + typeof(TemperatureAlgorithm), + typeof(TemperatureUtility) + }; + foreach (Type type in publicTypes) + { + foreach (MemberInfo member in type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + { + foreach (Type exposedType in GetExposedTypes(member)) + { + Assert.DoesNotContain("ScheduleOne", exposedType.Namespace ?? string.Empty, StringComparison.Ordinal); + Assert.DoesNotContain("Il2Cpp", exposedType.FullName ?? string.Empty, StringComparison.Ordinal); + } + } + } + } + + private static IEnumerable GetExposedTypes(MemberInfo member) + { + switch (member) + { + case MethodInfo method: + yield return method.ReturnType; + foreach (ParameterInfo parameter in method.GetParameters()) + yield return parameter.ParameterType; + break; + case PropertyInfo property: + yield return property.PropertyType; + break; + case FieldInfo field: + yield return field.FieldType; + break; + case EventInfo eventInfo: + yield return eventInfo.EventHandlerType!; + break; + } + } +} diff --git a/S1API.Tests/TestObjectFactory.cs b/S1API.Tests/TestObjectFactory.cs new file mode 100644 index 00000000..e5b26716 --- /dev/null +++ b/S1API.Tests/TestObjectFactory.cs @@ -0,0 +1,19 @@ +using System.Runtime.CompilerServices; + +namespace S1API.Tests; + +internal static class TestObjectFactory +{ + public static T CreateUninitialized() where T : class => + (T)CreateUninitialized(typeof(T)); + + public static object CreateUninitialized(Type type) + { + object instance = RuntimeHelpers.GetUninitializedObject(type); + + // IL2CPP wrappers finalize through GameAssembly, which is unavailable in + // the standalone test host. These fixtures never own a native object. + GC.SuppressFinalize(instance); + return instance; + } +} diff --git a/S1API.Tests/Trash/TrashApiCompatibilityTests.cs b/S1API.Tests/Trash/TrashApiCompatibilityTests.cs new file mode 100644 index 00000000..25be3717 --- /dev/null +++ b/S1API.Tests/Trash/TrashApiCompatibilityTests.cs @@ -0,0 +1,46 @@ +using S1API.Items.Storable; +using UnityEngine; + +namespace S1API.Tests.Trash; + +public sealed class TrashApiCompatibilityTests +{ + [Fact] + public void TrashManagerExposesPrefabRegistration() + { + var method = typeof(global::S1API.Trash.TrashManager).GetMethod( + nameof(global::S1API.Trash.TrashManager.RegisterTrashPrefab), + new[] { typeof(string), typeof(GameObject), typeof(bool) }); + + Assert.NotNull(method); + Assert.Equal(typeof(GameObject), method!.ReturnType); + } + + [Fact] + public void StorableBuilderExposesBothTrashPrefabOverloads() + { + Assert.NotNull( + typeof(StorableItemDefinitionBuilder).GetMethod( + nameof(StorableItemDefinitionBuilder.WithTrashPrefab), + new[] { typeof(GameObject), typeof(bool) })); + Assert.NotNull( + typeof(StorableItemDefinitionBuilder).GetMethod( + nameof(StorableItemDefinitionBuilder.WithTrashPrefab), + new[] { typeof(string), typeof(GameObject), typeof(bool) })); + } + + [Fact] + public void StorableBuilderDerivesDefaultTrashIdFromItemId() + { + Assert.Equal( + "example.mod:precursor_trash", + StorableItemDefinitionBuilderBase + .ResolveTrashId("example.mod:precursor", trashId: null)); + Assert.Equal( + "example.mod:empty-bottle", + StorableItemDefinitionBuilderBase + .ResolveTrashId( + "example.mod:precursor", + "example.mod:empty-bottle")); + } +} diff --git a/S1API.Tests/Trash/TrashContainerApiCompatibilityTests.cs b/S1API.Tests/Trash/TrashContainerApiCompatibilityTests.cs new file mode 100644 index 00000000..85f9c8e2 --- /dev/null +++ b/S1API.Tests/Trash/TrashContainerApiCompatibilityTests.cs @@ -0,0 +1,56 @@ +using System.Reflection; +using S1API.Trash; + +namespace S1API.Tests.Trash; + +public sealed class TrashContainerApiCompatibilityTests +{ + [Fact] + public void WrapperSurfaceUsesManagedTypes() + { + Assert.True(typeof(TrashContainer).IsSealed); + Assert.Equal(typeof(int), GetProperty(nameof(TrashContainer.Capacity)).PropertyType); + Assert.Equal(typeof(int), GetProperty(nameof(TrashContainer.Level)).PropertyType); + Assert.Equal(typeof(float), GetProperty(nameof(TrashContainer.NormalizedLevel)).PropertyType); + Assert.Equal( + typeof(IReadOnlyList), + GetProperty(nameof(TrashContainer.Contents)).PropertyType); + Assert.Equal(typeof(bool), GetProperty(nameof(TrashContainer.CanBeBagged)).PropertyType); + Assert.Equal(typeof(Action), GetEvent(nameof(TrashContainer.OnTrashAdded)).EventHandlerType); + Assert.Equal(typeof(Action), GetEvent(nameof(TrashContainer.OnTrashLevelChanged)).EventHandlerType); + Assert.Equal(typeof(bool), GetMethod(nameof(TrashContainer.TryBagTrash)).ReturnType); + } + + [Fact] + public void ContentEntryIsAnImmutableManagedValue() + { + Assert.True(typeof(TrashContentEntry).IsValueType); + Assert.True(typeof(TrashContentEntry).IsDefined(typeof(System.Runtime.CompilerServices.IsReadOnlyAttribute))); + + PropertyInfo[] properties = typeof(TrashContentEntry).GetProperties(BindingFlags.Instance | BindingFlags.Public); + Assert.Equal(4, properties.Length); + Assert.All(properties, property => Assert.Null(property.SetMethod)); + Assert.DoesNotContain( + typeof(TrashContentEntry).GetFields(BindingFlags.Instance | BindingFlags.Public), + field => !field.IsInitOnly); + } + + [Fact] + public void ConstructionDoesNotExposeNativeTypes() + { + Assert.Empty(typeof(TrashContainer).GetConstructors(BindingFlags.Instance | BindingFlags.Public)); + Assert.Empty(typeof(TrashContentEntry).GetConstructors(BindingFlags.Instance | BindingFlags.Public)); + } + + private static PropertyInfo GetProperty(string name) => + typeof(TrashContainer).GetProperty(name, BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException($"Missing public property {name}."); + + private static EventInfo GetEvent(string name) => + typeof(TrashContainer).GetEvent(name, BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException($"Missing public event {name}."); + + private static MethodInfo GetMethod(string name) => + typeof(TrashContainer).GetMethod(name, BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException($"Missing public method {name}."); +} diff --git a/S1API.Tests/Trash/TrashContainerApiCompileFixture.cs b/S1API.Tests/Trash/TrashContainerApiCompileFixture.cs new file mode 100644 index 00000000..3df394af --- /dev/null +++ b/S1API.Tests/Trash/TrashContainerApiCompileFixture.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using S1API.Trash; +using UnityEngine; + +namespace S1API.Tests.Trash; + +internal static class TrashContainerApiCompileFixture +{ + internal static void ReadAndSubscribe(GameObject gameObject, TrashContainer container) + { + TrashContainer? existing = TrashContainer.FromGameObject(gameObject); + TrashContainer[] containers = TrashContainer.FindInScene(includeInactive: true); + GameObject? owner = container.GameObject; + int capacity = container.Capacity; + int level = container.Level; + float normalizedLevel = container.NormalizedLevel; + IReadOnlyList contents = container.Contents; + bool canBeBagged = container.CanBeBagged; + + Action trashAdded = _ => { }; + Action levelChanged = () => { }; + container.OnTrashAdded += trashAdded; + container.OnTrashLevelChanged += levelChanged; + container.OnTrashAdded -= trashAdded; + container.OnTrashLevelChanged -= levelChanged; + + bool bagged = container.TryBagTrash(); + + _ = existing; + _ = containers; + _ = owner; + _ = capacity; + _ = level; + _ = normalizedLevel; + _ = contents; + _ = canBeBagged; + _ = bagged; + } +} diff --git a/S1API.Tests/Vehicles/VehicleColorCatalogCollection.cs b/S1API.Tests/Vehicles/VehicleColorCatalogCollection.cs new file mode 100644 index 00000000..50e7e3fc --- /dev/null +++ b/S1API.Tests/Vehicles/VehicleColorCatalogCollection.cs @@ -0,0 +1,7 @@ +namespace S1API.Tests.Vehicles; + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class VehicleColorCatalogCollection +{ + public const string Name = "Vehicle color catalog"; +} diff --git a/S1API.Tests/Vehicles/VehicleColorCatalogTests.cs b/S1API.Tests/Vehicles/VehicleColorCatalogTests.cs new file mode 100644 index 00000000..3e89db3c --- /dev/null +++ b/S1API.Tests/Vehicles/VehicleColorCatalogTests.cs @@ -0,0 +1,177 @@ +using S1API.Vehicles; +using UnityEngine; + +namespace S1API.Tests.Vehicles; + +[Collection(VehicleColorCatalogCollection.Name)] +public sealed class VehicleColorCatalogTests : IDisposable +{ + private readonly FakeVehicleColorMetadataProvider _provider = new(); + + public VehicleColorCatalogTests() + { + VehicleColorCatalog.ResetForTesting(_provider); + } + + public void Dispose() + { + VehicleColorCatalog.RestoreProviderForTesting(); + } + + [Fact] + public void LookupsExposeS1ApiOwnedMetadata() + { + Color materialColor = CreateColor(0.2f, 0.4f, 0.6f); +#if IL2CPPMELON + Color32 uiColor = default; +#else + Color32 uiColor = new(12, 34, 56, 255); +#endif + var metadata = new VehicleColorMetadata( + VehicleColor.DarkBlue, + "Dark Blue", + materialColor, + uiColor); + _provider.Colors.Add(metadata); + + Assert.Same(metadata, VehicleColorCatalog.GetColor(VehicleColor.DarkBlue)); + Assert.True( + VehicleColorCatalog.TryGetColor( + VehicleColor.DarkBlue, + out VehicleColorMetadata? resolved)); + Assert.Same(metadata, resolved); + Assert.Equal("Dark Blue", resolved!.DisplayName); + AssertColor(materialColor, resolved.MaterialColor); +#if !IL2CPPMELON + Assert.Equal(uiColor, resolved.UIColor); +#endif + } + + [Fact] + public void ColorsReturnsOrderedReadOnlySnapshots() + { + var red = new VehicleColorMetadata( + VehicleColor.Red, + "Red", + default, + default); + var black = new VehicleColorMetadata( + VehicleColor.Black, + "Black", + default, + default); + _provider.Colors.Add(red); + _provider.Colors.Add(black); + + IReadOnlyList snapshot = VehicleColorCatalog.Colors; + _provider.Colors.Remove(red); + + Assert.Equal(new[] { red, black }, snapshot); + Assert.Equal(new[] { black }, VehicleColorCatalog.Colors); + + IList mutableView = + Assert.IsAssignableFrom>(snapshot); + Assert.True(mutableView.IsReadOnly); + Assert.Throws(() => mutableView.Add(red)); + } + + [Fact] + public void InvalidValuesDoNotReachNativeProvider() + { + Assert.False( + VehicleColorCatalog.TryGetColor( + (VehicleColor)(-1), + out VehicleColorMetadata? negativeMetadata)); + Assert.False( + VehicleColorCatalog.TryGetColor( + (VehicleColor)999, + out VehicleColorMetadata? largeMetadata)); + + Assert.Null(negativeMetadata); + Assert.Null(largeMetadata); + Assert.Equal(0, _provider.ColorLookupCount); + } + + [Fact] + public void UnavailableRuntimeReturnsEmptyAndNullResults() + { + Assert.Empty(VehicleColorCatalog.Colors); + Assert.Null(VehicleColorCatalog.GetColor(VehicleColor.Black)); + } + + [Fact] + public void MissingNativeColorsReturnFalse() + { + _provider.Colors.Add(new VehicleColorMetadata( + VehicleColor.Black, + "Black", + default, + default)); + + Assert.False( + VehicleColorCatalog.TryGetColor( + VehicleColor.Custom, + out VehicleColorMetadata? customMetadata)); + Assert.Null(customMetadata); + } + + [Fact] + public void MetadataPropertiesAreReadOnly() + { + Assert.All( + typeof(VehicleColorMetadata).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 FakeVehicleColorMetadataProvider : + IVehicleColorMetadataProvider + { + internal List Colors { get; } = new(); + + internal int ColorLookupCount { get; private set; } + + public bool TryGetColors(out IReadOnlyList? metadata) + { + metadata = Colors.Count == 0 ? null : Colors; + return metadata != null; + } + + public bool TryGetColor( + VehicleColor color, + out VehicleColorMetadata? metadata) + { + ColorLookupCount++; + + for (int i = 0; i < Colors.Count; i++) + { + VehicleColorMetadata entry = Colors[i]; + if (entry.Color == color) + { + metadata = entry; + return true; + } + } + + metadata = null; + return false; + } + } +} diff --git a/S1API.Tests/Weather/WeatherApiCompatibilityTests.cs b/S1API.Tests/Weather/WeatherApiCompatibilityTests.cs new file mode 100644 index 00000000..c80f867f --- /dev/null +++ b/S1API.Tests/Weather/WeatherApiCompatibilityTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using S1API.Weather; + +namespace S1API.Tests.Weather; + +public sealed class WeatherApiCompatibilityTests +{ + [Fact] + public void WeatherStateExposesAnImmutableManagedShape() + { + Assert.True(typeof(WeatherState).IsValueType); + Assert.True(typeof(WeatherState).IsPublic); + Assert.NotNull(typeof(WeatherState).GetConstructor(new[] + { + typeof(float), typeof(float), typeof(float), + typeof(float), typeof(float), typeof(float), + typeof(float), typeof(float), typeof(float) + })); + + string[] propertyNames = + { + nameof(WeatherState.Sunny), + nameof(WeatherState.Cloudy), + nameof(WeatherState.Rainy), + nameof(WeatherState.Stormy), + nameof(WeatherState.Snowy), + nameof(WeatherState.Foggy), + nameof(WeatherState.Windy), + nameof(WeatherState.Hail), + nameof(WeatherState.Sleet) + }; + + foreach (string propertyName in propertyNames) + { + PropertyInfo? property = typeof(WeatherState).GetProperty(propertyName); + Assert.NotNull(property); + Assert.Equal(typeof(float), property!.PropertyType); + Assert.False(property.CanWrite); + } + } + + [Fact] + public void WeatherManagerSurfaceIsAdditiveAndRuntimeNeutral() + { + PropertyInfo? current = typeof(WeatherManager).GetProperty(nameof(WeatherManager.Current)); + PropertyInfo? sequenceIds = typeof(WeatherManager).GetProperty(nameof(WeatherManager.KnownSequenceIds)); + EventInfo? changed = typeof(WeatherManager).GetEvent(nameof(WeatherManager.OnWeatherChanged)); + + Assert.NotNull(current); + Assert.Equal(typeof(WeatherState?), current!.PropertyType); + Assert.NotNull(sequenceIds); + Assert.Equal(typeof(IReadOnlyList), sequenceIds!.PropertyType); + Assert.NotNull(changed); + Assert.Equal(typeof(Action), changed!.EventHandlerType); + + Assert.DoesNotContain( + typeof(WeatherManager).GetProperties(BindingFlags.Public | BindingFlags.Static), + property => property.PropertyType.Namespace?.StartsWith("ScheduleOne", StringComparison.Ordinal) == true); + } +} diff --git a/S1API.Tests/Weather/WeatherApiCompileFixture.cs b/S1API.Tests/Weather/WeatherApiCompileFixture.cs new file mode 100644 index 00000000..99b1a275 --- /dev/null +++ b/S1API.Tests/Weather/WeatherApiCompileFixture.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using S1API.Weather; + +namespace S1API.Tests.Weather; + +internal static class WeatherApiCompileFixture +{ + internal static void SubscribeAndRead() + { + Action handler = state => + { + _ = state.Rainy; + }; + WeatherManager.OnWeatherChanged += handler; + + WeatherState? current = WeatherManager.Current; + IReadOnlyList sequenceIds = WeatherManager.KnownSequenceIds; + _ = current; + _ = sequenceIds; + + WeatherManager.OnWeatherChanged -= handler; + } +} diff --git a/S1API.Tests/Weather/WeatherStateTests.cs b/S1API.Tests/Weather/WeatherStateTests.cs new file mode 100644 index 00000000..68ab7a2e --- /dev/null +++ b/S1API.Tests/Weather/WeatherStateTests.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using S1API.Weather; + +namespace S1API.Tests.Weather; + +public sealed class WeatherStateTests +{ + [Fact] + public void ConstructorCopiesAllNativeWeatherComponents() + { + var state = new WeatherState( + sunny: 0.1f, + cloudy: 0.2f, + rainy: 0.3f, + stormy: 0.4f, + snowy: 0.5f, + foggy: 0.6f, + windy: 0.7f, + hail: 0.8f, + sleet: 0.9f); + + Assert.Equal(0.1f, state.Sunny); + Assert.Equal(0.2f, state.Cloudy); + Assert.Equal(0.3f, state.Rainy); + Assert.Equal(0.4f, state.Stormy); + Assert.Equal(0.5f, state.Snowy); + Assert.Equal(0.6f, state.Foggy); + Assert.Equal(0.7f, state.Windy); + Assert.Equal(0.8f, state.Hail); + Assert.Equal(0.9f, state.Sleet); + } + + [Fact] + public void NativeComponentMappingPreservesTheWeatherFieldOrder() + { + WeatherState state = WeatherState.FromNativeComponents( + 0.11f, + 0.22f, + 0.33f, + 0.44f, + 0.55f, + 0.66f, + 0.77f, + 0.88f, + 0.99f); + + Assert.Equal(0.11f, state.Sunny); + Assert.Equal(0.22f, state.Cloudy); + Assert.Equal(0.33f, state.Rainy); + Assert.Equal(0.44f, state.Stormy); + Assert.Equal(0.55f, state.Snowy); + Assert.Equal(0.66f, state.Foggy); + Assert.Equal(0.77f, state.Windy); + Assert.Equal(0.88f, state.Hail); + Assert.Equal(0.99f, state.Sleet); + } + + [Fact] + public void SequenceSnapshotsCopyOrderCasingAndDuplicates() + { + var source = new List { "Sunny", "RAIN", null, string.Empty, "Sunny" }; + + IReadOnlyList snapshot = WeatherManager.SnapshotSequenceIds(source); + source.Add("Changed after snapshot"); + + Assert.Equal(new[] { "Sunny", "RAIN", "Sunny" }, snapshot); + Assert.IsAssignableFrom>(snapshot); + Assert.Throws( + () => ((IList)snapshot)[0] = "mutated"); + } + + [Fact] + public void EqualityIncludesEveryWeatherComponent() + { + WeatherState baseline = CreateState(); + + Assert.Equal(baseline, CreateState()); + Assert.Equal(baseline.GetHashCode(), CreateState().GetHashCode()); + Assert.NotEqual(baseline, new WeatherState( + baseline.Sunny, + baseline.Cloudy, + baseline.Rainy, + baseline.Stormy, + baseline.Snowy, + baseline.Foggy, + baseline.Windy, + baseline.Hail, + baseline.Sleet + 0.01f)); + Assert.True(baseline == CreateState()); + Assert.True(baseline != new WeatherState( + baseline.Sunny, + baseline.Cloudy, + baseline.Rainy, + baseline.Stormy, + baseline.Snowy, + baseline.Foggy, + baseline.Windy, + baseline.Hail, + baseline.Sleet + 0.01f)); + } + + [Fact] + public void ManagerSuppressesDuplicateSnapshotsAndKeepsCurrentInSync() + { + WeatherManager.ResetState(); + WeatherState first = CreateState(); + WeatherState second = new WeatherState( + first.Sunny, + first.Cloudy, + first.Rainy + 0.1f, + first.Stormy, + first.Snowy, + first.Foggy, + first.Windy, + first.Hail, + first.Sleet); + int notificationCount = 0; + Action handler = _ => notificationCount++; + WeatherManager.OnWeatherChanged += handler; + + try + { + WeatherManager.NotifyWeatherChanged(first); + WeatherManager.NotifyWeatherChanged(first); + WeatherManager.NotifyWeatherChanged(second); + + Assert.Equal(2, notificationCount); + Assert.True(WeatherManager.Current.HasValue); + Assert.Equal(second, WeatherManager.Current.Value); + } + finally + { + WeatherManager.OnWeatherChanged -= handler; + WeatherManager.ResetState(); + } + } + + [Fact] + public void ManagerContinuesAfterASubscriberThrows() + { + WeatherManager.ResetState(); + WeatherState state = CreateState(); + int notificationCount = 0; + Action throwingHandler = _ => throw new InvalidOperationException("test"); + Action observingHandler = _ => notificationCount++; + WeatherManager.OnWeatherChanged += throwingHandler; + WeatherManager.OnWeatherChanged += observingHandler; + + try + { + WeatherManager.NotifyWeatherChanged(state); + + Assert.Equal(1, notificationCount); + Assert.True(WeatherManager.Current.HasValue); + Assert.Equal(state, WeatherManager.Current.Value); + } + finally + { + WeatherManager.OnWeatherChanged -= throwingHandler; + WeatherManager.OnWeatherChanged -= observingHandler; + WeatherManager.ResetState(); + } + } + + private static WeatherState CreateState() + { + return new WeatherState( + sunny: 0.1f, + cloudy: 0.2f, + rainy: 0.3f, + stormy: 0.4f, + snowy: 0.5f, + foggy: 0.6f, + windy: 0.7f, + hail: 0.8f, + sleet: 0.9f); + } +} diff --git a/S1API/Audio/Jukebox.cs b/S1API/Audio/Jukebox.cs new file mode 100644 index 00000000..fd1e5c7f --- /dev/null +++ b/S1API/Audio/Jukebox.cs @@ -0,0 +1,378 @@ +#if IL2CPPMELON +using Il2CppInterop.Runtime; +using NativeAction = Il2CppSystem.Action; +using S1Jukebox = Il2CppScheduleOne.ObjectScripts.Jukebox; +#elif MONOMELON +using NativeAction = System.Action; +using S1Jukebox = ScheduleOne.ObjectScripts.Jukebox; +#endif + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using S1API.Internal.Utils; +using S1API.Logging; +using UnityEngine; + +namespace S1API.Audio +{ + /// + /// Provides managed access to a placed native jukebox. + /// + /// + /// This wrapper exposes only tracks configured by the game. Controls use the native jukebox + /// methods, including its existing authority, replication, and persistence behavior. + /// + public sealed class Jukebox + { + private static readonly IReadOnlyList EmptyTracks = + new ReadOnlyCollection(Array.Empty()); + private static readonly Log Logger = new Log("Jukebox"); + + private Action? _stateChangedHandlers; + private NativeAction? _nativeStateChangedDispatcher; + + /// + /// INTERNAL: The native jukebox component. + /// + internal readonly S1Jukebox S1Jukebox; + + /// + /// INTERNAL: Creates a wrapper for a native jukebox component. + /// + /// The native jukebox component. + internal Jukebox(S1Jukebox jukebox) + { + S1Jukebox = jukebox ?? throw new ArgumentNullException(nameof(jukebox)); + } + + /// + /// Gets the jukebox component on a game object. + /// + /// The game object to inspect. + /// A managed jukebox wrapper, or when no jukebox is attached. + /// is null or destroyed. + public static Jukebox? FromGameObject(GameObject gameObject) + { + if (gameObject == null) + throw new ArgumentNullException(nameof(gameObject)); + + S1Jukebox? jukebox = gameObject.GetComponent(); + return jukebox == null ? null : JukeboxManager.Wrap(jukebox); + } + + /// + /// Gets the placed-object identifier assigned by the game. + /// + public string GUID => + S1Jukebox.GUID.ToString(); + + /// + /// Gets the game object that owns the native jukebox. + /// + public GameObject GameObject => + S1Jukebox.gameObject; + + /// + /// Gets an immutable snapshot of tracks configured on this jukebox. + /// + public IReadOnlyList Tracks => + SnapshotTracks(); + + /// + /// Gets the current native track, or when none is selected. + /// + public JukeboxTrack? CurrentTrack => + SnapshotCurrentTrack(); + + /// + /// Gets the current native volume value. + /// + public int Volume => + S1Jukebox.CurrentVolume; + + /// + /// Gets the native volume normalized by the game's maximum volume. + /// + public float NormalizedVolume => + S1Jukebox.NormalizedVolume; + + /// + /// Gets whether the jukebox is playing. + /// + public bool IsPlaying => + S1Jukebox.IsPlaying; + + /// + /// Gets the elapsed time of the current track in seconds. + /// + public float CurrentTrackTime => + S1Jukebox.CurrentTrackTime; + + /// + /// Gets the current position in the native playback queue. + /// + public int CurrentTrackOrderIndex => + S1Jukebox.CurrentTrackOrderIndex; + + /// + /// Gets whether the native playback queue is shuffled. + /// + public bool Shuffle => + S1Jukebox.Shuffle; + + /// + /// Gets the native repeat mode. + /// + public JukeboxRepeatMode RepeatMode => + (JukeboxRepeatMode)(int)S1Jukebox.RepeatMode; + + /// + /// Gets whether the jukebox synchronizes with other native jukeboxes. + /// + public bool Sync => + S1Jukebox.Sync; + + /// + /// Gets an immutable snapshot of the jukebox's current state. + /// + public JukeboxState State => + new JukeboxState( + Volume, + NormalizedVolume, + IsPlaying, + CurrentTrackTime, + CurrentTrackOrderIndex, + Shuffle, + RepeatMode, + Sync, + CurrentTrack); + + /// + /// Occurs when the native jukebox reports a state change. + /// + public event Action OnStateChanged + { + add + { + if (value == null) + return; + + _stateChangedHandlers += value; + try + { + EnsureStateChangedHook(); + } + catch + { + _stateChangedHandlers -= value; + throw; + } + } + remove + { + if (value == null) + return; + + _stateChangedHandlers -= value; + if (_stateChangedHandlers == null) + RemoveStateChangedHook(); + } + } + + /// + /// Toggles playback using the native jukebox control. + /// + public void TogglePlay() => + S1Jukebox.TogglePlay(); + + /// + /// Selects the previous track using the native jukebox control. + /// + public void PreviousTrack() => + S1Jukebox.Back(); + + /// + /// Selects the next track using the native jukebox control. + /// + public void NextTrack() => + S1Jukebox.Next(); + + /// + /// Changes the volume using the native jukebox control. + /// + /// The change to apply to the current native volume, which the game clamps from 0 through 8. + public void ChangeVolume(int change) => + S1Jukebox.ChangeVolume(change); + + /// + /// Sets the volume and requests native replication. + /// + /// The native volume value to set. The game clamps the value from 0 through 8. + public void SetVolume(int volume) => + S1Jukebox.SetVolume(volume, replicate: true); + + /// + /// Toggles native queue shuffling. + /// + public void ToggleShuffle() => + S1Jukebox.ToggleShuffle(); + + /// + /// Cycles the native repeat mode. + /// + public void ToggleRepeatMode() => + S1Jukebox.ToggleRepeatMode(); + + /// + /// Toggles native jukebox synchronization. + /// + public void ToggleSync() => + S1Jukebox.ToggleSync(); + + /// + /// Selects a configured native track. + /// + /// The of the configured track. + /// + /// does not identify a configured track. + /// + public void SelectTrack(int trackIndex) + { + ValidateTrackIndex(trackIndex, S1Jukebox.TrackList?.Length ?? 0); + S1Jukebox.PlayTrack(trackIndex); + } + + internal static void ValidateTrackIndex(int trackIndex, int trackCount) + { + if (trackIndex < 0 || trackIndex >= trackCount) + { + throw new ArgumentOutOfRangeException( + nameof(trackIndex), + trackIndex, + "The track index does not identify a configured jukebox track."); + } + } + + internal void Cleanup() + { + RemoveStateChangedHook(); + _stateChangedHandlers = null; + } + + private IReadOnlyList SnapshotTracks() + { + var nativeTracks = S1Jukebox.TrackList; + if (nativeTracks == null || nativeTracks.Length == 0) + return EmptyTracks; + + var tracks = new List(nativeTracks.Length); + for (int index = 0; index < nativeTracks.Length; index++) + { + var nativeTrack = nativeTracks[index]; + if (nativeTrack != null) + { + tracks.Add(new JukeboxTrack( + index, + nativeTrack.TrackName, + nativeTrack.ArtistName)); + } + } + + return tracks.Count == 0 + ? EmptyTracks + : new ReadOnlyCollection(tracks); + } + + private JukeboxTrack? SnapshotCurrentTrack() + { + var nativeTrack = S1Jukebox.currentTrack; + if (nativeTrack == null) + return null; + + var nativeTracks = S1Jukebox.TrackList; + if (nativeTracks != null) + { + for (int index = 0; index < nativeTracks.Length; index++) + { + if (nativeTracks[index] == nativeTrack) + { + return new JukeboxTrack( + index, + nativeTrack.TrackName, + nativeTrack.ArtistName); + } + } + } + + return null; + } + + private void EnsureStateChangedHook() + { + if (_nativeStateChangedDispatcher != null) + return; + + NativeAction dispatcher = CreateNativeStateChangedDispatcher(); +#if IL2CPPMELON + S1Jukebox.onStateChanged = S1Jukebox.onStateChanged == null + ? dispatcher + : Il2CppSystem.Delegate.Combine(S1Jukebox.onStateChanged, dispatcher) + .Cast(); +#else + S1Jukebox.onStateChanged += dispatcher; +#endif + _nativeStateChangedDispatcher = dispatcher; + } + + private void RemoveStateChangedHook() + { + if (_nativeStateChangedDispatcher == null) + return; + + if (S1Jukebox != null) + { +#if IL2CPPMELON + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + S1Jukebox.onStateChanged, + _nativeStateChangedDispatcher); + S1Jukebox.onStateChanged = remaining?.Cast(); +#else + S1Jukebox.onStateChanged -= _nativeStateChangedDispatcher; +#endif + } + + _nativeStateChangedDispatcher = null; + } + + private NativeAction CreateNativeStateChangedDispatcher() + { +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(NotifyStateChanged) + ?? throw new InvalidOperationException("Could not create the native jukebox state-change delegate."); +#else + return NotifyStateChanged; +#endif + } + + private void NotifyStateChanged() + { + Action? handlers = _stateChangedHandlers; + if (handlers == null) + return; + + JukeboxState state = State; + foreach (Delegate callback in handlers.GetInvocationList()) + { + try + { + ((Action)callback)(state); + } + catch (Exception exception) + { + Logger.Warning($"A Jukebox.OnStateChanged subscriber failed: {exception.Message}"); + } + } + } + } +} diff --git a/S1API/Audio/JukeboxManager.cs b/S1API/Audio/JukeboxManager.cs new file mode 100644 index 00000000..f4779684 --- /dev/null +++ b/S1API/Audio/JukeboxManager.cs @@ -0,0 +1,111 @@ +#if IL2CPPMELON +using S1Jukebox = Il2CppScheduleOne.ObjectScripts.Jukebox; +#elif MONOMELON +using S1Jukebox = ScheduleOne.ObjectScripts.Jukebox; +#endif + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using S1API.Lifecycle; +using UnityEngine; + +namespace S1API.Audio +{ + /// + /// Discovers placed native jukeboxes in the current scene. + /// + public static class JukeboxManager + { + private static readonly Dictionary Jukeboxes = new Dictionary(); + private static bool _lifecycleHooked; + + /// + /// Gets an immutable snapshot of all active jukeboxes in the current scene. + /// + public static IReadOnlyList GetAll() + { + EnsureLifecycleHook(); + PruneDestroyedJukeboxes(); + var nativeJukeboxes = UnityEngine.Object.FindObjectsOfType(); + var jukeboxes = new List(nativeJukeboxes.Length); + for (int index = 0; index < nativeJukeboxes.Length; index++) + { + S1Jukebox? nativeJukebox = nativeJukeboxes[index]; + if (nativeJukebox != null) + jukeboxes.Add(Wrap(nativeJukebox)); + } + + return new ReadOnlyCollection(jukeboxes); + } + + /// + /// Gets an active jukebox by its placed-object identifier. + /// + /// The placed-object identifier assigned by the game. + /// The matching jukebox, or when it is not active in the current scene. + public static Jukebox? GetByGUID(string guid) + { + if (string.IsNullOrEmpty(guid)) + return null; + + foreach (Jukebox jukebox in GetAll()) + { + if (string.Equals(jukebox.GUID, guid, StringComparison.OrdinalIgnoreCase)) + return jukebox; + } + + return null; + } + + internal static Jukebox Wrap(S1Jukebox nativeJukebox) + { + EnsureLifecycleHook(); + PruneDestroyedJukeboxes(); + int instanceId = nativeJukebox.GetInstanceID(); + if (Jukeboxes.TryGetValue(instanceId, out Jukebox? jukebox)) + return jukebox; + + jukebox = new Jukebox(nativeJukebox); + Jukeboxes.Add(instanceId, jukebox); + return jukebox; + } + + private static void EnsureLifecycleHook() + { + if (_lifecycleHooked) + return; + + GameLifecycle.OnPreSceneChange += ClearSceneState; + _lifecycleHooked = true; + } + + private static void PruneDestroyedJukeboxes() + { + List? destroyedInstanceIds = null; + foreach (KeyValuePair pair in Jukeboxes) + { + if (pair.Value.S1Jukebox != null) + continue; + + pair.Value.Cleanup(); + destroyedInstanceIds ??= new List(); + destroyedInstanceIds.Add(pair.Key); + } + + if (destroyedInstanceIds == null) + return; + + foreach (int instanceId in destroyedInstanceIds) + Jukeboxes.Remove(instanceId); + } + + private static void ClearSceneState() + { + foreach (Jukebox jukebox in Jukeboxes.Values) + jukebox.Cleanup(); + + Jukeboxes.Clear(); + } + } +} diff --git a/S1API/Audio/JukeboxSnapshots.cs b/S1API/Audio/JukeboxSnapshots.cs new file mode 100644 index 00000000..7e713dd2 --- /dev/null +++ b/S1API/Audio/JukeboxSnapshots.cs @@ -0,0 +1,126 @@ +using System; + +namespace S1API.Audio +{ + /// + /// A track configured on a native jukebox. + /// + public sealed class JukeboxTrack + { + internal JukeboxTrack(int index, string name, string artist) + { + Index = index; + Name = name ?? string.Empty; + Artist = artist ?? string.Empty; + } + + /// + /// Gets the index to pass to . + /// + public int Index { get; } + + /// + /// Gets the track name configured by the game. + /// + public string Name { get; } + + /// + /// Gets the artist name configured by the game. + /// + public string Artist { get; } + } + + /// + /// The repeat modes supported by native jukeboxes. + /// + public enum JukeboxRepeatMode + { + /// + /// The queue does not repeat. + /// + None = 0, + + /// + /// The queue repeats after its final track. + /// + RepeatQueue = 1, + + /// + /// The current track repeats. + /// + RepeatTrack = 2 + } + + /// + /// An immutable snapshot of a jukebox's current native state. + /// + public sealed class JukeboxState + { + internal JukeboxState( + int volume, + float normalizedVolume, + bool isPlaying, + float currentTrackTime, + int currentTrackOrderIndex, + bool shuffle, + JukeboxRepeatMode repeatMode, + bool sync, + JukeboxTrack? currentTrack) + { + Volume = volume; + NormalizedVolume = normalizedVolume; + IsPlaying = isPlaying; + CurrentTrackTime = currentTrackTime; + CurrentTrackOrderIndex = currentTrackOrderIndex; + Shuffle = shuffle; + RepeatMode = repeatMode; + Sync = sync; + CurrentTrack = currentTrack; + } + + /// + /// Gets the current native volume value. + /// + public int Volume { get; } + + /// + /// Gets the native volume normalized by the game's maximum volume. + /// + public float NormalizedVolume { get; } + + /// + /// Gets whether the jukebox is playing. + /// + public bool IsPlaying { get; } + + /// + /// Gets the elapsed time of the current track in seconds. + /// + public float CurrentTrackTime { get; } + + /// + /// Gets the current position in the native playback queue. + /// + public int CurrentTrackOrderIndex { get; } + + /// + /// Gets whether the native playback queue is shuffled. + /// + public bool Shuffle { get; } + + /// + /// Gets the native repeat mode. + /// + public JukeboxRepeatMode RepeatMode { get; } + + /// + /// Gets whether the jukebox synchronizes with other native jukeboxes. + /// + public bool Sync { get; } + + /// + /// Gets the current track, or when the native jukebox has no current track. + /// + public JukeboxTrack? CurrentTrack { get; } + } +} diff --git a/S1API/Avatar/Avatar.cs b/S1API/Avatar/Avatar.cs index 4e1f2114..cd333caf 100644 --- a/S1API/Avatar/Avatar.cs +++ b/S1API/Avatar/Avatar.cs @@ -51,7 +51,9 @@ public void LoadAvatarSettings(AvatarSettings settings) if (S1Avatar == null || settings?.S1AvatarSettings == null) return; - S1Avatar.LoadAvatarSettings(settings.S1AvatarSettings); + Internal.Compatibility.AvatarCompatibility.ApplyLegacySettings( + S1Avatar, + settings.S1AvatarSettings); } /// diff --git a/S1API/Building/BuildEvents.cs b/S1API/Building/BuildEvents.cs index 7d48d162..f6dfcbbd 100644 --- a/S1API/Building/BuildEvents.cs +++ b/S1API/Building/BuildEvents.cs @@ -30,18 +30,25 @@ public static class BuildEvents /// /// Subscribers receive a BuildEventArgs containing the item and GameObject. /// The GameObject can be modified to change appearance or behavior. + /// is null for items that are not storage containers. /// public static event Action? OnGridItemCreated; /// /// Event raised after a surface item (table-top item) is created. /// + /// + /// is null for items that are not storage containers. + /// public static event Action? OnSurfaceItemCreated; /// /// Event raised after a buildable item component is initialized. /// This event fires for all buildable items and can be used for additional setup. /// + /// + /// is null for items that are not storage containers. + /// public static event Action? OnBuildableItemInitialized; /// diff --git a/S1API/Casino/CasinoGameRegistry.cs b/S1API/Casino/CasinoGameRegistry.cs new file mode 100644 index 00000000..5365ac29 --- /dev/null +++ b/S1API/Casino/CasinoGameRegistry.cs @@ -0,0 +1,238 @@ +#if IL2CPPMELON +using S1Casino = Il2CppScheduleOne.Casino; +#elif MONOMELON +using S1Casino = ScheduleOne.Casino; +#endif + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using S1API.Internal; +using S1API.Lifecycle; +using S1API.Logging; +using UnityEngine; + +namespace S1API.Casino +{ + /// + /// Discovers native casino games and publishes their synchronized lifecycle transitions. + /// + /// + /// This first-version API is intentionally read-only. It does not expose native RPCs, payout + /// replacement, game authoring, or methods that mutate bets and round state. + /// + public static class CasinoGameRegistry + { + private static readonly Log Logger = new Log("CasinoGameRegistry"); + private static readonly Dictionary BlackjackGames = new Dictionary(); + private static readonly Dictionary RideTheBusGames = new Dictionary(); + private static readonly Dictionary SlotMachines = new Dictionary(); + private static readonly Dictionary ActiveSpins = new Dictionary(); + private static bool _lifecycleHooked; + + /// Raised after a blackjack table changes stage. + public static event Action? BlackjackStageChanged; + + /// Raised when a blackjack table enters the dealing stage. + public static event Action? BlackjackRoundStarted; + + /// Raised when a blackjack table returns to the waiting stage. + public static event Action? BlackjackRoundEnded; + + /// Raised after the Ride the Bus table changes stage. + public static event Action? RideTheBusStageChanged; + + /// Raised when a Ride the Bus table begins its first question. + public static event Action? RideTheBusRoundStarted; + + /// Raised when a Ride the Bus table returns to the waiting stage. + public static event Action? RideTheBusRoundEnded; + + /// Raised when a slot machine begins a synchronized spin. + public static event Action? SlotSpinStarted; + + /// Raised when a slot machine displays a synchronized spin outcome. + public static event Action? SlotSpinCompleted; + + /// Gets a read-only snapshot of all active blackjack tables. + public static IReadOnlyList GetBlackjackGames() + { + EnsureLifecycleHook(); + var nativeGames = UnityEngine.Object.FindObjectsOfType(); + var games = new List(nativeGames.Length); + for (int i = 0; i < nativeGames.Length; i++) + { + if (nativeGames[i] != null) + games.Add(Wrap(nativeGames[i])); + } + return new ReadOnlyCollection(games); + } + + /// Gets a read-only snapshot of all active Ride the Bus tables. + public static IReadOnlyList GetRideTheBusGames() + { + EnsureLifecycleHook(); + var nativeGames = UnityEngine.Object.FindObjectsOfType(); + var games = new List(nativeGames.Length); + for (int i = 0; i < nativeGames.Length; i++) + { + if (nativeGames[i] != null) + games.Add(Wrap(nativeGames[i])); + } + return new ReadOnlyCollection(games); + } + + /// Gets a read-only snapshot of all active native slot machines. + public static IReadOnlyList GetSlotMachines() + { + EnsureLifecycleHook(); + var nativeMachines = UnityEngine.Object.FindObjectsOfType(); + var machines = new List(nativeMachines.Length); + for (int i = 0; i < nativeMachines.Length; i++) + { + if (nativeMachines[i] != null) + machines.Add(Wrap(nativeMachines[i])); + } + return new ReadOnlyCollection(machines); + } + + /// Finds the active slot machine nearest to a world position. + /// The world position to search from. + /// The maximum search distance. + /// The nearest managed slot-machine wrapper, or when none is in range. + public static SlotMachine? FindNearestSlotMachine(Vector3 position, float maxDistance) + { + S1Casino.SlotMachine? native = SlotMachineHelper.FindNearestNativeSlotMachine(position, maxDistance); + return native == null ? null : Wrap(native); + } + + /// Gets a read-only snapshot of all active blackjack and Ride the Bus tables. + public static IReadOnlyList GetTables() + { + var tables = new List(); + tables.AddRange(GetBlackjackGames()); + tables.AddRange(GetRideTheBusGames()); + return new ReadOnlyCollection(tables); + } + + internal static BlackjackGame Wrap(S1Casino.BlackjackGameController native) + { + EnsureLifecycleHook(); + int key = native.GetInstanceID(); + if (!BlackjackGames.TryGetValue(key, out BlackjackGame? game)) + { + game = new BlackjackGame(native); + BlackjackGames[key] = game; + } + return game; + } + + internal static RideTheBusGame Wrap(S1Casino.RTBGameController native) + { + EnsureLifecycleHook(); + int key = native.GetInstanceID(); + if (!RideTheBusGames.TryGetValue(key, out RideTheBusGame? game)) + { + game = new RideTheBusGame(native); + RideTheBusGames[key] = game; + } + return game; + } + + internal static SlotMachine Wrap(S1Casino.SlotMachine native) + { + EnsureLifecycleHook(); + int key = native.GetInstanceID(); + if (!SlotMachines.TryGetValue(key, out SlotMachine? machine)) + { + machine = new SlotMachine(native); + SlotMachines[key] = machine; + } + return machine; + } + + internal static void NotifyBlackjackStageChanged( + S1Casino.BlackjackGameController native, + BlackjackStage previous, + BlackjackStage current) + { + if (previous == current) + return; + + BlackjackGame game = Wrap(native); + game.NotifyStageChanged(previous, current); + CasinoEventInvoker.Invoke(BlackjackStageChanged, game, previous, current, nameof(BlackjackStageChanged)); + + if (current == BlackjackStage.Dealing && previous == BlackjackStage.WaitingForPlayers) + CasinoEventInvoker.Invoke(BlackjackRoundStarted, game, nameof(BlackjackRoundStarted)); + if (current == BlackjackStage.WaitingForPlayers) + CasinoEventInvoker.Invoke(BlackjackRoundEnded, game, nameof(BlackjackRoundEnded)); + } + + internal static void NotifyRideTheBusStageChanged( + S1Casino.RTBGameController native, + RideTheBusStage previous, + RideTheBusStage current) + { + if (previous == current) + return; + + RideTheBusGame game = Wrap(native); + game.NotifyStageChanged(previous, current); + CasinoEventInvoker.Invoke(RideTheBusStageChanged, game, previous, current, nameof(RideTheBusStageChanged)); + + if (current == RideTheBusStage.RedOrBlack && previous == RideTheBusStage.WaitingForPlayers) + CasinoEventInvoker.Invoke(RideTheBusRoundStarted, game, nameof(RideTheBusRoundStarted)); + if (current == RideTheBusStage.WaitingForPlayers) + CasinoEventInvoker.Invoke(RideTheBusRoundEnded, game, nameof(RideTheBusRoundEnded)); + } + + internal static void NotifySlotSpinStarted( + S1Casino.SlotMachine native, + SlotSpinSnapshot snapshot) + { + int key = native.GetInstanceID(); + ActiveSpins[key] = snapshot; + + SlotMachine machine = Wrap(native); + machine.NotifySpinStarted(snapshot); + CasinoEventInvoker.Invoke(SlotSpinStarted, machine, snapshot, nameof(SlotSpinStarted)); + } + + internal static void NotifySlotSpinCompleted( + S1Casino.SlotMachine native, + SlotOutcome outcome, + int winAmount) + { + int key = native.GetInstanceID(); + if (!ActiveSpins.TryGetValue(key, out SlotSpinSnapshot? started)) + return; + + ActiveSpins.Remove(key); + SlotSpinSnapshot completed = started.Complete(outcome, winAmount); + SlotMachine machine = Wrap(native); + machine.NotifySpinCompleted(completed); + CasinoEventInvoker.Invoke(SlotSpinCompleted, machine, completed, nameof(SlotSpinCompleted)); + } + + internal static void LogSubscriberFailure(string eventName, Exception exception) => + Logger.Warning($"A {eventName} subscriber failed: {exception.Message}"); + + private static void EnsureLifecycleHook() + { + if (_lifecycleHooked) + return; + + GameLifecycle.OnPreSceneChange += ClearSceneState; + _lifecycleHooked = true; + } + + private static void ClearSceneState() + { + BlackjackGames.Clear(); + RideTheBusGames.Clear(); + SlotMachines.Clear(); + ActiveSpins.Clear(); + } + } +} diff --git a/S1API/Casino/CasinoGames.cs b/S1API/Casino/CasinoGames.cs new file mode 100644 index 00000000..951da1fb --- /dev/null +++ b/S1API/Casino/CasinoGames.cs @@ -0,0 +1,381 @@ +#if IL2CPPMELON +using S1Casino = Il2CppScheduleOne.Casino; +using S1PlayerScripts = Il2CppScheduleOne.PlayerScripts; +#elif MONOMELON +using S1Casino = ScheduleOne.Casino; +using S1PlayerScripts = ScheduleOne.PlayerScripts; +#endif + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reflection; +using S1API.Entities; +using S1API.Internal; +using S1API.Internal.Utils; +using S1API.Logging; +using UnityEngine; + +namespace S1API.Casino +{ + /// + /// Read-only managed view of a native multiplayer casino table. + /// + public abstract class CasinoGameTable + { + private static readonly IReadOnlyList EmptyPlayers = + new ReadOnlyCollection(Array.Empty()); + private static readonly Log Logger = new Log("CasinoGameTable"); + + internal CasinoGameTable(S1Casino.CasinoGameController controller) + { + S1Controller = controller ?? throw new ArgumentNullException(nameof(controller)); + } + + internal S1Casino.CasinoGameController S1Controller { get; } + + /// Gets the scene object name. + public string Name => S1Controller.gameObject?.name ?? string.Empty; + + /// Gets the current world position. + public Vector3 Position => S1Controller.transform.position; + + /// + /// Gets whether this table's interface is open for the local player. + /// + public bool IsOpen => S1Controller.IsOpen; + + /// Gets whether the table is currently accepting ready players. + public bool IsWaitingForPlayers => S1Controller.IsWaitingForPlayers(); + + /// Gets the local player's currently selected bet. + public float LocalBet => S1Controller.LocalPlayerBet; + + /// Gets the native minimum and maximum bet. + public CasinoBetLimits BetLimits + { + get + { + S1Controller.GetBetLimits(out float minimum, out float maximum); + return new CasinoBetLimits(minimum, maximum); + } + } + + /// + /// Gets an immutable snapshot of players currently seated at the table. + /// + public IReadOnlyList Players + { + get + { + S1Casino.CasinoGamePlayers? players = S1Controller.Players; + if (players == null) + return EmptyPlayers; + + var snapshots = new List(); + for (int seatIndex = 0; seatIndex < players.PlayerLimit; seatIndex++) + { + S1PlayerScripts.Player? nativePlayer = players.GetPlayer(seatIndex); + if (nativePlayer == null) + continue; + + bool ready = false; + try + { + S1Casino.CasinoGamePlayerData? data = players.GetPlayerData(nativePlayer); + ready = data != null && data.GetData("Ready"); + } + catch (Exception ex) + { + Logger.Warning($"Failed to read ready state for player '{nativePlayer.PlayerName}': {ex.Message}"); + } + + snapshots.Add(new CasinoPlayerSnapshot( + ResolvePlayer(nativePlayer), + nativePlayer.PlayerName ?? string.Empty, + seatIndex, + players.GetPlayerScore(nativePlayer), + ready)); + } + + return snapshots.Count == 0 + ? EmptyPlayers + : new ReadOnlyCollection(snapshots); + } + } + + private static Player? ResolvePlayer(S1PlayerScripts.Player nativePlayer) => + Player.All.FirstOrDefault(player => player.S1Player == nativePlayer); + } + + /// + /// Read-only managed view of a native blackjack table. + /// + public sealed class BlackjackGame : CasinoGameTable + { + private static readonly IReadOnlyList EmptyCards = + new ReadOnlyCollection(Array.Empty()); +#if MONOMELON + private const BindingFlags NativeMemberFlags = + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + private static readonly MethodInfo? GetPlayerCardsMethod = + typeof(S1Casino.BlackjackGameController).GetMethod( + "GetPlayerCards", + NativeMemberFlags, + binder: null, + types: new[] { typeof(int) }, + modifiers: null); + private static readonly FieldInfo? DealerHandField = + typeof(S1Casino.BlackjackGameController).GetField("dealerHand", NativeMemberFlags); +#endif + + internal BlackjackGame(S1Casino.BlackjackGameController controller) + : base(controller) + { + S1Native = controller; + } + + internal S1Casino.BlackjackGameController S1Native { get; } + + /// Raised after the native blackjack stage changes. + public event Action? StageChanged; + + /// Raised when the table enters the dealing stage. + public event Action? RoundStarted; + + /// Raised when the table returns to its waiting stage. + public event Action? RoundEnded; + + /// Gets the current round stage. + public BlackjackStage Stage => (BlackjackStage)(int)S1Native.CurrentStage; + + /// Gets the current dealer score visible to this peer. + public int DealerScore => S1Native.DealerScore; + + /// Gets the current local-player score. + public int LocalPlayerScore => S1Native.LocalPlayerScore; + + /// Gets whether the local player has a natural blackjack. + public bool IsLocalPlayerBlackjack => S1Native.IsLocalPlayerBlackjack; + + /// Gets whether the local player is bust. + public bool IsLocalPlayerBust => S1Native.IsLocalPlayerBust; + + /// Gets whether the local player belongs to the active round. + public bool IsLocalPlayerInRound => S1Native.IsLocalPlayerInCurrentRound; + + /// Gets the number of seated players currently marked ready. + public int ReadyPlayerCount => S1Native.GetPlayersReadyCount(); + + /// + /// Gets an immutable snapshot of a seated player's current hand. + /// + /// The zero-based seat index. + public IReadOnlyList GetPlayerHand(int seatIndex) + { + if (seatIndex < 0 || seatIndex >= S1Native.Players.PlayerLimit) + return EmptyCards; + +#if IL2CPPMELON + var cards = S1Native.GetPlayerCards(seatIndex); +#else + var cards = GetPlayerCardsMethod?.Invoke(S1Native, new object[] { seatIndex }) + as List; +#endif + return FreezeCards(cards); + } + + /// Gets an immutable snapshot of the dealer's current hand. + public IReadOnlyList DealerHand + { + get + { +#if IL2CPPMELON + var cards = S1Native.dealerHand; +#else + var cards = DealerHandField?.GetValue(S1Native) as List; +#endif + return FreezeCards(cards); + } + } + + internal void NotifyStageChanged(BlackjackStage previous, BlackjackStage current) + { + CasinoEventInvoker.Invoke(StageChanged, previous, current, nameof(StageChanged)); + if (current == BlackjackStage.Dealing && previous == BlackjackStage.WaitingForPlayers) + CasinoEventInvoker.Invoke(RoundStarted, nameof(RoundStarted)); + if (current == BlackjackStage.WaitingForPlayers && previous != current) + CasinoEventInvoker.Invoke(RoundEnded, nameof(RoundEnded)); + } + + private static IReadOnlyList FreezeCards( +#if IL2CPPMELON + Il2CppSystem.Collections.Generic.List? cards) +#else + List? cards) +#endif + { + if (cards == null || cards.Count == 0) + return EmptyCards; + + var snapshots = new List(cards.Count); + for (int i = 0; i < cards.Count; i++) + { + S1Casino.PlayingCard? card = cards[i]; + if (card != null) + snapshots.Add(ToSnapshot(card)); + } + + return snapshots.Count == 0 + ? EmptyCards + : new ReadOnlyCollection(snapshots); + } + + internal static CasinoCardSnapshot ToSnapshot(S1Casino.PlayingCard card) => + new CasinoCardSnapshot( + card.CardID ?? string.Empty, + (CasinoCardSuit)(int)card.Suit, + (CasinoCardValue)(int)card.Value, + card.IsFaceUp); + } + + /// + /// Read-only managed view of the native Ride the Bus table. + /// + public sealed class RideTheBusGame : CasinoGameTable + { + private static readonly IReadOnlyList EmptyCards = + new ReadOnlyCollection(Array.Empty()); + + internal RideTheBusGame(S1Casino.RTBGameController controller) + : base(controller) + { + S1Native = controller; + } + + internal S1Casino.RTBGameController S1Native { get; } + + /// Raised after the native Ride the Bus stage changes. + public event Action? StageChanged; + + /// Raised when a new Ride the Bus round begins. + public event Action? RoundStarted; + + /// Raised when the table returns to its waiting stage. + public event Action? RoundEnded; + + /// Gets the current round stage. + public RideTheBusStage Stage => (RideTheBusStage)(int)S1Native.CurrentStage; + + /// Gets whether a question is currently accepting answers. + public bool IsQuestionActive => S1Native.IsQuestionActive; + + /// Gets the local player's current bet multiplier. + public float LocalBetMultiplier => S1Native.LocalPlayerBetMultiplier; + + /// Gets the local player's multiplied bet. + public float MultipliedLocalBet => S1Native.MultipliedLocalPlayerBet; + + /// Gets the answer time remaining on the current peer. + public float RemainingAnswerTime => S1Native.RemainingAnswerTime; + + /// Gets whether the local player belongs to the active round. + public bool IsLocalPlayerInRound => S1Native.IsLocalPlayerInCurrentRound; + + /// Gets the number of seated players currently marked ready. + public int ReadyPlayerCount => S1Native.GetPlayersReadyCount(); + + /// Gets the number of active-round players who submitted an answer. + public int AnsweredPlayerCount => S1Native.GetAnsweredPlayersCount(); + + /// Gets immutable snapshots of cards currently assigned by the table. + public IReadOnlyList Cards + { + get + { + var nativeCards = S1Native.Cards; + if (nativeCards == null || nativeCards.Length == 0) + return EmptyCards; + + var cards = new List(); + for (int i = 0; i < nativeCards.Length; i++) + { + S1Casino.PlayingCard? card = nativeCards[i]; + if (card != null && (int)card.Value != (int)CasinoCardValue.Blank) + cards.Add(BlackjackGame.ToSnapshot(card)); + } + + return cards.Count == 0 + ? EmptyCards + : new ReadOnlyCollection(cards); + } + } + + internal void NotifyStageChanged(RideTheBusStage previous, RideTheBusStage current) + { + CasinoEventInvoker.Invoke(StageChanged, previous, current, nameof(StageChanged)); + if (current == RideTheBusStage.RedOrBlack && previous == RideTheBusStage.WaitingForPlayers) + CasinoEventInvoker.Invoke(RoundStarted, nameof(RoundStarted)); + if (current == RideTheBusStage.WaitingForPlayers && previous != current) + CasinoEventInvoker.Invoke(RoundEnded, nameof(RoundEnded)); + } + } + + /// + /// Read-only managed view of a native slot machine. + /// + public sealed class SlotMachine + { + internal SlotMachine(S1Casino.SlotMachine machine) + { + S1Native = machine ?? throw new ArgumentNullException(nameof(machine)); + } + + internal S1Casino.SlotMachine S1Native { get; } + + /// Raised when a synchronized spin begins. + public event Action? SpinStarted; + + /// Raised when a synchronized spin displays its outcome. + public event Action? SpinCompleted; + + /// Gets the scene object name. + public string Name => S1Native.gameObject?.name ?? string.Empty; + + /// Gets the current world position. + public Vector3 Position => S1Native.transform.position; + + /// Gets whether the reels are currently spinning. + public bool IsSpinning => S1Native.IsSpinning; + + /// Gets the machine's currently selected bet. + public int CurrentBet + { + get + { + object? value = ReflectionUtils.TryGetFieldOrProperty(S1Native, "currentBetAmount"); + return value is int bet ? bet : 0; + } + } + + /// Gets the immutable native bet choices. + public IReadOnlyList AvailableBets + { + get + { + var nativeAmounts = S1Casino.SlotMachine.BetAmounts; + var amounts = new int[nativeAmounts.Length]; + for (int i = 0; i < nativeAmounts.Length; i++) + amounts[i] = nativeAmounts[i]; + return new ReadOnlyCollection(amounts); + } + } + + internal void NotifySpinStarted(SlotSpinSnapshot snapshot) => + CasinoEventInvoker.Invoke(SpinStarted, snapshot, nameof(SpinStarted)); + + internal void NotifySpinCompleted(SlotSpinSnapshot snapshot) => + CasinoEventInvoker.Invoke(SpinCompleted, snapshot, nameof(SpinCompleted)); + } +} diff --git a/S1API/Casino/CasinoSnapshots.cs b/S1API/Casino/CasinoSnapshots.cs new file mode 100644 index 00000000..14564f42 --- /dev/null +++ b/S1API/Casino/CasinoSnapshots.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using S1API.Entities; + +namespace S1API.Casino +{ + /// + /// Represents the native minimum and maximum bet accepted by a casino table. + /// + public readonly struct CasinoBetLimits + { + /// + /// Creates an immutable bet-limit snapshot. + /// + /// The minimum bet accepted by the table. + /// The maximum bet accepted by the table. + public CasinoBetLimits(float minimum, float maximum) + { + Minimum = minimum; + Maximum = maximum; + } + + /// Gets the minimum accepted bet. + public float Minimum { get; } + + /// Gets the maximum accepted bet. + public float Maximum { get; } + } + + /// + /// Card suits used by the native casino games. + /// + public enum CasinoCardSuit + { + /// Spades. + Spades = 0, + /// Hearts. + Hearts = 1, + /// Diamonds. + Diamonds = 2, + /// Clubs. + Clubs = 3 + } + + /// + /// Card values used by the native casino games. + /// + public enum CasinoCardValue + { + /// No card value is assigned. + Blank = 0, + /// Ace. + Ace = 1, + /// Two. + Two = 2, + /// Three. + Three = 3, + /// Four. + Four = 4, + /// Five. + Five = 5, + /// Six. + Six = 6, + /// Seven. + Seven = 7, + /// Eight. + Eight = 8, + /// Nine. + Nine = 9, + /// Ten. + Ten = 10, + /// Jack. + Jack = 11, + /// Queen. + Queen = 12, + /// King. + King = 13 + } + + /// + /// Immutable public representation of a casino playing card. + /// + public readonly struct CasinoCardSnapshot + { + /// + /// Creates an immutable card snapshot. + /// + /// The scene-local native card identifier. + /// The card suit. + /// The card value. + /// Whether the card is face up for the current client. + public CasinoCardSnapshot(string id, CasinoCardSuit suit, CasinoCardValue value, bool isFaceUp) + { + Id = id ?? string.Empty; + Suit = suit; + Value = value; + IsFaceUp = isFaceUp; + } + + /// Gets the scene-local native card identifier. + public string Id { get; } + + /// Gets the card suit. + public CasinoCardSuit Suit { get; } + + /// Gets the card value. + public CasinoCardValue Value { get; } + + /// Gets whether the card is face up for the current client. + public bool IsFaceUp { get; } + } + + /// + /// Immutable player state captured from a casino table. + /// + public sealed class CasinoPlayerSnapshot + { + internal CasinoPlayerSnapshot(Player? player, string name, int seatIndex, int score, bool isReady) + { + Player = player; + Name = name; + SeatIndex = seatIndex; + Score = score; + IsReady = isReady; + } + + /// + /// Gets the S1API player wrapper when that player has completed S1API initialization. + /// + public Player? Player { get; } + + /// Gets the current native player name. + public string Name { get; } + + /// Gets the zero-based table seat index. + public int SeatIndex { get; } + + /// Gets the synchronized score stored by the table. + public int Score { get; } + + /// Gets whether the player has marked themselves ready. + public bool IsReady { get; } + } + + /// + /// Stages in a native blackjack round. + /// + public enum BlackjackStage + { + /// The table is accepting players. + WaitingForPlayers = 0, + /// Initial cards are being dealt. + Dealing = 1, + /// A player is taking their turn. + PlayerTurn = 2, + /// The dealer is taking their turn. + DealerTurn = 3, + /// The round is resolving payouts. + Ending = 4 + } + + /// + /// Native blackjack payout classifications. + /// + public enum BlackjackPayout + { + /// No payout. + None = 0, + /// Natural blackjack. + Blackjack = 1, + /// Standard win. + Win = 2, + /// Push; the original bet is returned. + Push = 3 + } + + /// + /// Stages in a native Ride the Bus round. + /// + public enum RideTheBusStage + { + /// The table is accepting players. + WaitingForPlayers = 0, + /// The player predicts red or black. + RedOrBlack = 1, + /// The player predicts higher or lower. + HigherOrLower = 2, + /// The player predicts inside or outside. + InsideOrOutside = 3, + /// The player predicts the suit. + Suit = 4 + } + + /// + /// Symbols displayed by a native slot machine. + /// + public enum SlotSymbol + { + /// Cherry. + Cherry = 0, + /// Lemon. + Lemon = 1, + /// Grape. + Grape = 2, + /// Watermelon. + Watermelon = 3, + /// Bell. + Bell = 4, + /// Seven. + Seven = 5 + } + + /// + /// Native slot-machine outcome classifications. + /// + public enum SlotOutcome + { + /// Three sevens. + Jackpot = 0, + /// Three bells. + BigWin = 1, + /// Three matching fruit symbols. + SmallWin = 2, + /// Three fruit symbols. + MiniWin = 3, + /// No winning combination. + NoWin = 4 + } + + /// + /// Immutable state for a slot spin as observed by the current peer. + /// + public sealed class SlotSpinSnapshot + { + internal SlotSpinSnapshot( + int bet, + IReadOnlyList symbols, + bool wasStartedByLocalPlayer, + SlotOutcome? outcome, + int? winAmount) + { + Bet = bet; + Symbols = symbols; + WasStartedByLocalPlayer = wasStartedByLocalPlayer; + Outcome = outcome; + WinAmount = winAmount; + } + + /// Gets the bet used for this spin. + public int Bet { get; } + + /// Gets the immutable ordered reel symbols. + public IReadOnlyList Symbols { get; } + + /// Gets whether the native spinner connection belongs to this client. + public bool WasStartedByLocalPlayer { get; } + + /// Gets the outcome after completion, or null while spinning. + public SlotOutcome? Outcome { get; } + + /// Gets the win amount after completion, or null while spinning. + public int? WinAmount { get; } + + internal SlotSpinSnapshot Complete(SlotOutcome outcome, int winAmount) => + new SlotSpinSnapshot(Bet, Symbols, WasStartedByLocalPlayer, outcome, winAmount); + + internal static IReadOnlyList Freeze(IList symbols) + { + if (symbols.Count == 0) + return new ReadOnlyCollection(Array.Empty()); + + var copy = new SlotSymbol[symbols.Count]; + symbols.CopyTo(copy, 0); + return new ReadOnlyCollection(copy); + } + } +} diff --git a/S1API/Casino/SlotMachineHelper.cs b/S1API/Casino/SlotMachineHelper.cs index 6c992cfc..5a8888f1 100644 --- a/S1API/Casino/SlotMachineHelper.cs +++ b/S1API/Casino/SlotMachineHelper.cs @@ -13,6 +13,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.ComponentModel; using S1API.Entities; using S1API.Lifecycle; using S1API.Logging; @@ -282,7 +283,7 @@ public static bool UseSlotMachine(NPC npc, Vector3 machinePosition, int betAmoun if (_isSceneChangeInProgress) return false; - var machine = FindNearestSlotMachine(machinePosition, maxSearchDistance); + var machine = FindNearestNativeSlotMachine(machinePosition, maxSearchDistance); if (machine == null) { Logger.Warning($"No slot machine found near position {machinePosition}"); @@ -325,6 +326,18 @@ public static bool UseSlotMachine(NPC npc, Vector3 machinePosition, int betAmoun SpinSlotMachineForNPC(npc, machine, symbols, betAmount, activeSpin)); #endif + var managedSymbols = new List(symbols.Length); + for (int i = 0; i < symbols.Length; i++) + managedSymbols.Add((SlotSymbol)(int)symbols[i]); + + CasinoGameRegistry.NotifySlotSpinStarted( + machine, + new SlotSpinSnapshot( + betAmount, + SlotSpinSnapshot.Freeze(managedSymbols), + wasStartedByLocalPlayer: false, + outcome: null, + winAmount: null)); RegisterActiveSpin(activeSpin); return true; @@ -337,12 +350,21 @@ public static bool UseSlotMachine(NPC npc, Vector3 machinePosition, int betAmoun } /// - /// Finds the nearest slot machine to a given position. + /// Finds the nearest native slot machine to a given position. /// /// The position to search from. /// Maximum distance to search. - /// The nearest slot machine, or null if none found. + /// The nearest native slot machine, or null if none found. + /// + /// This compatibility method exposes a game type. New code should use + /// . + /// + [Obsolete("Use CasinoGameRegistry.FindNearestSlotMachine to receive an S1API managed wrapper.")] + [EditorBrowsable(EditorBrowsableState.Never)] public static S1Casino.SlotMachine? FindNearestSlotMachine(Vector3 position, float maxDistance) + => FindNearestNativeSlotMachine(position, maxDistance); + + internal static S1Casino.SlotMachine? FindNearestNativeSlotMachine(Vector3 position, float maxDistance) { try { diff --git a/S1API/Console/ConsoleHelper.cs b/S1API/Console/ConsoleHelper.cs index 90e462a9..5e4fcfef 100644 --- a/S1API/Console/ConsoleHelper.cs +++ b/S1API/Console/ConsoleHelper.cs @@ -22,6 +22,13 @@ public static class ConsoleHelper private static readonly Log Logger = new Log("ConsoleHelper"); private static bool _setPlayerEnergyUnavailableWarningLogged; + /// + /// The custom commands registered through , keyed by command word. + /// They are routed by patches instead of the game's command list, so this is the only way to enumerate them. + /// + public static System.Collections.Generic.IReadOnlyDictionary RegisteredCommands + => CustomConsoleRegistry.RegisteredCommands; + /// /// Submits a raw console command string (e.g. "settime 1530"). /// Works across both IL2CPP and Mono builds. diff --git a/S1API/Console/CustomConsoleRegistry.cs b/S1API/Console/CustomConsoleRegistry.cs index 4771ab30..379ef8ac 100644 --- a/S1API/Console/CustomConsoleRegistry.cs +++ b/S1API/Console/CustomConsoleRegistry.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; namespace S1API.Console { @@ -13,7 +14,11 @@ internal static class CustomConsoleRegistry private static readonly Dictionary registry = new Dictionary(StringComparer.OrdinalIgnoreCase); - internal static IReadOnlyDictionary RegisteredCommands => registry; + // Live view of the same storage; blocks the cast back to IDictionary<,> + private static readonly ReadOnlyDictionary readOnlyRegistry = + new ReadOnlyDictionary(registry); + + internal static IReadOnlyDictionary RegisteredCommands => readOnlyRegistry; internal static void Register(BaseConsoleCommand command) { diff --git a/S1API/Deliveries/Delivery.cs b/S1API/Deliveries/Delivery.cs index d63d5a5a..49ff771e 100644 --- a/S1API/Deliveries/Delivery.cs +++ b/S1API/Deliveries/Delivery.cs @@ -51,6 +51,27 @@ internal Delivery(S1Delivery.DeliveryInstance delivery) /// public int LoadingDockIndex => NativeDelivery.LoadingDockIndex; + /// + /// Gets the selected destination loading dock, or while it is unavailable. + /// + public LoadingDock? LoadingDock + { + get + { + try + { + var loadingDock = NativeDelivery.LoadingDock; + return loadingDock == null + ? null + : global::S1API.Deliveries.LoadingDock.Wrap(loadingDock); + } + catch + { + return null; + } + } + } + /// /// Gets the wrapped destination property, or while it is unavailable. /// diff --git a/S1API/Deliveries/LoadingDock.cs b/S1API/Deliveries/LoadingDock.cs new file mode 100644 index 00000000..f3d5ffd0 --- /dev/null +++ b/S1API/Deliveries/LoadingDock.cs @@ -0,0 +1,245 @@ +#if IL2CPPMELON +using S1Delivery = Il2CppScheduleOne.Delivery; +using S1ItemSlotList = Il2CppSystem.Collections.Generic.List; +#elif MONOMELON +using S1Delivery = ScheduleOne.Delivery; +using S1ItemSlotList = System.Collections.Generic.List; +#endif + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using S1API.Items; +using S1API.Lifecycle; +using S1API.Logging; +using S1API.Property; +using S1API.Vehicles; + +namespace S1API.Deliveries +{ + /// + /// Provides read-only access to a property's native loading dock. + /// + /// + /// Scalar properties reflect the live dock state. Slot collections are immutable + /// snapshots containing live wrappers. Instances are + /// cached for the loaded scene so property and delivery lookups share event subscriptions. + /// Resolve the dock again after a scene or save transition. Events report state observed + /// by the local peer and do not add network replication. + /// + public sealed class LoadingDock + { + private static readonly Log Logger = new Log("LoadingDock"); + private static readonly Dictionary Cache = new Dictionary(); + private static readonly IReadOnlyList EmptySlots = + new ReadOnlyCollection(Array.Empty()); + private static bool _lifecycleHooked; + + internal LoadingDock(S1Delivery.LoadingDock loadingDock) + { + S1LoadingDock = loadingDock ?? throw new ArgumentNullException(nameof(loadingDock)); + } + + /// + /// INTERNAL: Gets the native loading dock represented by this wrapper. + /// + internal S1Delivery.LoadingDock S1LoadingDock { get; } + + /// + /// Gets the stable GUID assigned to this loading dock. + /// + public string GUID => S1LoadingDock.GUID.ToString(); + + /// + /// Gets the display name assigned by the owning property. + /// + public string Name + { + get + { + try + { + return S1LoadingDock.Name ?? string.Empty; + } + catch + { + return S1LoadingDock.gameObject?.name ?? string.Empty; + } + } + } + + /// + /// Gets the owning property, or while it is unavailable. + /// + public PropertyWrapper? Property + { + get + { + try + { + var property = S1LoadingDock.ParentProperty; + return property == null ? null : new PropertyWrapper(property); + } + catch + { + return null; + } + } + } + + /// + /// Gets an immutable snapshot of the dock's input slots. + /// + /// + /// The collection cannot be modified, but each contained slot retains the behavior + /// of the existing API. + /// + public IReadOnlyList InputSlots => + SnapshotSlots(S1LoadingDock.InputSlots); + + /// + /// Gets an immutable snapshot of the dock's output slots. + /// + /// + /// The collection cannot be modified, but each contained slot retains the behavior + /// of the existing API. + /// + public IReadOnlyList OutputSlots => + SnapshotSlots(S1LoadingDock.OutputSlots); + + /// + /// Gets whether the dock currently accepts incoming transit items. + /// + public bool IsAcceptingItems => S1LoadingDock.IsAcceptingItems; + + /// + /// Gets whether the dock has been removed from transit routing. + /// + public bool IsDestroyed => S1LoadingDock.IsDestroyed; + + /// + /// Gets whether a dynamic or static vehicle currently occupies the dock. + /// + public bool IsInUse => S1LoadingDock.IsInUse; + + /// + /// Gets the vehicle detected in the dock, or when none is present. + /// + public LandVehicle? DynamicOccupant => + VehicleRegistry.Wrap(S1LoadingDock.DynamicOccupant); + + /// + /// Gets the delivery vehicle assigned to the dock, or when none is assigned. + /// + public LandVehicle? StaticOccupant => + VehicleRegistry.Wrap(S1LoadingDock.StaticOccupant); + + /// + /// Raised after the dynamically detected vehicle changes. + /// + public event Action? DynamicOccupantChanged; + + /// + /// Raised after the assigned delivery vehicle changes. + /// + public event Action? StaticOccupantChanged; + + /// + /// Raised after the accepting-items state changes. + /// + public event Action? AcceptingItemsChanged; + + internal static LoadingDock Wrap(S1Delivery.LoadingDock native) + { + EnsureLifecycleHook(); + int key = native.GetInstanceID(); + if (!Cache.TryGetValue(key, out LoadingDock? loadingDock) + || loadingDock.S1LoadingDock != native) + { + loadingDock = new LoadingDock(native); + Cache[key] = loadingDock; + } + + return loadingDock; + } + + internal void NotifyDynamicOccupantChanged(LandVehicle? previous, LandVehicle? current) + { + if (ReferenceEquals(previous, current)) + return; + + Invoke(DynamicOccupantChanged, previous, current, nameof(DynamicOccupantChanged)); + } + + internal void NotifyStaticOccupantChanged(LandVehicle? previous, LandVehicle? current) + { + if (ReferenceEquals(previous, current)) + return; + + Invoke(StaticOccupantChanged, previous, current, nameof(StaticOccupantChanged)); + } + + internal void NotifyAcceptingItemsChanged(bool previous, bool current) + { + if (previous == current) + return; + + Invoke(AcceptingItemsChanged, previous, current, nameof(AcceptingItemsChanged)); + } + + private static IReadOnlyList SnapshotSlots(S1ItemSlotList? nativeSlots) + { + if (nativeSlots == null || nativeSlots.Count == 0) + return EmptySlots; + + var slots = new List(nativeSlots.Count); + for (int i = 0; i < nativeSlots.Count; i++) + { + if (nativeSlots[i] != null) + slots.Add(new ItemSlotInstance(nativeSlots[i])); + } + + return slots.Count == 0 + ? EmptySlots + : new ReadOnlyCollection(slots); + } + + private static void Invoke( + Action? handlers, + T previous, + T current, + string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try + { + handler(previous, current); + } + catch (Exception ex) + { + try + { + Logger.Warning($"A {eventName} subscriber failed: {ex.Message}"); + } + catch + { + // Logging must not prevent the remaining subscribers from running. + } + } + } + } + + private static void EnsureLifecycleHook() + { + if (_lifecycleHooked) + return; + + GameLifecycle.OnPreSceneChange += Cache.Clear; + _lifecycleHooked = true; + } + } +} diff --git a/S1API/Dialogues/DialogueInjector.cs b/S1API/Dialogues/DialogueInjector.cs index 7e1c6ba9..8d75c74a 100644 --- a/S1API/Dialogues/DialogueInjector.cs +++ b/S1API/Dialogues/DialogueInjector.cs @@ -128,7 +128,7 @@ private static void TryInject(DialogueInjection injection, S1NPC npc) if (handler == null) return; - DialogueContainer? container = ResolveContainer(handler, injection.ContainerName); + Conversation? container = ResolveContainer(handler, injection.ContainerName); if (container == null) return; @@ -184,7 +184,7 @@ private static void TryInject(DialogueInjection injection, S1NPC npc) // MelonLogger.Msg($"[DialogueInjector] Injected '{injection.ChoiceLabel}' into NPC '{npc.name}'"); } - private static DialogueContainer? ResolveContainer(DialogueHandler handler, string containerName) + private static Conversation? ResolveContainer(DialogueHandler handler, string containerName) { if (handler == null || string.IsNullOrEmpty(containerName)) return null; @@ -192,8 +192,9 @@ private static void TryInject(DialogueInjection injection, S1NPC npc) DialogueController controller = handler.GetComponent(); if (controller != null) { - if (controller.GenericDialogue != null && controller.GenericDialogue.name == containerName) - return controller.GenericDialogue; + Conversation? genericConversation = DialogueManager.Instance?.GenericConversation; + if (genericConversation != null && genericConversation.name == containerName) + return genericConversation; if (controller.OverrideContainer != null && controller.OverrideContainer.name == containerName) return controller.OverrideContainer; @@ -207,14 +208,14 @@ private static void TryInject(DialogueInjection injection, S1NPC npc) var containers = handler.dialogueContainers; #else var field = typeof(DialogueHandler).GetField("dialogueContainers", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var containers = field?.GetValue(handler) as List; + var containers = field?.GetValue(handler) as List; #endif if (containers == null) return null; for (int i = 0; i < containers.Count; i++) { - DialogueContainer candidate = containers.ToArray()[i]; + Conversation candidate = containers.ToArray()[i]; if (candidate != null && candidate.name == containerName) return candidate; } diff --git a/S1API/Entities/Dealer/DealerDataBuilder.cs b/S1API/Entities/Dealer/DealerDataBuilder.cs index f3854b80..af13c261 100644 --- a/S1API/Entities/Dealer/DealerDataBuilder.cs +++ b/S1API/Entities/Dealer/DealerDataBuilder.cs @@ -28,6 +28,9 @@ internal class DealerConfigData public bool SellInsufficientQualityItems { get; set; } = false; public bool SellExcessQualityItems { get; set; } = true; public string CompletedDealsVariable { get; set; } = string.Empty; + public bool InsufficientQualityConfigured { get; set; } + public bool ExcessQualityConfigured { get; set; } + public bool CompletedDealsVariableConfigured { get; set; } public List Recommendations { get; } = new List(); } @@ -96,6 +99,7 @@ public DealerDataBuilder WithHome(Map.Building building) public DealerDataBuilder AllowInsufficientQuality(bool allow) { _data.SellInsufficientQualityItems = allow; + _data.InsufficientQualityConfigured = true; return this; } @@ -105,6 +109,7 @@ public DealerDataBuilder AllowInsufficientQuality(bool allow) public DealerDataBuilder AllowExcessQuality(bool allow) { _data.SellExcessQualityItems = allow; + _data.ExcessQualityConfigured = true; return this; } @@ -114,6 +119,7 @@ public DealerDataBuilder AllowExcessQuality(bool allow) public DealerDataBuilder WithCompletedDealsVariable(string varName) { _data.CompletedDealsVariable = varName ?? string.Empty; + _data.CompletedDealsVariableConfigured = true; return this; } diff --git a/S1API/Entities/Dialogue/DialogueContainerBuilder.cs b/S1API/Entities/Dialogue/DialogueContainerBuilder.cs index 4fcd1911..9a26d13b 100644 --- a/S1API/Entities/Dialogue/DialogueContainerBuilder.cs +++ b/S1API/Entities/Dialogue/DialogueContainerBuilder.cs @@ -62,9 +62,9 @@ public DialogueContainerBuilder SetAllowExit(bool allow) /// /// INTERNAL: Builds a ScriptableObject DialogueContainer. /// - internal S1Dialogue.DialogueContainer Build(string containerName) + internal S1Dialogue.Conversation Build(string containerName) { - var container = ScriptableObject.CreateInstance(); + var container = ScriptableObject.CreateInstance(); container.name = string.IsNullOrEmpty(containerName) ? "CustomContainer" : containerName; // Build node data diff --git a/S1API/Entities/Employees/EmployeeManager.cs b/S1API/Entities/Employees/EmployeeManager.cs index ee346488..d6cbff8f 100644 --- a/S1API/Entities/Employees/EmployeeManager.cs +++ b/S1API/Entities/Employees/EmployeeManager.cs @@ -51,8 +51,19 @@ public static bool GetRandomAppearance(bool male, out int index, out AvatarSetti return false; } - S1Employees.EmployeeManager.Instance.GetRandomAppearance(male, out var i, out var avatarSettings); - if (avatarSettings == null) + S1Employees.EmployeeManager.Instance.GetRandomAppearance(male, out var i, out var appearanceObject); + var appearance = S1Employees.EmployeeManager.Instance.GetAppearance(male, i); + var avatarSettings = appearance == null + ? null + : global::S1API.Internal.Utils.ReflectionUtils.TryGetFieldOrProperty( + appearance, + "Settings") as +#if IL2CPPMELON + Il2CppScheduleOne.AvatarFramework.AvatarSettings; +#else + ScheduleOne.AvatarFramework.AvatarSettings; +#endif + if (appearanceObject == null || avatarSettings == null) { settings = null; index = -1; @@ -78,7 +89,21 @@ public class EmployeeAppearance /// /// Gets the avatar settings associated with this employee appearance. /// - public AvatarSettings Settings => new(S1EmployeeAppearance.Settings); + public AvatarSettings Settings + { + get + { + var settings = global::S1API.Internal.Utils.ReflectionUtils.TryGetFieldOrProperty( + S1EmployeeAppearance, + "Settings") as +#if IL2CPPMELON + Il2CppScheduleOne.AvatarFramework.AvatarSettings; +#else + ScheduleOne.AvatarFramework.AvatarSettings; +#endif + return settings == null ? AvatarSettings.Create() : new AvatarSettings(settings); + } + } /// /// Gets the mugshot sprite associated with this employee appearance. diff --git a/S1API/Entities/NPC.cs b/S1API/Entities/NPC.cs index b36feeca..59c22937 100644 --- a/S1API/Entities/NPC.cs +++ b/S1API/Entities/NPC.cs @@ -1,4 +1,5 @@ #if (IL2CPPMELON) +using NativeVehicleLifecycleAction = Il2CppSystem.Action; using S1DevUtilities = Il2CppScheduleOne.DevUtilities; using S1AvatarEquipping = Il2CppScheduleOne.AvatarFramework.Equipping; using S1Dialogue = Il2CppScheduleOne.Dialogue; @@ -28,6 +29,7 @@ using S1Money = Il2CppScheduleOne.Money; using ConversationCategoryList = Il2CppSystem.Collections.Generic.List; #elif MONOMELON +using NativeVehicleLifecycleAction = System.Action; using S1DevUtilities = ScheduleOne.DevUtilities; using S1AvatarEquipping = ScheduleOne.AvatarFramework.Equipping; using S1Dialogue = ScheduleOne.Dialogue; @@ -143,11 +145,12 @@ internal static readonly System.Collections.Generic.HashSet private static readonly System.Collections.Generic.HashSet CustomerTypes = new System.Collections.Generic.HashSet(); private static readonly System.Collections.Generic.HashSet DealerTypes = new System.Collections.Generic.HashSet(); private static readonly System.Collections.Generic.HashSet SupplierTypes = new System.Collections.Generic.HashSet(); - private const float DefaultRelationDelta = 2f; private const string DealerPrefabName = "Dealer"; private const string CivilianNpcPrefabName = "CivilianNPC"; private const string BaseNpcPrefabName = "BaseNPC"; private const string BaseEmployeePrefabName = "BaseEmployee"; + private const string PropertyInteriorNavMeshAreaName = "PropertyInterior"; + private const string LadderNavMeshAreaName = "Ladder"; private static readonly bool LogBetaNpcPrefabDiagnostics = false; private static readonly string[] BaseNpcMembersToCopy = { @@ -185,6 +188,10 @@ internal static readonly System.Collections.Generic.HashSet }; private static volatile bool _prefabsConfiguredForLocalProcess; private static bool _loggedBaseEmployeeNormalization; + private static bool _loggedExternalPreRegisterAllCall; + private static bool _loggedExternalPreRegisterTypeCall; + private static readonly System.Collections.Generic.HashSet WarnedUnsupportedDealerSettings = + new System.Collections.Generic.HashSet(StringComparer.Ordinal); private static int _clientNetworkSpawnHydrationDepth; #if MONOMELON private static readonly FieldInfo BehaviourOwnerField = @@ -250,9 +257,9 @@ private static GameObject InstantiateTemplateInstance(System.Type npcType, NPC o } } catch { } - if (S1NPCs.NPCManager.InstanceExists && S1NPCs.NPCManager.Instance.NPCContainer != null) + if (S1NPCs.NPCManager.InstanceExists) { - Transform parent = S1NPCs.NPCManager.Instance.NPCContainer; + Transform parent = S1NPCs.NPCManager.Instance.transform; if (parent != null && parent.gameObject != null && parent.gameObject.activeInHierarchy) instance.transform.SetParent(parent, false); } @@ -469,6 +476,7 @@ private static void NormalizeBaseEmployeePrefab( if (sourceNpc == null && existingPlainNpc != null) { RepairNpcPrefabReferences(prefabRoot, existingPlainNpc); + NormalizeBaseEmployeeNavigation(prefabRoot, rootRole); if (rootRole == NpcRootRole.Dealer) EnsureDealerComponentOnPrefab(prefabRoot); else if (rootRole == NpcRootRole.Supplier) @@ -533,9 +541,11 @@ private static void NormalizeBaseEmployeePrefab( } RemoveEmployeeComponentsFromBaseEmployeeFallback(prefabRoot); + NormalizeBaseEmployeeDialogueComponents(prefabRoot, rootRole); LogBaseEmployeeComponentState("after employee cleanup", prefabRoot); RewireChildNpcReferences(prefabRoot, replacementNpc); RepairNpcPrefabReferences(prefabRoot, replacementNpc); + NormalizeBaseEmployeeNavigation(prefabRoot, rootRole); LogBaseEmployeeNormalization(); } catch (Exception ex) @@ -594,15 +604,15 @@ private static void RepairNpcPrefabReferences(GameObject prefabRoot, S1NPCs.NPC if (movement != null) { SetGameMember(npc, "Movement", movement); - SetGameMember(movement, "npc", npc); + SetGameMember(movement, "_npc", npc); - movement.Agent = EnsureRootNavMeshAgent(prefabRoot); + SetGameMember(movement, "_agent", EnsureRootNavMeshAgent(prefabRoot)); var speedController = prefabRoot.GetComponent() ?? prefabRoot.GetComponentInChildren(true); if (speedController != null) { - movement.SpeedController = speedController; + SetGameMember(movement, "_speedController", speedController); SetGameMember(speedController, "Movement", movement); } } @@ -614,6 +624,41 @@ private static UnityEngine.AI.NavMeshAgent EnsureRootNavMeshAgent(GameObject pre ?? prefabRoot.AddComponent(); } + private static void NormalizeBaseEmployeeNavigation(GameObject prefabRoot, NpcRootRole rootRole) + { + if (prefabRoot == null || rootRole != NpcRootRole.Plain) + return; + + var movement = prefabRoot.GetComponent() + ?? prefabRoot.GetComponentInChildren(true); + UnityEngine.AI.NavMeshAgent agent = EnsureRootNavMeshAgent(prefabRoot); + int propertyInteriorArea = UnityEngine.AI.NavMesh.GetAreaFromName(PropertyInteriorNavMeshAreaName); + int ladderArea = UnityEngine.AI.NavMesh.GetAreaFromName(LadderNavMeshAreaName); + agent.areaMask = IncludeNavMeshArea( + ExcludeNavMeshArea(agent.areaMask, propertyInteriorArea), + ladderArea); + agent.obstacleAvoidanceType = UnityEngine.AI.ObstacleAvoidanceType.MedQualityObstacleAvoidance; + + if (movement != null) + { + movement.SetAgentType(S1NPCs.NPCMovement.EAgentType.Humanoid); + SetGameMember( + movement, + "_defaultObstacleAvoidanceType", + UnityEngine.AI.ObstacleAvoidanceType.HighQualityObstacleAvoidance); + } + } + + internal static int ExcludeNavMeshArea(int areaMask, int areaIndex) => + areaIndex is >= 0 and < 32 + ? areaMask & ~(1 << areaIndex) + : areaMask; + + internal static int IncludeNavMeshArea(int areaMask, int areaIndex) => + areaIndex is >= 0 and < 32 + ? areaMask | (1 << areaIndex) + : areaMask; + private static S1Economy.Dealer? EnsureDealerComponentOnPrefab(GameObject prefabRoot) { if (prefabRoot == null) @@ -678,8 +723,10 @@ private static UnityEngine.AI.NavMeshAgent EnsureRootNavMeshAgent(GameObject pre private static void RepairDealerPrefabReferences(GameObject prefabRoot, S1Economy.Dealer dealer) { - dealer.HomeEvent ??= - prefabRoot.GetComponentInChildren(true); + dealer.HomeEvent ??= prefabRoot + .GetComponentsInChildren(true) + .FirstOrDefault(action => + NPCPrefabBuilder.IsDealerHomeEventName(action?.gameObject?.name)); S1Dialogue.DialogueController_Dealer controller = prefabRoot.GetComponentInChildren(true); @@ -694,8 +741,14 @@ private static void RepairDealerPrefabReferences(GameObject prefabRoot, S1Econom controller = controllerObject.AddComponent(); if (source != null) { - controller.IntObj = source.IntObj; - controller.GenericDialogue = source.GenericDialogue; + SetGameMember( + controller, + "_interactable", + GetGameMember(source, "_interactable")); + SetGameMember( + controller, + "_genericConversation", + GetGameMember(source, "_genericConversation")); controller.DialogueEnabled = source.DialogueEnabled; controller.UseDialogueBehaviour = source.UseDialogueBehaviour; controller.Choices = source.Choices; @@ -822,6 +875,56 @@ private static void RemoveEmployeeComponentsFromBaseEmployeeFallback(GameObject } } + private static void NormalizeBaseEmployeeDialogueComponents( + GameObject prefabRoot, + NpcRootRole rootRole) + { + if (prefabRoot == null || rootRole != NpcRootRole.Plain) + return; + + try + { + var employeeControllers = + prefabRoot.GetComponentsInChildren(true); + foreach (S1Dialogue.DialogueController_Employee employeeController in employeeControllers) + { + if (employeeController == null) + continue; + + GameObject controllerObject = employeeController.gameObject; + var civilianController = controllerObject.GetComponent(); + if (civilianController == null || civilianController == employeeController) + { + civilianController = controllerObject.AddComponent(); + SetGameMember( + civilianController, + "_interactable", + GetGameMember(employeeController, "_interactable")); + SetGameMember( + civilianController, + "_genericConversation", + GetGameMember(employeeController, "_genericConversation")); + civilianController.DialogueEnabled = employeeController.DialogueEnabled; + civilianController.UseDialogueBehaviour = employeeController.UseDialogueBehaviour; + // Customer and other runtime components rebuild their own role-specific dialogue state. + civilianController.Choices = new List(); + civilianController.GreetingOverrides = new List(); + civilianController.OverrideContainer = null; + } + + RemoveComponentImmediate(employeeController); + Logger.Debug( + $"[S1API][BaseEmployeeFallback][Dialogue] Replaced employee dialogue controller on " + + $"'{controllerObject.name}' with the base civilian controller."); + } + } + catch (Exception ex) + { + Logger.Warning( + $"[S1API][BaseEmployeeFallback][Dialogue] Failed to normalize employee dialogue components: {ex.Message}"); + } + } + private static void RemoveComponentImmediate(Component component) { try @@ -867,7 +970,8 @@ private static GameObject GetOrCreatePerNpcPrefab(System.Type npcType, NPC? owne NetworkObject? chosen = null; int count = spawnablePrefabs.GetObjectCount(); - NpcRootRole rootRole = GetDeclaredRootRole(npcType); + NpcRoleDeclaration roleDeclaration = GetDeclaredRoles(npcType); + NpcRootRole rootRole = roleDeclaration.RootRole; chosen = ResolveNpcSpawnablePrefab(spawnablePrefabs, count, rootRole); if (chosen == null) @@ -948,6 +1052,7 @@ private static GameObject GetOrCreatePerNpcPrefab(System.Type npcType, NPC? owne throw new InvalidOperationException("NPC prefab is missing its NetworkObject."); var prefabRoot = prefabNO.gameObject ?? throw new InvalidOperationException("NPC prefab is missing its GameObject."); var builder = new NPCPrefabBuilder(prefabRoot, npcType); + PrepareDeclaredRoleInfrastructure(builder, roleDeclaration); if (owner != null) { owner.ConfigurePrefab(builder); @@ -957,27 +1062,37 @@ private static GameObject GetOrCreatePerNpcPrefab(System.Type npcType, NPC? owne InvokeConfigurePrefabWithoutInstance(npcType, builder); } - // ConfigurePrefab may declare a specialized root role even when the virtual property was not overridden. - rootRole = GetDeclaredRootRole(npcType); + // Legacy builder calls may declare roles that were not exposed by type-level properties. + roleDeclaration = GetDeclaredRoles(npcType); + rootRole = roleDeclaration.RootRole; + S1Economy.Dealer? dealerComponent = null; + S1Economy.Supplier? supplierComponent = null; switch (rootRole) { case NpcRootRole.Dealer: - { - var dealerComponent = EnsureDealerComponentOnPrefab(prefabNO.gameObject); - var dealerDefaults = BuildDealerDefaultsForType(npcType); - if (dealerComponent != null && dealerDefaults != null) - TryApplyDealerDefaults(dealerComponent, dealerDefaults); + dealerComponent = EnsureDealerComponentOnPrefab(prefabNO.gameObject); break; - } case NpcRootRole.Supplier: - { - var supplierComponent = EnsureSupplierComponentOnPrefab(prefabNO.gameObject); - var supplierDefaults = BuildSupplierDefaultsForType(npcType); - if (supplierComponent != null && supplierDefaults != null) - TryApplySupplierDefaults(supplierComponent, supplierDefaults); - SupplierRuntimeCoordinator.FinalizePrefabInfrastructure(prefabNO.gameObject); + supplierComponent = EnsureSupplierComponentOnPrefab(prefabNO.gameObject); break; - } + } + + // Root replacement can invalidate references prepared against the donor NPC, so this + // pass is deliberately repeated after compatibility roles have been materialized. + PrepareDeclaredRoleInfrastructure(builder, roleDeclaration); + + var dealerDefaults = BuildDealerDefaultsForType(npcType); + if (dealerComponent != null && dealerDefaults != null) + TryApplyDealerDefaults(dealerComponent, dealerDefaults); + + var supplierDefaults = BuildSupplierDefaultsForType(npcType); + if (supplierComponent != null && supplierDefaults != null) + TryApplySupplierDefaults(supplierComponent, supplierDefaults); + if (supplierComponent != null) + { + SupplierRuntimeCoordinator.FinalizePrefabInfrastructure( + prefabNO.gameObject, + supplierDefaults?.PersistentId); } // Ensure schedule actions exist on the template so NetworkBehaviour indices are stable @@ -992,34 +1107,6 @@ private static GameObject GetOrCreatePerNpcPrefab(System.Type npcType, NPC? owne RemoveEmployeeComponentsFromBaseEmployeeFallback(prefabNO.gameObject); } - // If we are pre-registering without an instance owner, ensure baseline Customer exists when applicable - if (owner == null) - { - try - { - // Only add Customer for types that opted-in via EnsureCustomer - if (IsCustomerType(npcType)) - { - var existingCustomer = prefabNO.gameObject.GetComponent(); - if (existingCustomer == null) - { - existingCustomer = prefabNO.gameObject.AddComponent(); - } - - // Apply defaults if the mod registered them - var defaults = GetCustomerDefaultsForType(npcType); - if (defaults != null && existingCustomer != null) - { - var data = BuildCustomerDefaultsForType(npcType); - if (data != null) - TrySetCustomerDataOnComponent(existingCustomer, data); - } - } - - } - catch { } - } - RepairBehaviourOwnership(prefabRoot, GetPreferredNpcComponent(prefabRoot)); // Register as spawnable so FishNet assigns stable behaviour indices and can network-spawn @@ -1062,49 +1149,44 @@ private static void FinalizeSupplierPrefabIfNeeded(System.Type npcType, GameObje if (prefabRoot == null || GetDeclaredRootRole(npcType) != NpcRootRole.Supplier) return; - SupplierRuntimeCoordinator.FinalizePrefabInfrastructure(prefabRoot); + SupplierRuntimeCoordinator.FinalizePrefabInfrastructure( + prefabRoot, + BuildSupplierDefaultsForType(npcType)?.PersistentId); } - private static NpcRootRole GetDeclaredRootRole(System.Type npcType) + private static NpcRoleDeclaration GetDeclaredRoles(System.Type npcType) { - bool isDealer = IsDealerType(npcType); - bool isSupplier = IsSupplierType(npcType); - bool isPhysical = false; + NpcRoleDeclaration declaration = NpcRoleDeclarationResolver + .GetDeclaredProperties(npcType) + .WithCompatibilityRoles( + IsCustomerType(npcType), + IsDealerType(npcType), + IsSupplierType(npcType)) + .Validate(npcType); - try - { - NPC tempInstance = (NPC)FormatterServices.GetUninitializedObject(npcType); - isDealer |= tempInstance.IsDealer; - isSupplier |= tempInstance.IsSupplier; - isPhysical = tempInstance.IsPhysical; - } - catch - { - } + if (declaration.IsCustomer) + RegisterCustomerType(npcType); + if (declaration.IsSupplier) + RegisterSupplierType(npcType); + else if (declaration.IsDealer) + RegisterDealerType(npcType); - if (isDealer && isSupplier) - { - throw new InvalidOperationException( - $"Custom NPC type '{npcType.FullName}' cannot be both a dealer and a supplier root."); - } + return declaration; + } - if (isSupplier) - { - if (!isPhysical) - { - throw new InvalidOperationException( - $"Custom supplier type '{npcType.FullName}' must override IsPhysical to return true."); - } + private static NpcRootRole GetDeclaredRootRole(System.Type npcType) => + GetDeclaredRoles(npcType).RootRole; - RegisterSupplierType(npcType); - return NpcRootRole.Supplier; - } - if (isDealer) - { - RegisterDealerType(npcType); - return NpcRootRole.Dealer; - } - return NpcRootRole.Plain; + private static void PrepareDeclaredRoleInfrastructure( + NPCPrefabBuilder builder, + NpcRoleDeclaration declaration) + { + if (declaration.IsCustomer) + builder.EnsureCustomerInfrastructure(); + if (declaration.IsDealer) + builder.EnsureDealerInfrastructure(); + if (declaration.IsSupplier) + builder.EnsureSupplierInfrastructure(); } private static void InvokeConfigurePrefabWithoutInstance(System.Type npcType, NPCPrefabBuilder builder) @@ -1198,29 +1280,11 @@ internal static bool TryGetConfiguredNpcId(System.Type npcType, out string id) return null; // Find the NPC type in loaded assemblies - System.Type? npcType = null; - var baseType = typeof(NPC); - var asms = AppDomain.CurrentDomain.GetAssemblies(); - for (int ai = 0; ai < asms.Length && npcType == null; ai++) - { - var asm = asms[ai]; - if (asm == baseType.Assembly) - continue; // Skip S1API assembly (internal wrappers) - - System.Type[] types; - try { types = asm.GetTypes(); } catch { continue; } - for (int ti = 0; ti < types.Length; ti++) - { - var t = types[ti]; - if (t == null || t.IsAbstract || !baseType.IsAssignableFrom(t)) - continue; - if (t.Name == typeName) - { - npcType = t; - break; - } - } - } + var baseAssembly = typeof(NPC).Assembly; + System.Type? npcType = ReflectionUtils.GetDerivedClasses() + .FirstOrDefault(type => + type.Assembly != baseAssembly && + type.Name == typeName); if (npcType == null) return null; @@ -1296,6 +1360,7 @@ internal static bool TryGetConfiguredNpcId(System.Type npcType, out string id) // Add to All list All.Add(wrapper); + ReconcileAllCustomNpcRelationshipConnections(); return wrapper; } @@ -1319,6 +1384,14 @@ private static void InitializeWrapperStateFromNetworkSpawn(NPC wrapper, S1NPCs.N wrapper.Appearance = new NPCAppearance(wrapper, runtimeAvatar); wrapper.RestoreRuntimeAvatarAppearance(); + try + { + var registry = S1NPCs.NPCManager.NPCRegistry; + if (registry != null && !registry.Contains(baseNpc)) + registry.Add(baseNpc); + } + catch { } + var identity = baseNpc.gameObject?.GetComponent(); if (identity != null) { @@ -1330,14 +1403,6 @@ private static void InitializeWrapperStateFromNetworkSpawn(NPC wrapper, S1NPCs.N wrapper.RefreshMessagingIcons(); wrapper._relationshipDataAppliedFromPrefab = identity != null && baseNpc.RelationData != null; - - try - { - var registry = S1NPCs.NPCManager.NPCRegistry; - if (registry != null && !registry.Contains(baseNpc)) - registry.Add(baseNpc); - } - catch { } } catch (Exception ex) { @@ -1413,10 +1478,25 @@ internal static void RegisterSchedulePlanForType(System.Type npcType, System.Col } /// - /// Pre-registers a per-type NPC prefab into FishNet spawnables without creating a live instance. - /// Should be called on both server and client before any NPC instances are spawned. + /// Compatibility shim for manually pre-registering a per-type NPC prefab. /// + /// S1API owns prefab registration and retry timing. Mods should not call this method. + /// The custom NPC type whose prefab S1API will pre-register. + [Obsolete("S1API automatically pre-registers NPC prefabs. Remove this call.", false)] public static void PreRegisterPrefabForType(System.Type npcType) + { + if (!_loggedExternalPreRegisterTypeCall) + { + _loggedExternalPreRegisterTypeCall = true; + Logger.Warning( + "[S1API][NPCPrefabRegistration] A mod called the legacy NPC.PreRegisterPrefabForType API. " + + "S1API already owns prefab registration and retry timing; remove this call from mod initialization."); + } + + PreRegisterPrefabForTypeInternal(npcType); + } + + internal static void PreRegisterPrefabForTypeInternal(System.Type npcType) { try { @@ -1435,9 +1515,24 @@ public static void PreRegisterPrefabForType(System.Type npcType) } /// - /// Scans loaded assemblies for subclasses of S1API.Entities.NPC and pre-registers their prefabs. + /// Compatibility shim for manually scanning and pre-registering NPC prefabs. /// + /// S1API owns prefab registration and retry timing. Mods should not call this method. + [Obsolete("S1API automatically pre-registers NPC prefabs. Remove this call.", false)] public static void PreRegisterAllNpcPrefabs() + { + if (!_loggedExternalPreRegisterAllCall) + { + _loggedExternalPreRegisterAllCall = true; + Logger.Warning( + "[S1API][NPCPrefabRegistration] A mod called the legacy NPC.PreRegisterAllNpcPrefabs API. " + + "S1API already scans and registers NPC prefabs when FishNet is ready; remove this call from mod initialization."); + } + + PreRegisterAllNpcPrefabsInternal(); + } + + internal static void PreRegisterAllNpcPrefabsInternal() { try { @@ -1447,35 +1542,15 @@ public static void PreRegisterAllNpcPrefabs() if (spawnables == null) return; - var baseType = typeof(NPC); - var baseAssembly = baseType.Assembly; - var candidateTypes = new System.Collections.Generic.List(); - var asms = AppDomain.CurrentDomain.GetAssemblies(); - for (int ai = 0; ai < asms.Length; ai++) - { - var asm = asms[ai]; - Type[] types; - try { types = asm.GetTypes(); } catch { continue; } - for (int ti = 0; ti < types.Length; ti++) - { - var t = types[ti]; - if (t == null || t.IsAbstract) - continue; - if (baseType.IsAssignableFrom(t)) - { - // Skip internal S1API NPC wrappers; only pre-register mod-defined types - if (t.Assembly == baseAssembly) - continue; - candidateTypes.Add(t); - } - } - } + var baseAssembly = typeof(NPC).Assembly; + var candidateTypes = ReflectionUtils.GetDerivedClasses() + .Where(type => type.Assembly != baseAssembly); foreach (System.Type type in candidateTypes.OrderBy( candidate => candidate.FullName, StringComparer.Ordinal)) { - PreRegisterPrefabForType(type); + PreRegisterPrefabForTypeInternal(type); } SupplierRuntimeCoordinator.EnsureAllDeliveryPrefabsRegistered(); @@ -1669,7 +1744,9 @@ internal static void RegisterDealerDefaultsForType(System.Type npcType, System.A { var builder = new DealerDataBuilder(); configure(builder); - TypeToBuiltDealerDefaults[npcType] = builder.BuildInternal(); + var built = builder.BuildInternal(); + TypeToBuiltDealerDefaults[npcType] = built; + WarnForUnsupportedDealerSettings(DescribeNpcTypeOwner(npcType), built); } catch (Exception ex) { @@ -1678,6 +1755,60 @@ internal static void RegisterDealerDefaultsForType(System.Type npcType, System.A } } + private static void WarnForUnsupportedDealerSettings( + string dealerOwner, + DealerDataBuilder.DealerConfigData data) + { + if (data.InsufficientQualityConfigured || data.ExcessQualityConfigured) + { + var configuredOptions = new System.Collections.Generic.List(); + if (data.InsufficientQualityConfigured) + configuredOptions.Add("AllowInsufficientQuality"); + if (data.ExcessQualityConfigured) + configuredOptions.Add("AllowExcessQuality"); + + WarnUnsupportedDealerSettingOnce( + dealerOwner, + "quality", + $"{string.Join(" and ", configuredOptions)} cannot be applied because the native dealer quality fields were removed. " + + "Remove these calls; S1API has preserved them as compatibility no-ops."); + } + + if (data.CompletedDealsVariableConfigured) + { + WarnUnsupportedDealerSettingOnce( + dealerOwner, + "completed-deals-variable", + $"WithCompletedDealsVariable('{data.CompletedDealsVariable}') is not supported for custom dealers and is not applied. " + + "Remove this call or track completed deals in the mod's own save data."); + } + } + + private static void WarnUnsupportedDealerSettingOnce( + string dealerOwner, + string settingKey, + string guidance) + { + string normalizedOwner = string.IsNullOrWhiteSpace(dealerOwner) + ? "" + : dealerOwner; + string warningKey = normalizedOwner + "|" + settingKey; + lock (WarnedUnsupportedDealerSettings) + { + if (!WarnedUnsupportedDealerSettings.Add(warningKey)) + return; + } + + Logger.Warning($"[S1API][DealerConfiguration] Dealer '{normalizedOwner}': {guidance}"); + } + + private static string DescribeNpcTypeOwner(System.Type npcType) + { + string typeName = npcType.FullName ?? npcType.Name; + string assemblyName = npcType.Assembly.GetName().Name ?? ""; + return $"{typeName} (assembly={assemblyName})"; + } + internal static void RegisterDealerType(System.Type npcType) { if (npcType == null) @@ -1934,7 +2065,7 @@ protected NPC() if (Icon == null) NPCDataAccess.ApplyIcon(S1NPC, S1DevUtilities.PlayerSingleton.Instance.AppIcon); - S1NPC.BakedGUID = Guid.NewGuid().ToString(); + AssignPersistentGuid(id); if (IsPhysical) ResetConversationCategoriesToDefaults(); @@ -1993,6 +2124,7 @@ protected NPC(string id, string? firstName, string? lastName, Sprite? icon = nul bool hasLastName = !string.IsNullOrEmpty(lastName); NPCDataAccess.ApplyIdentity(S1NPC, id, firstName, lastName); + AssignPersistentGuid(id); if (icon != null) { NPCDataAccess.ApplyIcon(S1NPC, icon); @@ -2196,6 +2328,7 @@ public Sprite? Icon _hasExplicitIcon = value != null; NPCDataAccess.ApplyIcon(S1NPC, value); RefreshMessagingIcons(); + Internal.Patches.ContactsAppPatches.RefreshContactIcon(S1NPC); } } @@ -2206,6 +2339,7 @@ internal void ApplyGeneratedIcon(Sprite icon) NPCDataAccess.ApplyIcon(S1NPC, icon); RefreshMessagingIcons(); + Internal.Patches.ContactsAppPatches.RefreshContactIcon(S1NPC); } /// @@ -2253,7 +2387,18 @@ internal void ApplyGeneratedIcon(Sprite icon) /// Non-physical NPCs (false): Invisible, primarily for messaging and phone contacts, cannot move or be directly interacted with. /// public virtual bool IsPhysical => false; - + + /// + /// Determines whether this NPC has native customer functionality. + /// Override as true for NPCs that should buy products from the player. + /// + /// + /// This declarative type-level value is inspected while S1API prepares the NPC prefab. + /// Overrides must be stable, side-effect-free, and must not depend on constructor or field initialization. + /// When true, S1API adds and configures the customer component before network registration. + /// + public virtual bool IsCustomer => false; + /// /// Determines if the NPC has dealer functionality. Override as true for NPCs that should be dealers. /// @@ -2261,6 +2406,7 @@ internal void ApplyGeneratedIcon(Sprite icon) /// Dealer NPCs (true): Can manage customers, handle contracts, accept cash payments, and track inventory for sales. /// When true, the NPC prefab will use the "Dealer" network prefab instead of "CivilianNPC". /// Non-dealer NPCs (false): Regular NPCs without dealer-specific functionality. + /// This declarative type-level value must be stable, side-effect-free, and independent of normal instance initialization. /// public virtual bool IsDealer => false; @@ -2270,6 +2416,8 @@ internal void ApplyGeneratedIcon(Sprite icon) /// /// Supplier NPCs can provide dead-drop orders, meetings, delivery unlocks, and debt tracking. /// A custom NPC cannot be both a dealer and a supplier. + /// Suppliers must also override to return true. + /// This declarative type-level value must be stable, side-effect-free, and independent of normal instance initialization. /// public virtual bool IsSupplier => false; @@ -2409,12 +2557,10 @@ private void EnsureMessageConversationInstance(ConversationCategoryList categori if (S1NPC.MSGConversation == null) { -#if IL2CPPMELON - S1NPC.CreateMessageConversation(); -#elif MONOMELON - MethodInfo createConvoMethod = AccessTools.Method(typeof(S1NPCs.NPC), "CreateMessageConversation"); + MethodInfo createConvoMethod = AccessTools.Method( + typeof(S1NPCs.NPC), + "CreateAndAssignDefaultMessageConversation"); createConvoMethod?.Invoke(S1NPC, null); -#endif if (S1NPC.MSGConversation == null) { Logger.Warning($"EnsureMessageConversationInstance: creation failed for '{GetSafeNpcId()}'."); @@ -2445,19 +2591,6 @@ private void EnsureMessageConversationInstance(ConversationCategoryList categori } } - private static bool SafeIsServer() - { - try - { - var nm = InstanceFinder.NetworkManager; - return nm != null && nm.IsServer; - } - catch - { - return false; - } - } - private static object? GetGameMember(object? target, string memberName) { return target == null @@ -2621,17 +2754,23 @@ public float Scale /// public bool RequiresRegionUnlocked { -#if IL2CPPMELON - get => DefaultRequiresRegionUnlocked; - set { /* no-op under IL2CPP; constant in base game so non accessible */ } -#else - get => _requiresRegionUnlockedField != null && (bool)_requiresRegionUnlockedField.GetValue(S1NPC)!; - set { _requiresRegionUnlockedField?.SetValue(S1NPC, value); } -#endif + get => ResolveRequiresRegionUnlocked(S1NPC); + set => TrySetRequiresRegionUnlocked(S1NPC, value); } - // TODO: Add CurrentBuilding (currently missing NPCEnterableBuilding abstraction) - // public ??? CurrentBuilding { get; set; } + /// + /// The enterable building the NPC is currently in, if any. + /// + public Map.Building? CurrentBuilding + { + get + { + object? currentBuilding = S1NPC.CurrentBuilding; + return currentBuilding == null + ? null + : Map.Building.All.FirstOrDefault(building => ReferenceEquals(building._gameBuilding, currentBuilding)); + } + } /// /// The current vehicle the NPC is occupying, if any. @@ -2639,6 +2778,44 @@ public bool RequiresRegionUnlocked public LandVehicle? CurrentVehicle => S1NPC.CurrentVehicle != null ? new LandVehicle(S1NPC.CurrentVehicle) : null; + /// + /// Occurs when the NPC enters a vehicle. + /// + /// This event preserves the timing of the native vehicle-entry callback. + public event Action OnEnterVehicle + { + add => AddVehicleLifecycleHandler( + value, + _enterVehicleRegistrations ??= + new ManagedEventRegistrationTracker(), + SubscribeEnterVehicle, + nameof(OnEnterVehicle)); + remove => RemoveVehicleLifecycleHandler( + value, + _enterVehicleRegistrations, + UnsubscribeEnterVehicle, + nameof(OnEnterVehicle)); + } + + /// + /// Occurs when the NPC exits a vehicle. + /// + /// This event preserves the timing of the native vehicle-exit callback. + public event Action OnExitVehicle + { + add => AddVehicleLifecycleHandler( + value, + _exitVehicleRegistrations ??= + new ManagedEventRegistrationTracker(), + SubscribeExitVehicle, + nameof(OnExitVehicle)); + remove => RemoveVehicleLifecycleHandler( + value, + _exitVehicleRegistrations, + UnsubscribeExitVehicle, + nameof(OnExitVehicle)); + } + // TODO: Add Inventory (currently missing NPCInventory abstraction) // public ??? Inventory { get; set; } @@ -2673,9 +2850,9 @@ public bool IsInvincible } /// - /// Revives the NPC. - /// Note: custom NPC revive currently routes through a temporary NPCHealth patch with a reflective fallback meant - /// for pre-network-init safety; live multiplayer calls should eventually stay on the authoritative network path. + /// Revives the NPC. For a network-spawned custom NPC, the server uses the native revive method. + /// A client call does not alter local state. + /// Unspawned custom NPCs use a temporary compatibility fallback until FishNet initializes. /// public void Revive() => S1NPC.Health.Revive(); @@ -2728,19 +2905,10 @@ public void LerpScale(float scale, float lerpTime) => S1NPC.SetScale(scale, lerpTime); /// - /// Causes the NPC to become panicked. - /// Currently host/server-only: non-host clients hit the SafeIsServer guard below and this becomes a silent no-op, - /// which is inconsistent with other server-RPC-style wrappers in the API and with the runtime docs. + /// Requests that the NPC become panicked. /// - public void Panic() - { - // TODO: Revisit this guard. Unlike wrappers such as Cartel.SetStatus and CombatBehaviour.SetAndAttackTarget, - // Panic() can no longer be invoked meaningfully from multiplayer clients because non-host callers return here. - if (!SafeIsServer()) - return; - + public void Panic() => S1NPC.SetPanicked_Server(); - } /// /// Causes the NPC to stop panicking, if they are currently. @@ -2780,35 +2948,149 @@ public void ClearConversationCategories() } } - // TODO: Add OnEnterVehicle listener (currently missing LandVehicle abstraction) - // public event Action OnEnterVehicle { } - - // TODO: Add OnExitVehicle listener (currently missing LandVehicle abstraction) - // public event Action OnExitVehicle { } - - // TODO: Add OnExplosionHeard listener (currently missing NoiseEvent abstraction) - // public event Action OnExplosionHeard { } + /// + /// Called when this NPC hears an explosion. The snapshot is null only when the native event has no noise event. + /// + public event Action OnExplosionHeard + { + add => AddAwarenessHandler( + value, + _explosionHeardRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onExplosionHeard, + ResolveNoiseEvent, + nameof(OnExplosionHeard)); + remove => RemoveAwarenessHandler( + value, + _explosionHeardRegistrations, + nameof(OnExplosionHeard)); + } - // TODO: Add OnGunshotHeard listener (currently missing NoiseEvent abstraction) - // public event Action OnGunshotHeard { } + /// + /// Called when this NPC hears a gunshot. The snapshot is null only when the native event has no noise event. + /// + public event Action OnGunshotHeard + { + add => AddAwarenessHandler( + value, + _gunshotHeardRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onGunshotHeard, + ResolveNoiseEvent, + nameof(OnGunshotHeard)); + remove => RemoveAwarenessHandler( + value, + _gunshotHeardRegistrations, + nameof(OnGunshotHeard)); + } - // TODO: Add OnHitByCar listener (currently missing LandVehicle abstraction) - // public event Action OnHitByCar { } + /// + /// Called when this NPC is hit by a vehicle. The vehicle is null when the native event has no vehicle. + /// + public event Action OnHitByCar + { + add => AddAwarenessHandler( + value, + _hitByCarRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onHitByCar, + ResolveVehicle, + nameof(OnHitByCar)); + remove => RemoveAwarenessHandler( + value, + _hitByCarRegistrations, + nameof(OnHitByCar)); + } - // TODO: Add OnNoticedDrugDealing listener (currently missing Player abstraction) - // public event Action OnNoticedDrugDealing { } + /// + /// Called when this NPC notices a player dealing drugs. The player is null when S1API has no wrapper for the native player. + /// + public event Action OnNoticedDrugDealing + { + add => AddAwarenessHandler( + value, + _noticedDrugDealingRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedDrugDealing, + ResolvePlayer, + nameof(OnNoticedDrugDealing)); + remove => RemoveAwarenessHandler( + value, + _noticedDrugDealingRegistrations, + nameof(OnNoticedDrugDealing)); + } - // TODO: Add OnNoticedGeneralCrime listener (currently missing Player abstraction) - // public event Action OnNoticedGeneralCrime { } + /// + /// Called when this NPC notices a player committing a general crime. The player is null when S1API has no wrapper for the native player. + /// + public event Action OnNoticedGeneralCrime + { + add => AddAwarenessHandler( + value, + _noticedGeneralCrimeRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedGeneralCrime, + ResolvePlayer, + nameof(OnNoticedGeneralCrime)); + remove => RemoveAwarenessHandler( + value, + _noticedGeneralCrimeRegistrations, + nameof(OnNoticedGeneralCrime)); + } - // TODO: Add OnNoticedPettyCrime listener (currently missing Player abstraction) - // public event Action OnNoticedPettyCrime { } + /// + /// Called when this NPC notices a player committing a petty crime. The player is null when S1API has no wrapper for the native player. + /// + public event Action OnNoticedPettyCrime + { + add => AddAwarenessHandler( + value, + _noticedPettyCrimeRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedPettyCrime, + ResolvePlayer, + nameof(OnNoticedPettyCrime)); + remove => RemoveAwarenessHandler( + value, + _noticedPettyCrimeRegistrations, + nameof(OnNoticedPettyCrime)); + } - // TODO: Add OnPlayerViolatingCurfew listener (currently missing Player abstraction) - // public event Action OnPlayerViolatingCurfew { } + /// + /// Called when this NPC notices a player violating curfew. The player is null when S1API has no wrapper for the native player. + /// + public event Action OnNoticedPlayerViolatingCurfew + { + add => AddAwarenessHandler( + value, + _noticedPlayerViolatingCurfewRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedPlayerViolatingCurfew, + ResolvePlayer, + nameof(OnNoticedPlayerViolatingCurfew)); + remove => RemoveAwarenessHandler( + value, + _noticedPlayerViolatingCurfewRegistrations, + nameof(OnNoticedPlayerViolatingCurfew)); + } - // TODO: Add OnNoticedSuspiciousPlayer listener (currently missing Player abstraction) - // public event Action OnNoticedSuspiciousPlayer { } + /// + /// Called when this NPC notices a suspicious player. The player is null when S1API has no wrapper for the native player. + /// + public event Action OnNoticedSuspiciousPlayer + { + add => AddAwarenessHandler( + value, + _noticedSuspiciousPlayerRegistrations ??= + new ManagedEventRegistrationTracker>(), + awareness => awareness.onNoticedSuspiciousPlayer, + ResolvePlayer, + nameof(OnNoticedSuspiciousPlayer)); + remove => RemoveAwarenessHandler( + value, + _noticedSuspiciousPlayerRegistrations, + nameof(OnNoticedSuspiciousPlayer)); + } /// /// Called when the NPC died. @@ -3076,6 +3358,29 @@ public bool ConversationCanBeHidden /// internal bool RelationshipDataAppliedFromPrefab => _relationshipDataAppliedFromPrefab; + /// + /// INTERNAL: Whether native relationship state was hydrated from save data. + /// + internal bool RelationshipLoadedFromSave { get; private set; } + + /// + /// INTERNAL: Applies relationship data from a native save payload and retains it through activation. + /// + internal void LoadRelationshipFromSave( + float relationDelta, + bool unlocked, + S1Relation.NPCRelationData.EUnlockType unlockType) + { + if (!NPCRelationshipPersistencePolicy.IsValidSavedDelta(relationDelta)) + return; + + _loadedRelationshipDelta = relationDelta; + _loadedRelationshipUnlocked = unlocked; + _loadedRelationshipUnlockType = unlockType; + RelationshipLoadedFromSave = true; + RestoreLoadedRelationship(); + } + /// /// INTERNAL: Constructor used for base game NPCs. /// @@ -3143,6 +3448,8 @@ internal void CreateFromClientNetworkSpawn() { _clientNetworkSpawnHydrationDepth--; } + + MarkCustomNpcFinalized(); } internal override void SaveInternal(string folderPath, ref List extraSaveables) @@ -3247,6 +3554,7 @@ private void InitializeAwarenessComponent() { awareness.Responses = validCivilianResponses; } + } private void InitializeBehaviourComponents() @@ -3434,6 +3742,27 @@ private void InitializeBehaviourComponents() npcBehaviour.DeadBehaviour = existing; } + // Bridge NPCScheduleManager into the Behaviour priority stack. Without this, + // custom NPCs' schedules run entirely outside the priority system: nothing ever + // pauses NPCScheduleManager when a higher-priority behaviour (dialogue, combat, + // flee, etc.) activates, so e.g. NPCs keep walking their schedule while a + // conversation is in progress. ScheduleBehaviour.Priority is left at the lowest + // value so every other behaviour preempts it, matching vanilla NPC prefabs where + // the schedule is the baseline/fallback activity. + var scheduleManager = gameObject.GetComponentInChildren(true); + if (scheduleManager != null) + { + var scheduleBehaviour = npcBehaviour.GetComponentInChildren(true); + if (scheduleBehaviour == null) + { + GameObject go = new GameObject("ScheduleBehaviour"); + go.transform.SetParent(npcBehaviour.transform, false); + scheduleBehaviour = go.AddComponent(); + } + scheduleBehaviour.schedule = scheduleManager; + scheduleBehaviour.Priority = -1; + } + RepairBehaviourOwnership(gameObject, S1NPC); foreach (S1Behaviour.Behaviour behaviour in @@ -3616,6 +3945,8 @@ private void InitializeRelationshipData() Logger.Error($"[NPC] InitializeRelationshipData: Stack trace: {ex.StackTrace}"); /* ignore: base game will handle in its own lifecycle if not ready */ } + + _relationship?.EnsureUnlockedHook(); } private void ApplyRandomInventoryDefaults() @@ -3853,6 +4184,52 @@ private void InitializeNetworkBehaviours() } } + private void AssignPersistentGuid(string? npcId) + { + Guid guid = NPCPersistentIds.TryGetGuid(npcId, out Guid persistentGuid) + ? persistentGuid + : Guid.NewGuid(); +#if IL2CPPMELON + S1NPC.SetGUID(new Il2CppSystem.Guid(guid.ToString())); +#else + S1NPC.SetGUID(guid); +#endif + } + + internal void RegisterPersistentGuidForContractLoad() + { + if (!Guid.TryParse(S1NPC.GUID.ToString(), out Guid guid)) + return; + + try + { +#if IL2CPPMELON + S1NPC.SetGUID(new Il2CppSystem.Guid(guid.ToString())); +#else + S1NPC.SetGUID(guid); +#endif + } + catch (Exception ex) + { + Logger.Warning( + $"[NPC] Failed to register persistent GUID for '{GetSafeNpcId()}': {ex.Message}"); + } + } + + private void RestoreLoadedRelationship() + { + if (!_loadedRelationshipDelta.HasValue || S1NPC.RelationData == null) + return; + + S1NPC.RelationData.SetRelationship(_loadedRelationshipDelta.Value, false); + if (_loadedRelationshipUnlocked) + { + S1NPC.RelationData.Unlock( + _loadedRelationshipUnlockType, + notify: false); + } + } + private void RestoreRuntimeAvatarAppearance() { if (_runtimeAvatar == null) @@ -3868,11 +4245,15 @@ private void RestoreRuntimeAvatarAppearance() internal readonly bool IsCustomNPC; -#if IL2CPPMELON private const bool DefaultRequiresRegionUnlocked = true; -#else - private readonly FieldInfo _requiresRegionUnlockedField = AccessTools.Field(typeof(S1NPCs.NPC), "RequiresRegionUnlocked"); -#endif + + internal static bool ResolveRequiresRegionUnlocked(object npc) => + Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(npc, "RequiresRegionUnlocked") is bool value + ? value + : DefaultRequiresRegionUnlocked; + + internal static bool TrySetRequiresRegionUnlocked(object npc, bool value) => + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(npc, "RequiresRegionUnlocked", value); private readonly MethodInfo _unsettleMethod = AccessTools.Method(typeof(S1NPCs.NPC), "SetUnsettled"); private readonly MethodInfo _removePanicMethod = AccessTools.Method(typeof(S1NPCs.NPC), "RemovePanicked"); @@ -3885,11 +4266,32 @@ private void RestoreRuntimeAvatarAppearance() private NPCSupplier? _supplier; private NPCRelationship? _relationship; private NPCMessaging? _messaging; + private ManagedEventRegistrationTracker>? + _noticedDrugDealingRegistrations; + private ManagedEventRegistrationTracker>? + _noticedGeneralCrimeRegistrations; + private ManagedEventRegistrationTracker>? + _noticedPettyCrimeRegistrations; + private ManagedEventRegistrationTracker>? + _noticedPlayerViolatingCurfewRegistrations; + private ManagedEventRegistrationTracker>? + _noticedSuspiciousPlayerRegistrations; + private ManagedEventRegistrationTracker>? + _gunshotHeardRegistrations; + private ManagedEventRegistrationTracker>? + _explosionHeardRegistrations; + private ManagedEventRegistrationTracker>? + _hitByCarRegistrations; private NPCSmoking? _smoking; private NPCSprayPainting? _sprayPainting; private NPCDrinking? _drinking; private NPCItemHolding? _itemHolding; + private ManagedEventRegistrationTracker? _enterVehicleRegistrations; + private ManagedEventRegistrationTracker? _exitVehicleRegistrations; private bool _relationshipDataAppliedFromPrefab; + private float? _loadedRelationshipDelta; + private bool _loadedRelationshipUnlocked; + private S1Relation.NPCRelationData.EUnlockType _loadedRelationshipUnlockType; private readonly System.Collections.Generic.List _recommendationSubscriptions = new System.Collections.Generic.List(); @@ -3926,6 +4328,7 @@ internal bool PrepareForNetworkSpawn() } NPCDataAccess.PrepareForRuntime(S1NPC); + RestoreLoadedRelationship(); var customer = gameObject.GetComponent(); if (customer != null) @@ -3978,11 +4381,7 @@ private bool TryValidateNativeAwakeReferences(out string diagnostic) ? "Avatar" : "Avatar(active)"); } - else if (activeAvatar.HeadBone == null) - { - missing.Add("Avatar.HeadBone"); - } - else if (activeAvatar.HeadBone.GetComponentInChildren() == null) + else if (activeAvatar.GetComponentInChildren() == null) { missing.Add(nameof(S1VoiceOver.VOEmitter)); } @@ -4008,6 +4407,8 @@ internal void FinalizeNetworkSpawn() { try { + RestoreLoadedRelationship(); + // Ensure NPCAwareness.Responses reference is valid after spawn // Network spawning can sometimes break component references if (S1NPC.Awareness != null && S1NPC.Responses is S1Responses.NPCResponses_Civilian validResponses) @@ -4102,24 +4503,10 @@ internal void FinalizeNetworkSpawn() Logger.Error($"Stack trace: {ex.StackTrace}"); } - // Apply per-type relationship defaults after base fields are present, unless loaded from save - // Also preserve unlock state if NPC is already unlocked (might have been loaded from save) - var currentRelationData = S1NPC.RelationData; - - // Check if relationship data appears to have been loaded from save (unlocked or non-default delta) - // This handles the case where load happens after FinalizeNetworkSpawn but before it runs - bool appearsLoadedFromSave = false; - if (currentRelationData != null) - { - bool isUnlocked = currentRelationData.Unlocked; - float delta = currentRelationData.RelationDelta; - - // If NPC is unlocked or delta is not default (2.0), it likely came from save data - // This prevents defaults from overwriting loaded relationship data - appearsLoadedFromSave = isUnlocked || (Math.Abs(delta - DefaultRelationDelta) > 0.01f); - } - - if (!appearsLoadedFromSave) + // Apply per-type relationship defaults after base fields are present, unless + // the native relationship payload has already been hydrated from save data. + if (NPCRelationshipPersistencePolicy.ShouldApplyDefaults( + RelationshipLoadedFromSave)) { try { @@ -4196,6 +4583,8 @@ internal void FinalizeNetworkSpawn() _relationshipDataAppliedFromPrefab = true; } + _relationship?.EnsureUnlockedHook(); + // Note: Random inventory defaults are applied in InitializeInventoryComponent, not here // to avoid duplicate item insertion when StartupItems is processed by NPCInventory.Awake @@ -4235,8 +4624,7 @@ internal void FinalizeNetworkSpawn() // Check if all custom NPCs are now ready (finalized) // This sets the CustomNpcsReady flag once all custom NPCs have been spawned and finalized - FinalizedCustomNpcTypes.Add(GetType()); - CheckAndSetCustomNpcsReady(); + MarkCustomNpcFinalized(); } catch (Exception ex) { @@ -4246,7 +4634,7 @@ internal void FinalizeNetworkSpawn() /// /// Checks if all custom NPCs have been finalized and sets the CustomNpcsReady flag. - /// This is called from FinalizeNetworkSpawn to signal when all custom NPCs are ready. + /// Called after server finalization and client network-spawn hydration. /// internal static void CheckAndSetCustomNpcsReady() { @@ -4264,8 +4652,8 @@ internal static void CheckAndSetCustomNpcsReady() if (customNpcTypes.Count == 0) return; - bool allTypesFinalized = customNpcTypes.All( - type => FinalizedCustomNpcTypes.Contains(type)); + bool allTypesFinalized = Internal.Entities.CustomNpcReadinessPolicy + .AreAllTypesFinalized(customNpcTypes, FinalizedCustomNpcTypes); if (allTypesFinalized) CustomNpcsReady = true; @@ -4276,6 +4664,81 @@ internal static void CheckAndSetCustomNpcsReady() } } + private void MarkCustomNpcFinalized() + { + Internal.Entities.CustomNpcReadinessPolicy.MarkFinalized( + GetType(), + FinalizedCustomNpcTypes); + ReconcileAllCustomNpcRelationshipConnections(); + CheckAndSetCustomNpcsReady(); + } + + internal static void ReconcileAllCustomNpcRelationshipConnections() + { + var configuredNpcs = All + .Where(wrapper => wrapper?.S1NPC != null && wrapper.IsCustomNPC) + .Select(wrapper => new + { + Wrapper = wrapper, + Identity = wrapper.gameObject?.GetComponent() + }) + .Where(entry => entry.Identity != null && !string.IsNullOrWhiteSpace(entry.Wrapper.ID)) + .ToArray(); + + var declarations = + new global::System.Collections.Generic.Dictionary< + string, + global::System.Collections.Generic.IReadOnlyList>( + StringComparer.OrdinalIgnoreCase); + foreach (var entry in configuredNpcs) + { + if (!declarations.TryAdd( + entry.Wrapper.ID, + entry.Identity!.GetConfiguredConnectionIds())) + { + Logger.Warning( + $"[Relationship Data] Duplicate custom NPC ID '{entry.Wrapper.ID}' " + + "prevents deterministic connection reconciliation for that duplicate."); + } + } + + foreach (var entry in configuredNpcs) + { + try + { + global::System.Collections.Generic.IReadOnlyList connectionIds = + NPCRelationshipGraphPolicy.BuildUndirectedConnectionIds( + entry.Wrapper.ID, + declarations); + if (connectionIds.Count == 0 + && !entry.Identity!.HasConfiguredConnections()) + continue; + + var builder = new NPCRelationshipDataBuilder(); + builder.WithConnectionsById(connectionIds); + var relationData = entry.Wrapper.S1NPC.RelationData; + if (relationData == null) + { + Logger.Warning( + $"[Relationship Data] RelationData is null for " + + $"'{entry.Wrapper.GetSafeNpcId()}'; skipping connection reconciliation."); + continue; + } + + builder.ApplyTo( + relationData, + entry.Wrapper.S1NPC, + preserveUnlockState: true); + } + catch (Exception ex) + { + Logger.Warning( + $"[Relationship Data] Could not reconcile connections for " + + $"'{entry.Wrapper.GetSafeNpcId()}': {ex.Message}"); + } + } + } + private void ApplyDealerRecommendationDefaults() { var npcType = GetType(); @@ -4363,10 +4826,326 @@ private void ClearDealerRecommendationHooks() _recommendationSubscriptions.Clear(); } + private void AddAwarenessHandler( + Action? handler, + ManagedEventRegistrationTracker> registrations, + Func?> selectEvent, + Func convert, + string eventName) + { + if (handler == null) + return; + + S1NPCs.NPCAwareness? awareness = S1NPC?.Awareness; + UnityEvent? nativeEvent = awareness == null + ? null + : selectEvent(awareness); + if (nativeEvent == null) + return; + + try + { + Action managedHandler = value => + { + try + { + handler(convert(value)); + } + catch (Exception ex) + { + Logger.Warning( + $"NPC.{eventName} subscriber " + + $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' failed: {ex}"); + } + }; + +#if IL2CPPMELON + UnityAction nativeHandler = + DelegateSupport.ConvertDelegate>(managedHandler) + ?? throw new InvalidOperationException( + $"Could not create the native {eventName} listener."); +#else + UnityAction nativeHandler = new UnityAction(managedHandler); +#endif + nativeEvent.AddListener(nativeHandler); + registrations.Add( + handler, + new AwarenessEventRegistration(nativeEvent, nativeHandler)); + } + catch (Exception ex) + { + Logger.Warning( + $"Could not subscribe to NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } + + private void RemoveAwarenessHandler( + Action? handler, + ManagedEventRegistrationTracker>? registrations, + string eventName) + { + if (handler == null || registrations == null || + !registrations.TryTakeLast(handler, out var registration)) + return; + + try + { + registration.Event.RemoveListener(registration.Handler); + } + catch (Exception ex) + { + registrations.Add(handler, registration); + Logger.Warning( + $"Could not unsubscribe from NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } + + private void CleanupAwarenessEventHooks() + { + CleanupAwarenessHandlers( + _noticedDrugDealingRegistrations, + nameof(OnNoticedDrugDealing)); + CleanupAwarenessHandlers( + _noticedGeneralCrimeRegistrations, + nameof(OnNoticedGeneralCrime)); + CleanupAwarenessHandlers( + _noticedPettyCrimeRegistrations, + nameof(OnNoticedPettyCrime)); + CleanupAwarenessHandlers( + _noticedPlayerViolatingCurfewRegistrations, + nameof(OnNoticedPlayerViolatingCurfew)); + CleanupAwarenessHandlers( + _noticedSuspiciousPlayerRegistrations, + nameof(OnNoticedSuspiciousPlayer)); + CleanupAwarenessHandlers( + _gunshotHeardRegistrations, + nameof(OnGunshotHeard)); + CleanupAwarenessHandlers( + _explosionHeardRegistrations, + nameof(OnExplosionHeard)); + CleanupAwarenessHandlers( + _hitByCarRegistrations, + nameof(OnHitByCar)); + } + + private void CleanupAwarenessHandlers( + ManagedEventRegistrationTracker>? registrations, + string eventName) + { + if (registrations == null) + return; + + foreach (var registration in registrations.TakeAll()) + { + try + { + registration.NativeHandler.Event.RemoveListener( + registration.NativeHandler.Handler); + } + catch (Exception ex) + { + registrations.Add( + registration.ManagedHandler, + registration.NativeHandler); + Logger.Warning( + $"Could not clean up NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } + } + + private static Player? ResolvePlayer(S1PlayerScripts.Player player) => + player == null + ? null + : Player.All.FirstOrDefault(apiPlayer => apiPlayer.S1Player == player); + + private static NPCNoiseEvent? ResolveNoiseEvent(S1Noise.NoiseEvent noiseEvent) => + noiseEvent == null + ? null + : new NPCNoiseEvent(noiseEvent); + + private static LandVehicle? ResolveVehicle(S1Vehicles.LandVehicle vehicle) => + vehicle == null + ? null + : new LandVehicle(vehicle); + + private sealed class AwarenessEventRegistration + { + internal UnityEvent Event { get; } + internal UnityAction Handler { get; } + + internal AwarenessEventRegistration( + UnityEvent nativeEvent, + UnityAction handler) + { + Event = nativeEvent; + Handler = handler; + } + } + internal void CleanupRuntimeHooks() { ClearDealerRecommendationHooks(); + CleanupAwarenessEventHooks(); + _dealer?.Cleanup(); _messaging?.Cleanup(); + CleanupVehicleLifecycleHooks(); + } + + private void AddVehicleLifecycleHandler( + Action? handler, + ManagedEventRegistrationTracker registrations, + Action subscribe, + string eventName) + { + if (handler == null) + return; + + try + { + NativeVehicleLifecycleAction nativeHandler = + CreateVehicleLifecycleHandler(handler, eventName); + subscribe(nativeHandler); + registrations.Add(handler, nativeHandler); + } + catch (Exception ex) + { + Logger.Warning( + $"Could not subscribe to NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } + + private void RemoveVehicleLifecycleHandler( + Action? handler, + ManagedEventRegistrationTracker? registrations, + Action unsubscribe, + string eventName) + { + if (handler == null || registrations == null || + !registrations.TryTakeLast(handler, out var nativeHandler)) + return; + + try + { + unsubscribe(nativeHandler); + } + catch (Exception ex) + { + registrations.Add(handler, nativeHandler); + Logger.Warning( + $"Could not unsubscribe from NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } + + private NativeVehicleLifecycleAction CreateVehicleLifecycleHandler( + Action handler, + string eventName) + { + Action managedHandler = vehicle => + { + try + { + handler(new LandVehicle(vehicle)); + } + catch (Exception ex) + { + Logger.Warning( + $"NPC.{eventName} subscriber " + + $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' failed: {ex}"); + } + }; + +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(managedHandler) + ?? throw new InvalidOperationException( + $"Could not create the native {eventName} delegate."); +#else + return managedHandler; +#endif + } + + private void SubscribeEnterVehicle(NativeVehicleLifecycleAction handler) + { +#if IL2CPPMELON + S1NPC.onEnterVehicle = S1NPC.onEnterVehicle == null + ? handler + : Il2CppSystem.Delegate.Combine(S1NPC.onEnterVehicle, handler) + .Cast(); +#else + S1NPC.onEnterVehicle += handler; +#endif + } + + private void UnsubscribeEnterVehicle(NativeVehicleLifecycleAction handler) + { +#if IL2CPPMELON + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + S1NPC.onEnterVehicle, + handler); + S1NPC.onEnterVehicle = remaining?.Cast(); +#else + S1NPC.onEnterVehicle -= handler; +#endif + } + + private void SubscribeExitVehicle(NativeVehicleLifecycleAction handler) + { +#if IL2CPPMELON + S1NPC.onExitVehicle = S1NPC.onExitVehicle == null + ? handler + : Il2CppSystem.Delegate.Combine(S1NPC.onExitVehicle, handler) + .Cast(); +#else + S1NPC.onExitVehicle += handler; +#endif + } + + private void UnsubscribeExitVehicle(NativeVehicleLifecycleAction handler) + { +#if IL2CPPMELON + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + S1NPC.onExitVehicle, + handler); + S1NPC.onExitVehicle = remaining?.Cast(); +#else + S1NPC.onExitVehicle -= handler; +#endif + } + + private void CleanupVehicleLifecycleHooks() + { + CleanupVehicleLifecycleHandlers( + _enterVehicleRegistrations, + UnsubscribeEnterVehicle, + nameof(OnEnterVehicle)); + CleanupVehicleLifecycleHandlers( + _exitVehicleRegistrations, + UnsubscribeExitVehicle, + nameof(OnExitVehicle)); + } + + private void CleanupVehicleLifecycleHandlers( + ManagedEventRegistrationTracker? registrations, + Action unsubscribe, + string eventName) + { + if (registrations == null) + return; + + foreach (var registration in registrations.TakeAll()) + { + try + { + unsubscribe(registration.NativeHandler); + } + catch (Exception ex) + { + registrations.Add( + registration.ManagedHandler, + registration.NativeHandler); + Logger.Warning( + $"Could not clean up NPC.{eventName} for '{GetSafeNpcId()}': {ex}"); + } + } } private sealed class DealerRecommendationSubscription @@ -4414,8 +5193,9 @@ internal static bool ResolveSpawnVisibility( isPhysical && (!isSupplier || isSupplierMeeting); internal static bool ShouldApplyLoadedVisibilityBeforeSpawn( + bool isPhysical, bool isSupplier) => - !isSupplier; + isPhysical && !isSupplier; private IEnumerator DelayedVisibilityRPC() { @@ -4537,17 +5317,9 @@ private static void EnsureScheduleActionsOnPrefab(GameObject prefabRoot) // Best-effort wire internal references so actions have context even while inactive try { -#if MONOMELON - var npcField = t.GetField("npc", BindingFlags.NonPublic | BindingFlags.Instance); - var schedField = t.GetField("schedule", BindingFlags.NonPublic | BindingFlags.Instance); -#else - var npcField = t.GetField("npc", Il2CppSystem.Reflection.BindingFlags.NonPublic | Il2CppSystem.Reflection.BindingFlags.Public | Il2CppSystem.Reflection.BindingFlags.Instance); - var schedField = t.GetField("schedule", Il2CppSystem.Reflection.BindingFlags.NonPublic | Il2CppSystem.Reflection.BindingFlags.Public | Il2CppSystem.Reflection.BindingFlags.Instance); -#endif var baseNpc = prefabRoot.GetComponent(); - - npcField?.SetValue(comp, baseNpc); - schedField?.SetValue(comp, existingMgr); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(comp, "npc", baseNpc); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(comp, "schedule", existingMgr); } catch { } go.SetActive(false); diff --git a/S1API/Entities/NPCAppearance.cs b/S1API/Entities/NPCAppearance.cs index 98cb418f..115c8ea1 100644 --- a/S1API/Entities/NPCAppearance.cs +++ b/S1API/Entities/NPCAppearance.cs @@ -39,6 +39,8 @@ namespace S1API.Entities public class NPCAppearance { private static readonly Log _logger = new Log("NPCAppearance"); + private bool _mugshotQueued; + private bool _mugshotCompleted; #region Internal Members @@ -61,10 +63,9 @@ internal NPCAppearance(NPC npc, S1AvatarFramework.Avatar? runtimeAvatar) if (_runtimeAvatar != null) { - if (_runtimeAvatar.CurrentSettings != null) - sourceSettings = _runtimeAvatar.CurrentSettings; - else - sourceSettings = global::S1API.Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(_runtimeAvatar, "InitialAvatarSettings") as S1AvatarFramework.AvatarSettings; + sourceSettings = global::S1API.Internal.Utils.ReflectionUtils.TryGetFieldOrProperty( + _runtimeAvatar, + "InitialAvatarSettings") as S1AvatarFramework.AvatarSettings; } if (sourceSettings != null) @@ -88,15 +89,19 @@ internal NPCAppearance(NPC npc, S1AvatarFramework.Avatar? runtimeAvatar) internal void GenerateMugshot() { if (NPC.HasExplicitIcon) + { + _mugshotCompleted = true; return; - - // Enqueue serialized mugshot generation to avoid shared rig race conditions - var generator = S1AvatarFramework.MugshotGenerator.Instance; - if (generator == null || generator.MugshotRig == null) - return; + } lock (_mugshotQueueLock) { + if (_mugshotQueued || _mugshotCompleted) + return; + + // Queue even before MugshotGenerator is ready. The processor waits for the + // native rig, avoiding a permanent shared Contacts icon on early OnCreated calls. + _mugshotQueued = true; _mugshotQueue.Enqueue(this); _hasQueuedMugshots = true; if (!_isProcessingMugshots) @@ -109,6 +114,46 @@ internal void GenerateMugshot() private static IEnumerator ProcessMugshotQueue() { + while (true) + { + IEnumerator processor = ProcessMugshotQueueCore(); + bool restart = false; + + while (true) + { + bool hasNext; + object? current = null; + try + { + hasNext = processor.MoveNext(); + if (hasNext) + current = processor.Current; + } + catch (Exception ex) + { + _logger.Error($"[Mugshot] Capture job failed: {ex}"); + restart = CompleteActiveMugshot(); + if (!restart) + AbortQueuedMugshots(); + break; + } + + if (!hasNext) + yield break; + + yield return current; + } + + if (!restart) + yield break; + + yield return null; + } + } + + private static IEnumerator ProcessMugshotQueueCore() + { +#if false var generator = S1AvatarFramework.MugshotGenerator.Instance; var mugshotRig = generator != null ? generator.MugshotRig : null; var iconGenerator = generator != null ? generator.Generator : null; @@ -170,7 +215,10 @@ private static IEnumerator ProcessMugshotQueue() } if (next.NPC.HasExplicitIcon) + { + next.MarkMugshotCompleted(); continue; + } // Refresh references in case they became stale generator = S1AvatarFramework.MugshotGenerator.Instance; @@ -185,13 +233,92 @@ private static IEnumerator ProcessMugshotQueue() continue; } - S1AvatarFramework.Avatar previousAvatar = next.NPC.S1NPC.Avatar; - global::S1API.Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(next.NPC.S1NPC, "Avatar", mugshotRig); + _activeMugshot = next; // Use a per-capture clone so subsequent appearance edits don't mutate the in-flight mugshot var mugshotSettings = ScriptableObject.Instantiate(next._customAvatarSettings); mugshotSettings.Height = 1f; + // The mugshot rig is shared scene state. Keep it detached from the live NPC: + // since the NPC rewrite, assigning it to NPC.Avatar lets runtime LOD/look logic + // alter the rig while this coroutine yields, producing impostor captures and + // player-directed eyes. + bool previousAllowCulling = mugshotRig.Animation != null && mugshotRig.Animation.AllowCulling; + var lookController = mugshotRig.LookController; + bool previousLookControllerEnabled = lookController != null && lookController.enabled; + var animator = mugshotRig.Animation != null ? mugshotRig.Animation.animator : null; + float previousAnimatorSpeed = animator != null ? animator.speed : 1f; + var eyes = mugshotRig.Eyes; + bool previousBlinkingEnabled = eyes != null && eyes.BlinkingEnabled; + Transform? leftPupil = eyes != null && eyes.leftEye != null ? eyes.leftEye.PupilContainer : null; + Transform? rightPupil = eyes != null && eyes.rightEye != null ? eyes.rightEye.PupilContainer : null; + Quaternion previousLeftPupilRotation = leftPupil != null ? leftPupil.localRotation : Quaternion.identity; + Quaternion previousRightPupilRotation = rightPupil != null ? rightPupil.localRotation : Quaternion.identity; + + _restoreActiveMugshotRig = () => + { + TryRestoreRigState( + () => + { + if (defaultSettings != null) + mugshotRig.LoadAvatarSettings(defaultSettings); + }, + "default appearance"); + TryRestoreRigState( + () => + { + if (mugshotRig.Animation != null) + mugshotRig.Animation.AllowCulling = previousAllowCulling; + }, + "animation culling"); + TryRestoreRigState( + () => + { + if (lookController != null) + { + lookController.ResetIKWeight(); + lookController.enabled = previousLookControllerEnabled; + } + }, + "look controller"); + TryRestoreRigState( + () => + { + if (animator != null) + animator.speed = previousAnimatorSpeed; + }, + "animator speed"); + TryRestoreRigState( + () => + { + if (eyes != null) + eyes.BlinkingEnabled = previousBlinkingEnabled; + }, + "blinking"); + TryRestoreRigState( + () => + { + if (leftPupil != null) + leftPupil.localRotation = previousLeftPupilRotation; + if (rightPupil != null) + rightPupil.localRotation = previousRightPupilRotation; + }, + "pupil rotation"); + TryRestoreRigState( + () => mugshotRig.gameObject.SetActive(false), + "rig visibility"); + }; + + if (mugshotRig.Animation != null) + mugshotRig.Animation.AllowCulling = false; + if (lookController != null) + { + lookController.OverrideIKWeight(0f); + lookController.enabled = false; + } + if (eyes != null) + eyes.BlinkingEnabled = false; + // === Content-validated capture with retry === // On cold start the rig's renderers may not be ready, producing completely // black textures (brightness 0.0) for 20+ consecutive frames before content @@ -203,6 +330,7 @@ private static IEnumerator ProcessMugshotQueue() const float contentBrightnessFloor = 0.01f; Texture2D? generatedMugshot = null; bool hasContent = false; + bool poseReset = false; for (int attempt = 0; attempt <= maxRetries; attempt++) { @@ -212,12 +340,24 @@ private static IEnumerator ProcessMugshotQueue() mugshotRig.gameObject.SetActive(true); // Disable distance culling so the mugshot rig never hides while the player camera is far away - bool previousAllowCulling = mugshotRig.Animation != null && mugshotRig.Animation.AllowCulling; - if (mugshotRig.Animation != null) - mugshotRig.Animation.AllowCulling = false; mugshotRig.SetVisible(true); mugshotRig.Impostor.DisableImpostor(); + if (animator != null) + { + animator.speed = previousAnimatorSpeed; + if (!poseReset) + { + animator.Rebind(); + animator.Update(0f); + poseReset = true; + } + animator.speed = 0f; + } + + // Rebind before applying appearance data. Rebinding afterwards can restore + // the prefab's animated scale/shape and produce a visibly shorter, wider + // portrait even though the mugshot settings specify Height = 1. mugshotRig.LoadAvatarSettings(mugshotSettings); SetLayerRecursively(mugshotRig.gameObject, LayerMask.NameToLayer("IconGeneration")); @@ -230,6 +370,24 @@ private static IEnumerator ProcessMugshotQueue() yield return null; yield return new WaitForEndOfFrame(); + // Live scene systems can toggle visibility late in the frame. Reassert the + // portrait-only state immediately before reading pixels. + mugshotRig.SetVisible(true); + mugshotRig.Impostor.DisableImpostor(); + // IconGenerator uses a fixed camera; it does not normalize model scale. + // Keep the preview rig at the base game's canonical mugshot height even if + // an Animator or scene callback changed the shared transform while yielding. + mugshotRig.transform.localScale = Vector3.one; + if (eyes != null) + eyes.SetEyesOpen(true); + if (leftPupil != null) + leftPupil.localRotation = Quaternion.identity; + if (rightPupil != null) + rightPupil.localRotation = Quaternion.identity; + // Neutral pupil rotations produce a straight-ahead portrait. EyeController's + // runtime target is player-driven and the thumbnail camera is deliberately + // offset, either of which makes the eyes visibly track to one side. + generatedMugshot = null; try { @@ -240,27 +398,10 @@ private static IEnumerator ProcessMugshotQueue() _logger.Error($"Direct GetTexture failed: {ex.Message}"); } - // Check if capture has actual content (not black/empty) - hasContent = false; - if (generatedMugshot != null && generatedMugshot.width > 0 && generatedMugshot.height > 0) - { - int cx = generatedMugshot.width / 2; - int cy = generatedMugshot.height / 2; - Color centerPx = generatedMugshot.GetPixel(cx, cy); - Color topPx = generatedMugshot.GetPixel(cx, (int)(generatedMugshot.height * 0.85f)); - Color botPx = generatedMugshot.GetPixel(cx, (int)(generatedMugshot.height * 0.15f)); - Color leftPx = generatedMugshot.GetPixel((int)(generatedMugshot.width * 0.25f), cy); - Color rightPx = generatedMugshot.GetPixel((int)(generatedMugshot.width * 0.75f), cy); - - float maxBrightness = 0f; - Color[] samples = { centerPx, topPx, botPx, leftPx, rightPx }; - foreach (var s in samples) - { - float b = s.r + s.g + s.b; - if (b > maxBrightness) maxBrightness = b; - } - hasContent = maxBrightness > contentBrightnessFloor; - } + // Reject empty frames and partially initialized meshes. The previous five- + // pixel brightness check accepted a stray impostor or a single clothing mesh + // as a valid portrait. + hasContent = HasPortraitContent(generatedMugshot, contentBrightnessFloor); if (hasContent) break; @@ -268,15 +409,15 @@ private static IEnumerator ProcessMugshotQueue() // No content — deactivate and retry if (defaultSettings != null) mugshotRig.LoadAvatarSettings(defaultSettings); - if (mugshotRig.Animation != null) - mugshotRig.Animation.AllowCulling = previousAllowCulling; + if (animator != null) + animator.speed = previousAnimatorSpeed; mugshotRig.gameObject.SetActive(false); if (attempt == maxRetries) _logger.Warning($"[Mugshot] {next.NPC.FirstName}: no content after {maxRetries + 1} attempts, using last capture"); } - if (generatedMugshot != null) + if (generatedMugshot != null && hasContent) { try { @@ -293,24 +434,155 @@ private static IEnumerator ProcessMugshotQueue() _logger.Error($"Failed to finalize mugshot: {ex.Message}"); } } + else + { + _logger.Error($"[Mugshot] {next.NPC.FirstName}: no complete portrait was captured; keeping the existing icon"); + } - // Restore avatar reference - global::S1API.Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(next.NPC.S1NPC, "Avatar", previousAvatar ?? next._runtimeAvatar); - next.ApplyToAvatar(next._runtimeAvatar); - - // Reset rig and deactivate - bool finalAllowCulling = mugshotRig.Animation != null && mugshotRig.Animation.AllowCulling; - if (defaultSettings != null) - mugshotRig.LoadAvatarSettings(defaultSettings); - if (mugshotRig.Animation != null) - mugshotRig.Animation.AllowCulling = finalAllowCulling; - mugshotRig.gameObject.SetActive(false); + CompleteActiveMugshot(); // Small delay between jobs to let the mugshot rig fully reset yield return new WaitForSeconds(0.1f); } +#else + while (true) + { + NPCAppearance? next; + lock (_mugshotQueueLock) + { + if (_mugshotQueue.Count == 0) + { + _isProcessingMugshots = false; + yield break; + } + + next = _mugshotQueue.Dequeue(); + } + + next.MarkMugshotCompleted(); + yield return null; + } +#endif + } + + private void MarkMugshotCompleted() + { + lock (_mugshotQueueLock) + { + _mugshotQueued = false; + _mugshotCompleted = true; + } } + private static bool CompleteActiveMugshot() + { + NPCAppearance? active = _activeMugshot; + if (active == null) + return false; + + try + { + active.ApplyToAvatar(active._runtimeAvatar); + } + catch (Exception ex) + { + _logger.Error($"[Mugshot] Failed to restore the runtime avatar: {ex.Message}"); + } + + try + { + _restoreActiveMugshotRig?.Invoke(); + } + catch (Exception ex) + { + _logger.Error($"[Mugshot] Failed to restore shared rig state: {ex.Message}"); + } + finally + { + active.MarkMugshotCompleted(); + _activeMugshot = null; + _restoreActiveMugshotRig = null; + } + + return true; + } + + private static void AbortQueuedMugshots() + { + lock (_mugshotQueueLock) + { + while (_mugshotQueue.Count > 0) + _mugshotQueue.Dequeue().MarkMugshotCompleted(); + _isProcessingMugshots = false; + } + } + + private static void TryRestoreRigState(Action restore, string stateName) + { + try + { + restore(); + } + catch (Exception ex) + { + _logger.Error($"[Mugshot] Failed to restore {stateName}: {ex.Message}"); + } + } + + internal bool MugshotReady => IsMugshotReady(NPC.HasExplicitIcon, _mugshotCompleted); + + internal static bool IsMugshotReady(bool hasExplicitIcon, bool generationCompleted) => + hasExplicitIcon || generationCompleted; + + private static bool HasPortraitContent(Texture2D? texture, float brightnessFloor) + { + if (texture == null || texture.width <= 0 || texture.height <= 0) + return false; + + int stepX = Math.Max(1, texture.width / 64); + int stepY = Math.Max(1, texture.height / 64); + int sampled = 0; + int visible = 0; + int minX = texture.width; + int minY = texture.height; + int maxX = -1; + int maxY = -1; + + for (int y = 0; y < texture.height; y += stepY) + { + for (int x = 0; x < texture.width; x += stepX) + { + sampled++; + Color pixel = texture.GetPixel(x, y); + if (pixel.a <= 0.05f || pixel.r + pixel.g + pixel.b <= brightnessFloor) + continue; + + visible++; + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + + if (maxX < minX || maxY < minY) + return false; + + float contentWidth = (maxX - minX + stepX) / (float)texture.width; + float contentHeight = (maxY - minY + stepY) / (float)texture.height; + return IsPortraitCoverageSufficient(visible, sampled, contentWidth, contentHeight); + } + + internal static bool IsPortraitCoverageSufficient( + int visibleSamples, + int totalSamples, + float contentWidth, + float contentHeight) => + totalSamples > 0 && + visibleSamples >= totalSamples * 0.1f && + contentWidth >= 0.4f && + contentHeight >= 0.82f; + /// /// INTERNAL: Applies the currently configured avatar settings to a runtime avatar instance. /// @@ -320,7 +592,9 @@ internal void ApplyToAvatar(S1AvatarFramework.Avatar? avatar) if (avatar == null) return; - avatar.LoadAvatarSettings(_customAvatarSettings); + global::S1API.Internal.Compatibility.AvatarCompatibility.ApplyLegacySettings( + avatar, + _customAvatarSettings); } #endregion @@ -698,6 +972,8 @@ private void InvalidateCombinedLayer() private static readonly Queue _mugshotQueue = new Queue(); private static bool _isProcessingMugshots = false; private static bool _hasQueuedMugshots = false; + private static NPCAppearance? _activeMugshot; + private static Action? _restoreActiveMugshotRig; /// /// INTERNAL: Resets mugshot queue state on scene change so warmup runs fresh on reload. @@ -710,6 +986,8 @@ internal static void ResetMugshotState() _mugshotQueue.Clear(); _isProcessingMugshots = false; _hasQueuedMugshots = false; + _activeMugshot = null; + _restoreActiveMugshotRig = null; } } diff --git a/S1API/Entities/NPCCustomer.cs b/S1API/Entities/NPCCustomer.cs index d54d13a0..182cd53f 100644 --- a/S1API/Entities/NPCCustomer.cs +++ b/S1API/Entities/NPCCustomer.cs @@ -42,6 +42,7 @@ using S1API.Internal.Abstraction; using S1API.Internal.Utils; #if (IL2CPPMELON) +using Il2CppInterop.Runtime; using Il2CppFishNet; using Il2CppFishNet.Managing; using Il2CppFishNet.Managing.Object; @@ -88,7 +89,7 @@ public void EnsureCustomer() { if (Component == null) { - Logger.Warning($"Customer component not present on NPC prefab for {NPC.ID}. Add it via NPC.ConfigurePrefab(builder.EnsureCustomer())."); + Logger.Warning($"Customer component not present on NPC prefab for {NPC.ID}. Override NPC.IsCustomer to return true."); return; } @@ -581,7 +582,18 @@ private void InitializeRuntimeState(S1Economy.Customer customer) // Ensure the deal-attendance implementation used by this game version is present. try { - EnsureDealAttendanceSupport(NPC?.gameObject, NPC?.GetType()); + if (EnsureDealAttendanceSupport(NPC?.gameObject, NPC?.GetType())) + { + var attendance = NPC?.gameObject + .GetComponentInChildren(true); + if (attendance != null) + { + ReflectionUtils.TrySetFieldOrProperty( + customer, + "_attendDealBehaviour", + attendance); + } + } } catch { /* ignore */ } } @@ -611,6 +623,7 @@ internal static bool EnsureDealAttendanceSupport(GameObject? prefabRoot, Type? o component = behaviourObject.AddComponent(); } + component.gameObject.SetActive(NPCPrefabBuilder.BehaviourObjectsRemainActive); component.EnabledOnAwake = false; component.Name = "Customer attend deal"; component.Priority = 4; @@ -770,21 +783,21 @@ private bool EnsureContractAssignedHook() try { - var onContractAssignedField = typeof(S1Economy.Customer).GetField("onContractAssigned", BindingFlags.Public | BindingFlags.Instance); - var evt = onContractAssignedField?.GetValue(Component); + UnityEvent? evt = Component.onContractAssigned; if (evt == null) return false; - var contractType = typeof(S1Quests.Contract); - var unityActionType = typeof(UnityAction<>).MakeGenericType(contractType); - var method = GetType().GetMethod(nameof(HandleContractAssigned), BindingFlags.NonPublic | BindingFlags.Instance); - if (method == null) - return false; - - var del = Delegate.CreateDelegate(unityActionType, this, method); - var addListener = evt.GetType().GetMethod("AddListener", new[] { unityActionType }); - addListener?.Invoke(evt, new object[] { del }); - _contractAssignedBridge = del; +#if IL2CPPMELON + _contractAssignedBridge = + DelegateSupport.ConvertDelegate>( + new Action(HandleContractAssigned)) + ?? throw new InvalidOperationException( + "Could not create the native contract-assigned listener."); +#else + _contractAssignedBridge = + new UnityAction(HandleContractAssigned); +#endif + evt.AddListener(_contractAssignedBridge); _contractAssignedUnityEvent = evt; return true; } @@ -802,9 +815,7 @@ private void TryUnhookContractAssignedEvent() try { - var unityActionType = _contractAssignedBridge.GetType(); - var removeListener = _contractAssignedUnityEvent.GetType().GetMethod("RemoveListener", new[] { unityActionType }); - removeListener?.Invoke(_contractAssignedUnityEvent, new object[] { _contractAssignedBridge }); + _contractAssignedUnityEvent.RemoveListener(_contractAssignedBridge); } catch (Exception ex) { @@ -818,11 +829,11 @@ private void TryUnhookContractAssignedEvent() } private Action? _onContractAssigned; - private Delegate? _contractAssignedBridge; - private object? _contractAssignedUnityEvent; + private UnityAction? _contractAssignedBridge; + private UnityEvent? _contractAssignedUnityEvent; // Maps Contract to safe primitives for modders - private void HandleContractAssigned(object contract) + private void HandleContractAssigned(S1Quests.Contract contract) { try { @@ -834,36 +845,35 @@ private void HandleContractAssigned(object contract) int winStart = 0; int winEnd = 0; - var contractType = contract.GetType(); - var paymentProp = contractType.GetProperty("Payment", BindingFlags.Public | BindingFlags.Instance); - if (paymentProp != null) - payment = Convert.ToSingle(paymentProp.GetValue(contract)); + var paymentValue = Utils.ReflectionUtils.TryGetFieldOrProperty(contract, "Payment"); + if (paymentValue != null) + payment = Convert.ToSingle(paymentValue); - var productListProp = contractType.GetProperty("ProductList", BindingFlags.Public | BindingFlags.Instance); - var productList = productListProp?.GetValue(contract); + var productList = Utils.ReflectionUtils.TryGetFieldOrProperty(contract, "ProductList"); if (productList != null) { - var entriesField = productList.GetType().GetField("entries", BindingFlags.Public | BindingFlags.Instance); - var entries = entriesField?.GetValue(productList) as System.Collections.IEnumerable; + var entries = Utils.ReflectionUtils.TryGetFieldOrProperty(productList, "entries") as System.Collections.IEnumerable; if (entries != null) { foreach (var e in entries) { - var qtyField = e.GetType().GetField("Quantity", BindingFlags.Public | BindingFlags.Instance); - if (qtyField != null) - totalQty += Convert.ToInt32(qtyField.GetValue(e)); + if (e != null) + { + var quantity = Utils.ReflectionUtils.TryGetFieldOrProperty(e, "Quantity"); + if (quantity != null) + totalQty += Convert.ToInt32(quantity); + } } } } - var windowProp = contractType.GetProperty("DeliveryWindow", BindingFlags.Public | BindingFlags.Instance); - var window = windowProp?.GetValue(contract); + var window = Utils.ReflectionUtils.TryGetFieldOrProperty(contract, "DeliveryWindow"); if (window != null) { - var startField = window.GetType().GetField("WindowStartTime", BindingFlags.Public | BindingFlags.Instance); - var endField = window.GetType().GetField("WindowEndTime", BindingFlags.Public | BindingFlags.Instance); - if (startField != null) winStart = Convert.ToInt32(startField.GetValue(window)); - if (endField != null) winEnd = Convert.ToInt32(endField.GetValue(window)); + var start = Utils.ReflectionUtils.TryGetFieldOrProperty(window, "WindowStartTime"); + var end = Utils.ReflectionUtils.TryGetFieldOrProperty(window, "WindowEndTime"); + if (start != null) winStart = Convert.ToInt32(start); + if (end != null) winEnd = Convert.ToInt32(end); } foreach (Action handler in handlers.GetInvocationList()) @@ -1046,7 +1056,7 @@ public void RecommendDealer(NPCDealer dealer) dialogueLine = dialogueLine.Replace("", dealer.NPC.FullName); // Create dialogue container - var container = ScriptableObject.CreateInstance(); + var container = ScriptableObject.CreateInstance(); var nodeData = new S1Dialogue.DialogueNodeData { DialogueText = dialogueLine, @@ -1078,7 +1088,7 @@ public void RecommendDealer(NPCDealer dealer) } } - private System.Collections.IEnumerator WaitAndShowDialogue(S1Dialogue.DialogueContainer container, S1Dialogue.DialogueHandler handler) + private System.Collections.IEnumerator WaitAndShowDialogue(S1Dialogue.Conversation container, S1Dialogue.DialogueHandler handler) { yield return new WaitForSeconds(0.1f); if (handler != null && container != null) @@ -1113,19 +1123,8 @@ private System.Collections.IEnumerator WaitAndShowDialogue(S1Dialogue.DialogueCo private static void SetNonPublicInstanceField(object target, string fieldName, object? value) { - try - { - if (target == null || string.IsNullOrEmpty(fieldName)) return; - var type = target.GetType(); - FieldInfo? field = null; - while (type != null && field == null) - { - field = type.GetField(fieldName, BindingFlags.Instance | System.Reflection.BindingFlags.Public | BindingFlags.NonPublic); - type = type.BaseType; - } - field?.SetValue(target, value); - } - catch (Exception) { } + if (target == null || string.IsNullOrEmpty(fieldName)) return; + Utils.ReflectionUtils.TrySetFieldOrProperty(target, fieldName, value); } } } diff --git a/S1API/Entities/NPCDealer.cs b/S1API/Entities/NPCDealer.cs index 744ccec2..6dc32266 100644 --- a/S1API/Entities/NPCDealer.cs +++ b/S1API/Entities/NPCDealer.cs @@ -10,6 +10,7 @@ using S1DevUtilities = Il2CppScheduleOne.DevUtilities; using S1UIPhoneMessages = Il2CppScheduleOne.UI.Phone.Messages; using S1Money = Il2CppScheduleOne.Money; +using NativeDealerRecruitedAction = Il2CppSystem.Action; #elif MONOMELON using S1Quests = ScheduleOne.Quests; using S1NPCs = ScheduleOne.NPCs; @@ -21,6 +22,7 @@ using S1DevUtilities = ScheduleOne.DevUtilities; using S1UIPhoneMessages = ScheduleOne.UI.Phone.Messages; using S1Money = ScheduleOne.Money; +using NativeDealerRecruitedAction = System.Action; #endif using System; @@ -33,6 +35,7 @@ using MelonLoader; using S1API.Economy; using S1API.Internal.Abstraction; +using S1API.Internal.Utils; using S1API.Map; #if (IL2CPPMELON) using Il2CppFishNet; @@ -62,11 +65,11 @@ public sealed class NPCDealer { internal readonly NPC NPC; private static readonly Logging.Log Logger = new Logging.Log("NPCDealer"); - private static readonly FieldInfo? DealerRecruitedField = typeof(S1Economy.Dealer).GetField("onDealerRecruited", BindingFlags.Public | BindingFlags.Static); - - private readonly Dictionary> _dealerRecruitedHandlers = new Dictionary>(); + private readonly ManagedEventRegistrationTracker _dealerRecruitedHandlers = new ManagedEventRegistrationTracker(); private Action? _contractAcceptedHandlers; private bool _contractAcceptedHooked; + private Action? _relationshipUnlockedHandler; + private S1Messaging.MSGConversation? _conversationUiRefreshHooked; internal NPCDealer(NPC npc) { @@ -82,8 +85,10 @@ internal static void ClearStaticDelegates() { try { - if (DealerRecruitedField != null) - DealerRecruitedField.SetValue(null, null); + Internal.Utils.ReflectionUtils.TrySetStaticFieldOrProperty( + typeof(S1Economy.Dealer), + "onDealerRecruited", + null); } catch { } } @@ -99,14 +104,14 @@ internal static void ClearStaticDelegates() /// /// Note: Since Dealer inherits from NPC in the base game (not a component), this will only work /// if the wrapped NPC is already a Dealer instance. For custom NPCs created via S1API, - /// dealer functionality must be configured at prefab creation time using . - /// This method is called automatically when the NPC spawns if was used. + /// dealer functionality must be declared at prefab creation time by overriding . + /// This method is called automatically when a dealer NPC spawns. /// public void EnsureDealer() { if (Component == null) { - Logger.Warning($"Dealer component not present on NPC prefab for {NPC.ID}. Add it via NPC.ConfigurePrefab(builder.EnsureDealer())."); + Logger.Warning($"Dealer component not present on NPC prefab for {NPC.ID}. Override NPC.IsDealer to return true."); return; } @@ -135,10 +140,16 @@ private void EnsureDealerCategory() { try { - NPC.SetConversationCategory(S1Messaging.EConversationCategory.Dealer); + bool isUnlocked = NPC.Relationship.IsUnlocked; + NPC.SetConversationCategory( + S1Messaging.EConversationCategory.Dealer, + ensureUi: ShouldEnsureConversationUi(isUnlocked)); + EnsureRelationshipUnlockHook(); + if (NPC.S1NPC.MSGConversation != null) { TryHookConversationUIRefresh(NPC.S1NPC.MSGConversation); + HideLockedEmptyConversation(); RefreshDealerCategoryBadge(); } } @@ -161,25 +172,22 @@ private void TryHookConversationUIRefresh(object convoObj) var convo = convoObj as S1Messaging.MSGConversation; if (convo == null) return; - // Check if UI already exists (uiCreated field) - var uiCreatedField = typeof(S1Messaging.MSGConversation).GetField("uiCreated", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (uiCreatedField != null) + // Check if UI already exists (field on Mono, property on IL2CPP) + if (Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(convo, "uiCreated") is bool created && created) { - var uiCreated = uiCreatedField.GetValue(convo); - - if (uiCreated != null && uiCreated is bool created && created) - { - // UI already exists, refresh immediately - RefreshDealerCategoryBadge(); - } + // UI already exists, refresh immediately + RefreshDealerCategoryBadge(); } + if (ReferenceEquals(_conversationUiRefreshHooked, convo)) + return; + // Hook onLoaded (called after UI is loaded from save) var prevLoaded = convo.onLoaded; convo.onLoaded = new System.Action(() => { try { prevLoaded?.Invoke(); } catch { } + HideLockedEmptyConversation(); RefreshDealerCategoryBadge(); }); @@ -190,6 +198,8 @@ private void TryHookConversationUIRefresh(object convoObj) try { prevOpened?.Invoke(); } catch { } RefreshDealerCategoryBadge(); }); + + _conversationUiRefreshHooked = convo; } catch (Exception ex) { @@ -197,6 +207,102 @@ private void TryHookConversationUIRefresh(object convoObj) } } + private void EnsureRelationshipUnlockHook() + { + _relationshipUnlockedHandler ??= (_, _) => ShowConversationAfterUnlock(); + + NPC.Relationship.OnUnlocked -= _relationshipUnlockedHandler; + NPC.Relationship.OnUnlocked += _relationshipUnlockedHandler; + } + + private void ShowConversationAfterUnlock() + { + try + { + NPC.SetConversationCategory( + S1Messaging.EConversationCategory.Dealer, + ensureUi: true); + + var conversation = NPC.S1NPC.MSGConversation; + if (conversation == null) + return; + + conversation.SetIsKnown(true); + conversation.SetEntryVisibility(true); + TryHookConversationUIRefresh(conversation); + RefreshDealerCategoryBadge(); + } + catch (Exception ex) + { + Logger.Warning($"Exception showing dealer conversation after unlock for {NPC.ID}: {ex.Message}"); + } + } + + private void HideLockedEmptyConversation() + { + var conversation = NPC.S1NPC.MSGConversation; + if (conversation == null) + return; + + bool uiCreated = + Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(conversation, "uiCreated") is bool created + && created; + int messageCount = + (Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(conversation, "_messageHistory") as System.Collections.ICollection)?.Count ?? 0; + int messageChainCount = + (Internal.Utils.ReflectionUtils.TryGetFieldOrProperty(conversation, "_messageChainHistory") as System.Collections.ICollection)?.Count ?? 0; + int responseCount = conversation.currentResponses?.Count ?? 0; + if (!ShouldHideLockedConversation( + NPC.Relationship.IsUnlocked, + uiCreated, + messageCount, + messageChainCount, + responseCount)) + { + return; + } + + bool conversationCanBeHidden = NPC.ConversationCanBeHidden; + try + { + if (!conversationCanBeHidden) + NPC.ConversationCanBeHidden = true; + + conversation.SetEntryVisibility(false); + } + finally + { + if (!conversationCanBeHidden) + NPC.ConversationCanBeHidden = false; + } + } + + internal static bool ShouldHideLockedConversation( + bool relationshipUnlocked, + bool uiCreated, + int messageCount, + int messageChainCount, + int responseCount) => + !relationshipUnlocked + && uiCreated + && messageCount == 0 + && messageChainCount == 0 + && responseCount == 0; + + internal static bool ShouldEnsureConversationUi(bool relationshipUnlocked) => + relationshipUnlocked; + + internal void Cleanup() + { + if (_relationshipUnlockedHandler != null) + { + NPC.Relationship.OnUnlocked -= _relationshipUnlockedHandler; + _relationshipUnlockedHandler = null; + } + + _conversationUiRefreshHooked = null; + } + /// /// Refresh the category badge on the existing conversation entry to ensure Dealer icon/label/color are shown. /// Mirrors MessagesApp.CreateConversationUI category setup. @@ -720,31 +826,26 @@ private void InitializeRuntimeState(S1Economy.Dealer dealer) } } #else - // In IL2CPP, overflow slots are private fields - try to initialize via reflection - var overflowSlotsField = typeof(S1Economy.Dealer).GetField("overflowSlots", BindingFlags.NonPublic | BindingFlags.Instance); - if (overflowSlotsField != null) + var overflowSlots = dealer.overflowSlots; + if (overflowSlots == null || overflowSlots.Length == 0) { - var overflowSlots = overflowSlotsField.GetValue(dealer) as S1Items.ItemSlot[]; - if (overflowSlots == null || overflowSlots.Length == 0) + overflowSlots = new Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppReferenceArray(10); + for (int i = 0; i < 10; i++) { - overflowSlots = new S1Items.ItemSlot[10]; - for (int i = 0; i < 10; i++) - { - overflowSlots[i] = new S1Items.ItemSlot(); - // In IL2CPP, cast Dealer to IItemSlotOwner interface - overflowSlots[i].SetSlotOwner(dealer.Cast()); - } - overflowSlotsField.SetValue(dealer, overflowSlots); + overflowSlots[i] = new S1Items.ItemSlot(); + overflowSlots[i].SetSlotOwner(dealer.Cast()); } + dealer.overflowSlots = overflowSlots; } #endif // Ensure DealerAttendDealBehaviour exists (replaced NPCSignal_HandleDeal in v0.4.2f4) try { - var attendDealField = typeof(S1Economy.Dealer).GetField("_attendDealBehaviour", BindingFlags.NonPublic | BindingFlags.Instance); - var existingBehaviour = attendDealField?.GetValue(dealer) as S1NPCsBehaviour.DealerAttendDealBehaviour; - if (existingBehaviour == null) + var behaviour = Internal.Utils.ReflectionUtils.TryGetFieldOrProperty( + dealer, + "_attendDealBehaviour") as S1NPCsBehaviour.DealerAttendDealBehaviour; + if (behaviour == null) { // Get or create NPCBehaviour manager var npcBehaviour = NPC.gameObject.GetComponentInChildren(true); @@ -755,16 +856,24 @@ private void InitializeRuntimeState(S1Economy.Dealer dealer) npcBehaviour = behGo.AddComponent(); } - var behaviour = NPC.gameObject.GetComponentInChildren(true); + behaviour = NPC.gameObject.GetComponentInChildren(true); if (behaviour == null) { var go = new GameObject("DealerAttendDealBehaviour"); + go.SetActive(false); go.transform.SetParent(npcBehaviour.transform, false); behaviour = go.AddComponent(); - go.SetActive(false); } - attendDealField?.SetValue(dealer, behaviour); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(behaviour, "beh", npcBehaviour); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(npcBehaviour, "Npc", dealer); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty( + dealer, + "_attendDealBehaviour", + behaviour); } + behaviour.gameObject.SetActive(NPCPrefabBuilder.BehaviourObjectsRemainActive); + behaviour.Name = "Attend deal"; + behaviour.Priority = NPCPrefabBuilder.DealerAttendDealPriority; } catch { /* ignore */ } @@ -777,19 +886,25 @@ private void InitializeRuntimeState(S1Economy.Dealer dealer) var homeEventField = typeof(S1Economy.Dealer).GetProperty("HomeEvent", BindingFlags.Public | BindingFlags.Instance); #endif var homeEvent = homeEventField?.GetValue(dealer) as S1NPCsSchedules.NPCEvent_StayInBuilding; - if (homeEvent == null) + if (homeEvent == null + || !NPCPrefabBuilder.IsDealerHomeEventName(homeEvent.gameObject?.name)) { var sched = NPC.gameObject.GetComponentInChildren(true); if (sched != null) { - homeEvent = NPC.gameObject.GetComponentInChildren(true); + homeEvent = NPC.gameObject + .GetComponentsInChildren(true) + .FirstOrDefault(action => + NPCPrefabBuilder.IsDealerHomeEventName(action?.gameObject?.name)); if (homeEvent == null) { - var go = new GameObject("HomeEvent"); + var go = new GameObject(NPCPrefabBuilder.DealerHomeEventName); go.transform.SetParent(sched.transform, false); homeEvent = go.AddComponent(); go.SetActive(false); } + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(homeEvent, "npc", dealer); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(homeEvent, "schedule", sched); homeEventField?.SetValue(dealer, homeEvent); } } @@ -842,13 +957,11 @@ public event Action OnRecruited add { EnsureDealer(); - if (Component == null || value == null || DealerRecruitedField == null) return; - if (_dealerRecruitedHandlers.ContainsKey(value)) - return; + if (Component == null || value == null) return; try { - Action wrapper = dealer => + Action managedWrapper = dealer => { if (dealer != Component) return; @@ -856,12 +969,28 @@ public event Action OnRecruited catch (Exception ex) { Logger.Warning($"Exception in OnRecruited handler for {NPC.ID}: {ex.Message}"); } }; - var existingValue = DealerRecruitedField.GetValue(null) as Action; +#if IL2CPPMELON + var wrapper = DelegateSupport.ConvertDelegate(managedWrapper) + ?? throw new InvalidOperationException("Failed to create IL2CPP dealer recruitment delegate."); + var existingValue = S1Economy.Dealer.onDealerRecruited; + var combined = existingValue != null + ? Il2CppSystem.Delegate.Combine(existingValue, wrapper).Cast() + : wrapper; + S1Economy.Dealer.onDealerRecruited = combined; +#else + NativeDealerRecruitedAction wrapper = managedWrapper; + var existingValue = Internal.Utils.ReflectionUtils.TryGetStaticFieldOrProperty( + typeof(S1Economy.Dealer), + "onDealerRecruited") as NativeDealerRecruitedAction; var combined = existingValue != null - ? (Action)Delegate.Combine(existingValue, wrapper) + ? (NativeDealerRecruitedAction)Delegate.Combine(existingValue, wrapper) : wrapper; - DealerRecruitedField.SetValue(null, combined); - _dealerRecruitedHandlers[value] = wrapper; + Internal.Utils.ReflectionUtils.TrySetStaticFieldOrProperty( + typeof(S1Economy.Dealer), + "onDealerRecruited", + combined); +#endif + _dealerRecruitedHandlers.Add(value, wrapper); } catch (Exception ex) { @@ -870,21 +999,33 @@ public event Action OnRecruited } remove { - if (value == null || DealerRecruitedField == null) + if (value == null) return; - if (!_dealerRecruitedHandlers.TryGetValue(value, out var wrapper)) + if (!_dealerRecruitedHandlers.TryTakeLast(value, out var wrapper)) return; - - _dealerRecruitedHandlers.Remove(value); try { - var existingValue = DealerRecruitedField.GetValue(null) as Action; +#if IL2CPPMELON + var existingValue = S1Economy.Dealer.onDealerRecruited; if (existingValue == null) return; - var remaining = (Action?)Delegate.Remove(existingValue, wrapper); - DealerRecruitedField.SetValue(null, remaining); + var remaining = Il2CppSystem.Delegate.Remove(existingValue, wrapper); + S1Economy.Dealer.onDealerRecruited = remaining?.Cast(); +#else + var existingValue = Internal.Utils.ReflectionUtils.TryGetStaticFieldOrProperty( + typeof(S1Economy.Dealer), + "onDealerRecruited") as NativeDealerRecruitedAction; + if (existingValue == null) + return; + + var remaining = (NativeDealerRecruitedAction?)Delegate.Remove(existingValue, wrapper); + Internal.Utils.ReflectionUtils.TrySetStaticFieldOrProperty( + typeof(S1Economy.Dealer), + "onDealerRecruited", + remaining); +#endif } catch (Exception ex) { @@ -1045,19 +1186,8 @@ public event Action OnRecommended private static void SetNonPublicInstanceField(object target, string fieldName, object value) { - try - { - if (target == null || string.IsNullOrEmpty(fieldName)) return; - var type = target.GetType(); - FieldInfo? field = null; - while (type != null && field == null) - { - field = type.GetField(fieldName, BindingFlags.Instance | System.Reflection.BindingFlags.Public | BindingFlags.NonPublic); - type = type.BaseType; - } - field?.SetValue(target, value); - } - catch (Exception) { } + if (target == null || string.IsNullOrEmpty(fieldName)) return; + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(target, fieldName, value); } } } diff --git a/S1API/Entities/NPCDialogue.cs b/S1API/Entities/NPCDialogue.cs index 66313819..023d7f1d 100644 --- a/S1API/Entities/NPCDialogue.cs +++ b/S1API/Entities/NPCDialogue.cs @@ -1,7 +1,10 @@ #if (IL2CPPMELON) +using Il2CppInterop.Runtime; using S1Dialogue = Il2CppScheduleOne.Dialogue; +using NativeAction = Il2CppSystem.Action; #elif MONOMELON using S1Dialogue = ScheduleOne.Dialogue; +using NativeAction = System.Action; #endif using System; @@ -20,7 +23,7 @@ namespace S1API.Entities /// /// /// Dialogue configuration is done in . Use for dialogue entries and for conversation flows. - /// Subscribe to choice and node events for dynamic dialogue behavior. + /// Subscribe to choice, node, and completion events for dynamic dialogue behavior. /// public sealed class NPCDialogue { @@ -49,12 +52,7 @@ public NPCDialogue OnChoiceSelected(string choiceLabel, Action callback) return this; EnsureHandler(); EnsureEventHooks(); - if (!_choiceCallbacks.TryGetValue(choiceLabel, out var list)) - { - list = new List(); - _choiceCallbacks[choiceLabel] = list; - } - list.Add(callback); + _choiceCallbacks.Add(choiceLabel, callback); return this; } @@ -67,12 +65,7 @@ public NPCDialogue OnNodeDisplayed(string nodeLabel, Action callback) return this; EnsureHandler(); EnsureEventHooks(); - if (!_nodeCallbacks.TryGetValue(nodeLabel, out var list)) - { - list = new List(); - _nodeCallbacks[nodeLabel] = list; - } - list.Add(callback); + _nodeCallbacks.Add(nodeLabel, callback); return this; } @@ -90,6 +83,26 @@ public NPCDialogue OnConversationStart(Action callback) return this; } + /// + /// Register a callback to run when any dialogue interaction handled by this NPC ends. + /// + /// + /// The callback is handler-wide and does not identify the ending container. It runs when the native + /// handler ends an interaction, including explicit calls to . Register it against + /// the active NPC handler; it does not persist through destruction and recreation of the handler. + /// + /// The callback to invoke when the dialogue handler ends an interaction. + /// This dialogue wrapper. + public NPCDialogue OnDialogueEnded(Action callback) + { + if (callback == null) + return this; + EnsureHandler(); + EnsureEventHooks(); + _dialogueEndedCallbacks.Add(callback); + return this; + } + /// /// Removes all registered dialogue callbacks for this NPC. /// @@ -98,6 +111,45 @@ public void ClearCallbacks() _choiceCallbacks.Clear(); _nodeCallbacks.Clear(); _conversationStartCallbacks.Clear(); + _dialogueEndedCallbacks.Clear(); + RemoveEventHooks(); + } + + /// + /// Enables or disables a controller-level dialogue choice by its destination container name. + /// + /// + /// This changes only the live DialogueController choice state. It does not modify node choices, + /// save data, or network state. The name is matched case-insensitively against the choice's destination + /// container, and the method returns when that controller choice is unavailable. + /// + /// The name of the dialogue container opened by the choice. + /// Whether the choice should be enabled. + /// when a matching controller choice was updated; otherwise . + public bool SetChoiceEnabled(string dialogueContainerName, bool enabled) + { + if (string.IsNullOrEmpty(dialogueContainerName)) + return false; + + var controller = Handler?.GetComponent(); + var choices = controller?.Choices; + if (choices == null) + return false; + + for (int i = 0; i < choices.Count; i++) + { + var choice = choices[i]; + if (choice == null || choice.Conversation == null) + continue; + + if (!NPCDialoguePolicy.MatchesChoiceContainer(choice.Conversation.name, dialogueContainerName)) + continue; + + choice.Enabled = enabled; + return true; + } + + return false; } /// @@ -166,7 +218,16 @@ public void StopOverride() /// /// INTERNAL: Returns the DialogueHandler instance, if present. /// - internal S1Dialogue.DialogueHandler Handler => NPC.gameObject.GetComponentInChildren(true); + internal S1Dialogue.DialogueHandler? Handler + { + get + { + var handler = FindHandler(); + if (handler != null && HasCallbacks) + EnsureEventHooks(handler); + return handler; + } + } /// /// INTERNAL: Ensures there is a DialogueHandler component attached. @@ -179,15 +240,49 @@ internal void EnsureHandler() private void EnsureEventHooks() { - if (Handler == null || _eventsHooked) + EnsureEventHooks(FindHandler()); + } + + private void EnsureEventHooks(S1Dialogue.DialogueHandler? handler) + { + if (handler == null) + return; + + if (_hookedHandler == handler) return; - _eventsHooked = true; + + RemoveEventHooks(); + + NativeAction? dialogueEndedDispatcher = CreateDialogueEndedDispatcher(); + if (dialogueEndedDispatcher == null) + return; + + _hookedHandler = handler; + _nativeDialogueEndedDispatcher = dialogueEndedDispatcher; + // Handler events are invoked from DialogueHandler.ChoiceCallback and DialogueCallback - global::S1API.Utils.EventHelper.AddListener(Internal_OnChoice, Handler.onDialogueChoiceChosen); - global::S1API.Utils.EventHelper.AddListener(Internal_OnNode, Handler.onDialogueNodeDisplayed); - global::S1API.Utils.EventHelper.AddListener(Internal_OnConversationStart, Handler.onConversationStart); + try + { + global::S1API.Utils.EventHelper.AddListener(Internal_OnChoice, handler.onDialogueChoiceChosen); + global::S1API.Utils.EventHelper.AddListener(Internal_OnNode, handler.onDialogueNodeDisplayed); + global::S1API.Utils.EventHelper.AddListener(Internal_OnConversationStart, handler.onConversationStart); + handler.OnDialogueEnd += dialogueEndedDispatcher; + } + catch + { + RemoveEventHooks(); + } } + private S1Dialogue.DialogueHandler? FindHandler() => + NPC.gameObject.GetComponentInChildren(true); + + private bool HasCallbacks => + _choiceCallbacks.HasCallbacks + || _nodeCallbacks.HasCallbacks + || _conversationStartCallbacks.HasCallbacks + || _dialogueEndedCallbacks.HasCallbacks; + /// /// INTERNAL: Rebuilds runtime modules on the handler to match a new database. /// Mirrors the logic in DialogueHandler.Awake for initializing modules. @@ -378,7 +473,7 @@ public void BuildAndRegisterContainer(string containerName, Action; + var list = dialogueContainersField?.GetValue(Handler) as List; #else var list = Handler.dialogueContainers; #endif @@ -414,13 +509,13 @@ public bool UseContainerOnInteract(string containerName) return false; #if MONOMELON - var list = dialogueContainersField?.GetValue(Handler) as List; + var list = dialogueContainersField?.GetValue(Handler) as List; #else var list = Handler.dialogueContainers; #endif if (list == null) return false; - S1Dialogue.DialogueContainer? container = null; + S1Dialogue.Conversation? container = null; for (int i = 0; i < list.Count; i++) { var item = list[i]; @@ -455,13 +550,13 @@ public bool UseContainerOnInteractOnce(string containerName) return false; #if MONOMELON - var list = dialogueContainersField?.GetValue(Handler) as List; + var list = dialogueContainersField?.GetValue(Handler) as List; #else var list = Handler.dialogueContainers; #endif if (list == null) return false; - S1Dialogue.DialogueContainer? container = null; + S1Dialogue.Conversation? container = null; for (int i = 0; i < list.Count; i++) { var item = list[i]; @@ -503,13 +598,13 @@ public bool JumpTo(string containerName, string entryNodeLabel, bool enableBehav if (Handler == null) return false; #if MONOMELON - var list = dialogueContainersField?.GetValue(Handler) as List; + var list = dialogueContainersField?.GetValue(Handler) as List; #else var list = Handler.dialogueContainers; #endif if (list == null) return false; - S1Dialogue.DialogueContainer? container = null; + S1Dialogue.Conversation? container = null; for (int i = 0; i < list.Count; i++) { var item = list[i]; @@ -526,35 +621,50 @@ public bool JumpTo(string containerName, string entryNodeLabel, bool enableBehav private void Internal_OnChoice(string choiceLabel) { - if (string.IsNullOrEmpty(choiceLabel)) - return; - if (_choiceCallbacks.TryGetValue(choiceLabel, out var list)) - { - for (int i = 0; i < list.Count; i++) - { - try { list[i]?.Invoke(); } catch { } - } - } + _choiceCallbacks.Invoke(choiceLabel); } private void Internal_OnNode(string nodeLabel) { - if (string.IsNullOrEmpty(nodeLabel)) - return; - if (_nodeCallbacks.TryGetValue(nodeLabel, out var list)) - { - for (int i = 0; i < list.Count; i++) - { - try { list[i]?.Invoke(); } catch { } - } - } + _nodeCallbacks.Invoke(nodeLabel); } private void Internal_OnConversationStart() { - for (int i = 0; i < _conversationStartCallbacks.Count; i++) + _conversationStartCallbacks.InvokeAll(); + } + + private void Internal_OnDialogueEnded() + { + _dialogueEndedCallbacks.InvokeAll(); + } + + private NativeAction? CreateDialogueEndedDispatcher() + { +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(new Action(Internal_OnDialogueEnded)); +#else + return Internal_OnDialogueEnded; +#endif + } + + private void RemoveEventHooks() + { + var handler = _hookedHandler; + var dialogueEndedDispatcher = _nativeDialogueEndedDispatcher; + + _hookedHandler = null; + _nativeDialogueEndedDispatcher = null; + + if (handler == null) + return; + + try { global::S1API.Utils.EventHelper.RemoveListener(Internal_OnChoice, handler.onDialogueChoiceChosen); } catch { } + try { global::S1API.Utils.EventHelper.RemoveListener(Internal_OnNode, handler.onDialogueNodeDisplayed); } catch { } + try { global::S1API.Utils.EventHelper.RemoveListener(Internal_OnConversationStart, handler.onConversationStart); } catch { } + if (dialogueEndedDispatcher != null) { - try { _conversationStartCallbacks[i]?.Invoke(); } catch { } + try { handler.OnDialogueEnd -= dialogueEndedDispatcher; } catch { } } } @@ -564,22 +674,32 @@ private void Internal_OnConversationStart() #else // In IL2CPP, dialogueContainers is a property, not a field #endif - private readonly Dictionary> _choiceCallbacks = new Dictionary>(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary> _nodeCallbacks = new Dictionary>(StringComparer.OrdinalIgnoreCase); - private readonly List _conversationStartCallbacks = new List(); - private bool _eventsHooked; + private readonly NPCDialogueCallbackRegistry _choiceCallbacks = new NPCDialogueCallbackRegistry(); + private readonly NPCDialogueCallbackRegistry _nodeCallbacks = new NPCDialogueCallbackRegistry(); + private readonly NPCDialogueCallbackRegistry _conversationStartCallbacks = new NPCDialogueCallbackRegistry(); + private readonly NPCDialogueCallbackRegistry _dialogueEndedCallbacks = new NPCDialogueCallbackRegistry(); + private S1Dialogue.DialogueHandler? _hookedHandler; + private NativeAction? _nativeDialogueEndedDispatcher; #if IL2CPPMELON private Il2CppSystem.Collections.Generic.List? GetRuntimeModules() { - return ReflectionUtils.TryGetFieldOrProperty(Handler, "RuntimeModules") as Il2CppSystem.Collections.Generic.List - ?? ReflectionUtils.TryGetFieldOrProperty(Handler, "runtimeModules") as Il2CppSystem.Collections.Generic.List; + var handler = Handler; + if (handler == null) + return null; + + return ReflectionUtils.TryGetFieldOrProperty(handler, "RuntimeModules") as Il2CppSystem.Collections.Generic.List + ?? ReflectionUtils.TryGetFieldOrProperty(handler, "runtimeModules") as Il2CppSystem.Collections.Generic.List; } #else private List? GetRuntimeModules() { - return ReflectionUtils.TryGetFieldOrProperty(Handler, "runtimeModules") as List - ?? ReflectionUtils.TryGetFieldOrProperty(Handler, "RuntimeModules") as List; + var handler = Handler; + if (handler == null) + return null; + + return ReflectionUtils.TryGetFieldOrProperty(handler, "runtimeModules") as List + ?? ReflectionUtils.TryGetFieldOrProperty(handler, "RuntimeModules") as List; } #endif @@ -592,7 +712,7 @@ private bool StartDialogueCompat(string containerName, bool enableBehaviour = tr return true; } - private bool StartDialogueCompat(S1Dialogue.DialogueContainer container, bool enableBehaviour = true, string entryNodeLabel = "ENTRY") + private bool StartDialogueCompat(S1Dialogue.Conversation container, bool enableBehaviour = true, string entryNodeLabel = "ENTRY") { if (Handler == null || container == null) return false; @@ -619,5 +739,3 @@ private bool StartDialogueCompat(S1Dialogue.DialogueContainer container, bool en #endif } } - - diff --git a/S1API/Entities/NPCDialogueCallbackRegistry.cs b/S1API/Entities/NPCDialogueCallbackRegistry.cs new file mode 100644 index 00000000..c2f2eac3 --- /dev/null +++ b/S1API/Entities/NPCDialogueCallbackRegistry.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; + +namespace S1API.Entities +{ + internal sealed class NPCDialogueCallbackRegistry + { + private readonly Dictionary> _keyedCallbacks = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + private readonly List _callbacks = new List(); + + internal bool HasCallbacks => + _keyedCallbacks.Count > 0 || _callbacks.Count > 0; + + internal void Add(string key, Action callback) + { + if (!_keyedCallbacks.TryGetValue(key, out var callbacks)) + { + callbacks = new List(); + _keyedCallbacks[key] = callbacks; + } + + callbacks.Add(callback); + } + + internal void Add(Action callback) => + _callbacks.Add(callback); + + internal void Clear() + { + _keyedCallbacks.Clear(); + _callbacks.Clear(); + } + + internal void Invoke(string key) + { + if (string.IsNullOrEmpty(key) || !_keyedCallbacks.TryGetValue(key, out var callbacks)) + return; + + for (int i = 0; i < callbacks.Count; i++) + InvokeSafely(callbacks[i]); + } + + internal void InvokeAll() + { + for (int i = 0; i < _callbacks.Count; i++) + InvokeSafely(_callbacks[i]); + } + + private static void InvokeSafely(Action callback) + { + try { callback?.Invoke(); } catch { } + } + } +} diff --git a/S1API/Entities/NPCDialoguePolicy.cs b/S1API/Entities/NPCDialoguePolicy.cs new file mode 100644 index 00000000..53460ddb --- /dev/null +++ b/S1API/Entities/NPCDialoguePolicy.cs @@ -0,0 +1,14 @@ +using System; + +namespace S1API.Entities +{ + internal static class NPCDialoguePolicy + { + internal static bool MatchesChoiceContainer(string? candidateName, string? requestedName) + { + return !string.IsNullOrEmpty(candidateName) + && !string.IsNullOrEmpty(requestedName) + && string.Equals(candidateName, requestedName, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/S1API/Entities/NPCInventory.cs b/S1API/Entities/NPCInventory.cs index 1ad52263..aae74d7f 100644 --- a/S1API/Entities/NPCInventory.cs +++ b/S1API/Entities/NPCInventory.cs @@ -1,4 +1,5 @@ #if (IL2CPPMELON) +using Il2CppInterop.Runtime; using S1NPCs = Il2CppScheduleOne.NPCs; using S1Items = Il2CppScheduleOne.ItemFramework; using S1Interaction = Il2CppScheduleOne.Interaction; @@ -24,6 +25,7 @@ namespace S1API.Entities /// public sealed class NPCInventory { + private const int DefaultCustomNpcSlotCount = 5; private static readonly Logging.Log Logger = new Logging.Log("NPCInventory"); private readonly NPC NPC; @@ -164,10 +166,10 @@ public void EnsureInitialized() // ignored } }); - slot.onItemDataChanged = (Il2CppSystem.Action)Il2CppSystem.Delegate.Combine( - slot.onItemDataChanged, - (Il2CppSystem.Action)handler - ); + slot.onItemDataChanged = Il2CppSystem.Delegate.Combine( + slot.onItemDataChanged, + (Il2CppSystem.Action)handler) + .Cast(); #else slot.onItemDataChanged = (Action)Delegate.Combine( slot.onItemDataChanged, @@ -249,12 +251,42 @@ public void EnsureInitialized() try { inv.NetworkInitializeIfDisabled(); } catch (Exception ex) { Logger.Warning($"[NPCInventory] EnsureInitialized: NetworkInitializeIfDisabled threw for '{npcId}': {ex.Message}"); } } - private static int GetSlotCount(S1NPCs.NPCInventory inv, int fallback) + private int GetSlotCount(S1NPCs.NPCInventory inv, int fallback) + { + var legacyValue = ReflectionUtils.TryGetFieldOrProperty(inv, "SlotCount"); + int? npcDataSlotCount = null; + + try + { + var npcData = NPC?.S1NPC?.NPCData; + if (npcData?.Inventory != null) + npcDataSlotCount = npcData.Inventory.InventorySlotCount; + } + catch + { + // Fall back to the current collection when native data is unavailable. + } + + return ResolveTargetSlotCount( + legacyValue is int legacySlotCount ? legacySlotCount : null, + npcDataSlotCount, + fallback, + NPC?.IsCustomNPC == true); + } + + internal static int ResolveTargetSlotCount( + int? legacySlotCount, + int? npcDataSlotCount, + int fallback, + bool isCustomNpc) { - var value = ReflectionUtils.TryGetFieldOrProperty(inv, "SlotCount"); - return value is int slotCount && slotCount >= 0 - ? slotCount - : fallback; + if (legacySlotCount > 0) + return legacySlotCount.Value; + if (npcDataSlotCount > 0) + return npcDataSlotCount.Value; + if (fallback > 0) + return fallback; + return isCustomNpc ? DefaultCustomNpcSlotCount : 0; } private static void TryInvokeContentsChanged(S1NPCs.NPCInventory inv) diff --git a/S1API/Entities/NPCMessaging.cs b/S1API/Entities/NPCMessaging.cs index bf66c0a2..61091cfb 100644 --- a/S1API/Entities/NPCMessaging.cs +++ b/S1API/Entities/NPCMessaging.cs @@ -49,7 +49,7 @@ internal NPCMessaging(NPC npc) /// Gets whether the NPC's conversation is currently open in the phone's Messages app. /// public bool IsOpen => - _npc.S1NPC?.MSGConversation?.isOpen ?? false; + _npc.S1NPC?.MSGConversation?.IsOpen ?? false; /// /// Occurs when the player opens the NPC's conversation in the phone's Messages app. diff --git a/S1API/Entities/NPCMovement.cs b/S1API/Entities/NPCMovement.cs index c7965bf2..7891315f 100644 --- a/S1API/Entities/NPCMovement.cs +++ b/S1API/Entities/NPCMovement.cs @@ -129,11 +129,15 @@ public void FacePoint(Vector3 position) /// public float SpeedMultiplier { - get => SpeedController?.SpeedMultiplier ?? 1f; + get => SpeedController != null && ReflectionUtils.TryGetFieldOrProperty( + SpeedController, + "_speedMultiplier") is float multiplier + ? multiplier + : 1f; set { if (SpeedController != null) - SpeedController.SpeedMultiplier = value; + SpeedController.SetSpeedMultiplier(value); } } @@ -155,8 +159,11 @@ public float SpeedMultiplier public void AddSpeedControl(string id, int priority, float speed) { if (SpeedController == null) return; - var control = new S1NPCs.NPCSpeedController.SpeedControl(id, priority, speed); - SpeedController.AddSpeedControl(control); + SpeedController.AddSpeedControl( + id, + speed, + priority, + S1NPCs.SpeedControl.EType.Normalized); } /// diff --git a/S1API/Entities/NPCNoiseEvent.cs b/S1API/Entities/NPCNoiseEvent.cs new file mode 100644 index 00000000..d39e8430 --- /dev/null +++ b/S1API/Entities/NPCNoiseEvent.cs @@ -0,0 +1,86 @@ +#if IL2CPPMELON +using S1Noise = Il2CppScheduleOne.Noise; +#elif MONOMELON +using S1Noise = ScheduleOne.Noise; +#endif + +using UnityEngine; + +namespace S1API.Entities +{ + /// + /// Describes the type of noise an NPC heard. + /// + public enum NPCNoiseType + { + /// + /// A footstep sound. + /// + Footstep = 0, + + /// + /// A gunshot sound. + /// + Gunshot = 1, + + /// + /// An explosion sound. + /// + Explosion = 2 + } + + /// + /// An immutable snapshot of a noise event heard by an NPC. + /// + public sealed class NPCNoiseEvent + { + /// + /// The world-space origin of the noise. + /// + public Vector3 Origin { get; } + + /// + /// The range of the noise. + /// + public float Range { get; } + + /// + /// The type of noise. + /// + public NPCNoiseType Type { get; } + + /// + /// The GameObject that emitted the noise, if the native event identified one. + /// + public GameObject? Source { get; } + + /// + /// Whether the noise originated in the sewer. + /// + public bool OriginInSewer { get; } + + internal NPCNoiseEvent(S1Noise.NoiseEvent noiseEvent) + : this( + noiseEvent.origin, + noiseEvent.range, + (NPCNoiseType)(int)noiseEvent.type, + noiseEvent.source, + noiseEvent.OriginInSewer) + { + } + + internal NPCNoiseEvent( + Vector3 origin, + float range, + NPCNoiseType type, + GameObject? source, + bool originInSewer) + { + Origin = origin; + Range = range; + Type = type; + Source = source; + OriginInSewer = originInSewer; + } + } +} diff --git a/S1API/Entities/NPCPrefabBuilder.cs b/S1API/Entities/NPCPrefabBuilder.cs index 2b580be1..3a9764aa 100644 --- a/S1API/Entities/NPCPrefabBuilder.cs +++ b/S1API/Entities/NPCPrefabBuilder.cs @@ -35,6 +35,7 @@ using S1API.Entities.Relation; using S1API.Entities.Appearances.Base; using System.Collections.Generic; +using System.Linq; using S1API.Internal.Entities; using S1API.Internal.Utils; using S1API.Logging; @@ -57,6 +58,14 @@ public sealed class NPCPrefabBuilder private readonly GameObject prefabRoot; private readonly Type ownerType; + internal const int DealerAttendDealPriority = 5; + internal const string DealerHomeEventName = "DealerHomeEvent"; + internal const bool BehaviourObjectsRemainActive = true; + + internal static bool IsDealerHomeEventName(string? name) => + string.Equals(name, DealerHomeEventName, StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "HomeEvent", StringComparison.OrdinalIgnoreCase); + internal NPCPrefabBuilder(GameObject prefabRoot, Type ownerType) { this.prefabRoot = prefabRoot; @@ -78,13 +87,23 @@ private S1NPCs.NPCScheduleManager EnsureScheduleManager() } /// - /// Adds customer behavior component to the NPC. Required before configuring customer defaults. + /// Ensures customer infrastructure for compatibility with existing prefab-builder declarations. /// /// - /// Enables the NPC to act as a business customer that can buy products from the player. + /// Compatibility shim for existing mods. New NPC types should override . /// /// The builder instance for fluent chaining. - public NPCPrefabBuilder EnsureCustomer() + [Obsolete("Override NPC.IsCustomer to return true instead.", false)] + public NPCPrefabBuilder EnsureCustomer() => + DeclareCustomerCompatibility(); + + internal NPCPrefabBuilder DeclareCustomerCompatibility() + { + NPC.RegisterCustomerType(ownerType); + return EnsureCustomerInfrastructure(); + } + + internal NPCPrefabBuilder EnsureCustomerInfrastructure() { var customer = prefabRoot.GetComponent(); if (customer == null) @@ -92,8 +111,6 @@ public NPCPrefabBuilder EnsureCustomer() customer = prefabRoot.AddComponent(); customer.enabled = true; } - // Mark this NPC type as a Customer-bearing type so pre-registration adds Customer on template - NPC.RegisterCustomerType(ownerType); return this; } @@ -247,9 +264,13 @@ public NPCPrefabBuilder WithAppearanceDefaults(Action con // Apply settings directly to Avatar component on prefab to prevent destruction issues ApplyAvatarSettingsToPrefab(settings); } - catch + catch (Exception ex) { - // ignored + string ownerName = ownerType?.FullName ?? ""; + string assemblyName = ownerType?.Assembly.GetName().Name ?? ""; + Logger.Warning( + $"[S1API][NPCAppearanceConfiguration] Failed to configure appearance defaults for " + + $"'{ownerName}' from assembly '{assemblyName}': {ex.GetType().Name}: {ex.Message}"); } return this; @@ -321,10 +342,10 @@ public NPCPrefabBuilder WithSchedule(params IScheduleActionSpec[] specs) } /// - /// Adds dealer behavior to the NPC. Required before configuring dealer defaults. + /// Ensures dealer infrastructure for compatibility with existing prefab-builder declarations. /// /// - /// Enables the NPC to act as a dealer that sells products to assigned customers. + /// Compatibility shim for existing mods. New NPC types should override . /// This marks the NPC type as dealer-capable; S1API will ensure the generated spawnable prefab /// has a Dealer-compatible NPC component before network registration when the selected base prefab /// does not already include one. @@ -332,11 +353,18 @@ public NPCPrefabBuilder WithSchedule(params IScheduleActionSpec[] specs) /// dealer functionality and ensure the messaging app displays the correct Dealer category badge. /// /// The builder instance for fluent chaining. - public NPCPrefabBuilder EnsureDealer() + [Obsolete("Override NPC.IsDealer to return true instead.", false)] + public NPCPrefabBuilder EnsureDealer() => + DeclareDealerCompatibility(); + + internal NPCPrefabBuilder DeclareDealerCompatibility() { - // Mark the type as dealer-capable; NPC prefab creation materializes the correct runtime component. NPC.RegisterDealerType(ownerType); - + return EnsureDealerInfrastructure(); + } + + internal NPCPrefabBuilder EnsureDealerInfrastructure() + { // Ensure required schedule components exist var mgr = EnsureScheduleManager(); @@ -355,21 +383,33 @@ public NPCPrefabBuilder EnsureDealer() var go = new GameObject("DealerAttendDealBehaviour"); go.transform.SetParent(npcBehaviour.transform, false); attendDeal = go.AddComponent(); - go.SetActive(false); } + attendDeal.gameObject.SetActive(BehaviourObjectsRemainActive); var baseNpcForDealer = prefabRoot.GetComponent(); SetBehaviourRefs(attendDeal, npcBehaviour, baseNpcForDealer); - - // Ensure NPCEvent_StayInBuilding exists for home behavior - var stayInBuilding = prefabRoot.GetComponentInChildren(true); - if (stayInBuilding != null) return this; + attendDeal.Name = "Attend deal"; + attendDeal.Priority = DealerAttendDealPriority; + + // Keep the dealer's HomeEvent separate from mod-defined schedule actions. Dealer.OnTick + // toggles this object directly, so reusing an arbitrary StayInBuilding action makes the + // schedule and contract behaviour fight over the same doorway. + var stayInBuilding = prefabRoot + .GetComponentsInChildren(true) + .FirstOrDefault(action => IsDealerHomeEventName(action?.gameObject?.name)); + if (stayInBuilding == null) { - var go = new GameObject("StayInBuilding"); + var go = new GameObject(DealerHomeEventName); go.transform.SetParent(mgr.transform, false); stayInBuilding = go.AddComponent(); go.SetActive(false); } + ReflectionUtils.TrySetFieldOrProperty(stayInBuilding, "npc", baseNpcForDealer); + ReflectionUtils.TrySetFieldOrProperty(stayInBuilding, "schedule", mgr); + if (baseNpcForDealer != null + && CrossType.Is(baseNpcForDealer, out S1Economy.Dealer dealer)) + dealer.HomeEvent = stayInBuilding; + return this; } @@ -452,33 +492,45 @@ private NPCPrefabBuilder WithVoiceInternal(NPCVoiceDefinition voice, float? pitc } /// - /// Configures this NPC type to use the native supplier root. + /// Ensures supplier infrastructure for compatibility with existing prefab-builder declarations. /// /// + /// Compatibility shim for existing mods. New NPC types should override . /// Supplier NPCs support dead-drop orders, supplier meetings, delivery unlocks, and debt tracking. /// S1API reserves a location-dialogue schedule action required by the native supplier lifecycle. /// A custom NPC cannot be both a dealer and a supplier. /// /// The builder instance for fluent chaining. - public NPCPrefabBuilder EnsureSupplier() + [Obsolete("Override NPC.IsSupplier to return true instead.", false)] + public NPCPrefabBuilder EnsureSupplier() => + DeclareSupplierCompatibility(); + + internal NPCPrefabBuilder DeclareSupplierCompatibility() { NPC.RegisterSupplierType(ownerType); + return EnsureSupplierInfrastructure(); + } + + internal NPCPrefabBuilder EnsureSupplierInfrastructure() + { SupplierRuntimeCoordinator.EnsurePrefabInfrastructure(prefabRoot); return this; } /// - /// Configures customer behavior defaults using the . Requires to be called first. + /// Configures customer behavior defaults using the . /// /// /// Configure spending behavior, order frequency, customer standards, product preferences, and relationship requirements. + /// Override to declare customer capability. This method retains the + /// legacy implicit declaration behavior for source and behavioral compatibility. /// This configuration is essential for proper save/load behavior and must be done in . /// /// Action to configure customer defaults using the builder. /// The builder instance for fluent chaining. public NPCPrefabBuilder WithCustomerDefaults(Action configure) { - EnsureCustomer(); + DeclareCustomerCompatibility(); var customer = prefabRoot.GetComponent(); if (customer != null) { @@ -559,17 +611,19 @@ public NPCPrefabBuilder WithSpawnPosition(Vector3 position) } /// - /// Configures dealer behavior defaults using the . Requires to be called first. + /// Configures dealer behavior defaults using the . /// /// /// Configure dealer settings such as signing fee, commission cut, dealer type, quality restrictions, and deal tracking. + /// Override to declare dealer capability. This method retains the + /// legacy implicit declaration behavior for source and behavioral compatibility. /// This configuration is essential for proper save/load behavior and must be done in . /// /// Action to configure dealer defaults using the builder. /// The builder instance for fluent chaining. public NPCPrefabBuilder WithDealerDefaults(Action configure) { - EnsureDealer(); + DeclareDealerCompatibility(); // Register dealer defaults for type-level application NPC.RegisterDealerDefaultsForType(ownerType, configure); @@ -610,6 +664,10 @@ public NPCPrefabBuilder WithRegion(Region region) /// /// Configures native supplier data for this NPC type. /// + /// + /// Override to declare supplier capability. This method retains the + /// legacy implicit declaration behavior for source and behavioral compatibility. + /// /// Action that defines order limits, delivery items, and supplier messages. /// The builder instance for fluent chaining. /// Thrown when is null. @@ -618,7 +676,7 @@ public NPCPrefabBuilder WithSupplierDefaults(Action configu if (configure == null) throw new ArgumentNullException(nameof(configure)); - EnsureSupplier(); + DeclareSupplierCompatibility(); NPC.RegisterSupplierDefaultsForType(ownerType, configure); return this; } @@ -664,8 +722,8 @@ public NPCPrefabBuilder EnsureSmokeBreak(string? cigarettePrefabPath = null, boo var go = new GameObject("SmokeBreakBehaviour"); go.transform.SetParent(npcBehaviour.gameObject.transform, false); smokeBreak = go.AddComponent(); - go.SetActive(false); } + smokeBreak.gameObject.SetActive(BehaviourObjectsRemainActive); smokeBreak.Name = "SmokeBreakBehaviour"; var smokeCigarette = smokeBreak.GetComponentInChildren(true); @@ -877,8 +935,8 @@ private NPCPrefabBuilder EnsureGraffitiInternal(string? sprayPaintEquippablePath var go = new GameObject("GraffitiBehaviour"); go.transform.SetParent(npcBehaviour.gameObject.transform, false); graffiti = go.AddComponent(); - go.SetActive(false); } + graffiti.gameObject.SetActive(BehaviourObjectsRemainActive); graffiti.Name = "GraffitiBehaviour"; var sprayPaint = graffiti.GetComponentInChildren(true); @@ -1202,10 +1260,8 @@ private void EnsurePrefabAction(int count, string namePrefix) where T : S1NPC try { var baseNpc = prefabRoot.GetComponent(); - var npcField = typeof(T).GetField("npc", BindingFlags.NonPublic | BindingFlags.Instance); - npcField?.SetValue(comp, baseNpc); - var schedField = typeof(T).GetField("schedule", BindingFlags.NonPublic | BindingFlags.Instance); - schedField?.SetValue(comp, mgr); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(comp, "npc", baseNpc); + Internal.Utils.ReflectionUtils.TrySetFieldOrProperty(comp, "schedule", mgr); } catch (Exception ex) { @@ -1235,6 +1291,7 @@ private void EnsureIl2CppCustomerAttendDealBehaviour() component = behaviourObject.AddComponent(); } + component.gameObject.SetActive(BehaviourObjectsRemainActive); ReflectionUtils.TrySetFieldOrProperty(component, "EnabledOnAwake", false); ReflectionUtils.TrySetFieldOrProperty(component, "Name", "Customer attend deal"); ReflectionUtils.TrySetFieldOrProperty(component, "Priority", 4); diff --git a/S1API/Entities/NPCRelationship.cs b/S1API/Entities/NPCRelationship.cs index 29852d52..de1f855e 100644 --- a/S1API/Entities/NPCRelationship.cs +++ b/S1API/Entities/NPCRelationship.cs @@ -1,7 +1,10 @@ #if (IL2CPPMELON) +using Il2CppInterop.Runtime; +using NativeRelationshipUnlockedAction = Il2CppSystem.Action; using S1Relation = Il2CppScheduleOne.NPCs.Relation; using S1NPCs = Il2CppScheduleOne.NPCs; #elif MONOMELON +using NativeRelationshipUnlockedAction = System.Action; using S1Relation = ScheduleOne.NPCs.Relation; using S1NPCs = ScheduleOne.NPCs; #endif @@ -10,6 +13,7 @@ using System.Collections.Generic; using System.Reflection; using S1API.Entities.Relation; +using S1API.Logging; namespace S1API.Entities { @@ -23,6 +27,8 @@ namespace S1API.Entities /// public sealed class NPCRelationship { + private static readonly Log Logger = new Log("NPCRelationship"); + #region Types /// @@ -43,12 +49,11 @@ public enum UnlockType /// internal readonly NPC NPC; private readonly Dictionary, Delegate> _relationshipChangedHandlers = new Dictionary, Delegate>(); - private readonly Dictionary, Delegate> _relationshipUnlockedHandlers = new Dictionary, Delegate>(); + private Action? _relationshipUnlockedHandlers; + private S1Relation.NPCRelationData? _subscribedRelationship; + private NativeRelationshipUnlockedAction? _nativeRelationshipUnlockedDispatcher; private static readonly MemberInfo? RelationshipChangedMember = ResolveNativeEventMember("OnRelationshipChange", "onRelationshipChange"); - private static readonly MemberInfo? RelationshipUnlockedMember = - ResolveNativeEventMember("OnUnlocked", "onUnlocked"); - internal NPCRelationship(NPC npc) { NPC = npc; @@ -236,75 +241,34 @@ public event Action OnChanged /// /// Subscribes to unlocked events. Callback receives unlock type and notify flag. - /// Best-effort under IL2CPP; silently no-ops if delegate bridging is unavailable. + /// The wrapper retains a native dispatcher and bridges it explicitly under IL2CPP. /// public event Action OnUnlocked { add { - if (value == null || Component == null) + if (value == null) return; - if (_relationshipUnlockedHandlers.ContainsKey(value)) - return; - - try + if (_relationshipUnlockedHandlers != null && + Array.IndexOf( + _relationshipUnlockedHandlers.GetInvocationList(), + value) >= 0) { - MemberInfo? member = RelationshipUnlockedMember; - if (member == null) - return; - - object? existing = GetNativeEventValue(member, Component); -#if IL2CPPMELON - System.Action wrapped = new System.Action((t, notify) => - { - try { value(FromS1(t), notify); } catch { } - }); - var combined = (Il2CppSystem.Delegate)Il2CppSystem.Delegate.Combine(existing as Il2CppSystem.Delegate, (Il2CppSystem.Delegate)(object)wrapped); - SetNativeEventValue(member, Component, combined); - _relationshipUnlockedHandlers[value] = wrapped; -#else - Action wrapped = (t, notify) => - { - try { value(FromS1(t), notify); } catch { } - }; - var combined = Delegate.Combine(existing as Delegate, wrapped); - SetNativeEventValue(member, Component, combined); - _relationshipUnlockedHandlers[value] = wrapped; -#endif + return; } - catch { } + + _relationshipUnlockedHandlers += value; + EnsureUnlockedHook(); } remove { - if (value == null || Component == null) + if (value == null) return; - if (!_relationshipUnlockedHandlers.TryGetValue(value, out var wrapped)) - return; - - _relationshipUnlockedHandlers.Remove(value); - try - { - MemberInfo? member = RelationshipUnlockedMember; - if (member == null) - return; - -#if IL2CPPMELON - var existing = GetNativeEventValue(member, Component); - var remaining = existing != null - ? Il2CppSystem.Delegate.Remove(existing as Il2CppSystem.Delegate, (Il2CppSystem.Delegate)(object)wrapped) - : null; - SetNativeEventValue(member, Component, remaining); -#else - var existing = GetNativeEventValue(member, Component); - var remaining = existing != null - ? Delegate.Remove(existing as Delegate, (Delegate)wrapped) - : null; - SetNativeEventValue(member, Component, remaining); -#endif - } - catch { } + _relationshipUnlockedHandlers -= value; + if (_relationshipUnlockedHandlers == null) + RemoveUnlockedHook(); } } @@ -321,6 +285,115 @@ public event Action OnUnlocked #region Private Helpers + internal void EnsureUnlockedHook() + { + if (_relationshipUnlockedHandlers == null) + return; + + S1Relation.NPCRelationData? relationship = Component; + if (relationship == null || ReferenceEquals(relationship, _subscribedRelationship)) + return; + + try + { + RemoveUnlockedHook(); + NativeRelationshipUnlockedAction dispatcher = + GetOrCreateNativeUnlockedDispatcher(); + +#if IL2CPPMELON + relationship.OnUnlocked = relationship.OnUnlocked == null + ? dispatcher + : Il2CppSystem.Delegate.Combine( + relationship.OnUnlocked, + dispatcher) + .Cast(); +#else + relationship.OnUnlocked += dispatcher; +#endif + _subscribedRelationship = relationship; + } + catch (Exception ex) + { + Logger.Warning( + $"Could not attach the native relationship-unlocked hook: {ex}"); + } + } + + private NativeRelationshipUnlockedAction GetOrCreateNativeUnlockedDispatcher() + { + if (_nativeRelationshipUnlockedDispatcher != null) + return _nativeRelationshipUnlockedDispatcher; + +#if IL2CPPMELON + _nativeRelationshipUnlockedDispatcher = + DelegateSupport.ConvertDelegate( + new Action( + DispatchUnlocked)) + ?? throw new InvalidOperationException( + "Could not create the native relationship-unlocked dispatcher."); +#else + _nativeRelationshipUnlockedDispatcher = DispatchUnlocked; +#endif + return _nativeRelationshipUnlockedDispatcher; + } + + private void RemoveUnlockedHook() + { + if (_subscribedRelationship == null || + _nativeRelationshipUnlockedDispatcher == null) + { + _subscribedRelationship = null; + return; + } + + try + { +#if IL2CPPMELON + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + _subscribedRelationship.OnUnlocked, + _nativeRelationshipUnlockedDispatcher); + _subscribedRelationship.OnUnlocked = + remaining?.Cast(); +#else + _subscribedRelationship.OnUnlocked -= + _nativeRelationshipUnlockedDispatcher; +#endif + } + catch (Exception ex) + { + Logger.Warning( + $"Could not remove the native relationship-unlocked hook: {ex}"); + } + finally + { + _subscribedRelationship = null; + } + } + + private void DispatchUnlocked( + S1Relation.NPCRelationData.EUnlockType type, + bool notify) + { + Action? handlers = _relationshipUnlockedHandlers; + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try + { + handler(FromS1(type), notify); + } + catch (Exception ex) + { + Logger.Warning( + $"NPCRelationship.OnUnlocked subscriber " + + $"'{handler.Method.DeclaringType?.FullName}.{handler.Method.Name}' " + + $"failed: {ex}"); + } + } + } + internal static MemberInfo? ResolveNativeEventMember( string canonicalName, string legacyName) diff --git a/S1API/Entities/NPCSchedule.cs b/S1API/Entities/NPCSchedule.cs index 20c4a3d4..df98109f 100644 --- a/S1API/Entities/NPCSchedule.cs +++ b/S1API/Entities/NPCSchedule.cs @@ -2,10 +2,12 @@ using S1NPCs = Il2CppScheduleOne.NPCs; using S1GameTime = Il2CppScheduleOne.GameTime; using S1NPCsSchedules = Il2CppScheduleOne.NPCs.Schedules; +using S1Behaviour = Il2CppScheduleOne.NPCs.Behaviour; #elif MONOMELON using S1NPCs = ScheduleOne.NPCs; using S1GameTime = ScheduleOne.GameTime; using S1NPCsSchedules = ScheduleOne.NPCs.Schedules; +using S1Behaviour = ScheduleOne.NPCs.Behaviour; #endif using System; @@ -51,6 +53,7 @@ public void Enable() { EnsureManager(); Manager?.EnableSchedule(); + ScheduleBehaviour?.Enable_Server(); } /// @@ -59,8 +62,12 @@ public void Enable() public void Disable() { Manager?.DisableSchedule(); + ScheduleBehaviour?.Disable_Server(); } + private S1Behaviour.ScheduleBehaviour? ScheduleBehaviour => + NPC.gameObject.GetComponentInChildren(true); + /// /// Initializes/sorts the order of the schedules on this NPC. /// This method is responsible for adding times to the schedule names. @@ -166,10 +173,10 @@ internal void AddActionFromSpec(IScheduleActionSpec spec) /// /// /// Schedule I 0.4.6 removed NPCSignal_WaitForDelivery. Customer deal attendance is - /// configured automatically by . This method + /// configured automatically when is true. This method /// now performs no runtime work and logs one compatibility warning per process. /// - [Obsolete("NPCSignal_WaitForDelivery was removed in game version 0.4.6. Customer deal attendance is configured automatically by EnsureCustomer().")] + [Obsolete("NPCSignal_WaitForDelivery was removed in game version 0.4.6. Override NPC.IsCustomer; customer deal attendance is configured automatically.")] public void EnsureDealSignal() { if (_loggedRemovedDealSignal) diff --git a/S1API/Entities/NPCSupplier.cs b/S1API/Entities/NPCSupplier.cs index 1edebe2e..d2fef09c 100644 --- a/S1API/Entities/NPCSupplier.cs +++ b/S1API/Entities/NPCSupplier.cs @@ -139,14 +139,14 @@ public IReadOnlyList ActiveDeliveries /// Ensures supplier-specific messaging state is initialized for the wrapped NPC. /// /// - /// The native supplier root must be declared during with - /// . This method does not replace an already spawned root component. + /// The native supplier root must be declared by overriding . + /// This method does not replace an already spawned root component. /// internal void EnsureSupplier() { if (Component == null) { - Logger.Warning($"Supplier root not present for NPC '{npc.ID}'. Configure it with NPCPrefabBuilder.EnsureSupplier()."); + Logger.Warning($"Supplier root not present for NPC '{npc.ID}'. Override NPC.IsSupplier to return true."); return; } diff --git a/S1API/Entities/Player.cs b/S1API/Entities/Player.cs index 72b8f176..de269c74 100644 --- a/S1API/Entities/Player.cs +++ b/S1API/Entities/Player.cs @@ -1,9 +1,11 @@ #if (IL2CPPMELON) using S1PlayerScripts = Il2CppScheduleOne.PlayerScripts; using S1Health = Il2CppScheduleOne.PlayerScripts.Health; +using S1AvatarTools = Il2CppScheduleOne.Avatar.Tools; #else using S1PlayerScripts = ScheduleOne.PlayerScripts; using S1Health = ScheduleOne.PlayerScripts.Health; +using S1AvatarTools = ScheduleOne.Avatar.Tools; #endif using System; @@ -274,7 +276,7 @@ public PropertyWrapper? LastVisitedProperty { /// The player's current avatar settings (appearance configuration). /// [Obsolete("Use GetCurrentBasicAvatarSettings(). This compatibility property may be removed in a future S1API version.")] - public object CurrentAvatarSettings => S1Player.CurrentBasicAppearance; + public object CurrentAvatarSettings => S1Player.CurrentAppearance; /// /// Retrieves the player's current avatar settings as an S1API wrapper. @@ -282,8 +284,7 @@ public PropertyWrapper? LastVisitedProperty { /// public BasicAvatarSettings? GetCurrentBasicAvatarSettings() { - var settings = S1Player.CurrentBasicAppearance; - return settings == null ? null : new BasicAvatarSettings(settings); + return null; } /// @@ -297,7 +298,7 @@ public void InsertClothing(ClothingItemInstance clothing) throw new ArgumentNullException(nameof(clothing)); } - S1Player.Clothing.InsertClothing(clothing.S1ClothingInstance); + S1Player.Clothing.InsertClothingItem(clothing.S1ClothingInstance); } /// @@ -322,7 +323,9 @@ public ClothingItemInstance EquipClothing(ClothingItemDefinition definition) /// Refreshes the player's avatar from the current clothing slots. /// public void RefreshClothingAppearance() => - S1Player.Clothing.RefreshAppearance(); + AccessTools.Method(S1Player.Clothing.GetType(), "RefreshAppearance")?.Invoke( + S1Player.Clothing, + Array.Empty()); /// /// Sends updated appearance settings through the game's native appearance flow. @@ -334,7 +337,9 @@ public void SendAppearance(BasicAvatarSettings settings) throw new ArgumentNullException(nameof(settings)); } - S1Player.SetAppearance_Server(settings.S1BasicAvatarSettings); + S1Player.SetAppearance_Server( + S1AvatarTools.BasicAvatarSettingsConverter.ConvertToPlayerAppearance( + settings.S1BasicAvatarSettings)); } /// diff --git a/S1API/Entities/Relation/NPCRelationshipDataBuilder.cs b/S1API/Entities/Relation/NPCRelationshipDataBuilder.cs index 64152f78..e26cc07f 100644 --- a/S1API/Entities/Relation/NPCRelationshipDataBuilder.cs +++ b/S1API/Entities/Relation/NPCRelationshipDataBuilder.cs @@ -28,6 +28,7 @@ public sealed class NPCRelationshipDataBuilder private bool? _unlocked; private NPCRelationship.UnlockType? _unlockType; private readonly List _connectionIDs = new List(); + private bool _connectionsConfigured; /// /// Sets the relationship delta in [0, 5]. @@ -81,25 +82,32 @@ public NPCRelationshipDataBuilder SetUnlockType(string typeName) /// public NPCRelationshipDataBuilder WithConnectionsById(IEnumerable ids) { + _connectionsConfigured = true; _connectionIDs.Clear(); + _connectionIDs.AddRange(NormalizeConnectionIds(ids)); + + return this; + } + + internal static IReadOnlyList NormalizeConnectionIds(IEnumerable? ids) + { if (ids == null) + return Array.Empty(); + + var normalized = new List(); + foreach (string? id in ids) { - return this; - } - - int addedCount = 0; - foreach (var id in ids) - { - if (string.IsNullOrEmpty(id)) - continue; - if (!_connectionIDs.Contains(id, StringComparer.OrdinalIgnoreCase)) + string value = id?.Trim() ?? string.Empty; + if (value.Length == 0 + || normalized.Contains(value, StringComparer.OrdinalIgnoreCase)) { - _connectionIDs.Add(id); - addedCount++; + continue; } + + normalized.Add(value); } - - return this; + + return normalized; } /// @@ -154,6 +162,7 @@ public NPCRelationshipDataBuilder WithConnections() where T1 : NPC w /// public NPCRelationshipDataBuilder WithConnections(params System.Type?[]? npcTypes) { + _connectionsConfigured = true; _connectionIDs.Clear(); if (npcTypes == null || npcTypes.Length == 0) { @@ -221,7 +230,8 @@ internal RelationshipDefaultsData CaptureData() RelationDelta = _relationDelta, Unlocked = _unlocked, UnlockType = _unlockType, - ConnectionIDs = _connectionIDs.Count > 0 ? new List(_connectionIDs) : null + ConnectionsConfigured = _connectionsConfigured, + ConnectionIDs = new List(_connectionIDs) }; } @@ -245,7 +255,7 @@ public void ApplyTo(S1Relation.NPCRelationData relationData, S1NPCs.NPC owner, b try { - if (_connectionIDs.Count > 0) + if (_connectionsConfigured) { var registry = S1NPCs.NPCManager.NPCRegistry; var targetList = relationData.Connections; @@ -356,6 +366,7 @@ internal sealed class RelationshipDefaultsData public float? RelationDelta; public bool? Unlocked; public NPCRelationship.UnlockType? UnlockType; + public bool ConnectionsConfigured; public List? ConnectionIDs; } } diff --git a/S1API/Entities/Schedule/ActionSpecs/HandleDealSpec.cs b/S1API/Entities/Schedule/ActionSpecs/HandleDealSpec.cs index ad90ea07..17a83fef 100644 --- a/S1API/Entities/Schedule/ActionSpecs/HandleDealSpec.cs +++ b/S1API/Entities/Schedule/ActionSpecs/HandleDealSpec.cs @@ -7,8 +7,8 @@ namespace S1API.Entities.Schedule /// /// /// As of v0.4.2f4, deal handling is now automatic through the DealerAttendDealBehaviour system. - /// This spec is kept for backwards compatibility but is a no-op. Dealer NPCs set up with - /// EnsureDealer() will automatically handle deals when contracts are assigned. + /// This spec is kept for backwards compatibility but is a no-op. NPCs with + /// enabled automatically handle deals when contracts are assigned. /// [Obsolete("HandleDealSpec is no longer needed as of game version 0.4.2f4. Deal handling is now automatic through DealerAttendDealBehaviour.")] public sealed class HandleDealSpec : IScheduleActionSpec @@ -26,7 +26,7 @@ public sealed class HandleDealSpec : IScheduleActionSpec void IScheduleActionSpec.ApplyTo(NPCSchedule schedule) { // No-op: Deal handling is now automatic through DealerAttendDealBehaviour. - // Dealers set up with EnsureDealer() will automatically handle deals when contracts are assigned. + // Declared dealers automatically handle deals when contracts are assigned. // This method intentionally does nothing to maintain backwards compatibility. } } diff --git a/S1API/Entities/Schedule/ActionSpecs/UseSlotMachineSpec.cs b/S1API/Entities/Schedule/ActionSpecs/UseSlotMachineSpec.cs index 7126915d..174a742a 100644 --- a/S1API/Entities/Schedule/ActionSpecs/UseSlotMachineSpec.cs +++ b/S1API/Entities/Schedule/ActionSpecs/UseSlotMachineSpec.cs @@ -222,13 +222,13 @@ void IScheduleActionSpec.ApplyTo(NPCSchedule schedule) { Logger.Warning($"[{npc.ID}] Initial position not reachable, searching for nearest slot machine"); // Try to find the nearest reachable slot machine - var machine = SlotMachineHelper.FindNearestSlotMachine(targetPosition, MaxSearchDistance * 2f); + var machine = CasinoGameRegistry.FindNearestSlotMachine(targetPosition, MaxSearchDistance * 2f); if (machine != null) { // Check if this machine is reachable - if (npc.Movement.CanGetTo(machine.transform.position)) + if (npc.Movement.CanGetTo(machine.Position)) { - targetPosition = machine.transform.position; + targetPosition = machine.Position; } else { @@ -315,10 +315,10 @@ private static System.Collections.IEnumerator WaitForArrivalThenGamble( { Logger.Warning($"[{npc.ID}] NPC can't pathfind to target, searching for alternative"); // NPC can't reach the destination - try to find the nearest slot machine instead - var machine = SlotMachineHelper.FindNearestSlotMachine(targetPosition, maxDistance * 2f); + var machine = CasinoGameRegistry.FindNearestSlotMachine(targetPosition, maxDistance * 2f); if (machine != null) { - targetPosition = machine.transform.position; + targetPosition = machine.Position; } else { diff --git a/S1API/Entities/Schedule/NPCScheduleBuilder.cs b/S1API/Entities/Schedule/NPCScheduleBuilder.cs index ac368d34..096cd6a2 100644 --- a/S1API/Entities/Schedule/NPCScheduleBuilder.cs +++ b/S1API/Entities/Schedule/NPCScheduleBuilder.cs @@ -102,7 +102,7 @@ public PrefabScheduleBuilder SitAtSeatSet(string? seatSetName, int startTime, in /// Schedule I 0.4.6 removed the deal signal. The retained specification configures the /// current customer deal-attendance behaviour during prefab creation and otherwise no-ops. /// - [System.Obsolete("NPCSignal_WaitForDelivery was removed in game version 0.4.6. Use EnsureCustomer(); deal attendance is configured automatically.")] + [System.Obsolete("NPCSignal_WaitForDelivery was removed in game version 0.4.6. Override NPC.IsCustomer; deal attendance is configured automatically.")] public PrefabScheduleBuilder EnsureDealSignal() { _specs.Add(new EnsureDealSignalSpec()); @@ -374,8 +374,8 @@ public LocationBasedActionSpecBuilder LocationBased(Vector3 destination, int sta /// This builder instance for method chaining. /// /// As of v0.4.2f4, deal handling is now automatic through the DealerAttendDealBehaviour system. - /// This method is kept for backwards compatibility but is a no-op. Dealer NPCs set up with - /// EnsureDealer() will automatically handle deals when contracts are assigned. + /// This method is kept for backwards compatibility but is a no-op. Dealer NPCs + /// automatically handle deals when contracts are assigned. /// [System.Obsolete("HandleDeal is no longer needed as of game version 0.4.2f4. Deal handling is now automatic through DealerAttendDealBehaviour.")] public PrefabScheduleBuilder HandleDeal(int startTime, string? name = null) diff --git a/S1API/Entities/Supplier/SupplierDataBuilder.cs b/S1API/Entities/Supplier/SupplierDataBuilder.cs index cb08886c..9086f95b 100644 --- a/S1API/Entities/Supplier/SupplierDataBuilder.cs +++ b/S1API/Entities/Supplier/SupplierDataBuilder.cs @@ -31,6 +31,7 @@ internal sealed class SupplierConfigData public string SupplierUnlockHint { get; set; } = "You can now order from . can be used to ."; public string? StashDeadDropGuid { get; set; } + public string? PersistentId { get; set; } internal IReadOnlyList ResolveDeliveryItems() @@ -106,6 +107,31 @@ public SupplierDataBuilder WithOrderLimits(float minimum, float maximum) return this; } + /// + /// Keeps this supplier's generated shop, delivery vehicle, and generated stash + /// identities on an existing persistent ID. + /// + /// + /// This does not change the NPC's runtime ID. Use it only when a released + /// supplier must adopt a new runtime NPC ID while preserving the supplier data + /// derived from its former ID. + /// + /// The non-empty ID used by the previous supplier release. + /// The current builder for chaining. + /// Thrown when the ID is empty or whitespace. + public SupplierDataBuilder WithPersistentId(string persistentId) + { + if (string.IsNullOrWhiteSpace(persistentId)) + { + throw new ArgumentException( + "The persistent supplier ID cannot be empty.", + nameof(persistentId)); + } + + data.PersistentId = persistentId.Trim(); + return this; + } + /// /// Adds an item to this supplier's dead-drop and meeting-shop listings. /// diff --git a/S1API/GameTime/TimeManager.cs b/S1API/GameTime/TimeManager.cs index 201c466f..07abd71b 100644 --- a/S1API/GameTime/TimeManager.cs +++ b/S1API/GameTime/TimeManager.cs @@ -48,6 +48,7 @@ public static class TimeManager private static int _lastSleepSkippedMinutes; private static S1GameTime.TimeManager? _boundInstance; + private static S1GameTime.SleepController? _boundSleepController; private static readonly Action HourPassHandler = () => OnHourPass(); private static readonly Action DayPassHandler = () => OnDayPass(); @@ -69,11 +70,14 @@ static TimeManager() internal static void TryBindToCurrentInstance() { var instance = S1GameTime.TimeManager.Instance; - if (instance == null || ReferenceEquals(instance, _boundInstance)) + var sleepController = S1GameTime.SleepController.Instance; + if (instance == null || + (ReferenceEquals(instance, _boundInstance) && ReferenceEquals(sleepController, _boundSleepController))) return; - UnbindFromInstance(_boundInstance); + UnbindFromInstances(_boundInstance, _boundSleepController); _boundInstance = instance; + _boundSleepController = sleepController; instance.onHourPass += HourPassHandler; instance.onDayPass += DayPassHandler; @@ -81,9 +85,12 @@ internal static void TryBindToCurrentInstance() AddToActionList(instance.onTick, TickHandler); - instance.onSleepStart += SleepStartHandler; instance.onTimeSkip += TimeSkipHandler; - instance.onSleepEnd += SleepEndHandler; + if (sleepController != null) + { + sleepController.OnSleepStart += SleepStartHandler; + sleepController.OnSleepEnd += SleepEndHandler; + } } /// @@ -91,25 +98,30 @@ internal static void TryBindToCurrentInstance() /// internal static void ResetBindings() { - UnbindFromInstance(_boundInstance); + UnbindFromInstances(_boundInstance, _boundSleepController); _boundInstance = null; + _boundSleepController = null; _lastSleepSkippedMinutes = 0; } - private static void UnbindFromInstance(S1GameTime.TimeManager? instance) + private static void UnbindFromInstances( + S1GameTime.TimeManager? instance, + S1GameTime.SleepController? sleepController) { - if (instance == null) - return; + if (instance != null) + { + instance.onHourPass -= HourPassHandler; + instance.onDayPass -= DayPassHandler; + instance.onWeekPass -= WeekPassHandler; + RemoveFromActionList(instance.onTick, TickHandler); + instance.onTimeSkip -= TimeSkipHandler; + } - instance.onHourPass -= HourPassHandler; - instance.onDayPass -= DayPassHandler; - instance.onWeekPass -= WeekPassHandler; - - RemoveFromActionList(instance.onTick, TickHandler); - - instance.onSleepStart -= SleepStartHandler; - instance.onTimeSkip -= TimeSkipHandler; - instance.onSleepEnd -= SleepEndHandler; + if (sleepController != null) + { + sleepController.OnSleepStart -= SleepStartHandler; + sleepController.OnSleepEnd -= SleepEndHandler; + } } private static void AddToActionList(object? actionList, Action handler) @@ -174,7 +186,7 @@ private static void RemoveFromActionList(object? actionList, Action handler) /// /// Whether the player is currently sleeping. /// - public static bool SleepInProgress => S1GameTime.TimeManager.Instance.IsSleepInProgress; + public static bool SleepInProgress => S1GameTime.SleepController.Instance?.IsSleepInProgress ?? false; /// /// The current normalized time of day (0.0 = start, 1.0 = end). @@ -190,7 +202,7 @@ private static void RemoveFromActionList(object? actionList, Action handler) /// Sets the current time manually and synchronizes across the network. /// This can only be called by the host. /// - public static void SetTime(int time24h) => S1GameTime.TimeManager.Instance.SetTimeAndSync(time24h); + public static void SetTime(int time24h) => S1GameTime.TimeManager.Instance.SetTime_Server(time24h); /// /// Gets the current time formatted in 12-hour AM/PM format. diff --git a/S1API/Interaction/InteractionPrompt.cs b/S1API/Interaction/InteractionPrompt.cs new file mode 100644 index 00000000..d92dfaa6 --- /dev/null +++ b/S1API/Interaction/InteractionPrompt.cs @@ -0,0 +1,223 @@ +using System; +using UnityEngine; + +namespace S1API.Interaction +{ + /// + /// Managed handle for a native Schedule One interaction prompt. + /// + public sealed class InteractionPrompt : IDisposable + { + private readonly Internal.Interaction.InteractionPromptRuntime _runtime; + private bool _removed; + + /// Creates a builder for a mod-owned GameObject. + /// The GameObject whose collider should be interactable. + /// A new interaction prompt builder. + public static InteractionPromptBuilder CreateBuilder(GameObject target) => + InteractionPromptBuilder.Create(target); + + /// The GameObject containing the native prompt component. + public GameObject Target => _runtime.Target; + + /// The current prompt message. + public string Message { get; private set; } + + /// The current native input action. + public InteractionPromptInput Input { get; private set; } + + /// The current native prompt state. + public InteractionPromptState State { get; private set; } + + /// The current maximum interaction range. + public float Range { get; private set; } + + /// The current overlap priority. + public int Priority { get; private set; } + + /// Whether the prompt currently applies an angle restriction. + public bool IsAngleLimited { get; private set; } + + /// The current angle restriction in degrees. + public float AngleLimit { get; private set; } + + /// Whether this handle has been removed or its target destroyed. + public bool IsRemoved => _removed || !_runtime.IsAttached; + + /// Invoked while the native interaction manager selects this prompt. + public event Action? Hovered; + + /// Invoked when the native interaction starts. + public event Action? InteractionStarted; + + /// Invoked when the native interaction ends. + public event Action? InteractionEnded; + + internal InteractionPrompt(InteractionPromptBuilder builder) + { + Message = builder.Message; + Input = builder.Input; + State = builder.State; + Range = builder.Range; + Priority = builder.Priority; + IsAngleLimited = builder.LimitAngle; + AngleLimit = builder.AngleLimit; + _runtime = new Internal.Interaction.InteractionPromptRuntime( + this, + builder.Target, + builder.Input, + builder.State, + builder.Message, + builder.Range, + builder.Priority, + builder.LimitAngle, + builder.AngleLimit, + builder.DisplayPoint, + builder.DisplayCollider); + + foreach (Action callback in builder.HoveredCallbacks) + Hovered += callback; + foreach (Action callback in builder.InteractionStartedCallbacks) + InteractionStarted += callback; + foreach (Action callback in builder.InteractionEndedCallbacks) + InteractionEnded += callback; + } + + /// Updates the native prompt message. + /// Non-empty player-facing prompt text. + /// This prompt for fluent chaining. + public InteractionPrompt SetMessage(string message) + { + EnsureUsable(); + Message = InteractionPromptContract.NormalizeMessage(message); + _runtime.SetMessage(Message); + return this; + } + + /// Updates the native input action. + /// The interaction input action. + /// This prompt for fluent chaining. + public InteractionPrompt SetInput(InteractionPromptInput input) + { + EnsureUsable(); + InteractionPromptContract.ValidateEnum(input, nameof(input)); + Input = input; + _runtime.SetInput(input); + return this; + } + + /// Updates the native prompt state. + /// The visual and interaction state. + /// This prompt for fluent chaining. + public InteractionPrompt SetState(InteractionPromptState state) + { + EnsureUsable(); + InteractionPromptContract.ValidateEnum(state, nameof(state)); + State = state; + _runtime.SetState(state); + return this; + } + + /// Updates the maximum selection distance. + /// A finite distance between zero and four metres. + /// This prompt for fluent chaining. + public InteractionPrompt SetRange(float range) + { + EnsureUsable(); + Range = InteractionPromptContract.NormalizeRange(range); + _runtime.SetRange(Range); + return this; + } + + /// Updates the overlap priority. + /// A higher value takes precedence. + /// This prompt for fluent chaining. + public InteractionPrompt SetPriority(int priority) + { + EnsureUsable(); + Priority = priority; + _runtime.SetPriority(priority); + return this; + } + + /// Applies a horizontal angle restriction. + /// A finite angle greater than zero and at most 180 degrees. + /// This prompt for fluent chaining. + public InteractionPrompt SetAngleLimit(float angleLimit) + { + EnsureUsable(); + AngleLimit = InteractionPromptContract.NormalizeAngleLimit(angleLimit); + IsAngleLimited = true; + _runtime.SetAngleLimit(AngleLimit); + return this; + } + + /// Clears the horizontal angle restriction. + /// This prompt for fluent chaining. + public InteractionPrompt WithoutAngleLimit() + { + EnsureUsable(); + IsAngleLimited = false; + _runtime.ClearAngleLimit(); + return this; + } + + /// Updates the prompt display anchor to a transform. + /// The transform whose position should be used. + /// This prompt for fluent chaining. + public InteractionPrompt SetDisplayLocation(Transform displayPoint) + { + EnsureUsable(); + if (ReferenceEquals(displayPoint, null) || displayPoint == null) + throw new ArgumentNullException(nameof(displayPoint)); + _runtime.SetDisplayLocation(displayPoint); + return this; + } + + /// Updates the prompt display anchor to a collider. + /// The collider used for display positioning. + /// This prompt for fluent chaining. + public InteractionPrompt SetDisplayLocation(Collider displayCollider) + { + EnsureUsable(); + if (ReferenceEquals(displayCollider, null) || displayCollider == null) + throw new ArgumentNullException(nameof(displayCollider)); + _runtime.SetDisplayLocation(displayCollider); + return this; + } + + /// Resets prompt positioning to the target transform. + /// This prompt for fluent chaining. + public InteractionPrompt ClearDisplayLocation() + { + EnsureUsable(); + _runtime.ClearDisplayLocation(); + return this; + } + + /// Removes the owned native prompt component. + /// when this call performed the removal. + public bool Remove() + { + if (IsRemoved) + return false; + + _removed = true; + _runtime.Dispose(); + return true; + } + + /// + public void Dispose() => Remove(); + + internal void RaiseHovered() => Hovered?.Invoke(); + internal void RaiseInteractionStarted() => InteractionStarted?.Invoke(); + internal void RaiseInteractionEnded() => InteractionEnded?.Invoke(); + + private void EnsureUsable() + { + if (IsRemoved) + throw new ObjectDisposedException(nameof(InteractionPrompt)); + } + } +} diff --git a/S1API/Interaction/InteractionPromptBuilder.cs b/S1API/Interaction/InteractionPromptBuilder.cs new file mode 100644 index 00000000..7ae31b8a --- /dev/null +++ b/S1API/Interaction/InteractionPromptBuilder.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace S1API.Interaction +{ + /// + /// Builds a native Schedule One interaction prompt for a mod-owned GameObject. + /// + /// + /// The target must contain a collider on a layer included by the game's interaction + /// search mask. The builder does not create colliders or change object layers. + /// + public sealed class InteractionPromptBuilder + { + private readonly GameObject _target; + private readonly List _hoveredCallbacks = new List(); + private readonly List _interactionStartedCallbacks = new List(); + private readonly List _interactionEndedCallbacks = new List(); + + private string? _message; + private InteractionPromptInput _input = InteractionPromptInput.Interact; + private InteractionPromptState _state = InteractionPromptState.Default; + private float _range = InteractionPromptContract.NativeMaxInteractionRange; + private int _priority; + private bool _limitAngle; + private float _angleLimit = InteractionPromptContract.DefaultAngleLimit; + private Transform? _displayPoint; + private Collider? _displayCollider; + private InteractionPrompt? _builtPrompt; + + internal GameObject Target => _target; + internal string Message => _message!; + internal InteractionPromptInput Input => _input; + internal InteractionPromptState State => _state; + internal float Range => _range; + internal int Priority => _priority; + internal bool LimitAngle => _limitAngle; + internal float AngleLimit => _angleLimit; + internal Transform? DisplayPoint => _displayPoint; + internal Collider? DisplayCollider => _displayCollider; + internal IReadOnlyList HoveredCallbacks => _hoveredCallbacks; + internal IReadOnlyList InteractionStartedCallbacks => _interactionStartedCallbacks; + internal IReadOnlyList InteractionEndedCallbacks => _interactionEndedCallbacks; + + internal InteractionPromptBuilder(GameObject target) + { + if (ReferenceEquals(target, null) || target == null) + throw new ArgumentNullException(nameof(target)); + + _target = target; + } + + /// + /// Creates a builder for a mod-owned GameObject. + /// + /// The GameObject whose collider should be interactable. + /// A new interaction prompt builder. + public static InteractionPromptBuilder Create(GameObject target) => + new InteractionPromptBuilder(target); + + /// Sets the text rendered by the native interaction prompt. + /// Non-empty player-facing prompt text. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithMessage(string message) + { + EnsureMutable(); + _message = InteractionPromptContract.NormalizeMessage(message); + return this; + } + + /// Sets the native input action used to begin the interaction. + /// The interaction input action. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithInput(InteractionPromptInput input) + { + EnsureMutable(); + InteractionPromptContract.ValidateEnum(input, nameof(input)); + _input = input; + return this; + } + + /// Sets the native prompt state. + /// The visual and interaction state. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithState(InteractionPromptState state) + { + EnsureMutable(); + InteractionPromptContract.ValidateEnum(state, nameof(state)); + _state = state; + return this; + } + + /// + /// Sets the maximum distance at which the prompt can be selected. + /// + /// A finite distance between zero and four metres. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithRange(float range) + { + EnsureMutable(); + _range = InteractionPromptContract.NormalizeRange(range); + return this; + } + + /// Sets the priority used when native prompts overlap. + /// A higher value takes precedence. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithPriority(int priority) + { + EnsureMutable(); + _priority = priority; + return this; + } + + /// + /// Limits selection to the specified horizontal angle around the target's forward direction. + /// + /// A finite angle greater than zero and at most 180 degrees. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithAngleLimit(float angleLimit) + { + EnsureMutable(); + _angleLimit = InteractionPromptContract.NormalizeAngleLimit(angleLimit); + _limitAngle = true; + return this; + } + + /// Clears any angle restriction. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithoutAngleLimit() + { + EnsureMutable(); + _limitAngle = false; + return this; + } + + /// + /// Uses a transform as the native prompt display anchor. + /// + /// The transform whose position should be used. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithDisplayLocation(Transform displayPoint) + { + EnsureMutable(); + if (ReferenceEquals(displayPoint, null) || displayPoint == null) + throw new ArgumentNullException(nameof(displayPoint)); + + _displayPoint = displayPoint; + _displayCollider = null; + return this; + } + + /// + /// Uses a collider's closest point to the player as the native prompt display anchor. + /// + /// The collider used for display positioning. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithDisplayLocation(Collider displayCollider) + { + EnsureMutable(); + if (ReferenceEquals(displayCollider, null) || displayCollider == null) + throw new ArgumentNullException(nameof(displayCollider)); + + _displayCollider = displayCollider; + _displayPoint = null; + return this; + } + + /// Registers a callback invoked while this prompt is selected. + /// The callback invoked by the native hover lifecycle. + /// This builder for fluent chaining. + public InteractionPromptBuilder OnHovered(Action callback) + { + EnsureMutable(); + AddCallback(_hoveredCallbacks, callback, nameof(callback)); + return this; + } + + /// Registers a callback invoked when the native interaction starts. + /// The callback invoked by the native interaction lifecycle. + /// This builder for fluent chaining. + public InteractionPromptBuilder OnInteractionStarted(Action callback) + { + EnsureMutable(); + AddCallback(_interactionStartedCallbacks, callback, nameof(callback)); + return this; + } + + /// Registers a callback invoked when the native interaction ends. + /// The callback invoked by the native interaction lifecycle. + /// This builder for fluent chaining. + public InteractionPromptBuilder OnInteractionEnded(Action callback) + { + EnsureMutable(); + AddCallback(_interactionEndedCallbacks, callback, nameof(callback)); + return this; + } + + /// + /// Attaches the native interaction component and returns a managed runtime handle. + /// + /// The managed interaction prompt handle. + /// + /// Thrown when the message is missing, the target already has an interaction component, + /// or no usable collider exists beneath the target. + /// + public InteractionPrompt Build() + { + if (_builtPrompt != null) + return _builtPrompt; + if (_message == null) + throw new InvalidOperationException("WithMessage must be called before Build()."); + + _builtPrompt = new InteractionPrompt(this); + return _builtPrompt; + } + + private void EnsureMutable() + { + if (_builtPrompt != null) + throw new InvalidOperationException("This interaction prompt builder has already been built."); + } + + private static void AddCallback(List callbacks, Action callback, string parameterName) + { + if (callback == null) + throw new ArgumentNullException(parameterName); + if (!callbacks.Contains(callback)) + callbacks.Add(callback); + } + } +} diff --git a/S1API/Interaction/InteractionPromptContract.cs b/S1API/Interaction/InteractionPromptContract.cs new file mode 100644 index 00000000..d2f4481f --- /dev/null +++ b/S1API/Interaction/InteractionPromptContract.cs @@ -0,0 +1,59 @@ +using System; +using UnityEngine; + +namespace S1API.Interaction +{ + internal static class InteractionPromptContract + { + internal const float NativeMaxInteractionRange = 4f; + internal const float DefaultAngleLimit = 90f; + + internal static string NormalizeMessage(string message) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentException("Interaction prompt messages cannot be empty or whitespace.", nameof(message)); + + return message; + } + + internal static float NormalizeRange(float range) + { + if (float.IsNaN(range) || float.IsInfinity(range) || range <= 0f || range > NativeMaxInteractionRange) + { + throw new ArgumentOutOfRangeException( + nameof(range), + "Interaction prompt range must be finite, greater than zero, and at most four metres."); + } + + return range; + } + + internal static float NormalizeAngleLimit(float angleLimit) + { + if (float.IsNaN(angleLimit) || float.IsInfinity(angleLimit) || angleLimit <= 0f || angleLimit > 180f) + { + throw new ArgumentOutOfRangeException( + nameof(angleLimit), + "Interaction prompt angle limits must be finite, greater than zero, and at most 180 degrees."); + } + + return angleLimit; + } + + internal static void ValidateEnum(TEnum value, string parameterName) + where TEnum : struct, Enum + { + if (!Enum.IsDefined(typeof(TEnum), value)) + throw new ArgumentOutOfRangeException(parameterName, value, "The interaction prompt value is not defined."); + } + + internal static void ValidateTarget(GameObject target) + { + if (ReferenceEquals(target, null) || target == null) + throw new ArgumentNullException(nameof(target)); + } + } +} diff --git a/S1API/Interaction/InteractionPromptInput.cs b/S1API/Interaction/InteractionPromptInput.cs new file mode 100644 index 00000000..9d52d234 --- /dev/null +++ b/S1API/Interaction/InteractionPromptInput.cs @@ -0,0 +1,14 @@ +namespace S1API.Interaction +{ + /// + /// Selects the native input action used to start an interaction. + /// + public enum InteractionPromptInput + { + /// The configured keyboard or controller interaction action. + Interact, + + /// The configured primary-click action. + PrimaryClick + } +} diff --git a/S1API/Interaction/InteractionPromptState.cs b/S1API/Interaction/InteractionPromptState.cs new file mode 100644 index 00000000..79b1e9ee --- /dev/null +++ b/S1API/Interaction/InteractionPromptState.cs @@ -0,0 +1,20 @@ +namespace S1API.Interaction +{ + /// + /// Selects the native visual and interaction state shown by an interaction prompt. + /// + public enum InteractionPromptState + { + /// Shows the normal input prompt. + Default, + + /// Shows the native invalid prompt and prevents interaction start. + Invalid, + + /// Hides the prompt and prevents interaction selection. + Disabled, + + /// Shows the message without an input icon. + Label + } +} diff --git a/S1API/Internal/Building/BuildableGhostRuntime.cs b/S1API/Internal/Building/BuildableGhostRuntime.cs new file mode 100644 index 00000000..9d6be0c1 --- /dev/null +++ b/S1API/Internal/Building/BuildableGhostRuntime.cs @@ -0,0 +1,138 @@ +#if (IL2CPPMELON) +using S1Building = Il2CppScheduleOne.Building; +#elif MONOMELON +using S1Building = ScheduleOne.Building; +#endif +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace S1API.Internal.Building +{ + /// + /// Applies opt-in S1API visual configuration after the native placement system creates a ghost. + /// + internal static class BuildableGhostRuntime + { + internal const string FurnitureVisualName = "FurnitureVisual"; + internal const string FurnitureGhostVisualName = "FurnitureGhostVisual"; + + private static readonly Dictionary> Configurators = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + internal static void RegisterVisual( + string itemId, + Func visualFactory, + bool replaceExistingVisual) + { + if (string.IsNullOrWhiteSpace(itemId)) + throw new ArgumentException("Buildable item ID cannot be null or whitespace.", nameof(itemId)); + if (visualFactory == null) + throw new ArgumentNullException(nameof(visualFactory)); + + Configurators[itemId] = ghostRoot => + CreateVisual(ghostRoot, visualFactory, replaceExistingVisual); + } + + internal static void RegisterVisualSource( + string itemId, + GameObject visualSource, + string ghostVisualName, + bool replaceExistingVisual) + { + if (visualSource == null) + throw new ArgumentNullException(nameof(visualSource)); + if (string.IsNullOrWhiteSpace(ghostVisualName)) + throw new ArgumentException("Ghost visual name cannot be null or whitespace.", nameof(ghostVisualName)); + + RegisterVisual( + itemId, + parent => + { + GameObject visual = UnityEngine.Object.Instantiate( + visualSource, + parent, + false); + visual.name = ghostVisualName; + return visual; + }, + replaceExistingVisual); + } + + internal static bool TryConfigure(string? itemId, GameObject? ghostRoot) + { + if (string.IsNullOrWhiteSpace(itemId) || ghostRoot == null) + return false; + if (!Configurators.TryGetValue(itemId, out Action? configure)) + return false; + + configure(ghostRoot); + return true; + } + + private static void CreateVisual( + GameObject ghostRoot, + Func visualFactory, + bool replaceExistingVisual) + { + Renderer[] existingRenderers = ghostRoot.GetComponentsInChildren(true); + bool[] previousStates = new bool[existingRenderers.Length]; + GameObject? createdVisual = null; + + try + { + if (replaceExistingVisual) + { + for (int index = 0; index < existingRenderers.Length; index++) + { + previousStates[index] = existingRenderers[index].enabled; + existingRenderers[index].enabled = false; + } + } + + GameObject visual = visualFactory(ghostRoot.transform); + if (visual == null) + throw new InvalidOperationException("The buildable ghost visual factory returned null."); + + createdVisual = visual; + if (!visual.transform.IsChildOf(ghostRoot.transform)) + visual.transform.SetParent(ghostRoot.transform, false); + + visual.SetActive(true); + PrepareVisualForGhost(visual); + } + catch + { + if (createdVisual != null) + UnityEngine.Object.Destroy(createdVisual); + + if (replaceExistingVisual) + { + for (int index = 0; index < existingRenderers.Length; index++) + { + if (existingRenderers[index] != null) + existingRenderers[index].enabled = previousStates[index]; + } + } + + throw; + } + } + + private static void PrepareVisualForGhost(GameObject visual) + { + S1Building.BuildManager? buildManager = S1Building.BuildManager.Instance; + if (buildManager == null) + throw new InvalidOperationException("The native build manager is unavailable while creating a placement ghost."); + + // Native ghost preparation runs before S1API adds this visual. Apply the same + // non-interactive runtime contract to the late-added hierarchy. + buildManager.DisableColliders(visual); + buildManager.DisableNavigation(visual); + buildManager.DisableNetworking(visual); + buildManager.DisableCanvases(visual); + buildManager.DisableLights(visual); + } + + } +} diff --git a/S1API/Internal/Building/FurnitureBuildSoundMapper.cs b/S1API/Internal/Building/FurnitureBuildSoundMapper.cs new file mode 100644 index 00000000..1b4b4dae --- /dev/null +++ b/S1API/Internal/Building/FurnitureBuildSoundMapper.cs @@ -0,0 +1,43 @@ +#if (IL2CPPMELON) +using S1ItemFramework = Il2CppScheduleOne.ItemFramework; +#elif MONOMELON +using S1ItemFramework = ScheduleOne.ItemFramework; +#endif +using System; +using S1API.Items.Buildable; + +namespace S1API.Internal.Building +{ + /// + /// Maps the public furniture sound choices without changing the legacy buildable builder's ordinal behavior. + /// + internal static class FurnitureBuildSoundMapper + { + internal const BuildSoundType Default = BuildSoundType.Wood; + + internal static S1ItemFramework.BuildableItemDefinition.EBuildSoundType ToNative( + BuildSoundType soundType) + { + return soundType switch + { + BuildSoundType.Wood => S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Wood, + BuildSoundType.Metal => S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Metal, + BuildSoundType.Plastic => S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Metal, + BuildSoundType.Cardboard => S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Cardboard, + _ => throw new ArgumentOutOfRangeException(nameof(soundType)), + }; + } + + internal static BuildSoundType FromNative( + S1ItemFramework.BuildableItemDefinition.EBuildSoundType soundType) + { + return soundType switch + { + S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Cardboard => BuildSoundType.Cardboard, + S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Wood => BuildSoundType.Wood, + S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Metal => BuildSoundType.Metal, + _ => throw new ArgumentOutOfRangeException(nameof(soundType)), + }; + } + } +} diff --git a/S1API/Internal/Building/FurnitureClonePolicy.cs b/S1API/Internal/Building/FurnitureClonePolicy.cs new file mode 100644 index 00000000..294595c6 --- /dev/null +++ b/S1API/Internal/Building/FurnitureClonePolicy.cs @@ -0,0 +1,17 @@ +using System; + +namespace S1API.Internal.Building +{ + internal static class FurnitureClonePolicy + { + internal static void ValidateNewId(string itemId, string? donorId) + { + if (donorId != null && + string.Equals(itemId, donorId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "A furniture variant must use a new stable item ID."); + } + } + } +} diff --git a/S1API/Internal/Building/FurnitureCloneSource.cs b/S1API/Internal/Building/FurnitureCloneSource.cs new file mode 100644 index 00000000..408ffa92 --- /dev/null +++ b/S1API/Internal/Building/FurnitureCloneSource.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using S1API.Items.Buildable; +using UnityEngine; + +namespace S1API.Internal.Building +{ + /// + /// Builder-owned presentation and safe scalar defaults extracted from a furniture donor. + /// + internal sealed class FurnitureCloneSource + { + internal FurnitureCloneSource( + string donorId, + GameObject model, + FurniturePlacementMode placementMode, + IReadOnlyList? footprint, + FurnitureSurfaceType surfaceTypes, + bool allowSurfaceRotation, + BuildSoundType buildSound, + int stackLimit, + float purchasePrice, + float resellMultiplier, + Sprite? icon) + { + DonorId = donorId; + Model = model; + PlacementMode = placementMode; + Footprint = footprint; + SurfaceTypes = surfaceTypes; + AllowSurfaceRotation = allowSurfaceRotation; + BuildSound = buildSound; + StackLimit = stackLimit; + PurchasePrice = purchasePrice; + ResellMultiplier = resellMultiplier; + Icon = icon; + } + + internal string DonorId { get; } + internal GameObject Model { get; } + internal FurniturePlacementMode PlacementMode { get; } + internal IReadOnlyList? Footprint { get; } + internal FurnitureSurfaceType SurfaceTypes { get; } + internal bool AllowSurfaceRotation { get; } + internal BuildSoundType BuildSound { get; } + internal int StackLimit { get; } + internal float PurchasePrice { get; } + internal float ResellMultiplier { get; } + internal Sprite? Icon { get; } + } + + internal readonly struct FurnitureFootprintCoordinate + { + internal FurnitureFootprintCoordinate(int x, int y) + { + X = x; + Y = y; + } + + internal int X { get; } + internal int Y { get; } + } +} diff --git a/S1API/Internal/Building/FurnitureComposition.cs b/S1API/Internal/Building/FurnitureComposition.cs new file mode 100644 index 00000000..ad5e099a --- /dev/null +++ b/S1API/Internal/Building/FurnitureComposition.cs @@ -0,0 +1,33 @@ +#if (IL2CPPMELON) +using S1EntityFramework = Il2CppScheduleOne.EntityFramework; +using S1ItemFramework = Il2CppScheduleOne.ItemFramework; +using S1Storage = Il2CppScheduleOne.Storage; +#elif MONOMELON +using S1EntityFramework = ScheduleOne.EntityFramework; +using S1ItemFramework = ScheduleOne.ItemFramework; +using S1Storage = ScheduleOne.Storage; +#endif +using S1API.Items; + +namespace S1API.Internal.Building +{ + internal sealed class FurnitureComposition + { + internal FurnitureComposition( + S1ItemFramework.BuildableItemDefinition templateDefinition, + S1EntityFramework.BuildableItem builtItem, + S1Storage.StoredItem storedItem, + Equippable equippable) + { + TemplateDefinition = templateDefinition; + BuiltItem = builtItem; + StoredItem = storedItem; + Equippable = equippable; + } + + internal S1ItemFramework.BuildableItemDefinition TemplateDefinition { get; } + internal S1EntityFramework.BuildableItem BuiltItem { get; } + internal S1Storage.StoredItem StoredItem { get; } + internal Equippable Equippable { get; } + } +} diff --git a/S1API/Internal/Building/FurnitureIconRuntime.cs b/S1API/Internal/Building/FurnitureIconRuntime.cs new file mode 100644 index 00000000..e3398c26 --- /dev/null +++ b/S1API/Internal/Building/FurnitureIconRuntime.cs @@ -0,0 +1,291 @@ +using System.Collections; +using System.Collections.Generic; +using MelonLoader; +using S1API.Internal.Products; +using S1API.Internal.Utils; +using S1API.Items.Buildable; +using S1API.Logging; +using S1API.Rendering; +using UnityEngine; + +namespace S1API.Internal.Building +{ + /// + /// Defers furniture thumbnail rendering until the gameplay scene provides the native icon rig. + /// Definitions remain available during pre-load so native save restoration can resolve them. + /// + internal static class FurnitureIconRuntime + { + private const int RenderLeaseWaitFrames = 600; + private static readonly Log Logger = new Log("FurnitureIconRuntime"); + private static readonly object Gate = new object(); + private static readonly Queue Pending = new Queue(); + private static bool _processing; + + internal static void Queue( + BuildableItemDefinition definition, + Transform model, + int resolution, + bool isolateMaterials) + { + bool startProcessor = false; + lock (Gate) + { + Pending.Enqueue(new Request( + definition, + model, + resolution, + isolateMaterials)); + if (!_processing) + { + _processing = true; + startProcessor = true; + } + } + + if (startProcessor) + MelonCoroutines.Start(ProcessQueue()); + } + + private static IEnumerator ProcessQueue() + { + try + { + // Registration must happen before save restoration, while the native render rig + // appears only after gameplay is ready. Preserve queued work across loading and + // new-game setup instead of discarding it on an arbitrary frame deadline. + while (!IconFactory.IsItemIconGeneratorReady) + yield return null; + + while (TryDequeue(out Request? request)) + { + IEnumerator requestProcessor = ProcessRequest(request!); + try + { + while (requestProcessor.MoveNext()) + yield return requestProcessor.Current; + } + finally + { + (requestProcessor as System.IDisposable)?.Dispose(); + } + } + } + finally + { + FinishProcessing(); + } + } + + private static IEnumerator ProcessRequest(Request request) + { + if (request.Model == null) + { + Logger.Warning( + $"Could not generate furniture icon for '{request.Definition.ID}': " + + "the source model was destroyed before capture."); + yield break; + } + + ProductIconRenderRigArbiter.CaptureLease renderLease = + ProductIconRenderRigArbiter.Enqueue(); + GameObject? iconModel = null; + try + { + int acquisitionFrame = 0; + bool leaseAcquired = false; + while (!(leaseAcquired = ProductIconRenderRigArbiter.TryAcquire(renderLease)) && + acquisitionFrame < RenderLeaseWaitFrames) + { + acquisitionFrame++; + yield return null; + } + + if (!leaseAcquired) + { + Logger.Warning( + $"Could not generate furniture icon for '{request.Definition.ID}': " + + "the shared item-icon rendering rig remained busy."); + yield break; + } + + if (!TryCreatePreview(request, out iconModel, out string failure)) + { + Logger.Warning( + $"Could not generate furniture icon for '{request.Definition.ID}': {failure}."); + yield break; + } + + const int maxRetries = 30; + bool generated = false; + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + // Let the newly activated preview complete Update and render before capture. + yield return null; + yield return new WaitForEndOfFrame(); + + RenderAttemptResult result = TryRender( + request, + iconModel!, + out Sprite? icon, + out failure); + if (result == RenderAttemptResult.Success) + { + request.Definition.Icon = icon!; + generated = true; + break; + } + + if (result == RenderAttemptResult.Failure) + break; + } + + if (!generated) + { + Logger.Warning( + $"Could not generate furniture icon for '{request.Definition.ID}': {failure}."); + } + + Object.Destroy(iconModel); + iconModel = null; + + // Keep the shared rig lease through cleanup and a settled frame so the next + // queued subject cannot capture the outgoing preview. + yield return null; + yield return new WaitForEndOfFrame(); + } + finally + { + if (iconModel != null) + Object.Destroy(iconModel); + + ProductIconRenderRigArbiter.Release(renderLease); + } + } + + private static bool TryCreatePreview( + Request request, + out GameObject? iconModel, + out string failure) + { + iconModel = null; + try + { + if (request.Model == null) + { + failure = "the source model was destroyed before capture"; + return false; + } + + iconModel = request.IsolateMaterials + ? FurnitureVisualCloner.CloneOwnedVisual(request.Model.gameObject) + : InactiveObjectCloner.CloneGameObject(request.Model.gameObject); + if (iconModel == null) + { + failure = "the source model could not be cloned"; + return false; + } + + iconModel.name = $"{request.Model.name}_IconPreview"; + foreach (Collider collider in iconModel.GetComponentsInChildren(true)) + collider.enabled = false; + iconModel.SetActive(true); + failure = string.Empty; + return true; + } + catch (System.Exception exception) + { + failure = $"preview creation failed: {exception.Message}"; + if (iconModel != null) + Object.Destroy(iconModel); + iconModel = null; + return false; + } + } + + private static RenderAttemptResult TryRender( + Request request, + GameObject iconModel, + out Sprite? icon, + out string failure) + { + icon = null; + try + { + icon = IconFactory.GenerateIconSprite( + iconModel.transform, + request.Resolution); + if (icon == null) + { + failure = "the native renderer returned no visible pixels"; + return RenderAttemptResult.Retry; + } + + failure = string.Empty; + return RenderAttemptResult.Success; + } + catch (System.Exception exception) + { + failure = $"rendering failed: {exception.Message}"; + return RenderAttemptResult.Failure; + } + } + + private static bool TryDequeue(out Request? request) + { + lock (Gate) + { + if (Pending.Count == 0) + { + request = null; + return false; + } + + request = Pending.Dequeue(); + return true; + } + } + + private static void FinishProcessing() + { + bool restart; + lock (Gate) + { + _processing = false; + restart = Pending.Count != 0; + if (restart) + _processing = true; + } + + if (restart) + MelonCoroutines.Start(ProcessQueue()); + } + + private enum RenderAttemptResult + { + Success, + Retry, + Failure, + } + + private sealed class Request + { + internal Request( + BuildableItemDefinition definition, + Transform model, + int resolution, + bool isolateMaterials) + { + Definition = definition; + Model = model; + Resolution = resolution; + IsolateMaterials = isolateMaterials; + } + + internal BuildableItemDefinition Definition { get; } + internal Transform Model { get; } + internal int Resolution { get; } + internal bool IsolateMaterials { get; } + } + } +} diff --git a/S1API/Internal/Building/FurniturePrefabComposer.cs b/S1API/Internal/Building/FurniturePrefabComposer.cs new file mode 100644 index 00000000..27633c4f --- /dev/null +++ b/S1API/Internal/Building/FurniturePrefabComposer.cs @@ -0,0 +1,359 @@ +#if (IL2CPPMELON) +using S1Building = Il2CppScheduleOne.Building; +using S1EntityFramework = Il2CppScheduleOne.EntityFramework; +using S1ItemFramework = Il2CppScheduleOne.ItemFramework; +using S1Registry = Il2CppScheduleOne.Registry; +using S1Storage = Il2CppScheduleOne.Storage; +using S1Tiles = Il2CppScheduleOne.Tiles; +#elif MONOMELON +using S1Building = ScheduleOne.Building; +using S1EntityFramework = ScheduleOne.EntityFramework; +using S1ItemFramework = ScheduleOne.ItemFramework; +using S1Registry = ScheduleOne.Registry; +using S1Storage = ScheduleOne.Storage; +using S1Tiles = ScheduleOne.Tiles; +#endif +using System; +using System.Collections.Generic; +using S1API.Internal.Utils; +using S1API.Items; +using S1API.Items.Buildable; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace S1API.Internal.Building +{ + /// + /// Preserves the game's serialized build, save, and networking graph while replacing only + /// presentation, bounds, footprint, and surface-placement configuration. + /// + internal static class FurniturePrefabComposer + { + private const float GridTileSize = 0.5f; + + internal static FurnitureComposition Compose( + string id, + GameObject model, + FurniturePlacementMode placementMode, + int footprintWidth, + int footprintDepth, + FurnitureSurfaceType surfaceTypes, + bool allowSurfaceRotation, + IReadOnlyList? donorFootprint, + bool centerModelOnFootprint, + bool isolateMaterials) + { + string templateId = placementMode == FurniturePlacementMode.Grid + ? FurnitureTemplateCatalog.GridItemId + : FurnitureTemplateCatalog.SurfaceItemId; + S1ItemFramework.BuildableItemDefinition template = GetTemplateDefinition(templateId); + + S1EntityFramework.BuildableItem builtItem = InactiveObjectCloner.CloneComponent( + template.BuiltItem, + parent: null); + builtItem.gameObject.name = $"{id}_BuiltItem"; + DisableTemplateRenderers(builtItem.gameObject); + + Vector3 visualOffset = placementMode == FurniturePlacementMode.Grid && + centerModelOnFootprint + ? new Vector3( + (footprintWidth - 1) * GridTileSize * 0.5f, + 0f, + (footprintDepth - 1) * GridTileSize * 0.5f) + : Vector3.zero; + GameObject builtModel = AddModelClone( + model, + builtItem.transform, + visualOffset, + BuildableGhostRuntime.FurnitureVisualName, + isolateMaterials); + ConfigureBoundsAndCulling(builtItem, builtModel, placementMode); + + if (placementMode == FurniturePlacementMode.Grid) + { + if (!CrossType.Is(builtItem, out S1EntityFramework.GridItem gridItem)) + throw new InvalidOperationException($"Furniture template '{templateId}' is not a native GridItem."); + + ConfigureGridFootprint( + gridItem, + footprintWidth, + footprintDepth, + donorFootprint); + } + else + { + if (!CrossType.Is(builtItem, out S1EntityFramework.SurfaceItem surfaceItem)) + throw new InvalidOperationException($"Furniture template '{templateId}' is not a native SurfaceItem."); + + ConfigureSurfacePlacement(surfaceItem, surfaceTypes, allowSurfaceRotation); + } + + RuntimePrefabCache.Store(builtItem.gameObject); + + S1Storage.StoredItem storedItem = InactiveObjectCloner.CloneComponent( + template.StoredItem, + parent: null); + storedItem.gameObject.name = $"{id}_StoredItem"; + DisableTemplateRenderers(storedItem.gameObject); + AddModelClone( + model, + storedItem.transform, + Vector3.zero, + BuildableGhostRuntime.FurnitureVisualName, + isolateMaterials); + RuntimePrefabCache.Store(storedItem.gameObject); + + return new FurnitureComposition( + template, + builtItem, + storedItem, + new Equippable(template.Equippable)); + } + + private static S1ItemFramework.BuildableItemDefinition GetTemplateDefinition(string templateId) + { + object? item = S1Registry.GetItem(templateId); + if (item == null || !CrossType.Is(item, out S1ItemFramework.BuildableItemDefinition definition)) + { + throw new InvalidOperationException( + $"Native furniture template '{templateId}' is unavailable. Build custom furniture after the item registry is initialized."); + } + + if (definition.BuiltItem == null) + throw new InvalidOperationException($"Native furniture template '{templateId}' has no placed-item prefab."); + if (definition.StoredItem == null) + throw new InvalidOperationException($"Native furniture template '{templateId}' has no stored-item prefab."); + if (definition.Equippable == null) + throw new InvalidOperationException($"Native furniture template '{templateId}' has no native equippable prefab."); + + return definition; + } + + private static GameObject AddModelClone( + GameObject model, + Transform parent, + Vector3 localPosition, + string name, + bool isolateMaterials) + { + GameObject clone = isolateMaterials + ? FurnitureVisualCloner.CloneOwnedVisual(model) + : InactiveObjectCloner.CloneGameObject(model); + clone.name = name; + clone.transform.SetParent(parent, false); + clone.transform.localPosition = localPosition; + clone.transform.localRotation = Quaternion.identity; + clone.SetActive(true); + return clone; + } + + private static void DisableTemplateRenderers(GameObject root) + { + foreach (Renderer renderer in root.GetComponentsInChildren(true)) + renderer.enabled = false; + } + + private static void ConfigureBoundsAndCulling( + S1EntityFramework.BuildableItem builtItem, + GameObject model, + FurniturePlacementMode placementMode) + { + Renderer[] renderers = model.GetComponentsInChildren(true); + if (renderers.Length == 0) + throw new ArgumentException("Furniture model must contain at least one Renderer.", nameof(model)); + + Bounds builtItemBounds = CalculateCombinedBounds(renderers, builtItem.transform); + Bounds modelBounds = CalculateCombinedBounds(renderers, model.transform); + + BoxCollider boundingCollider = builtItem.BoundingCollider; + if (boundingCollider == null) + boundingCollider = builtItem.gameObject.AddComponent(); + boundingCollider.center = builtItemBounds.center; + boundingCollider.size = builtItemBounds.size; + builtItem.BoundingCollider = boundingCollider; + + var collision = model.AddComponent(); + collision.center = modelBounds.center; + collision.size = modelBounds.size; + + builtItem.GameObjectsToCull = new[] { model }; +#if (IL2CPPMELON) + builtItem.MeshesToCull = new Il2CppSystem.Collections.Generic.List(); +#else + builtItem.MeshesToCull = new System.Collections.Generic.List(); +#endif + foreach (MeshRenderer renderer in model.GetComponentsInChildren(true)) + builtItem.MeshesToCull.Add(renderer); + +#if (IL2CPPMELON) + var outlineRenderers = new Il2CppSystem.Collections.Generic.List(); +#else + var outlineRenderers = new System.Collections.Generic.List(); +#endif + foreach (Renderer renderer in renderers) + outlineRenderers.Add(renderer.gameObject); + if (!ReflectionUtils.TrySetFieldOrProperty(builtItem, "OutlineRenderers", outlineRenderers)) + throw new InvalidOperationException("Native furniture template does not expose its outline renderer list."); + + // Grid build points are ground anchors. Moving one to the model center lowers the + // footprint below the tile detectors, which makes the ghost invisible and invalid. + Vector3 boundsCenterWorld = builtItem.transform.TransformPoint(builtItemBounds.center); + if (placementMode == FurniturePlacementMode.Surface && builtItem.BuildPoint != null) + builtItem.BuildPoint.position = boundsCenterWorld; + if (builtItem.MidAirCenterPoint != null) + builtItem.MidAirCenterPoint.position = boundsCenterWorld; + } + + private static Bounds CalculateCombinedBounds( + Renderer[] renderers, + Transform relativeTo) + { + Bounds combined = default; + bool hasPoint = false; + + foreach (Renderer renderer in renderers) + { + if (!TryGetRendererLocalBounds(renderer, out Bounds rendererBounds)) + continue; + + Vector3 center = rendererBounds.center; + Vector3 extents = rendererBounds.extents; + for (int corner = 0; corner < 8; corner++) + { + var offset = new Vector3( + (corner & 1) == 0 ? -extents.x : extents.x, + (corner & 2) == 0 ? -extents.y : extents.y, + (corner & 4) == 0 ? -extents.z : extents.z); + Vector3 point = relativeTo.InverseTransformPoint( + renderer.transform.TransformPoint(center + offset)); + if (!hasPoint) + { + combined = new Bounds(point, Vector3.zero); + hasPoint = true; + } + else + { + combined.Encapsulate(point); + } + } + } + + if (!hasPoint) + { + throw new ArgumentException( + "Furniture model must contain a MeshRenderer with a mesh or a SkinnedMeshRenderer.", + nameof(renderers)); + } + + return combined; + } + + private static bool TryGetRendererLocalBounds( + Renderer renderer, + out Bounds bounds) + { + MeshFilter? meshFilter = renderer.GetComponent(); + if (meshFilter != null && meshFilter.sharedMesh != null) + { + bounds = meshFilter.sharedMesh.bounds; + return true; + } + + SkinnedMeshRenderer? skinnedRenderer = renderer.GetComponent(); + if (skinnedRenderer != null) + { + bounds = skinnedRenderer.localBounds; + return true; + } + + bounds = default; + return false; + } + + private static void ConfigureGridFootprint( + S1EntityFramework.GridItem gridItem, + int width, + int depth, + IReadOnlyList? donorFootprint) + { + if (gridItem.CoordinateFootprintTilePairs == null || + gridItem.CoordinateFootprintTilePairs.Count == 0 || + gridItem.CoordinateFootprintTilePairs[0].footprintTile == null) + { + throw new InvalidOperationException("Native grid furniture template has no footprint tile to clone."); + } + + S1Tiles.FootprintTile templateTile = gridItem.CoordinateFootprintTilePairs[0].footprintTile; + Transform oldFootprintRoot = templateTile.transform.parent; + if (oldFootprintRoot != null && oldFootprintRoot != gridItem.transform) + oldFootprintRoot.gameObject.SetActive(false); + + var footprintRoot = new GameObject("FurnitureFootprint"); + footprintRoot.transform.SetParent(gridItem.transform, false); + footprintRoot.SetActive(true); + +#if (IL2CPPMELON) + var pairs = new Il2CppSystem.Collections.Generic.List(); +#else + var pairs = new System.Collections.Generic.List(); +#endif + if (donorFootprint != null) + { + foreach (FurnitureFootprintCoordinate coordinate in donorFootprint) + AddFootprintTile(coordinate.X, coordinate.Y); + } + else + { + for (int x = 0; x < width; x++) + for (int y = 0; y < depth; y++) + AddFootprintTile(x, y); + } + + gridItem.CoordinateFootprintTilePairs = pairs; + + void AddFootprintTile(int x, int y) + { + S1Tiles.FootprintTile tile = InactiveObjectCloner.CloneComponent( + templateTile, + footprintRoot.transform); + tile.name = $"FootprintTile_{x}_{y}"; + tile.X = x; + tile.Y = y; + tile.transform.localPosition = new Vector3(x * GridTileSize, 0f, y * GridTileSize); + tile.gameObject.SetActive(true); + +#if (IL2CPPMELON) + var pair = new S1Tiles.CoordinateFootprintTilePair(); + pair.coord = new S1Tiles.Coordinate(x, y); + pair.footprintTile = tile; +#else + var pair = new S1Tiles.CoordinateFootprintTilePair + { + coord = new S1Tiles.Coordinate(x, y), + footprintTile = tile, + }; +#endif + pairs.Add(pair); + } + } + + private static void ConfigureSurfacePlacement( + S1EntityFramework.SurfaceItem surfaceItem, + FurnitureSurfaceType surfaceTypes, + bool allowRotation) + { +#if (IL2CPPMELON) + var nativeTypes = new Il2CppSystem.Collections.Generic.List(); +#else + var nativeTypes = new System.Collections.Generic.List(); +#endif + if ((surfaceTypes & FurnitureSurfaceType.Wall) != 0) + nativeTypes.Add(S1Building.Surface.ESurfaceType.Wall); + if ((surfaceTypes & FurnitureSurfaceType.Roof) != 0) + nativeTypes.Add(S1Building.Surface.ESurfaceType.Roof); + + surfaceItem.ValidSurfaceTypes = nativeTypes; + surfaceItem.AllowRotation = allowRotation; + } + } +} diff --git a/S1API/Internal/Building/FurnitureTemplateCatalog.cs b/S1API/Internal/Building/FurnitureTemplateCatalog.cs new file mode 100644 index 00000000..1ea6d97b --- /dev/null +++ b/S1API/Internal/Building/FurnitureTemplateCatalog.cs @@ -0,0 +1,11 @@ +namespace S1API.Internal.Building +{ + /// + /// Stable vanilla definitions whose serialized prefab graphs provide the generic placement contract. + /// + internal static class FurnitureTemplateCatalog + { + internal const string GridItemId = "grandfatherclock"; + internal const string SurfaceItemId = "wallclock"; + } +} diff --git a/S1API/Internal/Building/FurnitureVisualCloner.cs b/S1API/Internal/Building/FurnitureVisualCloner.cs new file mode 100644 index 00000000..26d1c2e2 --- /dev/null +++ b/S1API/Internal/Building/FurnitureVisualCloner.cs @@ -0,0 +1,266 @@ +#if (IL2CPPMELON) +using S1Building = Il2CppScheduleOne.Building; +using S1EntityFramework = Il2CppScheduleOne.EntityFramework; +using S1ItemFramework = Il2CppScheduleOne.ItemFramework; +#elif MONOMELON +using S1Building = ScheduleOne.Building; +using S1EntityFramework = ScheduleOne.EntityFramework; +using S1ItemFramework = ScheduleOne.ItemFramework; +#endif +using System; +using System.Collections.Generic; +using S1API.Internal.Utils; +using S1API.Items.Buildable; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace S1API.Internal.Building +{ + /// + /// Extracts presentation-only furniture clones without exposing native prefabs or materials. + /// + internal static class FurnitureVisualCloner + { + internal static FurnitureCloneSource CreateSource( + S1ItemFramework.BuildableItemDefinition definition) + { + if (definition == null) + throw new ArgumentNullException(nameof(definition)); + if (string.IsNullOrWhiteSpace(definition.ID)) + throw new ArgumentException("Furniture donor has no stable item ID.", nameof(definition)); + if (definition.BuiltItem == null) + { + throw new ArgumentException( + $"Furniture donor '{definition.ID}' has no placed-item prefab.", + nameof(definition)); + } + + S1EntityFramework.BuildableItem builtItem = definition.BuiltItem; + FurniturePlacementMode placementMode; + IReadOnlyList? footprint = null; + FurnitureSurfaceType surfaceTypes = FurnitureSurfaceType.Wall; + bool allowSurfaceRotation = true; + + if (CrossType.IsExact(builtItem) && + CrossType.Is(builtItem, out S1EntityFramework.GridItem gridItem)) + { + placementMode = FurniturePlacementMode.Grid; + footprint = ExtractFootprint(gridItem, definition.ID); + } + else if (CrossType.IsExact(builtItem) && + CrossType.Is(builtItem, out S1EntityFramework.SurfaceItem surfaceItem)) + { + placementMode = FurniturePlacementMode.Surface; + surfaceTypes = ExtractSurfaceTypes(surfaceItem, definition.ID); + allowSurfaceRotation = surfaceItem.AllowRotation; + } + else + { + throw new ArgumentException( + $"Furniture donor '{definition.ID}' uses '{builtItem.GetType().Name}'. " + + "Only presentation-only GridItem and SurfaceItem donors are supported.", + nameof(definition)); + } + + GameObject model = CreatePresentationClone(builtItem.gameObject, definition.ID); + model.hideFlags = HideFlags.HideAndDontSave; + model.SetActive(false); + Object.DontDestroyOnLoad(model); + return new FurnitureCloneSource( + definition.ID, + model, + placementMode, + footprint, + surfaceTypes, + allowSurfaceRotation, + FurnitureBuildSoundMapper.FromNative(definition.BuildSoundType), + definition.StackLimit, + definition.BasePurchasePrice, + definition.ResellMultiplier, + definition.Icon); + } + + internal static GameObject CloneOwnedVisual(GameObject source) + { + GameObject clone = InactiveObjectCloner.CloneGameObject(source); + try + { + CloneRendererMaterials(clone); + return clone; + } + catch + { + Object.DestroyImmediate(clone); + throw; + } + } + + private static GameObject CreatePresentationClone(GameObject donor, string donorId) + { + GameObject clone = InactiveObjectCloner.CloneGameObject(donor); + try + { + clone.name = $"{donorId}_FurnitureVisualSource"; + clone.transform.localPosition = Vector3.zero; + clone.transform.localRotation = Quaternion.identity; + StripRuntimeComponents(clone); + EnsureRenderable(clone, donorId); + CloneRendererMaterials(clone); + return clone; + } + catch + { + Object.DestroyImmediate(clone); + throw; + } + } + + private static void StripRuntimeComponents(GameObject root) + { + Component[] components = root.GetComponentsInChildren(true); + for (int index = components.Length - 1; index >= 0; index--) + { + Component component = components[index]; + if (component == null || IsPresentationComponent(component)) + continue; + + Object.DestroyImmediate(component); + } + + foreach (Component component in root.GetComponentsInChildren(true)) + { + if (component != null && !IsPresentationComponent(component)) + { + throw new InvalidOperationException( + $"Furniture visual extraction could not remove '{component.GetType().Name}'."); + } + } + } + + private static bool IsPresentationComponent(Component component) + { + return CrossType.Is(component, out Transform _) || + CrossType.Is(component, out Renderer _) || + CrossType.Is(component, out MeshFilter _) || + CrossType.Is(component, out LODGroup _) || + CrossType.Is(component, out Animator _) || + CrossType.Is(component, out Animation _); + } + + private static void EnsureRenderable(GameObject root, string donorId) + { + foreach (MeshRenderer renderer in root.GetComponentsInChildren(true)) + { + MeshFilter? meshFilter = renderer.GetComponent(); + if (meshFilter != null && meshFilter.sharedMesh != null) + return; + } + + if (root.GetComponentsInChildren(true).Length != 0) + return; + + throw new ArgumentException( + $"Furniture donor '{donorId}' has no supported presentation renderer.", + nameof(donorId)); + } + + private static void CloneRendererMaterials(GameObject root) + { + var materialClones = new Dictionary(); + foreach (Renderer renderer in root.GetComponentsInChildren(true)) + { + Material[] sourceMaterials = renderer.sharedMaterials; + var ownedMaterials = new Material[sourceMaterials.Length]; + for (int index = 0; index < sourceMaterials.Length; index++) + { + Material source = sourceMaterials[index]; + if (source == null) + continue; + if (!materialClones.TryGetValue(source, out Material? owned)) + { + owned = new Material(source) + { + name = source.name + "_S1API_FurnitureVariant", + }; + materialClones.Add(source, owned); + } + + ownedMaterials[index] = owned; + } + + renderer.sharedMaterials = ownedMaterials; + } + } + + private static IReadOnlyList ExtractFootprint( + S1EntityFramework.GridItem gridItem, + string donorId) + { + if (gridItem.CoordinateFootprintTilePairs == null || + gridItem.CoordinateFootprintTilePairs.Count == 0) + { + throw new ArgumentException( + $"Furniture donor '{donorId}' has no grid footprint.", + nameof(gridItem)); + } + + var footprint = new List( + gridItem.CoordinateFootprintTilePairs.Count); + var seen = new HashSet(StringComparer.Ordinal); + for (int index = 0; index < gridItem.CoordinateFootprintTilePairs.Count; index++) + { + var coordinate = gridItem.CoordinateFootprintTilePairs[index].coord; + if (coordinate == null) + { + throw new ArgumentException( + $"Furniture donor '{donorId}' has an unsupported grid footprint.", + nameof(gridItem)); + } + if (coordinate.x < 0 || coordinate.y < 0 || + !seen.Add($"{coordinate.x}:{coordinate.y}")) + { + throw new ArgumentException( + $"Furniture donor '{donorId}' has an unsupported grid footprint.", + nameof(gridItem)); + } + + footprint.Add(new FurnitureFootprintCoordinate(coordinate.x, coordinate.y)); + } + + return footprint; + } + + private static FurnitureSurfaceType ExtractSurfaceTypes( + S1EntityFramework.SurfaceItem surfaceItem, + string donorId) + { + if (surfaceItem.ValidSurfaceTypes == null || surfaceItem.ValidSurfaceTypes.Count == 0) + { + throw new ArgumentException( + $"Furniture donor '{donorId}' has no supported surfaces.", + nameof(surfaceItem)); + } + + FurnitureSurfaceType surfaceTypes = FurnitureSurfaceType.None; + for (int index = 0; index < surfaceItem.ValidSurfaceTypes.Count; index++) + { + S1Building.Surface.ESurfaceType surfaceType = surfaceItem.ValidSurfaceTypes[index]; + switch (surfaceType) + { + case S1Building.Surface.ESurfaceType.Wall: + surfaceTypes |= FurnitureSurfaceType.Wall; + break; + case S1Building.Surface.ESurfaceType.Roof: + surfaceTypes |= FurnitureSurfaceType.Roof; + break; + default: + throw new ArgumentException( + $"Furniture donor '{donorId}' uses an unsupported surface type.", + nameof(surfaceItem)); + } + } + + return surfaceTypes; + } + } +} diff --git a/S1API/Internal/CasinoEventInvoker.cs b/S1API/Internal/CasinoEventInvoker.cs new file mode 100644 index 00000000..cb38196e --- /dev/null +++ b/S1API/Internal/CasinoEventInvoker.cs @@ -0,0 +1,65 @@ +using System; +using S1API.Casino; + +namespace S1API.Internal +{ + internal static class CasinoEventInvoker + { + internal static void Invoke(Action? handlers, string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + + internal static void Invoke(Action? handlers, T value, string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(value); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + + internal static void Invoke( + Action? handlers, + T1 value1, + T2 value2, + string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(value1, value2); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + + internal static void Invoke( + Action? handlers, + T1 value1, + T2 value2, + T3 value3, + string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(value1, value2, value3); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + } +} diff --git a/S1API/Internal/Compatibility/AvatarCompatibility.cs b/S1API/Internal/Compatibility/AvatarCompatibility.cs new file mode 100644 index 00000000..89fc226c --- /dev/null +++ b/S1API/Internal/Compatibility/AvatarCompatibility.cs @@ -0,0 +1,27 @@ +#if IL2CPPMELON +using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +#elif MONOMELON +using S1AvatarFramework = ScheduleOne.AvatarFramework; +#endif + +namespace S1API.Internal.Compatibility +{ + internal static class AvatarCompatibility + { + internal static void ApplyLegacySettings( + S1AvatarFramework.Avatar avatar, + S1AvatarFramework.AvatarSettings settings) + { + if (avatar?.Appearance == null || settings == null) + return; + + var nakedAppearance = settings.EquivalentNakedAppearance; + if (nakedAppearance != null) + avatar.Appearance.ApplyNakedAppearance(nakedAppearance.Appearance); + + var outfit = settings.EquivalentOutfit; + if (outfit != null) + avatar.Appearance.ApplyOutfit(outfit); + } + } +} diff --git a/S1API/Internal/Console/PresentationWorkbenchCommand.cs b/S1API/Internal/Console/PresentationWorkbenchCommand.cs index 92b41352..abe28115 100644 --- a/S1API/Internal/Console/PresentationWorkbenchCommand.cs +++ b/S1API/Internal/Console/PresentationWorkbenchCommand.cs @@ -14,13 +14,13 @@ public PresentationWorkbenchCommand() { } - public override string CommandWord => "presentation_workbench"; + public override string CommandWord => "presentationworkbench"; public override string CommandDescription => "Open the local icon and equippable presentation authoring workbench."; public override string ExampleUsage => - "presentation_workbench [product|item] | close"; + "presentationworkbench [product|item] | close"; public override void ExecuteCommand(List args) { diff --git a/S1API/Internal/Cutscenes/NativeCutsceneAdapter.cs b/S1API/Internal/Cutscenes/NativeCutsceneAdapter.cs index 5546f553..089b2424 100644 --- a/S1API/Internal/Cutscenes/NativeCutsceneAdapter.cs +++ b/S1API/Internal/Cutscenes/NativeCutsceneAdapter.cs @@ -1,9 +1,11 @@ #if IL2CPPMELON using S1Cutscene = Il2CppScheduleOne.Cutscenes.Cutscene; +using S1CutsceneCamera = Il2CppScheduleOne.Cutscenes.CutsceneCamera; using S1PlayerCamera = Il2CppScheduleOne.PlayerScripts.PlayerCamera; using S1PlayerSingleton = Il2CppScheduleOne.DevUtilities.PlayerSingleton; #elif MONOMELON using S1Cutscene = ScheduleOne.Cutscenes.Cutscene; +using S1CutsceneCamera = ScheduleOne.Cutscenes.CutsceneCamera; using S1PlayerCamera = ScheduleOne.PlayerScripts.PlayerCamera; using S1PlayerSingleton = ScheduleOne.DevUtilities.PlayerSingleton; #endif @@ -41,12 +43,13 @@ internal bool TryBegin( _cutscene = _host.AddComponent(); _cutscene.Name = name; - _cutscene.CameraControl = _cameraControlObject.transform; - _cutscene.OverrideFOV = fov.HasValue; + var nativeCamera = _cameraControlObject.AddComponent(); + Utils.ReflectionUtils.TrySetFieldOrProperty(nativeCamera, "_controlFoV", fov.HasValue); if (fov.HasValue) { - _cutscene.CameraFOV = fov.Value; + Utils.ReflectionUtils.TrySetFieldOrProperty(nativeCamera, "_fov", fov.Value); } + _cutscene.SetActiveCameraControl(nativeCamera); _nativeStateName = $"Cutscene ({name})"; _cutscene.Play(); diff --git a/S1API/Internal/Deliveries/LoadingDockEventBridge.cs b/S1API/Internal/Deliveries/LoadingDockEventBridge.cs new file mode 100644 index 00000000..59102655 --- /dev/null +++ b/S1API/Internal/Deliveries/LoadingDockEventBridge.cs @@ -0,0 +1,56 @@ +#if IL2CPPMELON +using S1Delivery = Il2CppScheduleOne.Delivery; +using S1Vehicles = Il2CppScheduleOne.Vehicles; +#elif MONOMELON +using S1Delivery = ScheduleOne.Delivery; +using S1Vehicles = ScheduleOne.Vehicles; +#endif + +using S1API.Deliveries; +using S1API.Vehicles; + +namespace S1API.Internal.Deliveries +{ + /// + /// Converts native loading-dock state transitions into managed wrapper events. + /// + internal static class LoadingDockEventBridge + { + internal static void NotifyDynamicOccupantChanged( + S1Delivery.LoadingDock native, + S1Vehicles.LandVehicle? previous, + S1Vehicles.LandVehicle? current) + { + if (previous == current) + return; + + LoadingDock.Wrap(native).NotifyDynamicOccupantChanged( + VehicleRegistry.Wrap(previous), + VehicleRegistry.Wrap(current)); + } + + internal static void NotifyStaticOccupantChanged( + S1Delivery.LoadingDock native, + S1Vehicles.LandVehicle? previous, + S1Vehicles.LandVehicle? current) + { + if (previous == current) + return; + + LoadingDock.Wrap(native).NotifyStaticOccupantChanged( + VehicleRegistry.Wrap(previous), + VehicleRegistry.Wrap(current)); + } + + internal static void NotifyAcceptingItemsChanged( + S1Delivery.LoadingDock native, + bool previous, + bool current) + { + if (previous == current) + return; + + LoadingDock.Wrap(native).NotifyAcceptingItemsChanged(previous, current); + } + } +} diff --git a/S1API/Internal/Entities/CustomNpcPreparationPolicy.cs b/S1API/Internal/Entities/CustomNpcPreparationPolicy.cs new file mode 100644 index 00000000..3cffe733 --- /dev/null +++ b/S1API/Internal/Entities/CustomNpcPreparationPolicy.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace S1API.Internal.Entities +{ + /// + /// Selects prepared custom NPC instances for reuse across native loader phases. + /// + internal static class CustomNpcPreparationPolicy + { + internal static T? FindExactType( + IEnumerable instances, + Type requestedType) + where T : class => + instances.FirstOrDefault( + instance => instance != null && instance.GetType() == requestedType); + } +} diff --git a/S1API/Internal/Entities/CustomNpcReadinessPolicy.cs b/S1API/Internal/Entities/CustomNpcReadinessPolicy.cs new file mode 100644 index 00000000..270d6426 --- /dev/null +++ b/S1API/Internal/Entities/CustomNpcReadinessPolicy.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace S1API.Internal.Entities +{ + /// + /// Determines whether every registered custom NPC type completed runtime hydration. + /// + internal static class CustomNpcReadinessPolicy + { + internal static void MarkFinalized(Type customNpcType, ISet finalizedTypes) => + finalizedTypes.Add(customNpcType); + + internal static bool AreAllTypesFinalized( + IEnumerable customNpcTypes, + ISet finalizedTypes) + { + var expectedTypes = customNpcTypes as IReadOnlyCollection + ?? customNpcTypes.ToList(); + + return expectedTypes.Count > 0 + && expectedTypes.All(finalizedTypes.Contains); + } + } +} diff --git a/S1API/Internal/Entities/ImpostorTextureResolver.cs b/S1API/Internal/Entities/ImpostorTextureResolver.cs index c394f649..02a05004 100644 --- a/S1API/Internal/Entities/ImpostorTextureResolver.cs +++ b/S1API/Internal/Entities/ImpostorTextureResolver.cs @@ -298,11 +298,11 @@ private static void AddDefinitionsFromNpcRegistry( continue; } - S1AvatarFramework.AvatarSettings? settings = - npc.Avatar?.CurrentSettings ?? - (npc.Avatar != null - ? ReflectionUtils.TryGetFieldOrProperty(npc.Avatar, "InitialAvatarSettings") as S1AvatarFramework.AvatarSettings - : null); + S1AvatarFramework.AvatarSettings? settings = npc.Avatar != null + ? ReflectionUtils.TryGetFieldOrProperty( + npc.Avatar, + "InitialAvatarSettings") as S1AvatarFramework.AvatarSettings + : null; if (settings == null || settings.ImpostorTexture == null) { continue; diff --git a/S1API/Internal/Entities/NPCDataAccess.cs b/S1API/Internal/Entities/NPCDataAccess.cs index a20d9673..59cd00d8 100644 --- a/S1API/Internal/Entities/NPCDataAccess.cs +++ b/S1API/Internal/Entities/NPCDataAccess.cs @@ -4,6 +4,7 @@ using S1DevUtilities = Il2CppScheduleOne.DevUtilities; using S1Dialogue = Il2CppScheduleOne.Dialogue; using S1Economy = Il2CppScheduleOne.Economy; +using S1Employees = Il2CppScheduleOne.Employees; using S1ItemFramework = Il2CppScheduleOne.ItemFramework; using S1Messaging = Il2CppScheduleOne.Messaging; using S1NPCFramework = Il2CppScheduleOne.NPCs.Framework; @@ -14,6 +15,7 @@ using S1DevUtilities = ScheduleOne.DevUtilities; using S1Dialogue = ScheduleOne.Dialogue; using S1Economy = ScheduleOne.Economy; +using S1Employees = ScheduleOne.Employees; using S1ItemFramework = ScheduleOne.ItemFramework; using S1Messaging = ScheduleOne.Messaging; using S1NPCFramework = ScheduleOne.NPCs.Framework; @@ -33,10 +35,8 @@ namespace S1API.Internal.Entities { internal static class NPCDataAccess { + private static readonly Logging.Log Logger = new Logging.Log("NPCDataAccess"); #if !IL2CPPMELON - private static readonly FieldInfo NpcDataObjectField = - typeof(S1NPCs.NPC).GetField("_npcData", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingFieldException(typeof(S1NPCs.NPC).FullName, "_npcData"); private static readonly FieldInfo CurrentNpcDataField = typeof(S1NPCs.NPC).GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new MissingFieldException(typeof(S1NPCs.NPC).FullName, "k__BackingField"); @@ -204,7 +204,8 @@ internal static bool ApplyAppearance(S1NPCs.NPC npc, S1AvatarFramework.AvatarSet if (appearance == null) return false; - appearance.AvatarSettings = settings; + appearance.DefaultAppearance = settings.EquivalentNakedAppearance; + appearance.DefaultOutfit = settings.EquivalentOutfit; return true; } @@ -215,14 +216,21 @@ internal static bool ApplyDealerDefaults( if (dealer == null || data == null) return false; - if (GetOriginalData(dealer) is not S1NPCFramework.DealerNPCData dealerData) + S1NPCFramework.NPCData? originalData = GetOriginalData(dealer); + if (originalData == null + || !CrossType.Is(originalData, out S1NPCFramework.DealerNPCData dealerData)) return false; - dealerData.SigningFee = data.SigningFee; - dealerData.SalesCutPercentage = data.Cut; - dealerData.DealerType = (S1Economy.EDealerType)(int)data.DealerType; - if (!string.IsNullOrWhiteSpace(data.HomeName)) - dealerData.HomeName = data.HomeName; + ApplyDealerDefaults(dealerData, data); + + S1NPCFramework.NPCData? currentData = GetCurrentData(dealer); + if (currentData != null + && CrossType.Is(currentData, out S1NPCFramework.DealerNPCData currentDealerData) + && !ReferenceEquals(currentDealerData, dealerData)) + { + ApplyDealerDefaults(currentDealerData, data); + } + return true; } @@ -262,8 +270,19 @@ internal static void PrepareForRuntime(S1NPCs.NPC npc) if (CrossType.Is(data, out S1NPCFramework.SupplierNPCData _)) EnsureSupplierDialogueDatabase(data, required: true); - if (data is S1NPCFramework.DealerNPCData dealerData) - PopulateDealerDialogueDefaults(dealerData); + if (CrossType.Is(data, out S1NPCFramework.DealerNPCData _)) + EnsureDealerDialogueDefaults(npc, logFailure: false); + } + + private static void ApplyDealerDefaults( + S1NPCFramework.DealerNPCData dealerData, + DealerDataBuilder.DealerConfigData data) + { + dealerData.SigningFee = data.SigningFee; + dealerData.SalesCutPercentage = data.Cut; + dealerData.DealerType = (S1Economy.EDealerType)(int)data.DealerType; + if (!string.IsNullOrWhiteSpace(data.HomeName)) + dealerData.HomeName = data.HomeName; } private static void ApplySupplierDefaults( @@ -323,22 +342,16 @@ private static bool ApplyToData(S1NPCs.NPC? npc, Action internal static S1NPCFramework.BaseNPCDataObject? GetDataObject(S1NPCs.NPC npc) { -#if IL2CPPMELON - return npc._npcData; -#else - return NpcDataObjectField.GetValue(npc) as S1NPCFramework.BaseNPCDataObject; -#endif + return ReflectionUtils.TryGetFieldOrProperty( + npc, + "_defaultNPCData") as S1NPCFramework.BaseNPCDataObject; } private static void SetDataObject( S1NPCs.NPC npc, S1NPCFramework.BaseNPCDataObject dataObject) { -#if IL2CPPMELON - npc._npcData = dataObject; -#else - NpcDataObjectField.SetValue(npc, dataObject); -#endif + ReflectionUtils.TrySetFieldOrProperty(npc, "_defaultNPCData", dataObject); } private static void SetCurrentData(S1NPCs.NPC npc, S1NPCFramework.NPCData data) @@ -374,6 +387,30 @@ private static void PrepareData(S1NPCFramework.NPCData data) if (CrossType.Is(data, out S1NPCFramework.SupplierNPCData supplierData)) supplierData.DeliveryShopListings ??= Array.Empty(); #endif + + EnsureMovementDefaults(data); + } + + /// + /// A freshly created NPCDataObject has no MovementPreset assigned, so + /// NPCData.Movement falls back to the hardcoded class default (WalkSpeed 1.8), + /// which is noticeably faster than every vanilla NPC archetype - they're all tuned + /// to a slower shared preset (observed: WalkSpeed 1.2). Source the real value from + /// any loaded preset so custom NPCs match vanilla walking pace. + /// + private static void EnsureMovementDefaults(S1NPCFramework.NPCData data) + { + S1NPCFramework.Movement movement = data.Movement; + if (movement == null) + return; + + S1NPCFramework.Movement? donor = Resources + .FindObjectsOfTypeAll() + .Select(preset => preset?.GetValue()) + .FirstOrDefault(value => value != null); + + movement.WalkSpeed = donor?.WalkSpeed ?? 1.2f; + movement.MaxSpeed = donor?.MaxSpeed ?? movement.MaxSpeed; } private static void EnsureDialogueDatabase( @@ -387,15 +424,19 @@ private static void EnsureDialogueDatabase( return; S1NPCFramework.NPCData? sourceData = GetOriginalData(sourceNpc) ?? GetCurrentData(sourceNpc); - if (sourceData?.Dialogue?.DialogueDatabase != null) + bool sourceIsEmployee = sourceNpc is S1Employees.Employee; + if (ShouldReuseSourceDialogueDatabase(sourceIsEmployee) + && sourceData?.Dialogue?.DialogueDatabase != null) + { data.Dialogue.DialogueDatabase = sourceData.Dialogue.DialogueDatabase; + } if (data.Dialogue.DialogueDatabase == null) { S1Dialogue.DialogueManager manager = S1DevUtilities.Singleton.Instance; if (manager != null) - data.Dialogue.DialogueDatabase = manager.DefaultDatabase; + data.Dialogue.DialogueDatabase = manager.DefaultDialogueDatabase; } if (data.Dialogue.DialogueDatabase == null) @@ -407,9 +448,24 @@ private static void EnsureDialogueDatabase( } if (data.Dialogue.DialogueDatabase == null) - throw new InvalidOperationException("No 0.4.6 dialogue database is loaded for the custom NPC."); + throw new InvalidOperationException("No dialogue database is loaded for the custom NPC."); + + if (sourceIsEmployee) + { + Logger.Debug( + $"[S1API][BaseEmployeeFallback][Dialogue] Rebased employee source dialogue " + + $"'{sourceData?.Dialogue?.DialogueDatabase?.name ?? ""}' to " + + $"'{data.Dialogue.DialogueDatabase.name}' for the replacement NPC data."); + } } + /// + /// Determines whether a source NPC's dialogue database may be inherited by a rebuilt custom NPC. + /// Employee databases contain employee-only greeting and transfer content, so the BaseEmployee + /// fallback must instead resolve the native default database for the replacement NPC role. + /// + internal static bool ShouldReuseSourceDialogueDatabase(bool sourceIsEmployee) => !sourceIsEmployee; + private static void EnsureSupplierDialogueDatabase( S1NPCFramework.NPCData data, bool required) @@ -542,33 +598,422 @@ private static void SetSupplierDialogueLines( } } - private static void PopulateDealerDialogueDefaults(S1NPCFramework.DealerNPCData dealerData) + internal static bool EnsureDealerDialogueDefaults(S1NPCs.NPC npc, bool logFailure = true) { - if (dealerData.RecruitDialogue != null - && dealerData.CollectCashDialogue != null - && dealerData.AssignCustomersDialogue != null) + if (npc == null) + return false; + + var targets = new List(); + AddDealerDataTarget(targets, GetOriginalData(npc)); + AddDealerDataTarget(targets, GetCurrentData(npc)); + if (CrossType.Is(npc, out S1Economy.Dealer dealer) + && dealer.DealerData != null) + targets.Add(dealer.DealerData); + + bool ready = targets.Count > 0; + foreach (S1NPCFramework.DealerNPCData target in targets) + ready &= PopulateDealerDialogueDefaults(target); + + if (!ready && logFailure) { - return; + Logger.Error( + $"Dealer '{GetId(npc)}' has no complete native dealer dialogue template. " + + "Recruitment, cash collection, or customer assignment dialogue may be unavailable."); + } + + return ready; + } + + private static void AddDealerDataTarget( + ICollection targets, + S1NPCFramework.NPCData? data) + { + if (data != null + && CrossType.Is(data, out S1NPCFramework.DealerNPCData dealerData)) + { + targets.Add(dealerData); + } + } + + internal static bool HasCompleteDealerDialogueSet( + bool hasRecruitDialogue, + bool hasCollectCashDialogue, + bool hasAssignCustomersDialogue) => + hasRecruitDialogue && hasCollectCashDialogue && hasAssignCustomersDialogue; + + // Despite the dealer context, this is the stable name of the vanilla recruitment asset. + internal const string DealerRecruitDialogueName = "Supplier_Recruitment"; + internal const string DealerCollectCashDialogueName = "Dealer_CollectCash"; + internal const string DealerAssignCustomersDialogueName = "Dealer_AssignCustomers"; + + private static S1Dialogue.Conversation? _fallbackRecruitDialogue; + private static S1Dialogue.Conversation? _fallbackCollectCashDialogue; + private static S1Dialogue.Conversation? _fallbackAssignCustomersDialogue; + private static S1Dialogue.Conversation? _cachedRecruitDialogue; + private static S1Dialogue.Conversation? _cachedCollectCashDialogue; + private static S1Dialogue.Conversation? _cachedAssignCustomersDialogue; + + private static bool PopulateDealerDialogueDefaults(S1NPCFramework.DealerNPCData dealerData) + { + if (HasCompleteDealerDialogueSet( + dealerData.RecruitDialogue != null, + dealerData.CollectCashDialogue != null, + dealerData.AssignCustomersDialogue != null)) + { + CacheDealerDialogueDefaults(dealerData); + return true; + } + + if (TryCopyCachedDealerDialogueDefaults(dealerData)) + return true; + + // DealerNPCDataObject assets are no longer reliably enumerated in 0.4.6, but the + // loaded native Dealer components still expose their complete current/original data. + foreach (S1Economy.Dealer donorDealer in + Resources.FindObjectsOfTypeAll()) + { + if (donorDealer == null) + continue; + + S1NPCFramework.DealerNPCData? source = donorDealer.DealerData; + if (source == null) + TryGetDealerData(GetOriginalData(donorDealer), out source); + if (!TryCopyDealerDialogueDefaults(dealerData, source)) + continue; + + CacheDealerDialogueDefaults(source!); + return true; } S1NPCFramework.DealerNPCDataObject[] donors = Resources.FindObjectsOfTypeAll(); foreach (S1NPCFramework.DealerNPCDataObject donor in donors) { - if (donor == null || donor.GetOriginalData() is not S1NPCFramework.DealerNPCData source) + if (donor == null) continue; - dealerData.RecruitDialogue ??= source.RecruitDialogue; - dealerData.CollectCashDialogue ??= source.CollectCashDialogue; - dealerData.AssignCustomersDialogue ??= source.AssignCustomersDialogue; + TryGetDealerData(donor.GetOriginalData(), out S1NPCFramework.DealerNPCData? source); + if (source == null) + TryGetDealerData(donor.GetRuntimeData(), out source); + if (!TryCopyDealerDialogueDefaults(dealerData, source)) + continue; - if (dealerData.RecruitDialogue != null - && dealerData.CollectCashDialogue != null - && dealerData.AssignCustomersDialogue != null) - { - return; - } + CacheDealerDialogueDefaults(source!); + return true; + } + + // 0.4.6 no longer guarantees that native DealerNPCDataObject assets are surfaced by + // FindObjectsOfTypeAll. Their referenced DialogueContainer assets are still loaded for + // base-game dealers, so resolve the same three vanilla assets by stable asset name. + S1Dialogue.Conversation[] dialogues = + Resources.FindObjectsOfTypeAll(); + if (dealerData.RecruitDialogue == null) + dealerData.RecruitDialogue = + FindDialogueContainer(dialogues, DealerRecruitDialogueName); + if (dealerData.CollectCashDialogue == null) + dealerData.CollectCashDialogue = + FindDialogueContainer(dialogues, DealerCollectCashDialogueName); + if (dealerData.AssignCustomersDialogue == null) + dealerData.AssignCustomersDialogue = + FindDialogueContainer(dialogues, DealerAssignCustomersDialogueName); + + // On 0.4.6 the three containers can be absent from Unity's loaded-object set until a + // native dealer has already initialized. Custom NPCs spawn earlier than that in some + // saves, so preserve the native dialogue graph as an always-available final fallback. + if (dealerData.RecruitDialogue == null) + dealerData.RecruitDialogue = GetFallbackRecruitDialogue(); + if (dealerData.CollectCashDialogue == null) + dealerData.CollectCashDialogue = GetFallbackCollectCashDialogue(); + if (dealerData.AssignCustomersDialogue == null) + dealerData.AssignCustomersDialogue = GetFallbackAssignCustomersDialogue(); + + bool ready = HasCompleteDealerDialogueSet( + dealerData.RecruitDialogue != null, + dealerData.CollectCashDialogue != null, + dealerData.AssignCustomersDialogue != null); + if (!ready) + { + Logger.Error( + "[S1API][DealerDialogue] Failed to persist fallback containers on DealerNPCData. " + + $"RecruitManagedNull={ReferenceEquals(dealerData.RecruitDialogue, null)}, " + + $"CollectManagedNull={ReferenceEquals(dealerData.CollectCashDialogue, null)}, " + + $"AssignManagedNull={ReferenceEquals(dealerData.AssignCustomersDialogue, null)}, " + + $"FallbackRecruitManagedNull={ReferenceEquals(_fallbackRecruitDialogue, null)}, " + + $"FallbackCollectManagedNull={ReferenceEquals(_fallbackCollectCashDialogue, null)}, " + + $"FallbackAssignManagedNull={ReferenceEquals(_fallbackAssignCustomersDialogue, null)}."); + } + else + { + CacheDealerDialogueDefaults(dealerData); + } + + return ready; + } + + private static bool TryCopyCachedDealerDialogueDefaults( + S1NPCFramework.DealerNPCData target) + { + if (!HasCompleteDealerDialogueSet( + _cachedRecruitDialogue != null, + _cachedCollectCashDialogue != null, + _cachedAssignCustomersDialogue != null)) + { + return false; + } + + if (target.RecruitDialogue == null) + target.RecruitDialogue = _cachedRecruitDialogue; + if (target.CollectCashDialogue == null) + target.CollectCashDialogue = _cachedCollectCashDialogue; + if (target.AssignCustomersDialogue == null) + target.AssignCustomersDialogue = _cachedAssignCustomersDialogue; + + return HasCompleteDealerDialogueSet( + target.RecruitDialogue != null, + target.CollectCashDialogue != null, + target.AssignCustomersDialogue != null); + } + + private static void CacheDealerDialogueDefaults( + S1NPCFramework.DealerNPCData source) + { + if (!HasCompleteDealerDialogueSet( + source.RecruitDialogue != null, + source.CollectCashDialogue != null, + source.AssignCustomersDialogue != null)) + { + return; + } + + _cachedRecruitDialogue = source.RecruitDialogue; + _cachedCollectCashDialogue = source.CollectCashDialogue; + _cachedAssignCustomersDialogue = source.AssignCustomersDialogue; + } + + private static bool TryGetDealerData( + S1NPCFramework.NPCData? data, + out S1NPCFramework.DealerNPCData? dealerData) + { + if (data != null + && CrossType.Is(data, out S1NPCFramework.DealerNPCData typedData)) + { + dealerData = typedData; + return true; + } + + dealerData = null; + return false; + } + + private static bool TryCopyDealerDialogueDefaults( + S1NPCFramework.DealerNPCData target, + S1NPCFramework.DealerNPCData? source) + { + if (source == null + || !HasCompleteDealerDialogueSet( + source.RecruitDialogue != null, + source.CollectCashDialogue != null, + source.AssignCustomersDialogue != null)) + { + return false; + } + + if (target.RecruitDialogue == null) + target.RecruitDialogue = source.RecruitDialogue; + if (target.CollectCashDialogue == null) + target.CollectCashDialogue = source.CollectCashDialogue; + if (target.AssignCustomersDialogue == null) + target.AssignCustomersDialogue = source.AssignCustomersDialogue; + return HasCompleteDealerDialogueSet( + target.RecruitDialogue != null, + target.CollectCashDialogue != null, + target.AssignCustomersDialogue != null); + } + + private static S1Dialogue.Conversation? FindDialogueContainer( + IEnumerable dialogues, + string name) => + dialogues.FirstOrDefault(dialogue => + dialogue != null + && string.Equals(dialogue.name, name, StringComparison.Ordinal)); + + private static S1Dialogue.Conversation GetFallbackRecruitDialogue() + { + if (_fallbackRecruitDialogue != null) + return _fallbackRecruitDialogue; + + const string entryGuid = "e53bd440-29c4-4dce-a5cd-7415dcda83f8"; + const string confirmGuid = "4854545e-b2f9-4cba-9703-94e68c058352"; + const string exitGuid = "1575094b-13a3-4d54-9c06-136b571f8c51"; + const string acceptedGuid = "dbffb23a-034f-4d0f-9815-36cc247335b6"; + + S1Dialogue.Conversation dialogue = CreateDialogueContainer( + DealerRecruitDialogueName); + AddDialogueNode( + dialogue, + CreateDialogueNode( + entryGuid, + "Alright, I'll be a dealer for you but there is a signing fee, " + + "and I'll take a cut of each sale. We got a deal?", + "ENTRY", + new Vector2(416f, 458f), + CreateDialogueChoices( + CreateDialogueChoice(confirmGuid, "Deal ()", "CONFIRM"), + CreateDialogueChoice(exitGuid, "Nevermind", string.Empty)))); + AddDialogueNode( + dialogue, + CreateDialogueNode( + acceptedGuid, + "Thank you. Bring me some product and assign some customers to me, and I'll get to work.", + string.Empty, + new Vector2(1313f, 566f), + CreateDialogueChoices())); + AddNodeLink(dialogue, entryGuid, confirmGuid, acceptedGuid); + + _fallbackRecruitDialogue = dialogue; + return dialogue; + } + + private static S1Dialogue.Conversation GetFallbackCollectCashDialogue() + { + if (_fallbackCollectCashDialogue != null) + return _fallbackCollectCashDialogue; + + S1Dialogue.Conversation dialogue = CreateDialogueContainer( + DealerCollectCashDialogueName); + AddDialogueNode( + dialogue, + CreateDialogueNode( + "4057413b-2c8a-411d-a9a0-d8e606558846", + "No worries, here you are.", + "ENTRY", + new Vector2(765f, 412f), + CreateDialogueChoices())); + + _fallbackCollectCashDialogue = dialogue; + return dialogue; + } + + private static S1Dialogue.Conversation GetFallbackAssignCustomersDialogue() + { + if (_fallbackAssignCustomersDialogue != null) + return _fallbackAssignCustomersDialogue; + + S1Dialogue.Conversation dialogue = CreateDialogueContainer( + DealerAssignCustomersDialogueName); + AddDialogueNode( + dialogue, + CreateDialogueNode( + "56a2eae5-0e64-4ca1-b296-148df22793f9", + "You can assign customers to me with the dealer management app on your phone.", + "ENTRY", + new Vector2(622f, 287f), + CreateDialogueChoices())); + + _fallbackAssignCustomersDialogue = dialogue; + return dialogue; + } + + private static S1Dialogue.Conversation CreateDialogueContainer(string name) + { + S1Dialogue.Conversation dialogue = + ScriptableObject.CreateInstance(); + if (dialogue == null) + throw new InvalidOperationException($"Failed to create dealer dialogue '{name}'."); + + dialogue.name = name; + dialogue.hideFlags = HideFlags.DontUnloadUnusedAsset; + return dialogue; + } + + private static S1Dialogue.DialogueNodeData CreateDialogueNode( + string guid, + string text, + string label, + Vector2 position, +#if IL2CPPMELON + Il2CppReferenceArray choices) +#else + S1Dialogue.DialogueChoiceData[] choices) +#endif + { + return new S1Dialogue.DialogueNodeData + { + Guid = guid, + DialogueText = text, + DialogueNodeLabel = label, + Position = position, + choices = choices, + VoiceLine = S1VoiceOver.EVOLineType.None + }; + } + + private static S1Dialogue.DialogueChoiceData CreateDialogueChoice( + string guid, + string text, + string label) => + new S1Dialogue.DialogueChoiceData + { + Guid = guid, + ChoiceText = text, + ChoiceLabel = label, + ShowWorldspaceDialogue = true + }; + +#if IL2CPPMELON + private static Il2CppReferenceArray CreateDialogueChoices( + params S1Dialogue.DialogueChoiceData[] choices) + { + var result = new Il2CppReferenceArray(choices.Length); + for (int i = 0; i < choices.Length; i++) + result[i] = choices[i]; + return result; + } +#else + private static S1Dialogue.DialogueChoiceData[] CreateDialogueChoices( + params S1Dialogue.DialogueChoiceData[] choices) => choices; +#endif + + private static void AddDialogueNode( + S1Dialogue.Conversation dialogue, + S1Dialogue.DialogueNodeData node) + { + if (dialogue.DialogueNodeData == null) + { +#if IL2CPPMELON + dialogue.DialogueNodeData = + new Il2CppSystem.Collections.Generic.List(); +#else + dialogue.DialogueNodeData = new List(); +#endif } + + dialogue.DialogueNodeData.Add(node); + } + + private static void AddNodeLink( + S1Dialogue.Conversation dialogue, + string baseNodeGuid, + string baseChoiceGuid, + string targetNodeGuid) + { + if (dialogue.NodeLinks == null) + { +#if IL2CPPMELON + dialogue.NodeLinks = + new Il2CppSystem.Collections.Generic.List(); +#else + dialogue.NodeLinks = new List(); +#endif + } + + dialogue.NodeLinks.Add( + new S1Dialogue.NodeLinkData + { + BaseDialogueOrBranchNodeGuid = baseNodeGuid, + BaseChoiceOrOptionGUID = baseChoiceGuid, + TargetNodeGuid = targetNodeGuid + }); } } } diff --git a/S1API/Internal/Entities/NPCHealthRevivePolicy.cs b/S1API/Internal/Entities/NPCHealthRevivePolicy.cs new file mode 100644 index 00000000..aad2c61c --- /dev/null +++ b/S1API/Internal/Entities/NPCHealthRevivePolicy.cs @@ -0,0 +1,19 @@ +namespace S1API.Internal.Entities +{ + /// + /// INTERNAL: Selects the safe revive path for custom NPCs during FishNet initialization. + /// + internal static class NPCHealthRevivePolicy + { + internal static bool ShouldUsePreSpawnFallback( + bool isInMainScene, + bool isCustomNpc, + bool isSpawned) => + isInMainScene && isCustomNpc && !isSpawned; + + internal static bool ShouldSuppressSpawnedClientRevive( + bool isSpawned, + bool isServer) => + isSpawned && !isServer; + } +} diff --git a/S1API/Internal/Entities/NPCNetworkBootstrap.cs b/S1API/Internal/Entities/NPCNetworkBootstrap.cs index fbc27128..f3c7ca95 100644 --- a/S1API/Internal/Entities/NPCNetworkBootstrap.cs +++ b/S1API/Internal/Entities/NPCNetworkBootstrap.cs @@ -131,7 +131,7 @@ public static void OnMainSceneInitialized() var spawnables = nm?.SpawnablePrefabs; if (spawnables != null) { - NPC.PreRegisterAllNpcPrefabs(); + NPC.PreRegisterAllNpcPrefabsInternal(); if (!AllNetworkPrefabsReady) EnsurePrefabsWarmup(); } @@ -169,7 +169,7 @@ private static IEnumerator PrefabWarmupCoroutine() { try { - NPC.PreRegisterAllNpcPrefabs(); + NPC.PreRegisterAllNpcPrefabsInternal(); if (AllNetworkPrefabsReady) break; } diff --git a/S1API/Internal/Entities/NPCPersistentIds.cs b/S1API/Internal/Entities/NPCPersistentIds.cs new file mode 100644 index 00000000..6f995383 --- /dev/null +++ b/S1API/Internal/Entities/NPCPersistentIds.cs @@ -0,0 +1,35 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace S1API.Internal.Entities +{ + /// + /// Creates stable native identifiers for custom NPCs that participate in persisted game systems. + /// + internal static class NPCPersistentIds + { + internal static bool TryGetGuid(string? npcId, out Guid guid) + { + if (string.IsNullOrWhiteSpace(npcId)) + { + guid = Guid.Empty; + return false; + } + + byte[] hash; + using (SHA256 algorithm = SHA256.Create()) + { + string value = $"S1API.NPC:v1:{npcId.Trim().ToLowerInvariant()}"; + hash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(value)); + } + + var guidBytes = new byte[16]; + Array.Copy(hash, guidBytes, guidBytes.Length); + guidBytes[6] = (byte)((guidBytes[6] & 0x0f) | 0x50); + guidBytes[8] = (byte)((guidBytes[8] & 0x3f) | 0x80); + guid = new Guid(guidBytes); + return true; + } + } +} diff --git a/S1API/Internal/Entities/NPCPrefabIdentity.cs b/S1API/Internal/Entities/NPCPrefabIdentity.cs index d3850610..52b1a1e5 100644 --- a/S1API/Internal/Entities/NPCPrefabIdentity.cs +++ b/S1API/Internal/Entities/NPCPrefabIdentity.cs @@ -47,6 +47,7 @@ internal sealed class NPCPrefabIdentity : MonoBehaviour private string? _dealerHomeBuildingName; private string? _prefabName; private List? _connectionIds; + private bool _hasConfiguredConnections; private string? _voiceId; private bool _hasVoicePitch; private float _voicePitch; @@ -60,6 +61,7 @@ internal sealed class NPCPrefabIdentity : MonoBehaviour [SerializeField] private string? _dealerHomeBuildingName; [SerializeField] private string? _prefabName; [SerializeField] private List? _connectionIds; + [SerializeField] private bool _hasConfiguredConnections; [SerializeField] private string? _voiceId; [SerializeField] private bool _hasVoicePitch; [SerializeField] private float _voicePitch; @@ -174,6 +176,7 @@ private struct IdentityData internal float? RelationDelta; internal bool? Unlocked; internal int? UnlockType; // Stored as int (0=Recommendation, 1=DirectApproach) to avoid enum dependency + internal bool HasConfiguredConnections; internal List? ConnectionIDs; internal string? PrefabName; internal string? VoiceId; @@ -227,8 +230,9 @@ internal static void RegisterRelationshipDataToStaticCache(string prefabName, NP float? relationDelta = snapshot?.RelationDelta; bool? unlocked = snapshot?.Unlocked; NPCRelationship.UnlockType? unlockType = snapshot?.UnlockType; - List? connectionIDs = snapshot?.ConnectionIDs != null && snapshot.ConnectionIDs.Count > 0 - ? new List(snapshot.ConnectionIDs) + bool hasConfiguredConnections = snapshot?.ConnectionsConfigured == true; + List? connectionIDs = hasConfiguredConnections + ? new List(snapshot?.ConnectionIDs ?? new List()) : null; // Get or create identity data entry @@ -245,8 +249,11 @@ internal static void RegisterRelationshipDataToStaticCache(string prefabName, NP updatedData.Unlocked = unlocked; if (unlockType.HasValue) updatedData.UnlockType = (int?)unlockType.Value; - if (connectionIDs != null && connectionIDs.Count > 0) - updatedData.ConnectionIDs = new List(connectionIDs); + if (hasConfiguredConnections) + { + updatedData.HasConfiguredConnections = true; + updatedData.ConnectionIDs = new List(connectionIDs!); + } _registry[normalizedName] = updatedData; } @@ -318,17 +325,18 @@ internal void RegisterToStaticCache(string prefabName) } // ALWAYS preserve connection IDs from registry if they exist (they come from RegisterRelationshipDataToStaticCache) - if (existingData.ConnectionIDs != null && existingData.ConnectionIDs.Count > 0) + if (existingData.HasConfiguredConnections) { - connectionIDs = new List(existingData.ConnectionIDs); + connectionIDs = new List( + existingData.ConnectionIDs ?? new List()); } } // Only use component field if registry doesn't have connection IDs // (Component field is typically empty during prefab configuration, but check it as fallback) - if ((connectionIDs == null || connectionIDs.Count == 0) && _connectionIds != null && _connectionIds.Count > 0) + if (connectionIDs == null && _hasConfiguredConnections) { - connectionIDs = new List(_connectionIds); + connectionIDs = new List(_connectionIds ?? new List()); } var identityData = new IdentityData @@ -343,6 +351,7 @@ internal void RegisterToStaticCache(string prefabName) RelationDelta = relationDelta, Unlocked = unlocked, UnlockType = unlockType, + HasConfiguredConnections = connectionIDs != null, ConnectionIDs = connectionIDs, PrefabName = normalizedName, VoiceId = VoiceId, @@ -453,6 +462,7 @@ private void TryRestoreFromRegistry() this.RelationDelta = dataRef.RelationDelta; this.Unlocked = dataRef.Unlocked; this.UnlockType = dataRef.UnlockType.HasValue ? (NPCRelationship.UnlockType?)dataRef.UnlockType.Value : null; + _hasConfiguredConnections = dataRef.HasConfiguredConnections; _connectionIds = dataRef.ConnectionIDs != null ? new List(dataRef.ConnectionIDs) : null; PrefabName = dataRef.PrefabName ?? PrefabName; if (string.IsNullOrEmpty(VoiceId) && !string.IsNullOrEmpty(dataRef.VoiceId)) @@ -608,10 +618,9 @@ internal void ApplyRelationshipDataTo(S1NPCs.NPC npc, bool preserveUnlockState = if (UnlockType.HasValue) builder.SetUnlockType(UnlockType.Value); - if (_connectionIds != null && _connectionIds.Count > 0) - { - builder.WithConnectionsById(_connectionIds); - } + if (_hasConfiguredConnections) + builder.WithConnectionsById( + (IEnumerable?)_connectionIds ?? Array.Empty()); builder.ApplyTo(relationData, npc, preserveUnlockState); } @@ -649,7 +658,7 @@ internal void ApplyTo(S1NPCs.NPC npc) if (avatar != null && AppearanceDefaults != null) { EnsureAppearanceImpostorTexture(npc.ID ?? PrefabName ?? gameObject.name); - avatar.LoadAvatarSettings(AppearanceDefaults); + Compatibility.AvatarCompatibility.ApplyLegacySettings(avatar, AppearanceDefaults); } } catch { } @@ -926,13 +935,14 @@ internal void ApplyRelationshipConnectionsTo(S1NPCs.NPC npc) return; EnsureRelationshipDataFromRegistry(); - if (_connectionIds == null || _connectionIds.Count == 0) + if (!_hasConfiguredConnections) return; try { var builder = new NPCRelationshipDataBuilder(); - builder.WithConnectionsById(_connectionIds); + builder.WithConnectionsById( + (IEnumerable?)_connectionIds ?? Array.Empty()); builder.ApplyTo(npc.RelationData, npc, preserveUnlockState: true); } catch (Exception ex) @@ -941,6 +951,23 @@ internal void ApplyRelationshipConnectionsTo(S1NPCs.NPC npc) } } +#if IL2CPPMELON + [HideFromIl2Cpp] +#endif + internal IReadOnlyList GetConfiguredConnectionIds() + { + EnsureRelationshipDataFromRegistry(); + return _connectionIds == null + ? Array.Empty() + : new List(_connectionIds); + } + + internal bool HasConfiguredConnections() + { + EnsureRelationshipDataFromRegistry(); + return _hasConfiguredConnections; + } + internal bool ApplyAppearanceTo(S1NPCs.NPC npc, S1AvatarFramework.Avatar avatar) { if (npc == null || avatar == null) @@ -953,7 +980,7 @@ internal bool ApplyAppearanceTo(S1NPCs.NPC npc, S1AvatarFramework.Avatar avatar) EnsureAppearanceImpostorTexture(npc.ID ?? PrefabName ?? gameObject.name); NPCDataAccess.ApplyAppearance(npc, AppearanceDefaults); - avatar.LoadAvatarSettings(AppearanceDefaults); + Compatibility.AvatarCompatibility.ApplyLegacySettings(avatar, AppearanceDefaults); return true; } diff --git a/S1API/Internal/Entities/NPCRelationshipGraphPolicy.cs b/S1API/Internal/Entities/NPCRelationshipGraphPolicy.cs new file mode 100644 index 00000000..2d3aab39 --- /dev/null +++ b/S1API/Internal/Entities/NPCRelationshipGraphPolicy.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace S1API.Internal.Entities +{ + internal static class NPCRelationshipGraphPolicy + { + internal static IReadOnlyList BuildUndirectedConnectionIds( + string ownerId, + IReadOnlyDictionary> declarations) + { + if (string.IsNullOrWhiteSpace(ownerId) || declarations == null) + return Array.Empty(); + + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + if (declarations.TryGetValue(ownerId, out IReadOnlyList? outgoing)) + { + foreach (string id in outgoing) + { + if (!string.IsNullOrWhiteSpace(id) + && !string.Equals(id, ownerId, StringComparison.OrdinalIgnoreCase)) + { + result.Add(id); + } + } + } + + foreach (KeyValuePair> declaration in declarations) + { + if (string.Equals(declaration.Key, ownerId, StringComparison.OrdinalIgnoreCase) + || declaration.Value == null + || !declaration.Value.Contains(ownerId, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + result.Add(declaration.Key); + } + + return result.OrderBy(id => id, StringComparer.OrdinalIgnoreCase).ToArray(); + } + } +} diff --git a/S1API/Internal/Entities/NPCRelationshipPersistencePolicy.cs b/S1API/Internal/Entities/NPCRelationshipPersistencePolicy.cs new file mode 100644 index 00000000..b0eee7cc --- /dev/null +++ b/S1API/Internal/Entities/NPCRelationshipPersistencePolicy.cs @@ -0,0 +1,11 @@ +namespace S1API.Internal.Entities +{ + internal static class NPCRelationshipPersistencePolicy + { + internal static bool IsValidSavedDelta(float relationDelta) => + !float.IsNaN(relationDelta) && !float.IsInfinity(relationDelta); + + internal static bool ShouldApplyDefaults(bool relationshipLoadedFromSave) => + !relationshipLoadedFromSave; + } +} diff --git a/S1API/Internal/Entities/NpcRoleDeclaration.cs b/S1API/Internal/Entities/NpcRoleDeclaration.cs new file mode 100644 index 00000000..ee98902b --- /dev/null +++ b/S1API/Internal/Entities/NpcRoleDeclaration.cs @@ -0,0 +1,117 @@ +using S1API.Entities; +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace S1API.Internal.Entities +{ + internal readonly struct NpcRoleDeclaration + { + internal bool IsPhysical { get; } + internal bool IsCustomer { get; } + internal bool IsDealer { get; } + internal bool IsSupplier { get; } + + internal NpcRootRole RootRole => + IsSupplier + ? NpcRootRole.Supplier + : IsDealer + ? NpcRootRole.Dealer + : NpcRootRole.Plain; + + internal NpcRoleDeclaration( + bool isPhysical, + bool isCustomer, + bool isDealer, + bool isSupplier) + { + IsPhysical = isPhysical; + IsCustomer = isCustomer; + IsDealer = isDealer; + IsSupplier = isSupplier; + } + + internal NpcRoleDeclaration WithCompatibilityRoles( + bool isCustomer, + bool isDealer, + bool isSupplier) => + new NpcRoleDeclaration( + IsPhysical, + IsCustomer || isCustomer, + IsDealer || isDealer, + IsSupplier || isSupplier); + + internal NpcRoleDeclaration Validate(Type npcType) + { + string typeName = npcType.FullName ?? npcType.Name; + if (IsDealer && IsSupplier) + { + throw new InvalidOperationException( + $"Custom NPC type '{typeName}' cannot be both a dealer and a supplier root."); + } + + if (IsSupplier && !IsPhysical) + { + throw new InvalidOperationException( + $"Custom supplier type '{typeName}' must override IsPhysical to return true."); + } + + return this; + } + } + + internal static class NpcRoleDeclarationResolver + { + private static readonly Dictionary TypeDeclarations = + new Dictionary(); + private static readonly object DeclarationLock = new object(); + + internal static NpcRoleDeclaration GetDeclaredProperties(Type npcType) + { + if (npcType == null) + throw new ArgumentNullException(nameof(npcType)); + if (!typeof(NPC).IsAssignableFrom(npcType)) + { + throw new ArgumentException( + $"Type '{npcType.FullName}' does not derive from {typeof(NPC).FullName}.", + nameof(npcType)); + } + + lock (DeclarationLock) + { + if (TypeDeclarations.TryGetValue(npcType, out var declaration)) + return declaration; + + var npc = (NPC)FormatterServices.GetUninitializedObject(npcType); + declaration = new NpcRoleDeclaration( + ReadProperty(npc, npcType, nameof(NPC.IsPhysical), value => value.IsPhysical), + ReadProperty(npc, npcType, nameof(NPC.IsCustomer), value => value.IsCustomer), + ReadProperty(npc, npcType, nameof(NPC.IsDealer), value => value.IsDealer), + ReadProperty(npc, npcType, nameof(NPC.IsSupplier), value => value.IsSupplier)); + TypeDeclarations[npcType] = declaration; + return declaration; + } + } + + private static bool ReadProperty( + NPC npc, + Type npcType, + string propertyName, + Func read) + { + try + { + return read(npc); + } + catch (Exception ex) + { + string typeName = npcType.FullName ?? npcType.Name; + throw new InvalidOperationException( + $"Custom NPC type '{typeName}' could not evaluate declarative property " + + $"'{propertyName}' from an uninitialized instance. NPC role properties must " + + "be stable, side-effect-free values that do not depend on constructor or field initialization.", + ex); + } + } + } +} diff --git a/S1API/Internal/Entities/SupplierRuntimeCoordinator.cs b/S1API/Internal/Entities/SupplierRuntimeCoordinator.cs index 50c94ba6..1d1468ae 100644 --- a/S1API/Internal/Entities/SupplierRuntimeCoordinator.cs +++ b/S1API/Internal/Entities/SupplierRuntimeCoordinator.cs @@ -76,7 +76,9 @@ internal static bool EnsureReady( try { - string stableId = SupplierRuntimeIds.ResolveStableId(supplier.gameObject, supplierId); + string stableId = SupplierRuntimeIds.ResolveStableId( + supplier.gameObject, + config?.PersistentId ?? supplierId); SupplierStashRuntime.Bind( supplier, stableId, diff --git a/S1API/Internal/Entities/Suppliers/SupplierStashRuntime.cs b/S1API/Internal/Entities/Suppliers/SupplierStashRuntime.cs index 578b7c69..fd7104b7 100644 --- a/S1API/Internal/Entities/Suppliers/SupplierStashRuntime.cs +++ b/S1API/Internal/Entities/Suppliers/SupplierStashRuntime.cs @@ -343,15 +343,18 @@ private static void BindDeadDrop( } } - S1Economy.SupplierStash stash = - nativeDeadDrop.GetComponent() ?? - nativeDeadDrop.gameObject.AddComponent(); S1Storage.StorageEntityInteractable interactable = nativeDeadDrop.Storage.GetComponent() ?? nativeDeadDrop.Storage.GetComponentInChildren(true) ?? throw new InvalidOperationException( $"Dead drop '{deadDrop.Name}' has no storage interaction component."); + RemoveGeneratedPrefabStash(supplier); + + S1Economy.SupplierStash stash = + nativeDeadDrop.GetComponent() ?? + nativeDeadDrop.gameObject.AddComponent(); + stash.Supplier = supplier; stash.Storage = nativeDeadDrop.Storage; stash.IntObj = interactable; @@ -364,6 +367,30 @@ private static void BindDeadDrop( ConfiguredStashGuids[supplierKey] = deadDrop.GUID; } + private static void RemoveGeneratedPrefabStash(S1Economy.Supplier supplier) + { + int supplierKey = supplier.GetInstanceID(); + GameObject? generatedStashObject = null; + if (OwnedStashes.TryGetValue(supplierKey, out GameObject? ownedStash)) + { + generatedStashObject = ownedStash; + OwnedStashes.Remove(supplierKey); + } + + S1Economy.SupplierStash? generatedStash = + generatedStashObject?.GetComponent() ?? + FindInHierarchy(supplier.gameObject); + generatedStashObject ??= generatedStash?.gameObject; + if (generatedStashObject == null) + return; + + if (generatedStash?.IntObj != null) + generatedStash.IntObj.enabled = false; + + generatedStashObject.SetActive(false); + Destroy(generatedStashObject); + } + internal static bool IsReservedDeadDrop(S1Economy.DeadDrop deadDrop) { if (deadDrop == null) diff --git a/S1API/Internal/Interaction/InteractionPromptRuntime.cs b/S1API/Internal/Interaction/InteractionPromptRuntime.cs new file mode 100644 index 00000000..3ce7ee81 --- /dev/null +++ b/S1API/Internal/Interaction/InteractionPromptRuntime.cs @@ -0,0 +1,182 @@ +#if IL2CPPMELON +using S1Interaction = Il2CppScheduleOne.Interaction; +#elif MONOMELON +using S1Interaction = ScheduleOne.Interaction; +#endif + +using System; +using S1API.Utils; +using UnityEngine; + +namespace S1API.Internal.Interaction +{ + internal sealed class InteractionPromptRuntime : IDisposable + { + private readonly global::S1API.Interaction.InteractionPrompt _owner; + private readonly S1Interaction.InteractableObject _interactable; + private readonly Action _hoveredHandler; + private readonly Action _interactionStartedHandler; + private readonly Action _interactionEndedHandler; + private bool _disposed; + + internal GameObject Target { get; } + + internal bool IsAttached => + !_disposed && _interactable != null && Target != null; + + internal InteractionPromptRuntime( + global::S1API.Interaction.InteractionPrompt owner, + GameObject target, + global::S1API.Interaction.InteractionPromptInput input, + global::S1API.Interaction.InteractionPromptState state, + string message, + float range, + int priority, + bool limitAngle, + float angleLimit, + Transform? displayPoint, + Collider? displayCollider) + { + if (owner == null) + throw new ArgumentNullException(nameof(owner)); + global::S1API.Interaction.InteractionPromptContract.ValidateTarget(target); + if (target.GetComponent() != null) + { + throw new InvalidOperationException( + $"GameObject '{target.name}' already contains an InteractableObject component."); + } + ValidateColliderComposition(target); + + Target = target; + _owner = owner; + S1Interaction.InteractableObject? interactable = null; + try + { + interactable = target.AddComponent(); + _interactable = interactable; + _hoveredHandler = _owner.RaiseHovered; + _interactionStartedHandler = _owner.RaiseInteractionStarted; + _interactionEndedHandler = _owner.RaiseInteractionEnded; + ApplyInput(input); + ApplyState(state); + _interactable.SetMessage(message); + _interactable.MaxInteractionRange = range; + _interactable.Priority = priority; + _interactable.LimitInteractionAngle = limitAngle; + _interactable.AngleLimit = angleLimit; + ApplyDisplayLocation(displayPoint, displayCollider); + + EventHelper.AddListener(_hoveredHandler, _interactable.onHovered); + EventHelper.AddListener(_interactionStartedHandler, _interactable.onInteractStart); + EventHelper.AddListener(_interactionEndedHandler, _interactable.onInteractEnd); + } + catch + { + if (interactable != null) + UnityEngine.Object.Destroy(interactable); + throw; + } + } + + internal void SetMessage(string message) => _interactable.SetMessage(message); + + internal void SetInput(global::S1API.Interaction.InteractionPromptInput input) => ApplyInput(input); + + internal void SetState(global::S1API.Interaction.InteractionPromptState state) => ApplyState(state); + + internal void SetRange(float range) => _interactable.MaxInteractionRange = range; + + internal void SetPriority(int priority) => _interactable.Priority = priority; + + internal void SetAngleLimit(float angleLimit) + { + _interactable.AngleLimit = angleLimit; + _interactable.LimitInteractionAngle = true; + } + + internal void ClearAngleLimit() => _interactable.LimitInteractionAngle = false; + + internal void SetDisplayLocation(Transform displayPoint) => + ApplyDisplayLocation(displayPoint, null); + + internal void SetDisplayLocation(Collider displayCollider) => + ApplyDisplayLocation(null, displayCollider); + + internal void ClearDisplayLocation() => + ApplyDisplayLocation(null, null); + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + if (_interactable != null) + { + EventHelper.RemoveListener(_hoveredHandler, _interactable.onHovered); + EventHelper.RemoveListener(_interactionStartedHandler, _interactable.onInteractStart); + EventHelper.RemoveListener(_interactionEndedHandler, _interactable.onInteractEnd); + UnityEngine.Object.Destroy(_interactable); + } + } + + private void ApplyInput(global::S1API.Interaction.InteractionPromptInput input) + { + S1Interaction.InteractableObject.EInteractionType nativeInput = + input == global::S1API.Interaction.InteractionPromptInput.PrimaryClick + ? S1Interaction.InteractableObject.EInteractionType.LeftMouse_Click + : S1Interaction.InteractableObject.EInteractionType.Key_Press; + _interactable.SetInteractionType(nativeInput); + } + + private void ApplyState(global::S1API.Interaction.InteractionPromptState state) + { + S1Interaction.InteractableObject.EInteractableState nativeState = + (S1Interaction.InteractableObject.EInteractableState)(int)state; + _interactable.SetInteractableState(nativeState); + } + + private void ApplyDisplayLocation(Transform? displayPoint, Collider? displayCollider) + { + if (displayCollider != null) + { + if (!global::S1API.Internal.Utils.ReflectionUtils.TrySetFieldOrProperty( + _interactable, + "displayLocationCollider", + displayCollider)) + { + throw new InvalidOperationException( + "The native interaction component does not expose displayLocationCollider."); + } + + _interactable.displayLocationPoint = null; + return; + } + + if (!global::S1API.Internal.Utils.ReflectionUtils.TrySetFieldOrProperty( + _interactable, + "displayLocationCollider", + null)) + { + throw new InvalidOperationException( + "The native interaction component does not expose displayLocationCollider."); + } + + _interactable.displayLocationPoint = displayPoint; + } + + private static void ValidateColliderComposition(GameObject target) + { + Collider[] colliders = target.GetComponentsInChildren(true); + for (int i = 0; i < colliders.Length; i++) + { + Collider collider = colliders[i]; + if (collider != null) + return; + } + + throw new InvalidOperationException( + $"GameObject '{target.name}' and its children do not contain a collider usable by the interaction manager."); + } + } +} diff --git a/S1API/Internal/Lifecycle/SceneStateCleaner.cs b/S1API/Internal/Lifecycle/SceneStateCleaner.cs index 20f98640..b5a3f704 100644 --- a/S1API/Internal/Lifecycle/SceneStateCleaner.cs +++ b/S1API/Internal/Lifecycle/SceneStateCleaner.cs @@ -10,6 +10,7 @@ using S1API.Internal.Entities; using S1API.Internal.Map; using S1API.Internal.Patches; +using S1API.Internal.Weather; using UnityEngine; namespace S1API.Internal.Lifecycle @@ -65,9 +66,14 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload) for (int i = 0; i < NPC.All.Count; i++) { var npc = NPC.All[i]; - if (npc != null && npc.gameObject != null) + if (npc != null) { - TryRun(() => UnityEngine.Object.Destroy(npc.gameObject)); + TryRun( + npc.CleanupRuntimeHooks, + "Failed to remove NPC runtime hooks during scene cleanup"); + + if (npc.gameObject != null) + TryRun(() => UnityEngine.Object.Destroy(npc.gameObject)); } } NPC.All.Clear(); @@ -82,6 +88,7 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload) ShopManager.InvalidateCache(); DeferredMapResolver.Clear(); TimeManager.ResetBindings(); + WeatherRuntime.ResetBindings(); HomeScreenScrollPatch.ResetInitializationState(); NPCAppearance.ResetMugshotState(); LoadingScreenPatches.ResetState(); @@ -112,6 +119,9 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload) } TryRun(TimeManager.TryBindToCurrentInstance); + + if (string.Equals(sceneName, "Main", StringComparison.OrdinalIgnoreCase)) + TryRun(WeatherRuntime.TryBindToCurrentInstance); } } catch (Exception ex) @@ -121,5 +131,3 @@ internal static void ResetForSceneChange(string sceneName, bool afterUnload) } } } - - diff --git a/S1API/Internal/Lifecycle/TimeManagerShim.cs b/S1API/Internal/Lifecycle/TimeManagerShim.cs index 7828895c..c5f11cbe 100644 --- a/S1API/Internal/Lifecycle/TimeManagerShim.cs +++ b/S1API/Internal/Lifecycle/TimeManagerShim.cs @@ -51,7 +51,8 @@ internal void AddDelegatesToReal() try { var real = S1GameTime.TimeManager.Instance; - if (real == null) + var sleepController = S1GameTime.SleepController.Instance; + if (real == null || sleepController == null) { Debug.LogWarning( "TimeManagerShim: Real TimeManager instance not available yet; cannot migrate delegates."); @@ -66,8 +67,7 @@ internal void AddDelegatesToReal() if (!_addedSleepStart.Contains(sleepStart)) { - real.onSleepStart = Il2CppSystem.Delegate.Combine(real.onSleepStart, sleepStart) - .Cast(); + sleepController.add_OnSleepStart(sleepStart); _addedSleepStart.Add(sleepStart); } @@ -78,7 +78,7 @@ internal void AddDelegatesToReal() _addedHourPass.Add(hourPass); } #else - real.onSleepStart = (Action)Delegate.Combine(real.onSleepStart, onSleepStart); + sleepController.OnSleepStart += onSleepStart; real.onHourPass = (Action)Delegate.Combine(real.onHourPass, onHourPass); #endif } @@ -92,7 +92,8 @@ internal void DeleteDelegatesFromReal() try { var real = S1GameTime.TimeManager.Instance; - if (real == null) + var sleepController = S1GameTime.SleepController.Instance; + if (real == null || sleepController == null) { Debug.LogWarning( "TimeManagerShim: Real TimeManager instance no longer available; cannot delete delegates."); @@ -101,14 +102,14 @@ internal void DeleteDelegatesFromReal() #if IL2CPPMELON foreach (var d in _addedSleepStart) - real.onSleepStart = RemoveAll(real.onSleepStart, d); + sleepController.remove_OnSleepStart(d); _addedSleepStart.Clear(); foreach (var d in _addedHourPass) real.onHourPass = RemoveAll(real.onHourPass, d); _addedHourPass.Clear(); #else - real.onSleepStart = (Action)Delegate.RemoveAll(real.onSleepStart, onSleepStart); + sleepController.OnSleepStart -= onSleepStart; real.onHourPass = (Action)Delegate.RemoveAll(real.onHourPass, onHourPass); #endif } diff --git a/S1API/Internal/Map/DeferredMapResolver.cs b/S1API/Internal/Map/DeferredMapResolver.cs index 07db2141..1599ac8e 100644 --- a/S1API/Internal/Map/DeferredMapResolver.cs +++ b/S1API/Internal/Map/DeferredMapResolver.cs @@ -17,6 +17,8 @@ internal static class DeferredMapResolver private static readonly List PendingLookups = new List(); private static bool MainSceneLoaded = false; + internal static bool IsMainSceneReady => MainSceneLoaded; + /// /// Registers a deferred lookup that will be resolved when Main scene loads. /// diff --git a/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs b/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs index c7a0175d..ba01edb4 100644 --- a/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs +++ b/S1API/Internal/NPCWorkbench/NPCWorkbenchPreview.cs @@ -43,9 +43,7 @@ private NPCWorkbenchPreview( _camera = camera; _texture = texture; _renderLayer = renderLayer; - _template = avatar.CurrentSettings != null - ? Object.Instantiate(avatar.CurrentSettings) - : null; + _template = null; UpdateCamera(); } @@ -54,8 +52,11 @@ private NPCWorkbenchPreview( internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string failure) { preview = null; - failure = string.Empty; + failure = "NPC avatar previews are unavailable with the Schedule I 0.4.7 avatar pipeline."; + return false; +#if false + failure = string.Empty; var generator = S1AvatarFramework.MugshotGenerator.Instance; var source = generator != null ? generator.MugshotRig : null; if (source == null) @@ -85,8 +86,6 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail throw new InvalidOperationException("The cloned native preview rig has no Avatar component."); avatar.SetVisible(true); - if (avatar.Animation != null) - avatar.Animation.AllowCulling = false; foreach (var renderer in avatarObject.GetComponentsInChildren(true)) renderer.updateWhenOffscreen = true; @@ -143,6 +142,7 @@ internal static bool TryCreate(out NPCWorkbenchPreview? preview, out string fail Object.Destroy(root); return false; } +#endif } internal void ScheduleApply(NPCWorkbenchDraft draft) @@ -207,11 +207,8 @@ private void Apply(NPCWorkbenchDraft draft) var settings = NPCWorkbenchRuntimeAdapter.CreateSettings(draft, _template); try { - _avatar.LoadAvatarSettings(settings); + Compatibility.AvatarCompatibility.ApplyLegacySettings(_avatar, settings); _avatar.SetVisible(true); - _avatar.Impostor.DisableImpostor(); - if (_avatar.Animation != null) - _avatar.Animation.AllowCulling = false; RefreshRenderers(); _rendererRefreshFrames = 2; diff --git a/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs b/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs index 6dba7e3c..a6e75c6b 100644 --- a/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs +++ b/S1API/Internal/NPCWorkbench/NPCWorkbenchRuntimeAdapter.cs @@ -92,7 +92,7 @@ private static NPCWorkbenchDraft ImportNative(string id) throw new InvalidOperationException($"Native NPC '{id}' is not available in the current scene."); var avatar = npc.Avatar ?? npc.gameObject.GetComponentInChildren(true); - var sourceSettings = avatar?.CurrentSettings; + S1AvatarFramework.AvatarSettings? sourceSettings = null; if (sourceSettings == null && avatar != null) { sourceSettings = ReflectionUtils.TryGetFieldOrProperty( diff --git a/S1API/Internal/Patches/AvatarAccessoryPatches.cs b/S1API/Internal/Patches/AvatarAccessoryPatches.cs index 9b69411f..8cb68b30 100644 --- a/S1API/Internal/Patches/AvatarAccessoryPatches.cs +++ b/S1API/Internal/Patches/AvatarAccessoryPatches.cs @@ -5,6 +5,7 @@ #endif using HarmonyLib; +using S1API.Internal.Rendering; using S1API.Internal.Utils; using S1API.Logging; using S1API.Rendering; @@ -30,12 +31,22 @@ internal static class AvatarAccessoryPatches [HarmonyPatch] internal static class AvatarApplyAccessorySettingsPatch { + [HarmonyPrepare] + private static bool Prepare() => TargetMethod() != null; + static MethodBase? TargetMethod() { return typeof(S1AvatarFramework.Avatar).GetMethod("ApplyAccessorySettings", BindingFlags.Public | BindingFlags.Instance); } + private static void Prefix( + S1AvatarFramework.Avatar __instance, + S1AvatarFramework.AvatarSettings __0) + { + AvatarAccessoryDiagnostics.Validate(__instance, __0); + } + static void Postfix(S1AvatarFramework.Avatar __instance) { try diff --git a/S1API/Internal/Patches/BuildingPatches.cs b/S1API/Internal/Patches/BuildingPatches.cs index a75d60de..6be4c4ce 100644 --- a/S1API/Internal/Patches/BuildingPatches.cs +++ b/S1API/Internal/Patches/BuildingPatches.cs @@ -12,6 +12,7 @@ using HarmonyLib; using S1API.Building; +using S1API.Internal.Building; using S1API.Items; using S1API.Storage; using S1API.Logging; @@ -27,6 +28,51 @@ internal static class BuildingPatches { private static readonly Log Logger = new Log("BuildingPatches"); + [HarmonyPatch(typeof(S1Building.BuildStart_Grid), "CreateGhostModel")] + [HarmonyPostfix] + private static void CreateGridGhostModel_Postfix( + S1ItemFramework.BuildableItemDefinition itemDefinition, + S1EntityFramework.GridItem __result) + { + ConfigureGhostVisual(itemDefinition, __result); + } + + [HarmonyPatch(typeof(S1Building.BuildStart_Surface), "CreateGhostModel")] + [HarmonyPostfix] + private static void CreateSurfaceGhostModel_Postfix( + S1ItemFramework.BuildableItemDefinition itemDefinition, + S1EntityFramework.SurfaceItem __result) + { + ConfigureGhostVisual(itemDefinition, __result); + } + + [HarmonyPatch(typeof(S1Building.BuildStart_ProceduralGrid), "CreateGhostModel")] + [HarmonyPostfix] + private static void CreateProceduralGridGhostModel_Postfix( + S1ItemFramework.BuildableItemDefinition itemDefinition, + S1EntityFramework.ProceduralGridItem __result) + { + ConfigureGhostVisual(itemDefinition, __result); + } + + private static void ConfigureGhostVisual( + S1ItemFramework.BuildableItemDefinition itemDefinition, + S1EntityFramework.BuildableItem ghost) + { + if (itemDefinition == null || ghost == null) + return; + + try + { + BuildableGhostRuntime.TryConfigure(itemDefinition.ID, ghost.gameObject); + } + catch (System.Exception ex) + { + Logger.Error( + $"Failed to configure placement ghost for buildable '{itemDefinition.ID}': {ex}"); + } + } + /// /// Patch for BuildManager.CreateGridItem - raises OnGridItemCreated event. /// @@ -49,7 +95,6 @@ private static void CreateGridItem_Postfix(S1EntityFramework.GridItem __result, storageWrapper = new StorageEntity(placeableStorage.StorageEntity, placeableStorage); } - if (storageWrapper == null) return; var args = new BuildEventArgs(itemInstance, __result.gameObject, storageWrapper); BuildEvents.RaiseGridItemCreated(args); } @@ -81,7 +126,6 @@ private static void CreateSurfaceItem_Postfix(S1EntityFramework.SurfaceItem __re storageWrapper = new StorageEntity(placeableStorage.StorageEntity, placeableStorage); } - if (storageWrapper == null) return; var args = new BuildEventArgs(itemInstance, __result.gameObject, storageWrapper); BuildEvents.RaiseSurfaceItemCreated(args); } @@ -113,7 +157,6 @@ private static void InitializeBuildableItem_Postfix(S1EntityFramework.BuildableI storageWrapper = new StorageEntity(placeableStorage.StorageEntity, placeableStorage); } - if (storageWrapper == null) return; var args = new BuildEventArgs(itemInstance, __instance.gameObject, storageWrapper); BuildEvents.RaiseBuildableItemInitialized(args); } diff --git a/S1API/Internal/Patches/CasinoGamePatches.cs b/S1API/Internal/Patches/CasinoGamePatches.cs new file mode 100644 index 00000000..89798c74 --- /dev/null +++ b/S1API/Internal/Patches/CasinoGamePatches.cs @@ -0,0 +1,158 @@ +#if IL2CPPMELON +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using S1Casino = Il2CppScheduleOne.Casino; +using S1NetworkConnection = Il2CppFishNet.Connection.NetworkConnection; +#elif MONOMELON +using S1Casino = ScheduleOne.Casino; +using S1NetworkConnection = FishNet.Connection.NetworkConnection; +#endif + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using S1API.Casino; + +namespace S1API.Internal.Patches +{ + /// + /// Bridges native casino state transitions to the read-only managed casino API. + /// + [HarmonyPatch] + internal static class CasinoGamePatches + { + [HarmonyPatch(typeof(S1Casino.BlackjackGameController), "set_CurrentStage")] + [HarmonyPrefix] + private static void BlackjackStagePrefix( + S1Casino.BlackjackGameController __instance, + out BlackjackStage __state) + { + __state = (BlackjackStage)(int)__instance.CurrentStage; + } + + [HarmonyPatch(typeof(S1Casino.BlackjackGameController), "set_CurrentStage")] + [HarmonyPostfix] + private static void BlackjackStagePostfix( + S1Casino.BlackjackGameController __instance, + S1Casino.BlackjackGameController.EStage __0, + BlackjackStage __state) + { + CasinoGameRegistry.NotifyBlackjackStageChanged( + __instance, + __state, + (BlackjackStage)(int)__0); + } + + [HarmonyPatch(typeof(S1Casino.RTBGameController), "set_CurrentStage")] + [HarmonyPrefix] + private static void RideTheBusStagePrefix( + S1Casino.RTBGameController __instance, + out RideTheBusStage __state) + { + __state = (RideTheBusStage)(int)__instance.CurrentStage; + } + + [HarmonyPatch(typeof(S1Casino.RTBGameController), "set_CurrentStage")] + [HarmonyPostfix] + private static void RideTheBusStagePostfix( + S1Casino.RTBGameController __instance, + S1Casino.RTBGameController.EStage __0, + RideTheBusStage __state) + { + CasinoGameRegistry.NotifyRideTheBusStageChanged( + __instance, + __state, + (RideTheBusStage)(int)__0); + } + + internal static MethodBase? FindSlotStartLogicMethod(Type slotMachineType) + { + return slotMachineType + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .FirstOrDefault(method => + { + if (!method.Name.StartsWith("RpcLogic___StartSpin_", StringComparison.Ordinal)) + return false; + + ParameterInfo[] parameters = method.GetParameters(); + return method.ReturnType == typeof(void) + && parameters.Length == 3 + && parameters[0].ParameterType == typeof(S1NetworkConnection) +#if IL2CPPMELON + && parameters[1].ParameterType == typeof(Il2CppStructArray) +#else + && parameters[1].ParameterType == typeof(S1Casino.SlotMachine.ESymbol[]) +#endif + && parameters[2].ParameterType == typeof(int); + }); + } + + [HarmonyPatch] + private static class SlotSpinPatch + { + private static MethodBase? TargetMethod() => + FindSlotStartLogicMethod(typeof(S1Casino.SlotMachine)); + + [HarmonyPrefix] + private static void Prefix( + S1Casino.SlotMachine __instance, + S1NetworkConnection __0, +#if IL2CPPMELON + Il2CppStructArray __1, +#else + S1Casino.SlotMachine.ESymbol[] __1, +#endif + int __2, + out SlotStartPatchState __state) + { + var symbols = new List(__1.Length); + for (int i = 0; i < __1.Length; i++) + symbols.Add((SlotSymbol)(int)__1[i]); + + __state = new SlotStartPatchState( + __instance.IsSpinning, + new SlotSpinSnapshot( + __2, + SlotSpinSnapshot.Freeze(symbols), + __0 != null && __0.IsLocalClient, + outcome: null, + winAmount: null)); + } + + [HarmonyPostfix] + private static void Postfix( + S1Casino.SlotMachine __instance, + SlotStartPatchState __state) + { + if (!__state.WasSpinning && __instance.IsSpinning) + CasinoGameRegistry.NotifySlotSpinStarted(__instance, __state.Snapshot); + } + } + + [HarmonyPatch(typeof(S1Casino.SlotMachine), "DisplayOutcome")] + [HarmonyPostfix] + private static void SlotOutcomePostfix( + S1Casino.SlotMachine __instance, + S1Casino.SlotMachine.EOutcome __0, + int __1) + { + CasinoGameRegistry.NotifySlotSpinCompleted( + __instance, + (SlotOutcome)(int)__0, + __1); + } + + private readonly struct SlotStartPatchState + { + internal SlotStartPatchState(bool wasSpinning, SlotSpinSnapshot snapshot) + { + WasSpinning = wasSpinning; + Snapshot = snapshot; + } + + internal bool WasSpinning { get; } + internal SlotSpinSnapshot Snapshot { get; } + } + } +} diff --git a/S1API/Internal/Patches/ContactsAppPatches.cs b/S1API/Internal/Patches/ContactsAppPatches.cs index e7cf13e2..1ef7e88f 100644 --- a/S1API/Internal/Patches/ContactsAppPatches.cs +++ b/S1API/Internal/Patches/ContactsAppPatches.cs @@ -71,6 +71,32 @@ internal static void ResetState() _hasCustomNpcTypesCache = null; } + internal static void RefreshContactIcon(S1NPCs.NPC npc) + { + if (npc == null || string.IsNullOrWhiteSpace(npc.ID)) + return; + + try + { + foreach (S1Relations.RelationCircle circle in + Object.FindObjectsOfType(true)) + { + if (!string.Equals( + GetAssignedNpcId(circle), + npc.ID, + global::System.StringComparison.OrdinalIgnoreCase)) + continue; + + SetAssignedNpc(circle, npc); + circle.AssignNPC(npc); + } + } + catch (System.Exception ex) + { + Logger.Warning($"Could not refresh Contacts icon for NPC '{npc.ID}': {ex.Message}"); + } + } + /// /// Checks if any custom NPC types exist (excluding S1API internal types). /// Caches the result to avoid repeated reflection calls. @@ -166,9 +192,10 @@ private static IEnumerator WaitForNPCs(S1ContactsApp.ContactsApp contactsApp) yield return null; yield return null; - // Wait for mugshots to be generated before creating circles - // This ensures HeadshotImg.sprite gets the correct mugshot, not the default icon - yield return new WaitUntil((Func)(() => NPCAppearance.MugshotsProcessingComplete)); + // Wait per NPC rather than using only the global queue. Explicit-icon NPC sets + // never enqueue a mugshot, while early-generated NPCs may enqueue before the rig exists. + yield return new WaitUntil((Func)(() => + customNPCs.All(npc => npc.Appearance.MugshotReady))); // Add circles after native Start so they are not processed as serialized native circles. AddRelationCircles(contactsApp); @@ -207,7 +234,12 @@ private static void AddRelationCircles(S1ContactsApp.ContactsApp contactsApp) var existing = regionUI.Container.GetComponentsInChildren(true) .FirstOrDefault(c => GetAssignedNpcId(c) == npc.S1NPC.ID); if (existing != null) + { + SetAssignedNpc(existing, npc.S1NPC); + existing.AssignNPC(npc.S1NPC); + ApplyRoleIndicators(existing, npc.GetType()); continue; + } // Find a base game template - exclude circles we've already created var allCirclesInRegion = regionUI.Container.GetComponentsInChildren(true); diff --git a/S1API/Internal/Patches/CustomProductManifestPatches.cs b/S1API/Internal/Patches/CustomProductManifestPatches.cs index 15ee69ad..7027b346 100644 --- a/S1API/Internal/Patches/CustomProductManifestPatches.cs +++ b/S1API/Internal/Patches/CustomProductManifestPatches.cs @@ -8,6 +8,7 @@ using S1Player = ScheduleOne.PlayerScripts.Player; #endif +using System; using System.Reflection; using HarmonyLib; using S1API.Internal.Products; @@ -52,29 +53,55 @@ private static void UpdatePostfix() [HarmonyPatch] internal static class CustomProductManifestPlayerPatches { - [HarmonyPatch(typeof(S1Player), nameof(S1Player.RequestPlayerData))] - [HarmonyPrefix] - private static bool RequestPlayerDataPrefix(S1Player __instance, string playerCode) + [HarmonyPatch] + private static class RequestPlayerDataPatch { - return CustomProductManifestRuntime.AuthorizeClientPlayerDataRequest( - () => __instance.RequestPlayerData(playerCode)); + private static MethodBase TargetMethod() => + AccessTools.DeclaredMethod( + typeof(S1Player), + "RequestPlayerData_Server") ?? + throw new MissingMethodException( + typeof(S1Player).FullName, + "RequestPlayerData_Server"); + + private static bool Prefix( + S1Player __instance, + string playerCode, + bool isHost, + MethodBase __originalMethod) + { + return CustomProductManifestRuntime.AuthorizeClientPlayerDataRequest( + () => __originalMethod.Invoke( + __instance, + new object[] { playerCode, isHost })); + } } - [HarmonyPatch(typeof(S1Player), nameof(S1Player.ReceivePlayerData))] - [HarmonyPrefix] - private static bool ReceivePlayerDataPrefix( - object __instance, - object[] __args, - MethodBase __originalMethod) + [HarmonyPatch] + private static class SetPlayerDataPatch { - if (__args.Length == 0 || !(__args[0] is S1Connection connection)) - return true; + private static MethodBase TargetMethod() => + AccessTools.DeclaredMethod( + typeof(S1Player), + "SetPlayerData_Client") ?? + throw new MissingMethodException( + typeof(S1Player).FullName, + "SetPlayerData_Client"); + + private static bool Prefix( + object __instance, + object[] __args, + MethodBase __originalMethod) + { + if (__args.Length == 0 || !(__args[0] is S1Connection connection)) + return true; - return CustomProductManifestRuntime.AuthorizeHostPlayerData( - __instance, - connection, - __args, - __originalMethod); + return CustomProductManifestRuntime.AuthorizeHostPlayerData( + __instance, + connection, + __args, + __originalMethod); + } } } } diff --git a/S1API/Internal/Patches/LoadingDockPatches.cs b/S1API/Internal/Patches/LoadingDockPatches.cs new file mode 100644 index 00000000..4dad3e69 --- /dev/null +++ b/S1API/Internal/Patches/LoadingDockPatches.cs @@ -0,0 +1,83 @@ +#if IL2CPPMELON +using S1Delivery = Il2CppScheduleOne.Delivery; +using S1Vehicles = Il2CppScheduleOne.Vehicles; +#elif MONOMELON +using S1Delivery = ScheduleOne.Delivery; +using S1Vehicles = ScheduleOne.Vehicles; +#endif + +using HarmonyLib; +using S1API.Internal.Deliveries; + +namespace S1API.Internal.Patches +{ + /// + /// Observes native loading-dock transitions without replacing their behavior. + /// + [HarmonyPatch(typeof(S1Delivery.LoadingDock))] + internal static class LoadingDockPatches + { + [HarmonyPatch("SetOccupant")] + [HarmonyPrefix] + private static void SetOccupantPrefix( + S1Delivery.LoadingDock __instance, + out S1Vehicles.LandVehicle? __state) + { + __state = __instance.DynamicOccupant; + } + + [HarmonyPatch("SetOccupant")] + [HarmonyPostfix] + private static void SetOccupantPostfix( + S1Delivery.LoadingDock __instance, + S1Vehicles.LandVehicle? __state) + { + LoadingDockEventBridge.NotifyDynamicOccupantChanged( + __instance, + __state, + __instance.DynamicOccupant); + } + + [HarmonyPatch(nameof(S1Delivery.LoadingDock.SetStaticOccupant))] + [HarmonyPrefix] + private static void SetStaticOccupantPrefix( + S1Delivery.LoadingDock __instance, + out S1Vehicles.LandVehicle? __state) + { + __state = __instance.StaticOccupant; + } + + [HarmonyPatch(nameof(S1Delivery.LoadingDock.SetStaticOccupant))] + [HarmonyPostfix] + private static void SetStaticOccupantPostfix( + S1Delivery.LoadingDock __instance, + S1Vehicles.LandVehicle? __state) + { + LoadingDockEventBridge.NotifyStaticOccupantChanged( + __instance, + __state, + __instance.StaticOccupant); + } + + [HarmonyPatch(nameof(S1Delivery.LoadingDock.IsAcceptingItems), MethodType.Setter)] + [HarmonyPrefix] + private static void SetAcceptingItemsPrefix( + S1Delivery.LoadingDock __instance, + out bool __state) + { + __state = __instance.IsAcceptingItems; + } + + [HarmonyPatch(nameof(S1Delivery.LoadingDock.IsAcceptingItems), MethodType.Setter)] + [HarmonyPostfix] + private static void SetAcceptingItemsPostfix( + S1Delivery.LoadingDock __instance, + bool __state) + { + LoadingDockEventBridge.NotifyAcceptingItemsChanged( + __instance, + __state, + __instance.IsAcceptingItems); + } + } +} diff --git a/S1API/Internal/Patches/MixReactionPatches.cs b/S1API/Internal/Patches/MixReactionPatches.cs index 33cebdcd..c88fccfd 100644 --- a/S1API/Internal/Patches/MixReactionPatches.cs +++ b/S1API/Internal/Patches/MixReactionPatches.cs @@ -17,6 +17,7 @@ using S1API.Logging; using S1API.Products; using S1API.Internal.Products; +using S1API.Internal.Utils; namespace S1API.Internal.Patches { @@ -55,7 +56,7 @@ private static void MixProperties_Postfix( // The game can return a shared list (e.g. a product definition's Properties for a named recipe), // so never mutate __result directly. Clone once, only if a rule actually fires. - EffectList? working = null; + EffectList? working = null; foreach (var rule in rules) { @@ -173,14 +174,26 @@ private static bool FinishAndNameMix_Prefix( if (CustomProductDefinitionRegistry.IsRegistered(mixID)) return false; - S1Product.PropertyItemDefinition? ingredient = - S1Registry.GetItem(ingredientID) as S1Product.PropertyItemDefinition; - if (ingredient == null || ingredient.Properties == null || ingredient.Properties.Count != 1) + object? ingredientItem = S1Registry.GetItem(ingredientID); + if (ingredientItem == null || + !CrossType.Is( + ingredientItem, + out S1Product.PropertyItemDefinition ingredient)) { - Logger.Error("Custom product mix '" + productID + "' has no valid single-property ingredient '" + ingredientID + "'."); + Logger.Error("Custom product mix '" + productID + "' could not resolve ingredient '" + ingredientID + "' as a property item."); return false; } + if (ingredient.Properties == null || + !CustomProductMixingIngredientContract.HasUsableProperty( + ingredient.Properties.Count)) + { + Logger.Error("Custom product mix '" + productID + "' resolved ingredient '" + ingredientID + "' without any properties."); + return false; + } + + // Match the native ProductManager contract: mixing ingredients only need + // one usable property, and the first property drives the calculation. EffectList properties = S1Effects.EffectMixCalculator.MixProperties( source.Properties, ingredient.Properties[0], @@ -211,6 +224,31 @@ private static bool FinishAndNameMix_Prefix( packaging.Add(metadata.ValidPackaging[i].S1PackagingDefinition); S1Product.ProductDefinition template = metadata.RepresentationTemplate ?? source; + UnityEngine.Color32? generatedMixColor = null; + if (profile.UsePropertyColorMixing) + { + var colorSamples = + new System.Collections.Generic.List< + ProductMixingColorSample>(resolvedProperties.Count); + for (int i = 0; i < resolvedProperties.Count; i++) + { + UnityEngine.Color32 propertyColor = + resolvedProperties[i].ProductColor; + colorSamples.Add( + new ProductMixingColorSample( + (int)resolvedProperties[i].Tier, + new ProductMixingColorValue( + propertyColor.r, + propertyColor.g, + propertyColor.b, + propertyColor.a))); + } + + generatedMixColor = + ProductMixingColorContract.CalculatePrimaryColor( + profile.MixerMap, + colorSamples).ToColor32(); + } S1Product.ProductDefinition generated = CustomProductDefinitionFactory.Create( mixID, output.Name, @@ -228,7 +266,8 @@ private static bool FinishAndNameMix_Prefix( output.ProductKind, metadata.DefaultQuality, metadata.ValidPackaging, - template); + template, + generatedMixColor); var saveDescriptor = new CustomProductSaveDescriptorData { ProductId = mixID, @@ -246,7 +285,12 @@ private static bool FinishAndNameMix_Prefix( NpcEffectDurationSeconds = source.NPCEffectDuration, PropertyIds = resolvedProperties.ConvertAll(property => property.ID).ToArray(), PackagingIds = metadata.ValidPackaging.Select(packagingDefinition => packagingDefinition.ID).ToArray(), - IsGeneratedMix = true + IsGeneratedMix = true, + HasGeneratedMixColor = generatedMixColor.HasValue, + GeneratedMixColorR = generatedMixColor?.r ?? 0, + GeneratedMixColorG = generatedMixColor?.g ?? 0, + GeneratedMixColorB = generatedMixColor?.b ?? 0, + GeneratedMixColorA = generatedMixColor?.a ?? 0 }; try { @@ -258,6 +302,8 @@ private static bool FinishAndNameMix_Prefix( generated, generatedMetadata, saveDescriptor); + + CompleteGeneratedMixCreation(generated); } catch { @@ -273,6 +319,32 @@ private static bool FinishAndNameMix_Prefix( return false; } } + + private static void CompleteGeneratedMixCreation( + S1Product.ProductDefinition generated) + { + S1Product.ProductManager productManager = + S1Product.ProductManager.Instance + ?? throw new InvalidOperationException( + "Cannot complete custom product mixing before ProductManager is available."); + + // Match the native Create* lifecycle after the definition has entered the + // registry. Discovery makes the output listable, while this event creates + // its Product Manager entry and lets S1API route it to the logical kind. + productManager.SetProductDiscovered( + null, + generated.ID, + autoList: false); + productManager.onNewProductCreated?.Invoke(generated); + } + } + + internal static class CustomProductMixingIngredientContract + { + internal static bool HasUsableProperty(int propertyCount) + { + return propertyCount > 0; + } } /// diff --git a/S1API/Internal/Patches/NPCPatches.cs b/S1API/Internal/Patches/NPCPatches.cs index 25ec6eee..3c83778b 100644 --- a/S1API/Internal/Patches/NPCPatches.cs +++ b/S1API/Internal/Patches/NPCPatches.cs @@ -13,6 +13,7 @@ using S1Items = Il2CppScheduleOne.ItemFramework; using S1GameTime = Il2CppScheduleOne.GameTime; using S1Quests = Il2CppScheduleOne.Quests; +using S1Properties = Il2CppScheduleOne.Property; using Il2CppFishNet; using Il2CppFishNet.Object; using Il2CppScheduleOne.DevUtilities; @@ -35,6 +36,7 @@ using S1Items = ScheduleOne.ItemFramework; using S1GameTime = ScheduleOne.GameTime; using S1Quests = ScheduleOne.Quests; +using S1Properties = ScheduleOne.Property; #endif using System; @@ -43,6 +45,7 @@ using System.IO; using System.Linq; using System.Reflection; +using NumericsVector3 = System.Numerics.Vector3; using HarmonyLib; using MelonLoader; using S1API.Entities; @@ -64,7 +67,6 @@ internal class NPCPatches { private static readonly Logging.Log Logger = new Logging.Log("NPCPatches"); private static readonly HashSet _loadingDealers = new HashSet(); - private const float DefaultRelationDelta = 2f; public static bool CustomNpcsReady = false; // Pending custom NPC types to instantiate when using consolidated NPCs.json saves (non-physical/custom contacts). private static readonly System.Collections.Generic.List _pendingCustomNpcTypes = new System.Collections.Generic.List(); @@ -78,10 +80,6 @@ internal class NPCPatches private static readonly System.Collections.Generic.Dictionary _savedCurrentAddiction = new System.Collections.Generic.Dictionary(); - // Pending inventory loads for custom dealers - stored until NPCInventory.Awake creates slots - private static readonly System.Collections.Generic.Dictionary _pendingInventoryLoads - = new System.Collections.Generic.Dictionary(); - private static object? GetInventoryMember(S1NPCs.NPCInventory inventory, string memberName) { return ReflectionUtils.TryGetFieldOrProperty(inventory, memberName); @@ -92,6 +90,295 @@ private static bool SetInventoryMember(S1NPCs.NPCInventory inventory, string mem return ReflectionUtils.TrySetFieldOrProperty(inventory, memberName, value); } + internal static bool ShouldExitCustomNpcAfterSummon( + bool isServer, + bool isCustomNpc, + bool isInsideBuilding) => + isServer && isCustomNpc && isInsideBuilding; + + internal static bool ShouldSuppressResidenceReentry( + bool isServer, + bool isCustomNpc, + bool isSummonBehaviourEnabled) => + isServer && isCustomNpc && isSummonBehaviourEnabled; + + internal static MethodBase? FindSummonLogicMethod(Type behaviourType) + { + return behaviourType + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .FirstOrDefault(method => + { + if (!method.Name.StartsWith("RpcLogic___Summon_", StringComparison.Ordinal)) + return false; + + ParameterInfo[] parameters = method.GetParameters(); + return method.ReturnType == typeof(void) + && parameters.Length == 3 + && parameters[0].ParameterType == typeof(string) + && parameters[1].ParameterType == typeof(int) + && parameters[2].ParameterType == typeof(float); + }); + } + + internal static MethodBase? FindNpcMovementDestinationMethod(Type movementType) + { + return movementType.GetMethod( + "SetDestination", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + types: new[] { typeof(Vector3) }, + modifiers: null); + } + + internal static bool TryGetCustomNpcFollowDestination( + bool isCustomNpc, + bool isFollowingPlayer, + Vector3 playerPosition, + Vector3 npcPosition, + Vector3 fallbackDirection, + out Vector3 destination) + { + destination = default; + if (!TryCalculateCustomNpcFollowDestination( + isCustomNpc, + isFollowingPlayer, + new NumericsVector3(playerPosition.x, playerPosition.y, playerPosition.z), + new NumericsVector3(npcPosition.x, npcPosition.y, npcPosition.z), + new NumericsVector3(fallbackDirection.x, fallbackDirection.y, fallbackDirection.z), + out NumericsVector3 calculatedDestination)) + { + return false; + } + + destination = new Vector3( + calculatedDestination.X, + calculatedDestination.Y, + calculatedDestination.Z); + return true; + } + + internal static bool TryCalculateCustomNpcFollowDestination( + bool isCustomNpc, + bool isFollowingPlayer, + NumericsVector3 playerPosition, + NumericsVector3 npcPosition, + NumericsVector3 fallbackDirection, + out NumericsVector3 destination) + { + destination = default; + if (!isCustomNpc || !isFollowingPlayer) + return false; + + NumericsVector3 direction = npcPosition - playerPosition; + if (direction.LengthSquared() <= 0.0001f) + direction = fallbackDirection; + if (direction.LengthSquared() <= 0.0001f) + direction = -NumericsVector3.UnitZ; + + destination = playerPosition + NumericsVector3.Normalize(direction) * 2.5f; + return true; + } + + internal static bool TryCalculateCustomNpcPropertyApproachDestination( + bool isCustomNpc, + bool isInitialApproach, + bool playerInsideOwnedProperty, + NumericsVector3? propertyExteriorSpawnPosition, + out NumericsVector3 destination) + { + destination = default; + if (!isCustomNpc || !isInitialApproach || !playerInsideOwnedProperty || + propertyExteriorSpawnPosition is not NumericsVector3 spawnPosition || + !IsValidNavigationPosition(spawnPosition)) + { + return false; + } + + destination = spawnPosition; + return true; + } + + private static bool IsValidNavigationPosition(NumericsVector3 position) => + float.IsFinite(position.X) && + float.IsFinite(position.Y) && + float.IsFinite(position.Z) && + position.LengthSquared() <= 100_000_000f; + + private static bool TryGetCustomNpcPropertyApproachDestination( + bool isCustomNpc, + bool isInitialApproach, + Vector3 playerPosition, + out Vector3 destination) + { + destination = default; + foreach (S1Properties.Property property in S1Properties.Property.OwnedProperties) + { + if (property == null || !property.DoBoundsContainPoint(playerPosition)) + continue; + + Vector3? spawnPosition = property.SpawnPoint != null + ? property.SpawnPoint.position + : null; + NumericsVector3? numericSpawnPosition = spawnPosition.HasValue + ? new NumericsVector3( + spawnPosition.Value.x, + spawnPosition.Value.y, + spawnPosition.Value.z) + : null; + if (!TryCalculateCustomNpcPropertyApproachDestination( + isCustomNpc, + isInitialApproach, + playerInsideOwnedProperty: true, + numericSpawnPosition, + out NumericsVector3 calculatedDestination)) + { + return false; + } + + destination = new Vector3( + calculatedDestination.X, + calculatedDestination.Y, + calculatedDestination.Z); + return true; + } + + return false; + } + + [HarmonyPatch] + private static class RequestProductMovementDestinationPatch + { + private static MethodBase? TargetMethod() => + FindNpcMovementDestinationMethod(typeof(S1NPCs.NPCMovement)); + + [HarmonyPrefix] + private static void Prefix(S1NPCs.NPCMovement __instance, ref Vector3 pos) + { + S1NPCs.NPC? nativeNpc = __instance?.GetComponent(); + S1NPCsBehaviour.RequestProductBehaviour? request = + nativeNpc?.Behaviour?.RequestProductBehaviour; + var targetPlayer = request?.TargetPlayer; + if (nativeNpc == null || request == null || targetPlayer == null) + return; + + bool isCustomNpc = IsS1ApiCustomNpcComponent(nativeNpc); + bool isInitialApproach = + request.Active && + request.State == S1NPCsBehaviour.RequestProductBehaviour.EState.InitialApproach; + if (TryGetCustomNpcPropertyApproachDestination( + isCustomNpc, + isInitialApproach, + targetPlayer.transform.position, + out Vector3 propertyApproachDestination)) + { + pos = propertyApproachDestination; + return; + } + + bool isFollowingPlayer = + request.Active && + request.State == S1NPCsBehaviour.RequestProductBehaviour.EState.FollowPlayer; + if (!TryGetCustomNpcFollowDestination( + isCustomNpc, + isFollowingPlayer, + targetPlayer.transform.position, + nativeNpc.transform.position, + -targetPlayer.transform.forward, + out Vector3 desiredDestination)) + { + return; + } + + var agent = __instance == null + ? null + : ReflectionUtils.TryGetFieldOrProperty( + __instance, + "_agent") as UnityEngine.AI.NavMeshAgent; + if (agent != null && NavMeshUtility.SamplePosition( + desiredDestination, + out var hit, + 15f, + agent.areaMask)) + { + pos = hit.position; + } + else + { + Logger.Warning( + $"[RequestProduct] Failed to find a spaced follow destination for custom NPC '{nativeNpc.ID}'; preserving the native destination."); + } + } + } + + internal static S1NPCs.NPC? GetScheduleActionNpc(S1NPCsSchedules.NPCAction action) + { + return ReflectionUtils.TryGetFieldOrProperty(action, "npc") as S1NPCs.NPC; + } + + [HarmonyPatch] + private static class NpcBehaviourSummonLogicPatch + { + private static MethodBase? TargetMethod() => + FindSummonLogicMethod(typeof(S1NPCsBehaviour.NPCBehaviour)); + + [HarmonyPostfix] + private static void Postfix(S1NPCsBehaviour.NPCBehaviour __instance) + { + S1NPCs.NPC? npc = __instance?.Npc; + if (npc == null) + return; + + bool isCustomNpc = NPC.All.Any( + wrapper => wrapper != null && wrapper.IsCustomNPC && wrapper.S1NPC == npc); + var building = npc.CurrentBuilding; + bool isInsideBuilding = building != null; + if (!ShouldExitCustomNpcAfterSummon(InstanceFinder.IsServer, isCustomNpc, isInsideBuilding)) + return; + + Logger.Debug( + $"[NPCDoorKnock] Exiting summoned custom NPC '{npc.ID}' from " + + $"'{building!.BuildingName}' after native summon behaviour activation."); + npc.ExitBuilding(); + } + } + + [HarmonyPatch(typeof(S1NPCsSchedules.NPCEvent_StayInBuilding), nameof(S1NPCsSchedules.NPCEvent_StayInBuilding.OnActiveTick))] + [HarmonyPrefix] + private static bool StayInBuilding_OnActiveTick_Prefix( + S1NPCsSchedules.NPCEvent_StayInBuilding __instance) + { + // Prevent the residence action from scheduling a new entrance during native summon dwell. + return !IsCustomNpcSummonedDuringResidence(__instance); + } + + [HarmonyPatch(typeof(S1NPCsSchedules.NPCEvent_StayInBuilding), "PlayEnterAnimation")] + [HarmonyPrefix] + private static bool StayInBuilding_PlayEnterAnimation_Prefix( + S1NPCsSchedules.NPCEvent_StayInBuilding __instance) + { + // Guard callbacks already queued before the NPC was summoned out of the building. + return !IsCustomNpcSummonedDuringResidence(__instance); + } + + private static bool IsCustomNpcSummonedDuringResidence( + S1NPCsSchedules.NPCEvent_StayInBuilding action) + { + var npc = GetScheduleActionNpc(action); + if (npc == null) + return false; + + bool isSummonBehaviourEnabled = npc.Behaviour?.SummonBehaviour?.Enabled == true; + if (!isSummonBehaviourEnabled) + return false; + + bool isCustomNpc = NPC.All.Any( + wrapper => wrapper != null && wrapper.IsCustomNPC && wrapper.S1NPC == npc); + return ShouldSuppressResidenceReentry( + InstanceFinder.IsServer, + isCustomNpc, + isSummonBehaviourEnabled); + } + private static bool GetInventoryBool(S1NPCs.NPCInventory inventory, string memberName) { return GetInventoryMember(inventory, memberName) is bool value && value; @@ -208,7 +495,14 @@ internal static void ResetState() { _savedCurrentAddiction.Clear(); _loadingDealers.Clear(); - _pendingInventoryLoads.Clear(); + } + + internal static void RestoreInventoryAfterInitialization( + Action ensureInitialized, + Action restoreInventory) + { + ensureInitialized(); + restoreInventory(); } private static void LogCustomNpcInstantiationException(Type? type, string context, Exception? ex) @@ -284,7 +578,43 @@ private static void RebuildPendingCustomNpcTypes(bool useConsolidatedFlow) if (type.Assembly == Assembly.GetExecutingAssembly()) continue; // skip S1API internal wrapper types - _pendingCustomNpcTypes.Add(type); + if (CustomNpcPreparationPolicy.FindExactType(NPC.All, type) == null) + _pendingCustomNpcTypes.Add(type); + } + } + + /// + /// Creates inactive custom NPCs and registers their persistent GUIDs before native contracts load. + /// NPCsLoader later hydrates and queues the same instances for network spawn. + /// + internal static void PrepareCustomNpcsForContractLoad() + { + if (!IsInMainScene() || !InstanceFinder.IsServer) + return; + + foreach (Type type in ReflectionUtils.GetDerivedClasses()) + { + if (type == null || type.IsAbstract || type.Assembly == Assembly.GetExecutingAssembly()) + continue; + + NPC? customNpc = CustomNpcPreparationPolicy.FindExactType(NPC.All, type); + if (customNpc == null) + { + try + { + customNpc = (NPC)Activator.CreateInstance(type, true)!; + } + catch (Exception ex) + { + LogCustomNpcInstantiationException(type, "before contract loading", ex); + continue; + } + } + + if (customNpc.gameObject.GetComponent() != null) + customNpc.Customer.EnsureCustomer(); + + customNpc.RegisterPersistentGuidForContractLoad(); } } @@ -491,6 +821,15 @@ private static void InstantiateRemainingCustomNpcs(string mainPath) } } + private static void RegisterPreparedCustomNpcsForNetworking() + { + foreach (NPC customNpc in NPC.All) + { + if (customNpc.IsCustomNPC) + RegisterCustomNpcForNetworking(customNpc); + } + } + /// /// Patching performed for when game NPCs are loaded. /// Creates custom NPC instances before the loader runs. @@ -532,16 +871,19 @@ private static void NPCsLoadersLoad(S1Loaders.NPCsLoader __instance, string main int createdCount = 0; foreach (Type type in ReflectionUtils.GetDerivedClasses()) { - if (type.IsAbstract) + if (type.IsAbstract || type.Assembly == Assembly.GetExecutingAssembly()) continue; - - NPC? customNPC = (NPC)Activator.CreateInstance(type, true)!; - if (customNPC == null) - throw new Exception($"Unable to create instance of {type.FullName}!"); - // We skip any S1API NPCs, as they are base NPC wrappers. - if (type.Assembly == Assembly.GetExecutingAssembly()) - continue; + // QuestsLoader may have prepared this instance already so accepted contracts can + // resolve its persistent GUID. Reuse it rather than creating a duplicate wrapper + // whose default state would later win during save serialization. + NPC? customNPC = CustomNpcPreparationPolicy.FindExactType(NPC.All, type); + if (customNPC == null) + { + customNPC = (NPC?)Activator.CreateInstance(type, true); + if (customNPC == null) + throw new Exception($"Unable to create instance of {type.FullName}!"); + } var baseNpc = customNPC.S1NPC ?? throw new InvalidOperationException( @@ -665,6 +1007,7 @@ private static bool NPCsLoader_Load_Prefix(S1Loaders.NPCsLoader __instance, stri // Instantiate any new custom NPCs that don't have save entries yet (e.g., newly added mods) InstantiateRemainingCustomNpcs(mainPath); + RegisterPreparedCustomNpcsForNetworking(); return false; // Skip original loader } } @@ -778,13 +1121,6 @@ private static void NPCInventory_Awake_Postfix(S1NPCs.NPCInventory __instance) { var wrapperInventory = new NPCInventory(apiNpc); wrapperInventory.EnsureInitialized(); - - // Load pending inventory after slots are initialized - if (baseNpc != null && _pendingInventoryLoads.TryGetValue(baseNpc.ID, out var pendingItemSet)) - { - pendingItemSet.LoadTo(__instance.ItemSlots); - _pendingInventoryLoads.Remove(baseNpc.ID); - } } catch (Exception ex) { @@ -842,6 +1178,26 @@ private static bool Dealer_Awake_Prefix(S1Economy.Dealer __instance) return true; } + [HarmonyPatch(typeof(S1Economy.Dealer), "SetUpDialogue")] + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + private static void Dealer_SetUpDialogue_Prefix(S1Economy.Dealer __instance) + { + if (!IsS1ApiCustomNpcComponent(__instance)) + return; + + try + { + NPCDataAccess.EnsureDealerDialogueDefaults(__instance); + } + catch (Exception ex) + { + Logger.Error( + $"Dealer_SetUpDialogue_Prefix: Failed to repair dealer dialogue for " + + $"'{__instance?.ID ?? ""}': {ex.Message}"); + } + } + internal static bool IsS1ApiCustomNpcComponent(Component component) { if (component == null) @@ -925,7 +1281,6 @@ private static void NPCStart(S1NPCs.NPC __instance) else { apiNpc.CreateFromClientNetworkSpawn(); - NPC.CheckAndSetCustomNpcsReady(); } // Ensure visibility is set correctly on clients based on IsPhysical @@ -1098,8 +1453,8 @@ private static void NPC_GetSaveData(S1NPCs.NPC __instance, ref S1Datas.DynamicSa /// /// Temporary patch while S1API NPCs are not networked - /// Handle NPCLoader.Load for custom S1API NPCs to avoid inventory hydration which uses networking. - /// Replicates core parts of the original loader except Inventory and Health (Health already guarded). + /// Handles NPCLoader.Load for custom S1API NPCs without native networked inventory hydration. + /// Restores saved inventory after the final slot collection is initialized. /// [HarmonyPatch(typeof(S1Loaders.NPCLoader), nameof(S1Loaders.NPCLoader.Load))] [HarmonyPrefix] @@ -1147,6 +1502,7 @@ private static bool NPCLoader_Load_Prefix(S1Datas.DynamicSaveData saveData) { return true; // run original for base NPCs } + var customNpc = apiNpc; // Custom S1API NPC: perform safe subset of loading and skip original try @@ -1161,51 +1517,15 @@ private static bool NPCLoader_Load_Prefix(S1Datas.DynamicSaveData saveData) s1BaseNpc.Load(saveData, baseData); - // Check if relationship data exists in save + // Native relationship data is authoritative whenever a finite saved delta exists. if (saveData.TryGetData("Relationship", out S1Datas.RelationshipData rel) && rel != null && s1BaseNpc.RelationData != null) { - if (!float.IsNaN(rel.RelationDelta) && !float.IsInfinity(rel.RelationDelta)) - { - s1BaseNpc.RelationData.SetRelationship(rel.RelationDelta); - } - - if (rel.Unlocked) - { - s1BaseNpc.RelationData.Unlock(rel.UnlockType, notify: false); - - // Store unlock type for potential restoration - try - { - apiNpc = FindWrapperForS1Npc(s1BaseNpc); - if (apiNpc != null) - { - var unlockTypeField = typeof(NPC).GetField("_loadedUnlockType", BindingFlags.NonPublic | BindingFlags.Instance); - if (unlockTypeField != null) - { - var s1UnlockType = rel.UnlockType == S1Relation.NPCRelationData.EUnlockType.Recommendation - ? S1Relation.NPCRelationData.EUnlockType.Recommendation - : S1Relation.NPCRelationData.EUnlockType.DirectApproach; - unlockTypeField.SetValue(apiNpc, s1UnlockType); - } - } - } - catch { } - } + apiNpc.LoadRelationshipFromSave( + rel.RelationDelta, + rel.Unlocked, + rel.UnlockType); } - // IMPORTANT: Mark as loaded from save IMMEDIATELY after processing relationship data - // This must happen before FinalizeNetworkSpawn runs, otherwise defaults will overwrite loaded data - try - { - apiNpc = FindWrapperForS1Npc(s1BaseNpc); - if (apiNpc != null) - { - typeof(NPC).GetMethod("MarkLoadedFromSave", BindingFlags.NonPublic | BindingFlags.Instance) - ?.Invoke(apiNpc, null); - } - } - catch { } - if (saveData.TryGetData("MessageConversation", out S1Datas.MSGConversationData convo)) { apiNpc?.EnsureMessageConversationReady(resetDefaults: false); @@ -1276,7 +1596,19 @@ private static bool NPCLoader_Load_Prefix(S1Datas.DynamicSaveData saveData) { if (S1Datas.ItemSet.TryDeserialize(inventoryData, out var itemSet)) { - itemSet.LoadTo(s1BaseNpc.Inventory.ItemSlots); + RestoreInventoryAfterInitialization( + customNpc.Inventory.EnsureInitialized, + () => + { + var inventory = s1BaseNpc.GetComponent(); + if (inventory?.ItemSlots == null) + { + throw new InvalidOperationException( + $"Inventory slots were not initialized for custom NPC '{baseData.ID}'."); + } + + itemSet.LoadTo(inventory.ItemSlots); + }); } else { @@ -1289,7 +1621,6 @@ private static bool NPCLoader_Load_Prefix(S1Datas.DynamicSaveData saveData) $"NPCLoader_Load_Prefix: Exception loading Inventory data for '{baseData.ID}': {ex.Message}"); } } - } catch (Exception ex) { @@ -1302,37 +1633,24 @@ private static bool NPCLoader_Load_Prefix(S1Datas.DynamicSaveData saveData) var wrap = FindWrapperForS1Npc(s1BaseNpc); if (wrap != null) { - // Mark that this instance was hydrated from save data FIRST to prevent defaults overwrite - typeof(NPC).GetMethod("MarkLoadedFromSave", BindingFlags.NonPublic | BindingFlags.Instance) - ?.Invoke(wrap, null); - var npcType = wrap.GetType(); bool hasDefaults = NPC.TypeToRelationshipDefaults.TryGetValue(npcType, out var relCfg) && relCfg != null; - - if (hasDefaults) + + if (!NPCRelationshipPersistencePolicy.ShouldApplyDefaults( + wrap.RelationshipLoadedFromSave)) + { + s1BaseNpc.GetComponent() + ?.ApplyRelationshipConnectionsTo(s1BaseNpc); + } + else if (hasDefaults) { var builder = new NPCRelationshipDataBuilder(); relCfg!(builder); var rel = s1BaseNpc.RelationData; if (rel != null) { - // Preserve relationship delta if it was loaded from save (non-default value) - // Default relationship delta is 2.0, so if it's different, it came from save - float currentDelta = rel.RelationDelta; - bool deltaWasLoadedFromSave = Math.Abs(currentDelta - DefaultRelationDelta) > 0.01f; - - // Store the loaded delta before applying defaults - float savedDelta = currentDelta; - bool beforeApplyDefaults = rel.Unlocked; builder.ApplyTo(rel, s1BaseNpc, preserveUnlockState: true); - - // Restore relationship delta if it was loaded from save - if (deltaWasLoadedFromSave) - { - rel.SetRelationship(savedDelta); - } - bool afterApplyDefaults = rel.Unlocked; if (beforeApplyDefaults && !afterApplyDefaults) @@ -1346,10 +1664,13 @@ private static bool NPCLoader_Load_Prefix(S1Datas.DynamicSaveData saveData) } } - // SetVisible(false) deactivates the Avatar GameObject. Custom suppliers - // must remain active through FishNet spawn so native NPC.Awake can find - // the Avatar reference; FinalizeNetworkSpawn applies idle visibility. - if (NPC.ShouldApplyLoadedVisibilityBeforeSpawn(wrap.IsSupplier)) + // SetVisible(false) deactivates the Avatar GameObject. Invisible NPCs and + // custom suppliers must remain active through FishNet spawn so native + // NPC.Awake can find the Avatar reference; FinalizeNetworkSpawn applies + // their intended visibility. + if (NPC.ShouldApplyLoadedVisibilityBeforeSpawn( + wrap.IsPhysical, + wrap.IsSupplier)) { s1BaseNpc.SetVisible( wrap.ShouldBeVisibleAfterSpawn(), @@ -1388,58 +1709,31 @@ private static void ApplyCustomerDataSafely(S1Economy.Customer customerComponent // Ensure internal data structures exist first try { - // Use reflection to access currentAffinityData field/property - PropertyInfo? currentAffinityProp; - FieldInfo? currentAffinityField; - currentAffinityField = customerType.GetField("currentAffinityData", - BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); - currentAffinityProp = customerType.GetProperty("currentAffinityData", - BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); - - S1Economy.CustomerAffinityData? currentAffinity = null; - if (currentAffinityField != null) - { - if (currentAffinityField is FieldInfo field) - { - currentAffinity = field.GetValue(customerComponent) as S1Economy.CustomerAffinityData; - } - else if (currentAffinityProp is PropertyInfo prop) - { - currentAffinity = prop.GetValue(customerComponent) as S1Economy.CustomerAffinityData; - } - } + var currentAffinity = Utils.ReflectionUtils.TryGetFieldOrProperty( + customerComponent, + "currentAffinityData") as S1Economy.CustomerAffinityData; if (currentAffinity == null) { currentAffinity = new S1Economy.CustomerAffinityData(); - var customerDataProp = customerType.GetProperty("CustomerData", - BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); - if (customerDataProp != null) + var customerData = Utils.ReflectionUtils.TryGetFieldOrProperty(customerComponent, "CustomerData"); + if (customerData != null) { - var customerData = customerDataProp.GetValue(customerComponent); - if (customerData != null) + var defaults = Utils.ReflectionUtils.TryGetFieldOrProperty( + customerData, + "DefaultAffinityData"); + if (defaults != null) { - var defaultAffinityProp = customerData.GetType().GetProperty("DefaultAffinityData", - BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); - var defaults = defaultAffinityProp?.GetValue(customerData); - if (defaults != null) - { - var copyToMethod = defaults.GetType().GetMethod("CopyTo", - BindingFlags.Public | BindingFlags.Instance); - copyToMethod?.Invoke(defaults, new object[] { currentAffinity }); - } + var copyToMethod = defaults.GetType().GetMethod("CopyTo", + BindingFlags.Public | BindingFlags.Instance); + copyToMethod?.Invoke(defaults, new object[] { currentAffinity }); } } - // Set the new currentAffinityData back - if (currentAffinityField is FieldInfo setField) - { - setField.SetValue(customerComponent, currentAffinity); - } - else if (currentAffinityProp is PropertyInfo setProp && setProp.CanWrite) - { - setProp.SetValue(customerComponent, currentAffinity); - } + Utils.ReflectionUtils.TrySetFieldOrProperty( + customerComponent, + "currentAffinityData", + currentAffinity); } if (cust.ProductAffinities != null && currentAffinity != null) @@ -1774,29 +2068,39 @@ private static bool NPCInventory_OnSleepStart_Prefix(S1NPCs.NPCInventory __insta } /// - /// Temporary patch while S1API NPCs are not networked - /// Guard Revive() for custom S1API NPCs to avoid Health SyncVar access before FishNet init. - /// Applies equivalent revive effects without touching the SyncVar setter path. - /// TODO: Restrict this reflective fallback to the pre-network-init window only. - /// Once a custom NPC is live/networked, revive should stay on the authoritative server/original path instead of - /// mutating local health/death state and skipping the replicated revive flow. + /// Guard Revive() for custom S1API NPCs before FishNet initialization. + /// Applies equivalent revive effects without touching the SyncVar setter path until the NPC is spawned. /// [HarmonyPatch(typeof(S1NPCs.NPCHealth), nameof(S1NPCs.NPCHealth.Revive))] [HarmonyPrefix] private static bool NPCHealth_Revive_Prefix(S1NPCs.NPCHealth __instance) { - if (!IsInMainScene()) - return true; // allow original behaviour outside of Main + bool isInMainScene = IsInMainScene(); + if (!isInMainScene) + return true; + var baseNpc = __instance.GetComponent(); var apiNpc = baseNpc != null ? FindWrapperForS1Npc(baseNpc) : null; if (apiNpc == null || !apiNpc.IsCustomNPC) - return true; // use original for base NPCs + return true; + + if (NPCHealthRevivePolicy.ShouldSuppressSpawnedClientRevive( + __instance.IsSpawned, + InstanceFinder.IsServer)) + { + return false; + } + + if (!NPCHealthRevivePolicy.ShouldUsePreSpawnFallback( + isInMainScene, + true, + __instance.IsSpawned)) + { + return true; + } try { - // NOTE: This currently mutates revive state locally and then always skips the original revive call below. - // That is only safe before networking is initialized; on live/networked NPCs, client-side calls can revive - // only the local wrapper copy while server-side calls bypass the authoritative replicated revive path. bool healthSet = Utils.ReflectionUtils.TrySetFieldOrProperty( __instance, "k__BackingField", __instance.MaxHealth); bool isDeadSet = Utils.ReflectionUtils.TrySetFieldOrProperty(__instance, "IsDead", false); @@ -1817,7 +2121,7 @@ private static bool NPCHealth_Revive_Prefix(S1NPCs.NPCHealth __instance) // Fire revive event so downstream listeners still react __instance.onRevive?.Invoke(); - return false; // skip original to avoid SyncVar/networking calls; revisit for post-init/live NPC revives. + return false; // skip original to avoid SyncVar/networking calls before FishNet initialization. } catch (Exception ex) { @@ -1858,17 +2162,16 @@ private static void NPCLoader_Load_Postfix(S1Datas.DynamicSaveData saveData) apiNpc.LoadFromDynamic(saveData); - // Mark as loaded from save so prefab defaults won't overwrite - try - { - typeof(NPC).GetMethod("MarkLoadedFromSave", BindingFlags.NonPublic | BindingFlags.Instance) - ?.Invoke(apiNpc, null); - } - catch (Exception ex) + if (saveData.TryGetData( + "Relationship", + out S1Datas.RelationshipData relationshipData) + && relationshipData != null) { - Logger.Warning($"[S1API] NPCLoader_Load_Postfix: Exception marking NPC '{baseData.ID}' as loaded: {ex.Message}"); + apiNpc.LoadRelationshipFromSave( + relationshipData.RelationDelta, + relationshipData.Unlocked, + relationshipData.UnlockType); } - } /// @@ -2124,14 +2427,7 @@ private static void NPCManager_GetNPC_Postfix(string id, ref S1NPCs.NPC __result [HarmonyPrefix] private static bool NPCMovement_SetGravityMultiplier_Prefix(S1NPCs.NPCMovement __instance, float multiplier) { -#if !IL2CPPMELON - var ragdollForceComponentsField = typeof(S1NPCs.NPCMovement).GetField("ragdollForceComponents", - BindingFlags.NonPublic | BindingFlags.Instance); - var ragdollForceComponents = ragdollForceComponentsField?.GetValue(__instance) as List; -#else - var ragdollForceComponents = __instance.ragdollForceComponents; -#endif - return ragdollForceComponents == null || ragdollForceComponents.ToArray().All(comp => comp != null); + return true; } /// @@ -2150,7 +2446,7 @@ private static bool NPCHealth_Awake_Prefix(S1NPCs.NPCHealth __instance) var npc = __instance.GetComponent(); #if (!IL2CPPMELON) var npcField = typeof(S1NPCs.NPCHealth) - .GetField("npc", BindingFlags.NonPublic | BindingFlags.Instance); + .GetField("_npc", BindingFlags.NonPublic | BindingFlags.Instance); if (npcField != null) npcField.SetValue(__instance, npc); @@ -2177,7 +2473,7 @@ private static bool NPCHealth_Awake_Prefix(S1NPCs.NPCHealth __instance) (Action)Delegate.Combine(TimeManagerShim.Instance.onHourPass, hourPassDelegate); } #else - __instance.npc = npc; + __instance._npc = npc; TimeManagerShim.Instance.onSleepStart = (Action)Delegate.Combine(TimeManagerShim.Instance.onSleepStart, new Action(__instance.SleepStart)); @@ -2460,16 +2756,6 @@ private static bool Dealer_Load_Prefix(S1Economy.Dealer __instance, S1Datas.Dyna } } - if (isCustomNPC) - { - if (dynamicData.TryGetData("Inventory", out var inventoryData)) - { - if (S1Datas.ItemSet.TryDeserialize(inventoryData, out var itemSet)) - _pendingInventoryLoads[__instance.ID] = itemSet; - else - Logger.Warning($"Failed to deserialize inventory data for custom NPC dealer {__instance.ID}"); - } - } } catch (Exception ex) { diff --git a/S1API/Internal/Patches/ProductPresentationPatches.cs b/S1API/Internal/Patches/ProductPresentationPatches.cs new file mode 100644 index 00000000..9508b51b --- /dev/null +++ b/S1API/Internal/Patches/ProductPresentationPatches.cs @@ -0,0 +1,36 @@ +using HarmonyLib; +using S1API.Internal.Products; +#if IL2CPPMELON +using S1Product = Il2CppScheduleOne.Product; +using EffectList = Il2CppSystem.Collections.Generic.List; +#elif MONOMELON +using S1Product = ScheduleOne.Product; +using EffectList = System.Collections.Generic.List; +#endif + +namespace S1API.Internal.Patches +{ + /// + /// INTERNAL: Shows registered custom product visuals in the native new-mix display. + /// + [HarmonyPatch( + typeof(S1Product.NewMixDiscoveryBox), + nameof(S1Product.NewMixDiscoveryBox.ShowProduct))] + internal static class ProductPresentationPatches + { + [HarmonyPostfix] + private static void ShowProductPostfix( + S1Product.NewMixDiscoveryBox __instance, + S1Product.ProductDefinition baseDefinition, + EffectList properties) + { + if (__instance?.Visuals == null) + return; + + CustomProductPresentationRuntime.ApplyDiscoveryVisual( + __instance.Visuals, + baseDefinition, + properties); + } + } +} diff --git a/S1API/Internal/Patches/QuestPatches.cs b/S1API/Internal/Patches/QuestPatches.cs index d7426ac9..7420411e 100644 --- a/S1API/Internal/Patches/QuestPatches.cs +++ b/S1API/Internal/Patches/QuestPatches.cs @@ -127,6 +127,8 @@ private static void SaveManager_Save_Postfix(string saveFolderPath) [HarmonyPrefix] private static void QuestsLoaderLoad_Prefix(string mainPath) { + NPCPatches.PrepareCustomNpcsForContractLoad(); + // Load and parse the Quests.json file to extract modded quest data if (!File.Exists(mainPath)) return; diff --git a/S1API/Internal/Patches/StoragePatches.cs b/S1API/Internal/Patches/StoragePatches.cs index b72aa458..cf48b9db 100644 --- a/S1API/Internal/Patches/StoragePatches.cs +++ b/S1API/Internal/Patches/StoragePatches.cs @@ -42,6 +42,9 @@ internal static class StoragePatches private static readonly HashSet _processedStorages = new HashSet(); private const string ExtraSlotMetaKey = "S1API_Storage_SlotMeta"; + internal static int ResolveRestoreMaxSlots(int configuredMaxSlots, int persistedSlotCount) => + Math.Max(configuredMaxSlots, persistedSlotCount); + [Serializable] private class StorageSlotMeta : S1Persistence.SaveData { @@ -381,7 +384,14 @@ private static bool PlaceableStorageEntityLoader_Load_Prefix(S1PersistenceLoader // Expand slots before hydrating contents var wrapper = new StorageEntity(storageEntity, placeableStorage!); - wrapper.SetSlotCount(targetSlots); + wrapper.MaxSlots = ResolveRestoreMaxSlots(wrapper.MaxSlots, targetSlots); + if (!wrapper.SetSlotCount(targetSlots)) + { + string itemId = placeableStorage!.ItemInstance?.Definition?.ID ?? "unknown"; + Logger.Warning( + $"Failed to restore {targetSlots} storage slots for item '{itemId}'. " + + "Saved contents may not fit in the available slots."); + } contents.LoadTo(storageEntity.ItemSlots); diff --git a/S1API/Internal/Patches/TVPatches.cs b/S1API/Internal/Patches/TVPatches.cs index 231b8325..9ed195a4 100644 --- a/S1API/Internal/Patches/TVPatches.cs +++ b/S1API/Internal/Patches/TVPatches.cs @@ -256,14 +256,8 @@ static void Prefix(TVHomeScreen __instance) if (!SkipInterfaceClose) return; - // Set skipExit via reflection. If this fails on IL2CPP, - // TVInterface_Close_Patch will catch and skip Interface.Close() - var field = typeof(TVHomeScreen).GetField("skipExit", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - if (field != null) + if (ReflectionUtils.TrySetFieldOrProperty(__instance, "skipExit", true)) { - field.SetValue(__instance, true); SkipInterfaceClose = false; } } diff --git a/S1API/Internal/Products/CustomProductDefinitionMetadata.cs b/S1API/Internal/Products/CustomProductDefinitionMetadata.cs index 50e772a2..d75ab374 100644 --- a/S1API/Internal/Products/CustomProductDefinitionMetadata.cs +++ b/S1API/Internal/Products/CustomProductDefinitionMetadata.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using S1API.Products; +using UnityEngine; namespace S1API.Internal.Products { @@ -39,7 +40,8 @@ internal CustomProductDefinitionMetadata( ProductKind productKind, Quality defaultQuality, IReadOnlyList validPackaging, - S1Product.ProductDefinition? representationTemplate) + S1Product.ProductDefinition? representationTemplate, + Color32? generatedMixColor = null) { ProductKind = productKind; DefaultQuality = defaultQuality; @@ -49,6 +51,7 @@ internal CustomProductDefinitionMetadata( ValidPackaging = new List(validPackaging).AsReadOnly(); RepresentationTemplate = representationTemplate; + GeneratedMixColor = generatedMixColor; } internal ProductKind ProductKind { get; } @@ -58,5 +61,7 @@ internal CustomProductDefinitionMetadata( internal IReadOnlyList ValidPackaging { get; } internal S1Product.ProductDefinition? RepresentationTemplate { get; } + + internal Color32? GeneratedMixColor { get; } } } diff --git a/S1API/Internal/Products/CustomProductManifestData.cs b/S1API/Internal/Products/CustomProductManifestData.cs index d73ac3e1..3e224cb7 100644 --- a/S1API/Internal/Products/CustomProductManifestData.cs +++ b/S1API/Internal/Products/CustomProductManifestData.cs @@ -581,6 +581,14 @@ private static string ComputeCompatibilityHash( Append(builder, descriptor.DefaultQuality.ToString(CultureInfo.InvariantCulture)); Append(builder, descriptor.PlayerEffectDurationSeconds.ToString(CultureInfo.InvariantCulture)); Append(builder, descriptor.NpcEffectDurationSeconds.ToString(CultureInfo.InvariantCulture)); + Append(builder, descriptor.HasGeneratedMixColor ? "1" : "0"); + if (descriptor.HasGeneratedMixColor) + { + Append(builder, descriptor.GeneratedMixColorR.ToString(CultureInfo.InvariantCulture)); + Append(builder, descriptor.GeneratedMixColorG.ToString(CultureInfo.InvariantCulture)); + Append(builder, descriptor.GeneratedMixColorB.ToString(CultureInfo.InvariantCulture)); + Append(builder, descriptor.GeneratedMixColorA.ToString(CultureInfo.InvariantCulture)); + } for (int i = 0; i < descriptor.PropertyIds.Length; i++) AppendIdentifier(builder, descriptor.PropertyIds[i]); // Provider data remains local; only its deterministic digest participates in compatibility. diff --git a/S1API/Internal/Products/CustomProductManifestRuntime.cs b/S1API/Internal/Products/CustomProductManifestRuntime.cs index f8f15557..72e553f1 100644 --- a/S1API/Internal/Products/CustomProductManifestRuntime.cs +++ b/S1API/Internal/Products/CustomProductManifestRuntime.cs @@ -26,7 +26,8 @@ namespace S1API.Internal.Products /// INTERNAL: Owns the host-authoritative manifest handshake. internal static class CustomProductManifestRuntime { - private const int HandshakeTimeoutSeconds = 15; + private const int ClientManifestTimeoutSeconds = 15; + internal const int HostAcknowledgementTimeoutSeconds = 60; private static readonly object Gate = new object(); private static readonly Dictionary PendingHostDataByConnection = @@ -46,6 +47,7 @@ private static readonly HashSet private static bool _clientLifecycleSubscribed; private static bool _clientSessionActive; private static bool _clientDefinitionsReady; + private static bool _clientManifestReceived; private static bool _hostActive; private static bool _hostManifestReady; private static bool _hostRequiresValidation; @@ -150,8 +152,7 @@ internal static void FinalizeHostManifestAfterDescriptorRestore() _hostPayload = manifest.Serialize(_sessionId); _hostHash = manifest.CompatibilityHash; _hostEntryCount = manifest.Entries.Length; - _hostRequiresValidation = manifest.Entries.Length != 0 || - manifest.MixingProfiles.Length != 0; + _hostRequiresValidation = RequiresValidation(manifest); } catch (Exception exception) { @@ -198,8 +199,7 @@ internal static void RefreshHostManifestIfReady() _hostPayload = manifest.Serialize(_sessionId); _hostHash = manifest.CompatibilityHash; _hostEntryCount = manifest.Entries.Length; - _hostRequiresValidation = manifest.Entries.Length != 0 || - manifest.MixingProfiles.Length != 0; + _hostRequiresValidation = RequiresValidation(manifest); Info("host manifest refreshed after dynamic custom-product registration; entries=" + _hostEntryCount); } @@ -215,6 +215,7 @@ internal static void BeginClientSession() { _clientSessionActive = true; _clientDefinitionsReady = false; + _clientManifestReceived = false; _pendingClientManifest = null; _localClientManifest = null; ClientGate.Begin(requiresValidation: true); @@ -304,6 +305,7 @@ internal static void EndClientSession() { _clientSessionActive = false; _clientDefinitionsReady = false; + _clientManifestReceived = false; _pendingClientManifest = null; _localClientManifest = null; ClientGate.End(); @@ -343,7 +345,11 @@ internal static void Tick() var rejectedConnections = new List(); lock (Gate) { - rejectClient = ClientGate.IsWaiting && now >= _clientDeadline; + rejectClient = ShouldRejectClientForMissingManifest( + ClientGate.IsWaiting, + _clientManifestReceived, + now, + _clientDeadline); if (rejectClient) ClientGate.End(); @@ -388,15 +394,29 @@ internal static bool AuthorizeClientPlayerDataRequest(Action request) } bool authorized = ClientGate.AuthorizePlayerDataRequest(request); - if (!authorized) + if (ShouldStartClientManifestDeadline( + authorized, + _clientDeadline)) { - _clientDeadline = DateTime.UtcNow.AddSeconds(HandshakeTimeoutSeconds); + _clientDeadline = DateTime.UtcNow.AddSeconds(ClientManifestTimeoutSeconds); Info("client player-data request deferred until manifest validation"); } return authorized; } } + internal static bool ShouldStartClientManifestDeadline( + bool authorized, + DateTime currentDeadline) => + !authorized && currentDeadline == DateTime.MaxValue; + + internal static bool ShouldRejectClientForMissingManifest( + bool isWaiting, + bool manifestReceived, + DateTime now, + DateTime deadline) => + isWaiting && !manifestReceived && now >= deadline; + internal static bool AuthorizeHostPlayerData( object player, FishNetConnection.NetworkConnection connection, @@ -586,7 +606,7 @@ private static bool SendManifest(FishNetConnection.NetworkConnection connection) PendingConnections[connectionId] = new PendingConnection( connectionId, connection, - DateTime.UtcNow.AddSeconds(HandshakeTimeoutSeconds)); + DateTime.UtcNow.AddSeconds(HostAcknowledgementTimeoutSeconds)); } try @@ -696,6 +716,7 @@ private static void ReceiveManifest(CustomProductManifestBroadcast message) { if (!_clientSessionActive) return; + _clientManifestReceived = true; if (!_clientDefinitionsReady) { if (_pendingClientManifest != null && @@ -754,7 +775,7 @@ private static void OnClientDefinitionsReady() return; _localClientManifest = localManifest; _clientDefinitionsReady = true; - ClientGate.Begin(localManifest.Entries.Length != 0); + ClientGate.Begin(RequiresValidation(localManifest)); pending = _pendingClientManifest; _pendingClientManifest = null; } @@ -765,6 +786,11 @@ private static void OnClientDefinitionsReady() ProcessManifest(pending); } + internal static bool RequiresValidation( + CustomProductManifestData manifest) => + manifest.Entries.Length != 0 || + manifest.MixingProfiles.Length != 0; + private static void ProcessManifest(CustomProductManifestData manifest) { CustomProductManifestData localManifest; @@ -930,6 +956,7 @@ private static void RejectClient(string reason) ClientGate.End(); _clientSessionActive = false; _clientDefinitionsReady = false; + _clientManifestReceived = false; _pendingClientManifest = null; _localClientManifest = null; } diff --git a/S1API/Internal/Products/CustomProductMixingIdentity.cs b/S1API/Internal/Products/CustomProductMixingIdentity.cs index 9fb385ca..fd7f000f 100644 --- a/S1API/Internal/Products/CustomProductMixingIdentity.cs +++ b/S1API/Internal/Products/CustomProductMixingIdentity.cs @@ -6,6 +6,8 @@ namespace S1API.Internal.Products /// INTERNAL: Allocates stable IDs after the native mix-name sanitizer has run. internal static class CustomProductMixingIdentity { + private const string FallbackOwnerId = "s1api"; + internal static string CreateGeneratedProductId( string sourceProductId, string nativeMixId) @@ -16,10 +18,15 @@ internal static string CreateGeneratedProductId( int separator = sourceId.IndexOf(':'); string ownerId = sourceId.Substring(0, separator); - string sourceName = sourceId.Substring(separator + 1); + // Do not embed the prior generated ID here. A generated output can itself + // be mixed, and retaining that full lineage adds another "/mix/..." segment + // on every generation until it cannot be advertised in the bounded manifest. + // Hashing the complete normalized source keeps distinct source products + // distinct when an overflowing lineage needs a bounded replacement ID. + string sourceHash = CustomProductManifestData.ComputeHash(sourceId); string nativeHash = CustomProductManifestData.ComputeHash(nativeMixId); return ProductKindId.Normalize( - ownerId + ":mix/" + sourceName + "/" + nativeHash, + CreateGeneratedPrefix(sourceId, ownerId, sourceHash) + nativeHash, nameof(nativeMixId)); } @@ -27,9 +34,49 @@ internal static bool IsGeneratedIdForSource(string sourceProductId, string produ { string sourceId = ProductKindId.Normalize(sourceProductId, nameof(sourceProductId)); int separator = sourceId.IndexOf(':'); - string prefix = sourceId.Substring(0, separator) + ":mix/" + + string ownerId = sourceId.Substring(0, separator); + string boundedPrefix = CreateGeneratedPrefix( + sourceId, + ownerId, + CustomProductManifestData.ComputeHash(sourceId)); + if (productId.StartsWith(boundedPrefix, StringComparison.OrdinalIgnoreCase)) + return true; + + // Keep already-persisted generated IDs recognizable even when a source + // reached the bounded source-hash form in an earlier runtime. + string sourceHashPrefix = CreateBoundedSourceHashPrefix( + ownerId, + CustomProductManifestData.ComputeHash(sourceId)); + if (productId.StartsWith(sourceHashPrefix, StringComparison.OrdinalIgnoreCase)) + return true; + + string legacyPrefix = ownerId + ":mix/" + + sourceId.Substring(separator + 1) + "/"; + return productId.StartsWith(legacyPrefix, StringComparison.OrdinalIgnoreCase); + } + + private static string CreateGeneratedPrefix( + string sourceId, + string ownerId, + string sourceHash) + { + int separator = sourceId.IndexOf(':'); + string legacyPrefix = ownerId + ":mix/" + sourceId.Substring(separator + 1) + "/"; - return productId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + return legacyPrefix.Length + 64 <= + CustomProductManifestData.MaximumIdentifierLength + ? legacyPrefix + : CreateBoundedSourceHashPrefix(ownerId, sourceHash); + } + + private static string CreateBoundedSourceHashPrefix( + string ownerId, + string sourceHash) + { + string prefix = ownerId + ":mix/" + sourceHash + "/"; + return prefix.Length + 64 <= CustomProductManifestData.MaximumIdentifierLength + ? prefix + : FallbackOwnerId + ":mix/" + sourceHash + "/"; } } } diff --git a/S1API/Internal/Products/CustomProductMixingProfileManifestEntryData.cs b/S1API/Internal/Products/CustomProductMixingProfileManifestEntryData.cs index a0d34b06..5ca338eb 100644 --- a/S1API/Internal/Products/CustomProductMixingProfileManifestEntryData.cs +++ b/S1API/Internal/Products/CustomProductMixingProfileManifestEntryData.cs @@ -12,6 +12,7 @@ internal sealed class CustomProductMixingProfileManifestEntryData public int MixerMap; public string OutputFactoryIdentity = string.Empty; public int OutputFactoryVersion; + public bool UsePropertyColorMixing; internal static CustomProductMixingProfileManifestEntryData Create(ProductMixingProfile profile) => new CustomProductMixingProfileManifestEntryData @@ -19,7 +20,8 @@ internal static CustomProductMixingProfileManifestEntryData Create(ProductMixing ProductKindId = profile.ProductKind.Id, MixerMap = (int)profile.MixerMap, OutputFactoryIdentity = profile.OutputFactoryIdentity, - OutputFactoryVersion = profile.OutputFactoryVersion + OutputFactoryVersion = profile.OutputFactoryVersion, + UsePropertyColorMixing = profile.UsePropertyColorMixing }; internal bool IsValid() => @@ -39,6 +41,7 @@ internal void AppendCanonical(StringBuilder builder) builder.Append(MixerMap.ToString(CultureInfo.InvariantCulture)).Append('|'); builder.Append(OutputFactoryIdentity.ToUpperInvariant()).Append('|'); builder.Append(OutputFactoryVersion.ToString(CultureInfo.InvariantCulture)).Append('|'); + builder.Append(UsePropertyColorMixing ? "1|" : "0|"); } internal string? DescribeMismatch(CustomProductMixingProfileManifestEntryData local) @@ -50,6 +53,8 @@ internal void AppendCanonical(StringBuilder builder) { return "output-factory compatibility identity or version differs"; } + if (UsePropertyColorMixing != local.UsePropertyColorMixing) + return "property-color mixing strategy differs"; return null; } } diff --git a/S1API/Internal/Products/CustomProductPresentationRuntime.cs b/S1API/Internal/Products/CustomProductPresentationRuntime.cs index b6b96f3e..ed2fefdf 100644 --- a/S1API/Internal/Products/CustomProductPresentationRuntime.cs +++ b/S1API/Internal/Products/CustomProductPresentationRuntime.cs @@ -1,15 +1,19 @@ #if IL2CPPMELON using S1AvatarEquipping = Il2CppScheduleOne.AvatarFramework.Equipping; +using S1Effects = Il2CppScheduleOne.Effects; using S1Equipping = Il2CppScheduleOne.Equipping; using S1Product = Il2CppScheduleOne.Product; using S1Station = Il2CppScheduleOne.StationFramework; using S1Storage = Il2CppScheduleOne.Storage; +using EffectList = Il2CppSystem.Collections.Generic.List; #elif MONOMELON using S1AvatarEquipping = ScheduleOne.AvatarFramework.Equipping; +using S1Effects = ScheduleOne.Effects; using S1Equipping = ScheduleOne.Equipping; using S1Product = ScheduleOne.Product; using S1Station = ScheduleOne.StationFramework; using S1Storage = ScheduleOne.Storage; +using EffectList = System.Collections.Generic.List; #endif using System; @@ -38,6 +42,8 @@ internal sealed class CustomProductPresentationRuntime : private static readonly Queue GeneratedIconQueue = new Queue(); private static bool _isProcessingGeneratedIcons; + private const string DiscoveryVisualRootName = + "S1API_CustomProductDiscoveryVisual"; private sealed class GeneratedIconRequest { @@ -98,6 +104,174 @@ public void Apply( BuildAndCommit(product, profile); } + internal static void ApplyDiscoveryVisual( + S1Product.MultiTypeVisualsSetter setter, + S1Product.ProductDefinition product, + EffectList properties) + { + if (setter == null) + return; + + RemoveDiscoveryVisual(setter.transform); + if (product == null || + properties == null || + !CustomProductDefinitionRegistry.TryGetMetadata( + product, + out CustomProductDefinitionMetadata? metadata) || + metadata == null || + !ProductPresentationProfileRegistry.TryResolve( + product.ID, + metadata.ProductKind.Id, + out ProductPresentationProfileRegistration? registration) || + registration == null) + { + return; + } + + ProductPresentationProfile profile = registration.Profile; + if (!profile.TryGetVisualProvider( + ProductPresentationContext.FunctionalProduct, + out Func? provider) || + provider == null) + { + return; + } + + GameObject? source; + try + { + source = provider(); + } + catch (Exception exception) + { + MelonLogger.Warning( + $"[ProductPresentationProfile] Discovery visual provider for " + + $"'{product.ID}' failed: {exception.Message}"); + return; + } + + if (source == null) + return; + + GameObject? root = null; + try + { + root = new GameObject(DiscoveryVisualRootName); + root.transform.SetParent(setter.transform, false); + root.SetActive(false); + GameObject visual = Object.Instantiate(source); + visual.transform.SetParent(root.transform, false); + ApplyVisualTransform( + visual.transform, + profile, + ProductPresentationContext.FunctionalProduct); + ApplyDiscoveryMixColor( + visual, + metadata, + properties); + visual.SetActive(true); + + ResetMultiTypeVisuals(setter); + root.SetActive(true); + } + catch (Exception exception) + { + if (root != null) + Object.Destroy(root); + MelonLogger.Warning( + $"[ProductPresentationProfile] Could not show the discovery " + + $"visual for '{product.ID}'; retaining the native fallback: " + + exception.Message); + } + } + + private static void ApplyDiscoveryMixColor( + GameObject visual, + CustomProductDefinitionMetadata metadata, + EffectList properties) + { + if (!ProductMixingProfiles.TryGet( + metadata.ProductKind.Id, + out ProductMixingProfile? mixingProfile) || + mixingProfile == null || + !mixingProfile.UsePropertyColorMixing) + { + return; + } + + var samples = + new List(properties.Count); + for (int i = 0; i < properties.Count; i++) + { + S1Effects.Effect property = properties[i]; + if (property == null) + continue; + Color32 color = property.ProductColor; + samples.Add( + new ProductMixingColorSample( + (int)property.Tier, + new ProductMixingColorValue( + color.r, + color.g, + color.b, + color.a))); + } + + Color mixedColor = + ProductMixingColorContract.CalculatePrimaryColor( + mixingProfile.MixerMap, + samples).ToColor32(); + Renderer[] renderers = + visual.GetComponentsInChildren(true); + var propertyBlock = new MaterialPropertyBlock(); + for (int i = 0; i < renderers.Length; i++) + { + Renderer renderer = renderers[i]; + renderer.GetPropertyBlock(propertyBlock); + Material[] materials = renderer.sharedMaterials; + for (int materialIndex = 0; + materialIndex < materials.Length; + materialIndex++) + { + Material material = materials[materialIndex]; + if (material == null) + continue; + if (material.HasProperty("_BaseColor")) + propertyBlock.SetColor("_BaseColor", mixedColor); + if (material.HasProperty("_Color")) + propertyBlock.SetColor("_Color", mixedColor); + } + + renderer.SetPropertyBlock(propertyBlock); + propertyBlock.Clear(); + } + } + + private static void ResetMultiTypeVisuals( + S1Product.MultiTypeVisualsSetter setter) + { + setter.WeedVisuals?.ResetVisuals(); + setter.MethVisuals?.ResetVisuals(); + setter.CocaineVisuals?.ResetVisuals(); + setter.ShroomVisuals?.ResetVisuals(); + } + + private static void RemoveDiscoveryVisual(Transform parent) + { + for (int i = parent.childCount - 1; i >= 0; i--) + { + Transform child = parent.GetChild(i); + if (string.Equals( + child.name, + DiscoveryVisualRootName, + StringComparison.Ordinal)) + { + child.gameObject.SetActive(false); + Object.Destroy(child.gameObject); + } + } + } + private static void BuildAndCommit( CustomProductDefinitionRegistration product, ProductPresentationProfileRegistration registration) @@ -227,6 +401,10 @@ private static void BuildAndCommit( source, profile, ProductPresentationContext.Stored); + ApplyGeneratedMixColor( + stored.Visuals.VisualsContainer.gameObject, + product, + created); return clone; } @@ -273,6 +451,10 @@ private static void BuildAndCommit( source, profile, ProductPresentationContext.Held); + ApplyGeneratedMixColor( + held.Visuals.VisualsContainer.gameObject, + product, + created); held.ModelContainer = held.Visuals.VisualsContainer; GameObject avatarSource = ResolveAvatarHeldSource(product, profile, source, sources); @@ -322,12 +504,15 @@ private static void BuildAndCommit( } station.Visuals = - ReplaceVisual( + ReplaceStationVisual( station.gameObject, station.Visuals, source, - profile, - ProductPresentationContext.Station); + profile); + ApplyGeneratedMixColor( + station.Visuals.VisualsContainer.gameObject, + product, + created); return clone; } @@ -378,6 +563,10 @@ private static void BuildAndCommit( source, profile, ProductPresentationContext.FunctionalProduct); + ApplyGeneratedMixColor( + functional.Visuals.VisualsContainer.gameObject, + product, + created); if (profile.UseFunctionalProductConvexMeshColliders) { ReplaceWithConvexMeshColliders( @@ -498,6 +687,7 @@ private static IEnumerator ProcessGeneratedIconQueue() } CustomProductPresentationState state = request.State; + ProductIconRenderRigArbiter.CaptureLease? renderLease = null; try { if (!state.IsGeneratedIconPending || @@ -525,6 +715,25 @@ private static IEnumerator ProcessGeneratedIconQueue() continue; } + renderLease = ProductIconRenderRigArbiter.Enqueue(); + while (!ProductIconRenderRigArbiter.TryAcquire(renderLease)) + { + if (!state.IsGeneratedIconPending || + !ReferenceEquals( + state.AppliedRegistration, + request.Registration)) + { + ProductIconRenderRigArbiter.Cancel(renderLease); + renderLease = null; + break; + } + + yield return null; + } + + if (renderLease == null) + continue; + const int maxRetries = 30; string lastError = "the native renderer returned no visible pixels"; for (int attempt = 0; attempt <= maxRetries; attempt++) @@ -556,9 +765,19 @@ private static IEnumerator ProcessGeneratedIconQueue() if (state.IsGeneratedIconPending) LogGeneratedIconFailure(request, lastError); + + // IconFactory restores the shared rig synchronously. Keep + // ownership through a full settled frame so another subject + // cannot capture a transition between the outgoing model and + // the next queued model. + yield return null; + yield return new WaitForEndOfFrame(); } finally { + if (renderLease != null) + ProductIconRenderRigArbiter.Release(renderLease); + state.IsGeneratedIconQueued = false; if (state.IsGeneratedIconPending && state.AppliedRegistration != null && @@ -611,8 +830,13 @@ private static GeneratedIconAttemptResult TryGenerateIcon( } GameObject model = Object.Instantiate(source); + var generatedMaterials = new List(); try { + ApplyGeneratedMixColor( + model, + request.Product, + generatedMaterials); if (profile.GeneratedIconTransform != null) { profile.GeneratedIconTransform.ApplyTo(model.transform); @@ -651,6 +875,7 @@ private static GeneratedIconAttemptResult TryGenerateIcon( finally { Object.Destroy(model); + DestroyAll(generatedMaterials); } icon = global::S1API.Utils.ImageUtils.TextureToSprite(texture); @@ -864,7 +1089,7 @@ private static void LogGeneratedIconFailure( private static StaticProductVisualsSetter ReplaceVisual( GameObject scaffold, - S1Product.ProductVisualsSetter oldSetter, + S1Product.ProductVisualsSetter? oldSetter, GameObject source, ProductPresentationProfile profile, ProductPresentationContext context) @@ -882,10 +1107,7 @@ private static StaticProductVisualsSetter ReplaceVisual( oldSetter.VisualsContainer.gameObject.SetActive(false); } - GameObject visual = Object.Instantiate(source); - visual.transform.SetParent(parent, false); - ApplyVisualTransform(visual.transform, profile, context); - visual.SetActive(true); + GameObject visual = CreateVisual(source, parent, profile, context); StaticProductVisualsSetter setter = scaffold.AddComponent(); @@ -893,6 +1115,92 @@ private static StaticProductVisualsSetter ReplaceVisual( return setter; } + private static S1Product.ProductVisualsSetter ReplaceStationVisual( + GameObject scaffold, + S1Product.ProductVisualsSetter? oldSetter, + GameObject source, + ProductPresentationProfile profile) + { + S1Station.IngredientPiece? ingredientPiece = + scaffold.GetComponentInChildren(true); + if (ingredientPiece == null || + ingredientPiece.ModelContainer == null || + oldSetter == null || + oldSetter.VisualsContainer == null) + { + return ReplaceVisual( + scaffold, + oldSetter, + source, + profile, + ProductPresentationContext.Station); + } + + Transform? replacementParent = + oldSetter.VisualsContainer != scaffold.transform + ? oldSetter.VisualsContainer.parent + : scaffold.transform; + GameObject visual = + CreateVisual( + source, + replacementParent ?? scaffold.transform, + profile, + ProductPresentationContext.Station); + + Transform interactionRoot = ingredientPiece.transform; + RetireStationModel(ingredientPiece.ModelContainer, interactionRoot); + + // Keep the profile-authored pose while moving the replacement under + // the native rigidbody/Draggable object that owns station interaction. + visual.transform.SetParent(interactionRoot, true); + EnsureCollider(visual); + ingredientPiece.ModelContainer = visual.transform; + + StaticStationProductVisualsSetter setter = + scaffold.AddComponent(); + setter.VisualsContainer = visual.transform; + + // The cached prefab is active so native instantiation preserves its + // active state. Freeze only the physics child until Initialize() + // activates it through the station-specific visuals setter. + interactionRoot.gameObject.SetActive(false); + return setter; + } + + private static GameObject CreateVisual( + GameObject source, + Transform parent, + ProductPresentationProfile profile, + ProductPresentationContext context) + { + GameObject visual = Object.Instantiate(source); + visual.transform.SetParent(parent, false); + ApplyVisualTransform(visual.transform, profile, context); + visual.SetActive(true); + return visual; + } + + private static void RetireStationModel( + Transform modelContainer, + Transform interactionRoot) + { + if (modelContainer != interactionRoot) + { + modelContainer.gameObject.SetActive(false); + return; + } + + Renderer[] renderers = + modelContainer.GetComponentsInChildren(true); + for (int i = 0; i < renderers.Length; i++) + renderers[i].enabled = false; + + Collider[] colliders = + modelContainer.GetComponentsInChildren(true); + for (int i = 0; i < colliders.Length; i++) + colliders[i].enabled = false; + } + private static S1AvatarEquipping.AvatarEquippable BuildAvatarEquippable( CustomProductDefinitionRegistration product, S1AvatarEquipping.AvatarEquippable template, @@ -908,6 +1216,7 @@ private static S1AvatarEquipping.AvatarEquippable BuildAvatarEquippable( created.Add(root); GameObject visual = Object.Instantiate(source); visual.transform.SetParent(root.transform, false); + ApplyGeneratedMixColor(visual, product, created); if (profile.TryGetAvatarHeldTransform( out ProductPresentationTransform? avatarTransform)) { @@ -988,6 +1297,48 @@ private static void ApplyVisualTransform( } } + private static void ApplyGeneratedMixColor( + GameObject visual, + CustomProductDefinitionRegistration product, + List created) + { + Color32? generatedMixColor = product.Metadata?.GeneratedMixColor; + if (!generatedMixColor.HasValue) + return; + + Color color = generatedMixColor.Value; + Renderer[] renderers = visual.GetComponentsInChildren(true); + for (int rendererIndex = 0; + rendererIndex < renderers.Length; + rendererIndex++) + { + Renderer renderer = renderers[rendererIndex]; + Material[] sourceMaterials = renderer.sharedMaterials; + var coloredMaterials = new Material[sourceMaterials.Length]; + for (int materialIndex = 0; + materialIndex < sourceMaterials.Length; + materialIndex++) + { + Material sourceMaterial = sourceMaterials[materialIndex]; + if (sourceMaterial == null) + continue; + + var coloredMaterial = new Material(sourceMaterial) + { + name = sourceMaterial.name + "_S1API_MixedColor" + }; + if (coloredMaterial.HasProperty("_BaseColor")) + coloredMaterial.SetColor("_BaseColor", color); + if (coloredMaterial.HasProperty("_Color")) + coloredMaterial.SetColor("_Color", color); + coloredMaterials[materialIndex] = coloredMaterial; + created.Add(coloredMaterial); + } + + renderer.sharedMaterials = coloredMaterials; + } + } + private static GameObject GetCacheRoot() { if (_cacheRoot != null) diff --git a/S1API/Internal/Products/CustomProductSaveDescriptorData.cs b/S1API/Internal/Products/CustomProductSaveDescriptorData.cs index bc3c316e..3b38b1dc 100644 --- a/S1API/Internal/Products/CustomProductSaveDescriptorData.cs +++ b/S1API/Internal/Products/CustomProductSaveDescriptorData.cs @@ -25,6 +25,11 @@ internal sealed class CustomProductSaveDescriptorData public int ProviderVersion; public string ProviderData = string.Empty; public bool IsGeneratedMix; + public bool HasGeneratedMixColor; + public byte GeneratedMixColorR; + public byte GeneratedMixColorG; + public byte GeneratedMixColorB; + public byte GeneratedMixColorA; } [Serializable] diff --git a/S1API/Internal/Products/CustomProductSavePersistence.cs b/S1API/Internal/Products/CustomProductSavePersistence.cs index b8353719..d1a0dcf8 100644 --- a/S1API/Internal/Products/CustomProductSavePersistence.cs +++ b/S1API/Internal/Products/CustomProductSavePersistence.cs @@ -18,6 +18,7 @@ using Newtonsoft.Json; using S1API.Products; using S1API.Internal.Properties; +using UnityEngine; namespace S1API.Internal.Products { @@ -203,7 +204,19 @@ private static void RestoreFallback(CustomProductSaveDescriptor descriptor) var packagingMetadata = new List(); foreach (NativePackagingDefinition item in packaging) packagingMetadata.Add(new PackagingDefinition(item)); - var metadata = new CustomProductDefinitionMetadata(kind, (Quality)data.DefaultQuality, packagingMetadata, template); + Color32? generatedMixColor = data.HasGeneratedMixColor + ? new Color32( + data.GeneratedMixColorR, + data.GeneratedMixColorG, + data.GeneratedMixColorB, + data.GeneratedMixColorA) + : null; + var metadata = new CustomProductDefinitionMetadata( + kind, + (Quality)data.DefaultQuality, + packagingMetadata, + template, + generatedMixColor); try { CustomProductDefinitionRegistry.Register(data.OwnerId, data.ProductId, data.ProductName, data.InitialPrice, native, metadata, data); diff --git a/S1API/Internal/Products/ProductIconRenderRigArbiter.cs b/S1API/Internal/Products/ProductIconRenderRigArbiter.cs new file mode 100644 index 00000000..ef7c55cd --- /dev/null +++ b/S1API/Internal/Products/ProductIconRenderRigArbiter.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; + +namespace S1API.Internal.Products +{ + /// + /// INTERNAL: Serializes queued captures that share the native item-icon render rig. + /// + internal static class ProductIconRenderRigArbiter + { + private static readonly object Gate = new object(); + private static readonly Queue Pending = + new Queue(); + private static CaptureLease? _owner; + + internal sealed class CaptureLease + { + internal bool Cancelled { get; set; } + } + + internal static CaptureLease Enqueue() + { + lock (Gate) + { + var lease = new CaptureLease(); + Pending.Enqueue(lease); + return lease; + } + } + + internal static bool TryAcquire(CaptureLease lease) + { + lock (Gate) + { + if (lease.Cancelled) + return false; + if (ReferenceEquals(_owner, lease)) + return true; + if (_owner != null) + return false; + + RemoveCancelledHead(); + if (Pending.Count == 0 || + !ReferenceEquals(Pending.Peek(), lease)) + { + return false; + } + + _owner = Pending.Dequeue(); + return true; + } + } + + internal static void Release(CaptureLease lease) + { + lock (Gate) + { + if (ReferenceEquals(_owner, lease)) + _owner = null; + else + lease.Cancelled = true; + + RemoveCancelledHead(); + } + } + + internal static void Cancel(CaptureLease lease) + { + lock (Gate) + { + lease.Cancelled = true; + if (ReferenceEquals(_owner, lease)) + _owner = null; + RemoveCancelledHead(); + } + } + + internal static void ResetForTesting() + { + lock (Gate) + { + Pending.Clear(); + _owner = null; + } + } + + private static void RemoveCancelledHead() + { + while (Pending.Count != 0 && Pending.Peek().Cancelled) + Pending.Dequeue(); + } + } +} diff --git a/S1API/Internal/Products/ProductMixingColorContract.cs b/S1API/Internal/Products/ProductMixingColorContract.cs new file mode 100644 index 00000000..5d69f63b --- /dev/null +++ b/S1API/Internal/Products/ProductMixingColorContract.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using S1API.Products; + +namespace S1API.Internal.Products +{ + internal readonly struct ProductMixingColorValue : IEquatable + { + internal ProductMixingColorValue(byte red, byte green, byte blue, byte alpha) + { + Red = red; + Green = green; + Blue = blue; + Alpha = alpha; + } + + internal byte Red { get; } + + internal byte Green { get; } + + internal byte Blue { get; } + + internal byte Alpha { get; } + + internal UnityEngine.Color32 ToColor32() => + new UnityEngine.Color32(Red, Green, Blue, Alpha); + + public bool Equals(ProductMixingColorValue other) => + Red == other.Red && + Green == other.Green && + Blue == other.Blue && + Alpha == other.Alpha; + + public override bool Equals(object? obj) => + obj is ProductMixingColorValue other && Equals(other); + + public override int GetHashCode() => + HashCode.Combine(Red, Green, Blue, Alpha); + } + + internal readonly struct ProductMixingColorSample + { + internal ProductMixingColorSample( + int tier, + ProductMixingColorValue color) + { + Tier = tier; + Color = color; + } + + internal int Tier { get; } + + internal ProductMixingColorValue Color { get; } + } + + internal static class ProductMixingColorContract + { + internal static ProductMixingColorValue CalculatePrimaryColor( + ProductMixingMap mixerMap, + IReadOnlyList properties) + { + if (properties == null) + throw new ArgumentNullException(nameof(properties)); + + ProductMixingColorValue baseColor = GetBaseColor(mixerMap); + if (properties.Count == 0) + return baseColor; + + ProductMixingColorSample primary = properties[0]; + for (int i = 1; i < properties.Count; i++) + { + if (properties[i].Tier < primary.Tier) + primary = properties[i]; + } + + float influence = mixerMap switch + { + ProductMixingMap.Marijuana => primary.Tier * 0.15f, + ProductMixingMap.Methamphetamine => primary.Tier * 0.2f, + ProductMixingMap.Cocaine => primary.Tier * 0.13f, + ProductMixingMap.Shrooms => primary.Tier / 5f, + _ => throw new ArgumentOutOfRangeException(nameof(mixerMap)) + }; + return Lerp(baseColor, primary.Color, influence); + } + + private static ProductMixingColorValue GetBaseColor( + ProductMixingMap mixerMap) + { + return mixerMap switch + { + ProductMixingMap.Marijuana => + new ProductMixingColorValue(90, 100, 70, byte.MaxValue), + ProductMixingMap.Methamphetamine => + new ProductMixingColorValue( + byte.MaxValue, + byte.MaxValue, + byte.MaxValue, + byte.MaxValue), + ProductMixingMap.Cocaine => + new ProductMixingColorValue( + byte.MaxValue, + byte.MaxValue, + byte.MaxValue, + byte.MaxValue), + ProductMixingMap.Shrooms => + new ProductMixingColorValue(168, 125, 43, byte.MaxValue), + _ => throw new ArgumentOutOfRangeException(nameof(mixerMap)) + }; + } + + private static ProductMixingColorValue Lerp( + ProductMixingColorValue start, + ProductMixingColorValue end, + float amount) + { + float clamped = Math.Max(0f, Math.Min(1f, amount)); + return new ProductMixingColorValue( + LerpByte(start.Red, end.Red, clamped), + LerpByte(start.Green, end.Green, clamped), + LerpByte(start.Blue, end.Blue, clamped), + LerpByte(start.Alpha, end.Alpha, clamped)); + } + + private static byte LerpByte(byte start, byte end, float amount) => + (byte)(start + ((end - start) * amount)); + } +} diff --git a/S1API/Internal/Products/ProductMixingProfileRegistry.cs b/S1API/Internal/Products/ProductMixingProfileRegistry.cs index 118b6dc8..9fe086d3 100644 --- a/S1API/Internal/Products/ProductMixingProfileRegistry.cs +++ b/S1API/Internal/Products/ProductMixingProfileRegistry.cs @@ -21,7 +21,8 @@ internal static ProductMixingProfile Register(ProductMixingProfile profile) if (existing.MixerMap == profile.MixerMap && ReferenceEquals(existing.OutputFactory, profile.OutputFactory) && string.Equals(existing.OutputFactoryIdentity, profile.OutputFactoryIdentity, StringComparison.OrdinalIgnoreCase) && - existing.OutputFactoryVersion == profile.OutputFactoryVersion) + existing.OutputFactoryVersion == profile.OutputFactoryVersion && + existing.UsePropertyColorMixing == profile.UsePropertyColorMixing) return existing; throw new InvalidOperationException("A conflicting mixing profile is already registered for product kind '" + profile.ProductKind.Id + "'."); } diff --git a/S1API/Internal/Products/ProductPackagingContentRuntime.cs b/S1API/Internal/Products/ProductPackagingContentRuntime.cs index 93e24817..f5e324d5 100644 --- a/S1API/Internal/Products/ProductPackagingContentRuntime.cs +++ b/S1API/Internal/Products/ProductPackagingContentRuntime.cs @@ -562,50 +562,66 @@ private static IEnumerator ProcessGeneratedIconQueue(int generation) continue; } - yield return null; - yield return new WaitForEndOfFrame(); - - if (generation != _iconQueueGeneration) - yield break; - + ProductIconRenderRigArbiter.CaptureLease renderLease = + ProductIconRenderRigArbiter.Enqueue(); try { - S1DevUtilities.IconGenerator generator = - IconFactory.S1IconGenerator; - bool iconCached = false; - lock (IconGate) + while (!ProductIconRenderRigArbiter.TryAcquire(renderLease)) + { + if (generation != _iconQueueGeneration) + yield break; + + yield return null; + } + + yield return null; + yield return new WaitForEndOfFrame(); + + if (generation != _iconQueueGeneration) + yield break; + + try { - EnsureIconCacheMatches(generator); - if (!GeneratedIcons.ContainsKey(registration.Key) && - TryGeneratePackagingIconCore( - generator, - registration, - out Texture2D? texture) && - texture != null) + S1DevUtilities.IconGenerator generator = + IconFactory.S1IconGenerator; + bool iconCached = false; + lock (IconGate) { - iconCached = - TryCacheGeneratedIcon(registration, texture); + EnsureIconCacheMatches(generator); + if (!GeneratedIcons.ContainsKey(registration.Key) && + TryGeneratePackagingIconCore( + generator, + registration, + out Texture2D? texture) && + texture != null) + { + iconCached = + TryCacheGeneratedIcon(registration, texture); + } } + + if (iconCached) + RefreshMatchingItemUis(registration); + } + catch (Exception exception) + { + LogFailureOnce( + registration, + "composite icon", + exception.Message); } - if (iconCached) - RefreshMatchingItemUis(registration); - } - catch (Exception exception) - { - LogFailureOnce( - registration, - "composite icon", - exception.Message); + // TryGeneratePackagingIconCore restores the native rig in + // its finally block. Hold the lease through a full settled + // frame before the next queued subject can capture. + yield return null; + yield return new WaitForEndOfFrame(); } finally { + ProductIconRenderRigArbiter.Release(renderLease); CompleteQueuedIcon(registration.Key, generation); } - - // RuntimePreviewGenerator uses a shared render rig. Give its - // temporary model a frame to leave the rig before the next pair. - yield return null; } } diff --git a/S1API/Internal/Products/StaticStationProductVisualsSetter.cs b/S1API/Internal/Products/StaticStationProductVisualsSetter.cs new file mode 100644 index 00000000..11c8ec30 --- /dev/null +++ b/S1API/Internal/Products/StaticStationProductVisualsSetter.cs @@ -0,0 +1,51 @@ +#if IL2CPPMELON +using Il2CppInterop.Runtime.Attributes; +using Il2CppInterop.Runtime.Injection; +using S1Product = Il2CppScheduleOne.Product; +#elif MONOMELON +using S1Product = ScheduleOne.Product; +#endif + +using System; +using MelonLoader; + +namespace S1API.Internal.Products +{ + /// + /// INTERNAL: Activates a station visual together with its native interaction root. + /// +#if IL2CPPMELON + [RegisterTypeInIl2Cpp] +#endif + internal sealed class StaticStationProductVisualsSetter : + S1Product.ProductVisualsSetter + { +#if IL2CPPMELON + public StaticStationProductVisualsSetter(IntPtr pointer) + : base(pointer) + { + } + + public StaticStationProductVisualsSetter() + : base( + ClassInjector + .DerivedConstructorPointer()) + { + ClassInjector.DerivedConstructorBody(this); + } +#endif + + /// + public override void ApplyVisuals( + S1Product.ProductDefinition productDefinition) + { + if (VisualsContainer == null) + return; + + if (VisualsContainer.parent != null) + VisualsContainer.parent.gameObject.SetActive(true); + + VisualsContainer.gameObject.SetActive(true); + } + } +} diff --git a/S1API/Internal/Rendering/AvatarAccessoryDiagnostics.cs b/S1API/Internal/Rendering/AvatarAccessoryDiagnostics.cs new file mode 100644 index 00000000..9cd4a618 --- /dev/null +++ b/S1API/Internal/Rendering/AvatarAccessoryDiagnostics.cs @@ -0,0 +1,254 @@ +#if MONOMELON +using S1AvatarFramework = ScheduleOne.AvatarFramework; +#elif IL2CPPMELON +using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +#endif + +using System; +using System.Collections.Generic; +using S1API.Internal.Entities; +using S1API.Internal.Patches; +using S1API.Internal.Utils; +using S1API.Logging; +using S1API.Rendering; +using UnityEngine; + +namespace S1API.Internal.Rendering +{ + internal static class AvatarAccessoryDiagnostics + { + private const int NativeAccessorySlotCount = 9; + private static readonly Log Logger = new Log("AvatarAccessoryDiagnostics"); + private static readonly HashSet WarnedInvalidResources = + new HashSet(StringComparer.Ordinal); + + internal static void Validate( + S1AvatarFramework.Avatar avatar, + S1AvatarFramework.AvatarSettings settings) + { + if (avatar == null || !NPCPatches.IsS1ApiCustomNpcComponent(avatar)) + return; + + OwnerContext owner = ResolveOwner(avatar); + try + { + ValidateCore(settings, owner); + } + catch (Exception ex) + { + Logger.Warning( + $"[S1API][AvatarAccessoryValidation] Could not validate accessory settings for " + + $"S1API NPC {owner.Description}: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static void ValidateCore( + S1AvatarFramework.AvatarSettings settings, + OwnerContext owner) + { + if (settings?.AccessorySettings == null) + return; + + int count = Math.Min(settings.AccessorySettings.Count, NativeAccessorySlotCount); + for (int index = 0; index < count; index++) + { + var accessorySetting = settings.AccessorySettings[index]; + string? path = accessorySetting?.path; + if (string.IsNullOrWhiteSpace(path)) + continue; + + // Runtime-registered accessories have already passed AccessoryFactory's + // registration contract. In particular, an IL2CPP wrapper can compare null + // after registration even while the native Resources patch can still serve it. + if (RuntimeResourceRegistry.IsRegistered(path)) + continue; + + UnityEngine.Object? resource = Resources.Load(path); + if (resource == null) + { + WarnInvalidAccessoryOnce( + owner, + index, + path, + "Resources.Load returned null at runtime"); + continue; + } + + if (!CrossType.Is(resource, out GameObject accessoryObject) || accessoryObject == null) + { + WarnInvalidAccessoryOnce( + owner, + index, + path, + "Resources.Load resolved an asset that is not a GameObject at runtime"); + continue; + } + + if (accessoryObject.GetComponent() == null) + { + WarnInvalidAccessoryOnce( + owner, + index, + path, + $"the loaded GameObject '{accessoryObject.name}' has no AvatarFramework.Accessory component"); + } + } + } + + private static void WarnInvalidAccessoryOnce( + OwnerContext owner, + int index, + string path, + string reason) + { + string warningKey = owner.StableKey + "|" + index + "|" + path + "|" + reason; + lock (WarnedInvalidResources) + { + if (!WarnedInvalidResources.Add(warningKey)) + return; + } + + Logger.Warning( + $"[S1API][AvatarAccessoryValidation] S1API NPC {owner.Description} has an invalid accessory " + + $"at index {index}: path='{path}'; {reason}. Correct or remove the accessory path in " + + "WithAppearanceDefaults/AddAccessory, or register the custom accessory with " + + "AccessoryFactory before the appearance is applied. S1API left the settings unchanged; " + + "the native ApplyAccessorySettings call may throw."); + } + + private static OwnerContext ResolveOwner(S1AvatarFramework.Avatar avatar) + { + NPCPrefabIdentity? identity = null; + try { identity = FindIdentity(avatar); } + catch { } + + string? prefabName = null; + try + { + prefabName = identity?.PrefabName + ?? identity?.gameObject?.name + ?? FindS1ApiRootName(avatar); + } + catch + { + } + + string? id = identity?.Id; + string? name = JoinName(identity?.FirstName, identity?.LastName); + if ((string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) + && !string.IsNullOrWhiteSpace(prefabName)) + { + try + { + string registryPrefabName = + NormalizeGameObjectName(prefabName) ?? prefabName; + if (NPCPrefabIdentity.TryGetIdentityFromRegistry( + registryPrefabName, + out string? registryId, + out string? firstName, + out string? lastName, + out _)) + { + id = string.IsNullOrWhiteSpace(id) ? registryId : id; + name = string.IsNullOrWhiteSpace(name) ? JoinName(firstName, lastName) : name; + } + } + catch + { + } + } + + string gameObjectName = NormalizeGameObjectName(prefabName) + ?? avatar.gameObject?.name + ?? avatar.name + ?? ""; + if (string.IsNullOrWhiteSpace(name)) + name = !string.IsNullOrWhiteSpace(id) ? id : gameObjectName; + + string description = FormatOwnerDescription(name, id, gameObjectName); + string stableKey = SelectStableOwnerKey(id, prefabName, description); + return new OwnerContext(description, stableKey); + } + + private static NPCPrefabIdentity? FindIdentity(Component component) + { + for (Transform? current = component.transform; current != null; current = current.parent) + { + NPCPrefabIdentity? identity = current.gameObject?.GetComponent(); + if (identity != null) + return identity; + } + + return component.GetComponentInParent(true); + } + + private static string? FindS1ApiRootName(Component component) + { + for (Transform? current = component.transform; current != null; current = current.parent) + { + string? name = current.gameObject?.name; + if (!string.IsNullOrWhiteSpace(name) + && name.StartsWith("S1API_", StringComparison.OrdinalIgnoreCase)) + { + return name; + } + } + + return null; + } + + private static string? JoinName(string? firstName, string? lastName) + { + string name = $"{firstName} {lastName}".Trim(); + return string.IsNullOrWhiteSpace(name) ? null : name; + } + + private static string? NormalizeGameObjectName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + + const string cloneSuffix = "(Clone)"; + return name.EndsWith(cloneSuffix, StringComparison.Ordinal) + ? name.Substring(0, name.Length - cloneSuffix.Length) + : name; + } + + internal static string FormatOwnerDescription( + string? name, + string? id, + string? gameObjectName) + { + string safeName = string.IsNullOrWhiteSpace(name) ? "" : name; + string safeId = string.IsNullOrWhiteSpace(id) ? "" : id; + string safeGameObjectName = NormalizeGameObjectName(gameObjectName) ?? ""; + return $"'{safeName}' (ID='{safeId}', GameObject='{safeGameObjectName}')"; + } + + internal static string SelectStableOwnerKey( + string? id, + string? prefabName, + string fallbackDescription) + { + if (!string.IsNullOrWhiteSpace(id)) + return "id:" + id; + + string? normalizedPrefabName = NormalizeGameObjectName(prefabName); + return !string.IsNullOrWhiteSpace(normalizedPrefabName) + ? "prefab:" + normalizedPrefabName + : "description:" + fallbackDescription; + } + + private readonly struct OwnerContext + { + internal OwnerContext(string description, string stableKey) + { + Description = description; + StableKey = stableKey; + } + + internal string Description { get; } + internal string StableKey { get; } + } + } +} diff --git a/S1API/Internal/Rendering/PresentationWorkbenchRuntime.cs b/S1API/Internal/Rendering/PresentationWorkbenchRuntime.cs index 0dfe86ef..3e74e23d 100644 --- a/S1API/Internal/Rendering/PresentationWorkbenchRuntime.cs +++ b/S1API/Internal/Rendering/PresentationWorkbenchRuntime.cs @@ -414,6 +414,7 @@ private static void AlignAvatarEquippable( private object CreateAvatarStage() { +#if false var generator = S1AvatarFramework.MugshotGenerator.Instance; var source = generator != null ? generator.MugshotRig : null; if (source == null) @@ -462,6 +463,10 @@ private object CreateAvatarStage() RefreshAvatarRenderers(); return avatar; +#else + throw new InvalidOperationException( + "Avatar presentation previews are unavailable with the Schedule I 0.4.7 avatar pipeline."); +#endif } private void TickAvatarStage() @@ -474,11 +479,8 @@ private void TickAvatarStage() if (_avatarSettleFrames-- > 0) return; - _avatarRig.LoadAvatarSettings(_avatarSettings!); + Compatibility.AvatarCompatibility.ApplyLegacySettings(_avatarRig, _avatarSettings!); _avatarRig.SetVisible(true); - _avatarRig.Impostor.DisableImpostor(); - if (_avatarRig.Animation != null) - _avatarRig.Animation.AllowCulling = false; _avatarRendererRefreshFrames = 2; _avatarAppearanceApplied = true; _avatarSettleFrames = 2; diff --git a/S1API/Internal/Temperature/TemperatureValidation.cs b/S1API/Internal/Temperature/TemperatureValidation.cs new file mode 100644 index 00000000..c9790e5c --- /dev/null +++ b/S1API/Internal/Temperature/TemperatureValidation.cs @@ -0,0 +1,45 @@ +using System; +using UnityEngine; + +namespace S1API.Internal.Temperature +{ + internal static class TemperatureValidation + { + internal const float DefaultAmbientTemperature = 20f; + internal const float MinTemperature = 0f; + internal const float MaxTemperature = 40f; + internal const float DefaultRange = 5f; + internal const float MinRange = 0.1f; + internal const float MaxRange = 100f; + + internal static float ClampTemperature(float temperature, string parameterName) + { + EnsureFinite(temperature, parameterName); + return Clamp(temperature, MinTemperature, MaxTemperature); + } + + internal static float ClampRange(float range, string parameterName) + { + EnsureFinite(range, parameterName); + return Clamp(range, MinRange, MaxRange); + } + + internal static void EnsureFinite(Vector3 position, string parameterName) + { + if (!IsFinite(position.x) || !IsFinite(position.y) || !IsFinite(position.z)) + throw new ArgumentOutOfRangeException(parameterName, "Vector components must be finite."); + } + + internal static void EnsureFinite(float value, string parameterName) + { + if (!IsFinite(value)) + throw new ArgumentOutOfRangeException(parameterName, "Value must be finite."); + } + + private static float Clamp(float value, float minimum, float maximum) => + value < minimum ? minimum : value > maximum ? maximum : value; + + private static bool IsFinite(float value) => + !float.IsNaN(value) && !float.IsInfinity(value); + } +} diff --git a/S1API/Internal/Utils/CrossType.cs b/S1API/Internal/Utils/CrossType.cs index 5b59e791..7f588864 100644 --- a/S1API/Internal/Utils/CrossType.cs +++ b/S1API/Internal/Utils/CrossType.cs @@ -64,6 +64,31 @@ internal static bool Is(object obj, out T result) return false; } + /// + /// Checks whether an object has exactly the requested native type, excluding subclasses. + /// + internal static bool IsExact(object obj) +#if IL2CPPMELON + where T : Il2CppObjectBase +#elif MONOMELON + where T : class +#endif + { +#if IL2CPPMELON + if (obj is Object il2CppObj) + { + Type expected = Il2CppType.Of(); + Type actual = il2CppObj.GetIl2CppType(); + return expected.IsAssignableFrom(actual) && + actual.IsAssignableFrom(expected); + } + + return false; +#elif MONOMELON + return obj.GetType() == typeof(T); +#endif + } + /// /// Casts an object to a type. /// diff --git a/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs b/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs new file mode 100644 index 00000000..97aed17f --- /dev/null +++ b/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; + +namespace S1API.Internal.Utils +{ + /// + /// Tracks one native registration per managed event add operation. + /// + /// The runtime-specific handler type. + internal sealed class ManagedEventRegistrationTracker + { + private readonly Dictionary> _registrations = new Dictionary>(); + + internal bool IsEmpty => + _registrations.Count == 0; + + internal void Add(Delegate managedHandler, TNativeHandler nativeHandler) + { + if (!_registrations.TryGetValue(managedHandler, out var nativeHandlers)) + { + nativeHandlers = new List(); + _registrations.Add(managedHandler, nativeHandlers); + } + + nativeHandlers.Add(nativeHandler); + } + + internal bool TryTakeLast(Delegate managedHandler, out TNativeHandler nativeHandler) + { + if (!_registrations.TryGetValue(managedHandler, out var nativeHandlers) + || nativeHandlers.Count == 0) + { + nativeHandler = default!; + return false; + } + + int lastIndex = nativeHandlers.Count - 1; + nativeHandler = nativeHandlers[lastIndex]; + nativeHandlers.RemoveAt(lastIndex); + if (nativeHandlers.Count == 0) + { + _registrations.Remove(managedHandler); + } + + return true; + } + + internal IReadOnlyList<(Delegate ManagedHandler, TNativeHandler NativeHandler)> TakeAll() + { + var registrations = new List<(Delegate ManagedHandler, TNativeHandler NativeHandler)>(); + foreach (var registration in _registrations) + { + foreach (TNativeHandler nativeHandler in registration.Value) + { + registrations.Add((registration.Key, nativeHandler)); + } + } + + _registrations.Clear(); + return registrations; + } + } +} diff --git a/S1API/Internal/Utils/ReflectionUtils.cs b/S1API/Internal/Utils/ReflectionUtils.cs index a8685f25..853db58b 100644 --- a/S1API/Internal/Utils/ReflectionUtils.cs +++ b/S1API/Internal/Utils/ReflectionUtils.cs @@ -11,6 +11,18 @@ namespace S1API.Internal.Utils /// internal static class ReflectionUtils { + private static readonly Log Logger = new Log("ReflectionUtils"); + + private const BindingFlags InstanceMemberFlags = BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Instance + | BindingFlags.DeclaredOnly; + + private const BindingFlags StaticMemberFlags = BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Static + | BindingFlags.DeclaredOnly; + /// /// Identifies all classes derived from another class. /// @@ -19,9 +31,23 @@ internal static class ReflectionUtils internal static List GetDerivedClasses() { List derivedClasses = new List(); - Assembly[] applicableAssemblies = AppDomain.CurrentDomain.GetAssemblies() - .Where(assembly => !ShouldSkipAssembly(assembly)) + Type baseType = typeof(TBaseClass); + Assembly baseAssembly = baseType.Assembly; + Assembly[] loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); + IReadOnlyDictionary assembliesBySimpleName = + IndexAssembliesBySimpleName(loadedAssemblies); + Assembly[] applicableAssemblies = loadedAssemblies + .Where(assembly => assembly == baseAssembly || !ShouldSkipAssembly(assembly)) + .Where(assembly => CanContainTypesDerivedFrom( + assembly, + baseAssembly.GetName(), + assembliesBySimpleName)) .ToArray(); + + Logger.Debug( + $"[S1API][Reflection] Scanning {applicableAssemblies.Length} of {loadedAssemblies.Length} " + + $"loaded assemblies for types derived from '{baseType.FullName}'."); + foreach (Assembly assembly in applicableAssemblies) foreach (Type type in SafeGetTypes(assembly)) { @@ -29,8 +55,8 @@ internal static List GetDerivedClasses() { if (type == null) continue; - if (typeof(TBaseClass).IsAssignableFrom(type) - && type != typeof(TBaseClass) + if (baseType.IsAssignableFrom(type) + && type != baseType && !type.IsAbstract) { derivedClasses.Add(type); @@ -48,6 +74,104 @@ internal static List GetDerivedClasses() return derivedClasses; } + internal static bool CanContainTypesDerivedFrom( + Assembly candidateAssembly, + Assembly baseAssembly, + IEnumerable loadedAssemblies) + { + if (candidateAssembly == baseAssembly) + return true; + + IReadOnlyDictionary assembliesBySimpleName = + IndexAssembliesBySimpleName(loadedAssemblies); + + return ReferencesAssemblyTransitively( + candidateAssembly, + baseAssembly.GetName(), + assembliesBySimpleName, + new HashSet()); + } + + private static IReadOnlyDictionary IndexAssembliesBySimpleName( + IEnumerable loadedAssemblies) + { + return loadedAssemblies + .Where(assembly => assembly != null) + .GroupBy(assembly => assembly.GetName().Name ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase); + } + + private static bool CanContainTypesDerivedFrom( + Assembly candidateAssembly, + AssemblyName baseAssemblyName, + IReadOnlyDictionary assembliesBySimpleName) + { + if (AssemblyIdentityMatches(candidateAssembly.GetName(), baseAssemblyName)) + return true; + + return ReferencesAssemblyTransitively( + candidateAssembly, + baseAssemblyName, + assembliesBySimpleName, + new HashSet()); + } + + private static bool ReferencesAssemblyTransitively( + Assembly candidateAssembly, + AssemblyName baseAssemblyName, + IReadOnlyDictionary assembliesBySimpleName, + HashSet visitedAssemblies) + { + if (!visitedAssemblies.Add(candidateAssembly)) + return false; + + AssemblyName[] referencedAssemblies; + try + { + referencedAssemblies = candidateAssembly.GetReferencedAssemblies(); + } + catch + { + return false; + } + + foreach (AssemblyName referencedAssembly in referencedAssemblies) + { + if (AssemblyIdentityMatches(referencedAssembly, baseAssemblyName)) + return true; + + string referencedName = referencedAssembly.Name ?? string.Empty; + if (!assembliesBySimpleName.TryGetValue(referencedName, out Assembly[]? loadedReferences) + || loadedReferences == null) + continue; + + foreach (Assembly loadedReference in loadedReferences) + { + if (!AssemblyIdentityMatches(loadedReference.GetName(), referencedAssembly)) + continue; + + if (ReferencesAssemblyTransitively( + loadedReference, + baseAssemblyName, + assembliesBySimpleName, + visitedAssemblies)) + { + return true; + } + } + } + + return false; + } + + private static bool AssemblyIdentityMatches( + AssemblyName referenceAssemblyName, + AssemblyName definitionAssemblyName) => + string.Equals( + referenceAssemblyName.FullName, + definitionAssemblyName.FullName, + StringComparison.OrdinalIgnoreCase); + /// /// INTERNAL: Gets all types by their name. /// @@ -127,16 +251,26 @@ private static bool ShouldSkipAssembly(Assembly assembly) /// The types that were successfully loaded from the assembly. private static IEnumerable SafeGetTypes(Assembly asm) { + string assemblyName = asm.FullName ?? asm.GetName().Name ?? ""; + Logger.Debug($"[S1API][Reflection] About to enumerate types in '{assemblyName}'."); + try { - return asm.GetTypes(); + Type[] types = asm.GetTypes(); + Logger.Debug( + $"[S1API][Reflection] Enumerated {types.Length} types in '{assemblyName}'."); + return types; } catch (ReflectionTypeLoadException ex) { - return ex.Types.Where(t => t != null)!.Cast(); + Type[] loadedTypes = ex.Types.Where(type => type != null).Cast().ToArray(); + Logger.Debug( + $"[S1API][Reflection] Partially enumerated {loadedTypes.Length} types in '{assemblyName}'."); + return loadedTypes; } catch { + Logger.Debug($"[S1API][Reflection] Failed to enumerate types in '{assemblyName}'."); return Array.Empty(); } } @@ -283,7 +417,7 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob return false; var type = target.GetType(); - const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + const BindingFlags flags = InstanceMemberFlags; // Try field first var fi = GetField(type, memberName, flags); @@ -321,13 +455,7 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob } } - string[] backingFieldNames = - { - $"<{memberName}>k__BackingField", - $"_{memberName}_k__BackingField" - }; - - foreach (string backingFieldName in backingFieldNames) + foreach (string backingFieldName in GetBackingFieldNames(memberName)) { var backingField = GetField(type, backingFieldName, flags); if (backingField == null) @@ -360,7 +488,7 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob internal static object? TryGetFieldOrProperty(object target, string memberName) { var type = target.GetType(); - const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + const BindingFlags flags = InstanceMemberFlags; // Try field first var fi = GetField(type, memberName, flags); @@ -390,13 +518,7 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob } } - string[] backingFieldNames = - { - $"<{memberName}>k__BackingField", - $"_{memberName}_k__BackingField" - }; - - foreach (string backingFieldName in backingFieldNames) + foreach (string backingFieldName in GetBackingFieldNames(memberName)) { var backingField = GetField(type, backingFieldName, flags); if (backingField == null) @@ -453,10 +575,10 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob /// The value of the member, or null if not found or inaccessible. internal static object? TryGetStaticFieldOrProperty(Type type, string memberName) { - const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + const BindingFlags flags = StaticMemberFlags; // Try field first - var fi = type.GetField(memberName, flags); + var fi = GetField(type, memberName, flags); if (fi != null) { try @@ -470,15 +592,33 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob } // Try property - var pi = type.GetProperty(memberName, flags); - if (pi == null || !pi.CanRead) return null; - try + var pi = GetProperty(type, memberName, flags); + if (pi != null && pi.CanRead) { - return pi.GetValue(null); + try + { + return pi.GetValue(null); + } + catch + { + // ignored + } } - catch + + foreach (string backingFieldName in GetBackingFieldNames(memberName)) { - // ignored + var backingField = GetField(type, backingFieldName, flags); + if (backingField == null) + continue; + + try + { + return backingField.GetValue(null); + } + catch + { + // ignored + } } return null; @@ -494,10 +634,10 @@ internal static bool TrySetFieldOrProperty(object? target, string memberName, ob /// The value to set. internal static void TrySetStaticFieldOrProperty(Type type, string memberName, object? value) { - const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + const BindingFlags flags = StaticMemberFlags; // Try field first - var fi = type.GetField(memberName, flags); + var fi = GetField(type, memberName, flags); if (fi != null) { try @@ -515,21 +655,50 @@ internal static void TrySetStaticFieldOrProperty(Type type, string memberName, o } // Try property - var pi = type.GetProperty(memberName, flags); - if (pi == null || !pi.CanWrite) return; - try + var pi = GetProperty(type, memberName, flags); + if (pi != null && pi.CanWrite) { - if (CanAssignValue(pi.PropertyType, value)) + try { - pi.SetValue(null, value); + if (CanAssignValue(pi.PropertyType, value)) + { + pi.SetValue(null, value); + return; + } + } + catch + { + // ignored } } - catch + + foreach (string backingFieldName in GetBackingFieldNames(memberName)) { - // ignored + var backingField = GetField(type, backingFieldName, flags); + if (backingField == null) + continue; + + try + { + if (CanAssignValue(backingField.FieldType, value)) + { + backingField.SetValue(null, value); + return; + } + } + catch + { + // ignored + } } } + private static string[] GetBackingFieldNames(string memberName) => + [ + $"<{memberName}>k__BackingField", + $"_{memberName}_k__BackingField" + ]; + private static bool CanAssignValue(Type memberType, object? value) { if (value != null) diff --git a/S1API/Internal/Weather/WeatherRuntime.cs b/S1API/Internal/Weather/WeatherRuntime.cs new file mode 100644 index 00000000..f31225c0 --- /dev/null +++ b/S1API/Internal/Weather/WeatherRuntime.cs @@ -0,0 +1,243 @@ +#if IL2CPPMELON +using S1EnvironmentHandler = Il2CppScheduleOne.Core.Weather.EnvironmentHandler; +using S1WeatherChangeHandler = Il2CppScheduleOne.Core.Weather.WeatherChangeHandler; +using S1WeatherConditions = Il2CppScheduleOne.Core.Weather.WeatherConditions; +using S1WeatherSequence = Il2CppScheduleOne.Core.Weather.WeatherSequence; +using S1EnvironmentManager = Il2CppScheduleOne.Weather.EnvironmentManager; +using S1DevUtilities = Il2CppScheduleOne.DevUtilities; +using Il2CppInterop.Runtime; +#elif MONOMELON +using S1EnvironmentHandler = ScheduleOne.Core.Weather.EnvironmentHandler; +using S1WeatherChangeHandler = ScheduleOne.Core.Weather.WeatherChangeHandler; +using S1WeatherConditions = ScheduleOne.Core.Weather.WeatherConditions; +using S1WeatherSequence = ScheduleOne.Core.Weather.WeatherSequence; +using S1EnvironmentManager = ScheduleOne.Weather.EnvironmentManager; +using S1DevUtilities = ScheduleOne.DevUtilities; +#endif + +using System; +using System.Collections.Generic; +#if MONOMELON +using System.Reflection; +#endif +using S1API.Logging; + +namespace S1API.Internal.Weather +{ + /// + /// INTERNAL: Bridges the native weather callback and sequence collection to managed API + /// snapshots. + /// + internal static class WeatherRuntime + { + private const float RefreshIntervalSeconds = 0.1f; + private static readonly Log Logger = new Log("WeatherRuntime"); + +#if MONOMELON + private static readonly FieldInfo? CurrentWeatherConditionsField = + typeof(S1EnvironmentManager).GetField( + "_currentWeatherConditions", + BindingFlags.Instance | BindingFlags.NonPublic); +#endif + +#if IL2CPPMELON + private static readonly S1WeatherChangeHandler WeatherChangeHandler = + DelegateSupport.ConvertDelegate(OnNativeWeatherChanged) + ?? throw new InvalidOperationException("Could not convert the weather callback delegate."); +#else + private static readonly S1WeatherChangeHandler WeatherChangeHandler = OnNativeWeatherChanged; +#endif + + private static S1EnvironmentManager? _boundInstance; + private static bool _isBound; + private static bool _refreshFailureLogged; + private static float _nextRefreshTime; + + /// + /// INTERNAL: Binds the managed callback to the current Main-scene weather manager. + /// + internal static void TryBindToCurrentInstance() + { + try + { + S1EnvironmentManager? instance = GetNativeEnvironmentManager(); + if (instance == null) + return; + + if (_isBound && ReferenceEquals(instance, _boundInstance)) + return; + + if (_isBound) + Unbind(); + + S1EnvironmentHandler.SubscribeToWeatherChange(WeatherChangeHandler); + _boundInstance = instance; + _isBound = true; + PublishCurrentState(instance); + } + catch (Exception ex) + { + Logger.Warning($"Failed to bind to the native weather callback: {ex.Message}"); + } + } + + /// + /// INTERNAL: Refreshes the managed snapshot from the live weather manager. + /// + /// + /// The game's weather callback is not raised by the current weather implementation, so + /// polling the manager is required to observe normal weather-volume transitions. + /// + internal static void Tick() + { + if (UnityEngine.Time.unscaledTime < _nextRefreshTime) + return; + + _nextRefreshTime = UnityEngine.Time.unscaledTime + RefreshIntervalSeconds; + TryBindToCurrentInstance(); + + if (_boundInstance == null) + return; + + try + { + PublishCurrentState(_boundInstance); + _refreshFailureLogged = false; + } + catch (Exception ex) + { + if (!_refreshFailureLogged) + { + Logger.Warning($"Failed to refresh the current weather state: {ex.Message}"); + _refreshFailureLogged = true; + } + } + } + + /// + /// INTERNAL: Removes the native callback and clears scene-bound managed state. + /// + internal static void ResetBindings() + { + try + { + if (_isBound) + Unbind(); + } + catch (Exception ex) + { + Logger.Warning($"Failed to unbind the native weather callback: {ex.Message}"); + } + finally + { + _boundInstance = null; + _isBound = false; + _refreshFailureLogged = false; + _nextRefreshTime = 0f; + global::S1API.Weather.WeatherManager.ResetState(); + } + } + + /// + /// INTERNAL: Returns a managed copy of the native sequence identifiers. + /// + internal static IReadOnlyList GetKnownSequenceIds() + { + try + { + S1EnvironmentManager? manager = GetNativeEnvironmentManager(); + if (manager?.WeatherSequences == null) + return global::S1API.Weather.WeatherManager.SnapshotSequenceIds( + Array.Empty()); + + var ids = new List(); +#if IL2CPPMELON + for (int i = 0; i < manager.WeatherSequences.Count; i++) + { + S1WeatherSequence? sequence = manager.WeatherSequences[i]; + AddSequenceId(ids, sequence); + } +#else + foreach (S1WeatherSequence? sequence in manager.WeatherSequences) + AddSequenceId(ids, sequence); +#endif + + return global::S1API.Weather.WeatherManager.SnapshotSequenceIds(ids); + } + catch + { + return global::S1API.Weather.WeatherManager.SnapshotSequenceIds( + Array.Empty()); + } + } + + private static void AddSequenceId(List ids, S1WeatherSequence? sequence) + { + if (sequence == null) + return; + + ids.Add(sequence.Id); + } + + private static S1EnvironmentManager? GetNativeEnvironmentManager() + { + try + { + if (!S1DevUtilities.NetworkSingleton.InstanceExists) + return null; + + return S1DevUtilities.NetworkSingleton.Instance; + } + catch + { + return null; + } + } + + private static void Unbind() + { + S1EnvironmentHandler.UnsubscribeFromWeatherChange(WeatherChangeHandler); + _boundInstance = null; + _isBound = false; + } + + private static void OnNativeWeatherChanged(S1WeatherConditions? conditions) + { + try + { + PublishState(conditions); + } + catch (Exception ex) + { + Logger.Warning($"Failed to process the native weather callback: {ex.Message}"); + } + } + + private static void PublishCurrentState(S1EnvironmentManager manager) + { +#if IL2CPPMELON + PublishState(manager._currentWeatherConditions); +#else + PublishState(CurrentWeatherConditionsField?.GetValue(manager) as S1WeatherConditions); +#endif + } + + private static void PublishState(S1WeatherConditions? conditions) + { + if (conditions == null) + return; + + global::S1API.Weather.WeatherManager.NotifyWeatherChanged( + global::S1API.Weather.WeatherState.FromNativeComponents( + conditions.Sunny, + conditions.Cloudy, + conditions.Rainy, + conditions.Stormy, + conditions.Snowy, + conditions.Foggy, + conditions.Windy, + conditions.Hail, + conditions.Sleet)); + } + } +} diff --git a/S1API/Items/Buildable/BuildableItemDefinitionBuilder.cs b/S1API/Items/Buildable/BuildableItemDefinitionBuilder.cs index 2429545d..dc899c03 100644 --- a/S1API/Items/Buildable/BuildableItemDefinitionBuilder.cs +++ b/S1API/Items/Buildable/BuildableItemDefinitionBuilder.cs @@ -1,10 +1,14 @@ #if (IL2CPPMELON) using S1ItemFramework = Il2CppScheduleOne.ItemFramework; using S1CoreItemFramework = Il2CppScheduleOne.Core.Items.Framework; +using S1EntityFramework = Il2CppScheduleOne.EntityFramework; #elif MONOMELON using S1ItemFramework = ScheduleOne.ItemFramework; using S1CoreItemFramework = ScheduleOne.Core.Items.Framework; +using S1EntityFramework = ScheduleOne.EntityFramework; #endif +using System; +using S1API.Internal.Building; using S1API.Internal.Utils; using S1API.Items.Storable; using UnityEngine; @@ -18,6 +22,9 @@ namespace S1API.Items.Buildable public class BuildableItemDefinitionBuilder : StorableItemDefinitionBuilderBase { + private Func? _ghostVisualFactory; + private bool _replaceExistingGhostVisual; + private S1ItemFramework.BuildableItemDefinition BuildableDefinition => CrossType.As(Definition); @@ -62,13 +69,51 @@ public BuildableItemDefinitionBuilder WithBuildSound(BuildSoundType soundType) return this; } + /// + /// INTERNAL: Assigns a native build-sound value after a caller-specific compatibility mapping. + /// + internal BuildableItemDefinitionBuilder WithNativeBuildSound( + S1ItemFramework.BuildableItemDefinition.EBuildSoundType soundType) + { + BuildableDefinition.BuildSoundType = soundType; + return this; + } + + /// + /// Configures an optional visual created whenever the native placement system creates this + /// buildable's ghost. The factory runs on Unity's main thread and must return the created + /// visual. S1API parents and activates it under the supplied ghost transform. + /// + /// Creates the visual under the supplied placement-ghost transform. + /// + /// Whether S1API should hide renderers inherited from the native buildable before creating the visual. + /// + /// The builder instance for fluent chaining. + public BuildableItemDefinitionBuilder WithGhostVisual( + Func visualFactory, + bool replaceExistingVisual = false) + { + _ghostVisualFactory = visualFactory ?? throw new ArgumentNullException(nameof(visualFactory)); + _replaceExistingGhostVisual = replaceExistingVisual; + return this; + } + /// /// Builds the item definition, registers it with the game's registry, and returns a wrapper. /// /// A wrapper around the created buildable item definition. public new BuildableItemDefinition Build() { - return (BuildableItemDefinition)base.Build(); + BuildableItemDefinition definition = (BuildableItemDefinition)base.Build(); + if (_ghostVisualFactory != null) + { + BuildableGhostRuntime.RegisterVisual( + BuildableDefinition.ID, + _ghostVisualFactory, + _replaceExistingGhostVisual); + } + + return definition; } /// @@ -80,6 +125,15 @@ public BuildableItemDefinitionBuilder WithBuildSound(BuildSoundType soundType) return BuildableDefinition; } + /// + /// INTERNAL: Assigns the native placed-item prefab composed by S1API. + /// + internal BuildableItemDefinitionBuilder WithBuiltItem(S1EntityFramework.BuildableItem builtItem) + { + BuildableDefinition.BuiltItem = builtItem; + return this; + } + /// protected override Storable.StorableItemDefinition CreateWrapper( S1ItemFramework.StorableItemDefinition definition) diff --git a/S1API/Items/Buildable/FurnitureCreator.cs b/S1API/Items/Buildable/FurnitureCreator.cs new file mode 100644 index 00000000..f7eacf08 --- /dev/null +++ b/S1API/Items/Buildable/FurnitureCreator.cs @@ -0,0 +1,81 @@ +#if (IL2CPPMELON) +using S1ItemFramework = Il2CppScheduleOne.ItemFramework; +using S1Registry = Il2CppScheduleOne.Registry; +#elif MONOMELON +using S1ItemFramework = ScheduleOne.ItemFramework; +using S1Registry = ScheduleOne.Registry; +#endif +using System; +using S1API.Internal.Building; +using S1API.Internal.Utils; + +namespace S1API.Items.Buildable +{ + /// + /// Creates first-class custom furniture backed by the game's native building system. + /// + public static class FurnitureCreator + { + /// + /// Creates a builder for a custom placeable furniture item. + /// + /// A new furniture definition builder with grid-placement defaults. + public static FurnitureDefinitionBuilder CreateBuilder() + { + return new FurnitureDefinitionBuilder(); + } + + /// + /// Creates a presentation-only furniture variant from a registered native donor. + /// The builder owns an isolated visual clone and independent material instances. + /// + /// The stable ID of the native grid or surface furniture donor. + /// A builder initialized with the donor's safe placement and item defaults. + /// + /// Thrown when the ID is invalid, the donor is missing, or the donor has specialized runtime behavior. + /// + public static FurnitureDefinitionBuilder CloneFrom(string sourceItemId) + { + if (string.IsNullOrWhiteSpace(sourceItemId)) + { + throw new ArgumentException( + "Source item ID cannot be null or whitespace.", + nameof(sourceItemId)); + } + + object? source = S1Registry.GetItem(sourceItemId); + if (source == null || + !CrossType.Is(source, out S1ItemFramework.BuildableItemDefinition definition)) + { + throw new ArgumentException( + $"Furniture donor '{sourceItemId}' was not found or is not buildable.", + nameof(sourceItemId)); + } + + return CreateCloneBuilder(definition); + } + + /// + /// Creates a presentation-only furniture variant from an existing buildable definition. + /// The builder owns an isolated visual clone and independent material instances. + /// + /// The native grid or surface furniture donor. + /// A builder initialized with the donor's safe placement and item defaults. + /// Thrown when is null. + /// Thrown when the donor has specialized runtime behavior. + public static FurnitureDefinitionBuilder CloneFrom(BuildableItemDefinition source) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + + return CreateCloneBuilder(source.S1BuildableItemDefinition); + } + + private static FurnitureDefinitionBuilder CreateCloneBuilder( + S1ItemFramework.BuildableItemDefinition source) + { + return new FurnitureDefinitionBuilder( + FurnitureVisualCloner.CreateSource(source)); + } + } +} diff --git a/S1API/Items/Buildable/FurnitureDefinitionBuilder.cs b/S1API/Items/Buildable/FurnitureDefinitionBuilder.cs new file mode 100644 index 00000000..df0b3fb9 --- /dev/null +++ b/S1API/Items/Buildable/FurnitureDefinitionBuilder.cs @@ -0,0 +1,359 @@ +using System; +using System.Collections.Generic; +using S1API.Internal.Building; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace S1API.Items.Buildable +{ + /// + /// Composes a custom model into a native furniture definition, placement prefab, and inventory prefab. + /// + public sealed class FurnitureDefinitionBuilder + { + private string? _id; + private string? _name; + private string? _description; + private GameObject? _model; + private string? _donorId; + private IReadOnlyList? _donorFootprint; + private bool _modelIsBuilderOwned; + private bool _modelConfigured; + private bool _centerModelOnFootprint = true; + private bool _isolateRepresentationMaterials; + private FurniturePlacementMode _placementMode = FurniturePlacementMode.Grid; + private int _footprintWidth = 1; + private int _footprintDepth = 1; + private FurnitureSurfaceType _surfaceTypes = FurnitureSurfaceType.Wall; + private bool _allowSurfaceRotation = true; + private BuildSoundType _buildSound = FurnitureBuildSoundMapper.Default; + private int _stackLimit = 10; + private float _purchasePrice = 10f; + private float _resellMultiplier = 0.5f; + private Sprite? _icon; + private Sprite? _fallbackIcon; + private bool _generateIcon = true; + private int _generatedIconResolution = 512; + + internal FurnitureDefinitionBuilder() + { + } + + internal FurnitureDefinitionBuilder(FurnitureCloneSource source) + { + _donorId = source.DonorId; + _model = source.Model; + _modelIsBuilderOwned = true; + _centerModelOnFootprint = false; + _isolateRepresentationMaterials = true; + _placementMode = source.PlacementMode; + _donorFootprint = source.Footprint; + if (source.Footprint != null) + { + foreach (FurnitureFootprintCoordinate coordinate in source.Footprint) + { + _footprintWidth = Math.Max(_footprintWidth, coordinate.X + 1); + _footprintDepth = Math.Max(_footprintDepth, coordinate.Y + 1); + } + } + + _surfaceTypes = source.SurfaceTypes; + _allowSurfaceRotation = source.AllowSurfaceRotation; + _buildSound = source.BuildSound; + _stackLimit = source.StackLimit; + _purchasePrice = source.PurchasePrice; + _resellMultiplier = source.ResellMultiplier; + _fallbackIcon = source.Icon; + } + + /// Sets the stable registry ID and player-facing text. + /// The stable item ID shared by every multiplayer peer. + /// The player-facing item name. + /// The player-facing item description. An empty description is allowed. + /// This builder for fluent chaining. + public FurnitureDefinitionBuilder WithBasicInfo(string id, string name, string description) + { + _id = id; + _name = name; + _description = description; + return this; + } + + /// + /// Sets the model used for the placed object, placement ghost, stored item, and generated icon. + /// The supplied object is cloned and is never modified by S1API. + /// + /// The model root to clone. It must contain at least one mesh renderer. + /// This builder for fluent chaining. + /// Thrown when is null. + public FurnitureDefinitionBuilder WithModel(GameObject model) + { + if (ReferenceEquals(model, null) || model == null) + throw new ArgumentNullException(nameof(model)); + + if (_modelIsBuilderOwned && ReferenceEquals(_model, model)) + return this; + if (_modelIsBuilderOwned && _model != null) + Object.DestroyImmediate(_model); + + _model = model; + _modelIsBuilderOwned = false; + _centerModelOnFootprint = true; + _isolateRepresentationMaterials = false; + return this; + } + + /// + /// Configures the isolated visual owned by a builder returned from + /// . + /// Renderer materials are independent from the donor before this callback runs. + /// + /// A callback that modifies the builder-owned visual clone. + /// This builder for fluent chaining. + /// Thrown when is null. + /// + /// Thrown when this builder was not created by CloneFrom, or the model was already configured. + /// + public FurnitureDefinitionBuilder ConfigureModel(Action configure) + { + if (configure == null) + throw new ArgumentNullException(nameof(configure)); + if (!_modelIsBuilderOwned || _model == null) + { + throw new InvalidOperationException( + "ConfigureModel is available only for an unmodified FurnitureCreator.CloneFrom builder."); + } + if (_modelConfigured) + throw new InvalidOperationException("The cloned furniture model is already configured."); + + try + { + configure(_model); + if (_model == null) + throw new InvalidOperationException("The furniture model callback destroyed its visual root."); + _modelConfigured = true; + return this; + } + catch + { + if (_model != null) + Object.DestroyImmediate(_model); + _model = null; + _modelIsBuilderOwned = false; + throw; + } + } + + /// Chooses the native placement family. + /// The grid or surface placement family to compose. + /// This builder for fluent chaining. + /// Thrown for an undefined placement mode. + public FurnitureDefinitionBuilder WithPlacement(FurniturePlacementMode placementMode) + { + if (!Enum.IsDefined(typeof(FurniturePlacementMode), placementMode)) + throw new ArgumentOutOfRangeException(nameof(placementMode)); + + _placementMode = placementMode; + return this; + } + + /// Sets the floor-grid footprint in 0.5 metre tiles. + /// The positive footprint width in grid tiles. + /// The positive footprint depth in grid tiles. + /// This builder for fluent chaining. + /// Thrown when either dimension is less than one. + public FurnitureDefinitionBuilder WithFootprint(int width, int depth) + { + if (width < 1) + throw new ArgumentOutOfRangeException(nameof(width), "Footprint width must be at least one tile."); + if (depth < 1) + throw new ArgumentOutOfRangeException(nameof(depth), "Footprint depth must be at least one tile."); + + _footprintWidth = width; + _footprintDepth = depth; + _donorFootprint = null; + return this; + } + + /// Configures valid surfaces and rotation for surface-placed furniture. + /// One or more supported wall or roof surface flags. + /// Whether the player can rotate the item during surface placement. + /// This builder for fluent chaining. + /// + /// Thrown when is empty or contains an unknown flag. + /// + public FurnitureDefinitionBuilder WithSurfacePlacement( + FurnitureSurfaceType surfaceTypes, + bool allowRotation = true) + { + if (surfaceTypes == FurnitureSurfaceType.None || + (surfaceTypes & ~FurnitureSurfaceType.All) != 0) + { + throw new ArgumentOutOfRangeException(nameof(surfaceTypes)); + } + + _surfaceTypes = surfaceTypes; + _allowSurfaceRotation = allowRotation; + return this; + } + + /// Sets the sound family used when placement completes. + /// + /// The public sound family. Plastic uses the native metal sound because the game has no plastic family. + /// + /// This builder for fluent chaining. + /// Thrown for an undefined sound family. + public FurnitureDefinitionBuilder WithBuildSound(BuildSoundType buildSound) + { + if (!Enum.IsDefined(typeof(BuildSoundType), buildSound)) + throw new ArgumentOutOfRangeException(nameof(buildSound)); + + _buildSound = buildSound; + return this; + } + + /// Sets the purchase and resale values. + /// The non-negative base purchase price. + /// The resale fraction, clamped between zero and one. + /// This builder for fluent chaining. + public FurnitureDefinitionBuilder WithPricing(float basePurchasePrice, float resellMultiplier = 0.5f) + { + _purchasePrice = Mathf.Max(0f, basePurchasePrice); + _resellMultiplier = Mathf.Clamp01(resellMultiplier); + return this; + } + + /// Sets the inventory stack limit. + /// The stack limit, clamped between 1 and 999. + /// This builder for fluent chaining. + public FurnitureDefinitionBuilder WithStackLimit(int stackLimit) + { + _stackLimit = Mathf.Clamp(stackLimit, 1, 999); + return this; + } + + /// Uses an existing inventory icon. + /// The icon to assign without using the native render rig. + /// This builder for fluent chaining. + /// Thrown when is null. + public FurnitureDefinitionBuilder WithIcon(Sprite icon) + { + if (ReferenceEquals(icon, null) || icon == null) + throw new ArgumentNullException(nameof(icon)); + + _icon = icon; + _generateIcon = false; + return this; + } + + /// + /// Queues inventory-icon generation from the supplied model. The definition registers immediately, + /// and S1API replaces its fallback icon when the gameplay rendering rig becomes available. + /// + /// The square icon resolution from 64 through 2048 pixels. + /// This builder for fluent chaining. + /// + /// Thrown when is outside the supported range. + /// + public FurnitureDefinitionBuilder WithGeneratedIcon(int resolution = 512) + { + if (resolution < 64 || resolution > 2048) + throw new ArgumentOutOfRangeException(nameof(resolution), "Icon resolution must be between 64 and 2048 pixels."); + + _generateIcon = true; + _icon = null; + _generatedIconResolution = resolution; + return this; + } + + /// + /// Creates and registers the complete native buildable definition. + /// Call this after the game's item registry and vanilla furniture definitions are available. + /// Every multiplayer peer must perform the same registration. + /// + /// The registered custom furniture definition. + /// + /// Thrown when required identity or model configuration is missing, or a native donor is unavailable. + /// + public BuildableItemDefinition Build() + { + Validate(); + + FurnitureComposition composition = FurniturePrefabComposer.Compose( + _id!, + _model!, + _placementMode, + _footprintWidth, + _footprintDepth, + _surfaceTypes, + _allowSurfaceRotation, + _donorFootprint, + _centerModelOnFootprint, + _isolateRepresentationMaterials); + + var builder = new BuildableItemDefinitionBuilder(composition.TemplateDefinition) + .WithBasicInfo(_id!, _name!, _description!, ItemCategory.Furniture) + .WithNativeBuildSound(FurnitureBuildSoundMapper.ToNative(_buildSound)) + .WithPricing(_purchasePrice, _resellMultiplier) + .WithStackLimit(_stackLimit) + .WithBuiltItem(composition.BuiltItem) + .WithStoredItem(composition.StoredItem.gameObject) + .WithEquippable(composition.Equippable); + Sprite? initialIcon = _icon != null ? _icon : _fallbackIcon; + if (initialIcon != null) + builder.WithIcon(initialIcon); + + BuildableItemDefinition definition = builder.Build(); + Transform? visual = composition.BuiltItem.transform.Find( + BuildableGhostRuntime.FurnitureVisualName); + if (visual == null) + throw new InvalidOperationException("Composed furniture has no placement visual source."); + + if (_isolateRepresentationMaterials) + { + BuildableGhostRuntime.RegisterVisual( + _id!, + parent => + { + GameObject ghostVisual = FurnitureVisualCloner.CloneOwnedVisual( + visual.gameObject); + ghostVisual.name = BuildableGhostRuntime.FurnitureGhostVisualName; + ghostVisual.transform.SetParent(parent, false); + return ghostVisual; + }, + replaceExistingVisual: true); + } + else + { + BuildableGhostRuntime.RegisterVisualSource( + _id!, + visual.gameObject, + BuildableGhostRuntime.FurnitureGhostVisualName, + replaceExistingVisual: true); + } + if (_generateIcon) + { + FurnitureIconRuntime.Queue( + definition, + visual, + _generatedIconResolution, + _isolateRepresentationMaterials); + } + + return definition; + } + + private void Validate() + { + if (string.IsNullOrWhiteSpace(_id)) + throw new InvalidOperationException("Furniture ID must be configured before Build()."); + if (string.IsNullOrWhiteSpace(_name)) + throw new InvalidOperationException("Furniture name must be configured before Build()."); + if (_description == null) + throw new InvalidOperationException("Furniture description must be configured before Build()."); + if (_model == null) + throw new InvalidOperationException("Furniture model must be configured before Build()."); + FurnitureClonePolicy.ValidateNewId(_id!, _donorId); + } + } +} diff --git a/S1API/Items/Buildable/FurniturePlacementMode.cs b/S1API/Items/Buildable/FurniturePlacementMode.cs new file mode 100644 index 00000000..4ae1096e --- /dev/null +++ b/S1API/Items/Buildable/FurniturePlacementMode.cs @@ -0,0 +1,14 @@ +namespace S1API.Items.Buildable +{ + /// + /// Native placement families supported by the furniture builder. + /// + public enum FurniturePlacementMode + { + /// Places the item on a property's floor grid. + Grid, + + /// Places the item on a compatible wall or roof surface. + Surface, + } +} diff --git a/S1API/Items/Buildable/FurnitureSurfaceType.cs b/S1API/Items/Buildable/FurnitureSurfaceType.cs new file mode 100644 index 00000000..605517b3 --- /dev/null +++ b/S1API/Items/Buildable/FurnitureSurfaceType.cs @@ -0,0 +1,23 @@ +using System; + +namespace S1API.Items.Buildable +{ + /// + /// Surface types accepted by surface-placed furniture. + /// + [Flags] + public enum FurnitureSurfaceType + { + /// No surface type. + None = 0, + + /// Vertical wall surfaces. + Wall = 1, + + /// Horizontal roof or ceiling surfaces. + Roof = 2, + + /// All supported surface types. + All = Wall | Roof, + } +} diff --git a/S1API/Items/Clothing/ClothingColorMetadata.cs b/S1API/Items/Clothing/ClothingColorMetadata.cs new file mode 100644 index 00000000..a119552e --- /dev/null +++ b/S1API/Items/Clothing/ClothingColorMetadata.cs @@ -0,0 +1,42 @@ +using UnityEngine; + +namespace S1API.Items.Clothing +{ + /// + /// Describes a clothing color using the base game's display metadata. + /// + public sealed class ClothingColorMetadata + { + internal ClothingColorMetadata( + ClothingColor color, + string displayName, + Color actualColor, + Color labelColor) + { + Color = color; + DisplayName = displayName; + ActualColor = actualColor; + LabelColor = labelColor; + } + + /// + /// Gets the clothing color represented by this metadata. + /// + public ClothingColor Color { get; } + + /// + /// Gets the clothing color enum identifier used by the base game as its label. + /// + public string DisplayName { get; } + + /// + /// Gets the color applied to clothing materials. + /// + public Color ActualColor { get; } + + /// + /// Gets the contrasting color used to label this color in the base game UI. + /// + public Color LabelColor { get; } + } +} diff --git a/S1API/Items/Clothing/ClothingItemDefinition.cs b/S1API/Items/Clothing/ClothingItemDefinition.cs index 3cce7a08..ca1f3266 100644 --- a/S1API/Items/Clothing/ClothingItemDefinition.cs +++ b/S1API/Items/Clothing/ClothingItemDefinition.cs @@ -1,8 +1,10 @@ #if (IL2CPPMELON) using S1Clothing = Il2CppScheduleOne.Clothing; +using S1CoreAvatar = Il2CppScheduleOne.Core.Avatar; using Il2CppCollections = Il2CppSystem.Collections.Generic; #elif MONOMELON using S1Clothing = ScheduleOne.Clothing; +using S1CoreAvatar = ScheduleOne.Core.Avatar; #endif using System; using System.Collections.Generic; @@ -75,8 +77,8 @@ public ClothingSlot Slot /// public ClothingApplicationType ApplicationType { - get => (ClothingApplicationType)S1ClothingDefinition.ApplicationType; - set => S1ClothingDefinition.ApplicationType = (S1Clothing.EClothingApplicationType)value; + get => ClothingApplicationType.Accessory; + set { } } /// @@ -84,8 +86,9 @@ public ClothingApplicationType ApplicationType /// public string ClothingAssetPath { - get => S1ClothingDefinition.ClothingAssetPath; - set => S1ClothingDefinition.ClothingAssetPath = value; + get => S1ClothingDefinition.ClothingAvatarObject?.name ?? string.Empty; + set => S1ClothingDefinition.ClothingAvatarObject = + UnityEngine.Resources.Load(value); } /// diff --git a/S1API/Items/Clothing/ClothingItemDefinitionBuilder.cs b/S1API/Items/Clothing/ClothingItemDefinitionBuilder.cs index 133dceaa..90d36f3a 100644 --- a/S1API/Items/Clothing/ClothingItemDefinitionBuilder.cs +++ b/S1API/Items/Clothing/ClothingItemDefinitionBuilder.cs @@ -4,6 +4,7 @@ using S1CoreItemFramework = Il2CppScheduleOne.Core.Items.Framework; using S1Registry = Il2CppScheduleOne.Registry; using S1UiItems = Il2CppScheduleOne.UI.Items; +using S1CoreAvatar = Il2CppScheduleOne.Core.Avatar; using Il2CppCollections = Il2CppSystem.Collections.Generic; #elif MONOMELON using S1Clothing = ScheduleOne.Clothing; @@ -11,6 +12,7 @@ using S1CoreItemFramework = ScheduleOne.Core.Items.Framework; using S1Registry = ScheduleOne.Registry; using S1UiItems = ScheduleOne.UI.Items; +using S1CoreAvatar = ScheduleOne.Core.Avatar; #endif using System.Collections.Generic; using S1API.Internal.Utils; @@ -45,8 +47,6 @@ internal ClothingItemDefinitionBuilder() // Clothing-specific defaults ClothingDefinition.Slot = S1Clothing.EClothingSlot.Head; - ClothingDefinition.ApplicationType = S1Clothing.EClothingApplicationType.Accessory; - ClothingDefinition.ClothingAssetPath = "Path/To/Clothing/Asset"; ClothingDefinition.Colorable = true; ClothingDefinition.DefaultColor = S1Clothing.EClothingColor.White; #if (IL2CPPMELON) @@ -73,8 +73,7 @@ protected override void CopyPropertiesFrom( var clothingSource = CrossType.As(source); ClothingDefinition.Slot = clothingSource.Slot; - ClothingDefinition.ApplicationType = clothingSource.ApplicationType; - ClothingDefinition.ClothingAssetPath = clothingSource.ClothingAssetPath; + ClothingDefinition.ClothingAvatarObject = clothingSource.ClothingAvatarObject; ClothingDefinition.Colorable = clothingSource.Colorable; ClothingDefinition.DefaultColor = clothingSource.DefaultColor; #if (IL2CPPMELON) @@ -111,7 +110,6 @@ public ClothingItemDefinitionBuilder WithSlot(Clothing.ClothingSlot slot) /// The builder instance for fluent chaining. public ClothingItemDefinitionBuilder WithApplicationType(Clothing.ClothingApplicationType applicationType) { - ClothingDefinition.ApplicationType = (S1Clothing.EClothingApplicationType)applicationType; return this; } @@ -122,7 +120,8 @@ public ClothingItemDefinitionBuilder WithApplicationType(Clothing.ClothingApplic /// The builder instance for fluent chaining. public ClothingItemDefinitionBuilder WithClothingAsset(string assetPath) { - ClothingDefinition.ClothingAssetPath = assetPath; + ClothingDefinition.ClothingAvatarObject = + Resources.Load(assetPath); return this; } diff --git a/S1API/Items/Clothing/ClothingMetadataCatalog.cs b/S1API/Items/Clothing/ClothingMetadataCatalog.cs new file mode 100644 index 00000000..ab6995be --- /dev/null +++ b/S1API/Items/Clothing/ClothingMetadataCatalog.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using UnityEngine; +#if MONOMELON +using S1Clothing = ScheduleOne.Clothing; +using S1DevUtilities = ScheduleOne.DevUtilities; +#elif IL2CPPMELON +using S1Clothing = Il2CppScheduleOne.Clothing; +using S1DevUtilities = Il2CppScheduleOne.DevUtilities; +#endif + +namespace S1API.Items.Clothing +{ + /// + /// Provides read-only access to the base game's clothing slot and color metadata. + /// + public static class ClothingMetadataCatalog + { + private static readonly IReadOnlyList EmptySlots = + new ReadOnlyCollection( + Array.Empty()); + private static readonly IReadOnlyList EmptyColors = + new ReadOnlyCollection( + Array.Empty()); + private static IClothingMetadataProvider _provider = + new NativeClothingMetadataProvider(); + + /// + /// Gets a read-only snapshot of all available clothing slot metadata. + /// Returns an empty list before the base game's clothing utility is ready. + /// + public static IReadOnlyList Slots => + GetSlots(); + + /// + /// Gets a read-only snapshot of all available clothing color metadata. + /// Returns an empty list before the base game's clothing utility is ready. + /// + public static IReadOnlyList Colors => + GetColors(); + + /// + /// Gets metadata for a clothing slot. + /// + /// The clothing slot to look up. + /// + /// The slot metadata, or when the slot is unavailable + /// or the base game's clothing utility is not ready. + /// + public static ClothingSlotMetadata? GetSlot(ClothingSlot slot) + { + TryGetSlot(slot, out ClothingSlotMetadata? metadata); + return metadata; + } + + /// + /// Tries to get metadata for a clothing slot. + /// + /// The clothing slot to look up. + /// The resolved metadata when available. + /// when metadata was found. + public static bool TryGetSlot( + ClothingSlot slot, + out ClothingSlotMetadata? metadata) + { + if (!Enum.IsDefined(typeof(ClothingSlot), slot)) + { + metadata = null; + return false; + } + + return _provider.TryGetSlot(slot, out metadata); + } + + /// + /// Gets metadata for a clothing color. + /// + /// The clothing color to look up. + /// + /// The color metadata, or when the color is unavailable + /// or the base game's clothing utility is not ready. + /// + public static ClothingColorMetadata? GetColor(ClothingColor color) + { + TryGetColor(color, out ClothingColorMetadata? metadata); + return metadata; + } + + /// + /// Tries to get metadata for a clothing color. + /// + /// The clothing color to look up. + /// The resolved metadata when available. + /// when metadata was found. + public static bool TryGetColor( + ClothingColor color, + out ClothingColorMetadata? metadata) + { + if (!Enum.IsDefined(typeof(ClothingColor), color)) + { + metadata = null; + return false; + } + + return _provider.TryGetColor(color, out metadata); + } + + internal static void ResetForTesting(IClothingMetadataProvider provider) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + internal static void RestoreProviderForTesting() + { + _provider = new NativeClothingMetadataProvider(); + } + + private static IReadOnlyList GetSlots() + { + var metadata = new List(); + foreach (ClothingSlot slot in Enum.GetValues(typeof(ClothingSlot))) + { + if (_provider.TryGetSlot(slot, out ClothingSlotMetadata? entry) + && entry != null) + { + metadata.Add(entry); + } + } + + return metadata.Count == 0 + ? EmptySlots + : new ReadOnlyCollection(metadata); + } + + private static IReadOnlyList GetColors() + { + var metadata = new List(); + foreach (ClothingColor color in Enum.GetValues(typeof(ClothingColor))) + { + if (_provider.TryGetColor(color, out ClothingColorMetadata? entry) + && entry != null) + { + metadata.Add(entry); + } + } + + return metadata.Count == 0 + ? EmptyColors + : new ReadOnlyCollection(metadata); + } + } + + internal interface IClothingMetadataProvider + { + bool TryGetSlot( + ClothingSlot slot, + out ClothingSlotMetadata? metadata); + + bool TryGetColor( + ClothingColor color, + out ClothingColorMetadata? metadata); + } + + internal sealed class NativeClothingMetadataProvider : + IClothingMetadataProvider + { + public bool TryGetSlot( + ClothingSlot slot, + out ClothingSlotMetadata? metadata) + { + metadata = null; + + try + { + var configuration = Resources + .FindObjectsOfTypeAll() + .FirstOrDefault(); + if (configuration == null) + return false; + + S1Clothing.ClothingConfiguration.ClothingSlotData nativeMetadata = + configuration.GetSlotData((S1Clothing.EClothingSlot)slot); + if (nativeMetadata.IsNull()) + return false; + + metadata = new ClothingSlotMetadata( + slot, + nativeMetadata.Name ?? slot.ToString(), + nativeMetadata.Icon); + return true; + } + catch + { + return false; + } + } + + public bool TryGetColor( + ClothingColor color, + out ClothingColorMetadata? metadata) + { + metadata = null; + + try + { + var configuration = Resources + .FindObjectsOfTypeAll() + .FirstOrDefault(); + if (configuration == null) + return false; + + S1Clothing.ClothingConfiguration.ColorData nativeMetadata = + configuration.GetColorData((S1Clothing.EClothingColor)color); + if (nativeMetadata.IsNull()) + return false; + + metadata = new ClothingColorMetadata( + color, + color.ToString(), + nativeMetadata.ActualColor, + nativeMetadata.LabelColor); + return true; + } + catch + { + return false; + } + } + } +} diff --git a/S1API/Items/Clothing/ClothingSlotMetadata.cs b/S1API/Items/Clothing/ClothingSlotMetadata.cs new file mode 100644 index 00000000..c8facaff --- /dev/null +++ b/S1API/Items/Clothing/ClothingSlotMetadata.cs @@ -0,0 +1,35 @@ +using UnityEngine; + +namespace S1API.Items.Clothing +{ + /// + /// Describes a clothing slot using the base game's display metadata. + /// + public sealed class ClothingSlotMetadata + { + internal ClothingSlotMetadata( + ClothingSlot slot, + string displayName, + Sprite? icon) + { + Slot = slot; + DisplayName = displayName; + Icon = icon; + } + + /// + /// Gets the clothing slot represented by this metadata. + /// + public ClothingSlot Slot { get; } + + /// + /// Gets the base game's display name for the slot. + /// + public string DisplayName { get; } + + /// + /// Gets the base game's icon for the slot, if one is configured. + /// + public Sprite? Icon { get; } + } +} diff --git a/S1API/Items/ClothingItemDefinition.cs b/S1API/Items/ClothingItemDefinition.cs index abf48999..8bc53314 100644 --- a/S1API/Items/ClothingItemDefinition.cs +++ b/S1API/Items/ClothingItemDefinition.cs @@ -1,8 +1,10 @@ #if (IL2CPPMELON) using S1Clothing = Il2CppScheduleOne.Clothing; +using S1CoreAvatar = Il2CppScheduleOne.Core.Avatar; using Il2CppCollections = Il2CppSystem.Collections.Generic; #elif MONOMELON using S1Clothing = ScheduleOne.Clothing; +using S1CoreAvatar = ScheduleOne.Core.Avatar; using Il2CppCollections = System.Collections.Generic; #endif @@ -78,8 +80,8 @@ public ClothingSlot Slot /// public ClothingApplicationType ApplicationType { - get => (ClothingApplicationType)S1ClothingDefinition.ApplicationType; - set => S1ClothingDefinition.ApplicationType = (S1Clothing.EClothingApplicationType)value; + get => ClothingApplicationType.Accessory; + set { } } /// @@ -87,8 +89,9 @@ public ClothingApplicationType ApplicationType /// public string ClothingAssetPath { - get => S1ClothingDefinition.ClothingAssetPath; - set => S1ClothingDefinition.ClothingAssetPath = value; + get => S1ClothingDefinition.ClothingAvatarObject?.name ?? string.Empty; + set => S1ClothingDefinition.ClothingAvatarObject = + UnityEngine.Resources.Load(value); } /// diff --git a/S1API/Items/ClothingItemDefinitionBuilder.cs b/S1API/Items/ClothingItemDefinitionBuilder.cs index 7fe502aa..def6fdfc 100644 --- a/S1API/Items/ClothingItemDefinitionBuilder.cs +++ b/S1API/Items/ClothingItemDefinitionBuilder.cs @@ -4,6 +4,7 @@ using S1CoreItemFramework = Il2CppScheduleOne.Core.Items.Framework; using S1Registry = Il2CppScheduleOne.Registry; using S1UiItems = Il2CppScheduleOne.UI.Items; +using S1CoreAvatar = Il2CppScheduleOne.Core.Avatar; using Il2CppCollections = Il2CppSystem.Collections.Generic; #elif MONOMELON using S1Clothing = ScheduleOne.Clothing; @@ -11,6 +12,7 @@ using S1CoreItemFramework = ScheduleOne.Core.Items.Framework; using S1Registry = ScheduleOne.Registry; using S1UiItems = ScheduleOne.UI.Items; +using S1CoreAvatar = ScheduleOne.Core.Avatar; using Il2CppCollections = System.Collections.Generic; #endif @@ -54,8 +56,6 @@ internal ClothingItemDefinitionBuilder() // Clothing-specific defaults _definition.Slot = S1Clothing.EClothingSlot.Head; - _definition.ApplicationType = S1Clothing.EClothingApplicationType.Accessory; - _definition.ClothingAssetPath = "Path/To/Clothing/Asset"; _definition.Colorable = true; _definition.DefaultColor = S1Clothing.EClothingColor.White; #if (IL2CPPMELON) @@ -95,8 +95,7 @@ internal ClothingItemDefinitionBuilder(S1Clothing.ClothingDefinition source) // Copy clothing-specific properties _definition.Slot = source.Slot; - _definition.ApplicationType = source.ApplicationType; - _definition.ClothingAssetPath = source.ClothingAssetPath; + _definition.ClothingAvatarObject = source.ClothingAvatarObject; _definition.Colorable = source.Colorable; _definition.DefaultColor = source.DefaultColor; #if (IL2CPPMELON) @@ -149,7 +148,6 @@ public ClothingItemDefinitionBuilder WithSlot(ClothingSlot slot) /// The builder instance for fluent chaining. public ClothingItemDefinitionBuilder WithApplicationType(ClothingApplicationType applicationType) { - _definition.ApplicationType = (S1Clothing.EClothingApplicationType)applicationType; return this; } @@ -160,7 +158,7 @@ public ClothingItemDefinitionBuilder WithApplicationType(ClothingApplicationType /// The builder instance for fluent chaining. public ClothingItemDefinitionBuilder WithClothingAsset(string assetPath) { - _definition.ClothingAssetPath = assetPath; + _definition.ClothingAvatarObject = Resources.Load(assetPath); return this; } diff --git a/S1API/Items/ItemCreator.cs b/S1API/Items/ItemCreator.cs index 9f20d0a7..48864803 100644 --- a/S1API/Items/ItemCreator.cs +++ b/S1API/Items/ItemCreator.cs @@ -14,7 +14,9 @@ namespace S1API.Items { /// /// Provides convenient static methods for creating custom items. - /// Use for flexible configuration or for quick creation. + /// Use for flexible configuration or + /// + /// for quick creation. /// /// /// All items in Schedule One are storable items (StorableItemDefinition), so both methods create the same type. @@ -142,6 +144,74 @@ public static StorableItemDefinition CreateItem( return builder.Build(); } + /// + /// Creates an item using the parameter order from S1API 3.0.0 through 3.0.4, without a rank requirement. + /// Provided for source compatibility with nine-argument positional calls. + /// + /// Unique identifier for the item (e.g., "my_custom_tool"). + /// Display name shown in UI. + /// Item description shown in tooltips. + /// Item category for inventory organization. + /// Maximum quantity per inventory slot. + /// Base price when buying from shops. + /// Fraction of purchase price recovered when selling. + /// Whether the item is legal or illegal. + /// Optional sprite to use as the item icon. + /// A wrapper around the created item definition. + /// + /// The distinct parameter name keeps calls that target the current overload with icon: unambiguous. + /// + [Obsolete("Use the overload with requiresLevelToPurchase and requiredRank. This compatibility overload may be removed in a future S1API version.")] + public static StorableItemDefinition CreateItem( + string id, + string name, + string description, + ItemCategory category, + int stackLimit, + float basePurchasePrice, + float resellMultiplier, + LegalStatus legalStatus, + Sprite? legacyIcon) => + CreateItem(id, name, description, category, stackLimit, basePurchasePrice, + resellMultiplier, legalStatus, requiresLevelToPurchase: false, requiredRank: null, + icon: legacyIcon, equippable: null); + + /// + /// Creates an item using the parameter order from S1API 3.0.0 through 3.0.4, without a rank requirement. + /// Provided for binary compatibility with mods compiled against those versions. + /// + /// Unique identifier for the item (e.g., "my_custom_tool"). + /// Display name shown in UI. + /// Item description shown in tooltips. + /// Item category for inventory organization. + /// Maximum quantity per inventory slot. + /// Base price when buying from shops. + /// Fraction of purchase price recovered when selling. + /// Whether the item is legal or illegal. + /// Optional sprite to use as the item icon. + /// Optional equippable component to attach. + /// A wrapper around the created item definition. + /// + /// S1API 3.0.5 inserted requiresLevelToPurchase and requiredRank before icon. + /// This overload retains the former ten-parameter binary signature. Its parameters remain required + /// so current calls that use the named icon parameter continue to bind unambiguously. + /// + [Obsolete("Use the overload with requiresLevelToPurchase and requiredRank. This compatibility overload may be removed in a future S1API version.")] + public static StorableItemDefinition CreateItem( + string id, + string name, + string description, + ItemCategory category, + int stackLimit, + float basePurchasePrice, + float resellMultiplier, + LegalStatus legalStatus, + Sprite? icon, + Equippable? equippable) => + CreateItem(id, name, description, category, stackLimit, basePurchasePrice, + resellMultiplier, legalStatus, requiresLevelToPurchase: false, requiredRank: null, + icon: icon, equippable: equippable); + /// /// Creates a new equippable builder for creating custom equippable components. /// Use this to create equippable behavior that can be attached to items. diff --git a/S1API/Items/ItemManager.cs b/S1API/Items/ItemManager.cs index 8e64a22e..97fdc629 100644 --- a/S1API/Items/ItemManager.cs +++ b/S1API/Items/ItemManager.cs @@ -276,13 +276,7 @@ private static bool RemoveFromRuntimeCleanupQueue(S1ItemFramework.ItemDefinition return false; } - FieldInfo? runtimeItemsField = typeof(S1Registry).GetField("ItemsAddedAtRuntime", BindingFlags.NonPublic | BindingFlags.Instance); - if (runtimeItemsField == null) - { - return false; - } - - object? runtimeItems = runtimeItemsField.GetValue(S1Registry.Instance); + object? runtimeItems = ReflectionUtils.TryGetFieldOrProperty(S1Registry.Instance, "ItemsAddedAtRuntime"); if (runtimeItems == null) { return false; @@ -308,12 +302,8 @@ private static bool RemoveFromRuntimeCleanupQueue(S1ItemFramework.ItemDefinition continue; } - Type registerType = register.GetType(); - FieldInfo? idField = registerType.GetField("ID", BindingFlags.Public | BindingFlags.Instance); - FieldInfo? definitionField = registerType.GetField("Definition", BindingFlags.Public | BindingFlags.Instance); - - string? registeredId = idField?.GetValue(register) as string; - object? registeredDefinition = definitionField?.GetValue(register); + string? registeredId = ReflectionUtils.TryGetFieldOrProperty(register, "ID") as string; + object? registeredDefinition = ReflectionUtils.TryGetFieldOrProperty(register, "Definition"); if (!string.Equals(registeredId, itemId, StringComparison.OrdinalIgnoreCase) && !ReferenceEquals(registeredDefinition, nativeDefinition)) { diff --git a/S1API/Items/Storable/StorableItemDefinitionBuilder.cs b/S1API/Items/Storable/StorableItemDefinitionBuilder.cs index 5b878535..3f430442 100644 --- a/S1API/Items/Storable/StorableItemDefinitionBuilder.cs +++ b/S1API/Items/Storable/StorableItemDefinitionBuilder.cs @@ -5,6 +5,7 @@ using S1Registry = Il2CppScheduleOne.Registry; using S1StationFramework = Il2CppScheduleOne.StationFramework; using S1Storage = Il2CppScheduleOne.Storage; +using S1Trash = Il2CppScheduleOne.Trash; #elif MONOMELON using S1ItemFramework = ScheduleOne.ItemFramework; using S1CoreItemFramework = ScheduleOne.Core.Items.Framework; @@ -12,6 +13,7 @@ using S1Registry = ScheduleOne.Registry; using S1StationFramework = ScheduleOne.StationFramework; using S1Storage = ScheduleOne.Storage; +using S1Trash = ScheduleOne.Trash; #endif using System; using System.Collections.Generic; @@ -99,6 +101,9 @@ private static GameObject? StationItemRoot protected readonly S1ItemFramework.StorableItemDefinition Definition; private readonly GameObject _storedItemPlaceholder; private bool _hasCustomStoredItem; + private GameObject? _trashPrefab; + private string? _trashId; + private bool _replaceExistingTrash; /// /// INTERNAL: Whether a custom StoredItem was assigned via . @@ -341,6 +346,69 @@ public TSelf WithoutStationItem() return Self; } + /// + /// Assigns and registers the trash prefab spawned after this station item is consumed. + /// + /// + /// The station item must be configured before . If no explicit trash ID is + /// supplied, S1API uses <item ID>_trash. All multiplayer clients must build the + /// same registration. + /// + /// Prefab containing a native TrashItem component. + /// Whether an existing trash registration may be replaced. + /// The builder instance for fluent chaining. + /// + /// Thrown when is . + /// + /// + /// Thrown when has no native TrashItem component. + /// + public TSelf WithTrashPrefab( + GameObject trashPrefab, + bool replaceExisting = false) + { + return WithTrashPrefab( + trashId: null, + trashPrefab, + replaceExisting); + } + + /// + /// Assigns and registers the trash prefab spawned after this station item is consumed. + /// + /// Stable trash ID used for spawning and persistence. + /// Prefab containing a native TrashItem component. + /// Whether an existing trash registration may be replaced. + /// The builder instance for fluent chaining. + /// + /// Thrown when is . + /// + /// + /// Thrown when has no native TrashItem component, or + /// is empty or whitespace. + /// + public TSelf WithTrashPrefab( + string? trashId, + GameObject trashPrefab, + bool replaceExisting = false) + { + if (trashPrefab == null) + throw new ArgumentNullException(nameof(trashPrefab)); + if (trashPrefab.GetComponent() == null) + { + throw new ArgumentException( + "Trash prefab must have a TrashItem component.", + nameof(trashPrefab)); + } + if (trashId != null && string.IsNullOrWhiteSpace(trashId)) + throw new ArgumentException("Trash ID cannot be empty.", nameof(trashId)); + + _trashId = trashId; + _trashPrefab = trashPrefab; + _replaceExistingTrash = replaceExisting; + return Self; + } + /// /// Sets whether this item is available in the demo version of the game. /// @@ -393,6 +461,8 @@ protected virtual Storable.StorableItemDefinition Build() } } + ApplyTrashPrefab(); + // Register with the game's registry S1Registry.Instance.AddToRegistry(Definition); RuntimeItemDefinitionRegistry.Retain(Definition.ID, Definition); @@ -401,6 +471,39 @@ protected virtual Storable.StorableItemDefinition Build() return CreateWrapper(Definition); } + private void ApplyTrashPrefab() + { + if (_trashPrefab == null) + return; + if (Definition.StationItem == null) + { + throw new InvalidOperationException( + "A station item is required before configuring its trash prefab."); + } + + string trashId = ResolveTrashId(Definition.ID, _trashId); + GameObject? existing = + global::S1API.Trash.TrashManager.GetTrashPrefab(trashId); + if (!_replaceExistingTrash && existing != null) + { + Logger.Warning( + $"Item '{Definition.ID}' requested trash ID '{trashId}', " + + $"but '{existing.name}' is already registered and will be reused."); + } + + GameObject registered = global::S1API.Trash.TrashManager.RegisterTrashPrefab( + trashId, + _trashPrefab, + _replaceExistingTrash); + Definition.StationItem.TrashPrefab = + registered.GetComponent(); + } + + internal static string ResolveTrashId(string itemId, string? trashId) + { + return trashId ?? $"{itemId}_trash"; + } + /// /// INTERNAL: Builds and returns the raw game item definition without registering. /// Used internally by S1API. Modders should use instead. diff --git a/S1API/Law/LawController.cs b/S1API/Law/LawController.cs index 85d76fb7..48457cd0 100644 --- a/S1API/Law/LawController.cs +++ b/S1API/Law/LawController.cs @@ -57,19 +57,10 @@ public static float InternalIntensity get { if (Internal == null) return 0f; -#if (IL2CPPMELON) - // Access via reflection as it's a private field in IL2CPP - return 0f; // Safe fallback - modders should use Intensity property instead -#else - // In Mono we can access the field directly through reflection if needed - var fieldInfo = typeof(S1Law.LawController).GetField("internalLawIntensity", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (fieldInfo != null) - { - return (float)fieldInfo.GetValue(Internal); - } - return 0f; -#endif + object? value = global::S1API.Internal.Utils.ReflectionUtils.TryGetFieldOrProperty( + Internal, + "internalLawIntensity"); + return value == null ? 0f : System.Convert.ToSingle(value); } } diff --git a/S1API/Lifecycle/GameLifecycle.cs b/S1API/Lifecycle/GameLifecycle.cs index 83df7c7d..817d0bbb 100644 --- a/S1API/Lifecycle/GameLifecycle.cs +++ b/S1API/Lifecycle/GameLifecycle.cs @@ -5,6 +5,7 @@ #endif using System; +using S1API.Internal.Map; using UnityEngine.Events; namespace S1API.Lifecycle @@ -193,6 +194,7 @@ private static void InvokeOnLoadComplete() { try { + DeferredMapResolver.ResolveAll(); OnLoadComplete?.Invoke(); } catch (Exception ex) diff --git a/S1API/Map/Building.cs b/S1API/Map/Building.cs index 37e3c1bc..e4535040 100644 --- a/S1API/Map/Building.cs +++ b/S1API/Map/Building.cs @@ -255,8 +255,12 @@ public static Building[] GetAll() if (byTypeName != null) return byTypeName; - // If still not found and we're in Menu scene, create deferred wrapper - if (DeferredMapResolver.IsMenuScene()) + // NPC prefab configuration can run after Main becomes the active scene but before + // the map registry has finished populating. Keep typed identifiers deferred until + // the load-complete resolver has established that the map is ready. + if (ShouldDeferTypedLookup( + DeferredMapResolver.IsMenuScene(), + DeferredMapResolver.IsMainSceneReady)) { string deferredName = !string.IsNullOrEmpty(name) ? name : t.Name; var deferredWrapper = new Building(t, deferredName); @@ -275,6 +279,9 @@ public static Building[] GetAll() return null; } + internal static bool ShouldDeferTypedLookup(bool isMenuScene, bool isMainSceneReady) => + isMenuScene || !isMainSceneReady; + private static string? TryGetNameFromIdentifier(Type t) { try diff --git a/S1API/Map/Buildings/Buildings.md b/S1API/Map/Buildings/Buildings.md index b67d5315..8f353e2c 100644 --- a/S1API/Map/Buildings/Buildings.md +++ b/S1API/Map/Buildings/Buildings.md @@ -62,7 +62,6 @@ George and Molly's House Fish Warehouse Cafe Pillville -Casino Pawn Shop Upscale Apartments Community Center @@ -71,4 +70,4 @@ Charles' House Dan's Hardware Upstairs Hyland Bank Beth's Room -North apartments \ No newline at end of file +North apartments diff --git a/S1API/Map/Buildings/Casino.cs b/S1API/Map/Buildings/Casino.cs index 6079ced9..b4d876d6 100644 --- a/S1API/Map/Buildings/Casino.cs +++ b/S1API/Map/Buildings/Casino.cs @@ -1,9 +1,10 @@ namespace S1API.Map.Buildings { /// - /// Identifier for the base-game building named "Casino". - /// Modders can use to resolve it. + /// Legacy identifier for the base-game location named "Casino". + /// This identifier cannot resolve because the Casino is not an enterable building. /// + [System.Obsolete("Casino is not an enterable building and cannot be resolved. This compatibility identifier may be removed in a future S1API version.", false)] [BuildingName("Casino")] public sealed class Casino : IBuildingIdentifier { } } diff --git a/S1API/PhoneApp/ExitAction.cs b/S1API/PhoneApp/ExitAction.cs new file mode 100644 index 00000000..a6a331af --- /dev/null +++ b/S1API/PhoneApp/ExitAction.cs @@ -0,0 +1,31 @@ +using System; + +namespace S1API.PhoneApp +{ + /// + /// Represents a request to exit the active phone application without exposing + /// runtime-specific Schedule One types. + /// + public sealed class ExitAction + { + private readonly Func _getUsed; + private readonly Action _setUsed; + + internal ExitAction(Func getUsed, Action setUsed) + { + _getUsed = getUsed ?? throw new ArgumentNullException(nameof(getUsed)); + _setUsed = setUsed ?? throw new ArgumentNullException(nameof(setUsed)); + } + + /// + /// Gets or sets whether another listener has handled the exit request. + /// Set this to after handling the request to prevent + /// lower-priority listeners from processing it again. + /// + public bool Used + { + get => _getUsed(); + set => _setUsed(value); + } + } +} diff --git a/S1API/PhoneApp/PhoneApp.cs b/S1API/PhoneApp/PhoneApp.cs index 83c77ab4..3369a24d 100644 --- a/S1API/PhoneApp/PhoneApp.cs +++ b/S1API/PhoneApp/PhoneApp.cs @@ -1,12 +1,14 @@ +using System; +using System.Collections.Generic; using System.IO; -using UnityEngine; -using UnityEngine.UI; -using Object = UnityEngine.Object; +using HarmonyLib; +using MelonLoader; using S1API.Internal.Abstraction; using S1API.Internal.Patches; using S1API.Internal.Utils; -using System; -using MelonLoader; +using UnityEngine; +using UnityEngine.UI; +using Object = UnityEngine.Object; #if IL2CPPMELON using Il2CppScheduleOne.UI; using Il2CppScheduleOne.UI.Phone; @@ -15,6 +17,7 @@ using MelonLoader.Utils; using Il2CppInterop.Runtime; using S1GameInput = Il2CppScheduleOne.GameInput; +using S1ExitAction = Il2CppScheduleOne.ExitAction; #elif MONOMELON using ScheduleOne.UI; using ScheduleOne.DevUtilities; @@ -22,6 +25,7 @@ using ScheduleOne; using MelonLoader.Utils; using S1GameInput = ScheduleOne.GameInput; +using S1ExitAction = ScheduleOne.ExitAction; #endif namespace S1API.PhoneApp { @@ -218,8 +222,10 @@ protected override void OnDestroyed() } /// - /// Handles exit/home button functionality. Called when user presses escape or home. + /// Handles exit/home button functionality without exposing runtime-specific game types. + /// Called when the user presses escape or home. /// + /// The cross-runtime exit request. public virtual void Exit(ExitAction exit) { if (!exit.Used && IsOpen() && Phone.InstanceExists && Phone.Instance.IsOpen) @@ -285,9 +291,9 @@ internal void SpawnUI(HomeScreen homeScreenInstance) // Create IL2CPP-safe delegate instance #if IL2CPPMELON - _exitDelegate = DelegateSupport.ConvertDelegate(new System.Action(Exit)); + _exitDelegate = DelegateSupport.ConvertDelegate(new System.Action(HandleNativeExit)); #else - _exitDelegate = new S1GameInput.ExitDelegate(Exit); + _exitDelegate = new S1GameInput.ExitDelegate(HandleNativeExit); #endif GameInput.RegisterExitListener(_exitDelegate, 1); @@ -297,6 +303,13 @@ internal void SpawnUI(HomeScreen homeScreenInstance) } } + private void HandleNativeExit(S1ExitAction exit) + { + Exit(new ExitAction( + () => exit.Used, + used => exit.Used = used)); + } + /// /// Creates or modifies the application icon displayed on the in-game phone's home screen. /// This method clones an existing icon, updates its label, and changes its image based on the provided file name. @@ -314,16 +327,14 @@ internal void SpawnIcon(HomeScreen homeScreenInstance) return; } - // Find the LAST icon (the one most recently added) - Transform? lastIcon = appIcons.transform.childCount > 0 ? appIcons.transform.GetChild(appIcons.transform.childCount - 1) : null; - if (lastIcon == null) + GameObject? iconObj = CreateAppIcon(homeScreenInstance, appIcons.transform); + if (iconObj == null) { - Logger.Error("No icons found in AppIcons."); + Logger.Error($"Failed to create an icon for {AppName}."); return; } - GameObject iconObj = lastIcon.gameObject; - iconObj.name = AppName; // Rename it now + iconObj.name = AppName; // Cache icon image for future updates Transform imageTransform = iconObj.transform.Find("Mask/Image"); @@ -364,6 +375,57 @@ internal void SpawnIcon(HomeScreen homeScreenInstance) } } + /// + /// Creates and registers an independent home-screen icon using the native icon prefab. + /// + private static GameObject? CreateAppIcon(HomeScreen homeScreenInstance, Transform parent) + { +#if IL2CPPMELON + GameObject? iconPrefab = homeScreenInstance.appIconPrefab; +#else + GameObject? iconPrefab = AccessTools.Field( + typeof(HomeScreen), + "appIconPrefab")?.GetValue(homeScreenInstance) as GameObject; +#endif + if (iconPrefab == null) + { + Logger.Error("HomeScreen appIconPrefab was unavailable."); + return null; + } + + GameObject iconObject = Object.Instantiate(iconPrefab, parent); + iconObject.transform.Find("Notifications")?.gameObject.SetActive(false); + + Button? button = iconObject.GetComponent /// /// This descriptor deliberately contains no Unity objects, asset references, delegates, or - /// process-local references. A provider may use to retain its own - /// bounded scalar configuration. + /// process-local references. A provider may use for its own + /// bounded scalar configuration. It must recreate assets and callbacks from local mod resources. /// public sealed class CustomProductSaveDescriptor { @@ -65,15 +65,19 @@ public CustomProductSaveDescriptor( } /// Reconstructs a custom product from a persisted scalar descriptor. + /// + /// Register the provider before save restoration. The returned builder must retain the descriptor's + /// stable product ID. Returning preserves S1API's safe missing-content path. + /// public interface ICustomProductSaveProvider { /// Gets the stable, namespaced provider ID. string ProviderId { get; } /// Gets the highest provider descriptor version this provider accepts. int MaximumDescriptorVersion { get; } - /// Recreates and registers the descriptor's product. + /// Returns the fully configured builder that recreates the descriptor's product. /// The validated scalar descriptor. - /// A fully configured, unbuilt definition builder, or to use S1API's safe fallback. + /// A fully configured, unbuilt definition builder, or to skip restoration safely. CustomProductDefinitionBuilder? Restore(CustomProductSaveDescriptor descriptor); } } diff --git a/S1API/Products/CustomProductSaveProviderRegistry.cs b/S1API/Products/CustomProductSaveProviderRegistry.cs index ea237091..7feae8d8 100644 --- a/S1API/Products/CustomProductSaveProviderRegistry.cs +++ b/S1API/Products/CustomProductSaveProviderRegistry.cs @@ -4,9 +4,18 @@ namespace S1API.Products { /// Registers process-lifetime providers for custom-product save descriptors. + /// + /// Register providers during early mod initialization, before custom-product save restoration. + /// Provider IDs are durable and case-insensitive. A conflicting provider cannot replace the + /// provider that already owns that ID. + /// public static class CustomProductSaveProviderRegistry { - /// Registers a provider, or returns the existing equivalent provider. + /// Registers a save provider, or returns the existing equivalent provider. + /// The provider that reconstructs one family of saved custom products. + /// The registered provider, or the existing equivalent provider. + /// Thrown when is . + /// Thrown when another provider already owns the same ID. public static ICustomProductSaveProvider Register(ICustomProductSaveProvider provider) { return CustomProductSavePersistence.RegisterProvider(provider); diff --git a/S1API/Products/PackagingDefinition.cs b/S1API/Products/PackagingDefinition.cs index c89e91b3..e008f10b 100644 --- a/S1API/Products/PackagingDefinition.cs +++ b/S1API/Products/PackagingDefinition.cs @@ -13,8 +13,13 @@ namespace S1API.Products { /// - /// Represents a type of packaging in-game. + /// Represents a native packaging type in the active game runtime. /// + /// + /// Packaging definitions describe capacity and stealth. They do not create packaging assets + /// or alter a product's allowed packaging policy. Resolve a live definition through + /// or the item registry. + /// public class PackagingDefinition : ItemDefinition { /// @@ -31,13 +36,13 @@ internal PackagingDefinition(S1ItemFramework.ItemDefinition s1ItemDefinition) : base(s1ItemDefinition) { } /// - /// The quantity that this packaging can hold. + /// Gets the native product quantity this packaging can hold. /// public int Quantity => S1PackagingDefinition.Quantity; /// - /// The stealth level of this packaging. + /// Gets the runtime-agnostic stealth level for this packaging. /// public StealthLevel StealthLevel => S1PackagingDefinition.StealthLevel.ToAPI(); diff --git a/S1API/Products/ProductDefinition.cs b/S1API/Products/ProductDefinition.cs index 75f9a3cb..a62aa6de 100644 --- a/S1API/Products/ProductDefinition.cs +++ b/S1API/Products/ProductDefinition.cs @@ -20,8 +20,14 @@ namespace S1API.Products { /// - /// Represents a product definition in the game. + /// Represents one registered product type in the active game runtime. /// + /// + /// A definition supplies the shared identity, price, properties, icon, and packaging policy + /// for its instances. It is a wrapper over a native definition, not a new product-registration + /// mechanism. Use the custom-product or native-family builders when a mod needs to register + /// a definition. + /// public class ProductDefinition : Items.Storable.StorableItemDefinition { /// @@ -39,43 +45,50 @@ internal ProductDefinition(S1Product.ProductDefinition productDefinition) { } /// - /// The price associated with this product. + /// Gets the current price selected by the native product system. /// public float Price => S1ProductDefinition.Price; /// - /// The base price associated with this product. + /// Gets the native base price before market adjustments. /// public float BasePrice => S1ProductDefinition.BasePrice; /// - /// The market value associated with this product. + /// Gets the product's native market value. /// public float MarketValue => S1ProductDefinition.MarketValue; /// - /// Creates an instance of this product in-game. + /// Creates an unpackaged standard-quality instance of this definition. /// - /// The quantity of product. - /// An instance of the product. + /// The native product quantity for the new instance. + /// A new API wrapper around the native product instance. public override ItemInstance CreateInstance(int quantity = 1) => new ProductInstance(CrossType.As(S1ProductDefinition.GetDefaultInstance(quantity))); /// - /// Gets the in-game icon associated with the product. + /// Gets the current native inventory icon. /// + /// + /// The returned sprite is owned by the active Unity runtime. Do not destroy it. A + /// generated custom-product icon can replace this reference after its capture completes. + /// public new Sprite Icon { get { return S1ProductDefinition.Icon; } } /// - /// The list of product properties for this definition. - /// Returns runtime-agnostic property wrappers that work on both Mono and IL2CPP. + /// Gets runtime-agnostic wrappers for the definition's product properties. /// + /// + /// Each call creates a read-only snapshot that works on both Mono and IL2CPP. Do not use + /// the wrapper objects as stable identity keys; use their IDs when identity matters. + /// public IReadOnlyList Properties { get @@ -166,11 +179,14 @@ public System.Collections.Generic.IReadOnlyList Dru S1ProductDefinition.DrugType; /// - /// Creates a packaged instance of this product with the specified packaging. + /// Creates a standard-quality instance with the supplied native packaging. /// - /// The quantity of the product. - /// The packaging to apply to the product. - /// A packaged product instance, or null if packaging is not found. + /// The native product quantity for the new instance. + /// A live packaging definition from the active game runtime. + /// + /// A packaged product instance, or when the packaging cannot be + /// converted for the active runtime or native construction fails. + /// public ProductInstance? CreatePackagedInstance(int quantity, PackagingDefinition packaging) { try diff --git a/S1API/Products/ProductDefinitionWrapper.cs b/S1API/Products/ProductDefinitionWrapper.cs index 9f1a5955..473f040e 100644 --- a/S1API/Products/ProductDefinitionWrapper.cs +++ b/S1API/Products/ProductDefinitionWrapper.cs @@ -10,15 +10,20 @@ namespace S1API.Products { /// - /// Provides functionality to wrap and convert generic product definitions into their specific type-derived definitions. + /// Selects the most specific API wrapper for a registered native product definition. /// + /// + /// The wrapper exposes native-family types when available and returns + /// only for definitions registered through S1API's + /// custom-product metadata. It never changes the native definition or its identity. + /// public static class ProductDefinitionWrapper { /// - /// Converts a generic into its corresponding typed wrapper. + /// Returns the most specific wrapper available for a product definition. /// - /// The raw product definition to be processed and converted. - /// A wrapped instance of with type-specific methods and properties, or the input definition if no specific wrapper applies. + /// The product definition to classify. + /// A typed wrapper, or when no more specific wrapper applies. public static ProductDefinition Wrap(ProductDefinition def) { return Wrap(def.S1ProductDefinition, def); diff --git a/S1API/Products/ProductInstance.cs b/S1API/Products/ProductInstance.cs index 1e7a7a6a..f2de7cd2 100644 --- a/S1API/Products/ProductInstance.cs +++ b/S1API/Products/ProductInstance.cs @@ -12,11 +12,12 @@ namespace S1API.Products { /// - /// Represents an instance of a product in the game. + /// Represents one product stack or item in the active game runtime. /// /// - /// This class defines specific properties and behaviors for a product instance, - /// such as quality, packaging, and definition, derived from the S1API's item instance structure. + /// Product instances inherit quantity and base item behavior from . + /// This wrapper adds product definition, quality, packaging, and property access without + /// exposing runtime-specific native types. /// public class ProductInstance : S1ItemInstance { @@ -27,8 +28,7 @@ public class ProductInstance : S1ItemInstance CrossType.As(S1ItemInstance); /// - /// Represents an instance of a product, derived from a specific in-game product item instance, - /// with additional properties for packaging, quality, and product definition. + /// Creates a wrapper around a native product item instance. /// internal ProductInstance(S1Product.ProductItemInstance productInstance) : base(productInstance) @@ -36,22 +36,26 @@ internal ProductInstance(S1Product.ProductItemInstance productInstance) } /// - /// Indicates whether the product instance has applied packaging. + /// Gets whether the instance currently has native packaging. /// public bool IsPackaged => S1ProductInstance.AppliedPackaging; /// - /// Provides access to the packaging information applied to the product, - /// represented as a specific packaging definition instance. + /// Gets the packaging applied to this instance. /// + /// + /// Check before reading this property. The native runtime only + /// provides a packaging definition for packaged instances. + /// public PackagingDefinition AppliedPackaging => new PackagingDefinition(S1ProductInstance.AppliedPackaging); /// - /// Represents the quality level of the product instance. + /// Gets the runtime-agnostic quality level assigned to this instance. /// /// - /// Quality levels provide a measure of the product's grading, ranging from "Trash" to "Heavenly". + /// This value comes from the native instance. Creating an instance through + /// uses the native standard quality. /// public Quality Quality => S1ProductInstance.Quality.ToAPI(); @@ -63,12 +67,11 @@ internal ProductInstance(S1Product.ProductItemInstance productInstance) CrossType.As(S1ProductInstance.Definition)); /// - /// Gets the list of properties associated with the product definition. + /// Gets runtime-agnostic wrappers for the properties on . /// /// - /// This property provides an unmodifiable list of properties associated - /// with the underlying product definition. Each property represents - /// a specific characteristic or behavior of the corresponding product. + /// Properties belong to the definition, not the individual stack. The returned list is a + /// read-only snapshot from . /// public IReadOnlyList Properties => Definition.Properties; } diff --git a/S1API/Products/ProductMixingProfile.cs b/S1API/Products/ProductMixingProfile.cs index 9cea234d..7cb39a2d 100644 --- a/S1API/Products/ProductMixingProfile.cs +++ b/S1API/Products/ProductMixingProfile.cs @@ -7,13 +7,14 @@ namespace S1API.Products /// public sealed class ProductMixingProfile { - internal ProductMixingProfile(ProductKind productKind, ProductMixingMap mixerMap, Func outputFactory, string outputFactoryIdentity, int outputFactoryVersion) + internal ProductMixingProfile(ProductKind productKind, ProductMixingMap mixerMap, Func outputFactory, string outputFactoryIdentity, int outputFactoryVersion, bool usePropertyColorMixing) { ProductKind = productKind; MixerMap = mixerMap; OutputFactory = outputFactory; OutputFactoryIdentity = outputFactoryIdentity; OutputFactoryVersion = outputFactoryVersion; + UsePropertyColorMixing = usePropertyColorMixing; } /// Gets the opted-in logical product kind. @@ -24,6 +25,8 @@ internal ProductMixingProfile(ProductKind productKind, ProductMixingMap mixerMap public string OutputFactoryIdentity { get; } /// Gets the output-factory compatibility version used across peers. public int OutputFactoryVersion { get; } + /// Gets whether generated outputs use a native-style color derived from their properties. + public bool UsePropertyColorMixing { get; } internal Func OutputFactory { get; } } } diff --git a/S1API/Products/ProductMixingProfileBuilder.cs b/S1API/Products/ProductMixingProfileBuilder.cs index 504e152d..fc526752 100644 --- a/S1API/Products/ProductMixingProfileBuilder.cs +++ b/S1API/Products/ProductMixingProfileBuilder.cs @@ -13,8 +13,11 @@ public sealed class ProductMixingProfileBuilder private Func? _outputFactory; private string? _outputFactoryIdentity; private int _outputFactoryVersion; + private bool _usePropertyColorMixing; - /// Creates a profile builder for a registered logical product kind. + /// Creates a mixing-profile builder for a registered logical product kind. + /// The stable logical kind that opts into mixing. + /// Thrown when is . public ProductMixingProfileBuilder(ProductKind productKind) { _productKind = productKind ?? throw new ArgumentNullException(nameof(productKind)); @@ -35,7 +38,10 @@ public ProductMixingProfileBuilder WithMixerMap(ProductMixingMap mixerMap) return this; } - /// Sets the deterministic factory used to name, price, and optionally transform generated outputs. + /// Sets the deterministic factory that names, prices, and optionally transforms generated outputs. + /// A deterministic factory invoked for each native mixing output. + /// This builder. + /// Thrown when is . public ProductMixingProfileBuilder WithOutputFactory(Func outputFactory) { _outputFactory = outputFactory ?? throw new ArgumentNullException(nameof(outputFactory)); @@ -63,7 +69,24 @@ public ProductMixingProfileBuilder WithOutputFactoryCompatibility( return this; } - /// Registers this immutable profile. + /// + /// Colors each generated output from its mixed properties using the selected native + /// mixer map's primary-color strategy. + /// + /// + /// This is opt-in. It affects only generated mixes and does not recolor the base custom + /// product. The resulting color is persisted with the generated product so save reloads + /// and peers render the same appearance. + /// + public ProductMixingProfileBuilder WithPropertyColorMixing() + { + _usePropertyColorMixing = true; + return this; + } + + /// Builds and registers this immutable mixing profile. + /// The registered profile, or the existing equivalent profile. + /// Thrown when no output factory was configured. public ProductMixingProfile Build() { if (_outputFactory == null) @@ -77,7 +100,8 @@ public ProductMixingProfile Build() _mixerMap, _outputFactory, compatibilityIdentity, - _outputFactoryVersion)); + _outputFactoryVersion, + _usePropertyColorMixing)); } } } diff --git a/S1API/Products/ProductPopulator.cs b/S1API/Products/ProductPopulator.cs index aed302b6..a4dd1598 100644 --- a/S1API/Products/ProductPopulator.cs +++ b/S1API/Products/ProductPopulator.cs @@ -19,15 +19,21 @@ namespace S1API.Products { /// - /// Utility methods for populating storage with product instances. + /// Creates product instances and adds them to storage for mod-owned setup flows. /// + /// + /// Discovery-based helpers read products discovered in the active save. ID-based helpers + /// resolve registered definitions through the item registry, while direct-creation helpers + /// use the supplied definition. These APIs do not register products or stock shops. Use + /// explicit custom-product lifecycle APIs before calling them for mod-owned definitions. + /// public static class ProductPopulator { /// - /// Gets a packaging definition by its ID. + /// Resolves a native packaging definition by its item ID. /// /// The ID of the packaging (e.g., "baggie", "jar", "brick"). - /// The packaging definition, or null if not found. + /// The live packaging definition, or when the item is absent or not packaging. public static PackagingDefinition? GetPackaging(string packagingId) { var packaging = ItemManager.GetDefinition(packagingId); @@ -44,9 +50,9 @@ public static class ProductPopulator } /// - /// Gets all available product definitions from the game registry. + /// Gets product definitions discovered in the active save. /// - /// A list of product definitions. + /// A new list of discovered product definitions. It is empty when the save has discovered none. public static List GetAllProductDefinitions() { Debug.Log("[ProductPopulator] Getting all product definitions from ProductManager.DiscoveredProducts"); @@ -67,7 +73,7 @@ public static List GetAllProductDefinitions() } /// - /// Gets weed product definitions from the game registry. + /// Gets discovered marijuana-family product definitions. /// /// A list of weed product definitions. public static List GetWeedDefinitions() @@ -79,7 +85,7 @@ public static List GetWeedDefinitions() } /// - /// Gets meth product definitions from the game registry. + /// Gets discovered methamphetamine-family product definitions. /// /// A list of meth product definitions. public static List GetMethDefinitions() @@ -91,7 +97,7 @@ public static List GetMethDefinitions() } /// - /// Gets cocaine product definitions from the game registry. + /// Gets discovered cocaine-family product definitions. /// /// A list of cocaine product definitions. public static List GetCocaineDefinitions() @@ -103,7 +109,7 @@ public static List GetCocaineDefinitions() } /// - /// Gets shroom product definitions from the game registry. + /// Gets discovered shroom-family product definitions. /// /// A list of shroom product definitions. public static List GetShroomDefinitions() @@ -115,12 +121,12 @@ public static List GetShroomDefinitions() } /// - /// Populates a storage container with packaged products. + /// Fills available storage slots with packaged discovered products. /// /// The storage instance to populate. /// The ID of the packaging to use (e.g., "baggie", "jar", "brick"). /// The quantity of each product item. - /// The number of items successfully added. + /// The number of stacks added before storage fills, a product cannot fit, or setup fails. public static int PopulateWithPackagedProducts(StorageInstance storage, string packagingId, int quantityPerItem = 1) { Debug.Log($"[ProductPopulator] PopulateWithPackagedProducts called with packaging: {packagingId}"); @@ -190,13 +196,13 @@ public static int PopulateWithPackagedProducts(StorageInstance storage, string p } /// - /// Populates a storage container with specific packaged products by ID. + /// Adds one packaged stack for each supplied product ID that resolves and fits. /// /// The storage instance to populate. - /// List of product IDs to add. + /// Product IDs to resolve through the item registry. /// The ID of the packaging to use (e.g., "baggie", "jar", "brick"). /// Quantity of each product to add (default 1). - /// The number of items successfully added. + /// The number of stacks added. Unknown IDs and stacks that do not fit are skipped. public static int PopulateWithSpecificPackagedProducts(StorageInstance storage, List productIds, string packagingId, int quantityPerProduct = 1) { if (storage == null) @@ -245,12 +251,12 @@ public static int PopulateWithSpecificPackagedProducts(StorageInstance storage, } /// - /// Creates a packaged product instance. + /// Creates a standard-quality packaged product instance. /// /// The product definition. /// The packaging definition. /// The quantity of the product. - /// The created product instance, or null if failed. + /// A new product instance, or when native packaging conversion or construction fails. public static ProductInstance? CreatePackagedProduct(ProductDefinition productDef, PackagingDefinition packaging, int quantity) { try @@ -283,11 +289,11 @@ public static int PopulateWithSpecificPackagedProducts(StorageInstance storage, } /// - /// Populates a storage container with non-packaged products. + /// Fills available storage slots with unpackaged discovered products. /// /// The storage instance to populate. /// The quantity of each product item. - /// The number of items successfully added. + /// The number of stacks added before storage fills, a product cannot fit, or setup fails. public static int PopulateWithUnpackagedProducts(StorageInstance storage, int quantityPerItem = 1) { Debug.Log("[ProductPopulator] PopulateWithUnpackagedProducts called"); @@ -350,12 +356,12 @@ public static int PopulateWithUnpackagedProducts(StorageInstance storage, int qu } /// - /// Populates a storage container with specific non-packaged products by ID. + /// Adds one unpackaged stack for each supplied product ID that resolves and fits. /// /// The storage instance to populate. - /// List of product IDs to add. + /// Product IDs to resolve through the item registry. /// Quantity of each product to add (default 1). - /// The number of items successfully added. + /// The number of stacks added. Unknown IDs and stacks that do not fit are skipped. public static int PopulateWithSpecificProducts(StorageInstance storage, List productIds, int quantityPerProduct = 1) { if (storage == null) @@ -397,24 +403,22 @@ public static int PopulateWithSpecificProducts(StorageInstance storage, List - /// Populates a storage container with packaged weed products in jars. - /// Fills all available slots with 20 units (4 jars) of each product. + /// Fills available storage slots with discovered products in native jar packaging. /// /// The storage instance to populate. - /// The number of items successfully added. + /// The number of stacks successfully added. public static int PopulateWithWeedProducts(StorageInstance storage) { return PopulateWithPackagedProducts(storage, "jar", 20); } /// - /// Populates a storage container by finding it from a GameObject. - /// Fills all slots with packaged products in the specified packaging. + /// Finds a storage entity on a game object or one of its children, then fills it with packaged products. /// /// The GameObject with a StorageEntity component. /// The ID of the packaging to use (e.g., "baggie", "jar", "brick"). /// The quantity of each product item. - /// The number of items successfully added, or -1 if storage not found. + /// The number of stacks added, or -1 when no storage entity is found. public static int PopulateFromGameObject(GameObject gameObject, string packagingId, int quantityPerItem = 1) { Debug.Log($"[ProductPopulator] PopulateFromGameObject called for '{gameObject?.name}' with packaging '{packagingId}'"); diff --git a/S1API/Property/PropertyWrapper.cs b/S1API/Property/PropertyWrapper.cs index 41a35a68..e1ee8278 100644 --- a/S1API/Property/PropertyWrapper.cs +++ b/S1API/Property/PropertyWrapper.cs @@ -1,6 +1,8 @@ using System.Collections; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Reflection; +using S1API.Deliveries; using S1API.Logging; using UnityEngine; using S1API.Internal.Utils; @@ -20,6 +22,8 @@ namespace S1API.Property public class PropertyWrapper : BaseProperty { private static readonly Log Logger = new Log("PropertyWrapper"); + private static readonly IReadOnlyList EmptyLoadingDocks = + new ReadOnlyCollection(System.Array.Empty()); /// /// A readonly backing field encapsulating the core property instance @@ -140,6 +144,28 @@ public override bool IsPointInside(Vector3 point) public int LoadingDockCount => InnerProperty.LoadingDockCount; + /// + /// Gets an immutable snapshot of the loading docks assigned to this property. + /// + public IReadOnlyList LoadingDocks + { + get + { + var nativeDocks = InnerProperty.LoadingDocks; + if (nativeDocks == null || nativeDocks.Length == 0) + return EmptyLoadingDocks; + + var docks = new List(nativeDocks.Length); + for (int i = 0; i < nativeDocks.Length; i++) + { + if (nativeDocks[i] != null) + docks.Add(LoadingDock.Wrap(nativeDocks[i])); + } + + return new ReadOnlyCollection(docks); + } + } + /// /// Gets the default rotation value for the property. /// diff --git a/S1API/Rendering/IconFactory.cs b/S1API/Rendering/IconFactory.cs index f1c2714a..7546893c 100644 --- a/S1API/Rendering/IconFactory.cs +++ b/S1API/Rendering/IconFactory.cs @@ -1,10 +1,12 @@ #if (IL2CPPMELON) using S1DevUtils = Il2CppScheduleOne.DevUtilities; using S1AvatarFramework = Il2CppScheduleOne.AvatarFramework; +using S1AvatarTools = Il2CppScheduleOne.Avatar.Tools; using Il2CppScheduleOne.AvatarFramework.Customization; #elif MONOMELON using S1DevUtils = ScheduleOne.DevUtilities; using S1AvatarFramework = ScheduleOne.AvatarFramework; +using S1AvatarTools = ScheduleOne.Avatar.Tools; using ScheduleOne.AvatarFramework.Customization; #endif @@ -66,8 +68,8 @@ internal static bool IsItemIconGeneratorReady /// /// INTERNAL: Reference to the game's MugshotGenerator instance. /// - internal static S1AvatarFramework.MugshotGenerator S1MugshotGenerator => - S1AvatarFramework.MugshotGenerator.Instance; + internal static S1AvatarTools.MugshotGenerator? S1MugshotGenerator => + UnityEngine.Object.FindObjectOfType(); /// /// Generates a preview texture for the specified model. @@ -150,7 +152,6 @@ internal static bool IsItemIconGeneratorReady Quaternion originalRot = model.localRotation; Vector3 originalScale = model.localScale; bool wasActive = model.gameObject.activeSelf; - int originalSize = generator.IconSize; bool originalModifyLighting = generator.ModifyLighting; List? bakedRenderers = null; Texture2D? texture = null; @@ -201,7 +202,6 @@ internal static bool IsItemIconGeneratorReady } // Temporarily override IconGenerator state - generator.IconSize = size; generator.ModifyLighting = true; texture = generator.GetTexture(model); @@ -226,7 +226,6 @@ internal static bool IsItemIconGeneratorReady } finally { - generator.IconSize = originalSize; generator.ModifyLighting = originalModifyLighting; if (bakedRenderers != null) @@ -683,6 +682,7 @@ private class AccessoryIconRequest /// private static IEnumerator ProcessAccessoryIconQueue() { +#if false while (true) { AccessoryIconRequest? next = null; @@ -794,6 +794,27 @@ private static IEnumerator ProcessAccessoryIconQueue() // Small delay between jobs to let the mugshot rig fully reset yield return new WaitForSeconds(0.05f); } +#else + while (true) + { + AccessoryIconRequest? next; + lock (_accessoryIconQueueLock) + { + if (_accessoryIconQueue.Count == 0) + { + _isProcessingAccessoryIcons = false; + yield break; + } + + next = _accessoryIconQueue.Dequeue(); + } + + Logger.Warning( + "Accessory icon generation is unavailable with the Schedule I 0.4.7 avatar pipeline."); + next.Callback?.Invoke(null); + yield return null; + } +#endif } /// diff --git a/S1API/S1API.cs b/S1API/S1API.cs index 5bba8a92..9190e353 100644 --- a/S1API/S1API.cs +++ b/S1API/S1API.cs @@ -8,10 +8,11 @@ using S1API.Internal.NPCWorkbench; using S1API.Internal.Products; using S1API.Internal.Rendering; +using S1API.Internal.Weather; using S1API.Lifecycle; using S1API.Map; -[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.1.0-beta.9", "KaBooMa")] +[assembly: MelonInfo(typeof(S1API.S1API), "S1API (Forked by Bars)", "3.2.1-beta.1", "KaBooMa")] [assembly: MelonPriority(Int32.MinValue)] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace S1API @@ -36,6 +37,7 @@ public override void OnInitializeMelon() public override void OnDeinitializeMelon() { + WeatherRuntime.ResetBindings(); NPCWorkbenchRuntime.Close(); PresentationWorkbenchRuntime.Close(); ProductPackagingContentRuntime.ResetForSceneChange(); @@ -46,6 +48,7 @@ public override void OnDeinitializeMelon() public override void OnUpdate() { + WeatherRuntime.Tick(); PresentationWorkbenchRuntime.Tick(); CutsceneManager.Tick(UnityEngine.Time.unscaledDeltaTime); NPCWorkbenchRuntime.Tick(); diff --git a/S1API/S1API.csproj b/S1API/S1API.csproj index 058682c3..ad528183 100644 --- a/S1API/S1API.csproj +++ b/S1API/S1API.csproj @@ -23,7 +23,7 @@ $(NoWarn);1591 true latest - 3.1.0-beta.9 + 3.2.1-beta.1 @@ -49,6 +49,7 @@ + @@ -80,6 +81,9 @@ $(MonoAssembliesPath)/UnityEngine.CoreModule.dll + + $(MonoAssembliesPath)/UnityEngine.AnimationModule.dll + $(MonoAssembliesPath)/FishNet.Runtime.dll diff --git a/S1API/Temperature/TemperatureAlgorithm.cs b/S1API/Temperature/TemperatureAlgorithm.cs new file mode 100644 index 00000000..43548e9a --- /dev/null +++ b/S1API/Temperature/TemperatureAlgorithm.cs @@ -0,0 +1,77 @@ +#if IL2CPPMELON +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using S1Temperature = Il2CppScheduleOne.Temperature; +#elif MONOMELON +using S1Temperature = ScheduleOne.Temperature; +#endif + +using System; +using S1API.Internal.Temperature; +using UnityEngine; + +namespace S1API.Temperature +{ + /// + /// Queries temperatures with the game's native temperature algorithm. + /// + public static class TemperatureAlgorithm + { + /// + /// Calculates the temperature at a world position from an ambient temperature and emitter snapshots. + /// + /// The ambient temperature in degrees Celsius. + /// + /// The world origin forwarded to the native API for signature compatibility. The current native implementation + /// evaluates world-space emitter and query positions directly and does not otherwise use this value. + /// + /// The world position to query. + /// The emitter snapshots to include in the calculation. + /// The temperature at in degrees Celsius. + /// is null. + /// A scalar or vector input is not finite. + /// + /// This method does not discover scene emitters or register the supplied snapshots with a grid. + /// + public static float GetTemperatureAtPoint( + float ambientTemperature, + Vector3 originPoint, + Vector3 point, + TemperatureEmitterInfo[] emitters) + { + if (emitters == null) + throw new ArgumentNullException(nameof(emitters)); + + TemperatureValidation.EnsureFinite(ambientTemperature, nameof(ambientTemperature)); + TemperatureValidation.EnsureFinite(originPoint, nameof(originPoint)); + TemperatureValidation.EnsureFinite(point, nameof(point)); +#if IL2CPPMELON + var nativeEmitters = new Il2CppStructArray(emitters.Length); +#else + var nativeEmitters = new S1Temperature.TemperatureEmitterInfo[emitters.Length]; +#endif + for (int i = 0; i < emitters.Length; i++) + { + TemperatureEmitterInfo emitter = emitters[i]; + float temperature = TemperatureValidation.ClampTemperature( + emitter.Temperature, + $"{nameof(emitters)}[{i}].{nameof(TemperatureEmitterInfo.Temperature)}"); + float range = TemperatureValidation.ClampRange( + emitter.Range, + $"{nameof(emitters)}[{i}].{nameof(TemperatureEmitterInfo.Range)}"); + TemperatureValidation.EnsureFinite( + emitter.Position, + $"{nameof(emitters)}[{i}].{nameof(TemperatureEmitterInfo.Position)}"); + nativeEmitters[i] = new S1Temperature.TemperatureEmitterInfo( + temperature, + range * range, + emitter.Position); + } + + return S1Temperature.TemperatureAlgorithm.GetTemperatureAtPoint( + ambientTemperature, + originPoint, + point, + nativeEmitters); + } + } +} diff --git a/S1API/Temperature/TemperatureEmitter.cs b/S1API/Temperature/TemperatureEmitter.cs new file mode 100644 index 00000000..b0cf4fe9 --- /dev/null +++ b/S1API/Temperature/TemperatureEmitter.cs @@ -0,0 +1,314 @@ +#if IL2CPPMELON +using Il2CppInterop.Runtime; +using NativeEmitterChangedAction = Il2CppSystem.Action; +using S1Temperature = Il2CppScheduleOne.Temperature; +#elif MONOMELON +using NativeEmitterChangedAction = System.Action; +using S1Temperature = ScheduleOne.Temperature; +#endif + +using System; +using System.Collections.Generic; +using S1API.Internal.Temperature; +using S1API.Internal.Utils; +using UnityEngine; + +namespace S1API.Temperature +{ + /// + /// Wraps a native temperature emitter attached to a mod-owned game object. + /// + /// + /// Adding this component does not register it with a native grid, persist its configuration, or synchronize it over the network. + /// + public sealed class TemperatureEmitter + { + /// + /// INTERNAL: The native temperature emitter component. + /// + internal readonly S1Temperature.TemperatureEmitter S1TemperatureEmitter; + + /// + /// INTERNAL: Creates a wrapper around a native temperature emitter component. + /// + /// The native temperature emitter component. + internal TemperatureEmitter(S1Temperature.TemperatureEmitter temperatureEmitter) + { + S1TemperatureEmitter = temperatureEmitter; + } + + /// + /// Gets the default ambient temperature, in degrees Celsius. + /// + public const float DefaultAmbientTemperature = TemperatureValidation.DefaultAmbientTemperature; + + /// + /// Gets the minimum emitter temperature, in degrees Celsius. + /// + public const float MinTemperature = TemperatureValidation.MinTemperature; + + /// + /// Gets the maximum emitter temperature, in degrees Celsius. + /// + public const float MaxTemperature = TemperatureValidation.MaxTemperature; + + /// + /// Gets the default emitter range, in world units. + /// + public const float DefaultRange = TemperatureValidation.DefaultRange; + + /// + /// Gets the minimum emitter range, in world units. + /// + public const float MinRange = TemperatureValidation.MinRange; + + /// + /// Gets the maximum emitter range, in world units. + /// + public const float MaxRange = TemperatureValidation.MaxRange; + + /// + /// Gets the native temperature emitter attached to a game object. + /// + /// The game object to inspect. + /// A temperature-emitter wrapper, or null when the game object has no emitter component. + /// is null or destroyed. + public static TemperatureEmitter? FromGameObject(GameObject gameObject) + { + if (gameObject == null) + throw new ArgumentNullException(nameof(gameObject)); + + PruneDestroyedRegistrationStates(); + S1Temperature.TemperatureEmitter? emitter = + gameObject.GetComponent(); + return emitter == null ? null : new TemperatureEmitter(emitter); + } + + /// + /// Gets the first native temperature emitter on a game object, or adds one when none exists. + /// + /// The game object that owns the emitter component. + /// A wrapper around the existing or newly added emitter. + /// is null or destroyed. + /// + /// When a game object has multiple native emitter components, this method returns the first component selected by Unity. + /// + public static TemperatureEmitter GetOrAddComponent(GameObject gameObject) + { + if (gameObject == null) + throw new ArgumentNullException(nameof(gameObject)); + + PruneDestroyedRegistrationStates(); + S1Temperature.TemperatureEmitter? emitter = + gameObject.GetComponent(); + return new TemperatureEmitter( + emitter ?? gameObject.AddComponent()); + } + + /// + /// Gets the emitter temperature in degrees Celsius. + /// + public float Temperature => + S1TemperatureEmitter.Temperature; + + /// + /// Gets the emitter range in world units. + /// + public float Range => + S1TemperatureEmitter.Range; + + /// + /// Gets the emitter position in world space. + /// + public Vector3 EmissionPoint => + S1TemperatureEmitter.EmissionPoint; + + /// + /// Creates an immutable managed snapshot of the emitter's current values. + /// + /// A snapshot suitable for . + public TemperatureEmitterInfo ToInfo() => + new TemperatureEmitterInfo(Temperature, Range, EmissionPoint); + + /// + /// Occurs when the native emitter reports a change. + /// + public event Action OnChanged + { + add + { + if (value == null) + return; + + NativeEmitterChangedAction nativeHandler = CreateNativeChangedHandler(value); + Subscribe(nativeHandler); + GetChangedRegistrationState().Registrations.Add(value, nativeHandler); + } + remove + { + if (value == null || !TryTakeChangedRegistration(value, out ChangedRegistrationState state, + out NativeEmitterChangedAction nativeHandler)) + return; + + try + { + Unsubscribe(nativeHandler); + } + catch + { + state.Registrations.Add(value, nativeHandler); + throw; + } + + if (state.Registrations.IsEmpty) + ChangedRegistrations.Remove(S1TemperatureEmitter.GetInstanceID()); + } + } + + /// + /// Updates the emitter position in world space. + /// + /// The new emitter position in world space. Every component must be finite. + /// A position component is not finite. + public void SetPosition(Vector3 position) + { + TemperatureValidation.EnsureFinite(position, nameof(position)); + S1TemperatureEmitter.SetPosition(position); + } + + /// + /// Updates the emitter temperature. + /// + /// + /// The new temperature in degrees Celsius. Finite values are clamped to + /// through . + /// + /// is not finite. + public void SetTemperature(float temperature) + { + float clampedTemperature = TemperatureValidation.ClampTemperature(temperature, nameof(temperature)); + S1TemperatureEmitter.SetTemperature(clampedTemperature); + } + + /// + /// Updates the emitter range in world units. + /// + /// + /// The new range in world units. Finite values are clamped to through + /// . + /// + /// is not finite. + public void SetRange(float range) + { + float clampedRange = TemperatureValidation.ClampRange(range, nameof(range)); + S1TemperatureEmitter.SetRange(clampedRange); + } + + /// + /// Informs native listeners that the emitter changed. + /// + public void NotifyChanged() => + S1TemperatureEmitter.NotifyChanged(); + + private static readonly Dictionary ChangedRegistrations = + new Dictionary(); + + private static NativeEmitterChangedAction CreateNativeChangedHandler(Action handler) + { +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(handler) + ?? throw new InvalidOperationException("Could not create the native temperature-emitter delegate."); +#else + return handler; +#endif + } + + private ChangedRegistrationState GetChangedRegistrationState() + { + PruneDestroyedRegistrationStates(); + int instanceId = S1TemperatureEmitter.GetInstanceID(); + if (ChangedRegistrations.TryGetValue(instanceId, out ChangedRegistrationState? state)) + return state; + + state = new ChangedRegistrationState(S1TemperatureEmitter); + ChangedRegistrations.Add(instanceId, state); + return state; + } + + private bool TryTakeChangedRegistration( + Action managedHandler, + out ChangedRegistrationState state, + out NativeEmitterChangedAction nativeHandler) + { + PruneDestroyedRegistrationStates(); + if (ChangedRegistrations.TryGetValue( + S1TemperatureEmitter.GetInstanceID(), + out ChangedRegistrationState? registrationState) + && registrationState.Registrations.TryTakeLast(managedHandler, out nativeHandler)) + { + state = registrationState; + return true; + } + + state = null!; + nativeHandler = default!; + return false; + } + + private static void PruneDestroyedRegistrationStates() + { + List? destroyedIds = null; + foreach (KeyValuePair registration in ChangedRegistrations) + { + if (registration.Value.S1TemperatureEmitter != null) + continue; + + destroyedIds ??= new List(); + destroyedIds.Add(registration.Key); + } + + if (destroyedIds == null) + return; + + foreach (int destroyedId in destroyedIds) + ChangedRegistrations.Remove(destroyedId); + } + + private void Subscribe(NativeEmitterChangedAction handler) + { +#if IL2CPPMELON + S1TemperatureEmitter.OnEmitterChanged = S1TemperatureEmitter.OnEmitterChanged == null + ? handler + : Il2CppSystem.Delegate.Combine(S1TemperatureEmitter.OnEmitterChanged, handler) + .Cast(); +#else + S1TemperatureEmitter.OnEmitterChanged += handler; +#endif + } + + private void Unsubscribe(NativeEmitterChangedAction handler) + { +#if IL2CPPMELON + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + S1TemperatureEmitter.OnEmitterChanged, + handler); + S1TemperatureEmitter.OnEmitterChanged = remaining?.Cast(); +#else + S1TemperatureEmitter.OnEmitterChanged -= handler; +#endif + } + + private sealed class ChangedRegistrationState + { + internal S1Temperature.TemperatureEmitter S1TemperatureEmitter { get; } + + internal ManagedEventRegistrationTracker Registrations { get; } = + new ManagedEventRegistrationTracker(); + + internal ChangedRegistrationState(S1Temperature.TemperatureEmitter temperatureEmitter) + { + S1TemperatureEmitter = temperatureEmitter; + } + } + } +} diff --git a/S1API/Temperature/TemperatureEmitterInfo.cs b/S1API/Temperature/TemperatureEmitterInfo.cs new file mode 100644 index 00000000..b81f141a --- /dev/null +++ b/S1API/Temperature/TemperatureEmitterInfo.cs @@ -0,0 +1,44 @@ +using System; +using S1API.Internal.Temperature; +using UnityEngine; + +namespace S1API.Temperature +{ + /// + /// Describes one temperature emitter for a point-temperature query. + /// + public readonly struct TemperatureEmitterInfo + { + /// + /// Creates a temperature-emitter snapshot. + /// + /// + /// The emitter temperature in degrees Celsius. Finite values are clamped to the game's supported range. + /// + /// The emitter range in world units. Finite values are clamped to the game's supported range. + /// The emitter position in world space. Every component must be finite. + /// An argument is not finite. + public TemperatureEmitterInfo(float temperature, float range, Vector3 position) + { + Temperature = TemperatureValidation.ClampTemperature(temperature, nameof(temperature)); + Range = TemperatureValidation.ClampRange(range, nameof(range)); + TemperatureValidation.EnsureFinite(position, nameof(position)); + Position = position; + } + + /// + /// Gets the emitter temperature in degrees Celsius. + /// + public float Temperature { get; } + + /// + /// Gets the emitter range in world units. + /// + public float Range { get; } + + /// + /// Gets the emitter position in world space. + /// + public Vector3 Position { get; } + } +} diff --git a/S1API/Temperature/TemperatureUtility.cs b/S1API/Temperature/TemperatureUtility.cs new file mode 100644 index 00000000..f40ba45d --- /dev/null +++ b/S1API/Temperature/TemperatureUtility.cs @@ -0,0 +1,65 @@ +#if IL2CPPMELON +using S1Temperature = Il2CppScheduleOne.Temperature; +#elif MONOMELON +using S1Temperature = ScheduleOne.Temperature; +#endif + +namespace S1API.Temperature +{ + /// + /// Formats and converts temperatures with the game's native temperature helpers. + /// + public static class TemperatureUtility + { + /// + /// Gets whether the game's temperature system is currently enabled. + /// + public static bool TemperatureSystemEnabled => + S1Temperature.TemperatureUtility.TemperatureSystemEnabled; + + /// + /// Converts a Celsius temperature to Fahrenheit. + /// + /// The temperature in degrees Celsius. + /// The temperature in degrees Fahrenheit. + public static float ToFahrenheit(float celsius) => + S1Temperature.TemperatureUtility.ToFahrenheit(celsius); + + /// + /// Formats a Celsius temperature with the game's Celsius unit display. + /// + /// The temperature in degrees Celsius. + /// The number of decimal places to display. + /// The formatted Celsius temperature. + public static string FormatCelsiusTemperature(float celsius, int decimalPoints) => + S1Temperature.TemperatureUtility.FormatCelsiusTemperature(celsius, decimalPoints); + + /// + /// Formats a Fahrenheit temperature with the game's Fahrenheit unit display. + /// + /// The temperature in degrees Fahrenheit. + /// The number of decimal places to display. + /// The formatted Fahrenheit temperature. + public static string FormatFahrenheitTemperature(float fahrenheit, int decimalPoints) => + S1Temperature.TemperatureUtility.FormatFahrenheitTemperature(fahrenheit, decimalPoints); + + /// + /// Formats a Celsius temperature with the unit selected by the game. + /// + /// The temperature in degrees Celsius. + /// The number of decimal places to display. + /// The formatted temperature. + public static string FormatTemperatureWithAppropriateUnit( + float celsius, + int decimalPoints = 1) => + S1Temperature.TemperatureUtility.FormatTemperatureWithAppropriateUnit(celsius, decimalPoints); + + /// + /// Normalizes a Celsius temperature with the game's native temperature range. + /// + /// The temperature in degrees Celsius. + /// The normalized temperature. + public static float NormalizeTemperature(float celsius) => + S1Temperature.TemperatureUtility.NormalizeTemperature(celsius); + } +} diff --git a/S1API/Trash/TrashContainer.cs b/S1API/Trash/TrashContainer.cs new file mode 100644 index 00000000..7cb4fbbb --- /dev/null +++ b/S1API/Trash/TrashContainer.cs @@ -0,0 +1,425 @@ +#if IL2CPPMELON +using Il2CppInterop.Runtime; +using NativeTrashAddedAction = UnityEngine.Events.UnityAction; +using NativeTrashLevelChangedAction = UnityEngine.Events.UnityAction; +using S1InstanceFinder = Il2CppFishNet.InstanceFinder; +using S1Trash = Il2CppScheduleOne.Trash; +#elif MONOMELON +using NativeTrashAddedAction = System.Action; +using NativeTrashLevelChangedAction = System.Action; +using S1InstanceFinder = FishNet.InstanceFinder; +using S1Trash = ScheduleOne.Trash; +#endif + +using System; +using System.Collections.Generic; +using S1API.Internal.Utils; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace S1API.Trash +{ + /// + /// Provides managed access to an existing native trash container. + /// + /// + /// This wrapper does not add persistence or replacement replication for custom containers. + /// + public sealed class TrashContainer + { + private static readonly Dictionary TrashAddedRegistrations = + new Dictionary(); + + private static readonly Dictionary TrashLevelChangedRegistrations = + new Dictionary(); + + /// + /// INTERNAL: The native trash container. + /// + internal readonly S1Trash.TrashContainer S1TrashContainer; + + /// + /// INTERNAL: Creates a wrapper around a native trash container. + /// + /// The native trash container. + internal TrashContainer(S1Trash.TrashContainer trashContainer) + { + S1TrashContainer = trashContainer; + } + + /// + /// Gets a trash container attached directly to a game object. + /// + /// The game object to inspect. + /// A trash-container wrapper, or null when the game object has no container component. + /// is null or destroyed. + public static TrashContainer? FromGameObject(GameObject gameObject) + { + if (gameObject == null) + throw new ArgumentNullException(nameof(gameObject)); + + PruneDestroyedRegistrationStates(); + S1Trash.TrashContainer? trashContainer = + gameObject.GetComponent(); + return trashContainer == null ? null : new TrashContainer(trashContainer); + } + + /// + /// Finds native trash containers in the loaded scene. + /// + /// Whether to include containers on inactive game objects. + /// A snapshot of the trash containers found in the scene. + public static TrashContainer[] FindInScene(bool includeInactive = false) + { + PruneDestroyedRegistrationStates(); + var nativeContainers = + Object.FindObjectsOfType(includeInactive); + if (nativeContainers == null || nativeContainers.Length == 0) + return Array.Empty(); + + var containers = new List(nativeContainers.Length); + for (int index = 0; index < nativeContainers.Length; index++) + { + S1Trash.TrashContainer nativeContainer = nativeContainers[index]; + if (nativeContainer != null) + containers.Add(new TrashContainer(nativeContainer)); + } + + return containers.ToArray(); + } + + /// + /// Gets the game object that owns the native trash container. + /// + public GameObject? GameObject => + S1TrashContainer?.gameObject; + + /// + /// Gets the maximum number of capacity units the container can hold. + /// + public int Capacity => + S1TrashContainer.TrashCapacity; + + /// + /// Gets the current number of capacity units used by the container contents. + /// + public int Level => + S1TrashContainer.TrashLevel; + + /// + /// Gets the current level divided by the container capacity. + /// + public float NormalizedLevel => + S1TrashContainer.NormalizedTrashLevel; + + /// + /// Gets an immutable managed snapshot of the current content entries. + /// + public IReadOnlyList Contents + { + get + { + var entries = S1TrashContainer.Content?.Entries; + if (entries == null || entries.Count == 0) + return Array.Empty(); + + var snapshot = new List(entries.Count); + for (int index = 0; index < entries.Count; index++) + { + S1Trash.TrashContent.Entry entry = entries[index]; + if (entry == null) + continue; + + snapshot.Add(new TrashContentEntry( + entry.TrashID, + entry.Quantity, + entry.UnitSize, + entry.UnitValue)); + } + + return snapshot.AsReadOnly(); + } + } + + /// + /// Gets whether the native container currently has enough contents to create a trash bag. + /// + public bool CanBeBagged => + S1TrashContainer.CanBeBagged(); + + /// + /// Occurs after the native container adds trash. + /// + /// + /// A handler may be removed through any wrapper for the same native container. + /// Duplicate subscriptions are removed one at a time. + /// + public event Action OnTrashAdded + { + add + { + if (value == null) + return; + + NativeTrashAddedAction nativeHandler = CreateNativeTrashAddedHandler(value); + SubscribeTrashAdded(nativeHandler); + GetTrashAddedRegistrationState().Registrations.Add(value, nativeHandler); + } + remove + { + if (value == null || !TryTakeTrashAddedRegistration( + value, + out TrashAddedRegistrationState state, + out NativeTrashAddedAction nativeHandler)) + return; + + try + { + UnsubscribeTrashAdded(nativeHandler); + } + catch + { + state.Registrations.Add(value, nativeHandler); + throw; + } + + if (state.Registrations.IsEmpty) + TrashAddedRegistrations.Remove(S1TrashContainer.GetInstanceID()); + } + } + + /// + /// Occurs after the native container level changes. + /// + /// + /// A handler may be removed through any wrapper for the same native container. + /// Duplicate subscriptions are removed one at a time. + /// + public event Action OnTrashLevelChanged + { + add + { + if (value == null) + return; + + NativeTrashLevelChangedAction nativeHandler = CreateNativeTrashLevelChangedHandler(value); + SubscribeTrashLevelChanged(nativeHandler); + GetTrashLevelChangedRegistrationState().Registrations.Add(value, nativeHandler); + } + remove + { + if (value == null || !TryTakeTrashLevelChangedRegistration( + value, + out TrashLevelChangedRegistrationState state, + out NativeTrashLevelChangedAction nativeHandler)) + return; + + try + { + UnsubscribeTrashLevelChanged(nativeHandler); + } + catch + { + state.Registrations.Add(value, nativeHandler); + throw; + } + + if (state.Registrations.IsEmpty) + TrashLevelChangedRegistrations.Remove(S1TrashContainer.GetInstanceID()); + } + } + + /// + /// Bags the current contents through the native server-authoritative path when eligible. + /// + /// true when native bagging was invoked; otherwise, false. + public bool TryBagTrash() + { + if (!S1InstanceFinder.IsServer || !S1TrashContainer.CanBeBagged()) + return false; + + S1TrashContainer.BagTrash(); + return true; + } + + private static NativeTrashAddedAction CreateNativeTrashAddedHandler(Action handler) + { +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(handler) + ?? throw new InvalidOperationException("Could not create the native trash-added delegate."); +#else + return trashId => handler(trashId); +#endif + } + + private static NativeTrashLevelChangedAction CreateNativeTrashLevelChangedHandler(Action handler) + { +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(handler) + ?? throw new InvalidOperationException("Could not create the native trash-level delegate."); +#else + return () => handler(); +#endif + } + + private void SubscribeTrashAdded(NativeTrashAddedAction handler) + { +#if IL2CPPMELON + S1TrashContainer.onTrashAdded.AddListener(handler); +#else + global::S1API.Utils.EventHelper.AddListener(handler, S1TrashContainer.onTrashAdded); +#endif + } + + private void UnsubscribeTrashAdded(NativeTrashAddedAction handler) + { +#if IL2CPPMELON + S1TrashContainer.onTrashAdded.RemoveListener(handler); +#else + global::S1API.Utils.EventHelper.RemoveListener(handler, S1TrashContainer.onTrashAdded); +#endif + } + + private void SubscribeTrashLevelChanged(NativeTrashLevelChangedAction handler) + { +#if IL2CPPMELON + S1TrashContainer.onTrashLevelChanged.AddListener(handler); +#else + global::S1API.Utils.EventHelper.AddListener(handler, S1TrashContainer.onTrashLevelChanged); +#endif + } + + private void UnsubscribeTrashLevelChanged(NativeTrashLevelChangedAction handler) + { +#if IL2CPPMELON + S1TrashContainer.onTrashLevelChanged.RemoveListener(handler); +#else + global::S1API.Utils.EventHelper.RemoveListener(handler, S1TrashContainer.onTrashLevelChanged); +#endif + } + + private TrashAddedRegistrationState GetTrashAddedRegistrationState() + { + PruneDestroyedRegistrationStates(); + int instanceId = S1TrashContainer.GetInstanceID(); + if (TrashAddedRegistrations.TryGetValue(instanceId, out TrashAddedRegistrationState? state)) + return state; + + state = new TrashAddedRegistrationState(S1TrashContainer); + TrashAddedRegistrations.Add(instanceId, state); + return state; + } + + private bool TryTakeTrashAddedRegistration( + Action managedHandler, + out TrashAddedRegistrationState state, + out NativeTrashAddedAction nativeHandler) + { + PruneDestroyedRegistrationStates(); + if (TrashAddedRegistrations.TryGetValue( + S1TrashContainer.GetInstanceID(), + out TrashAddedRegistrationState? registrationState) + && registrationState.Registrations.TryTakeLast(managedHandler, out nativeHandler)) + { + state = registrationState; + return true; + } + + state = null!; + nativeHandler = null!; + return false; + } + + private TrashLevelChangedRegistrationState GetTrashLevelChangedRegistrationState() + { + PruneDestroyedRegistrationStates(); + int instanceId = S1TrashContainer.GetInstanceID(); + if (TrashLevelChangedRegistrations.TryGetValue( + instanceId, + out TrashLevelChangedRegistrationState? state)) + return state; + + state = new TrashLevelChangedRegistrationState(S1TrashContainer); + TrashLevelChangedRegistrations.Add(instanceId, state); + return state; + } + + private bool TryTakeTrashLevelChangedRegistration( + Action managedHandler, + out TrashLevelChangedRegistrationState state, + out NativeTrashLevelChangedAction nativeHandler) + { + PruneDestroyedRegistrationStates(); + if (TrashLevelChangedRegistrations.TryGetValue( + S1TrashContainer.GetInstanceID(), + out TrashLevelChangedRegistrationState? registrationState) + && registrationState.Registrations.TryTakeLast(managedHandler, out nativeHandler)) + { + state = registrationState; + return true; + } + + state = null!; + nativeHandler = null!; + return false; + } + + private static void PruneDestroyedRegistrationStates() + { + PruneDestroyedRegistrationStates(TrashAddedRegistrations); + PruneDestroyedRegistrationStates(TrashLevelChangedRegistrations); + } + + private static void PruneDestroyedRegistrationStates(Dictionary registrations) + where TState : TrashContainerRegistrationState + { + List? destroyedIds = null; + foreach (KeyValuePair registration in registrations) + { + if (registration.Value.S1TrashContainer != null) + continue; + + destroyedIds ??= new List(); + destroyedIds.Add(registration.Key); + } + + if (destroyedIds == null) + return; + + foreach (int destroyedId in destroyedIds) + registrations.Remove(destroyedId); + } + + private abstract class TrashContainerRegistrationState + { + internal S1Trash.TrashContainer S1TrashContainer { get; } + + protected TrashContainerRegistrationState(S1Trash.TrashContainer trashContainer) + { + S1TrashContainer = trashContainer; + } + } + + private sealed class TrashAddedRegistrationState : TrashContainerRegistrationState + { + internal ManagedEventRegistrationTracker Registrations { get; } = + new ManagedEventRegistrationTracker(); + + internal TrashAddedRegistrationState(S1Trash.TrashContainer trashContainer) + : base(trashContainer) + { + } + } + + private sealed class TrashLevelChangedRegistrationState : TrashContainerRegistrationState + { + internal ManagedEventRegistrationTracker Registrations { get; } = + new ManagedEventRegistrationTracker(); + + internal TrashLevelChangedRegistrationState(S1Trash.TrashContainer trashContainer) + : base(trashContainer) + { + } + } + } +} diff --git a/S1API/Trash/TrashContentEntry.cs b/S1API/Trash/TrashContentEntry.cs new file mode 100644 index 00000000..2c45e828 --- /dev/null +++ b/S1API/Trash/TrashContentEntry.cs @@ -0,0 +1,36 @@ +namespace S1API.Trash +{ + /// + /// Describes one immutable entry in a trash-container content snapshot. + /// + public readonly struct TrashContentEntry + { + internal TrashContentEntry(string? trashId, int quantity, int unitSize, int unitValue) + { + TrashId = trashId; + Quantity = quantity; + UnitSize = unitSize; + UnitValue = unitValue; + } + + /// + /// Gets the native trash-prefab identifier. + /// + public string? TrashId { get; } + + /// + /// Gets the number of trash items represented by this entry. + /// + public int Quantity { get; } + + /// + /// Gets the container-capacity units consumed by each item. + /// + public int UnitSize { get; } + + /// + /// Gets the sell value of each item. + /// + public int UnitValue { get; } + } +} diff --git a/S1API/Trash/TrashManager.cs b/S1API/Trash/TrashManager.cs index ab808c2c..221a1a50 100644 --- a/S1API/Trash/TrashManager.cs +++ b/S1API/Trash/TrashManager.cs @@ -1,7 +1,13 @@ using System; +using System.Collections.Generic; +using S1API.Lifecycle; +using S1API.Logging; +using S1API.Internal.Utils; using UnityEngine; +using Object = UnityEngine.Object; #if (IL2CPPMELON) +using Il2CppInterop.Runtime.InteropTypes.Arrays; using S1Trash = Il2CppScheduleOne.Trash; #elif MONOMELON using S1Trash = ScheduleOne.Trash; @@ -14,6 +20,14 @@ namespace S1API.Trash /// public static class TrashManager { + private static readonly Log Logger = new Log("TrashManager"); + private static readonly object RegistrationGate = new object(); + private static readonly Dictionary + RegisteredPrefabs = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private static GameObject? _prefabRoot; + private static bool _lifecycleSubscribed; + /// /// Maximum number of trash items allowed in the world (2000). /// @@ -48,8 +62,83 @@ public static class TrashManager /// The trash prefab GameObject, or null if not found. public static GameObject? GetTrashPrefab(string id) { - var prefab = S1Trash.TrashManager.Instance.GetTrashPrefab(id); - return prefab?.gameObject; + var manager = S1Trash.TrashManager.Instance; + var nativePrefab = manager != null + ? manager.GetTrashPrefab(id) + : null; + if (nativePrefab != null) + return nativePrefab.gameObject; + + lock (RegistrationGate) + { + return RegisteredPrefabs.TryGetValue(id, out var registered) && + registered != null + ? registered.gameObject + : null; + } + } + + /// + /// Registers a stable trash prefab that can be spawned by ID and referenced by station items. + /// + /// + /// The supplied prefab is cloned under an inactive, persistent cache root. Registrations are + /// reapplied after game loads so network trash spawning can resolve the same ID on every client. + /// All clients must register the same ID and prefab behavior for multiplayer consistency. + /// + /// Stable trash ID used for spawning and persistence. + /// Prefab containing a native TrashItem component. + /// Whether an existing registration with the same ID may be replaced. + /// The cached registered prefab. + public static GameObject RegisterTrashPrefab( + string id, + GameObject trashPrefab, + bool replaceExisting = false) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Trash ID is required.", nameof(id)); + if (trashPrefab == null) + throw new ArgumentNullException(nameof(trashPrefab)); + + var source = trashPrefab.GetComponent(); + if (source == null) + { + throw new ArgumentException( + "Trash prefab must have a TrashItem component.", + nameof(trashPrefab)); + } + + S1Trash.TrashItem cached; + lock (RegistrationGate) + { + if (RegisteredPrefabs.TryGetValue(id, out var existing) && + existing != null) + { + if (!replaceExisting) + { + Logger.Warning( + $"Trash ID '{id}' is already registered. " + + $"Keeping '{existing.gameObject.name}' and ignoring " + + $"'{trashPrefab.name}'. Set replaceExisting to true " + + "to replace the existing prefab."); + return existing.gameObject; + } + + Object.Destroy(existing.gameObject); + } + + var root = GetOrCreatePrefabRoot(); + cached = Object.Instantiate(source, root.transform, false); + cached.name = $"S1API_Trash_{id}"; + ReflectionUtils.TrySetFieldOrProperty(cached, "Id", id); + cached.gameObject.hideFlags = HideFlags.HideAndDontSave; + cached.gameObject.SetActive(true); + RegisteredPrefabs[id] = cached; + EnsureLifecycleSubscription(); + } + + ApplyRegistration(id, cached, replaceExisting: true); + return cached.gameObject; } /// @@ -61,5 +150,107 @@ public static class TrashManager var prefab = S1Trash.TrashManager.Instance.GetRandomGeneratableTrashPrefab(); return prefab?.gameObject; } + + private static void EnsureLifecycleSubscription() + { + if (_lifecycleSubscribed) + return; + + GameLifecycle.OnPreLoad += ApplyAllRegistrations; + GameLifecycle.OnLoadComplete += ApplyAllRegistrations; + _lifecycleSubscribed = true; + } + + private static void ApplyAllRegistrations() + { + KeyValuePair[] registrations; + lock (RegistrationGate) + { + registrations = + new KeyValuePair[RegisteredPrefabs.Count]; + int index = 0; + foreach (var registration in RegisteredPrefabs) + registrations[index++] = registration; + } + + foreach (var registration in registrations) + { + if (registration.Value != null) + { + ApplyRegistration( + registration.Key, + registration.Value, + replaceExisting: true); + } + } + } + + private static void ApplyRegistration( + string id, + S1Trash.TrashItem prefab, + bool replaceExisting) + { + var manager = S1Trash.TrashManager.Instance; + if (manager == null) + return; + +#if (IL2CPPMELON) + Il2CppReferenceArray? prefabs = manager.TrashPrefabs; + int count = prefabs?.Length ?? 0; + for (int index = 0; index < count; index++) + { + var existing = prefabs![index]; + if (!string.Equals(existing?.ID, id, StringComparison.OrdinalIgnoreCase)) + continue; + + if (replaceExisting) + { + prefabs[index] = prefab; + manager.TrashPrefabs = prefabs; + } + return; + } + + var expanded = new Il2CppReferenceArray(count + 1); + for (int index = 0; index < count; index++) + expanded[index] = prefabs![index]; + expanded[count] = prefab; + manager.TrashPrefabs = expanded; +#else + S1Trash.TrashItem[] prefabs = + manager.TrashPrefabs ?? Array.Empty(); + for (int index = 0; index < prefabs.Length; index++) + { + var existing = prefabs[index]; + if (!string.Equals(existing?.ID, id, StringComparison.OrdinalIgnoreCase)) + continue; + + if (replaceExisting) + { + prefabs[index] = prefab; + manager.TrashPrefabs = prefabs; + } + return; + } + + Array.Resize(ref prefabs, prefabs.Length + 1); + prefabs[prefabs.Length - 1] = prefab; + manager.TrashPrefabs = prefabs; +#endif + } + + private static GameObject GetOrCreatePrefabRoot() + { + if (_prefabRoot != null) + return _prefabRoot; + + _prefabRoot = new GameObject("S1API_TrashPrefabs") + { + hideFlags = HideFlags.HideAndDontSave, + }; + _prefabRoot.SetActive(false); + Object.DontDestroyOnLoad(_prefabRoot); + return _prefabRoot; + } } } diff --git a/S1API/UI/CharacterCreatorManager.cs b/S1API/UI/CharacterCreatorManager.cs index 236266bf..77e84229 100644 --- a/S1API/UI/CharacterCreatorManager.cs +++ b/S1API/UI/CharacterCreatorManager.cs @@ -1,10 +1,10 @@ #if (IL2CPPMELON) -using S1Customization = Il2CppScheduleOne.AvatarFramework.Customization; +using S1Customization = Il2CppScheduleOne.CharacterCreator; using S1DevUtilities = Il2CppScheduleOne.DevUtilities; using S1PlayerScripts = Il2CppScheduleOne.PlayerScripts; using S1UI = Il2CppScheduleOne.UI; #elif MONOMELON -using S1Customization = ScheduleOne.AvatarFramework.Customization; +using S1Customization = ScheduleOne.CharacterCreator; using S1DevUtilities = ScheduleOne.DevUtilities; using S1PlayerScripts = ScheduleOne.PlayerScripts; using S1UI = ScheduleOne.UI; @@ -78,10 +78,7 @@ public static BasicAvatarSettings? ActiveSettings get { EnsureInitialized(); - if (_s1Creator?.ActiveSettings == null) - return null; - - return new BasicAvatarSettings(_s1Creator.ActiveSettings); + return null; } } @@ -112,16 +109,14 @@ public static void Open(BasicAvatarSettings? initialSettings = null, bool showUI RegisterEvents(); - // If no initial settings provided, try to load player's current settings - if (initialSettings == null) + if (initialSettings != null || !showUI) { - initialSettings = GetPlayerAvatarSettings(); + Logger.Warning( + "Schedule I 0.4.7 no longer accepts legacy initial settings or a hidden " + + "character-creator canvas; opening the native creator with its defaults."); } - var s1Settings = initialSettings?.S1BasicAvatarSettings; - _s1Creator.Open(s1Settings); - if (!showUI && _s1Creator.Canvas != null) - _s1Creator.Canvas.enabled = false; + _s1Creator.Open(); try { @@ -152,7 +147,12 @@ public static void Close() return; } - _s1Creator.Close(); + var closeMethod = typeof(S1Customization.CharacterCreator).GetMethod( + "Close", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic); + closeMethod?.Invoke(_s1Creator, null); // Restore camera transform and FOV if no other UI elements are active // Use coroutine to wait for base game's Close() coroutine to finish @@ -218,7 +218,8 @@ public static void SelectPreset(string presetName) return; } - _s1Creator.SelectPreset(presetName); + Logger.Warning( + "Character-creator presets are not available in Schedule I 0.4.7."); } /// @@ -229,20 +230,7 @@ public static string[] GetAvailablePresets() { EnsureInitialized(); - if (_s1Creator == null || _s1Creator.Presets == null) - return Array.Empty(); - - var presets = new List(); - for (int i = 0; i < _s1Creator.Presets.Count; i++) - { - var preset = _s1Creator.Presets[i]; - if (preset != null && !string.IsNullOrWhiteSpace(preset.name)) - { - presets.Add(preset.name); - } - } - - return presets.ToArray(); + return Array.Empty(); } /// @@ -265,7 +253,7 @@ public static void SetRigRotation(float normalizedValue) return; } - _s1Creator.SliderChanged(Mathf.Clamp01(normalizedValue)); + _s1Creator.SetAvatarRotation(Mathf.Clamp01(normalizedValue) * 359f); } /// @@ -322,15 +310,12 @@ private static void RegisterEvents() try { - // Register completion event - if (_s1Creator.onComplete != null) - { #if (IL2CPPMELON) - _s1Creator.onComplete.AddListener((Action)OnCreatorCompleted); + _s1Creator.add_OnComplete( + (Action)OnCreatorCompleted); #else - _s1Creator.onComplete.AddListener(OnCreatorCompleted); + _s1Creator.OnComplete += OnCreatorCompleted; #endif - } _eventsRegistered = true; } @@ -340,17 +325,34 @@ private static void RegisterEvents() } } - private static void OnCreatorCompleted(S1Customization.BasicAvatarSettings s1Settings) + private static void OnCreatorCompleted(S1Customization.CharacterCreatorState state) { try { - if (s1Settings == null) + if (state?.Appearance == null) { Logger.Warning("CharacterCreator completed with null settings"); return; } - var wrappedSettings = new BasicAvatarSettings(s1Settings); + BasicAvatarSettings wrappedSettings = BasicAvatarSettings.Create(); + wrappedSettings.Gender = (int)state.Appearance.Gender; + wrappedSettings.Weight = state.Appearance.Weight; + wrappedSettings.SkinColor = state.Appearance.SkinColor; + wrappedSettings.HairStyle = state.Appearance.HairStyleId ?? string.Empty; + wrappedSettings.HairColor = state.Appearance.HairColor; + wrappedSettings.Mouth = state.Appearance.FaceId ?? string.Empty; + wrappedSettings.FacialHair = state.Appearance.FacialHairId ?? string.Empty; + wrappedSettings.FacialDetails = state.Appearance.FacialDetailId ?? string.Empty; + wrappedSettings.FacialDetailsIntensity = state.Appearance.FacialDetailIntensity; + wrappedSettings.EyeballColor = state.Appearance.EyeballColor; + wrappedSettings.PupilDilation = state.Appearance.PupilDilation; + wrappedSettings.UpperEyeLidRestingPosition = state.Appearance.UpperEyelidPosition; + wrappedSettings.LowerEyeLidRestingPosition = state.Appearance.LowerEyelidPosition; + wrappedSettings.EyebrowScale = state.Appearance.EyebrowScale; + wrappedSettings.EyebrowThickness = state.Appearance.EyebrowThickness; + wrappedSettings.EyebrowRestingHeight = state.Appearance.EyebrowHeight; + wrappedSettings.EyebrowRestingAngle = state.Appearance.EyebrowAngle; // Restore camera after a delay to let the base game's Close() coroutine finish // The base game's Done() calls Close() which starts a coroutine that removes UI element diff --git a/S1API/Vehicles/LandVehicle.cs b/S1API/Vehicles/LandVehicle.cs index 2cd49dc2..22af8ba7 100644 --- a/S1API/Vehicles/LandVehicle.cs +++ b/S1API/Vehicles/LandVehicle.cs @@ -13,6 +13,7 @@ using System; using System.Reflection; using UnityEngine; +using S1API.Internal.Utils; using S1API.Logging; using S1API.Storages; @@ -55,7 +56,7 @@ public LandVehicle(string vehicleCode) public float VehiclePrice { get => S1LandVehicle.VehiclePrice; - set => VehiclePriceField?.SetValue(S1LandVehicle, value); + set => ReflectionUtils.TrySetFieldOrProperty(S1LandVehicle, "vehiclePrice", value); } /// @@ -252,12 +253,6 @@ internal LandVehicle(string vehicleName, bool isDeferred) /// private static readonly Log _logger = new Log("S1API.LandVehicle"); - /// - /// The stored reference to protected vehiclePrice field in the land vehicle in-game. - /// - private static readonly FieldInfo? VehiclePriceField = - typeof(S1Vehicles.LandVehicle).GetField("vehiclePrice", BindingFlags.NonPublic); - /// /// Connection to the player that owns the vehicle. /// diff --git a/S1API/Vehicles/VehicleColorCatalog.cs b/S1API/Vehicles/VehicleColorCatalog.cs new file mode 100644 index 00000000..17e18353 --- /dev/null +++ b/S1API/Vehicles/VehicleColorCatalog.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using UnityEngine; +#if MONOMELON +using S1DevUtilities = ScheduleOne.DevUtilities; +using S1Vehicles = ScheduleOne.Vehicles; +#elif IL2CPPMELON +using S1DevUtilities = Il2CppScheduleOne.DevUtilities; +using S1Vehicles = Il2CppScheduleOne.Vehicles; +#endif + +namespace S1API.Vehicles +{ + /// + /// Provides read-only access to the base game's vehicle color metadata. + /// + public static class VehicleColorCatalog + { + private static readonly IReadOnlyList EmptyColors = + new ReadOnlyCollection( + Array.Empty()); + private static IVehicleColorMetadataProvider _provider = + new NativeVehicleColorMetadataProvider(); + + /// + /// Gets a read-only snapshot of the available vehicle color metadata in native library order. + /// Returns an empty list before the base game's vehicle color library is ready. + /// + public static IReadOnlyList Colors => + GetColors(); + + /// + /// Gets metadata for a vehicle color. + /// + /// The vehicle color to look up. + /// + /// The color metadata, or when the color is unavailable + /// or the base game's vehicle color library is not ready. + /// + public static VehicleColorMetadata? GetColor(VehicleColor color) + { + TryGetColor(color, out VehicleColorMetadata? metadata); + return metadata; + } + + /// + /// Tries to get metadata for a vehicle color. + /// + /// The vehicle color to look up. + /// The resolved metadata when available. + /// when metadata was found. + public static bool TryGetColor( + VehicleColor color, + out VehicleColorMetadata? metadata) + { + if (!Enum.IsDefined(typeof(VehicleColor), color)) + { + metadata = null; + return false; + } + + return _provider.TryGetColor(color, out metadata); + } + + internal static void ResetForTesting(IVehicleColorMetadataProvider provider) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + internal static void RestoreProviderForTesting() + { + _provider = new NativeVehicleColorMetadataProvider(); + } + + private static IReadOnlyList GetColors() + { + if (!_provider.TryGetColors(out IReadOnlyList? metadata) + || metadata == null + || metadata.Count == 0) + { + return EmptyColors; + } + + return new ReadOnlyCollection( + new List(metadata)); + } + } + + internal interface IVehicleColorMetadataProvider + { + bool TryGetColors(out IReadOnlyList? metadata); + + bool TryGetColor( + VehicleColor color, + out VehicleColorMetadata? metadata); + } + + internal sealed class NativeVehicleColorMetadataProvider : + IVehicleColorMetadataProvider + { + public bool TryGetColors(out IReadOnlyList? metadata) + { + metadata = null; + + try + { + if (!S1DevUtilities.Singleton + .InstanceExists) + { + return false; + } + + S1Vehicles.Modification.VehicleColors vehicleColors = + S1DevUtilities.Singleton + .Instance; + if (vehicleColors.colorLibrary == null) + return false; + + var colors = new List( + vehicleColors.colorLibrary.Count); + for (int i = 0; i < vehicleColors.colorLibrary.Count; i++) + { + S1Vehicles.Modification.VehicleColors.VehicleColorData? nativeColor = + vehicleColors.colorLibrary[i]; + if (nativeColor == null) + continue; + + VehicleColor color = (VehicleColor)nativeColor.color; + if (!Enum.IsDefined(typeof(VehicleColor), color)) + continue; + + colors.Add(new VehicleColorMetadata( + color, + nativeColor.colorName, + nativeColor.MaterialColor, + nativeColor.UIColor)); + } + + if (colors.Count == 0) + return false; + + metadata = new ReadOnlyCollection(colors); + return true; + } + catch + { + return false; + } + } + + public bool TryGetColor( + VehicleColor color, + out VehicleColorMetadata? metadata) + { + metadata = null; + + if (!TryGetColors(out IReadOnlyList? colors) + || colors == null) + { + return false; + } + + for (int i = 0; i < colors.Count; i++) + { + VehicleColorMetadata entry = colors[i]; + if (entry.Color == color) + { + metadata = entry; + return true; + } + } + + return false; + } + } +} diff --git a/S1API/Vehicles/VehicleColorMetadata.cs b/S1API/Vehicles/VehicleColorMetadata.cs new file mode 100644 index 00000000..175afa54 --- /dev/null +++ b/S1API/Vehicles/VehicleColorMetadata.cs @@ -0,0 +1,42 @@ +using UnityEngine; + +namespace S1API.Vehicles +{ + /// + /// Describes a vehicle color using the base game's display metadata. + /// + public sealed class VehicleColorMetadata + { + internal VehicleColorMetadata( + VehicleColor color, + string? displayName, + Color materialColor, + Color32 uiColor) + { + Color = color; + DisplayName = displayName; + MaterialColor = materialColor; + UIColor = uiColor; + } + + /// + /// Gets the vehicle color represented by this metadata. + /// + public VehicleColor Color { get; } + + /// + /// Gets the display name configured for this color in the base game. + /// + public string? DisplayName { get; } + + /// + /// Gets the color applied to vehicle materials. + /// + public Color MaterialColor { get; } + + /// + /// Gets the color used for this color in the base game UI. + /// + public Color32 UIColor { get; } + } +} diff --git a/S1API/Vehicles/VehicleRegistry.cs b/S1API/Vehicles/VehicleRegistry.cs index 6b8114f2..2f0576dd 100644 --- a/S1API/Vehicles/VehicleRegistry.cs +++ b/S1API/Vehicles/VehicleRegistry.cs @@ -205,7 +205,7 @@ public static void RemoveVehicle(string guidString) { _cache.Remove(gameVehicle); } - private static LandVehicle? Wrap(S1Vehicles.LandVehicle? veh) + internal static LandVehicle? Wrap(S1Vehicles.LandVehicle? veh) { if (veh == null) return null; diff --git a/S1API/Weather/WeatherManager.cs b/S1API/Weather/WeatherManager.cs new file mode 100644 index 00000000..72512a9a --- /dev/null +++ b/S1API/Weather/WeatherManager.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using S1API.Internal.Weather; +using S1API.Logging; + +namespace S1API.Weather +{ + /// + /// Provides read-only access to the game's current weather and known weather sequences. + /// + /// + /// Weather state is populated after the current Main-scene weather manager and its active + /// conditions are available. It is outside gameplay or while those + /// native services are still initializing. + /// This API does not select weather sequences or trigger weather effects. + /// + public static class WeatherManager + { + private static readonly Log Logger = new Log("WeatherManager"); + private static readonly IReadOnlyList EmptySequenceIds = + new ReadOnlyCollection(Array.Empty()); + private static WeatherState? _current; + + /// + /// Gets the most recently observed weather state, or while the + /// current scene does not have an initialized weather manager. + /// + public static WeatherState? Current => _current; + + /// + /// Raised when weather first becomes available or changes to a distinct snapshot. + /// + public static event Action? OnWeatherChanged; + + /// + /// Gets a fresh, read-only snapshot of the identifiers configured on the current native + /// weather manager. Identifier order, casing, and duplicates are preserved. + /// + /// + /// An empty list is returned when the weather manager is not available or its sequence + /// collection cannot be read. The returned list contains strings only and is independent + /// of the native collection. + /// + public static IReadOnlyList KnownSequenceIds => + WeatherRuntime.GetKnownSequenceIds(); + + /// + /// INTERNAL: Publishes a managed snapshot after it has been copied from the native + /// weather callback. + /// + internal static void NotifyWeatherChanged(WeatherState state) + { + if (_current.HasValue && _current.Value.Equals(state)) + return; + + _current = state; + Action? handlers = OnWeatherChanged; + if (handlers == null) + return; + + foreach (Delegate callback in handlers.GetInvocationList()) + { + try + { + ((Action)callback)(state); + } + catch (Exception ex) + { + TryLogSubscriberFailure(ex); + } + } + } + + private static void TryLogSubscriberFailure(Exception exception) + { + try + { + Logger.Warning( + $"A WeatherManager.OnWeatherChanged subscriber failed: {exception.Message}"); + } + catch + { + // Logging must not break event dispatch when MelonLoader is not initialized. + } + } + + /// + /// INTERNAL: Clears the scene-bound weather snapshot without raising a change event. + /// + internal static void ResetState() + { + _current = null; + } + + /// + /// INTERNAL: Copies sequence identifiers into an immutable managed snapshot. + /// + internal static IReadOnlyList SnapshotSequenceIds( + IEnumerable sequenceIds) + { + if (sequenceIds == null) + return EmptySequenceIds; + + var snapshot = new List(); + foreach (string? id in sequenceIds) + { + if (!string.IsNullOrEmpty(id)) + snapshot.Add(id); + } + + return snapshot.Count == 0 + ? EmptySequenceIds + : new ReadOnlyCollection(snapshot); + } + } +} diff --git a/S1API/Weather/WeatherState.cs b/S1API/Weather/WeatherState.cs new file mode 100644 index 00000000..f60b781f --- /dev/null +++ b/S1API/Weather/WeatherState.cs @@ -0,0 +1,169 @@ +using System; + +namespace S1API.Weather +{ + /// + /// An immutable snapshot of the game's active weather-condition weights. + /// Each value is normally normalized between 0 and 1 by the game. + /// + public readonly struct WeatherState : IEquatable + { + /// + /// Creates a weather snapshot from the nine native weather components. + /// + /// The sunny-condition weight. + /// The cloudy-condition weight. + /// The rainy-condition weight. + /// The stormy-condition weight. + /// The snowy-condition weight. + /// The foggy-condition weight. + /// The windy-condition weight. + /// The hail-condition weight. + /// The sleet-condition weight. + public WeatherState( + float sunny, + float cloudy, + float rainy, + float stormy, + float snowy, + float foggy, + float windy, + float hail, + float sleet) + { + Sunny = sunny; + Cloudy = cloudy; + Rainy = rainy; + Stormy = stormy; + Snowy = snowy; + Foggy = foggy; + Windy = windy; + Hail = hail; + Sleet = sleet; + } + + /// + /// Gets the sunny-condition weight. + /// + public float Sunny { get; } + + /// + /// Gets the cloudy-condition weight. + /// + public float Cloudy { get; } + + /// + /// Gets the rainy-condition weight. + /// + public float Rainy { get; } + + /// + /// Gets the stormy-condition weight. + /// + public float Stormy { get; } + + /// + /// Gets the snowy-condition weight. + /// + public float Snowy { get; } + + /// + /// Gets the foggy-condition weight. + /// + public float Foggy { get; } + + /// + /// Gets the windy-condition weight. + /// + public float Windy { get; } + + /// + /// Gets the hail-condition weight. + /// + public float Hail { get; } + + /// + /// Gets the sleet-condition weight. + /// + public float Sleet { get; } + + /// + /// INTERNAL: Creates a managed snapshot from the native weather component order. + /// + internal static WeatherState FromNativeComponents( + float sunny, + float cloudy, + float rainy, + float stormy, + float snowy, + float foggy, + float windy, + float hail, + float sleet) + { + return new WeatherState( + sunny, + cloudy, + rainy, + stormy, + snowy, + foggy, + windy, + hail, + sleet); + } + + /// + public bool Equals(WeatherState other) + { + return Sunny.Equals(other.Sunny) + && Cloudy.Equals(other.Cloudy) + && Rainy.Equals(other.Rainy) + && Stormy.Equals(other.Stormy) + && Snowy.Equals(other.Snowy) + && Foggy.Equals(other.Foggy) + && Windy.Equals(other.Windy) + && Hail.Equals(other.Hail) + && Sleet.Equals(other.Sleet); + } + + /// + public override bool Equals(object? obj) => + obj is WeatherState other && Equals(other); + + /// + public override int GetHashCode() + { + unchecked + { + int hash = Sunny.GetHashCode(); + hash = (hash * 397) ^ Cloudy.GetHashCode(); + hash = (hash * 397) ^ Rainy.GetHashCode(); + hash = (hash * 397) ^ Stormy.GetHashCode(); + hash = (hash * 397) ^ Snowy.GetHashCode(); + hash = (hash * 397) ^ Foggy.GetHashCode(); + hash = (hash * 397) ^ Windy.GetHashCode(); + hash = (hash * 397) ^ Hail.GetHashCode(); + return (hash * 397) ^ Sleet.GetHashCode(); + } + } + + /// + /// Determines whether two weather snapshots have the same component values. + /// + /// The first weather snapshot. + /// The second weather snapshot. + /// when all condition weights are equal. + public static bool operator ==(WeatherState left, WeatherState right) => + left.Equals(right); + + /// + /// Determines whether two weather snapshots have different component values. + /// + /// The first weather snapshot. + /// The second weather snapshot. + /// when any condition weight differs. + public static bool operator !=(WeatherState left, WeatherState right) => + !left.Equals(right); + } +} diff --git a/S1API/docs/basic-npc-creation.md b/S1API/docs/basic-npc-creation.md index 57694c0a..dfc12e51 100644 --- a/S1API/docs/basic-npc-creation.md +++ b/S1API/docs/basic-npc-creation.md @@ -115,8 +115,8 @@ Once the NPC exists in-world, add dialogue. Keep the logic small here and lean o ## Step 5 (optional): Make them a customer or dealer -- Customer NPCs: `builder.EnsureCustomer().WithCustomerDefaults(...)` (see `S1API/docs/customer-behavior.md`) -- Dealer NPCs: `public override bool IsDealer => true;` + `builder.EnsureDealer().WithDealerDefaults(...)` (see `S1API/docs/dealer-system.md`) +- Customer NPCs: `public override bool IsCustomer => true;` + `builder.WithCustomerDefaults(...)` (see `S1API/docs/customer-behavior.md`) +- Dealer NPCs: `public override bool IsDealer => true;` + `builder.WithDealerDefaults(...)` (see `S1API/docs/dealer-system.md`) ## Example NPCs diff --git a/S1API/docs/casino-games.md b/S1API/docs/casino-games.md new file mode 100644 index 00000000..3aaafaa4 --- /dev/null +++ b/S1API/docs/casino-games.md @@ -0,0 +1,118 @@ +# Casino game state + +S1API exposes read-only managed wrappers for the casino's blackjack table, Ride the Bus table, and slot machines. The wrappers provide discovery, synchronized state snapshots, and round or spin lifecycle events without exposing native casino controllers or RPC methods. + +This API observes the game. It does not create custom casino games, replace payouts, submit bets or answers, change readiness, or invoke client/server RPCs. + +## Discover games + +Casino objects belong to the gameplay scene. Query the registry after the game has loaded, such as from `GameLifecycle.OnLoadComplete`: + +```csharp +using S1API.Casino; +using S1API.Lifecycle; + +GameLifecycle.OnLoadComplete += () => +{ + IReadOnlyList blackjack = CasinoGameRegistry.GetBlackjackGames(); + IReadOnlyList rideTheBus = CasinoGameRegistry.GetRideTheBusGames(); + IReadOnlyList slots = CasinoGameRegistry.GetSlotMachines(); +}; +``` + +`GetTables()` returns blackjack and Ride the Bus through their shared `CasinoGameTable` base. Slot machines are separate because they do not have seated-player or table-bet state. + +Use `CasinoGameRegistry.FindNearestSlotMachine(position, maxDistance)` when you need the nearest slot machine without exposing the game's native casino type: + +```csharp +SlotMachine? nearest = CasinoGameRegistry.FindNearestSlotMachine(transform.position, 10f); +if (nearest != null) + MelonLogger.Msg($"Nearest slot machine: {nearest.Name} at {nearest.Position}"); +``` + +Registry results are read-only snapshots of the active casino objects at the time of discovery. Their wrapper instances represent live scene objects, so wrapper properties continue to reflect current controller state. Calling a discovery method again reflects the active objects in the current scene, while wrappers for the same live scene object retain their identity within that scene. + +## Shared table state + +Every `CasinoGameTable` exposes: + +- `Name` and `Position` +- `IsOpen`, which reports whether this table's interface is open for the local player +- `IsWaitingForPlayers` +- `LocalBet` +- `BetLimits` +- `Players`, an immutable snapshot containing managed `Player` wrappers when available, seat indices, synchronized scores, and ready state + +Local-player properties describe the current client. Stage, player, card, score, and spin data reflect the native state received by that peer. + +## Blackjack + +```csharp +BlackjackGame table = CasinoGameRegistry.GetBlackjackGames()[0]; + +BlackjackStage stage = table.Stage; +CasinoBetLimits limits = table.BetLimits; +IReadOnlyList dealerHand = table.DealerHand; + +foreach (CasinoPlayerSnapshot player in table.Players) +{ + IReadOnlyList hand = table.GetPlayerHand(player.SeatIndex); + MelonLogger.Msg($"{player.Name}: table score {player.Score}, cards {hand.Count}"); +} +``` + +Blackjack also exposes the dealer score, local score, local blackjack/bust flags, ready-player count, and whether the local player belongs to the active round. + +Subscribe globally when you want to observe every table: + +```csharp +CasinoGameRegistry.BlackjackStageChanged += (table, previous, current) => + MelonLogger.Msg($"{table.Name}: {previous} -> {current}"); + +CasinoGameRegistry.BlackjackRoundStarted += table => + MelonLogger.Msg($"Round started at {table.Name}"); + +CasinoGameRegistry.BlackjackRoundEnded += table => + MelonLogger.Msg($"Round ended at {table.Name}"); +``` + +The same events are available on an individual `BlackjackGame` wrapper. + +## Ride the Bus + +`RideTheBusGame` exposes its stage, question-active flag, remaining answer time, local bet multiplier, multiplied local bet, ready and answered player counts, active-round membership, and immutable card snapshots. + +```csharp +RideTheBusGame table = CasinoGameRegistry.GetRideTheBusGames()[0]; + +CasinoGameRegistry.RideTheBusStageChanged += (game, previous, current) => + MelonLogger.Msg($"Ride the Bus: {previous} -> {current}"); + +CasinoGameRegistry.RideTheBusRoundStarted += game => + MelonLogger.Msg("Ride the Bus round started"); + +CasinoGameRegistry.RideTheBusRoundEnded += game => + MelonLogger.Msg("Ride the Bus round ended"); +``` + +The same events are available on an individual `RideTheBusGame` wrapper. + +## Slot machines + +Slot wrappers expose their position, current bet, available native bets, and spinning state. Spin events include the bet, ordered reel symbols, whether the native spinner belongs to the current client, and the eventual outcome and win amount. + +```csharp +CasinoGameRegistry.SlotSpinStarted += (machine, spin) => + MelonLogger.Msg($"{machine.Name} started a ${spin.Bet} spin"); + +CasinoGameRegistry.SlotSpinCompleted += (machine, spin) => + MelonLogger.Msg($"{machine.Name}: {spin.Outcome}, won ${spin.WinAmount}"); +``` + +The same events are available on an individual `SlotMachine` wrapper. S1API's existing NPC slot-machine helper also publishes through these lifecycle events. + +## Event lifetime + +Static registry subscriptions belong to your mod and remain subscribed across scene changes. Unsubscribe them when your mod no longer needs them. S1API clears scene-object wrapper and active-spin state before scene transitions, so discovery never returns cached objects from a previous gameplay scene. + +Subscriptions attached directly to a `BlackjackGame`, `RideTheBusGame`, or `SlotMachine` wrapper last only for that wrapper's scene. Query and subscribe to new wrappers after each gameplay scene load, or use the static `CasinoGameRegistry` events when the subscription should remain active across scene changes. diff --git a/S1API/docs/casino-slot-machines.md b/S1API/docs/casino-slot-machines.md index 3b3738ee..0efcc423 100644 --- a/S1API/docs/casino-slot-machines.md +++ b/S1API/docs/casino-slot-machines.md @@ -2,6 +2,10 @@ The S1API provides a modder-facing API for making NPCs interact with slot machines in the casino. This system handles cash management, animations, and outcome determination automatically. +For read-only player-facing blackjack, Ride the Bus, and slot-machine state and lifecycle events, see [Casino game state](casino-games.md). + +For discovery, including finding the nearest slot machine, use `CasinoGameRegistry`. The legacy `SlotMachineHelper.FindNearestSlotMachine` method remains available for compatibility but exposes a native game type and is obsolete for new mods. + ## Overview The slot machine system allows NPCs to: diff --git a/S1API/docs/clothing-items.md b/S1API/docs/clothing-items.md index ff73bcd1..80c2b045 100644 --- a/S1API/docs/clothing-items.md +++ b/S1API/docs/clothing-items.md @@ -105,6 +105,35 @@ Shop injection should happen after the registry confirms your item exists (e.g., | Feet | Shoes, boots | | Wrist | Watches, bracelets | +### Reading the Native Clothing Catalog + +Use `ClothingMetadataCatalog` when a menu or tool needs the same clothing slot +names, icons, and colors as the base game: + +```csharp +using S1API.Items.Clothing; +using UnityEngine; + +foreach (ClothingSlotMetadata slot in ClothingMetadataCatalog.Slots) +{ + MelonLogger.Msg($"{slot.Slot}: {slot.DisplayName}"); + Sprite? icon = slot.Icon; +} + +ClothingColorMetadata? blue = + ClothingMetadataCatalog.GetColor(ClothingColor.Blue); +if (blue != null) +{ + Color materialColor = blue.ActualColor; + Color labelColor = blue.LabelColor; +} +``` + +The catalog is read-only and returns S1API-owned metadata snapshots rather than +native `ClothingUtility` objects. Access it after the main scene has initialized; +before the native clothing utility is ready, `Slots` and `Colors` are empty and +individual lookups return `null`. + | Application Type (`ClothingApplicationType`) | Purpose | | --- | --- | | Accessory | 3D meshes (hats, glasses) | diff --git a/S1API/docs/custom-npcs.md b/S1API/docs/custom-npcs.md index d040c82e..7ac9800f 100644 --- a/S1API/docs/custom-npcs.md +++ b/S1API/docs/custom-npcs.md @@ -55,7 +55,8 @@ Here's a minimal example to get you started: ```csharp public sealed class MyFirstNPC : NPC { - protected override bool IsPhysical => true; + public override bool IsPhysical => true; + public override bool IsCustomer => true; protected override void ConfigurePrefab(NPCPrefabBuilder builder) { @@ -64,7 +65,6 @@ public sealed class MyFirstNPC : NPC firstName: "John", lastName: "Doe") .WithSpawnPosition(new Vector3(0, 0, 0)) - .EnsureCustomer() .WithCustomerDefaults(cd => { cd.WithSpending(100f, 500f) .WithOrdersPerWeek(1, 3); @@ -148,7 +148,9 @@ Supported identifiers are `cold`, `crackhead`, `female-1`, `female-2`, `goblin`, These identifiers name reusable voice databases, not individual NPCs. For example, Ray's native configuration combines the `tyler` database with a character-specific pitch; use the pitch overload when reproducing that kind of voice profile. -The pitch overload accepts values from `0.1` through `4.0`. Omitting the pitch preserves the selected base prefab's inherited pitch. Omitting `WithVoice(...)` entirely preserves both the inherited voice database and pitch. Invalid identifiers, unavailable databases, and out-of-range pitch values throw an actionable configuration error. +The pitch overload accepts values from `0.1` through `4.0`. Omitting the pitch preserves the selected base prefab's inherited pitch. Omitting `WithVoice(...)` entirely preserves both the inherited voice database and pitch. + +Call `WithVoice(...)` when the NPC should keep a specific voice across game updates. Inherited voices depend on S1API's current donor prefab and may change when the game changes that prefab. A missing donor voice also leaves the NPC silent. Invalid identifiers, unavailable databases, and out-of-range pitch values throw an actionable configuration error. Voice selection controls which clips normal NPC dialogue and reactions play. It does not play an individual voice line. diff --git a/S1API/docs/customer-behavior.md b/S1API/docs/customer-behavior.md index 147f7454..772c9e8b 100644 --- a/S1API/docs/customer-behavior.md +++ b/S1API/docs/customer-behavior.md @@ -17,12 +17,13 @@ The customer system allows NPCs to act as business customers, buying products fr Customer NPCs can buy products from the player, follow spending patterns, and participate in the game's economy. The customer system is configured in `ConfigurePrefab` and managed at runtime. ```csharp +public override bool IsCustomer => true; + protected override void ConfigurePrefab(NPCPrefabBuilder builder) { - builder.EnsureCustomer() - .WithCustomerDefaults(cd => { - // Spending behavior - cd.WithSpending(minWeekly: 150f, maxWeekly: 600f) + builder.WithCustomerDefaults(cd => { + // Spending behavior + cd.WithSpending(minWeekly: 150f, maxWeekly: 600f) .WithOrdersPerWeek(1, 4) .WithPreferredOrderDay(Day.Friday) .WithOrderTime(1100); // 11:00 AM @@ -47,7 +48,7 @@ protected override void ConfigurePrefab(NPCPrefabBuilder builder) // Property preferences cd.WithPreferredProperties(Property.Munchies, Property.Energizing); - }); + }); } ``` @@ -55,10 +56,10 @@ protected override void ConfigurePrefab(NPCPrefabBuilder builder) ### Enabling Customer Behavior -First, ensure the customer component is added: +Declare customer capability at the NPC type level. S1API adds the native component before `ConfigurePrefab` and network registration: ```csharp -builder.EnsureCustomer(); +public override bool IsCustomer => true; ``` ### Customer Defaults @@ -266,11 +267,12 @@ var contract = Customer.CurrentContract; ### Basic Customer ```csharp +public override bool IsCustomer => true; + protected override void ConfigurePrefab(NPCPrefabBuilder builder) { - builder.EnsureCustomer() - .WithCustomerDefaults(cd => { - cd.WithSpending(100f, 300f) + builder.WithCustomerDefaults(cd => { + cd.WithSpending(100f, 300f) .WithOrdersPerWeek(1, 2) .WithPreferredOrderDay(Day.Friday) .WithOrderTime(1400) @@ -280,18 +282,19 @@ protected override void ConfigurePrefab(NPCPrefabBuilder builder) .WithAffinities(new[] { (DrugType.Marijuana, 0.5f) }); - }); + }); } ``` ### High-Value Customer ```csharp +public override bool IsCustomer => true; + protected override void ConfigurePrefab(NPCPrefabBuilder builder) { - builder.EnsureCustomer() - .WithCustomerDefaults(cd => { - cd.WithSpending(500f, 1000f) + builder.WithCustomerDefaults(cd => { + cd.WithSpending(500f, 1000f) .WithOrdersPerWeek(3, 5) .WithPreferredOrderDay(Day.Saturday) .WithOrderTime(1100) @@ -306,18 +309,19 @@ protected override void ConfigurePrefab(NPCPrefabBuilder builder) (DrugType.Marijuana, 0.6f) }) .WithPreferredProperties(Property.Energizing, Property.BrightEyed); - }); + }); } ``` ### Risky Customer ```csharp +public override bool IsCustomer => true; + protected override void ConfigurePrefab(NPCPrefabBuilder builder) { - builder.EnsureCustomer() - .WithCustomerDefaults(cd => { - cd.WithSpending(200f, 600f) + builder.WithCustomerDefaults(cd => { + cd.WithSpending(200f, 600f) .WithOrdersPerWeek(2, 4) .WithPreferredOrderDay(Day.Sunday) .WithOrderTime(2000) @@ -330,7 +334,7 @@ protected override void ConfigurePrefab(NPCPrefabBuilder builder) (DrugType.Heroin, 0.7f), (DrugType.Cocaine, 0.5f) }); - }); + }); } ``` @@ -376,7 +380,7 @@ protected override void OnCreated() ### Don'ts - **Don't modify customer data at runtime** (except through proper APIs) -- **Don't forget to call `EnsureCustomer()`** before `WithCustomerDefaults()` +- **Don't make `IsCustomer` depend on constructor state**; S1API reads role properties from an uninitialized instance - **Don't use extreme values** for spending, addiction, or police chance - **Don't create customers with impossible requirements** (e.g., high standards with low relationship) @@ -385,14 +389,15 @@ protected override void OnCreated() Wrap customer configuration in try-catch blocks: ```csharp +public override bool IsCustomer => true; + protected override void ConfigurePrefab(NPCPrefabBuilder builder) { try { - builder.EnsureCustomer() - .WithCustomerDefaults(cd => { - // Customer configuration - }); + builder.WithCustomerDefaults(cd => { + // Customer configuration + }); } catch (Exception ex) { diff --git a/S1API/docs/dealer-system.md b/S1API/docs/dealer-system.md index 072a462f..66f6c6d3 100644 --- a/S1API/docs/dealer-system.md +++ b/S1API/docs/dealer-system.md @@ -14,7 +14,7 @@ Dealer NPCs are special NPCs that can: ## Creating a Dealer NPC -To create a dealer NPC, set `IsDealer = true` and configure dealer defaults using the `EnsureDealer()` builder method: +To create a dealer NPC, override `IsDealer` and configure any optional dealer defaults in `ConfigurePrefab`: ```csharp using S1API.Entities; @@ -36,7 +36,6 @@ public class MyDealerNPC : NPC av.Gender = 0.0f; av.Height = 1.0f; }) - .EnsureDealer() .WithDealerDefaults(dd => { dd.WithSigningFee(1000f) // Cost to recruit @@ -246,7 +245,6 @@ public sealed class ProfessionalDealer : NPC av.WithBodyLayer("Avatar/Layers/Bottom/Jeans", new Color(0.2f, 0.2f, 0.3f)); av.WithAccessoryLayer("Avatar/Accessories/Feet/Sneakers/Sneakers", Color.black); }) - .EnsureDealer() .WithDealerDefaults(dd => { dd.WithSigningFee(2500f) // Higher fee = more experienced diff --git a/S1API/docs/delivery-system.md b/S1API/docs/delivery-system.md index fe6090cf..6d55b4a8 100644 --- a/S1API/docs/delivery-system.md +++ b/S1API/docs/delivery-system.md @@ -1,6 +1,6 @@ # Deliveries -`S1API.Deliveries` provides read-only, cross-runtime wrappers around supplier deliveries and their order-history receipts. It is intended for mods that need to observe delivery state without depending on Schedule I's native delivery, vehicle, UI, or networking types. +`S1API.Deliveries` provides read-only, cross-runtime wrappers around supplier deliveries, property loading docks, and order-history receipts. It is intended for mods that need to observe delivery state without depending on Schedule I's native delivery, vehicle, UI, or networking types. This API describes property/shop deliveries managed by the game's delivery app. Supplier dead-drop orders are a separate flow represented by `NPCSupplier.Status`, `MinutesUntilDeadDropReady`, and `OnDeadDropReady`. Customer drop-off points are another separate system documented in [Delivery Location Registry](delivery-location-registry.md). @@ -57,7 +57,8 @@ Registry results reflect the local peer's loaded state. Query after the world ha The delivery surface is deliberately split by concern: -- `Delivery`: A read-only live view of an active supplier delivery. It exposes `Id`, `StoreName`, `DestinationCode`, the wrapped `Destination`, `LoadingDockIndex`, `Status`, `MinutesUntilArrival`, `Items`, `Shop`, and `ActiveVehicle`. `Shop` or `Destination` may be unavailable during world setup, while `ActiveVehicle` is normally unavailable until arrival. +- `Delivery`: A read-only live view of an active supplier delivery. It exposes `Id`, `StoreName`, `DestinationCode`, the wrapped `Destination` and `LoadingDock`, `LoadingDockIndex`, `Status`, `MinutesUntilArrival`, `Items`, `Shop`, and `ActiveVehicle`. `Shop`, `Destination`, or `LoadingDock` may be unavailable during world setup, while `ActiveVehicle` is normally unavailable until arrival. +- `LoadingDock`: A read-only live view of one property dock, including stable identity, transit slots, accepting/destroyed state, and its dynamic and delivery-vehicle occupants. - `DeliveryItem`: An immutable `ItemId` and `Quantity` entry from an order. - `DeliveryReceipt`: An immutable delivery order-details snapshot with the delivery identity, destination, loading dock, and ordered items. `GetHistory()` returns receipts that were recorded in order history. - `DeliveryStatus`: The public delivery lifecycle state. @@ -68,6 +69,36 @@ The delivery surface is deliberately split by concern: The wrappers do not expose Schedule I's native `DeliveryInstance`, delivery shop, or delivery-vehicle component. Native delivery creation and lifecycle transitions remain hidden so mods cannot bypass server authority. Related `Shop`, `PropertyWrapper`, and `LandVehicle` objects use their existing S1API wrappers and retain the capabilities documented by those APIs. +## Loading Docks + +Enumerate all docks owned by a property through `PropertyWrapper.LoadingDocks`, or follow the selected dock directly from an active delivery: + +```csharp +using S1API.Deliveries; + +var deliveries = DeliveryRegistry.GetAll(); +Delivery? delivery = deliveries.Count == 0 ? null : deliveries[0]; +LoadingDock? dock = delivery?.LoadingDock; +if (dock is not null) +{ + MelonLoader.MelonLogger.Msg( + $"{dock.Name} ({dock.GUID}): {dock.OutputSlots.Count} output slots"); + + dock.DynamicOccupantChanged += (previous, current) => + MelonLoader.MelonLogger.Msg( + $"Detected vehicle: {previous?.GUID ?? "none"} -> {current?.GUID ?? "none"}"); + dock.StaticOccupantChanged += (previous, current) => + MelonLoader.MelonLogger.Msg( + $"Delivery vehicle: {previous?.GUID ?? "none"} -> {current?.GUID ?? "none"}"); + dock.AcceptingItemsChanged += (previous, current) => + MelonLoader.MelonLogger.Msg($"Accepting items: {previous} -> {current}"); +} +``` + +`InputSlots` and `OutputSlots` are immutable collection snapshots containing live `ItemSlotInstance` wrappers. `DynamicOccupant` represents a nearby stopped vehicle detected by the dock. `StaticOccupant` represents the delivery vehicle assigned during supplier-delivery arrival. `IsInUse` is true when either occupant exists. + +Dock wrappers are cached for the loaded scene, so a dock reached through a property and an active delivery shares event subscriptions. Resolve wrappers again after a scene or save transition. Events report native state observed by the local peer and do not add a new replication channel. + ## Lifecycle Events `DeliveryRegistry` exposes observation-only lifecycle events: @@ -108,6 +139,8 @@ The initial public API therefore supports observation and lookup only. It does n - construct an arbitrary delivery; - force a delivery status; - assign a native delivery vehicle; +- create, destroy, or mutate a loading dock or its GUID; +- force dock occupancy, accepting state, transit routing, or outline UI; - invoke native delivery UI or network RPCs. Use supplier configuration to define what a custom supplier sells, then use `NPCSupplier` and `DeliveryRegistry` to observe the resulting orders. @@ -118,3 +151,4 @@ Use supplier configuration to define what a custom supplier sells, then use `NPC - [Delivery Location Registry](delivery-location-registry.md) - - +- diff --git a/S1API/docs/dialogue-system.md b/S1API/docs/dialogue-system.md index d1a86b7e..95b39900 100644 --- a/S1API/docs/dialogue-system.md +++ b/S1API/docs/dialogue-system.md @@ -5,14 +5,15 @@ The dialogue system allows you to create interactive conversations with branchin ## Table of Contents 1. [Overview](#overview) -2. [Basic Dialogue Setup](#basic-dialogue-setup) -3. [Dialogue Database](#dialogue-database) -4. [Dialogue Containers](#dialogue-containers) -5. [Choice Callbacks](#choice-callbacks) -6. [Dynamic Navigation](#dynamic-navigation) -7. [Dialogue Events](#dialogue-events) -8. [Advanced Features](#advanced-features) -9. [Best Practices](#best-practices) +2. [Native Dialogue Reference](#native-dialogue-reference) +3. [Basic Dialogue Setup](#basic-dialogue-setup) +4. [Dialogue Database](#dialogue-database) +5. [Dialogue Containers](#dialogue-containers) +6. [Choice Callbacks](#choice-callbacks) +7. [Dynamic Navigation](#dynamic-navigation) +8. [Dialogue Events](#dialogue-events) +9. [Advanced Features](#advanced-features) +10. [Best Practices](#best-practices) ## Overview @@ -68,6 +69,72 @@ protected override void OnCreated() } ``` +## Native Dialogue Reference + +S1API's builders create dialogue that follows the same native model, but they do not expose the game-owned dialogue controller or its serialized content directly. This section explains that model so you can choose the correct module and avoid assuming that a custom container replaces a native interaction. + +### How a Native NPC Interaction Runs + +Each NPC has a `DialogueHandler`, a dialogue database, and usually a `DialogueController`: + +1. The controller decides whether interaction is currently allowed and gathers its enabled choices. +2. If at least one choice is available, the controller opens its generic dialogue container at `ENTRY`; the greeting becomes that entry's displayed text. +3. If no choice is available, it displays only a world-space greeting. +4. A temporary greeting override wins before the normal time- and weather-based greeting selection. + +Native controllers can also add temporary choices and greeting overrides for gameplay state. Customer requests, supplier meetings, police interactions, and scripted characters use that layer; a container alone does not automatically opt your NPC into those systems. + +### Native Dialogue Modules + +The game currently defines these module names. Pass these exact names to `WithModuleEntry`; names are parsed as the native `EDialogueModule` enum, so an unrecognized name is not a safe custom category. + +| Module | Native use | Example keys observed in native behaviour | +| --- | --- | --- | +| `Generic` | General NPC and progression lines, including text-message chains. Use `WithGeneric` for this module. | `supplier_meeting_greeting`, `cartel_deal_request` | +| `Greetings` | The default response when a player talks to an NPC without an active choice. | `rainy_greeting`, `morning_greeting`, `afternoon_greeting`, `night_greeting` | +| `Reactions` | Short reactive world-space lines used by NPC response behaviour. | `noticed_pickpocket` | +| `Customer` | Deal, contract, product-request, sample, and post-deal dialogue. | `awaiting_deal`, `contract_request`, `deal_completed` | +| `Police` | Checkpoint and body-search state dialogue. | `checkpoint_search_start`, `bodysearch_begin` | +| `Supplier` | Supplier delivery, dead-drop, and repayment dialogue. | `deaddrop_requested`, `supplier_request_repayment` | +| `Dealer` | Dealer robbery and inventory-status messages. | `dealer_rob_defended`, `inventory_depleted` | +| `CartelGoon` | Cartel ambush dialogue. | `ambush_start` | + +These keys describe the currently inspected game build, not a compatibility promise from S1API. Do not overwrite a game-owned key unless your mod intentionally replaces that native line; prefer a distinct key for dialogue used only by your own code. + +### Greeting Selection + +The stock `DialogueController` checks active greeting overrides first, in list order. If no override is active, it chooses from `Greetings` as follows: + +- `rainy_greeting` may be selected when the local weather is rainy (above the native threshold) and the random rainy check succeeds. +- `morning_greeting` is used from 04:00 through 12:00. +- `afternoon_greeting` is used from 12:00 through 18:00. +- `night_greeting` is used at other times. + +The greeting is independent of a conversation tree. It becomes the text of the controller's generic `ENTRY` node when there are active choices, or appears as a short world-space line when there are none. For custom NPCs, include the relevant `Greetings` keys only when you rely on this native controller behaviour; otherwise, put your intended opening text directly in your container's `ENTRY` node. + +### Database Lookup and Fallback + +`DialogueHandler` gives each NPC a runtime `Generic` module, then attaches the modules configured by its dialogue database. A lookup first uses that NPC's runtime module and otherwise falls back to the game-wide dialogue manager's module of the same type. This is why a custom NPC should supply every key that its own native-style behaviour requires instead of depending on a particular vanilla NPC database. + +Use `WithGeneric` for general entries and `WithModuleEntry` with an exact module name for the remaining native modules: + +```csharp +Dialogue.BuildAndSetDatabase(db => +{ + db.WithGeneric("my_intro", "I have something to discuss."); + db.WithModuleEntry("Greetings", "morning_greeting", "Morning."); + db.WithModuleEntry("Greetings", "afternoon_greeting", "Afternoon."); + db.WithModuleEntry("Greetings", "night_greeting", "Evening."); + db.WithModuleEntry("Reactions", "noticed_pickpocket", "Hey! What was that?"); +}); +``` + +### Native Systems Versus Custom Containers + +Use `BuildAndRegisterContainer` and `UseContainerOnInteract` for an interaction that your mod owns. Use [Dialogue Injection](dialogue-injection.md) to add a branch to an existing vanilla container. Do not use either approach to replace a native customer, supplier, dealer, police, or cartel state machine: those systems can add their own greeting overrides, choices, validation, and callbacks as gameplay state changes. + +If you need a native line for reference, inspect your locally installed game build and record only the module and key in your mod's source or documentation. Do not commit or redistribute game assemblies, decompiled source, prefabs, or dialogue assets. + ## Basic Dialogue Setup ### Setting Up Dialogue @@ -283,8 +350,34 @@ Dialogue.OnChoiceSelected("LEAVE", () => { Debug.Log("Player chose to leave"); Dialogue.StopOverride(); // Safe to call here }); + +// When any interaction handled by this NPC ends +Dialogue.OnDialogueEnded(() => { + Quest.Advance("talked-to-shopkeeper"); +}); + +// Enable or disable a controller-level choice by its destination container +bool updated = Dialogue.SetChoiceEnabled("ShopDialogue", enabled: false); ``` +`OnNodeDisplayed` matches the node label supplied by the dialogue container and +`OnChoiceSelected` matches the `ChoiceLabel` of a node choice. Both labels are +case-insensitive. `OnDialogueEnded` is handler-wide: it does not identify the +container that ended. It fires when the native handler ends the interaction, +including when code calls `Dialogue.End()`; the native handler controls the +exact ordering relative to other dialogue events. + +`SetChoiceEnabled` targets a controller-level choice, not a choice inside the +current dialogue node. Its key is the destination dialogue container name, not +the displayed choice text. Matching is case-insensitive. The change is live +runtime state only; it is not saved, synchronized, or applied to node choices. +The method returns `false` when the NPC has no matching controller choice. + +Callbacks are registered on the NPC's current dialogue handler. Callbacks are +invoked in registration order, duplicate registrations are preserved, and +`ClearCallbacks()` removes all four callback categories and their native event +hooks. Callback exceptions are isolated from other callbacks. + ### Important: StopOverride Usage **Safe**: Call `Dialogue.StopOverride()` from `Dialogue.OnChoiceSelected(...)` when you want a temporary container to stop after a choice. diff --git a/S1API/docs/furniture-items.md b/S1API/docs/furniture-items.md new file mode 100644 index 00000000..d0114e60 --- /dev/null +++ b/S1API/docs/furniture-items.md @@ -0,0 +1,143 @@ +# Custom Furniture + +`FurnitureCreator` turns a mod-provided `GameObject` into a complete native buildable item. S1API +creates the placed prefab, placement ghost source, footprint, bounds, collision, culling metadata, +stored-item prefab, equippable reference, definition, and icon while preserving Schedule One's +serialized build handlers, save lifecycle, and multiplayer initialization. + +## Grid furniture + +The model can come from any loader that returns a Unity `GameObject`. This example uses an embedded +GLB loaded by S1MAPI: + +```csharp +using S1API.Items.Buildable; +using S1MAPI.Gltf; +using S1MAPI.Utils; +using UnityEngine; + +byte[] glb = EmbeddedResourceLoader.LoadBytes("MyMod.Assets.SofaChair.glb") + ?? throw new InvalidOperationException("Embedded chair model is missing."); +GameObject model = GltfLoader.LoadGlb( + glb, + Shader.Find("Universal Render Pipeline/Lit")) + ?? throw new InvalidOperationException("Chair GLB could not be loaded."); + +var chair = FurnitureCreator.CreateBuilder() + .WithBasicInfo("my-mod:sofa-chair", "Sofa Chair", "A compact upholstered chair.") + .WithModel(model) + .WithPlacement(FurniturePlacementMode.Grid) + .WithFootprint(2, 2) + .WithBuildSound(BuildSoundType.Wood) + .WithPricing(175f, 0.5f) + .WithStackLimit(4) + .WithGeneratedIcon() + .Build(); +``` + +Grid footprint cells are 0.5 metres. Size the footprint to cover the model's horizontal bounds; +for example, a model just under one metre wide and deep uses `WithFootprint(2, 2)`. + +## Native furniture variants + +Use `CloneFrom` when a variant should reuse an ordinary native furniture model. S1API accepts only +donors whose placed prefab uses the exact native `GridItem` or `SurfaceItem` type. Machines, +stations, storage, toggleable objects, and other specialized subclasses are rejected because a +presentation clone cannot preserve their runtime behavior. + +```csharp +var blueClock = FurnitureCreator.CloneFrom("grandfatherclock") + .WithBasicInfo( + "my-mod:blue-grandfather-clock", + "Blue Grandfather Clock", + "A grandfather clock with a blue finish.") + .ConfigureModel(model => + { + foreach (Renderer renderer in model.GetComponentsInChildren(true)) + { + foreach (Material material in renderer.sharedMaterials) + { + if (material != null && material.HasProperty("_BaseColor")) + material.SetColor("_BaseColor", new Color(0.08f, 0.2f, 0.65f)); + if (material != null && material.HasProperty("_Color")) + material.SetColor("_Color", new Color(0.08f, 0.2f, 0.65f)); + } + } + }) + .WithPricing(250f) + .WithGeneratedIcon() + .Build(); +``` + +`ConfigureModel` runs once against a builder-owned hierarchy. S1API has already replaced every +renderer material with a private instance, so material edits cannot change the donor or other +native furniture. The final placed, stored, ghost, and icon representations also receive separate +material instances. + +The clone path preserves the donor's exact grid cells or surface flags, rotation setting, build +sound, price, resale multiplier, stack limit, and icon fallback. Any corresponding builder method +overrides that default. The variant must use a new stable ID; `Build()` rejects the donor ID even if +only its casing differs. + +## Placement ghost + +Furniture created with `FurnitureCreator` does not need separate ghost setup. `WithModel(model)` +uses the supplied model for the placed object, stored item, generated icon, and placement ghost. +When the native placement system creates a ghost, S1API clones that model into it and prepares the +clone as a non-interactive placement visual. + +`WithGhostVisual(...)` belongs to the lower-level `BuildableItemDefinitionBuilder` path. Use it when +cloning a native non-furniture buildable, such as a machine or station, whose inherited ghost should +show a custom model. See [Custom ghosts for cloned buildables](item-builder-reference.md#custom-ghosts-for-cloned-buildables) +for the complete pattern. + +Schedule One exposes native cardboard, wood, and metal placement sounds. `BuildSoundType.Plastic` +uses the native metal sound as its compatibility fallback. + +## Surface furniture + +Use surface placement for wall or roof-mounted decorations: + +```csharp +var wallSign = FurnitureCreator.CreateBuilder() + .WithBasicInfo("my-mod:wall-sign", "Wall Sign", "A placeable wall sign.") + .WithModel(signModel) + .WithPlacement(FurniturePlacementMode.Surface) + .WithSurfacePlacement(FurnitureSurfaceType.Wall, allowRotation: true) + .WithBuildSound(BuildSoundType.Wood) + .WithPricing(45f) + .WithGeneratedIcon() + .Build(); +``` + +`FurnitureSurfaceType.All` accepts both walls and roofs. `WithFootprint` applies only to grid +furniture. + +## Registration and multiplayer + +Build furniture after the vanilla item registry is initialized. The builder deliberately fails with +a clear error if its verified native template is not available yet. S1API retains the resulting +definition across scene transitions. + +Every multiplayer peer must load the same mod version and register the same stable item ID, model, +placement mode, and footprint. Placement authority, observer initialization, late joins, and +property save/load then travel through the game's native grid or surface item flow. + +Native variants also require every peer to register the same donor ID and apply the same +deterministic `ConfigureModel` changes before save restoration or placement. S1API does not send +models or materials over the network. + +The generated icon path is the default. Furniture still registers during pre-load with a temporary +fallback icon: the donor icon for native variants or the generic template icon for supplied models. +S1API replaces that icon after the gameplay rendering rig is ready and refreshes bound +inventory/shop UI. Call `WithIcon(sprite)` when an art-directed icon is preferred. + +## Placement scope + +The initial API supports ordinary floor-grid furniture and wall/roof surface furniture. S1API does +not expose procedural-grid placement yet because the current game provides no generic serialized +procedural template that can be composed without inheriting gameplay-specific behavior. This keeps +the public enum honest and leaves room for an additive placement family later. + +For a complete embedded-GLB mod, see the +[FurnitureMod example](https://github.com/ifBars/S1FurnitureMod). diff --git a/S1API/docs/generic-custom-products.md b/S1API/docs/generic-custom-products.md index 651a252e..8c062f0c 100644 --- a/S1API/docs/generic-custom-products.md +++ b/S1API/docs/generic-custom-products.md @@ -86,7 +86,7 @@ appropriate, or explicitly select `WithNativeMixerMap(...)` for a logical kind that has no base-game enum. Neither option changes the logical kind's identity. To give the logical kind a Product Manager section, separately register -[`ProductKindMetadata`](product-kinds.md#register-presentation-and-product-manager-metadata). +[`ProductKindMetadata`](product-kinds.md#add-optional-presentation-and-product-manager-metadata). This catalog metadata does not change definition construction, discovery, or listing. diff --git a/S1API/docs/interaction-prompts.md b/S1API/docs/interaction-prompts.md new file mode 100644 index 00000000..50c20158 --- /dev/null +++ b/S1API/docs/interaction-prompts.md @@ -0,0 +1,63 @@ +# Native Interaction Prompts + +`InteractionPrompt` attaches the game's native interaction prompt to a mod-owned `GameObject`. +The game remains responsible for raycasts, input-device glyphs, prompt rendering, overlap priority, +and the interaction lifecycle. + +```csharp +private InteractionPrompt? _prompt; + +void ConfigureMachine(GameObject machine, Collider interactionCollider) +{ + _prompt = InteractionPrompt + .CreateBuilder(machine) + .WithMessage("Start mixer") + .WithDisplayLocation(interactionCollider) + .WithRange(3f) + .WithPriority(5) + .OnInteractionStarted(StartMixer) + .OnInteractionEnded(StopHoldingMixer) + .Build(); +} +``` + +The target hierarchy must contain a collider on a layer included by the game's interaction search +mask. S1API does not create a collider or change the target's layer. A collider supplied through +`WithDisplayLocation(Collider)` controls only where the prompt is rendered; it is not a replacement +for the target's interaction collider. + +## Prompt configuration + +The builder supports the native input actions `Interact` and `PrimaryClick`, the native states +`Default`, `Invalid`, `Disabled`, and `Label`, a maximum range of four metres, overlap priority, +and an optional horizontal angle limit. The four-metre limit comes from the game's interaction +raycast, so a larger per-component value would not make the prompt reachable. + +If neither display location overload is used, the prompt renders at the target transform. A +transform uses its position directly. A collider uses the closest point to the player, which is +useful for large machines and furniture. + +## Runtime updates and callbacks + +The returned handle can update the message, input, state, range, priority, angle limit, and display +location: + +```csharp +_prompt + ?.SetState(canUse + ? InteractionPromptState.Default + : InteractionPromptState.Invalid) + .SetMessage(canUse ? "Start mixer" : "Mixer is busy"); +``` + +`OnHovered` follows the native event and may run every frame while the prompt is selected. +`OnInteractionStarted` and `OnInteractionEnded` follow the game's input-hold lifecycle. These +callbacks do not provide multiplayer authorization or synchronization. The mod must validate the +request and use its own network policy before changing shared state. + +Call `Remove()` or `Dispose()` when the mod-owned object is retired. Removing the prompt destroys +only the S1API-owned native component and leaves the target, colliders, and other components intact. + +The builder rejects targets that already contain an `InteractableObject`; configure an existing +native interaction component directly when a prefab already owns one or use a separate child +target for an additional prompt. diff --git a/S1API/docs/item-builder-reference.md b/S1API/docs/item-builder-reference.md index cbd2efee..c3f5b04e 100644 --- a/S1API/docs/item-builder-reference.md +++ b/S1API/docs/item-builder-reference.md @@ -25,6 +25,53 @@ This page collects the main builder methods, advanced item-instance notes, and i - `WithUseCallback(callback)` - Registers a callback when the item is used - `Build()` - Finalizes and returns the equippable +## FurnitureDefinitionBuilder Methods + +- `FurnitureCreator.CloneFrom(donor)` - Starts a presentation-only variant from native grid or surface furniture +- `WithBasicInfo(id, name, description)` - Sets the stable ID and player-facing text +- `WithModel(model)` - Supplies the model cloned into all native furniture representations +- `ConfigureModel(callback)` - Modifies the isolated model owned by a `CloneFrom` builder +- `WithPlacement(mode)` - Selects grid or surface placement +- `WithFootprint(width, depth)` - Sets a grid footprint in 0.5 metre tiles +- `WithSurfacePlacement(types, allowRotation)` - Selects wall/roof compatibility +- `WithBuildSound(soundType)` - Selects the native completion sound; plastic furniture uses the metal fallback +- `WithPricing(basePrice, resellMultiplier)` - Configures economic properties +- `WithStackLimit(limit)` - Sets the inventory stack limit +- `WithIcon(sprite)` / `WithGeneratedIcon(resolution)` - Configures the inventory icon +- `Build()` - Composes the native prefabs, registers, and returns the furniture definition + +## Custom ghosts for cloned buildables + +Use `WithGhostVisual(visualFactory, replaceExistingVisual)` when a buildable cloned from a native +machine or station needs a different placement model. Furniture created through `FurnitureCreator` +does not call this method: `WithModel(...)` automatically supplies its placed, stored, icon, and +ghost visuals. + +```csharp +GameObject ghostModel = LoadMachineModel(); +ghostModel.SetActive(false); + +var machine = BuildableItemCreator.CloneFrom("brickpress") + .WithBasicInfo( + "my-mod:tablet-press", + "Tablet Press", + "A compact manual tablet press.", + ItemCategory.Equipment) + .WithGhostVisual( + parent => Object.Instantiate(ghostModel, parent, false), + replaceExistingVisual: true) + .Build(); +``` + +The factory runs on Unity's main thread whenever the native grid, procedural-grid, or surface +placement system creates a ghost. It must create and return a fresh `GameObject`; do not return the +shared source object. S1API parents the result when necessary, activates it, and disables its +colliders, navigation, networking, canvases, and lights so it behaves as a placement visual. + +Set `replaceExistingVisual: true` when the custom visual replaces the cloned native model. Buildables +that do not call this method retain the game's normal ghost behavior. If the factory throws or +returns `null`, S1API removes the partial visual and restores any inherited renderers it hid. + ## Advanced: Custom Item Instances For items with custom runtime state, such as extra fields that must serialize, you will need to: @@ -45,6 +92,7 @@ For items with custom runtime state, such as extra fields that must serialize, y ## See Also - [Item Registration & Basics](item-registration-basics.md) +- [Custom Furniture](furniture-items.md) - [Runtime Additives](runtime-additives.md) - [Equippable Items](equippable-items.md) - [Avatar Equippable Prefabs](avatar-equippable-prefabs.md) diff --git a/S1API/docs/items.md b/S1API/docs/items.md index cb3443e5..e79792b2 100644 --- a/S1API/docs/items.md +++ b/S1API/docs/items.md @@ -7,6 +7,7 @@ S1API provides a comprehensive item system for Schedule One, including standard The Items system allows you to: - Create standard `StorableItemDefinition` instances with the Creator API or Builder API +- Create native grid and surface furniture directly from a mod-provided model - Register runtime additives with custom effects - Attach equippable behavior and viewmodels to items - Add icons from embedded resources or AssetBundles @@ -22,6 +23,7 @@ The Items system is documented across multiple focused pages: - **[Item Registration & Basics](item-registration-basics.md)** - Registration timing, Creator API, Builder API, categories, and common setup tips - **[Runtime Additives](runtime-additives.md)** - Creating additive definitions and allowing them on grow containers - **[Item Icons](item-icons.md)** - Loading item icons from embedded resources and AssetBundles +- **[Custom Furniture](furniture-items.md)** - Composing native placeable furniture from a model or embedded GLB ### Equippables - **[Equippable Items](equippable-items.md)** - Basic equippables, viewmodels, use callbacks, and custom equippable behaviors @@ -74,6 +76,7 @@ public class MyMod : MelonMod - Start here: **[Item Registration & Basics](item-registration-basics.md)** - Then: **[Equippable Items](equippable-items.md)** if the item can be held or used +- For placeable models: **[Custom Furniture](furniture-items.md)** - As needed: **[Runtime Additives](runtime-additives.md)**, **[Item Icons](item-icons.md)**, **[Avatar Equippable Prefabs](avatar-equippable-prefabs.md)** ## Related Systems diff --git a/S1API/docs/jukebox.md b/S1API/docs/jukebox.md new file mode 100644 index 00000000..b4663fce --- /dev/null +++ b/S1API/docs/jukebox.md @@ -0,0 +1,24 @@ +# Jukeboxes + +`S1API.Audio` provides managed access to placed native jukeboxes. Use `JukeboxManager.GetAll()` to discover active jukeboxes, or `Jukebox.FromGameObject()` when a placement or interaction already provides the object. + +```csharp +using S1API.Audio; + +Jukebox? jukebox = Jukebox.FromGameObject(placedObject); +if (jukebox == null) + return; + +foreach (JukeboxTrack track in jukebox.Tracks) + MelonLoader.MelonLogger.Msg($"{track.Index}: {track.Name} by {track.Artist}"); + +jukebox.SelectTrack(0); +jukebox.TogglePlay(); +jukebox.SetVolume(4); +``` + +`Jukebox.State` and `OnStateChanged` provide immutable managed state snapshots. The wrapper exposes only tracks already configured by the game. It does not register custom audio or create new music network payloads. + +Pass an index obtained from `Jukebox.Tracks` to `SelectTrack`. An index that does not identify a configured track throws `ArgumentOutOfRangeException` before native state is changed. `SetVolume` and `ChangeVolume` retain the game's native 0-through-8 clamping behavior. + +All controls call the native jukebox methods. The game's authority, replication, synchronization, and save behavior remain in effect. Test host and client behavior for the game version and runtime that a mod supports. diff --git a/S1API/docs/modules-overview.md b/S1API/docs/modules-overview.md index 8d8fb6d4..508bc762 100644 --- a/S1API/docs/modules-overview.md +++ b/S1API/docs/modules-overview.md @@ -123,6 +123,18 @@ Script multi-stage phone conversations. ## World & Interaction +### Weather +**Namespace**: `S1API.Weather` + +Read the current weather-condition state, observe distinct weather changes, and inspect the known +weather sequence identifiers without exposing native game types. + +**Key Classes**: +- `WeatherManager` - Current state, change events, and sequence identifiers +- `WeatherState` - Immutable nine-component weather snapshot + +**Documentation**: [Weather](weather.md) + ### Map & Buildings **Namespace**: `S1API.Map` @@ -146,6 +158,7 @@ Vehicle spawning and management. - `LandVehicle` - Land vehicle wrapper - `VehicleRegistry` - Vehicle registration - `VehicleColor` - Color configuration +- `VehicleColorCatalog` - Native vehicle color metadata - `ParkingAlignment` - Parking helpers **Documentation**: In development diff --git a/S1API/docs/phone-app.md b/S1API/docs/phone-app.md index 17a275f3..e181c745 100644 --- a/S1API/docs/phone-app.md +++ b/S1API/docs/phone-app.md @@ -8,7 +8,30 @@ Apps integrate with the native Home Screen, spawn icons, and manage open/close s - Derive from `PhoneApp` - Do not manually register; S1API auto-discovers `PhoneApp` subclasses when the phone `HomeScreen` starts - Implement `OnCreatedUI(GameObject container)` to build your UI -- Optionally override `OnPhoneClosed()` and `Exit(ExitAction exit)` for UX +- Optionally override `OnPhoneClosed()` and `Exit(S1API.PhoneApp.ExitAction exit)` for UX + +`S1API.PhoneApp.ExitAction` is a cross-runtime wrapper. Its `Used` property is +forwarded to the active Mono or IL2CPP game action, so phone apps do not need to +reference either native Schedule One type. + +### Migrating from S1API 3.0.6 + +S1API 3.0.6 exposed `ScheduleOne.DevUtilities.ExitAction` directly. Schedule I +0.4.6f11 moved that native type and made the old signature impossible to retain. +Change phone-app overrides to use the S1API-owned wrapper: + +```csharp +public override void Exit(S1API.PhoneApp.ExitAction exit) +{ + if (!exit.Used) + { + exit.Used = true; + // Close or reset custom UI state here. + } +} +``` + +This is the only intentional public signature exception in the 3.1.0 promotion. ## Minimal example diff --git a/S1API/docs/prefab-configuration.md b/S1API/docs/prefab-configuration.md index 698f5f05..fb107793 100644 --- a/S1API/docs/prefab-configuration.md +++ b/S1API/docs/prefab-configuration.md @@ -28,6 +28,8 @@ The `ConfigurePrefab` method is called during NPC prefab creation and allows you **Important**: Customer, relationship, and schedule configuration must be done in `ConfigurePrefab` to ensure proper save/load behavior and network compatibility. +Declare fundamental roles with `IsCustomer`, `IsDealer`, and `IsSupplier` on the NPC type. S1API reads these properties from an uninitialized instance before `ConfigurePrefab`, so overrides must be stable, side-effect-free constants that do not depend on constructors or initialized fields. A dealer cannot also be a supplier, and every supplier must be physical. + ## NPCPrefabBuilder Methods ### WithIdentity @@ -162,18 +164,18 @@ builder.WithAppearanceDefaults(avatar => avatar.WithRandomImpostor(71, "Kyle", " If no impostor is configured, S1API preserves the existing custom NPC behavior. -### EnsureCustomer +### IsCustomer -Adds customer behavior component to the NPC. +Declares customer capability at the NPC type level. S1API adds the native customer component before `ConfigurePrefab` and network registration. ```csharp -builder.EnsureCustomer(); +public override bool IsCustomer => true; ``` **What it does:** - Adds the `Customer` component to the NPC - Enables customer behavior -- Required for `WithCustomerDefaults` to work +- Makes the infrastructure available to `WithCustomerDefaults` **Use when:** - NPC should act as a business customer @@ -469,7 +471,7 @@ protected override void ConfigurePrefab(NPCPrefabBuilder builder) } ``` -`WithSupplierDefaults(...)` ensures the supplier root automatically. `EnsureSupplier()` is also available when the default order limits, empty listings, and default messages are sufficient. +`IsSupplier` ensures the supplier root automatically. `WithSupplierDefaults(...)` adds optional order limits, listings, and messages; omit it when the native defaults are sufficient. Supplier delivery items must be registered and storable before prefab configuration runs. Order limits must be finite; the minimum must be non-negative, the maximum must be positive, and the maximum cannot be below the minimum. Keep the identity ID stable: S1API uses it for the supplier's persistent stash, shop, and delivery vehicle. @@ -569,7 +571,7 @@ plan.Add(new DriveToCarParkSpec { 1. **Set identity** (id, firstName, lastName) 2. **Set icon** (optional) 3. **Set spawn position** -4. **Choose one specialized role** (customer component, dealer root, or supplier root, if needed) +4. **Declare roles on the NPC type** (`IsCustomer`, `IsDealer`, and `IsSupplier`); customer can compose with either root, while dealer and supplier are mutually exclusive 5. **Configure role defaults** 6. **Set relationship defaults** 7. **Define schedule** (if physical NPC) @@ -577,6 +579,9 @@ plan.Add(new DriveToCarParkSpec { ### Complete Example ```csharp +public override bool IsPhysical => true; +public override bool IsCustomer => true; + protected override void ConfigurePrefab(NPCPrefabBuilder builder) { Vector3 shopPosition = new Vector3(-28.060f, 1.065f, 62.070f); @@ -588,7 +593,6 @@ protected override void ConfigurePrefab(NPCPrefabBuilder builder) lastName: "Shopkeeper") .WithIcon(null) .WithSpawnPosition(spawnPosition) - .EnsureCustomer() .WithCustomerDefaults(cd => { cd.WithSpending(200f, 800f) .WithOrdersPerWeek(2, 5) @@ -634,7 +638,7 @@ protected override void ConfigurePrefab(NPCPrefabBuilder builder) - **Don't modify customer, relationship, or schedule data at runtime** (except through proper APIs) - **Don't spawn NPCs in inaccessible locations** - **Don't use invalid GUIDs** for buildings, vehicles, or machines -- **Don't forget to call `EnsureCustomer()`** before `WithCustomerDefaults()` +- **Don't make role properties depend on constructor state**; S1API reads them from an uninitialized instance - **Don't mark one NPC as both a dealer and a supplier** - **Don't configure a supplier as non-physical** - **Don't assign native supplier scene objects**; use the S1API wrappers and hidden runtime integration @@ -644,13 +648,14 @@ protected override void ConfigurePrefab(NPCPrefabBuilder builder) Wrap configuration code in try-catch blocks: ```csharp +public override bool IsCustomer => true; + protected override void ConfigurePrefab(NPCPrefabBuilder builder) { try { // Configuration code here builder.WithSpawnPosition(spawnPos) - .EnsureCustomer() .WithCustomerDefaults(cd => { // Customer configuration }); diff --git a/S1API/docs/presentation-workbench.md b/S1API/docs/presentation-workbench.md index e5fcd0fd..061f7da3 100644 --- a/S1API/docs/presentation-workbench.md +++ b/S1API/docs/presentation-workbench.md @@ -37,16 +37,16 @@ Open the native developer console after the local player has spawned. The explicit forms are: ```text -presentation_workbench product example.mod:products/focus-tablet -presentation_workbench item example.mod:items/storage-pallet -presentation_workbench close +presentationworkbench product example.mod:products/focus-tablet +presentationworkbench item example.mod:items/storage-pallet +presentationworkbench close ``` For convenience, omit the target kind to resolve a value in product, then item order: ```text -presentation_workbench example.mod:products/focus-tablet +presentationworkbench example.mod:products/focus-tablet ``` A consuming mod does not need initialization code for the workbench. If the diff --git a/S1API/docs/product-kinds.md b/S1API/docs/product-kinds.md index 0bd549ad..d9ad77b4 100644 --- a/S1API/docs/product-kinds.md +++ b/S1API/docs/product-kinds.md @@ -39,7 +39,7 @@ Building the same case-insensitive ID with the same compatibility metadata retur This makes per-load setup calls safe when they repeat an equivalent registration while rejecting two mods that claim the same logical ID differently. -## Register presentation and Product Manager metadata +## Add optional presentation and Product Manager metadata Product-kind identity and UI registration are separate. Register immutable metadata only when the kind needs a user-facing name, color, search aliases, or @@ -119,7 +119,7 @@ discover or list a definition. ## Keep definition and catalog actions explicit -These operations remain separate: +Keep these operations separate: 1. `ProductKindBuilder.Build()` registers logical identity. 2. `ProductKindMetadataBuilder.Build()` registers optional presentation and diff --git a/S1API/docs/products-api.md b/S1API/docs/products-api.md index 8eb671f2..ffb2c8e2 100644 --- a/S1API/docs/products-api.md +++ b/S1API/docs/products-api.md @@ -1,8 +1,10 @@ # Products API -This page documents the API surface in `S1API/Products/` (definitions, instances, quality, and packaging). +Use this page to inspect existing product definitions, create item instances, +work with properties and packaging, or register effect callbacks. -If you want customer preference configuration, see `S1API/docs/products-system.md`. +For a task-based map of the product APIs, see [Products system](products-system.md). +Customer preferences belong in [Customer behavior](customer-behavior.md). ## Key types @@ -32,7 +34,7 @@ For creation and lifecycle guidance, see [Logical Product Kinds](product-kinds.m ## Getting product definitions -### From the current save (discovered products) +### From the current save `ProductManager.DiscoveredProducts` returns product definitions discovered on the current save. @@ -47,7 +49,7 @@ foreach (var product in ProductManager.DiscoveredProducts) } ``` -### By item ID +### From an item ID Products are also item definitions, so you can look them up by item ID. @@ -118,7 +120,7 @@ compatible, and therefore require conditional compilation in cross-runtime mods. `DrugType.MDMA` and `DrugType.Heroin` mirror values present in the native enum. Their presence does not mean that every native product system supports those types. -## Overriding product effect behavior with callbacks +## Product effect callbacks You can register callbacks for both player and NPC product effects. @@ -252,7 +254,7 @@ save/load, reconnect, or late join. If an apply callback fails, S1API immediatel callback and permits the next native apply lifecycle call to retry. `TargetId` is the native stable player code for players and the native NPC ID for NPCs; it is not a display name. -## Creating product instances +## Create product instances ### Unpackaged @@ -310,8 +312,8 @@ void Log(ProductInstance inst) } ``` -## See Also +## See also -- `S1API/docs/products-system.md` (customer preferences and properties) -- `S1API/docs/products-populator.md` (filling storages with products) +- [Products system](products-system.md) +- [ProductPopulator](products-populator.md) - (API reference) diff --git a/S1API/docs/products-populator.md b/S1API/docs/products-populator.md index 24747eab..e8445cd6 100644 --- a/S1API/docs/products-populator.md +++ b/S1API/docs/products-populator.md @@ -1,91 +1,68 @@ -# ProductPopulator (Storage Helpers) +# ProductPopulator -`S1API.Products.ProductPopulator` contains convenience helpers for creating product instances (optionally packaged) and adding them to a `S1API.Storages.StorageInstance`. +`ProductPopulator` creates product instances and adds them to a +`S1API.Storages.StorageInstance`. Use it for scripted rewards, test setups, or +stock that your mod owns. It does not register products, discover them, or add +them to shops. -This is mainly useful for: - -- shop/vendor inventories -- debug/testing -- scripted rewards and stashes - -## Get packaging by ID +## Resolve packaging ```csharp using S1API.Products; -var jar = ProductPopulator.GetPackaging("jar"); -var baggie = ProductPopulator.GetPackaging("baggie"); +PackagingDefinition? jar = ProductPopulator.GetPackaging("jar"); ``` -Common IDs depend on the base game (examples mentioned in code include: `baggie`, `jar`, `brick`). +Packaging IDs come from the installed game. Check for `null` before using one. -## Enumerate discovered products +## Read discovered products -`ProductPopulator.GetAllProductDefinitions()` reads `ProductManager.DiscoveredProducts` and returns the discovered product definitions for the current save. +`GetAllProductDefinitions()` returns products discovered in the current save. +It returns an empty collection until that save has discovered products. ```csharp +using System.Collections.Generic; using S1API.Products; -var defs = ProductPopulator.GetAllProductDefinitions(); +IReadOnlyList products = + ProductPopulator.GetAllProductDefinitions(); ``` -There are also typed filters: - -- `GetWeedDefinitions()` -- `GetMethDefinitions()` -- `GetCocaineDefinitions()` -- `GetShroomDefinitions()` +Use `GetWeedDefinitions()`, `GetMethDefinitions()`, `GetCocaineDefinitions()`, +or `GetShroomDefinitions()` when the native family matters. ## Create a packaged instance ```csharp -using S1API.Products; +PackagingDefinition? packaging = ProductPopulator.GetPackaging("jar"); +IReadOnlyList products = + ProductPopulator.GetAllProductDefinitions(); -var packaging = ProductPopulator.GetPackaging("jar"); -if (packaging != null) +if (packaging != null && products.Count > 0) { - var productDef = ProductPopulator.GetAllProductDefinitions()[0]; - var inst = ProductPopulator.CreatePackagedProduct(productDef, packaging, quantity: 20); + ProductInstance? instance = ProductPopulator.CreatePackagedProduct( + products[0], packaging, quantity: 20); } ``` -## Populate a storage - -### From a StorageInstance - -```csharp -using S1API.Products; -using S1API.Storages; - -int added = ProductPopulator.PopulateWithPackagedProducts(storage, packagingId: "jar", quantityPerItem: 20); -``` - -### From a GameObject - -If you have a `GameObject` containing a storage entity (or in children), you can populate it directly: - -```csharp -using S1API.Products; - -int added = ProductPopulator.PopulateFromGameObject(someGameObject, packagingId: "jar", quantityPerItem: 20); -``` +## Populate storage -### Specific product IDs +In this example, `storage` already refers to the target +`S1API.Storages.StorageInstance`. ```csharp -using S1API.Products; - -var ids = new System.Collections.Generic.List { "weed", "cocaine" }; -int added = ProductPopulator.PopulateWithSpecificPackagedProducts(storage, ids, packagingId: "baggie", quantityPerProduct: 5); +int added = ProductPopulator.PopulateWithPackagedProducts( + storage, + packagingId: "jar", + quantityPerItem: 20); ``` -## Notes - -- `ProductManager.DiscoveredProducts` is save-dependent; if nothing is discovered yet, populators that rely on it will add nothing. -- The helper methods log a lot via `UnityEngine.Debug` (intended for debugging). +Use `PopulateFromGameObject(...)` when you have a game object that contains a +storage entity. Use `PopulateWithSpecificPackagedProducts(...)` when the mod +owns an explicit list of product IDs. -## See Also +## See also -- `S1API/docs/products-api.md` +- [Products API](products-api.md) +- [Generic custom products](generic-custom-products.md) - -- diff --git a/S1API/docs/products-system.md b/S1API/docs/products-system.md index 7d1ba2f5..b3b30ece 100644 --- a/S1API/docs/products-system.md +++ b/S1API/docs/products-system.md @@ -1,421 +1,79 @@ -# Products System +# Products system -S1API provides a comprehensive system for working with products (drugs, goods) in Schedule One, including product definitions, properties, and market dynamics. +Use the product APIs to work with existing products or register fixed, +mod-owned product definitions. S1API keeps product identity, save data, and +network behavior explicit so a visual or catalog addition does not silently +change gameplay state. -## Overview +## Choose the product path -The Products system allows you to: -- Access existing product definitions (Weed, Cocaine, Meth, etc.) -- Create marijuana-family weed variants through the native product lifecycle -- Work with product property tokens such as `Munchies`, `Energizing`, and `Cyclopean` -- Retrieve product information and pricing -- Integrate products with dealers and customers +- **Read or create instances of an existing product:** start with + [Products API](products-api.md). +- **Create a marijuana-family variant through the native game path:** use + [Native weed variants](weed-variants.md). +- **Create a fixed product outside the native drug families:** use + [Generic custom products](generic-custom-products.md). +- **Group mod-owned products under a durable logical ID:** use + [Logical product kinds](product-kinds.md). +- **Fill a storage, reward, or test inventory with product instances:** use + [ProductPopulator](products-populator.md). +- **Register runtime additives for growing:** use + [Runtime additives](runtime-additives.md). -## Product Definitions +## Product concepts -Products in Schedule One are represented by `ProductDefinition` wrappers that provide access to the game's internal product system. +`ProductDefinition` describes a product type. `ProductInstance` is one stack +or item of that product. A product definition can have properties, a price, +legal status, valid packaging, and presentation data. -### Accessing Products +Use `ProductManager.DiscoveredProducts` only for products discovered in the +current save. A custom definition is registered separately, then discovery and +listing are explicit host-side actions after loading. -Product definitions are discovered per-save. Use `ProductManager.DiscoveredProducts` to enumerate what's available: +## Registration order -```csharp -using S1API.Products; +Register custom product kinds, profiles, and definitions during +`GameLifecycle.OnPreLoad`. Register the same stable IDs on every peer before +native save restoration. Defer discovery, Product Manager listing, and shop +inventory changes until `GameLifecycle.OnLoadComplete`. -foreach (var product in ProductManager.DiscoveredProducts) -{ - MelonLoader.MelonLogger.Msg($"{product.ID}: {product.Name} (${product.MarketValue})"); -} -``` +Do not use a display name as an ID. Published product and product-kind IDs are +durable, namespaced, and case-insensitive. -If you already know an item ID, you can resolve it via `ItemManager` and cast to `ProductDefinition`. +## Presentation and packaging -To create a weed variant with native registration, saves, and multiplayer -replication, see [Native Weed Variants](weed-variants.md). Other custom product -families are not currently exposed by this builder. +Generic products borrow the game's interaction scaffolding from a native +representation template. Add a `ProductPresentationProfile` only when the +product needs mod-owned loose, held, station, functional-product, or icon +visuals. Add a packaging-content profile when a filled baggie, jar, or brick +needs mod-owned contents. -### Drug Types +The [presentation workbench](presentation-workbench.md) is a local authoring +tool for registered product and item visuals. It does not persist edits or +transfer assets between peers. -S1API exposes an API-safe `S1API.Products.DrugType` enum for use in affinities: +## Product Manager and shops -```csharp -using S1API.Products; +`ProductKind` establishes logical identity. `ProductKindMetadata` optionally +adds a display name, color, icon, aliases, and an S1API-managed Product Manager +section. It does not create a definition, discover a product, list it, or add +it to a shop. -// Mirrors the base game's drug types -public enum DrugType -{ - Marijuana, - Methamphetamine, - Cocaine, - MDMA, - Shrooms, - Heroin -} -``` +Discovery, listing, and shop stock are independent actions. This lets a mod +register a product before save loading without forcing it into a player's +catalog or a vendor inventory. -## Product Properties +## Customer preferences -Products have effect properties that affect value, customer preferences, and callbacks. S1API exposes these as `PropertyBase` tokens so mods do not need to reference runtime-specific game effect types directly. +Customers use `DrugType`, property tokens, affinities, and standards when they +choose orders. Configure that behavior through the NPC builders in +[Customer behavior](customer-behavior.md), not through product registration. -### Common Properties +## Read next -```csharp -using S1API.Properties; -using S1API.Products; - -// Properties are accessed through the Property class -Property.Munchies -Property.Energizing -Property.Cyclopean -Property.Calming -Property.Euphoric -// ... many more -``` - -### Working with Properties - -```csharp -using S1API.Properties; -using S1API.Products; - -// Get a product instance -var weedProduct = ProductDefinition.GetByType(DrugType.Marijuana); - -if (weedProduct != null) -{ - // Properties are accessed through the product's internal system - // You'll typically use properties when configuring customers - - // Example: Customer preferences for properties - .WithPreferredProperties(Property.Munchies, Property.Energizing, Property.Cyclopean) -} -``` - -## Product Instances - -`ProductInstance` represents an actual instance of a product with specific properties: - -```csharp -using S1API.Products; - -// Product instances are typically created/managed by the game -// Access them through game systems or events - -public void HandleProductSold(ProductInstance instance) -{ - if (instance != null) - { - var definition = instance.Definition; // Get the product definition - // Work with the specific product instance - } -} -``` - -## Using Products with Customers - -Products are most commonly used when configuring customer NPCs: - -```csharp -using S1API.Entities; -using S1API.Economy; -using S1API.Growing; -using S1API.Properties; - -.WithCustomerDefaults(cd => -{ - // Customer spending and order frequency - cd.WithSpending(minWeekly: 500f, maxWeekly: 2000f) - .WithOrdersPerWeek(2, 5) - - // Drug preferences and affinities - .WithAffinities(new[] - { - (DrugType.Marijuana, 0.45f), // Likes weed - (DrugType.Cocaine, -0.2f), // Dislikes cocaine - (DrugType.Methamphetamine, 0.0f) // Neutral on meth - }) - - // Preferred product properties - .WithPreferredProperties( - Property.Munchies, - Property.Energizing, - Property.Cyclopean - ) - - // Quality standards - .WithStandards(CustomerStandard.High); -}); -``` - -## Product Properties Reference - -The current `Property` helper exposes these built-in tokens: - -- `Property.Munchies` -- `Property.AntiGravity` -- `Property.Energizing` -- `Property.Focused` -- `Property.Smelly` -- `Property.Euphoric` -- `Property.Cyclopean` -- `Property.Slippery` -- `Property.Shrinking` -- `Property.Seizure` -- `Property.Electrifying` -- `Property.Zombifying` -- `Property.Disorienting` -- `Property.Sedating` -- `Property.CalorieDense` -- `Property.TropicThunder` -- `Property.Toxic` -- `Property.ThoughtProvoking` -- `Property.Lethal` -- `Property.Calming` -- `Property.Schizophrenic` -- `Property.Spicy` -- `Property.Laxative` -- `Property.BrightEyed` -- `Property.Sneaky` -- `Property.Jennerising` -- `Property.Balding` -- `Property.Glowie` -- `Property.Refreshing` -- `Property.Athletic` -- `Property.LongFaced` -- `Property.Paranoia` -- `Property.Gingeritis` -- `Property.Foggy` -- `Property.Explosive` - -## Customer Affinities - -Customer affinities determine how much a customer likes or dislikes specific drugs: - -```csharp -// Affinity values range from -1.0 to 1.0 -.WithAffinities(new[] -{ - (DrugType.Marijuana, 0.8f), // Strongly prefers - (DrugType.Cocaine, 0.3f), // Somewhat likes - (DrugType.Methamphetamine, 0.0f), // Neutral - (DrugType.Heroin, -0.5f) // Dislikes -}) -``` - -- **Positive values (0.0 to 1.0)**: Customer likes this drug type -- **Negative values (-1.0 to 0.0)**: Customer dislikes this drug type -- **Zero (0.0)**: Customer is neutral - -## Customer Standards - -Quality standards determine what quality products a customer will accept: - -```csharp -public enum CustomerStandard -{ - VeryLow, // Accepts any quality - Low, // Accepts poor to good quality - Medium, // Accepts average to good quality - High, // Only accepts good to excellent quality - VeryHigh // Only accepts excellent quality -} - -// Usage -.WithCustomerDefaults(cd => -{ - cd.WithStandards(CustomerStandard.High); // Picky customer -}); -``` - -## Complete Customer Example - -Here's a complete example of an NPC customer with detailed product preferences: - -```csharp -using S1API.Entities; -using S1API.Economy; -using S1API.GameTime; -using S1API.Growing; -using S1API.Properties; -using UnityEngine; - -public sealed class SelectiveCustomer : NPC -{ - public override bool IsPhysical => true; - - protected override void ConfigurePrefab(NPCPrefabBuilder builder) - { - builder.WithIdentity("selective_customer", "Sarah", "Johnson") - .WithSpawnPosition(new Vector3(0, 0, 0)) - .WithAppearanceDefaults(av => - { - av.Gender = 1.0f; - av.Height = 0.95f; - }) - .EnsureCustomer() - .WithCustomerDefaults(cd => - { - // High spending, selective customer - cd.WithSpending(minWeekly: 800f, maxWeekly: 3000f) - .WithOrdersPerWeek(2, 4) - .WithPreferredOrderDay(Day.Friday) - .WithOrderTime(1800) // 6 PM - - // Quality conscious - .WithStandards(CustomerStandard.High) - .AllowDirectApproach(false) // Must be introduced - .GuaranteeFirstSample(true) - - // Relationship requirements - .WithMutualRelationRequirement(minAt50: 3.0f, maxAt100: 4.5f) - .WithCallPoliceChance(0.05f) // Low risk - - // Addiction profile - .WithDependence(baseAddiction: 0.2f, dependenceMultiplier: 1.2f) - - // Drug preferences - loves weed, dislikes hard drugs - .WithAffinities(new[] - { - (DrugType.Marijuana, 0.9f), // Strongly prefers - (DrugType.Cocaine, -0.6f), // Strongly dislikes - (DrugType.Methamphetamine, -0.8f) // Very much dislikes - }) - - // Property preferences - .WithPreferredProperties( - Property.Calming, - Property.Euphoric, - Property.Munchies - ); - }) - .WithRelationshipDefaults(r => - { - r.WithDelta(2.0f) - .SetUnlocked(false) - .SetUnlockType(NPCRelationship.UnlockType.Introduction); - }); - } - - protected override void OnCreated() - { - base.OnCreated(); - Appearance.Build(); - - Dialogue.BuildAndSetDatabase(db => - { - db.WithModuleEntry("Reactions", "GREETING", - "I only deal with quality products. No junk."); - }); - - Aggressiveness = 1f; - Region = Region.Downtown; - Schedule.Enable(); - } -} -``` - -## Best Practices - -1. **Balanced Affinities**: Don't make all affinities extreme - mix preferences for realistic customers - -2. **Property Consistency**: Match preferred property tokens with drug affinities. Use actual `S1API.Properties.Property` constants, not inferred product stats. - -3. **Quality Standards**: Match standards with spending levels - - High spenders → High/VeryHigh standards - - Low spenders → Low/Medium standards - -4. **Addiction Progression**: Use `WithDependence()` to create realistic addiction dynamics - -5. **Risk Assessment**: Balance `WithCallPoliceChance()` with customer value and relationship - -## Property Discovery - -To discover all available properties, you can enumerate them at runtime: - -```csharp -using S1API.Properties; -using S1API.Properties.Interfaces; -using System.Reflection; - -// Get all static Property fields -var propertyType = typeof(Property); -var properties = propertyType.GetFields( - BindingFlags.Public | BindingFlags.Static -); - -foreach (var field in properties) -{ - if (field.FieldType == typeof(PropertyBase)) - { - var prop = (PropertyBase)field.GetValue(null); - MelonLogger.Msg($"Property: {field.Name}"); - } -} -``` - -## Common Patterns - -### Creating a Weed Enthusiast - -```csharp -.WithCustomerDefaults(cd => -{ - cd.WithAffinities(new[] { (DrugType.Marijuana, 0.9f) }) - .WithPreferredProperties( - Property.Calming, - Property.Munchies, - Property.Euphoric - ) - .WithStandards(CustomerStandard.High); -}); -``` - -### Creating a Party Customer - -```csharp -.WithCustomerDefaults(cd => -{ - cd.WithAffinities(new[] - { - (DrugType.Cocaine, 0.7f), - (DrugType.Marijuana, 0.4f) - }) - .WithPreferredProperties( - Property.Energizing, - Property.Euphoric, - Property.Focused - ) - .WithStandards(CustomerStandard.Medium); -}); -``` - -### Creating a Desperate Customer - -```csharp -.WithCustomerDefaults(cd => -{ - cd.WithSpending(50f, 200f) // Low budget - .WithAffinities(new[] - { - (DrugType.Methamphetamine, 0.8f) - }) - .WithStandards(CustomerStandard.VeryLow) // Accepts anything - .WithDependence(0.8f, 1.5f); // Highly addicted -}); -``` - -## Technical Notes - -- Product definitions are wrappers around the game's internal product system -- Properties use a token-based system internally for cross-runtime compatibility -- Customer preferences are saved with the game's save system -- Addiction levels affect order frequency and spending over time - -## See Also - -- [Customer Behavior](customer-behavior.md) - Detailed customer configuration -- [Dealer System](dealer-system.md) - Creating dealers who distribute products -- [Custom NPCs](custom-npcs.md) - Core NPC creation -- [CustomNPCTest Example](https://github.com/ifBars/S1API/tree/main/CustomNPCTest) -- - Products API Reference -- - Properties API Reference +1. Read [Products API](products-api.md) for wrappers, instances, packaging, and + effect callbacks. +2. Choose [Native weed variants](weed-variants.md) or + [Generic custom products](generic-custom-products.md) for registration. +3. Add [Logical product kinds](product-kinds.md) only when another system needs + a durable category or Product Manager metadata. diff --git a/S1API/docs/runtime-additives.md b/S1API/docs/runtime-additives.md index 34bff6ec..93ac9ca2 100644 --- a/S1API/docs/runtime-additives.md +++ b/S1API/docs/runtime-additives.md @@ -1,97 +1,66 @@ -# Runtime Additives +# Runtime additives -Create runtime additives (`AdditiveDefinition`) through the additive builder API. +Create `AdditiveDefinition` instances with the additive builder. Definitions +are read-only after registration, so configure their effects before `Build()`. -## Important Notes +Register additives before save restoration, preferably in +`GameLifecycle.OnPreLoad`. An additive that registers later may not be +available to restored items or grow containers. -- `AdditiveDefinition` is builder-only and intentionally read-only after registration to avoid mid-session mutation issues -- Configure additive effects during build time -- For best results, register additives before save data loads -- Prefer `GameLifecycle.OnPreLoad` when possible - -## Example: Recommended Timing +## Register an additive ```csharp -using MelonLoader; using S1API.Items; using S1API.Lifecycle; -public class MyMod : MelonMod +GameLifecycle.OnPreLoad += () => { - public override void OnSceneWasLoaded(int buildIndex, string sceneName) - { - if (sceneName != "Main") - return; - - GameLifecycle.OnPreLoad += RegisterItems; - } - - private static void RegisterItems() - { - var growthBooster = AdditiveItemCreator.CreateBuilder() - .WithBasicInfo( - id: "mymod_growth_booster", - name: "Growth Booster", - description: "A custom growth enhancer additive.", - category: ItemCategory.Growing - ) - .WithStackLimit(10) - .WithPricing(basePurchasePrice: 150f, resellMultiplier: 0.5f) - .WithEffects( - yieldMultiplier: 1.5f, - instantGrowth: 0.5f, - qualityChange: 1.0f - ) - .Build(); - - MelonLogger.Msg($"Registered additive: {growthBooster.Name} ({growthBooster.ID})"); - } -} + AdditiveDefinition growthBooster = AdditiveItemCreator.CreateBuilder() + .WithBasicInfo( + id: "my-mod:growth-booster", + name: "Growth Booster", + description: "A custom growing additive.", + category: ItemCategory.Growing) + .WithStackLimit(10) + .WithPricing(basePurchasePrice: 150f, resellMultiplier: 0.5f) + .WithEffects( + yieldMultiplier: 1.5f, + instantGrowth: 0.5f, + qualityChange: 1f) + .Build(); +}; ``` -## Cloning an Existing Additive +## Clone a native additive ```csharp -var variant = AdditiveItemCreator.CloneFrom("pgr") - .WithBasicInfo("mymod_pgr_variant", "PGR Variant", "A tweaked PGR.", ItemCategory.Growing) - .WithEffects(1.25f, 0.25f, 0.0f) +AdditiveDefinition variant = AdditiveItemCreator.CloneFrom("pgr") + .WithBasicInfo( + "my-mod:pgr-variant", + "PGR Variant", + "A modified growing additive.", + ItemCategory.Growing) + .WithEffects(1.25f, 0.25f, 0f) .Build(); ``` -## Allowing Additives on Grow Containers - -Grow containers have a fixed additive allowlist (`GrowContainer.AllowedAdditives`). S1API can extend that allowlist globally so mods do not need to patch `GrowContainer.InitializeGridItem`. +## Allow an additive in grow containers -Notes: - -- Applies to all grow containers -- Duplicate `AllowAdditive(...)` calls are a no-op -- If an ID cannot be resolved to an `AdditiveDefinition` at runtime, S1API warns once and skips it +Grow containers use a global allowlist. Register the additive first, then add +its stable ID during `OnPreLoad`: ```csharp -using MelonLoader; using S1API.Growing; -using S1API.Lifecycle; -public class MyMod : MelonMod -{ - public override void OnSceneWasLoaded(int buildIndex, string sceneName) - { - if (sceneName != "Main") - return; - - GameLifecycle.OnPreLoad += () => - { - GrowContainerAdditives.AllowAdditive("mymod_growth_booster"); - }; - } -} +GameLifecycle.OnPreLoad += () => + GrowContainerAdditives.AllowAdditive("my-mod:growth-booster"); ``` -## See Also +Repeated calls for the same ID do nothing. If S1API cannot resolve the ID to an +`AdditiveDefinition`, it logs one warning and skips it. + +## See also -- [Item Registration & Basics](item-registration-basics.md) -- [Builder API Reference](item-builder-reference.md) +- [Item registration](item-registration-basics.md) +- [Item builder reference](item-builder-reference.md) - -- -- diff --git a/S1API/docs/supplier-system.md b/S1API/docs/supplier-system.md index 8eeae49e..2600b912 100644 --- a/S1API/docs/supplier-system.md +++ b/S1API/docs/supplier-system.md @@ -34,7 +34,7 @@ public sealed class WarehouseSupplier : NPC } ``` -`WithSupplierDefaults(...)` calls `EnsureSupplier()` for you. Call `EnsureSupplier()` directly only when you want the supplier role with default data. +`IsSupplier` is the role declaration. `WithSupplierDefaults(...)` only supplies optional order, listing, and message configuration; omit it when the native defaults are sufficient. The string overload of `WithDeliveryItem(...)` is declaration-order safe: S1API stores the stable ID during NPC prefab discovery and resolves it when supplier @@ -52,7 +52,7 @@ storable to appear in the supplier shop. You can also pass an already registered - A custom NPC cannot be both a supplier and a dealer. Choose one native root role per NPC type. - `WithOrderLimits(minimum, maximum)` requires finite values, `minimum >= 0`, `maximum > 0`, and `maximum >= minimum`. - Every delivery listing must reference a storable item. Register custom items before S1API configures the NPC prefab. -- Use a permanent, unique ID in `WithIdentity(...)`. S1API derives persistent supplier infrastructure from that ID, so changing it breaks continuity with existing saves. +- Use a permanent, unique ID in `WithIdentity(...)`. S1API derives persistent supplier infrastructure from that ID, so changing it breaks continuity with existing saves. When a released supplier must adopt a new runtime NPC ID, opt in to `WithPersistentId("old_supplier_id")` in the supplier defaults. The runtime NPC ID changes while the generated shop, delivery vehicle, and stash keep their former persistent identities. New suppliers should omit this migration-only option. - Configure supplier defaults in `ConfigurePrefab`, not in `OnCreated`. This keeps host, client, and saved-game prefab data consistent. S1API creates the native meeting action and supplier-owned stash, shop, and delivery vehicle behind the public API. Do not copy or assign native supplier scene objects yourself. diff --git a/S1API/docs/toc.yml b/S1API/docs/toc.yml index 2a98839b..d9de097f 100644 --- a/S1API/docs/toc.yml +++ b/S1API/docs/toc.yml @@ -16,6 +16,10 @@ items: - name: Module Overview href: modules-overview.md + - name: Weather + href: weather.md + - name: Jukeboxes + href: jukebox.md - name: NPCs items: - name: Overview @@ -56,6 +60,8 @@ href: quests-system.md - name: Map POIs href: map-pois.md + - name: Native Interaction Prompts + href: interaction-prompts.md - name: Cutscenes href: cutscenes.md - name: Products @@ -84,6 +90,8 @@ href: item-registration-basics.md - name: Item Icons href: item-icons.md + - name: Custom Furniture + href: furniture-items.md - name: Equippable Items href: equippable-items.md - name: Avatar Equippable Prefabs @@ -116,7 +124,9 @@ href: stations.md - name: UI href: ui.md - - name: Casino Slots + - name: Casino Games + href: casino-games.md + - name: NPC Casino Slots href: casino-slot-machines.md - name: Cartel href: cartel-system.md diff --git a/S1API/docs/weather.md b/S1API/docs/weather.md new file mode 100644 index 00000000..9781648d --- /dev/null +++ b/S1API/docs/weather.md @@ -0,0 +1,35 @@ +# Weather + +The `S1API.Weather` module exposes the active weather-condition weights and the weather-change +callback without exposing Schedule One or Unity types. + +```csharp +using S1API.Weather; + +WeatherManager.OnWeatherChanged += state => +{ + if (state.Rainy > 0.5f || state.Stormy > 0.5f) + { + // React to a rainy or stormy world. + } +}; + +WeatherState? current = WeatherManager.Current; +foreach (string sequenceId in WeatherManager.KnownSequenceIds) +{ + // Sequence identifiers retain the game's order and casing. +} +``` + +`WeatherManager.Current` is nullable outside gameplay and while the current Main scene's weather +manager is still initializing. S1API reads the active conditions as soon as they are available. +`OnWeatherChanged` receives an immutable `WeatherState` when weather first becomes available and +for each distinct later snapshot. It contains the nine native weights: `Sunny`, `Cloudy`, `Rainy`, +`Stormy`, `Snowy`, `Foggy`, `Windy`, `Hail`, and `Sleet`. Repeated identical snapshots are suppressed. + +`KnownSequenceIds` is a fresh, read-only managed snapshot. It preserves the configured sequence +order, exact identifier casing, and duplicate entries, and returns an empty list when the native +weather manager is unavailable. Native weather objects and collections are never returned. + +The first version is read-only. Selecting sequences, triggering thunder or lightning, custom weather +profiles, persistence, and custom network payloads are intentionally outside this API's scope. diff --git a/S1API/index.md b/S1API/index.md index 13caf77e..82c07782 100644 --- a/S1API/index.md +++ b/S1API/index.md @@ -70,7 +70,7 @@ _layout: landing

Items

-

Register custom storable, additive, buildable, clothing, and equippable item definitions.

+

Register custom storable, furniture, additive, clothing, and equippable item definitions.

Products

@@ -84,10 +84,6 @@ _layout: landing

Cutscenes

Play local camera-driven cinematics with cross-runtime cleanup, skip controls, fades, and title cards.

- -

Law enforcement

-

Use checkpoint, curfew, patrol, pursuit, and dispatch abstractions for police-oriented mods.

-
diff --git a/VERSIONING.md b/VERSIONING.md index 9a8bdeaf..98035bdf 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -5,11 +5,11 @@ This repository uses branch-based version maintenance so active development can ## Core Rules - `stable` always represents the next planned release line. -- Every shipped version gets its own maintenance branch named `releases/x.y.z`. -- Release branches only receive fixes that are safe for that shipped version. +- Every shipped version gets an immutable branch named `releases/x.y.z` that points to the same commit as its tag. +- A release branch may be pushed before publication as the pull-request branch for that version, but it becomes immutable once `vX.Y.Z` is tagged. - Breaking changes, refactors, and new feature work stay on `stable` unless they are intentionally backported. - Each public release is identified by a git tag. -- NuGet publishing automation only runs from `releases/x.y.z` branches, not from `stable`. +- Stable package publication is triggered by the exact `vX.Y.Z` tag, not by a branch push. ## Branch Roles @@ -23,13 +23,22 @@ This repository uses branch-based version maintenance so active development can ### `releases/x.y.z` -Each release branch preserves the source for one shipped version. +Each release branch preserves the source for exactly one shipped version. -- Create the branch immediately after publishing `x.y.z`. -- For an active maintenance line, work from the newest shipped patch branch in that line. -- Only put hotfixes, packaging fixes, and other low-risk corrections on that branch. -- Do not merge unrelated `stable` work into a release branch. -- If a fix starts on a release branch, cherry-pick it back to `stable` if the issue also exists there. +- Prepare `releases/X.Y.Z` from the intended stable base and use it as the PR head for that release. +- Merge the release PR into `stable`, then fast-forward `releases/X.Y.Z` to the resulting stable merge commit. +- Tag that exact shared commit as `vX.Y.Z`; from that point onward, do not add commits to the release branch. +- Prepare the next patch on a new branch such as `releases/X.Y.(Z+1)` instead of changing the previous version branch. +- Do not merge unrelated future `stable` work into a patch release candidate. + +### `beta` + +`beta` is the optional public-prerelease lane. + +- Synchronize it from the intended stable release base before starting a new prerelease series. +- Use versions such as `X.Y.Z-beta.N` and tags such as `vX.Y.Z-beta.N`. +- Beta builds use the beta game-assembly branches and publish only as GitHub prereleases. +- A stable patch does not need to pass through `beta` unless public beta validation is intentionally part of that release. ## Tag Format @@ -55,48 +64,51 @@ Patch numbers are ordinary integers, not single digits. That means `2.9.10` is t ### New release line -1. Finish the planned work on `stable`. -2. Publish the release as tag `vX.Y.Z`. -3. Branch from that exact release commit to `releases/X.Y.Z`. -4. Verify the branch name matches the `releases/x.y.z` convention exactly so GitHub automation can detect it. -5. Continue forward development on `stable` toward the next version. +1. Prepare the release changes and version bump on `releases/X.Y.Z`, based on the intended `stable` commit. +2. Add curated release notes at `.github/release-notes/X.Y.Z.md`, following the grouped format used by recent releases. +3. Open a PR from `releases/X.Y.Z` into `stable` and complete validation. +4. Merge the PR into `stable` with a merge commit. +5. Fast-forward `releases/X.Y.Z` to that exact stable merge commit. +6. Tag the shared commit as `vX.Y.Z` to publish the stable release. +7. Treat both the tag and release branch as immutable release records. +8. Continue forward development on `stable` toward the next version. ### Hotfix release for an existing line -1. Create a short-lived branch from `releases/X.Y.Z`, such as `hotfix/X.Y.Z/fix-name`. -2. Apply only the fixes intended for that shipped line. -3. Open a PR back into `releases/X.Y.Z` and merge it after validation. -4. Bump the version on that release branch to the next patch version by incrementing the patch number normally, such as `2.9.10` after `2.9.9`. -5. Tag the updated release branch as that new patch version, such as `v2.9.10`. -6. Create `releases/X.Y.(Z+1)` from that exact tagged commit so the newly shipped version has its own maintenance branch. -7. Cherry-pick the merged fix back to `stable` if it still applies there, or open a matching PR if adaptation is needed. +1. Create `releases/X.Y.(Z+1)` from the current stable tree when it still matches the shipped version, or from the `vX.Y.Z` tag when stable has unrelated future work. +2. Apply only the intended hotfixes, release-tooling changes, and the `X.Y.(Z+1)` version bump to that new release branch. +3. Add curated release notes at `.github/release-notes/X.Y.(Z+1).md` for the exact previous-tag comparison range. +4. Open a PR from `releases/X.Y.(Z+1)` into `stable` and merge it after validation. +5. Fast-forward `releases/X.Y.(Z+1)` to the resulting stable merge commit. +6. Tag that exact commit as `vX.Y.(Z+1)` to publish the new patch. +7. Leave `releases/X.Y.Z` and its tag unchanged as the immutable record of the previous release. -Using PRs for hotfixes keeps review history attached to the release line and improves GitHub auto-generated release notes by linking each fix to its PR and author. +Using the new version's branch as the PR head keeps review history attached to the release while ensuring every version branch continues to identify the assembly it shipped. ## Pull Request Guidance -- Prefer a dedicated hotfix branch and PR for each maintenance fix. +- Prefer the new `releases/X.Y.Z` branch and a narrowly scoped PR for each release candidate. - Keep hotfix PRs narrowly scoped so release notes stay easy to read. -- Merge hotfix PRs into `releases/x.y.z` before tagging the next patch release. -- Backport the merged change to `stable` with a cherry-pick when possible. -- If `stable` has diverged too far for a clean cherry-pick, use a separate PR into `stable` that references the release-branch PR. +- Merge release PRs into `stable` before tagging. +- Fast-forward the release branch to the stable merge commit before creating the tag. +- Never put a new version bump on a branch named for an already shipped version. ## NuGet Publishing -The NuGet package publish workflow is intentionally tied to release branches. +The NuGet package publish workflow uses the stable release tag as its authority. -- Automatic publish only runs for pushes to `releases/**`. -- Automatic publish only runs when `S1API/S1API.csproj` changes and the `` value changes. -- `workflow_dispatch` can be used to rerun the publish workflow manually, but it should be run from the relevant `releases/x.y.z` branch. -- A version bump on `stable` does not publish to NuGet. That is expected. -- If a release branch does not exist yet, create `releases/x.y.z` from the tagged release commit before expecting NuGet automation to run. +- Automatic publication runs for stable `vX.Y.Z` tag pushes. +- Prerelease tags such as `vX.Y.Z-beta.N` do not publish to NuGet. +- The workflow checks out the tag and requires `S1API/S1API.csproj` `` to exactly match it. +- `workflow_dispatch` can republish an existing stable tag when explicitly supplied. +- Branch creation, release-PR merges, and ordinary version bumps do not publish packages by themselves. ### Contributor checklist Before expecting a NuGet package to publish: -1. Confirm the shipped line has a matching `releases/x.y.z` branch. -2. Confirm the version change is being merged into that release branch, not only into `stable`. +1. Confirm `stable`, `releases/x.y.z`, and `vX.Y.Z` identify the same release commit. +2. Confirm the project and Melon versions match `X.Y.Z`. 3. Confirm the branch name uses `releases/`, not `release/`. 4. Confirm the publish workflow secrets are configured in GitHub. @@ -105,37 +117,33 @@ Before expecting a NuGet package to publish: The GitHub release workflow packages public mod archives and can publish the same release to mod distribution platforms. - `publish-github-release.yml` runs from release tags and can also be rerun with `workflow_dispatch`. +- When `.github/release-notes/X.Y.Z.md` exists at the tagged commit, its curated Markdown is used as the GitHub release body. Historical tags and manual reruns without that file fall back to GitHub-generated notes. +- Curated notes should use concise domain-specific change sections, a compatibility and validation section, PR-linked contributor credits, and release links, matching the structure of recent stable releases. - The GitHub/Nexus archive is `S1API-Forked-x.y.z.zip` and contains `Mods/` and `Plugins/` at the archive root. - GitHub Releases should only publish `S1API-Forked-x.y.z.zip` as a release asset. - The Thunderstore archive is `S1API-TS-x.y.z.zip` and contains `icon.png`, `README.md`, `manifest.json`, `Mods/`, and `Plugins/` at the archive root, but it is only used for Thunderstore publishing. - The uppercase `Mods/` and `Plugins/` paths are intentional so case-sensitive filesystems do not create parallel lowercase install folders. - The GitHub release asset is always uploaded by the workflow. -- Nexus Mods upload runs when `NEXUSMODS_API_KEY` and `NEXUSMODS_FILE_GROUP_ID` are configured. +- Nexus Mods upload runs when `NEXUSMODS_API_KEY`, `NEXUSMODS_FILE_GROUP_ID`, and `NEXUSMODS_MOD_ID` are configured. +- The workflow publishes versioned curated Markdown on GitHub when present and falls back to GitHub-generated notes only when it is absent. The Nexus Mods upload intentionally omits the optional changelog input because Nexus handles file versions and changelogs through separate endpoints, and a rejected changelog request would otherwise fail the workflow after a successful file upload. - Thunderstore upload runs when `THUNDERSTORE_TOKEN` is configured. - `workflow_dispatch` exposes `publish_nexus` and `publish_thunderstore` toggles for refreshing GitHub assets without re-publishing external platforms. ## Backporting Rules -- Prefer cherry-picking specific commits instead of merging branches. -- Backport only fixes that are relevant and low risk for the release line. -- Preserve the original commit message when possible so history stays easy to trace. -- If a cherry-pick needs adaptation because `stable` has diverged, keep the behavior equivalent and mention the branch-specific adjustment in the commit body or PR notes. - -## Current Example - -The current repository state follows this model: - -- `releases/2.9.9` holds the shipped `2.9.9` code line until the next patch in that line ships. -- If a hotfix release is published from that line, tag it as `v2.9.10` and then create `releases/2.9.10` from that exact release commit. -- `stable` is already moving toward `3.0.0`. -- Fixes that still matter to both lines can be cherry-picked between the two branches as needed. +- When `stable` contains unrelated future work, create the next release branch from the previous stable tag and cherry-pick only the relevant fixes. +- Preserve original commit messages when possible so history stays easy to trace. +- Merge the completed release branch back into `stable`; resolve divergence without pulling unrelated stable work into the release candidate. +- If a cherry-pick needs adaptation, keep the behavior equivalent and mention the branch-specific adjustment in the commit body or PR notes. -## History Snapshot +## Example Patch Flow -At the time this policy was written: +For a `3.1.3` hotfix after `3.1.2` has shipped: -- `stable` and `releases/2.9.9` diverge from `v2.9.9`. -- `stable` contains forward-looking `3.0.0` work that should not be merged wholesale into the `2.9.9` maintenance branch. -- `releases/2.9.9` contains hotfix-oriented commits suitable for selective cherry-picking. +1. Leave `releases/3.1.2` and `v3.1.2` unchanged. +2. Prepare the fix and version bump on `releases/3.1.3`. +3. Merge `releases/3.1.3` into `stable` after validation. +4. Fast-forward `releases/3.1.3` to the stable merge commit. +5. Tag that shared commit `v3.1.3` to publish it. -This keeps maintenance releases isolated while allowing ongoing development to move ahead without blocking urgent fixes. +This keeps maintenance work isolated without allowing a version branch to drift away from the artifact named by that branch. diff --git a/skills/schedule-one-custom-npcs/SKILL.md b/skills/schedule-one-custom-npcs/SKILL.md index dc53de06..19471da4 100644 --- a/skills/schedule-one-custom-npcs/SKILL.md +++ b/skills/schedule-one-custom-npcs/SKILL.md @@ -21,7 +21,7 @@ Follow this order: Keep these responsibilities separate: -- `ConfigurePrefab(...)`: identity, icon, spawn position, relationship defaults, customer defaults, dealer defaults, inventory defaults, schedule, and required `Ensure*` components. +- `ConfigurePrefab(...)`: identity, icon, spawn position, relationship defaults, customer defaults, dealer defaults, inventory defaults, schedule, and action-specific `Ensure*` calls such as `plan.EnsureDealSignal()`. Role infrastructure comes automatically from `IsCustomer`, `IsDealer`, and `IsSupplier`; do not add `EnsureCustomer()`, `EnsureDealer()`, or `EnsureSupplier()`. - `OnCreated()`: `base.OnCreated()`, `Appearance.Build()`, `Schedule.Enable()`, `Schedule.InitializeActions()` when needed, dialogue wiring, event subscriptions, text messages, and runtime state. Do not move persistent customer, dealer, relationship, or schedule defaults into runtime code. @@ -35,9 +35,9 @@ Do not move persistent customer, dealer, relationship, or schedule defaults into ### Customer vs dealer -- Customer NPCs need `EnsureCustomer()` before `WithCustomerDefaults(...)`. +- Customer NPCs declare `public override bool IsCustomer => true;`, then optionally use `WithCustomerDefaults(...)`. - Customer schedules usually need `plan.EnsureDealSignal()`. -- Dealer NPCs need `public override bool IsDealer => true;` plus `EnsureDealer()` and `WithDealerDefaults(...)`. +- Dealer NPCs declare `public override bool IsDealer => true;`, then optionally use `WithDealerDefaults(...)`. - Dealer schedules need `plan.EnsureDealSignal()` to function correctly, and may use `plan.HandleDeal(...)` when that better fits the role. ## Hard Rules @@ -149,7 +149,7 @@ When producing code or guidance, include: ## Common Pitfalls - Setting appearance defaults but forgetting `Appearance.Build()`. -- Calling `WithCustomerDefaults(...)` without `EnsureCustomer()`. +- Making `IsCustomer`, `IsDealer`, or `IsSupplier` depend on constructor or initialized field state. - Calling `WithDealerDefaults(...)` without `IsDealer => true`. - Omitting `EnsureDealSignal()` for customer or dealer schedules that need deals/contracts. - Using advanced location-based actions without the matching `Ensure*` call. diff --git a/skills/schedule-one-custom-npcs/references/example-project-patterns.md b/skills/schedule-one-custom-npcs/references/example-project-patterns.md index 491723ef..433df210 100644 --- a/skills/schedule-one-custom-npcs/references/example-project-patterns.md +++ b/skills/schedule-one-custom-npcs/references/example-project-patterns.md @@ -11,10 +11,12 @@ Use when the NPC is visible in the world, directly interactable, and participate Recommended structure: ```csharp +public override bool IsPhysical => true; +public override bool IsCustomer => true; + builder.WithIdentity(...) .WithAppearanceDefaults(...) .WithSpawnPosition(...) - .EnsureCustomer() .WithCustomerDefaults(...) .WithRelationshipDefaults(...) .WithSchedule(plan => @@ -35,7 +37,7 @@ Typical runtime work: Extra checks: -- Confirm `EnsureCustomer()` exists before `WithCustomerDefaults(...)`. +- Confirm the NPC overrides `IsCustomer` with a stable, side-effect-free value. - Confirm the schedule includes `EnsureDealSignal()` when the customer should actively deal. - Keep spending, standards, and relationship requirements internally consistent. @@ -83,8 +85,7 @@ Minimum structure: ```csharp public override bool IsDealer => true; -builder.EnsureDealer() - .WithDealerDefaults(dd => +builder.WithDealerDefaults(dd => { dd.WithSigningFee(1000f) .WithCut(0.15f) diff --git a/skills/schedule-one-custom-npcs/references/s1api-custom-npc-reference.md b/skills/schedule-one-custom-npcs/references/s1api-custom-npc-reference.md index 771b712b..fba56bf6 100644 --- a/skills/schedule-one-custom-npcs/references/s1api-custom-npc-reference.md +++ b/skills/schedule-one-custom-npcs/references/s1api-custom-npc-reference.md @@ -115,8 +115,9 @@ Choose points that are on walkable surfaces and fit the planned route. ### Customer defaults ```csharp -builder.EnsureCustomer() - .WithCustomerDefaults(cd => +public override bool IsCustomer => true; + +builder.WithCustomerDefaults(cd => { cd.WithSpending(150f, 600f) .WithOrdersPerWeek(1, 4) @@ -153,8 +154,7 @@ Runtime customer work is limited to events and basic actions such as: ```csharp public override bool IsDealer => true; -builder.EnsureDealer() - .WithDealerDefaults(dd => +builder.WithDealerDefaults(dd => { dd.WithSigningFee(1000f) .WithCut(0.15f) diff --git a/tools/S1APICoverageAnalyzer/Analysis/ApiAssemblyAnalyzer.cs b/tools/S1APICoverageAnalyzer/Analysis/ApiAssemblyAnalyzer.cs index c16a1b3e..c0830095 100644 --- a/tools/S1APICoverageAnalyzer/Analysis/ApiAssemblyAnalyzer.cs +++ b/tools/S1APICoverageAnalyzer/Analysis/ApiAssemblyAnalyzer.cs @@ -12,6 +12,7 @@ public sealed class ApiAssemblyAnalyzer : AssemblyAnalyzer { private readonly HashSet _wrappedGameTypes = new(); private readonly Dictionary> _typeToAccessedMembers = new(); + private readonly Dictionary _explicitCoverageMappings = new(StringComparer.Ordinal); private readonly List _apiTypes = new(); public ApiAssemblyAnalyzer(Assembly assembly, string assemblyPath) @@ -67,12 +68,20 @@ public void Analyze() // Strategy 9: Attributes that reference game types AnalyzeAttributes(type, apiTypeInfo); + + // Strategy 10: Analyzer-owned declarations for runtime-agnostic mirrors. + AnalyzeExplicitCoverage(type, apiTypeInfo); if (apiTypeInfo.WrappedGameTypes.Count > 0) { _apiTypes.Add(apiTypeInfo); } } + + _apiTypes.Sort((left, right) => + StringComparer.Ordinal.Compare(left.FullName, right.FullName)); + + ValidateExplicitCoverageMappings(); } /// @@ -89,6 +98,12 @@ public void Analyze() /// Get information about all API types that wrap game types. /// public List GetApiTypes() => _apiTypes; + + /// + /// Get semantic coverage declarations keyed by game type name. + /// + public IReadOnlyDictionary GetExplicitCoverageMappings() => + _explicitCoverageMappings; /// /// Analyze fields that are primary wrappers (S1*, Inner*, etc.). @@ -230,14 +245,20 @@ private void AnalyzeSameNameWrapping(Type apiType, ApiTypeInfo apiTypeInfo) private void RegisterGameTypeReference(Type type, Type apiType, ApiTypeInfo apiTypeInfo) { + if (type.HasElementType) + { + var elementType = type.GetElementType(); + if (elementType != null) + RegisterGameTypeReference(elementType, apiType, apiTypeInfo); + return; + } + if (IsGameType(type)) { var normalizedName = NormalizeScheduleOneTypeName(type.FullName); if (!string.IsNullOrEmpty(normalizedName)) { - _wrappedGameTypes.Add(normalizedName); - apiTypeInfo.WrappedGameTypes.Add(normalizedName); - TrackTypeAccess(normalizedName, apiType); + RegisterGameTypeName(normalizedName, apiType, apiTypeInfo); } if (type.DeclaringType != null && IsGameType(type.DeclaringType)) @@ -256,14 +277,42 @@ private void RegisterGameTypeReference(Type type, Type apiType, ApiTypeInfo apiT var normalizedName = NormalizeScheduleOneTypeName(arg.FullName); if (!string.IsNullOrEmpty(normalizedName)) { - _wrappedGameTypes.Add(normalizedName); - apiTypeInfo.WrappedGameTypes.Add(normalizedName); - TrackTypeAccess(normalizedName, apiType); + RegisterGameTypeName(normalizedName, apiType, apiTypeInfo); } } } } } + + private void AnalyzeExplicitCoverage(Type apiType, ApiTypeInfo apiTypeInfo) + { + foreach (var gameTypeName in ExplicitCoverageConfig.GetGameTypesCoveredBy(apiTypeInfo.FullName)) + { + RegisterGameTypeName(gameTypeName, apiType, apiTypeInfo); + _explicitCoverageMappings.Add(gameTypeName, apiTypeInfo.FullName); + } + } + + private void ValidateExplicitCoverageMappings() + { + foreach (var mapping in ExplicitCoverageConfig.GetMappings()) + { + if (_explicitCoverageMappings.ContainsKey(mapping.Key)) + continue; + + throw new InvalidOperationException( + $"Explicit coverage mapping for '{mapping.Key}' references " + + $"missing API type '{mapping.Value}'."); + } + } + + private void RegisterGameTypeName(string gameTypeName, Type apiType, ApiTypeInfo apiTypeInfo) + { + _wrappedGameTypes.Add(gameTypeName); + if (!apiTypeInfo.WrappedGameTypes.Contains(gameTypeName, StringComparer.Ordinal)) + apiTypeInfo.WrappedGameTypes.Add(gameTypeName); + TrackTypeAccess(gameTypeName, apiType); + } private bool IsGameType(Type type) { diff --git a/tools/S1APICoverageAnalyzer/Analysis/CoverageCalculator.cs b/tools/S1APICoverageAnalyzer/Analysis/CoverageCalculator.cs index 47d8f139..7face3f9 100644 --- a/tools/S1APICoverageAnalyzer/Analysis/CoverageCalculator.cs +++ b/tools/S1APICoverageAnalyzer/Analysis/CoverageCalculator.cs @@ -9,26 +9,27 @@ namespace S1APICoverageAnalyzer.Analysis; public sealed class CoverageCalculator { private readonly List _gameTypes; - private readonly HashSet _wrappedGameTypes; private readonly Dictionary> _accessedMembers; private readonly List _apiTypes; + private readonly IReadOnlyDictionary _explicitCoverageMappings; private readonly int _excludedTypeCount; - private readonly Dictionary _fuzzyMatchCache = new(); - + public CoverageCalculator( List gameTypes, - HashSet wrappedGameTypes, Dictionary> accessedMembers, List apiTypes, + IReadOnlyDictionary explicitCoverageMappings, int excludedTypeCount) { _gameTypes = gameTypes; - _wrappedGameTypes = wrappedGameTypes; _accessedMembers = accessedMembers; - _apiTypes = apiTypes; + _apiTypes = apiTypes + .OrderBy(apiType => apiType.FullName, StringComparer.Ordinal) + .ToList(); + _explicitCoverageMappings = explicitCoverageMappings; _excludedTypeCount = excludedTypeCount; } - + /// /// Calculate coverage and return the result. /// @@ -42,33 +43,31 @@ public CoverageResult Calculate() .ToList(), ApiTypes = _apiTypes }; - + int totalMembers = 0; int coveredMembers = 0; - + foreach (var gameType in _gameTypes) { - // Check if this game type is covered by S1API - bool isCovered = IsTypeCovered(gameType.FullName); - gameType.IsCovered = isCovered; - - if (isCovered) + CoverageMatch? match = FindCoverageMatch(gameType.FullName); + gameType.IsCovered = match != null; + gameType.CoveredByApiType = match?.ApiTypeName; + gameType.MatchStrategy = match?.Strategy; + + if (match != null) { - // Find which API type covers this game type - gameType.CoveredByApiType = FindCoveringApiType(gameType.FullName); result.CoveredTypes.Add(gameType); - - // Check member coverage + if (_accessedMembers.TryGetValue(gameType.FullName, out var accessedMemberNames)) { foreach (var member in gameType.Members) { - if (accessedMemberNames.Contains(member.Name)) - { - member.IsCovered = true; - member.CoveredByApiType = gameType.CoveredByApiType; - coveredMembers++; - } + if (!accessedMemberNames.Contains(member.Name)) + continue; + + member.IsCovered = true; + member.CoveredByApiType = match.ApiTypeName; + coveredMembers++; } } } @@ -76,154 +75,158 @@ public CoverageResult Calculate() { result.UncoveredTypes.Add(gameType); } - + totalMembers += gameType.Members.Count; } - + result.TotalGameClasses = _gameTypes.Count; result.CoveredGameClasses = result.CoveredTypes.Count; result.TotalGameMembers = totalMembers; result.CoveredGameMembers = coveredMembers; - + return result; } - - private bool IsTypeCovered(string gameTypeFullName) + + private CoverageMatch? FindCoverageMatch(string gameTypeFullName) { - // Strategy 1: Direct exact match - if (_wrappedGameTypes.Contains(gameTypeFullName)) - return true; - - // Strategy 2: Normalized nested class separator (+ to . and vice versa) - var normalizedName = gameTypeFullName.Replace('+', '.'); - if (_wrappedGameTypes.Contains(normalizedName)) - return true; - - // Also try converting wrapped types from . to + - foreach (var wrapped in _wrappedGameTypes) + if (_explicitCoverageMappings.TryGetValue(gameTypeFullName, out var explicitApiType) && + _apiTypes.Any(apiType => apiType.FullName.Equals(explicitApiType, StringComparison.Ordinal))) { - var wrappedNormalized = wrapped.Replace('+', '.'); - if (wrappedNormalized == normalizedName || wrappedNormalized == gameTypeFullName) - return true; + return new CoverageMatch(explicitApiType, CoverageMatchStrategy.Explicit); } - - // Strategy 3: Check if any wrapped type starts with this (for nested types) - // Handle both + and . separators - foreach (var wrapped in _wrappedGameTypes) - { - // Check if wrapped type is a parent of this nested type - if (wrapped.StartsWith(gameTypeFullName + ".", StringComparison.Ordinal) || - wrapped.StartsWith(gameTypeFullName + "+", StringComparison.Ordinal)) - { - return true; - } - - // Check if this type is a nested type within wrapped - // e.g., gameTypeFullName = "ScheduleOne.Console+PackageProduct" - // wrapped = "ScheduleOne.Console" - if (gameTypeFullName.StartsWith(wrapped + "+", StringComparison.Ordinal) || - gameTypeFullName.StartsWith(wrapped + ".", StringComparison.Ordinal)) - { - return true; - } - - // Also check normalized versions - var wrappedNormalized = wrapped.Replace('+', '.'); - var gameNormalized = gameTypeFullName.Replace('+', '.'); - if (wrappedNormalized.StartsWith(gameNormalized + ".", StringComparison.Ordinal) || - gameNormalized.StartsWith(wrappedNormalized + ".", StringComparison.Ordinal)) - { - return true; - } - } - - // Strategy 4: Fuzzy matching based on type names - // This handles cases like: - // - Game: "ScheduleOne.Vehicles.Modification.EVehicleColor" vs S1API: "S1API.Vehicles.VehicleColor" - // - Game: "ScheduleOne.Vehicles.Modification.VehicleColors" vs S1API: "S1API.Vehicles.VehicleColor" - // - Nested types: "ScheduleOne.Console+PackageProduct" vs wrapper that uses Console - var fuzzyMatch = FindFuzzyMatch(gameTypeFullName); - if (fuzzyMatch != null) - { - _fuzzyMatchCache[gameTypeFullName] = fuzzyMatch; - return true; - } - - return false; + + string? exactApiType = FindApiTypeForWrappedName(gameTypeFullName, normalize: false); + if (exactApiType != null) + return new CoverageMatch(exactApiType, CoverageMatchStrategy.Exact); + + string normalizedGameTypeName = NormalizeNestedTypeName(gameTypeFullName); + string? normalizedApiType = FindApiTypeForWrappedName(normalizedGameTypeName, normalize: true); + if (normalizedApiType != null) + return new CoverageMatch(normalizedApiType, CoverageMatchStrategy.Normalized); + + string? nestedApiType = FindNestedMatch(gameTypeFullName); + if (nestedApiType != null) + return new CoverageMatch(nestedApiType, CoverageMatchStrategy.Nested); + + string? fuzzyApiType = FindFuzzyMatch(gameTypeFullName); + return fuzzyApiType == null + ? null + : new CoverageMatch(fuzzyApiType, CoverageMatchStrategy.Fuzzy); } - - /// - /// Find a fuzzy match for a game type among API types. - /// Uses similarity scoring to find the best match above a threshold. - /// + + private string? FindApiTypeForWrappedName(string gameTypeName, bool normalize) + { + return _apiTypes + .Where(apiType => apiType.WrappedGameTypes.Any(wrappedType => + (normalize ? NormalizeNestedTypeName(wrappedType) : wrappedType) + .Equals(gameTypeName, StringComparison.Ordinal))) + .Select(apiType => apiType.FullName) + .FirstOrDefault(); + } + + private string? FindNestedMatch(string gameTypeFullName) + { + string normalizedGameTypeName = NormalizeNestedTypeName(gameTypeFullName); + + return _apiTypes + .SelectMany(apiType => apiType.WrappedGameTypes + .Distinct(StringComparer.Ordinal) + .Select(wrappedType => new + { + ApiTypeName = apiType.FullName, + WrappedTypeName = NormalizeNestedTypeName(wrappedType) + })) + .Where(candidate => + IsNestedRelation(normalizedGameTypeName, candidate.WrappedTypeName)) + .OrderBy(candidate => + Math.Abs(normalizedGameTypeName.Length - candidate.WrappedTypeName.Length)) + .ThenBy(candidate => candidate.ApiTypeName, StringComparer.Ordinal) + .ThenBy(candidate => candidate.WrappedTypeName, StringComparer.Ordinal) + .Select(candidate => candidate.ApiTypeName) + .FirstOrDefault(); + } + private string? FindFuzzyMatch(string gameTypeFullName) { - // Check if fuzzy matching is enabled - if (!Configuration.MatchingConfig.EnableFuzzyMatching) + if (!MatchingConfig.EnableFuzzyMatching) return null; - - double similarityThreshold = Configuration.MatchingConfig.FuzzySimilarityThreshold; + + double similarityThreshold = MatchingConfig.FuzzySimilarityThreshold; double bestScore = 0.0; - string? bestMatch = null; - + string? bestApiType = null; + foreach (var apiType in _apiTypes) { - // Calculate similarity between game type and API type - var score = TypeNameMatcher.CalculateSimilarity( + double apiTypeScore = TypeNameMatcher.CalculateSimilarity( gameTypeFullName, apiType.FullName, apiType.Name); - - if (score > bestScore && score >= similarityThreshold) - { - bestScore = score; - bestMatch = apiType.FullName; - } - } - - // Also check against wrapped game types directly - // (in case the API wraps a game type with a different name) - foreach (var wrappedType in _wrappedGameTypes) - { - var wrappedSimpleName = wrappedType.Split('.', '+').Last(); - var score = TypeNameMatcher.CalculateSimilarity( - gameTypeFullName, - wrappedType, - wrappedSimpleName); - - if (score > bestScore && score >= similarityThreshold) + SelectBetterFuzzyMatch( + apiTypeScore, + similarityThreshold, + apiType.FullName, + ref bestScore, + ref bestApiType); + + foreach (string wrappedType in apiType.WrappedGameTypes + .Distinct(StringComparer.Ordinal) + .OrderBy(typeName => typeName, StringComparer.Ordinal)) { - bestScore = score; - bestMatch = wrappedType; + string wrappedSimpleName = wrappedType.Split('.', '+').Last(); + double wrappedTypeScore = TypeNameMatcher.CalculateSimilarity( + gameTypeFullName, + wrappedType, + wrappedSimpleName); + SelectBetterFuzzyMatch( + wrappedTypeScore, + similarityThreshold, + apiType.FullName, + ref bestScore, + ref bestApiType); } } - - if (Configuration.MatchingConfig.VerboseFuzzyMatching && bestMatch != null) + + if (MatchingConfig.VerboseFuzzyMatching && bestApiType != null) { - Console.WriteLine($"[Fuzzy Match] {gameTypeFullName} -> {bestMatch} (score: {bestScore:F2})"); + Console.WriteLine( + $"[Fuzzy Match] {gameTypeFullName} -> {bestApiType} (score: {bestScore:F2})"); } - - return bestMatch; + + return bestApiType; } - - private string? FindCoveringApiType(string gameTypeFullName) + + private static void SelectBetterFuzzyMatch( + double score, + double threshold, + string apiTypeName, + ref double bestScore, + ref string? bestApiType) { - // First try exact matches - foreach (var apiType in _apiTypes) - { - if (apiType.WrappedGameTypes.Contains(gameTypeFullName)) - return apiType.FullName; - - // Check normalized name - var normalizedName = gameTypeFullName.Replace('+', '.'); - if (apiType.WrappedGameTypes.Contains(normalizedName)) - return apiType.FullName; - } - - // If we found a fuzzy match earlier, return it - if (_fuzzyMatchCache.TryGetValue(gameTypeFullName, out var cachedMatch)) - return cachedMatch; - - return null; + if (score < threshold) + return; + + bool isBetterScore = score > bestScore; + bool isDeterministicTieBreak = + Math.Abs(score - bestScore) < double.Epsilon && + (bestApiType == null || + StringComparer.Ordinal.Compare(apiTypeName, bestApiType) < 0); + + if (!isBetterScore && !isDeterministicTieBreak) + return; + + bestScore = score; + bestApiType = apiTypeName; } + + private static bool IsNestedRelation(string left, string right) => + !left.Equals(right, StringComparison.Ordinal) && + (left.StartsWith(right + ".", StringComparison.Ordinal) || + right.StartsWith(left + ".", StringComparison.Ordinal)); + + private static string NormalizeNestedTypeName(string typeName) => + typeName.Replace('+', '.'); + + private sealed record CoverageMatch( + string ApiTypeName, + CoverageMatchStrategy Strategy); } diff --git a/tools/S1APICoverageAnalyzer/Configuration/ExclusionConfig.cs b/tools/S1APICoverageAnalyzer/Configuration/ExclusionConfig.cs index 4641a75a..f50a3303 100644 --- a/tools/S1APICoverageAnalyzer/Configuration/ExclusionConfig.cs +++ b/tools/S1APICoverageAnalyzer/Configuration/ExclusionConfig.cs @@ -20,9 +20,15 @@ public static class ExclusionConfig "ScheduleOne.Tutorials", "ScheduleOne.Tools", // Internal tools "ScheduleOne.DevUtilities", // Internal dev utilities + "ScheduleOne.Development", // Development and experimental tooling + "ScheduleOne.Reporting", // Built-in diagnostics and report submission + "ScheduleOne.Configuration", // Internal configuration services // FishNet networking internals "ScheduleOne.Networking", + + // Platform integration and authentication internals + "ScheduleOne.Platform", // Audio system (internal implementation, likely not wrapped) "ScheduleOne.Audio", @@ -38,6 +44,7 @@ public static class ExclusionConfig "ScheduleOne.Packaging", // Visuals for packaging "ScheduleOne.PostProcessing", "ScheduleOne.Shaders", + "ScheduleOne.Instancing", // Physics / Math / Core Utils "ScheduleOne.GamePhysics", @@ -49,6 +56,8 @@ public static class ExclusionConfig "ScheduleOne.Polling", // Internal polling/feedback "ScheduleOne.Dragging", "ScheduleOne.Decoration", + "ScheduleOne.Map.Infrastructure", + "ScheduleOne.Core.Utilities", // Avatar internals (Animation, Rendering, etc.) - API wraps high level Avatar only "ScheduleOne.AvatarFramework.Animation", @@ -64,6 +73,12 @@ public static class ExclusionConfig // UI - Entire UI namespace is internal implementation "ScheduleOne.UI", + "ScheduleOne.Casino.UI", + "ScheduleOne.CustomUI", + + // Input implementation + "ScheduleOne.Gamepad", + "ScheduleOne.GamepadInput", // Calling internals (CallManager is wrapped, but PayPhone etc are not) "ScheduleOne.Calling", @@ -82,8 +97,7 @@ public static class ExclusionConfig // Player Tasks (Mini-games internals) "ScheduleOne.PlayerTasks", - "ScheduleOne.Experimental", // Prototype/tuning data, not stable modding surface - "Casino.UI", + "ScheduleOne.TV", "ScheduleOne.Console", ]; diff --git a/tools/S1APICoverageAnalyzer/Configuration/ExplicitCoverageConfig.cs b/tools/S1APICoverageAnalyzer/Configuration/ExplicitCoverageConfig.cs new file mode 100644 index 00000000..21e45e85 --- /dev/null +++ b/tools/S1APICoverageAnalyzer/Configuration/ExplicitCoverageConfig.cs @@ -0,0 +1,25 @@ +namespace S1APICoverageAnalyzer.Configuration; + +/// +/// Declares semantic coverage that cannot be inferred from native type references. +/// +internal static class ExplicitCoverageConfig +{ + private static readonly IReadOnlyDictionary Mappings = + new Dictionary(StringComparer.Ordinal) + { + ["ScheduleOne.Temperature.TemperatureEmitterInfo"] = + "S1API.Temperature.TemperatureEmitterInfo", + ["ScheduleOne.Temperature.TemperatureUtility"] = + "S1API.Temperature.TemperatureUtility" + }; + + public static IReadOnlyDictionary GetMappings() => + Mappings; + + public static IEnumerable GetGameTypesCoveredBy(string apiTypeName) => + Mappings + .Where(mapping => mapping.Value.Equals(apiTypeName, StringComparison.Ordinal)) + .Select(mapping => mapping.Key) + .OrderBy(gameTypeName => gameTypeName, StringComparer.Ordinal); +} diff --git a/tools/S1APICoverageAnalyzer/Models/GameType.cs b/tools/S1APICoverageAnalyzer/Models/GameType.cs index 63902596..f70abb8b 100644 --- a/tools/S1APICoverageAnalyzer/Models/GameType.cs +++ b/tools/S1APICoverageAnalyzer/Models/GameType.cs @@ -20,6 +20,11 @@ public sealed class GameType /// The S1API wrapper type that provides coverage for this game type. /// public string? CoveredByApiType { get; set; } + + /// + /// How the analyzer matched this game type to its covering S1API type. + /// + public CoverageMatchStrategy? MatchStrategy { get; set; } /// /// Number of members that are covered. @@ -38,6 +43,15 @@ public sealed class GameType TotalMemberCount == 0 ? 0 : (double)CoveredMemberCount / TotalMemberCount * 100; } +public enum CoverageMatchStrategy +{ + Explicit, + Exact, + Normalized, + Nested, + Fuzzy +} + public enum GameTypeKind { Class, diff --git a/tools/S1APICoverageAnalyzer/Output/ReportGenerator.cs b/tools/S1APICoverageAnalyzer/Output/ReportGenerator.cs index 3d0052ec..9bb54eef 100644 --- a/tools/S1APICoverageAnalyzer/Output/ReportGenerator.cs +++ b/tools/S1APICoverageAnalyzer/Output/ReportGenerator.cs @@ -45,6 +45,7 @@ public static string GenerateJsonReport(CoverageResult result) { FullName = t.FullName, CoveredBy = t.CoveredByApiType, + MatchStrategy = t.MatchStrategy?.ToString(), MembersCovered = t.CoveredMemberCount, MembersTotal = t.TotalMemberCount }) @@ -148,7 +149,8 @@ public static string GenerateTextReport(CoverageResult result) if (!string.IsNullOrEmpty(type.CoveredByApiType)) { sb.AppendLine($" [✓] {type.Name}"); - sb.AppendLine($" -> {type.CoveredByApiType}"); + sb.AppendLine( + $" -> {type.CoveredByApiType} ({type.MatchStrategy})"); } else { @@ -235,6 +237,7 @@ internal sealed class TypeCoverageInfo { public required string FullName { get; init; } public string? CoveredBy { get; init; } + public string? MatchStrategy { get; init; } public int MembersCovered { get; init; } public int MembersTotal { get; init; } } diff --git a/tools/S1APICoverageAnalyzer/Program.cs b/tools/S1APICoverageAnalyzer/Program.cs index 4c98e8be..3eac389a 100644 --- a/tools/S1APICoverageAnalyzer/Program.cs +++ b/tools/S1APICoverageAnalyzer/Program.cs @@ -228,15 +228,16 @@ private static async Task RunAnalysis(AnalysisOptions options) var wrappedTypes = apiAnalyzer.GetWrappedGameTypes(); var accessedMembers = apiAnalyzer.GetAccessedMembers(); var apiTypes = apiAnalyzer.GetApiTypes(); + var explicitCoverageMappings = apiAnalyzer.GetExplicitCoverageMappings(); Console.WriteLine($" Found {wrappedTypes.Count} wrapped game types across {apiTypes.Count} API types"); // Calculate coverage Console.WriteLine("Calculating coverage..."); var calculator = new CoverageCalculator( - gameTypes, - wrappedTypes, - accessedMembers, + gameTypes, + accessedMembers, apiTypes, + explicitCoverageMappings, excludedTypeCount); var result = calculator.Calculate(); @@ -321,7 +322,10 @@ private static async Task RunAnalysis(AnalysisOptions options) { Console.WriteLine($" [covered] {type.FullName}"); if (!string.IsNullOrEmpty(type.CoveredByApiType)) - Console.WriteLine($" -> Wrapped by: {type.CoveredByApiType}"); + { + Console.WriteLine( + $" -> Wrapped by: {type.CoveredByApiType} ({type.MatchStrategy})"); + } } Console.WriteLine(); diff --git a/tools/S1APICoverageAnalyzer/README.md b/tools/S1APICoverageAnalyzer/README.md index 7391ed9f..c26b668d 100644 --- a/tools/S1APICoverageAnalyzer/README.md +++ b/tools/S1APICoverageAnalyzer/README.md @@ -9,8 +9,10 @@ If the game splits public `ScheduleOne.*` types across multiple assemblies, pass - **Type Coverage Analysis**: Identifies which game types are wrapped by S1API - **Member Coverage Analysis**: Tracks which members (fields, properties, methods) are exposed - **Smart Type Matching**: Uses multiple strategies to match game types to S1API types + - Explicit matching for reviewed semantic mirrors - Exact matching - Normalized matching (handles nested type separators) + - Nested matching (attributes declaring and nested types) - Fuzzy matching (handles naming variations) - **Configurable Exclusions**: Excludes internal/infrastructure types from analysis - **Multiple Output Formats**: JSON, plain text, and badge markdown @@ -39,17 +41,25 @@ dotnet run --project S1APICoverageAnalyzer.csproj \ ## Type Matching Strategies -The analyzer uses multiple strategies to match game types to S1API types, in order of priority: +The analyzer uses multiple strategies to match game types to S1API types, in order of priority. Every covered type records both the responsible S1API type and the selected strategy. -### 1. Exact Match +### 1. Explicit Match + +`Configuration/ExplicitCoverageConfig.cs` declares runtime-agnostic mirrors that intentionally avoid retaining native game types in their public or compiled shape. Keep this list limited to reviewed semantic equivalents. + +### 2. Exact Match Direct full name match: `ScheduleOne.NPCs.NPC` == `ScheduleOne.NPCs.NPC` -### 2. Normalized Match +### 3. Normalized Match Handles nested type separator differences: - Game: `ScheduleOne.Casino.SlotMachine+ESymbol` - Matches: `ScheduleOne.Casino.SlotMachine.ESymbol` -### 3. Fuzzy Match +### 4. Nested Match + +Attributes a nested game type to the S1API type that wraps its declaring type, or vice versa. + +### 5. Fuzzy Match Handles common naming variations: #### Enum Prefix Differences @@ -101,7 +111,7 @@ Edit `Configuration/ExclusionConfig.cs` to adjust which types are excluded from ### JSON Report Detailed coverage data including: - Class and member coverage percentages -- List of covered types with their covering API types +- List of covered types with their covering API types and match strategies - List of uncovered types - Excluded namespace information diff --git a/tools/S1APICoverageAnalyzer/S1APICoverageAnalyzer.csproj b/tools/S1APICoverageAnalyzer/S1APICoverageAnalyzer.csproj index 4d1a6ae0..545aaf2a 100644 --- a/tools/S1APICoverageAnalyzer/S1APICoverageAnalyzer.csproj +++ b/tools/S1APICoverageAnalyzer/S1APICoverageAnalyzer.csproj @@ -15,4 +15,8 @@ + + + + diff --git a/tools/S1APICoverageAnalyzer/coverage-history.json b/tools/S1APICoverageAnalyzer/coverage-history.json index a69b0352..81ba4d92 100644 --- a/tools/S1APICoverageAnalyzer/coverage-history.json +++ b/tools/S1APICoverageAnalyzer/coverage-history.json @@ -309,32 +309,139 @@ "note": null }, { - "timestamp": "2026-07-06T04:06:17.7808193Z", - "classCoveragePercentage": 31.610337972166995, + "timestamp": "2026-08-01T06:38:06.4190254Z", + "classCoveragePercentage": 32.01376936316696, "memberCoveragePercentage": 0, - "totalClasses": 1006, - "coveredClasses": 318, - "totalMembers": 25377, + "totalClasses": 1162, + "coveredClasses": 372, + "totalMembers": 25992, "coveredMembers": 0, - "excludedClasses": 1176, + "excludedClasses": 1207, "gameAssemblyVersion": "Assembly-CSharp.dll:0.0.0.0, ScheduleOne.Core.dll:0.0.0.0", - "gameAssemblyHash": "AF680F5F4219D455D790B33773AA471BA9797157BFEF71252D016F5EDBE0FC7D", + "gameAssemblyHash": "5689194244A2C00DA4D78EB5B010AADB85F7BF4D51CB5A6918162E7598860B62", + "analyzerVersion": "1.0.0.0", + "events": [ + { + "type": 1, + "description": "Game updated: \u002B156 types (\u002B15.5%)", + "details": "Hash: AF680F5F \u2192 56891942" + } + ], + "note": null + }, + { + "timestamp": "2026-08-06T01:54:51.5276418Z", + "classCoveragePercentage": 32.44406196213425, + "memberCoveragePercentage": 0, + "totalClasses": 1162, + "coveredClasses": 377, + "totalMembers": 25992, + "coveredMembers": 0, + "excludedClasses": 1207, + "gameAssemblyVersion": "Assembly-CSharp.dll:0.0.0.0, ScheduleOne.Core.dll:0.0.0.0", + "gameAssemblyHash": "5689194244A2C00DA4D78EB5B010AADB85F7BF4D51CB5A6918162E7598860B62", + "analyzerVersion": "1.0.0.0", + "events": [], + "note": null + }, + { + "timestamp": "2026-08-08T11:40:18.9808516Z", + "classCoveragePercentage": 35.03649635036496, + "memberCoveragePercentage": 0, + "totalClasses": 1096, + "coveredClasses": 384, + "totalMembers": 25209, + "coveredMembers": 0, + "excludedClasses": 1279, + "gameAssemblyVersion": "Assembly-CSharp.dll:0.0.0.0, ScheduleOne.Core.dll:0.0.0.0", + "gameAssemblyHash": "1823E93D331A299451B5C2AFEE62E43D447B3D17A8C0052DDF9746A47759A96A", + "analyzerVersion": "1.0.0.0", + "events": [ + { + "type": 1, + "description": "Game updated: -66 types (-5.7%)", + "details": "Hash: 56891942 \u2192 1823E93D" + }, + { + "type": 3, + "description": "Class coverage increased by \u002B2.59%", + "details": "32.44% \u2192 35.04%" + } + ], + "note": null + }, + { + "timestamp": "2026-08-09T10:24:21.5501504Z", + "classCoveragePercentage": 35.21897810218978, + "memberCoveragePercentage": 0, + "totalClasses": 1096, + "coveredClasses": 386, + "totalMembers": 25209, + "coveredMembers": 0, + "excludedClasses": 1279, + "gameAssemblyVersion": "Assembly-CSharp.dll:0.0.0.0, ScheduleOne.Core.dll:0.0.0.0", + "gameAssemblyHash": "1823E93D331A299451B5C2AFEE62E43D447B3D17A8C0052DDF9746A47759A96A", + "analyzerVersion": "1.0.0.0", + "events": [], + "note": null + }, + { + "timestamp": "2026-08-14T04:40:11.7423306Z", + "classCoveragePercentage": 35.583941605839414, + "memberCoveragePercentage": 0, + "totalClasses": 1096, + "coveredClasses": 390, + "totalMembers": 25209, + "coveredMembers": 0, + "excludedClasses": 1279, + "gameAssemblyVersion": "Assembly-CSharp.dll:0.0.0.0, ScheduleOne.Core.dll:0.0.0.0", + "gameAssemblyHash": "1823E93D331A299451B5C2AFEE62E43D447B3D17A8C0052DDF9746A47759A96A", + "analyzerVersion": "1.0.0.0", + "events": [], + "note": null + }, + { + "timestamp": "2026-08-16T07:21:16.3653501Z", + "classCoveragePercentage": 36.77007299270073, + "memberCoveragePercentage": 0, + "totalClasses": 1096, + "coveredClasses": 403, + "totalMembers": 25209, + "coveredMembers": 0, + "excludedClasses": 1279, + "gameAssemblyVersion": "Assembly-CSharp.dll:0.0.0.0, ScheduleOne.Core.dll:0.0.0.0", + "gameAssemblyHash": "1823E93D331A299451B5C2AFEE62E43D447B3D17A8C0052DDF9746A47759A96A", + "analyzerVersion": "1.0.0.0", + "events": [], + "note": null + }, + { + "timestamp": "2026-08-16T09:40:10.4257801Z", + "classCoveragePercentage": 37.31751824817518, + "memberCoveragePercentage": 0, + "totalClasses": 1096, + "coveredClasses": 409, + "totalMembers": 25209, + "coveredMembers": 0, + "excludedClasses": 1279, + "gameAssemblyVersion": "Assembly-CSharp.dll:0.0.0.0, ScheduleOne.Core.dll:0.0.0.0", + "gameAssemblyHash": "1823E93D331A299451B5C2AFEE62E43D447B3D17A8C0052DDF9746A47759A96A", "analyzerVersion": "1.0.0.0", "events": [], "note": null } ], "latestEntry": { - "timestamp": "2026-07-06T04:06:17.7808193Z", - "classCoveragePercentage": 31.610337972166995, + "timestamp": "2026-08-16T09:40:10.4257801Z", + "classCoveragePercentage": 37.31751824817518, "memberCoveragePercentage": 0, - "totalClasses": 1006, - "coveredClasses": 318, - "totalMembers": 25377, + "totalClasses": 1096, + "coveredClasses": 409, + "totalMembers": 25209, "coveredMembers": 0, - "excludedClasses": 1176, + "excludedClasses": 1279, "gameAssemblyVersion": "Assembly-CSharp.dll:0.0.0.0, ScheduleOne.Core.dll:0.0.0.0", - "gameAssemblyHash": "AF680F5F4219D455D790B33773AA471BA9797157BFEF71252D016F5EDBE0FC7D", + "gameAssemblyHash": "1823E93D331A299451B5C2AFEE62E43D447B3D17A8C0052DDF9746A47759A96A", "analyzerVersion": "1.0.0.0", "events": [], "note": null