diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..ef41d8ab --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,91 @@ +name: A-B Performance Benchmark + +on: + workflow_dispatch: + pull_request: + paths: + - '.github/workflows/benchmark.yml' + - 'benchmark/**' + - 'build.gradle.kts' + - 'common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java' + - 'common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java' + - 'common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java' + +permissions: + contents: read + +jobs: + benchmark: + name: Paper 26.1.2 A-B benchmark + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "25" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build disposable benchmark plugin + run: ./gradlew benchmarkJar --no-daemon --no-build-cache --rerun-tasks + + - name: Download stable Paper 26.1.2 + env: + PAPER_USER_AGENT: InteractionVisualizer-Benchmark/1.0 (https://github.com/EllanServer/InteractionVisualizer) + run: | + mkdir -p benchmark-server/plugins + BUILDS=$(curl --fail --silent --show-error \ + -H "User-Agent: $PAPER_USER_AGENT" \ + https://fill.papermc.io/v3/projects/paper/versions/26.1.2/builds) + PAPER_URL=$(echo "$BUILDS" | jq -r 'first(.[] | select(.channel == "STABLE") | .downloads."server:default".url) // empty') + test -n "$PAPER_URL" + curl --fail --location --show-error \ + -H "User-Agent: $PAPER_USER_AGENT" \ + --output benchmark-server/server.jar "$PAPER_URL" + cp build/libs/InteractionVisualizer-*-benchmark.jar benchmark-server/plugins/ + + - name: Configure isolated benchmark server + working-directory: benchmark-server + run: | + echo 'eula=true' > eula.txt + cat > server.properties <<'EOF' + online-mode=false + level-name=benchmark-world + level-type=minecraft:flat + generate-structures=false + spawn-protection=0 + view-distance=3 + simulation-distance=3 + max-players=1 + max-tick-time=-1 + sync-chunk-writes=false + EOF + + - name: Run alternating A-B measurements + working-directory: benchmark-server + run: | + java -Xms2G -Xmx2G -XX:+UseG1GC -jar server.jar --nogui | tee benchmark-server.log + test -f plugins/InteractionVisualizerBenchmark/benchmark-results.jsonl + test -f plugins/InteractionVisualizerBenchmark/benchmark-complete.txt + EXPECTED_RESULTS=$(cat plugins/InteractionVisualizerBenchmark/benchmark-complete.txt) + ACTUAL_RESULTS=$(wc -l < plugins/InteractionVisualizerBenchmark/benchmark-results.jsonl) + test "$ACTUAL_RESULTS" -eq "$EXPECTED_RESULTS" + + - name: Publish benchmark results + if: always() + uses: actions/upload-artifact@v4 + with: + name: interactionvisualizer-ab-${{ github.sha }} + path: | + benchmark-server/plugins/InteractionVisualizerBenchmark/benchmark-results.jsonl + benchmark-server/plugins/InteractionVisualizerBenchmark/benchmark-complete.txt + benchmark-server/benchmark-server.log + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3670cfdf..9d64c6e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,6 +29,59 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 + - name: Validate performance tooling + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $scripts = @( + 'tools/perf/phase2-pktmon.ps1', + 'tools/perf/analyze-presentmon.ps1', + 'tools/perf/analyze-minecraft-debug-profile.ps1', + 'tools/perf/analyze-jfr-socket-writes.ps1', + 'tools/perf/analyze-phase2-abba.ps1', + 'tools/perf/analyze-phase2-pcap.ps1' + ) + foreach ($script in $scripts) { + [void][scriptblock]::Create((Get-Content -Raw -LiteralPath $script)) + } + $pktmonSelfTestJson = & ./tools/perf/phase2-pktmon.ps1 selftest | Out-String + $pktmonSelfTest = $pktmonSelfTestJson | ConvertFrom-Json + if (-not $pktmonSelfTest.passed) { + throw 'PktMon wrapper self-test did not report passed=true.' + } + $selfTestJson = & ./tools/perf/analyze-presentmon.ps1 -SelfTest | Out-String + $selfTest = $selfTestJson | ConvertFrom-Json + if (-not $selfTest.passed) { + throw 'PresentMon analyzer self-test did not report passed=true.' + } + $debugProfileSelfTestJson = & ./tools/perf/analyze-minecraft-debug-profile.ps1 -SelfTest | Out-String + $debugProfileSelfTest = $debugProfileSelfTestJson | ConvertFrom-Json + if (-not $debugProfileSelfTest.passed) { + throw 'Minecraft debug-profile analyzer self-test did not report passed=true.' + } + $jfrSelfTestJson = & ./tools/perf/analyze-jfr-socket-writes.ps1 -SelfTest | Out-String + $jfrSelfTest = $jfrSelfTestJson | ConvertFrom-Json + if (-not $jfrSelfTest.passed) { + throw 'JFR socket-write analyzer self-test did not report passed=true.' + } + $abbaSelfTestJson = & ./tools/perf/analyze-phase2-abba.ps1 -SelfTest | Out-String + $abbaSelfTest = $abbaSelfTestJson | ConvertFrom-Json + if (-not $abbaSelfTest.passed) { + throw 'Phase 2 ABBA analyzer self-test did not report passed=true.' + } + $pcapSelfTestJson = & ./tools/perf/analyze-phase2-pcap.ps1 -SelfTest | Out-String + $pcapSelfTest = $pcapSelfTestJson | ConvertFrom-Json + if (-not $pcapSelfTest.passed) { + throw 'Phase 2 pcap analyzer self-test did not report passed=true.' + } + + - name: Validate runtime harness syntax + shell: bash + run: | + bash -n tools/perf/prepare-phase2-protocol-client.sh + bash -n tools/perf/run-phase2-runtime-once.sh + node --check tools/perf/phase2-protocol-client.js + - name: Run checks and build production jar run: ./gradlew clean check shadowJar --no-daemon --no-build-cache --rerun-tasks diff --git a/.github/workflows/phase2-packet-capture.yml b/.github/workflows/phase2-packet-capture.yml new file mode 100644 index 00000000..c1793b7b --- /dev/null +++ b/.github/workflows/phase2-packet-capture.yml @@ -0,0 +1,361 @@ +name: Phase 2 Packet Capture A-B + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: + inputs: + scenario: + description: "Independent network scenario" + required: true + default: "static-spawn" + type: choice + options: ["static-spawn", "visibility-return", "visibility-itemdisplay-return", "visibility-textdisplay-return"] + runs: + description: "Restart-isolated runs (4, 8, or formal 12)" + required: true + default: "12" + type: choice + options: ["4", "8", "12"] + items: + description: "Logical items" + required: true + default: "4096" + type: string + warmup_seconds: + description: "Warmup after the real TCP client is ready" + required: true + default: "60" + type: string + settle_seconds: + description: "Pre-window settling time" + required: true + default: "10" + type: string + measure_seconds: + description: "Packet capture window" + required: true + default: "10" + type: string + snaplen: + description: "tcpdump snapshot length; 128 for transport metrics, 0 for full compatibility capture" + required: true + default: "128" + type: choice + options: ["128", "0"] + +permissions: + contents: read + +jobs: + packet-capture-ab: + name: Paper 26.1.2 packet ABBA + if: >- + github.event_name == 'workflow_dispatch' || + github.event.action != 'labeled' || + github.event.label.name == 'phase2-packet-visibility-smoke' || + github.event.label.name == 'phase2-packet-static-formal' || + github.event.label.name == 'phase2-packet-visibility-formal' || + github.event.label.name == 'phase2-packet-visibility-itemdisplay-smoke' || + github.event.label.name == 'phase2-packet-visibility-itemdisplay-formal' || + github.event.label.name == 'phase2-packet-visibility-textdisplay-smoke' || + github.event.label.name == 'phase2-packet-visibility-textdisplay-formal' + runs-on: ubuntu-latest + timeout-minutes: 100 + env: + CAMPAIGN_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || contains(github.event.label.name, 'visibility-itemdisplay') && 'visibility-itemdisplay-return' || contains(github.event.label.name, 'visibility-textdisplay') && 'visibility-textdisplay-return' || contains(github.event.label.name, 'visibility') && 'visibility-return' || 'static-spawn' }} + CAMPAIGN_RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || contains(github.event.label.name, '-formal') && '12' || '4' }} + CAMPAIGN_ITEMS: ${{ github.event_name == 'workflow_dispatch' && inputs.items || contains(github.event.label.name, '-formal') && '4096' || '1024' }} + CAMPAIGN_WARMUP_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.warmup_seconds || contains(github.event.label.name, '-formal') && '60' || '10' }} + CAMPAIGN_SETTLE_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.settle_seconds || contains(github.event.label.name, '-formal') && '10' || '5' }} + CAMPAIGN_MEASURE_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.measure_seconds || contains(github.event.label.name, '-formal') && '10' || contains(github.event.label.name, 'visibility') && '10' || '5' }} + CAMPAIGN_SNAPLEN: ${{ github.event_name == 'workflow_dispatch' && inputs.snaplen || '128' }} + + steps: + - name: Check out immutable test source + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Java 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "25" + + - name: Set up Node 24 for the isolated protocol peer + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Install capture readers + env: + DEBIAN_FRONTEND: noninteractive + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends tcpdump tshark + tcpdump --version + tshark --version + + - name: Validate harness source + run: | + bash -n tools/perf/prepare-phase2-protocol-client.sh + bash -n tools/perf/run-phase2-runtime-once.sh + node --check tools/perf/phase2-protocol-client.js + node --check tools/perf/analyze-phase2-protocol-trace.js + node tools/perf/analyze-phase2-protocol-trace.js --self-test + pwsh -NoProfile -File tools/perf/analyze-phase2-abba.ps1 -SelfTest + pwsh -NoProfile -File tools/perf/analyze-phase2-pcap.ps1 -SelfTest + + - name: Build and test the production plugin + run: ./gradlew clean check shadowJar --no-daemon --no-build-cache --rerun-tasks + + - name: Download stable Paper 26.1.2 + env: + PAPER_USER_AGENT: InteractionVisualizer-Phase2/1.0 (https://github.com/EllanServer/InteractionVisualizer) + run: | + mkdir -p phase2-dependencies + BUILDS=$(curl --fail --silent --show-error \ + -H "User-Agent: $PAPER_USER_AGENT" \ + https://fill.papermc.io/v3/projects/paper/versions/26.1.2/builds) + PAPER_URL=$(echo "$BUILDS" | jq -r 'first(.[] | select(.channel == "STABLE") | .downloads."server:default".url) // empty') + test -n "$PAPER_URL" + curl --fail --location --show-error \ + -H "User-Agent: $PAPER_USER_AGENT" \ + --output phase2-dependencies/paper.jar "$PAPER_URL" + + - name: Prepare immutable protocol client artifact + run: bash tools/perf/prepare-phase2-protocol-client.sh phase2-dependencies/protocol-client + + - name: Run restart-isolated packet ABBA campaign + id: packet_campaign + env: + SCENARIO: ${{ env.CAMPAIGN_SCENARIO }} + RUNS: ${{ env.CAMPAIGN_RUNS }} + ITEMS: ${{ env.CAMPAIGN_ITEMS }} + WARMUP_SECONDS: ${{ env.CAMPAIGN_WARMUP_SECONDS }} + SETTLE_SECONDS: ${{ env.CAMPAIGN_SETTLE_SECONDS }} + MEASURE_SECONDS: ${{ env.CAMPAIGN_MEASURE_SECONDS }} + SNAPLEN: ${{ env.CAMPAIGN_SNAPLEN }} + run: | + set -euo pipefail + [[ "$SCENARIO" == static-spawn || "$SCENARIO" == visibility-return || \ + "$SCENARIO" == visibility-itemdisplay-return || "$SCENARIO" == visibility-textdisplay-return ]] + [[ "$RUNS" =~ ^(4|8|12)$ ]] + [[ "$ITEMS" =~ ^[0-9]+$ ]] && (( ITEMS >= 1 && ITEMS <= 8192 )) + [[ "$WARMUP_SECONDS" =~ ^[0-9]+$ ]] && (( WARMUP_SECONDS >= 10 )) + [[ "$SETTLE_SECONDS" =~ ^[0-9]+$ ]] && (( SETTLE_SECONDS >= 5 )) + [[ "$MEASURE_SECONDS" =~ ^[0-9]+$ ]] && (( MEASURE_SECONDS >= 5 )) + [[ "$SNAPLEN" == 0 || "$SNAPLEN" == 128 ]] + if [[ "$SCENARIO" == visibility-* ]]; then + (( MEASURE_SECONDS >= 10 )) + fi + + PLUGIN_JAR=$(find build/libs -maxdepth 1 -type f -name 'InteractionVisualizer-*.jar' ! -name '*-sources.jar' ! -name '*-benchmark.jar' -print -quit) + test -n "$PLUGIN_JAR" + mkdir -p phase2-results/packet + MANIFEST=phase2-results/packet/abba-manifest.csv + PROTOCOL_MANIFEST=phase2-results/packet/protocol-trace-abba-manifest.csv + printf 'Scenario,Block,Position,Variant,RunId,StackSha256,ArtifactSha256,CaptureMethod,SourcePath\n' > "$MANIFEST" + printf 'Scenario,Block,Position,Variant,RunId,StackSha256,ArtifactSha256,CaptureMethod,SourcePath\n' > "$PROTOCOL_MANIFEST" + ARTIFACT_SHA=$(sha256sum "$PLUGIN_JAR" | awk '{print $1}') + STACK_SHA=$( + { + sha256sum phase2-dependencies/paper.jar + sha256sum phase2-dependencies/protocol-client/client-build-manifest.json + sha256sum tools/perf/phase2-protocol-client.js + sha256sum tools/perf/analyze-phase2-protocol-trace.js + sha256sum tools/perf/analyze-phase2-pcap.ps1 + sha256sum tools/perf/analyze-phase2-abba.ps1 + sha256sum tools/perf/run-phase2-runtime-once.sh + java -version 2>&1 + printf '%s\n' \ + 'java=-Xms2G -Xmx2G -XX:+UseG1GC -XX:+AlwaysPreTouch -Dinteractionvisualizer.performance.allowBlockScene=true -Xlog:gc*=info,safepoint=info:file=jvm-gc-safepoint.log:time,uptime,level,tags:filecount=0 -Dfile.encoding=UTF-8' \ + "scenario=$SCENARIO" "snaplen=$SNAPLEN" 'protocolTrace=semantic-lifecycle-v1' \ + 'sparkProfile=none' + } | sha256sum | awk '{print $1}' + ) + + for run_number in $(seq 1 "$RUNS"); do + block=$(( (run_number - 1) / 4 + 1 )) + position=$(( (run_number - 1) % 4 + 1 )) + if (( block % 2 == 1 )); then pattern=ABBA; else pattern=BAAB; fi + variant=${pattern:$((position - 1)):1} + run_id=$(printf '%s_%s_%02d' "${SCENARIO//-/_}" "$variant" "$run_number") + + PHASE2_PLUGIN_JAR="$PLUGIN_JAR" \ + PHASE2_PAPER_JAR=phase2-dependencies/paper.jar \ + PHASE2_CLIENT_ROOT=phase2-dependencies/protocol-client \ + PHASE2_OUTPUT_ROOT=phase2-results/packet \ + PHASE2_RUN_ID="$run_id" \ + PHASE2_SCENARIO="$SCENARIO" \ + PHASE2_VARIANT="$variant" \ + PHASE2_ITEM_COUNT="$ITEMS" \ + PHASE2_WARMUP_SECONDS="$WARMUP_SECONDS" \ + PHASE2_SETTLE_SECONDS="$SETTLE_SECONDS" \ + PHASE2_MEASURE_SECONDS="$MEASURE_SECONDS" \ + PHASE2_CAPTURE_ENABLED=1 \ + PHASE2_CAPTURE_SNAPLEN="$SNAPLEN" \ + PHASE2_PROTOCOL_TRACE_ENABLED=1 \ + PHASE2_SPARK_PROFILE_MODE=none \ + bash tools/perf/run-phase2-runtime-once.sh + + printf '%s,%d,%d,%s,%s,%s,%s,tcpdump-lo-s%s,%s/%s.pcap-analysis.json\n' \ + "$SCENARIO" "$block" "$position" "$variant" "$run_id" "$STACK_SHA" "$ARTIFACT_SHA" \ + "$SNAPLEN" "$run_id" "$run_id" >> "$MANIFEST" + printf '%s,%d,%d,%s,%s,%s,%s,protocol-client-memory,%s/%s.protocol-trace-analysis.json\n' \ + "$SCENARIO" "$block" "$position" "$variant" "$run_id" "$STACK_SHA" "$ARTIFACT_SHA" \ + "$run_id" "$run_id" >> "$PROTOCOL_MANIFEST" + done + + minimum_seconds=$(( MEASURE_SECONDS - 1 )) + incomplete=() + if [[ "$RUNS" != 12 ]]; then incomplete=(-AllowIncomplete); fi + # Loopback capture preserves TCP payload volume/timing for paired + # regression checks, but its synthetic link headers and offload + # behavior are not evidence of physical on-wire frame bytes. + for metric in downstream.tcpPayloadBytes downstream.peak50ms.tcpPayloadBytes downstream.peak1s.tcpPayloadBytes; do + safe_metric=${metric//./_} + pwsh -NoProfile -File tools/perf/analyze-phase2-abba.ps1 "$MANIFEST" \ + -Scenario "$SCENARIO" -Metric "$metric" -Direction LowerIsBetter \ + -MinimumSeconds "$minimum_seconds" "${incomplete[@]}" \ + -OutputJson "phase2-results/packet/$safe_metric.analysis.json" -Overwrite + done + + # These semantic lifecycle counts are generated from the same + # explicit half-open window as the pcap evidence. They supplement, + # and never replace, the transport-level byte and retransmit gates. + for metric in traceCoverage.windowEventCount identity.spawn.observations spawnPeaks.epochAligned50ms.spawnedEntityIdCount spawnPeaks.epochAligned1s.spawnedEntityIdCount; do + safe_metric=${metric//./_} + pwsh -NoProfile -File tools/perf/analyze-phase2-abba.ps1 "$PROTOCOL_MANIFEST" \ + -Scenario "$SCENARIO" -Metric "$metric" -Direction LowerIsBetter \ + "${incomplete[@]}" \ + -OutputJson "phase2-results/packet/protocol_$safe_metric.analysis.json" -Overwrite + done + + - name: Summarize semantic protocol evidence + if: always() + env: + SCENARIO: ${{ env.CAMPAIGN_SCENARIO }} + EXPECTED_RUNS: ${{ env.CAMPAIGN_RUNS }} + CAMPAIGN_OUTCOME: ${{ steps.packet_campaign.outcome }} + run: | + python3 - <<'PY' + import json + import os + import re + from pathlib import Path + + results_root = Path("phase2-results/packet") + results_root.mkdir(parents=True, exist_ok=True) + suffix = ".protocol-trace-analysis.json" + records = [] + errors = [] + for analysis_path in sorted(results_root.glob(f"*/*{suffix}")): + run_id = analysis_path.name[:-len(suffix)] + variant_match = re.search(r"_([AB])_[0-9]+$", run_id) + try: + analysis = json.loads(analysis_path.read_text(encoding="utf-8")) + status = analysis.get("status", {}) + identity = analysis.get("identity", {}) + spawn = identity.get("spawn", {}) + destroy = identity.get("destroy", {}) + peaks = analysis.get("spawnPeaks", {}) + records.append({ + "runId": run_id, + "variant": variant_match.group(1) if variant_match else None, + "sourcePath": analysis_path.as_posix(), + "formalEvidenceReady": status.get("formalEvidenceReady") is True, + "windowCovered": status.get("windowCovered") is True, + "windowDurationMs": analysis.get("window", {}).get("durationMs"), + "windowEventCount": analysis.get("traceCoverage", {}).get("windowEventCount"), + "spawnObservations": spawn.get("observations"), + "uniqueSpawnIds": spawn.get("uniqueIdCount"), + "destroyObservations": destroy.get("observations"), + "peak50msSpawnIds": peaks.get("epochAligned50ms", {}).get("spawnedEntityIdCount"), + "peak1sSpawnIds": peaks.get("epochAligned1s", {}).get("spawnedEntityIdCount"), + "duplicateLiveSpawnObservations": identity.get("duplicateLiveSpawnObservations"), + "destroyWithoutKnownLiveObservations": identity.get("destroyWithoutKnownLiveObservations"), + }) + except Exception as error: + errors.append({"sourcePath": analysis_path.as_posix(), "error": str(error)}) + + expected_runs = int(os.environ["EXPECTED_RUNS"]) + run_ids = [record["runId"] for record in records] + unique_run_ids = len(run_ids) == len(set(run_ids)) + complete_run_set = len(records) == expected_runs and unique_run_ids and not errors + formal_evidence_ready = complete_run_set and all( + record["formalEvidenceReady"] for record in records + ) + summary = { + "schemaVersion": 1, + "scenario": os.environ["SCENARIO"], + "campaignOutcome": os.environ.get("CAMPAIGN_OUTCOME", "unknown"), + "requestedRuns": expected_runs, + "observedRuns": len(records), + "formalEvidenceReady": formal_evidence_ready, + "formalCampaignReady": ( + formal_evidence_ready + and expected_runs == 12 + and os.environ.get("CAMPAIGN_OUTCOME") == "success" + ), + "errors": errors, + "runs": records, + } + summary_path = results_root / "protocol-trace-summary.json" + summary_path.write_text( + json.dumps(summary, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + step_summary = os.environ.get("GITHUB_STEP_SUMMARY") + if step_summary: + lines = [ + "### Phase 2 semantic protocol trace", + "", + f"- Scenario: `{summary['scenario']}`", + f"- Explicit-window runs: `{summary['observedRuns']}/{summary['requestedRuns']}`", + f"- Per-run `formalEvidenceReady`: `{'ready' if formal_evidence_ready else 'not-ready'}`", + f"- Formal 12-run ABBA campaign: `{'ready' if summary['formalCampaignReady'] else 'incomplete'}`", + "", + "| Run | Variant | Ready | Window events | Spawn IDs | Unique IDs | Peak 50 ms | Peak 1 s |", + "|---|---:|---:|---:|---:|---:|---:|---:|", + ] + for record in records: + lines.append( + "| {runId} | {variant} | {ready} | {events} | {spawn} | {unique} | {peak50} | {peak1s} |".format( + runId=record["runId"], + variant=record["variant"] or "?", + ready="yes" if record["formalEvidenceReady"] else "no", + events=record["windowEventCount"], + spawn=record["spawnObservations"], + unique=record["uniqueSpawnIds"], + peak50=record["peak50msSpawnIds"], + peak1s=record["peak1sSpawnIds"], + ) + ) + if errors: + lines.extend(["", f"Trace summary errors: `{len(errors)}`"]) + with open(step_summary, "a", encoding="utf-8", newline="\n") as stream: + stream.write("\n".join(lines) + "\n") + + if summary["campaignOutcome"] == "success" and not formal_evidence_ready: + raise SystemExit("successful campaign is missing complete formal-ready protocol trace evidence") + PY + + - name: Publish packet evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: phase2-packet-${{ env.CAMPAIGN_SCENARIO }}-${{ github.sha }}-${{ github.run_id }} + path: | + phase2-results/packet + phase2-dependencies/protocol-client/client-build-manifest.json + phase2-dependencies/protocol-client/client-files.sha256 + phase2-dependencies/protocol-client/node-minecraft-protocol/package-lock.json + phase2-dependencies/protocol-client/node-minecraft-protocol/production-lock-inventory.json + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/phase2-runtime-ab.yml b/.github/workflows/phase2-runtime-ab.yml new file mode 100644 index 00000000..ef11386b --- /dev/null +++ b/.github/workflows/phase2-runtime-ab.yml @@ -0,0 +1,767 @@ +name: Phase 2 Runtime A-B + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: + inputs: + ab_factor: + description: "Independent A/B factor (text cache requires block-active)" + required: true + default: "scenario-config" + type: choice + options: ["scenario-config", "legacy-text-component-cache"] + scenario: + description: "Runtime workload (choose block-active for text cache)" + required: true + default: "static-steady" + type: choice + options: ["static-steady", "block-idle", "block-active", "block-direct-write"] + runs: + description: "Restart-isolated runs (4, 8, or formal 12)" + required: true + default: "12" + type: choice + options: ["4", "8", "12"] + items: + description: "Logical items (max 8192) or fully tracked workload blocks (max 1024)" + required: true + default: "1024" + type: string + warmup_seconds: + description: "Warmup after the real TCP client is ready" + required: true + default: "120" + type: string + settle_seconds: + description: "Scene settling time before sampling" + required: true + default: "20" + type: string + measure_seconds: + description: "MSPT/TPS sampling window (diagnostic when Spark is enabled)" + required: true + default: "180" + type: string + spark_profile_mode: + description: "Optional 4-run Spark diagnostic (cpu=slow ticks, cpu-all=steady-state)" + required: true + default: "none" + type: choice + options: ["none", "cpu", "cpu-all", "alloc"] + +permissions: + contents: read + +jobs: + clean-runtime-ab: + name: Paper 26.1.2 runtime ABBA + if: >- + github.event_name == 'workflow_dispatch' || + github.event.action != 'labeled' || + github.event.label.name == 'phase2-runtime-formal' + runs-on: ubuntu-latest + timeout-minutes: 130 + env: + CAMPAIGN_AB_FACTOR: ${{ github.event_name == 'workflow_dispatch' && inputs.ab_factor || 'scenario-config' }} + CAMPAIGN_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || 'static-steady' }} + CAMPAIGN_RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || github.event.action == 'labeled' && '12' || '4' }} + CAMPAIGN_ITEMS: ${{ github.event_name == 'workflow_dispatch' && inputs.items || github.event.action == 'labeled' && '4096' || '1024' }} + CAMPAIGN_WARMUP_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.warmup_seconds || github.event.action == 'labeled' && '120' || '10' }} + CAMPAIGN_SETTLE_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.settle_seconds || github.event.action == 'labeled' && '20' || '5' }} + CAMPAIGN_MEASURE_SECONDS: ${{ github.event_name == 'workflow_dispatch' && inputs.measure_seconds || github.event.action == 'labeled' && '180' || '10' }} + CAMPAIGN_SPARK_PROFILE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.spark_profile_mode || 'none' }} + CAMPAIGN_EVIDENCE_KIND: ${{ github.event_name == 'workflow_dispatch' && inputs.spark_profile_mode != 'none' && 'diagnostic' || 'clean' }} + + steps: + - name: Check out immutable test source + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Java 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "25" + + - name: Set up Node 24 for the isolated protocol peer + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Validate harness source + run: | + bash -n tools/perf/prepare-phase2-protocol-client.sh + bash -n tools/perf/run-phase2-runtime-once.sh + node --check tools/perf/phase2-protocol-client.js + pwsh -NoProfile -File tools/perf/analyze-phase2-abba.ps1 -SelfTest + pwsh -NoProfile -File tools/perf/analyze-phase2-pcap.ps1 -SelfTest + + - name: Build and test the production plugin + run: ./gradlew clean check shadowJar --no-daemon --no-build-cache --rerun-tasks + + - name: Download stable Paper 26.1.2 + env: + PAPER_USER_AGENT: InteractionVisualizer-Phase2/1.0 (https://github.com/EllanServer/InteractionVisualizer) + run: | + mkdir -p phase2-dependencies + BUILDS=$(curl --fail --silent --show-error \ + -H "User-Agent: $PAPER_USER_AGENT" \ + https://fill.papermc.io/v3/projects/paper/versions/26.1.2/builds) + PAPER_URL=$(echo "$BUILDS" | jq -r 'first(.[] | select(.channel == "STABLE") | .downloads."server:default".url) // empty') + test -n "$PAPER_URL" + curl --fail --location --show-error \ + -H "User-Agent: $PAPER_USER_AGENT" \ + --output phase2-dependencies/paper.jar "$PAPER_URL" + + - name: Prepare immutable protocol client artifact + run: bash tools/perf/prepare-phase2-protocol-client.sh phase2-dependencies/protocol-client + + - name: Run restart-isolated runtime campaign + env: + AB_FACTOR: ${{ env.CAMPAIGN_AB_FACTOR }} + SCENARIO: ${{ env.CAMPAIGN_SCENARIO }} + RUNS: ${{ env.CAMPAIGN_RUNS }} + ITEMS: ${{ env.CAMPAIGN_ITEMS }} + WARMUP_SECONDS: ${{ env.CAMPAIGN_WARMUP_SECONDS }} + SETTLE_SECONDS: ${{ env.CAMPAIGN_SETTLE_SECONDS }} + MEASURE_SECONDS: ${{ env.CAMPAIGN_MEASURE_SECONDS }} + SPARK_PROFILE_MODE: ${{ env.CAMPAIGN_SPARK_PROFILE_MODE }} + EVIDENCE_KIND: ${{ env.CAMPAIGN_EVIDENCE_KIND }} + run: | + set -euo pipefail + [[ "$AB_FACTOR" == scenario-config || "$AB_FACTOR" == legacy-text-component-cache ]] + [[ "$SCENARIO" == static-steady || "$SCENARIO" == block-idle || \ + "$SCENARIO" == block-active || "$SCENARIO" == block-direct-write ]] + [[ "$RUNS" =~ ^(4|8|12)$ ]] + [[ "$ITEMS" =~ ^[0-9]+$ ]] + if [[ "$SCENARIO" == block-* ]]; then + # TileEntityUpdate.CheckingRange=1 covers the centered 32x32 + # block footprint completely; larger command scenes are valid but + # are not full-workload clean ABBA evidence. + (( ITEMS >= 1 && ITEMS <= 1024 )) + else + (( ITEMS >= 1 && ITEMS <= 8192 )) + fi + [[ "$WARMUP_SECONDS" =~ ^[0-9]+$ ]] && (( WARMUP_SECONDS >= 10 )) + [[ "$SETTLE_SECONDS" =~ ^[0-9]+$ ]] && (( SETTLE_SECONDS >= 5 )) + [[ "$MEASURE_SECONDS" =~ ^[0-9]+$ ]] && (( MEASURE_SECONDS >= 10 )) + [[ "$SPARK_PROFILE_MODE" =~ ^(none|cpu|cpu-all|alloc)$ ]] + if [[ "$AB_FACTOR" == legacy-text-component-cache ]]; then + if [[ "$SCENARIO" != block-active ]]; then + echo "legacy-text-component-cache A/B is isolated to block-active" >&2 + exit 64 + fi + if [[ "$SPARK_PROFILE_MODE" == none && "$RUNS" != 12 ]]; then + echo "clean legacy-text-component-cache evidence is formal-only and requires runs=12" >&2 + exit 64 + fi + if [[ "$SPARK_PROFILE_MODE" != none && "$RUNS" != 4 ]]; then + echo "profiled legacy-text-component-cache diagnostics require runs=4" >&2 + exit 64 + fi + if (( ITEMS < 100 )); then + echo "legacy-text-component-cache A/B requires at least 100 workload blocks" >&2 + exit 64 + fi + fi + if [[ "$SPARK_PROFILE_MODE" == cpu-all && \ + "$SCENARIO" != block-active && "$SCENARIO" != block-direct-write ]]; then + echo "Spark cpu-all profiling is isolated to block-active or block-direct-write" >&2 + exit 64 + fi + if [[ "$SPARK_PROFILE_MODE" != none && "$SPARK_PROFILE_MODE" != cpu-all && \ + "$SCENARIO" != block-direct-write ]]; then + echo "Spark cpu/alloc profiling is isolated to block-direct-write" >&2 + exit 64 + fi + if [[ "$SPARK_PROFILE_MODE" != none && "$RUNS" != 4 ]]; then + echo "Spark profiling is diagnostic-only and requires runs=4" >&2 + exit 64 + fi + if [[ "$SCENARIO" == block-direct-write ]]; then + # Direct BlockState writes intentionally emit no Bukkit event. + # Allow the 600-tick audit to begin plus enough ticks for every + # per-display iterator to cover the <=1024-block scene. + (( MEASURE_SECONDS >= 45 )) + fi + + PLUGIN_JAR=$(find build/libs -maxdepth 1 -type f -name 'InteractionVisualizer-*.jar' ! -name '*-sources.jar' ! -name '*-benchmark.jar' -print -quit) + test -n "$PLUGIN_JAR" + if [[ "$SPARK_PROFILE_MODE" == none ]]; then + [[ "$EVIDENCE_KIND" == clean ]] + CAPTURE_METHOD=none + else + [[ "$EVIDENCE_KIND" == diagnostic ]] + CAPTURE_METHOD="spark-$SPARK_PROFILE_MODE" + fi + EVIDENCE_ROOT="phase2-results/$EVIDENCE_KIND" + mkdir -p "$EVIDENCE_ROOT" + MANIFEST="$EVIDENCE_ROOT/abba-manifest.csv" + printf 'Scenario,Block,Position,Variant,RunId,AbFactor,LegacyTextComponentCacheDisableProperty,LegacyTextComponentCacheEnabled,LegacyTextCacheRequests,LegacyTextCacheMisses,LegacyTextCacheHits,LegacyTextCacheHitRate,LegacyTextSameRawFastPaths,ConfigSha256,JvmArgumentsSha256,JvmArgumentsNormalizedSha256,StackSha256,ArtifactSha256,CaptureMethod,SourcePath\n' > "$MANIFEST" + ARTIFACT_SHA=$(sha256sum "$PLUGIN_JAR" | awk '{print $1}') + STACK_SHA=$( + { + sha256sum phase2-dependencies/paper.jar + sha256sum phase2-dependencies/protocol-client/client-build-manifest.json + sha256sum tools/perf/run-phase2-runtime-once.sh + java -version 2>&1 + printf '%s\n' \ + "abFactor=$AB_FACTOR" "scenario=$SCENARIO" "sparkProfile=$SPARK_PROFILE_MODE" + } | sha256sum | awk '{print $1}' + ) + + for run_number in $(seq 1 "$RUNS"); do + block=$(( (run_number - 1) / 4 + 1 )) + position=$(( (run_number - 1) % 4 + 1 )) + if (( block % 2 == 1 )); then pattern=ABBA; else pattern=BAAB; fi + variant=${pattern:$((position - 1)):1} + if [[ "$AB_FACTOR" == scenario-config ]]; then + run_id=$(printf '%s_%s_%02d' "${SCENARIO//-/_}" "$variant" "$run_number") + else + run_id=$(printf '%s_text_cache_%s_%02d' "${SCENARIO//-/_}" "$variant" "$run_number") + fi + + PHASE2_PLUGIN_JAR="$PLUGIN_JAR" \ + PHASE2_PAPER_JAR=phase2-dependencies/paper.jar \ + PHASE2_CLIENT_ROOT=phase2-dependencies/protocol-client \ + PHASE2_OUTPUT_ROOT="$EVIDENCE_ROOT" \ + PHASE2_RUN_ID="$run_id" \ + PHASE2_SCENARIO="$SCENARIO" \ + PHASE2_VARIANT="$variant" \ + PHASE2_AB_FACTOR="$AB_FACTOR" \ + PHASE2_ITEM_COUNT="$ITEMS" \ + PHASE2_WARMUP_SECONDS="$WARMUP_SECONDS" \ + PHASE2_SETTLE_SECONDS="$SETTLE_SECONDS" \ + PHASE2_MEASURE_SECONDS="$MEASURE_SECONDS" \ + PHASE2_CAPTURE_ENABLED=0 \ + PHASE2_PROTOCOL_TRACE_ENABLED=0 \ + PHASE2_SPARK_PROFILE_MODE="$SPARK_PROFILE_MODE" \ + bash tools/perf/run-phase2-runtime-once.sh + + python3 - "$MANIFEST" "$EVIDENCE_ROOT" "$SCENARIO" "$block" "$position" \ + "$variant" "$run_id" "$AB_FACTOR" "$STACK_SHA" "$ARTIFACT_SHA" \ + "$CAPTURE_METHOD" <<'PY' + import csv + import json + from pathlib import Path + import sys + + ( + manifest_text, + evidence_root_text, + scenario, + block, + position, + variant, + run_id, + ab_factor, + stack_sha, + artifact_sha, + capture_method, + ) = sys.argv[1:] + manifest_path = Path(manifest_text) + evidence_root = Path(evidence_root_text) + metrics_path = evidence_root / run_id / "iv-perf.json" + run_manifest_path = evidence_root / run_id / "run-manifest.json" + metrics = json.loads(metrics_path.read_text(encoding="utf-8")) + provenance = json.loads(run_manifest_path.read_text(encoding="utf-8")) + cache = provenance.get("legacyTextComponentCache", {}) + if provenance.get("abFactor") != ab_factor or metrics.get("abFactor") != ab_factor: + raise SystemExit(f"A/B factor provenance mismatch for {run_id}") + cache_metric_mapping = { + "enabled": "legacyTextComponentCache", + "requests": "legacyTextCacheRequests", + "misses": "legacyTextCacheMisses", + "hits": "legacyTextCacheHits", + "hitRate": "legacyTextCacheHitRate", + "sameRawFastPaths": "legacyTextSameRawFastPaths", + } + for provenance_field, metrics_field in cache_metric_mapping.items(): + if cache.get(provenance_field) != metrics.get(metrics_field): + raise SystemExit( + f"cache provenance mismatch for {run_id}: {provenance_field}/{metrics_field}" + ) + if cache.get("disableProperty") != metrics.get("legacyTextComponentCacheDisableProperty"): + raise SystemExit(f"cache property provenance mismatch for {run_id}") + if provenance.get("jvmArgumentsSha256") != metrics.get("jvmArgumentsSha256"): + raise SystemExit(f"JVM argument provenance mismatch for {run_id}") + if (provenance.get("jvmArgumentsNormalizedSha256") + != metrics.get("jvmArgumentsNormalizedSha256")): + raise SystemExit(f"normalized JVM argument provenance mismatch for {run_id}") + process_command_line = provenance.get("jvmDiagnostics", {}).get("processCommandLine", {}) + if (process_command_line.get("formalEvidenceReady") is not True + or process_command_line.get("capturedFromProcCmdline") is not True + or process_command_line.get("jvmArgumentsSha256") + != provenance.get("jvmArgumentsSha256") + or process_command_line.get("jvmArgumentsNormalizedSha256") + != provenance.get("jvmArgumentsNormalizedSha256")): + raise SystemExit(f"live JVM process command-line provenance mismatch for {run_id}") + row = [ + scenario, + block, + position, + variant, + run_id, + ab_factor, + str(cache.get("disableProperty")).lower(), + str(cache.get("enabled")).lower(), + cache.get("requests"), + cache.get("misses"), + cache.get("hits"), + cache.get("hitRate"), + cache.get("sameRawFastPaths"), + provenance.get("configSha256"), + provenance.get("jvmArgumentsSha256"), + provenance.get("jvmArgumentsNormalizedSha256"), + stack_sha, + artifact_sha, + capture_method, + f"{run_id}/iv-perf.json", + ] + with manifest_path.open("a", encoding="utf-8", newline="") as stream: + csv.writer(stream, lineterminator="\n").writerow(row) + PY + done + + if [[ "$SPARK_PROFILE_MODE" == none ]]; then + minimum_seconds=$(( MEASURE_SECONDS - 2 )) + incomplete=() + if [[ "$RUNS" != 12 ]]; then incomplete=(-AllowIncomplete); fi + for metric in msptP95 msptP99 msptP999 msptMean; do + pwsh -NoProfile -File tools/perf/analyze-phase2-abba.ps1 "$MANIFEST" \ + -Scenario "$SCENARIO" -Metric "$metric" -Direction LowerIsBetter \ + -MinimumSeconds "$minimum_seconds" "${incomplete[@]}" \ + -OutputJson "$EVIDENCE_ROOT/$metric.analysis.json" -Overwrite + done + # At a healthy 20 TPS cap this is only an overload/non-regression + # diagnostic. MSPT remains the optimization effect-size metric. + pwsh -NoProfile -File tools/perf/analyze-phase2-abba.ps1 "$MANIFEST" \ + -Scenario "$SCENARIO" -Metric observedTps -Direction HigherIsBetter \ + -MinimumSeconds "$minimum_seconds" "${incomplete[@]}" \ + -OutputJson "$EVIDENCE_ROOT/observedTps.analysis.json" -Overwrite + fi + + if [[ "$SCENARIO" == block-* ]]; then + # Active/direct-write CPU time has a meaningful LowerIsBetter + # direction after lifecycle/config assertions pass. An idle + # candidate can legitimately perform zero checks in a short + # window, so it stays diagnostic instead of entering log ratios. + # Raw check counts never carry a universal performance direction. + if [[ "$SPARK_PROFILE_MODE" == none && "$SCENARIO" != block-idle ]]; then + pwsh -NoProfile -File tools/perf/analyze-phase2-abba.ps1 "$MANIFEST" \ + -Scenario "$SCENARIO" -Metric blockUpdateMs -Direction LowerIsBetter \ + -MinimumSeconds "$minimum_seconds" "${incomplete[@]}" \ + -OutputJson "$EVIDENCE_ROOT/blockUpdateMs.analysis.json" -Overwrite + fi + + python3 - "$MANIFEST" "$SCENARIO" "$RUNS" "$ITEMS" "$CAPTURE_METHOD" "$AB_FACTOR" \ + "$EVIDENCE_ROOT/blockUpdateChecks.evidence.json" <<'PY' + from pathlib import Path + import csv + import json + import math + import os + import re + import statistics + import sys + + manifest_path = Path(sys.argv[1]) + scenario = sys.argv[2] + expected_runs = int(sys.argv[3]) + expected_items = int(sys.argv[4]) + capture_method = sys.argv[5] + ab_factor = sys.argv[6] + output_path = Path(sys.argv[7]) + records = [] + with manifest_path.open(encoding="utf-8", newline="") as stream: + rows = list(csv.DictReader(stream)) + if len(rows) != expected_runs: + raise SystemExit(f"block evidence expected {expected_runs} runs, found {len(rows)}") + + def parse_bool(value, field, run_id): + if value == "true": + return True + if value == "false": + return False + raise SystemExit(f"invalid {field} for {run_id}: {value!r}") + + def parse_nonnegative_int(value, field, run_id): + try: + parsed = int(value) + except (TypeError, ValueError) as error: + raise SystemExit(f"invalid {field} for {run_id}: {value!r}") from error + if parsed < 0: + raise SystemExit(f"invalid {field} for {run_id}: {parsed!r}") + return parsed + + for row in rows: + if row["Scenario"] != scenario: + raise SystemExit(f"manifest scenario drift: {row['Scenario']} != {scenario}") + if row["CaptureMethod"] != capture_method: + raise SystemExit( + f"manifest capture-method drift: {row['CaptureMethod']} != {capture_method}" + ) + if row["AbFactor"] != ab_factor: + raise SystemExit(f"manifest A/B factor drift: {row['AbFactor']} != {ab_factor}") + source_path = manifest_path.parent / row["SourcePath"] + metrics = json.loads(source_path.read_text(encoding="utf-8")) + run_id = row["RunId"] + if metrics.get("label") != run_id: + raise SystemExit(f"metrics label mismatch for {run_id}") + checks = metrics.get("blockUpdateChecks") + elapsed_ms = metrics.get("blockUpdateMs") + if isinstance(checks, bool) or not isinstance(checks, int) or checks < 0: + raise SystemExit(f"invalid blockUpdateChecks for {run_id}: {checks!r}") + if (isinstance(elapsed_ms, bool) or not isinstance(elapsed_ms, (int, float)) + or not math.isfinite(elapsed_ms) or elapsed_ms < 0): + raise SystemExit(f"invalid blockUpdateMs for {run_id}: {elapsed_ms!r}") + if checks > 0 and elapsed_ms <= 0: + raise SystemExit(f"{run_id} recorded {checks} checks but no elapsed time") + tick_samples = metrics.get("tickSamples") + if isinstance(tick_samples, bool) or not isinstance(tick_samples, int) or tick_samples <= 0: + raise SystemExit(f"invalid tickSamples for {run_id}: {tick_samples!r}") + variant = row["Variant"] + expected_event_driven = ( + ab_factor == "legacy-text-component-cache" or variant == "B" + ) + if metrics.get("eventDrivenBlockUpdates") is not expected_event_driven: + raise SystemExit(f"event-driven config mismatch for {run_id}") + disable_property = parse_bool( + row["LegacyTextComponentCacheDisableProperty"], + "LegacyTextComponentCacheDisableProperty", + run_id, + ) + cache_enabled = parse_bool( + row["LegacyTextComponentCacheEnabled"], + "LegacyTextComponentCacheEnabled", + run_id, + ) + expected_disable_property = ( + ab_factor == "legacy-text-component-cache" and variant == "A" + ) + if disable_property is not expected_disable_property: + raise SystemExit(f"legacy text cache property mismatch for {run_id}") + if cache_enabled is not (not disable_property): + raise SystemExit(f"legacy text cache enabled state mismatch for {run_id}") + requests = parse_nonnegative_int(row["LegacyTextCacheRequests"], "requests", run_id) + misses = parse_nonnegative_int(row["LegacyTextCacheMisses"], "misses", run_id) + hits = parse_nonnegative_int(row["LegacyTextCacheHits"], "hits", run_id) + same_raw_fast_paths = parse_nonnegative_int( + row["LegacyTextSameRawFastPaths"], "sameRawFastPaths", run_id + ) + try: + hit_rate = float(row["LegacyTextCacheHitRate"]) + except ValueError as error: + raise SystemExit(f"invalid cache hit rate for {run_id}") from error + if (not math.isfinite(hit_rate) or not 0.0 <= hit_rate <= 1.0 + or misses > requests or hits != requests - misses): + raise SystemExit(f"inconsistent legacy text cache metrics for {run_id}") + derived_hit_rate = hits / requests if requests else 0.0 + if not math.isclose(hit_rate, derived_hit_rate, rel_tol=0.0, abs_tol=0.000001): + raise SystemExit(f"legacy text cache hit rate mismatch for {run_id}") + metric_expectations = { + "abFactor": ab_factor, + "jvmArgumentsSha256": row["JvmArgumentsSha256"], + "jvmArgumentsNormalizedSha256": row["JvmArgumentsNormalizedSha256"], + "legacyTextComponentCacheDisableProperty": disable_property, + "legacyTextComponentCache": cache_enabled, + "legacyTextCacheRequests": requests, + "legacyTextCacheMisses": misses, + "legacyTextCacheHits": hits, + "legacyTextSameRawFastPaths": same_raw_fast_paths, + } + for field, expected in metric_expectations.items(): + if metrics.get(field) != expected: + raise SystemExit(f"{field} evidence mismatch for {run_id}") + if not math.isclose( + metrics.get("legacyTextCacheHitRate", -1), hit_rate, + rel_tol=0.0, abs_tol=0.000001, + ): + raise SystemExit(f"legacyTextCacheHitRate evidence mismatch for {run_id}") + config_sha = row["ConfigSha256"] + jvm_arguments_sha = row["JvmArgumentsSha256"] + jvm_arguments_normalized_sha = row["JvmArgumentsNormalizedSha256"] + for field, value in ( + ("ConfigSha256", config_sha), + ("JvmArgumentsSha256", jvm_arguments_sha), + ("JvmArgumentsNormalizedSha256", jvm_arguments_normalized_sha), + ): + if re.fullmatch(r"[0-9a-f]{64}", value) is None: + raise SystemExit(f"invalid {field} for {run_id}: {value!r}") + provenance_path = source_path.parent / "run-manifest.json" + provenance = json.loads(provenance_path.read_text(encoding="utf-8")) + if (provenance.get("abFactor") != ab_factor + or provenance.get("configSha256") != config_sha + or provenance.get("jvmArgumentsSha256") != jvm_arguments_sha + or provenance.get("jvmArgumentsNormalizedSha256") + != jvm_arguments_normalized_sha): + raise SystemExit(f"run provenance mismatch for {run_id}") + cache_provenance = provenance.get("legacyTextComponentCache", {}) + expected_cache_provenance = { + "propertyName": "interactionvisualizer.disableLegacyTextComponentCache", + "disableProperty": disable_property, + "enabled": cache_enabled, + "requests": requests, + "misses": misses, + "hits": hits, + "hitRate": hit_rate, + "sameRawFastPaths": same_raw_fast_paths, + } + if cache_provenance != expected_cache_provenance: + raise SystemExit(f"legacy text cache provenance mismatch for {run_id}") + records.append({ + "block": int(row["Block"]), + "position": int(row["Position"]), + "variant": variant, + "runId": run_id, + "abFactor": ab_factor, + "configSha256": config_sha, + "jvmArgumentsSha256": jvm_arguments_sha, + "jvmArgumentsNormalizedSha256": jvm_arguments_normalized_sha, + "legacyTextComponentCacheDisableProperty": disable_property, + "legacyTextComponentCacheEnabled": cache_enabled, + "legacyTextCacheRequests": requests, + "legacyTextCacheMisses": misses, + "legacyTextCacheHits": hits, + "legacyTextCacheHitRate": hit_rate, + "legacyTextSameRawFastPaths": same_raw_fast_paths, + "blockUpdateChecks": checks, + "tickSamples": tick_samples, + "checksPerTick": checks / tick_samples, + "blockUpdateMs": elapsed_ms, + "msPerCheck": elapsed_ms / checks if checks else None, + "sourcePath": source_path.as_posix(), + }) + + def summarize(variant): + selected = [record for record in records if record["variant"] == variant] + checks = [record["blockUpdateChecks"] for record in selected] + checks_per_tick = [record["checksPerTick"] for record in selected] + times = [record["blockUpdateMs"] for record in selected] + cache_requests = [record["legacyTextCacheRequests"] for record in selected] + cache_misses = [record["legacyTextCacheMisses"] for record in selected] + cache_hits = [record["legacyTextCacheHits"] for record in selected] + cache_hit_rates = [record["legacyTextCacheHitRate"] for record in selected] + same_raw_fast_paths = [record["legacyTextSameRawFastPaths"] for record in selected] + return { + "runCount": len(selected), + "blockUpdateChecks": { + "values": checks, + "median": statistics.median(checks), + "mean": statistics.fmean(checks), + }, + "checksPerTick": { + "values": checks_per_tick, + "median": statistics.median(checks_per_tick), + "mean": statistics.fmean(checks_per_tick), + }, + "blockUpdateMs": { + "values": times, + "median": statistics.median(times), + "mean": statistics.fmean(times), + }, + "legacyTextComponentCache": { + "enabled": sorted({record["legacyTextComponentCacheEnabled"] for record in selected}), + "disableProperty": sorted({ + record["legacyTextComponentCacheDisableProperty"] for record in selected + }), + "requests": cache_requests, + "misses": cache_misses, + "hits": cache_hits, + "hitRates": cache_hit_rates, + "sameRawFastPaths": same_raw_fast_paths, + "meanHitRate": statistics.fmean(cache_hit_rates), + }, + } + + by_variant = {"A": summarize("A"), "B": summarize("B")} + config_hashes = sorted({record["configSha256"] for record in records}) + config_hashes_by_variant = { + variant: sorted({ + record["configSha256"] + for record in records if record["variant"] == variant + }) + for variant in ("A", "B") + } + jvm_hashes_by_variant = { + variant: sorted({ + record["jvmArgumentsSha256"] + for record in records if record["variant"] == variant + }) + for variant in ("A", "B") + } + normalized_jvm_hashes = sorted({ + record["jvmArgumentsNormalizedSha256"] for record in records + }) + if any(len(hashes) != 1 for hashes in jvm_hashes_by_variant.values()): + raise SystemExit("JVM argument SHA drifted within a variant") + if any(len(hashes) != 1 for hashes in config_hashes_by_variant.values()): + raise SystemExit("config SHA drifted within a variant") + if len(normalized_jvm_hashes) != 1: + raise SystemExit("normalized JVM argument SHA drifted across the campaign") + if ab_factor == "legacy-text-component-cache": + if len(config_hashes) != 1: + raise SystemExit("cache A/B variants do not share the same config SHA") + if jvm_hashes_by_variant["A"] == jvm_hashes_by_variant["B"]: + raise SystemExit("cache A/B variants unexpectedly share one JVM argument SHA") + for record in records: + if record["legacyTextCacheRequests"] <= 0: + raise SystemExit(f"cache A/B workload was not exercised in {record['runId']}") + if (record["variant"] == "A" + and record["legacyTextCacheMisses"] != record["legacyTextCacheRequests"]): + raise SystemExit(f"disabled cache reported hits in {record['runId']}") + if (record["variant"] == "B" + and record["legacyTextCacheHitRate"] < 0.90): + raise SystemExit( + "enabled cache missed the 90% steady-state hit-rate guard in " + f"{record['runId']}: {record['legacyTextCacheHitRate']:.6f}" + ) + else: + if jvm_hashes_by_variant["A"] != jvm_hashes_by_variant["B"]: + raise SystemExit("scenario-config variants unexpectedly changed JVM arguments") + if config_hashes_by_variant["A"] == config_hashes_by_variant["B"]: + raise SystemExit("scenario-config variants unexpectedly share one config SHA") + cadence_guard = { + "evaluated": False, + "reason": "only block-active workloads with at least 100 blocks have a stable mixed-type cadence", + } + if scenario == "block-active" and expected_items >= 100: + baseline = by_variant["A"]["checksPerTick"]["median"] + candidate = by_variant["B"]["checksPerTick"]["median"] + if baseline <= 0: + raise SystemExit("block-active baseline recorded no checks") + ratio = candidate / baseline + if ab_factor == "legacy-text-component-cache": + minimum_ratio = 0.95 + maximum_ratio = 1.05 + cadence_interpretation = ( + "Both cache variants use the same event-driven configuration, so their executed " + "block-check cadence must remain equivalent." + ) + else: + minimum_ratio = 0.60 + maximum_ratio = 0.85 + cadence_interpretation = ( + "The active candidate aggregate rate must remain consistent with all three furnace " + "cadences while eliminating idle bee polling and noisy level-event invalidations." + ) + cadence_guard = { + "evaluated": True, + "minimumCandidateToBaselineRatio": minimum_ratio, + "maximumCandidateToBaselineRatio": maximum_ratio, + "candidateToBaselineRatio": ratio, + "passed": minimum_ratio <= ratio <= maximum_ratio, + "interpretation": cadence_interpretation, + } + + if capture_method == "none": + interpretation = ( + "blockUpdateChecks is workload evidence, not a standalone optimization objective; " + "fewer checks can mean either effective event-driven scheduling or missing work. " + "For block-idle, zero candidate checks/time in a short window is valid and blockUpdateMs " + "has no ratio analysis. Interpret all scenarios with machine-validated scene actions, " + "blockUpdateMs, MSPT and TPS." + ) + else: + interpretation = ( + "Instrumented Spark workload evidence only. blockUpdateChecks can establish executed work, " + "but MSPT, TPS and blockUpdateMs were collected under profiler overhead and must not be " + "used as clean performance effect sizes. Use the saved profile to attribute CPU cost, then " + "confirm any optimization in a separate captureMethod=none campaign." + ) + + evidence = { + "schemaVersion": 2, + "analysisType": "diagnostic-work-evidence", + "scenario": scenario, + "abFactor": ab_factor, + "requestedRuns": expected_runs, + "expectedItems": expected_items, + "captureMethod": capture_method, + "performanceEvidenceReady": capture_method == "none", + "formalComplete": expected_runs == 12 and capture_method == "none", + "blockUpdateChecksDirection": None, + "blockUpdateMsDirection": ( + "LowerIsBetter" if capture_method == "none" and scenario != "block-idle" else None + ), + "interpretation": interpretation, + "activeCadenceGuard": cadence_guard, + "provenance": { + "configSha256": config_hashes, + "configSha256ByVariant": config_hashes_by_variant, + "jvmArgumentsSha256ByVariant": jvm_hashes_by_variant, + "jvmArgumentsNormalizedSha256": normalized_jvm_hashes[0], + "legacyTextComponentCacheProperty": ( + "interactionvisualizer.disableLegacyTextComponentCache" + ), + "legacyTextProcessingTreatmentScope": [ + "sharedComponentCache", + "perEntitySameRawFastPath", + ], + }, + "byVariant": by_variant, + "runs": records, + } + output_path.write_text( + json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + step_summary = os.environ.get("GITHUB_STEP_SUMMARY") + if step_summary: + with open(step_summary, "a", encoding="utf-8", newline="\n") as stream: + stream.write(f"### {scenario} / {ab_factor} block update evidence\n\n") + stream.write( + f"Capture method: `{capture_method}`; clean performance evidence ready: " + f"`{str(capture_method == 'none').lower()}`.\n\n" + ) + stream.write("`blockUpdateChecks` is diagnostic work evidence (no universal direction).\n\n") + stream.write( + "| Run | Variant | Checks/tick | Cache | Requests | Misses | Hits | Hit rate | Same raw |\n" + ) + stream.write("|---|---:|---:|---:|---:|---:|---:|---:|---:|\n") + for record in records: + stream.write( + f"| {record['runId']} | {record['variant']} | " + f"{record['checksPerTick']:.6f} | " + f"{'on' if record['legacyTextComponentCacheEnabled'] else 'off'} | " + f"{record['legacyTextCacheRequests']} | {record['legacyTextCacheMisses']} | " + f"{record['legacyTextCacheHits']} | " + f"{record['legacyTextCacheHitRate']:.6f} | " + f"{record['legacyTextSameRawFastPaths']} |\n" + ) + if cadence_guard["evaluated"]: + stream.write( + "\nActive cadence guard: " + f"`{'pass' if cadence_guard['passed'] else 'fail'}`; " + f"candidate/baseline=`{cadence_guard['candidateToBaselineRatio']:.6f}` " + f"(required `{cadence_guard['minimumCandidateToBaselineRatio']:.2f}.." + f"{cadence_guard['maximumCandidateToBaselineRatio']:.2f}`).\n" + ) + + if cadence_guard["evaluated"] and not cadence_guard["passed"]: + raise SystemExit( + "block-active candidate checks/tick escaped the expected mixed-type cadence: " + f"ratio={cadence_guard['candidateToBaselineRatio']:.6f}" + ) + PY + fi + + - name: Publish runtime evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: phase2-${{ env.CAMPAIGN_EVIDENCE_KIND }}-runtime-${{ env.CAMPAIGN_SCENARIO }}-${{ env.CAMPAIGN_AB_FACTOR }}-${{ github.sha }}-${{ github.run_id }} + path: | + phase2-results/${{ env.CAMPAIGN_EVIDENCE_KIND }} + phase2-dependencies/protocol-client/client-build-manifest.json + phase2-dependencies/protocol-client/client-files.sha256 + phase2-dependencies/protocol-client/node-minecraft-protocol/package-lock.json + phase2-dependencies/protocol-client/node-minecraft-protocol/production-lock-inventory.json + if-no-files-found: warn + retention-days: 30 diff --git a/abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/BillboardDisplayEntity.java b/abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/BillboardDisplayEntity.java index 62e5e3ce..5241f4c5 100644 --- a/abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/BillboardDisplayEntity.java +++ b/abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/BillboardDisplayEntity.java @@ -17,11 +17,15 @@ import org.bukkit.util.Vector; /** - * Shared billboard replacement for the former per-viewer moving hologram. - * Client-side billboard rotation replaces O(players) teleport packets. + * Text-display replacement for the former per-viewer moving hologram. + * Viewer positions are calculated exactly as the legacy surrounding-plane + * marker entity and synchronized only after that viewer actually moves. */ public final class BillboardDisplayEntity extends DisplayEntity implements DynamicVisualizerEntity { + private static final double RIGHT_ANGLE = Math.PI / 2.0D; + private static final double FORTY_FIVE_DEGREES = RIGHT_ANGLE / 2.0D; + private double radius; private PathType path; @@ -29,12 +33,77 @@ public BillboardDisplayEntity(Location location, double radius, PathType path) { super(location); this.radius = radius; this.path = path; - setBillboard(Display.Billboard.VERTICAL); + setBillboard(Display.Billboard.CENTER); } @Override public Location getViewingLocation(Location from, Vector direction) { - return getLocation(); + Location location = getLocation(); + if (!from.getWorld().equals(location.getWorld())) { + throw new IllegalArgumentException("Cannot view billboard in " + + location.getWorld().getName() + " from " + from.getWorld().getName()); + } + return location.add(getViewingVector(location, from, direction, radius, path)); + } + + private static Vector getViewingVector(Location location, Location from, Vector direction, + double radius, PathType path) { + Location leveled = from.clone(); + leveled.setY(location.getY()); + if (location.distanceSquared(leveled) < radius * radius) { + Vector vector = direction.clone().setY(0.0D); + if (vector.getX() == 0.0D && vector.getZ() == 0.0D) { + vector.setX(0.001D); + } + vector.normalize().multiply(radius + 2.0D); + return getViewingVector(location, leveled.add(vector), direction, radius, path); + } + + Vector vector; + switch (path) { + case SQUARE: + Vector axis = location.clone().add(1.0D, 0.0D, 0.0D).toVector() + .subtract(location.toVector()).normalize(); + vector = leveled.toVector().subtract(location.toVector()).normalize(); + double rawAngle = Math.abs(axis.angle(vector)); + double angle = rawAngle % FORTY_FIVE_DEGREES; + if (rawAngle % RIGHT_ANGLE > FORTY_FIVE_DEGREES) { + angle = FORTY_FIVE_DEGREES - angle; + } + vector.multiply(radius / Math.cos(angle)); + break; + case CIRCLE: + vector = leveled.toVector().subtract(location.toVector()).normalize().multiply(radius); + break; + case FACE: + default: + Vector facing = leveled.toVector().subtract(location.toVector()).normalize(); + Location origin = location.clone().setDirection(facing); + origin.setYaw(getCardinalDirection(origin)); + vector = origin.getDirection().normalize().multiply(radius); + break; + } + return vector; + } + + static float getCardinalDirection(Location location) { + double rotation = (location.getYaw() - 90.0F) % 360.0F; + if (rotation < 0.0D) { + rotation += 360.0D; + } + if (rotation < 45.0D) { + return 90.0F; + } + if (rotation < 135.0D) { + return 180.0F; + } + if (rotation < 225.0D) { + return -90.0F; + } + if (rotation < 315.0D) { + return 0.0F; + } + return 90.0F; } @Override diff --git a/abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/DisplayEntity.java b/abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/DisplayEntity.java index b99457c0..c5429f01 100644 --- a/abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/DisplayEntity.java +++ b/abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/DisplayEntity.java @@ -12,9 +12,8 @@ package com.loohp.interactionvisualizer.entityholders; -import com.loohp.interactionvisualizer.utils.ComponentFont; +import com.loohp.interactionvisualizer.utils.LegacyTextComponentCache; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.entity.Display; @@ -38,10 +37,18 @@ public class DisplayEntity extends VisualizerEntity { private EulerAngle headPose; private ItemStack helmet; private ItemStack mainHand; + private boolean legacyRightHandItemTransform; + private boolean legacyNameTagGeometry; + private boolean legacyNameTagStyle; private Component customName; + private String customNameRawSource; + private boolean customNameRawSourceKnown; private boolean customNameVisible; + private boolean defaultBackground; private Vector velocity; private Display.Billboard billboard; + private float textScale; + private boolean unboundedTextWidth; private float viewRange; private int interpolationDuration; private int teleportDuration; @@ -55,8 +62,14 @@ public DisplayEntity(Location location) { this.headPose = EulerAngle.ZERO; this.helmet = ItemStack.empty(); this.mainHand = ItemStack.empty(); + this.legacyRightHandItemTransform = false; + this.legacyNameTagGeometry = false; + this.legacyNameTagStyle = false; + this.defaultBackground = false; this.velocity = new Vector(); this.billboard = Display.Billboard.FIXED; + this.textScale = 0.5F; + this.unboundedTextWidth = false; this.viewRange = 1.0F; this.interpolationDuration = 3; this.teleportDuration = 3; @@ -67,15 +80,46 @@ public Component getCustomName() { } public void setCustomName(String customName) { - setCustomName(customName == null ? null : ComponentFont.parseFont( - LegacyComponentSerializer.legacySection().deserialize(customName))); + updateCustomName(customName); } public void setCustomName(Component customName) { + customNameRawSource = null; + customNameRawSourceKnown = false; + assignCustomName(customName); + } + + public boolean updateCustomName(String customName) { + if (LegacyTextComponentCache.isEnabled() && customNameRawSourceKnown + && java.util.Objects.equals(customNameRawSource, customName)) { + if (customName != null) { + LegacyTextComponentCache.recordSameRawFastPath(); + } + return false; + } + Component parsed = customName == null ? null : LegacyTextComponentCache.parse(customName); + boolean changed = assignCustomName(parsed); + customNameRawSource = LegacyTextComponentCache.isEnabled() ? customName : null; + customNameRawSourceKnown = LegacyTextComponentCache.isEnabled(); + return changed; + } + + public boolean updateCustomName(String customName, boolean visible) { + boolean changed = updateCustomName(customName); + if (customNameVisible != visible) { + setCustomNameVisible(visible); + return true; + } + return changed; + } + + private boolean assignCustomName(Component customName) { if (!java.util.Objects.equals(this.customName, customName)) { this.customName = customName; markDirty(); + return true; } + return false; } public boolean isCustomNameVisible() { @@ -89,8 +133,25 @@ public void setCustomNameVisible(boolean visible) { } } + public boolean isDefaultBackground() { + return defaultBackground; + } + + public void setDefaultBackground(boolean defaultBackground) { + if (this.defaultBackground != defaultBackground) { + this.defaultBackground = defaultBackground; + markDirty(); + } + } + public boolean isTextDisplay() { - return customNameVisible; + /* + * Legacy name-tag holders remain text-shaped even while their name is + * temporarily hidden. Otherwise a lectern (and every other toggled + * hologram) is first created as an empty ItemDisplay and must be + * destroyed/re-spawned as a TextDisplay when its text becomes visible. + */ + return legacyNameTagGeometry || customNameVisible; } public ItemStack getDisplayItem() { @@ -99,7 +160,65 @@ public ItemStack getDisplayItem() { } public ItemDisplay.ItemDisplayTransform getItemDisplayTransform() { - return helmet.isEmpty() ? ItemDisplay.ItemDisplayTransform.FIXED : ItemDisplay.ItemDisplayTransform.HEAD; + if (helmet != null && !helmet.isEmpty()) { + return ItemDisplay.ItemDisplayTransform.HEAD; + } + return usesLegacyRightHandItemTransform() + ? ItemDisplay.ItemDisplayTransform.THIRDPERSON_RIGHTHAND + : ItemDisplay.ItemDisplayTransform.FIXED; + } + + /** + * The public item-holding API still exposes its historical stand-shaped + * state. An explicit profile flag distinguishes that compatibility surface + * from ordinary fixed ItemDisplays without parsing its reserved custom name + * on every refresh. + */ + public boolean usesLegacyRightHandItemTransform() { + return legacyRightHandItemTransform && (helmet == null || helmet.isEmpty()); + } + + public void setLegacyRightHandItemTransform(boolean legacyRightHandItemTransform) { + if (!lock && this.legacyRightHandItemTransform != legacyRightHandItemTransform) { + this.legacyRightHandItemTransform = legacyRightHandItemTransform; + markDirty(); + } + } + + /** + * Marks this text as a replacement for a visible custom name on the + * legacy marker entity. The client renders TextDisplay text from a + * different origin, so the renderer also uses this marker to apply the + * exact name-tag anchor compensation. + */ + public boolean usesLegacyNameTagStyle() { + return legacyNameTagStyle; + } + + /** + * Restores only the legacy name tag's 1:1 glyph size and attachment + * origin, leaving background and billboard choices untouched. + */ + public boolean usesLegacyNameTagGeometry() { + return legacyNameTagGeometry; + } + + public void useLegacyNameTagGeometry() { + if (!legacyNameTagGeometry) { + legacyNameTagGeometry = true; + markDirty(); + } + setTextScale(1.0F); + } + + public void useLegacyNameTagStyle() { + useLegacyNameTagGeometry(); + if (!legacyNameTagStyle) { + legacyNameTagStyle = true; + markDirty(); + } + setDefaultBackground(true); + setBillboard(Display.Billboard.CENTER); } public void setItemInMainHand(ItemStack item) { @@ -215,6 +334,29 @@ public void setBillboard(Display.Billboard billboard) { } } + public float getTextScale() { + return textScale; + } + + public void setTextScale(float textScale) { + float value = Float.isFinite(textScale) ? Math.max(0.0F, textScale) : 0.5F; + if (this.textScale != value) { + this.textScale = value; + markDirty(); + } + } + + public boolean usesUnboundedTextWidth() { + return unboundedTextWidth || legacyNameTagStyle; + } + + public void setUnboundedTextWidth(boolean unboundedTextWidth) { + if (this.unboundedTextWidth != unboundedTextWidth) { + this.unboundedTextWidth = unboundedTextWidth; + markDirty(); + } + } + public float getViewRange() { return viewRange; } diff --git a/abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/Item.java b/abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/Item.java index 5f4fac9d..1fc7aba3 100644 --- a/abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/Item.java +++ b/abstraction/src/main/java/com/loohp/interactionvisualizer/entityholders/Item.java @@ -12,9 +12,8 @@ package com.loohp.interactionvisualizer.entityholders; -import com.loohp.interactionvisualizer.utils.ComponentFont; +import com.loohp.interactionvisualizer.utils.LegacyTextComponentCache; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.inventory.ItemStack; @@ -26,35 +25,124 @@ */ public class Item extends VisualizerEntity { + /** + * Rendering presentation for one logical item. + * + *

{@link #DROPPED} preserves the Furnace/Sparrow fake-item path. The + * remaining modes use a fixed Paper ItemDisplay while sharing the same + * logical lifecycle, viewer filtering, updates, and removal API.

+ */ + public enum RenderMode { + DROPPED, + ITEM, + BLOCK, + LOW_BLOCK, + TOOL, + STANDING, + BANNER, + FRAME; + + public boolean isFixedDisplay() { + return this != DROPPED; + } + } + private ItemStack item; + private RenderMode renderMode; + private int frameRotation; private boolean gravity; private boolean glowing; private int pickupDelay; private Component customName; + private String customNameRawSource; + private boolean customNameRawSourceKnown; private boolean customNameVisible; private Vector velocity; public Item(Location location) { + this(location, RenderMode.DROPPED); + } + + public Item(Location location, RenderMode renderMode) { super(location); - this.item = ItemStack.of(Material.STONE); + this.renderMode = java.util.Objects.requireNonNull(renderMode, "renderMode"); + this.item = renderMode.isFixedDisplay() ? ItemStack.empty() : ItemStack.of(Material.STONE); this.gravity = false; this.velocity = new Vector(); } + public RenderMode getRenderMode() { + return renderMode; + } + + public void setRenderMode(RenderMode renderMode) { + if (lock) { + return; + } + RenderMode value = java.util.Objects.requireNonNull(renderMode, "renderMode"); + if (this.renderMode != value) { + this.renderMode = value; + if (!value.isFixedDisplay() && item.isEmpty()) { + item = ItemStack.of(Material.STONE); + } + markDirty(); + } + } + + public boolean isFixedDisplay() { + return renderMode.isFixedDisplay(); + } + + public int getFrameRotation() { + return frameRotation; + } + + public void setFrameRotation(int frameRotation) { + if (frameRotation < 0 || frameRotation > 7) { + throw new IllegalArgumentException("Item frame rotation must be between 0 and 7"); + } + if (!lock && this.frameRotation != frameRotation) { + this.frameRotation = frameRotation; + markDirty(); + } + } + public Component getCustomName() { return customName; } public void setCustomName(String name) { - setCustomName(name == null ? null : ComponentFont.parseFont( - LegacyComponentSerializer.legacySection().deserialize(name))); + updateCustomName(name); } public void setCustomName(Component name) { + customNameRawSource = null; + customNameRawSourceKnown = false; + assignCustomName(name); + } + + public boolean updateCustomName(String name) { + if (LegacyTextComponentCache.isEnabled() && customNameRawSourceKnown + && java.util.Objects.equals(customNameRawSource, name)) { + if (name != null) { + LegacyTextComponentCache.recordSameRawFastPath(); + } + return false; + } + Component parsed = name == null ? null : LegacyTextComponentCache.parse(name); + boolean changed = assignCustomName(parsed); + customNameRawSource = LegacyTextComponentCache.isEnabled() ? name : null; + customNameRawSourceKnown = LegacyTextComponentCache.isEnabled(); + return changed; + } + + private boolean assignCustomName(Component name) { if (!java.util.Objects.equals(customName, name)) { customName = name; markDirty(); + return true; } + return false; } public boolean isGlowing() { @@ -92,7 +180,9 @@ public void setItemStack(ItemStack item) { } private void setItemInternal(ItemStack value) { - ItemStack normalized = value == null || value.isEmpty() ? ItemStack.of(Material.STONE) : value.clone(); + ItemStack normalized = value == null || value.isEmpty() + ? (renderMode.isFixedDisplay() ? ItemStack.empty() : ItemStack.of(Material.STONE)) + : value.clone(); if (!item.equals(normalized)) { item = normalized; markDirty(); @@ -140,6 +230,7 @@ public void setPickupDelay(int pickupDelay) { @Override public double getHeight() { - return 0.25; + return renderMode == RenderMode.BANNER ? 1.5 + : (renderMode == RenderMode.FRAME ? 0.75 : (renderMode.isFixedDisplay() ? 0.5 : 0.25)); } } diff --git a/abstraction/src/main/java/com/loohp/interactionvisualizer/objectholders/ChunkPosition.java b/abstraction/src/main/java/com/loohp/interactionvisualizer/objectholders/ChunkPosition.java index dacee402..0a56472c 100644 --- a/abstraction/src/main/java/com/loohp/interactionvisualizer/objectholders/ChunkPosition.java +++ b/abstraction/src/main/java/com/loohp/interactionvisualizer/objectholders/ChunkPosition.java @@ -61,10 +61,13 @@ public int getChunkZ() { @Override public boolean equals(Object object) { - if (!(object instanceof ChunkPosition)) { + if (this == object) { + return true; + } + if (!(object instanceof ChunkPosition other)) { return false; } - return hashCode() == object.hashCode(); + return x == other.x && z == other.z && world.equals(other.world); } @Override diff --git a/abstraction/src/main/java/com/loohp/interactionvisualizer/objectholders/SynchronizedFilteredCollection.java b/abstraction/src/main/java/com/loohp/interactionvisualizer/objectholders/SynchronizedFilteredCollection.java index cdf9540f..b93a2942 100644 --- a/abstraction/src/main/java/com/loohp/interactionvisualizer/objectholders/SynchronizedFilteredCollection.java +++ b/abstraction/src/main/java/com/loohp/interactionvisualizer/objectholders/SynchronizedFilteredCollection.java @@ -21,7 +21,9 @@ package com.loohp.interactionvisualizer.objectholders; import java.lang.reflect.Array; +import java.util.AbstractCollection; import java.util.Collection; +import java.util.Collections; import java.util.Iterator; import java.util.Objects; import java.util.Spliterator; @@ -51,6 +53,39 @@ public static SynchronizedFilteredCollection from(Collection backingCo return new SynchronizedFilteredCollection(backingCollection, e -> true); } + /** + * Returns a read-only collection that preserves the snapshot-free + * {@link #anyMatch(Collection, Predicate)} path when the backing collection + * is a synchronized filtered view. + */ + public static Collection unmodifiableCollection(Collection backingCollection) { + return new ReadOnlyCollection<>(Objects.requireNonNull(backingCollection, "backingCollection")); + } + + /** + * Tests a collection without materializing an iterator snapshot when it is + * one of this class's filtered or read-only views. The matcher must be a + * side-effect-free read: mutating the same collection from the matcher would + * attempt a read-to-write lock upgrade. + */ + @SuppressWarnings("unchecked") + public static boolean anyMatch(Collection collection, Predicate matcher) { + Objects.requireNonNull(collection, "collection"); + Objects.requireNonNull(matcher, "matcher"); + if (collection instanceof SynchronizedFilteredCollection filtered) { + return ((SynchronizedFilteredCollection) filtered).anyMatch(matcher); + } + if (collection instanceof ReadOnlyCollection readOnly) { + return ((ReadOnlyCollection) readOnly).anyMatch(matcher); + } + for (E element : collection) { + if (matcher.test(element)) { + return true; + } + } + return false; + } + private final Collection backingCollection; private final Predicate predicate; private final ReentrantReadWriteLock lock; @@ -73,6 +108,34 @@ public ReentrantReadWriteLock getLock() { return lock; } + /** + * Tests the live filtered view while holding the shared read lock, without + * materializing the snapshot used by {@link #iterator()} and {@link #stream()}. + */ + public boolean anyMatch(Predicate matcher) { + Objects.requireNonNull(matcher, "matcher"); + try { + lock.readLock().lock(); + return anyMatchUnlocked(matcher); + } finally { + lock.readLock().unlock(); + } + } + + @SuppressWarnings("unchecked") + private boolean anyMatchUnlocked(Predicate matcher) { + if (backingCollection instanceof SynchronizedFilteredCollection filtered) { + SynchronizedFilteredCollection nested = (SynchronizedFilteredCollection) filtered; + return nested.anyMatchUnlocked(element -> predicate.test(element) && matcher.test(element)); + } + for (E element : backingCollection) { + if (predicate.test(element) && matcher.test(element)) { + return true; + } + } + return false; + } + @Override public int size() { try { @@ -95,12 +158,7 @@ public boolean isEmpty() { @Override public boolean contains(Object o) { - try { - lock.readLock().lock(); - return backingCollection.stream().filter(predicate).anyMatch(each -> Objects.equals(each, o)); - } finally { - lock.readLock().unlock(); - } + return anyMatch(each -> Objects.equals(each, o)); } @Override @@ -294,4 +352,84 @@ public Stream parallelStream() { } } + private static final class ReadOnlyCollection extends AbstractCollection { + + private final Collection backingCollection; + private final Collection readOnlyDelegate; + + private ReadOnlyCollection(Collection backingCollection) { + this.backingCollection = backingCollection; + this.readOnlyDelegate = Collections.unmodifiableCollection(backingCollection); + } + + private boolean anyMatch(Predicate matcher) { + return SynchronizedFilteredCollection.anyMatch(backingCollection, matcher); + } + + @Override + public Iterator iterator() { + return readOnlyDelegate.iterator(); + } + + @Override + public int size() { + return readOnlyDelegate.size(); + } + + @Override + public boolean isEmpty() { + return readOnlyDelegate.isEmpty(); + } + + @Override + public boolean contains(Object object) { + return readOnlyDelegate.contains(object); + } + + @Override + public Object[] toArray() { + return readOnlyDelegate.toArray(); + } + + @Override + public T[] toArray(T[] array) { + return readOnlyDelegate.toArray(array); + } + + @Override + public boolean add(E element) { + return readOnlyDelegate.add(element); + } + + @Override + public boolean remove(Object object) { + return readOnlyDelegate.remove(object); + } + + @Override + public boolean addAll(Collection collection) { + return readOnlyDelegate.addAll(collection); + } + + @Override + public boolean removeAll(Collection collection) { + return readOnlyDelegate.removeAll(collection); + } + + @Override + public boolean retainAll(Collection collection) { + return readOnlyDelegate.retainAll(collection); + } + + @Override + public boolean removeIf(Predicate filter) { + return readOnlyDelegate.removeIf(filter); + } + + @Override + public void clear() { + readOnlyDelegate.clear(); + } + } + } diff --git a/abstraction/src/main/java/com/loohp/interactionvisualizer/utils/LegacyTextComponentCache.java b/abstraction/src/main/java/com/loohp/interactionvisualizer/utils/LegacyTextComponentCache.java new file mode 100644 index 00000000..be9e6aa0 --- /dev/null +++ b/abstraction/src/main/java/com/loohp/interactionvisualizer/utils/LegacyTextComponentCache.java @@ -0,0 +1,150 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.utils; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.LongAdder; + +/** + * Shared, bounded conversion cache for legacy display text. + * + *

Progress displays tend to move through a small set of synchronized text + * states across hundreds of entities. Caching the immutable Adventure component + * keeps the exact legacy and {@code [font=...]} parsing semantics while avoiding + * the same flatten/compact work for every entity. Very large one-off strings + * bypass the cache, and the maximum size bounds all remaining key cardinality.

+ */ +public final class LegacyTextComponentCache { + + public static final int MAXIMUM_SIZE = 1_024; + public static final int MAXIMUM_CACHEABLE_LENGTH = 2_048; + public static final int ENTRY_BASE_WEIGHT = 256; + public static final long MAXIMUM_WEIGHT = (long) MAXIMUM_SIZE * ENTRY_BASE_WEIGHT; + public static final String DISABLE_PROPERTY = "interactionvisualizer.disableLegacyTextComponentCache"; + + private static final boolean ENABLED = !Boolean.getBoolean(DISABLE_PROPERTY); + private static final Cache CACHE = Caffeine.newBuilder() + // The base weight preserves the 1,024-entry ceiling; raw length also + // prevents a small number of style-heavy strings from owning it. + .maximumWeight(MAXIMUM_WEIGHT) + .weigher((String rawText, Component ignored) -> ENTRY_BASE_WEIGHT + rawText.length()) + // The cache is tiny; keep maintenance deterministic and off the JVM common pool. + .executor(Runnable::run) + .build(); + private static final AtomicReference ACTIVE_MEASUREMENT = new AtomicReference<>(); + private static volatile CacheMetrics latestMetrics = CacheMetrics.EMPTY; + + private LegacyTextComponentCache() { + } + + public static Component parse(String rawText) { + Objects.requireNonNull(rawText, "rawText"); + Measurement measurement = ACTIVE_MEASUREMENT.get(); + if (!ENABLED || rawText.length() > MAXIMUM_CACHEABLE_LENGTH) { + recordCacheOutcome(measurement, false); + return parseUncached(rawText); + } + Component cached = CACHE.getIfPresent(rawText); + if (cached != null) { + recordCacheOutcome(measurement, true); + return cached; + } + recordCacheOutcome(measurement, false); + return CACHE.get(rawText, LegacyTextComponentCache::parseUncached); + } + + private static void recordCacheOutcome(Measurement measurement, boolean hit) { + if (measurement == null) { + return; + } + if (hit) { + measurement.hits.increment(); + } else { + measurement.misses.increment(); + } + } + + private static Component parseUncached(String rawText) { + return ComponentFont.parseFont(LegacyComponentSerializer.legacySection().deserialize(rawText)); + } + + public static boolean isEnabled() { + return ENABLED; + } + + public static void recordSameRawFastPath() { + Measurement measurement = ACTIVE_MEASUREMENT.get(); + if (measurement != null) { + measurement.sameRawFastPaths.increment(); + } + } + + public static void startMeasurement() { + latestMetrics = CacheMetrics.EMPTY; + ACTIVE_MEASUREMENT.set(new Measurement()); + } + + public static CacheMetrics stopMeasurement() { + Measurement measurement = ACTIVE_MEASUREMENT.getAndSet(null); + if (measurement != null) { + latestMetrics = snapshot(measurement); + } + return latestMetrics; + } + + public static CacheMetrics metrics() { + Measurement measurement = ACTIVE_MEASUREMENT.get(); + return measurement == null ? latestMetrics : snapshot(measurement); + } + + public static void invalidateAll() { + CACHE.invalidateAll(); + CACHE.cleanUp(); + } + + static long estimatedSize() { + CACHE.cleanUp(); + return CACHE.estimatedSize(); + } + + private static CacheMetrics snapshot(Measurement measurement) { + long hits = measurement.hits.sum(); + long misses = measurement.misses.sum(); + return new CacheMetrics(hits + misses, misses, measurement.sameRawFastPaths.sum()); + } + + private static final class Measurement { + + private final LongAdder hits = new LongAdder(); + private final LongAdder misses = new LongAdder(); + private final LongAdder sameRawFastPaths = new LongAdder(); + } + + public record CacheMetrics(long requests, long misses, long sameRawFastPaths) { + + private static final CacheMetrics EMPTY = new CacheMetrics(0L, 0L, 0L); + + public long hits() { + return Math.max(0L, requests - misses); + } + + public double hitRate() { + return requests == 0L ? 0.0D : (double) hits() / (double) requests; + } + } +} diff --git a/benchmark/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemBenchmarkPlugin.java b/benchmark/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemBenchmarkPlugin.java new file mode 100644 index 00000000..909f4908 --- /dev/null +++ b/benchmark/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemBenchmarkPlugin.java @@ -0,0 +1,867 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.Item; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.util.Vector; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.function.LongSupplier; + +/** + * Disposable benchmark plugin run only by the GitHub Actions performance job. + * It deliberately alternates A/B order and reports medians and p95 values. + */ +public final class DroppedItemBenchmarkPlugin extends JavaPlugin { + + private static final int WARMUP_ROUNDS = 12; + private static final int MEASUREMENT_ROUNDS = 42; + private static final int VISIBILITY_SEEDS = 3; + private static final long SAMPLE_TARGET_NANOS = 2_000_000L; + private static final int MAX_SAMPLE_REPETITIONS = 4_096; + private static final double DEFAULT_VIEW_DISTANCE = 72.0D; + private static final List PRODUCTION_VISIBILITY_RANGES = List.of(64.0D, 80.0D); + private static final List PRODUCTION_GRID_PHASES = List.of( + new GridPhase("boundary", 0.0D, 0.0D), + new GridPhase("middle", 7.875D, 7.875D), + new GridPhase("near-boundary", 15.75D, 15.25D)); + private static final UUID BENCHMARK_WORLD_ID = UUID.fromString("f17968b7-ad29-4e08-bc29-56dc57f74b90"); + private static volatile long blackhole; + + private final List results = new ArrayList<>(); + private Path resultsOutput; + private Path completionOutput; + + @Override + public void onEnable() { + Bukkit.getScheduler().runTask(this, () -> { + try { + runBenchmarks(); + } catch (Throwable throwable) { + getLogger().severe("A/B benchmark failed: " + throwable.getMessage()); + throwable.printStackTrace(); + } finally { + Bukkit.shutdown(); + } + }); + } + + private void runBenchmarks() throws IOException { + World world = Bukkit.getWorlds().getFirst(); + world.setAutoSave(false); + world.getEntitiesByClass(Item.class).forEach(Item::remove); + Files.createDirectories(getDataFolder().toPath()); + resultsOutput = getDataFolder().toPath().resolve("benchmark-results.jsonl"); + completionOutput = getDataFolder().toPath().resolve("benchmark-complete.txt"); + Files.writeString(resultsOutput, ""); + Files.deleteIfExists(completionOutput); + assertDeterministicProductionRangePaths(); + + for (int itemCount : List.of(250, 1000, 2500)) { + benchmarkCramping(world, itemCount, false); + benchmarkCramping(world, itemCount, true); + } + for (String distribution : List.of("uniform", "hotspot", "no-hit", "enclosed-no-hit", + "diagonal-no-hit", "late-hit")) { + for (int itemCount : List.of(500, 2000, 8000)) { + for (int viewerCount : List.of(1, 8, 32, 64, 96, 128, 191, 192, 256, 384, 512, 768, 1024)) { + for (int seed = 0; seed < VISIBILITY_SEEDS; seed++) { + benchmarkVisibility(itemCount, viewerCount, distribution, seed); + } + } + } + } + for (double range : PRODUCTION_VISIBILITY_RANGES) { + for (String distribution : List.of("diagonal-no-hit", "late-hit")) { + for (int viewerCount : List.of(127, 128, 191, 192, 256)) { + for (GridPhase phase : PRODUCTION_GRID_PHASES) { + benchmarkVisibility(2000, viewerCount, distribution, 0, + range, phase, true); + } + } + } + } + for (GridPhase phase : PRODUCTION_GRID_PHASES) { + for (int seed = 0; seed < VISIBILITY_SEEDS; seed++) { + for (int itemCount : List.of(500, 8000)) { + benchmarkVisibility(itemCount, 128, "late-hit", seed, + 64.0D, phase, true); + benchmarkVisibility(itemCount, 191, "late-hit", seed, + 80.0D, phase, true); + } + } + for (double range : PRODUCTION_VISIBILITY_RANGES) { + benchmarkVisibility(8000, 191, "uniform", 0, + range, phase, true); + } + } + for (int pending : List.of(100, 500, 2000, 8000)) { + reportTokenBucket(pending, 128, 32); + } + + Files.writeString(completionOutput, Integer.toString(results.size())); + getLogger().info("Wrote " + results.size() + " A/B results to " + resultsOutput.toAbsolutePath()); + } + + private void benchmarkCramping(World world, int itemCount, boolean clustered) { + List items = spawnItems(world, itemCount, clustered); + UUID worldId = world.getUID(); + LongSupplier baseline = () -> paperNearbyQueries(items); + LongSupplier candidate = () -> indexedNearbyQueries(items, worldId); + Comparison comparison = compare(baseline, candidate); + String result = String.format(Locale.ROOT, + "{\"benchmark\":\"cramping\",\"distribution\":\"%s\",\"items\":%d," + + "\"baselineMedianNs\":%d,\"baselineP95Ns\":%d," + + "\"candidateMedianNs\":%d,\"candidateP95Ns\":%d,\"speedup\":%.3f}", + clustered ? "clustered" : "sparse", itemCount, + comparison.baseline().median(), comparison.baseline().p95(), + comparison.candidate().median(), comparison.candidate().p95(), comparison.speedup()); + report(result); + items.forEach(Item::remove); + } + + private List spawnItems(World world, int itemCount, boolean clustered) { + int logicalCount = clustered ? (itemCount + 7) / 8 : itemCount; + int columns = (int) Math.ceil(Math.sqrt(logicalCount)); + double spacing = clustered ? 2.0D : 1.25D; + List items = new ArrayList<>(itemCount); + for (int index = 0; index < itemCount; index++) { + int logicalIndex = clustered ? index / 8 : index; + double baseX = 8.0D + (logicalIndex % columns) * spacing; + double baseZ = 8.0D + (logicalIndex / columns) * spacing; + int clusterIndex = clustered ? index % 8 : 0; + double x = baseX + (clusterIndex & 1) * 0.12D; + double y = 80.0D + ((clusterIndex >>> 1) & 1) * 0.12D; + double z = baseZ + ((clusterIndex >>> 2) & 1) * 0.12D; + world.getChunkAt((int) Math.floor(x) >> 4, (int) Math.floor(z) >> 4).load(); + ItemStack stack = new ItemStack(Material.STONE); + int itemId = index; + stack.editMeta(meta -> meta.customName(Component.text("iv-benchmark-" + itemId))); + Item item = world.dropItem(new Location(world, x, y, z), stack, entity -> { + entity.setGravity(false); + entity.setVelocity(new Vector()); + entity.setPickupDelay(Integer.MAX_VALUE); + entity.setWillAge(false); + }); + items.add(item); + } + return items; + } + + private static long paperNearbyQueries(List items) { + long cramped = 0; + for (Item item : items) { + if (item.getWorld().getNearbyEntitiesByType(Item.class, item.getLocation(), 0.5D, 0.5D, 0.5D) + .stream().limit(7L).count() > 6L) { + cramped++; + } + } + blackhole = cramped; + return cramped; + } + + private static long indexedNearbyQueries(List items, UUID worldId) { + DroppedItemSpatialIndex index = new DroppedItemSpatialIndex(); + List locations = new ArrayList<>(items.size()); + for (Item item : items) { + Location location = item.getLocation(); + locations.add(location); + index.addItem(worldId, location.getX(), location.getY(), location.getZ()); + } + long cramped = 0; + for (Location location : locations) { + if (index.exceedsItemLimit(worldId, location.getX(), location.getY(), location.getZ(), 6)) { + cramped++; + } + } + blackhole = cramped; + return cramped; + } + + private void benchmarkVisibility(int itemCount, int viewerCount, String distribution, int seed) { + benchmarkVisibility(itemCount, viewerCount, distribution, seed, + DEFAULT_VIEW_DISTANCE, new GridPhase("default", 0.0D, 0.0D), false); + } + + private void benchmarkVisibility(int itemCount, int viewerCount, String distribution, int seed, + double range, GridPhase gridPhase, boolean productionRangeProbe) { + Random random = new Random(0x51A71A1L + itemCount * 31L + viewerCount * 17L + + distribution.hashCode() * 13L + seed * 0x9E3779B9L); + List items; + List viewers; + switch (distribution) { + case "uniform" -> { + items = randomPoints(random, itemCount, 0.0D, 1024.0D); + viewers = randomPoints(random, viewerCount, 0.0D, 1024.0D); + } + case "hotspot" -> { + items = randomPoints(random, itemCount, 448.0D, 128.0D); + viewers = randomPoints(random, viewerCount, 448.0D, 128.0D); + } + case "no-hit" -> { + items = randomPoints(random, itemCount, 0.0D, 256.0D); + viewers = randomPoints(random, viewerCount, 768.0D, 256.0D); + } + case "enclosed-no-hit" -> { + items = randomPoints(random, itemCount, 448.0D, 128.0D); + viewers = enclosingRingPoints(random, viewerCount); + } + case "diagonal-no-hit" -> { + items = centralPoints(random, itemCount, 4.0D); + viewers = diagonalMissPoints(random, viewerCount, range); + } + case "late-hit" -> { + items = centralPoints(random, itemCount, 4.0D); + viewers = lateHitPoints(random, viewerCount); + } + default -> throw new IllegalArgumentException("Unknown visibility distribution: " + distribution); + } + if (gridPhase.x() != 0.0D || gridPhase.z() != 0.0D) { + items = translatePoints(items, gridPhase.x(), gridPhase.z()); + viewers = translatePoints(viewers, gridPhase.x(), gridPhase.z()); + } + List benchmarkItems = items; + List benchmarkViewers = viewers; + LongSupplier linearReference = () -> linearActiveLabels(benchmarkItems, benchmarkViewers, range); + LongSupplier legacyProduction = () -> legacyGridActiveLabels(benchmarkItems, benchmarkViewers, range); + LongSupplier primitiveControl = () -> primitiveControlActiveLabels(benchmarkItems, benchmarkViewers, range); + LongSupplier candidate = () -> indexedActiveLabels(benchmarkItems, benchmarkViewers, range); + long expectedLabels = linearReference.getAsLong(); + long legacyLabels = legacyProduction.getAsLong(); + long controlLabels = primitiveControl.getAsLong(); + long candidateLabels = candidate.getAsLong(); + if (expectedLabels != legacyLabels || expectedLabels != controlLabels || expectedLabels != candidateLabels) { + throw new IllegalStateException("Visibility A/B mismatch: linear=" + expectedLabels + + ", legacy=" + legacyLabels + ", control=" + controlLabels + + ", candidate=" + candidateLabels); + } + if ((distribution.endsWith("no-hit") && expectedLabels != 0L) + || (distribution.equals("late-hit") && expectedLabels != itemCount)) { + throw new IllegalStateException("Invalid " + distribution + " fixture: active=" + expectedLabels); + } + int startingPermutation = Math.floorMod(itemCount * 31 + viewerCount * 17 + seed, 6); + ThreeWayComparison comparisons = compareThree( + legacyProduction, primitiveControl, candidate, startingPermutation); + AdaptivePathProbe pathProbe = probeAdaptivePaths(benchmarkItems, benchmarkViewers, range); + if (pathProbe.activeLabels() != expectedLabels) { + throw new IllegalStateException("Adaptive path probe mismatch: expected=" + expectedLabels + + ", probe=" + pathProbe.activeLabels()); + } + if (productionRangeProbe) { + boolean mustRejectGrid = distribution.equals("diagonal-no-hit") + || distribution.equals("late-hit") && (viewerCount < 128 + || range == 80.0D && viewerCount == 128); + if (mustRejectGrid && pathProbe.gridBuilt()) { + throw new IllegalStateException("Unexpected production-range grid build: range=" + range + + ", phase=" + gridPhase.name() + ", distribution=" + distribution + + ", viewers=" + viewerCount); + } + } + double reduction = itemCount == 0 ? 0.0D : 100.0D * (itemCount - expectedLabels) / itemCount; + reportVisibilityComparison(productionRangeProbe + ? "visibility-production-range-ab" : "visibility-production-ab", + "legacy-production-viewer-grid", true, + distribution, seed, itemCount, viewerCount, comparisons.legacyCandidate(), + pathProbe, expectedLabels, reduction, range, gridPhase); + reportVisibilityComparison(productionRangeProbe + ? "visibility-adaptive-range-control-ab" : "visibility-adaptive-control-ab", + "pure-primitive-soa-control", false, + distribution, seed, itemCount, viewerCount, comparisons.controlCandidate(), + pathProbe, expectedLabels, reduction, range, gridPhase); + } + + private void reportVisibilityComparison(String benchmark, String baseline, boolean baselineUsesGrid, + String distribution, int seed, int itemCount, int viewerCount, + Comparison comparison, AdaptivePathProbe pathProbe, + long activeLabels, double reduction, double range, + GridPhase gridPhase) { + String result = String.format(Locale.ROOT, + "{\"benchmark\":\"%s\",\"distribution\":\"%s\",\"seed\":%d," + + "\"items\":%d,\"viewers\":%d," + + "\"range\":%.1f,\"gridPhase\":\"%s\"," + + "\"gridPhaseX\":%.3f,\"gridPhaseZ\":%.3f," + + "\"baseline\":\"%s\",\"baselineUsesGrid\":%b," + + "\"baselineUsesBounds\":false,\"sharedCandidateSample\":true," + + "\"candidate\":\"production-adaptive-primitive-viewer-index\"," + + "\"candidateStorage\":\"primitive-soa\"," + + "\"indexRebuiltPerOperation\":true,\"candidateUsesGrid\":true," + + "\"candidateUsesBounds\":true,\"candidateBuildsIndexesLazily\":true," + + "\"probeGridBuilt\":%b,\"probeGridActiveAtEnd\":%b," + + "\"probeBoundsActiveAtEnd\":%b," + + "\"sampleTargetNs\":%d,\"warmupRounds\":%d,\"measurementRounds\":%d," + + "\"roundOrder\":\"%s\"," + + "\"baselineMedianNs\":%d,\"baselineP95Ns\":%d," + + "\"candidateMedianNs\":%d,\"candidateP95Ns\":%d,\"speedup\":%.3f," + + "\"baselineFirstBaselineMedianNs\":%d," + + "\"baselineFirstCandidateMedianNs\":%d," + + "\"baselineFirstBaselineP95Ns\":%d," + + "\"baselineFirstCandidateP95Ns\":%d," + + "\"baselineFirstSpeedup\":%.3f," + + "\"candidateFirstBaselineMedianNs\":%d," + + "\"candidateFirstCandidateMedianNs\":%d," + + "\"candidateFirstBaselineP95Ns\":%d," + + "\"candidateFirstCandidateP95Ns\":%d," + + "\"candidateFirstSpeedup\":%.3f," + + "\"baselineRoundsNs\":%s,\"candidateRoundsNs\":%s," + + "\"allLabels\":%d,\"activeLabels\":%d," + + "\"labelReductionPct\":%.3f}", + benchmark, distribution, seed, itemCount, viewerCount, range, + gridPhase.name(), gridPhase.x(), gridPhase.z(), baseline, baselineUsesGrid, + pathProbe.gridBuilt(), pathProbe.gridActiveAtEnd(), pathProbe.boundsActiveAtEnd(), + SAMPLE_TARGET_NANOS, WARMUP_ROUNDS, MEASUREMENT_ROUNDS, comparison.roundOrder(), + comparison.baseline().median(), comparison.baseline().p95(), + comparison.candidate().median(), comparison.candidate().p95(), comparison.speedup(), + comparison.baselineFirst().baseline().median(), + comparison.baselineFirst().candidate().median(), + comparison.baselineFirst().baseline().p95(), comparison.baselineFirst().candidate().p95(), + comparison.baselineFirst().speedup(), + comparison.candidateFirst().baseline().median(), + comparison.candidateFirst().candidate().median(), + comparison.candidateFirst().baseline().p95(), comparison.candidateFirst().candidate().p95(), + comparison.candidateFirst().speedup(), + Arrays.toString(comparison.baselineRoundsNs()), Arrays.toString(comparison.candidateRoundsNs()), + itemCount, activeLabels, reduction); + report(result); + } + + private static List translatePoints(List points, double deltaX, double deltaZ) { + List translated = new ArrayList<>(points.size()); + for (Point point : points) { + translated.add(new Point(point.id(), point.x() + deltaX, point.y(), point.z() + deltaZ)); + } + return translated; + } + + private static void assertDeterministicProductionRangePaths() { + for (GridPhase phase : PRODUCTION_GRID_PHASES) { + Random random = new Random(0x26_01_02L); + List items = identicalPoints(2000, 512.0D, 80.0D, 512.0D); + items = translatePoints(items, phase.x(), phase.z()); + + List showThresholdViewers = translatePoints( + lateHitPoints(random, 128), phase.x(), phase.z()); + AdaptivePathProbe showThreshold = probeAdaptivePaths( + items, showThresholdViewers, 64.0D); + if (!showThreshold.gridBuilt()) { + throw new IllegalStateException("64-block deterministic threshold did not build grid: phase=" + + phase.name()); + } + + List hideThresholdViewers = translatePoints( + lateHitPoints(random, 191), phase.x(), phase.z()); + AdaptivePathProbe hideThreshold = probeAdaptivePaths( + items, hideThresholdViewers, 80.0D); + if (!hideThreshold.gridBuilt()) { + throw new IllegalStateException("80-block deterministic threshold did not build grid: phase=" + + phase.name()); + } + + List belowThresholdViewers = translatePoints( + lateHitPoints(random, 127), phase.x(), phase.z()); + if (probeAdaptivePaths(items, belowThresholdViewers, 64.0D).gridBuilt()) { + throw new IllegalStateException("Below-threshold deterministic probe built grid: phase=" + + phase.name()); + } + if (probeAdaptivePaths(items, showThresholdViewers, 80.0D).gridBuilt()) { + throw new IllegalStateException("80-block cost rejection built grid at 128 viewers: phase=" + + phase.name()); + } + } + + Random random = new Random(0x26_01_02L); + List boundaryViewers128 = lateHitPoints(random, 128); + List boundaryViewers191 = lateHitPoints(random, 191); + List boundaryViewers192 = lateHitPoints(random, 192); + List boundaryViewers256 = lateHitPoints(random, 256); + List favorableItems = identicalPoints(2000, 516.0D, 80.0D, 516.0D); + List adverseItems = identicalPoints(2000, 508.0D, 80.0D, 508.0D); + if (!probeAdaptivePaths(favorableItems, boundaryViewers128, 64.0D).gridBuilt()) { + throw new IllegalStateException("64-block favorable boundary fixture did not build at 128 viewers"); + } + if (probeAdaptivePaths(adverseItems, boundaryViewers128, 64.0D).gridBuilt()) { + throw new IllegalStateException("64-block adverse boundary fixture built at 128 viewers"); + } + if (!probeAdaptivePaths(adverseItems, boundaryViewers191, 64.0D).gridBuilt()) { + throw new IllegalStateException("64-block adverse boundary fixture did not build at 191 viewers"); + } + if (!probeAdaptivePaths(favorableItems, boundaryViewers191, 80.0D).gridBuilt()) { + throw new IllegalStateException("80-block favorable boundary fixture did not build at 191 viewers"); + } + if (probeAdaptivePaths(adverseItems, boundaryViewers192, 80.0D).gridBuilt()) { + throw new IllegalStateException("80-block adverse boundary fixture built at 192 viewers"); + } + if (!probeAdaptivePaths(adverseItems, boundaryViewers256, 80.0D).gridBuilt()) { + throw new IllegalStateException("80-block adverse boundary fixture did not build at 256 viewers"); + } + } + + private static List identicalPoints(int count, double x, double y, double z) { + List points = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + points.add(new Point(index, x, y, z)); + } + return points; + } + + private static List randomPoints(Random random, int count, double offset, double width) { + List points = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + points.add(new Point(index, offset + random.nextDouble(width), 64.0D + random.nextDouble(32.0D), + offset + random.nextDouble(width))); + } + return points; + } + + /** + * Places viewers around an empty central region. From eight viewers onward the item region is + * enclosed by the viewer AABB, while every viewer remains well beyond the visibility radius. + * This prevents an outer-bounds fast rejection from disguising worst-case miss scans. + */ + private static List enclosingRingPoints(Random random, int count) { + List points = new ArrayList<>(count); + double phase = random.nextDouble(2.0D * Math.PI); + for (int index = 0; index < count; index++) { + double angle = phase + 2.0D * Math.PI * index / Math.max(1, count); + points.add(new Point(index, + 512.0D + Math.cos(angle) * 300.0D, + 64.0D + random.nextDouble(32.0D), + 512.0D + Math.sin(angle) * 300.0D)); + } + return points; + } + + private static List centralPoints(Random random, int count, double radius) { + List points = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + points.add(new Point(index, + 512.0D + random.nextDouble(-radius, radius), + 80.0D, + 512.0D + random.nextDouble(-radius, radius))); + } + return points; + } + + private static List diagonalMissPoints(Random random, int count, double range) { + List points = new ArrayList<>(count); + double minimumOffset = range - 12.0D; + double maximumOffset = range - 8.0D; + for (int index = 0; index < count; index++) { + int quadrant = index & 3; + double xSign = (quadrant & 1) == 0 ? -1.0D : 1.0D; + double zSign = (quadrant & 2) == 0 ? -1.0D : 1.0D; + points.add(new Point(index, + 512.0D + xSign * random.nextDouble(minimumOffset, maximumOffset), + 80.0D, + 512.0D + zSign * random.nextDouble(minimumOffset, maximumOffset))); + } + return points; + } + + private static List lateHitPoints(Random random, int count) { + if (count == 0) { + return List.of(); + } + List points = enclosingRingPoints(random, count - 1); + points.add(new Point(count - 1, 512.0D, 80.0D, 512.0D)); + return points; + } + + private static long linearActiveLabels(List items, List viewers, double range) { + List viewerSnapshot = new ArrayList<>(viewers.size()); + for (Point viewer : viewers) { + viewerSnapshot.add(new Point(viewer.id(), viewer.x(), viewer.y(), viewer.z())); + } + long active = 0; + double rangeSquared = range * range; + for (Point item : items) { + for (Point viewer : viewerSnapshot) { + if (item.distanceSquared(viewer) <= rangeSquared) { + active++; + break; + } + } + } + blackhole = active; + return active; + } + + private static long legacyGridActiveLabels(List items, List viewers, double range) { + LegacyViewerIndex index = new LegacyViewerIndex(); + for (Point viewer : viewers) { + index.addViewer(BENCHMARK_WORLD_ID, viewer.x(), viewer.y(), viewer.z()); + } + long active = 0; + for (int itemIndex = 0; itemIndex < items.size(); itemIndex++) { + Point item = items.get(itemIndex); + if (index.hasViewerWithin(BENCHMARK_WORLD_ID, item.x(), item.y(), item.z(), range)) { + active++; + } + } + blackhole = active; + return active; + } + + private static long indexedActiveLabels(List items, List viewers, double range) { + DroppedItemSpatialIndex.ViewerIndex index = createViewerIndex(viewers); + long active = 0; + for (int itemIndex = 0; itemIndex < items.size(); itemIndex++) { + Point item = items.get(itemIndex); + if (index.hasViewerWithin(BENCHMARK_WORLD_ID, item.x(), item.y(), item.z(), range, + items.size() - itemIndex - 1)) { + active++; + } + } + blackhole = active; + return active; + } + + private static long primitiveControlActiveLabels(List items, List viewers, double range) { + PrimitiveViewerIndex index = new PrimitiveViewerIndex(viewers.size()); + for (Point viewer : viewers) { + index.addViewer(BENCHMARK_WORLD_ID, viewer.x(), viewer.y(), viewer.z()); + } + long active = 0; + for (int itemIndex = 0; itemIndex < items.size(); itemIndex++) { + Point item = items.get(itemIndex); + if (index.hasViewerWithin(BENCHMARK_WORLD_ID, item.x(), item.y(), item.z(), range)) { + active++; + } + } + blackhole = active; + return active; + } + + private static AdaptivePathProbe probeAdaptivePaths(List items, List viewers, double range) { + DroppedItemSpatialIndex.ViewerIndex index = createViewerIndex(viewers); + long active = 0; + for (int itemIndex = 0; itemIndex < items.size(); itemIndex++) { + Point item = items.get(itemIndex); + if (index.hasViewerWithin(BENCHMARK_WORLD_ID, item.x(), item.y(), item.z(), range, + items.size() - itemIndex - 1)) { + active++; + } + } + return new AdaptivePathProbe(active, index.hasAdaptiveGrid(BENCHMARK_WORLD_ID), + index.isUsingAdaptiveGrid(BENCHMARK_WORLD_ID), + index.hasActiveBounds(BENCHMARK_WORLD_ID)); + } + + private static DroppedItemSpatialIndex.ViewerIndex createViewerIndex(List viewers) { + DroppedItemSpatialIndex.ViewerIndex index = + new DroppedItemSpatialIndex.ViewerIndex(viewers.size()); + for (Point viewer : viewers) { + index.addViewer(BENCHMARK_WORLD_ID, viewer.x(), viewer.y(), viewer.z()); + } + return index; + } + + private void reportTokenBucket(int pending, int capacity, int refill) { + int firstTick = Math.min(pending, capacity); + int remaining = Math.max(0, pending - firstTick); + int convergenceTicks = pending == 0 ? 0 : 1 + (remaining + refill - 1) / refill; + double peakReduction = pending == 0 ? 0.0D : 100.0D * (pending - firstTick) / pending; + report(String.format(Locale.ROOT, + "{\"benchmark\":\"visibility-burst\",\"pending\":%d," + + "\"baselinePeakOpsPerTick\":%d,\"candidatePeakOpsPerTick\":%d," + + "\"peakReductionPct\":%.3f,\"convergenceTicks\":%d}", + pending, pending, firstTick, peakReduction, convergenceTicks)); + } + + private static Comparison compare(LongSupplier baseline, LongSupplier candidate) { + return compare(baseline, candidate, true); + } + + private static Comparison compare(LongSupplier baseline, LongSupplier candidate, boolean baselineStarts) { + for (int round = 0; round < WARMUP_ROUNDS; round++) { + boolean baselineFirst = ((round & 1) == 0) == baselineStarts; + if (baselineFirst) { + time(baseline); + time(candidate); + } else { + time(candidate); + time(baseline); + } + } + long[] baselineTimes = new long[MEASUREMENT_ROUNDS]; + long[] candidateTimes = new long[MEASUREMENT_ROUNDS]; + boolean[] baselineFirstRounds = new boolean[MEASUREMENT_ROUNDS]; + for (int round = 0; round < MEASUREMENT_ROUNDS; round++) { + boolean baselineFirst = ((round & 1) == 0) == baselineStarts; + baselineFirstRounds[round] = baselineFirst; + if (baselineFirst) { + baselineTimes[round] = time(baseline); + candidateTimes[round] = time(candidate); + } else { + candidateTimes[round] = time(candidate); + baselineTimes[round] = time(baseline); + } + } + Samples baselineSamples = Samples.of(baselineTimes); + Samples candidateSamples = Samples.of(candidateTimes); + return new Comparison(baselineSamples, candidateSamples, + OrderStratum.of(baselineTimes, candidateTimes, baselineFirstRounds, true), + OrderStratum.of(baselineTimes, candidateTimes, baselineFirstRounds, false), + baselineStarts ? "ABBA" : "BAAB", baselineTimes, candidateTimes, + (double) baselineSamples.median() / Math.max(1L, candidateSamples.median())); + } + + private static ThreeWayComparison compareThree(LongSupplier legacy, LongSupplier control, + LongSupplier candidate, int startingPermutation) { + LongSupplier[] operations = {legacy, control, candidate}; + int[][] permutations = { + {0, 1, 2}, {0, 2, 1}, {1, 0, 2}, + {1, 2, 0}, {2, 0, 1}, {2, 1, 0} + }; + for (int round = 0; round < WARMUP_ROUNDS; round++) { + int[] order = permutations[(startingPermutation + round) % permutations.length]; + for (int operation : order) { + time(operations[operation]); + } + } + long[][] times = new long[operations.length][MEASUREMENT_ROUNDS]; + boolean[] legacyFirst = new boolean[MEASUREMENT_ROUNDS]; + boolean[] controlFirst = new boolean[MEASUREMENT_ROUNDS]; + for (int round = 0; round < MEASUREMENT_ROUNDS; round++) { + int[] order = permutations[(startingPermutation + round) % permutations.length]; + int legacyPosition = 0; + int controlPosition = 0; + int candidatePosition = 0; + for (int position = 0; position < order.length; position++) { + int operation = order[position]; + times[operation][round] = time(operations[operation]); + if (operation == 0) { + legacyPosition = position; + } else if (operation == 1) { + controlPosition = position; + } else { + candidatePosition = position; + } + } + legacyFirst[round] = legacyPosition < candidatePosition; + controlFirst[round] = controlPosition < candidatePosition; + } + String roundOrder = "BALANCED-6@" + startingPermutation; + return new ThreeWayComparison( + comparisonOf(times[0], times[2], legacyFirst, roundOrder), + comparisonOf(times[1], times[2], controlFirst, roundOrder)); + } + + private static Comparison comparisonOf(long[] baselineTimes, long[] candidateTimes, + boolean[] baselineFirstRounds, String roundOrder) { + Samples baselineSamples = Samples.of(baselineTimes); + Samples candidateSamples = Samples.of(candidateTimes); + return new Comparison(baselineSamples, candidateSamples, + OrderStratum.of(baselineTimes, candidateTimes, baselineFirstRounds, true), + OrderStratum.of(baselineTimes, candidateTimes, baselineFirstRounds, false), + roundOrder, baselineTimes, candidateTimes, + (double) baselineSamples.median() / Math.max(1L, candidateSamples.median())); + } + + private static long time(LongSupplier operation) { + long started = System.nanoTime(); + int repetitions = 0; + long elapsed; + do { + operation.getAsLong(); + repetitions++; + elapsed = System.nanoTime() - started; + } while (elapsed < SAMPLE_TARGET_NANOS && repetitions < MAX_SAMPLE_REPETITIONS); + return elapsed / repetitions; + } + + private void report(String result) { + results.add(result); + try { + Files.writeString(resultsOutput, result + System.lineSeparator(), StandardOpenOption.APPEND); + } catch (IOException exception) { + throw new UncheckedIOException("Unable to append benchmark result", exception); + } + getLogger().info("IV_BENCH_RESULT " + result); + } + + private record Samples(long median, long p95) { + + private static Samples of(long[] values) { + long[] sorted = Arrays.copyOf(values, values.length); + Arrays.sort(sorted); + return new Samples(sorted[sorted.length / 2], sorted[(int) Math.ceil(sorted.length * 0.95D) - 1]); + } + } + + private record OrderStratum(Samples baseline, Samples candidate, double speedup) { + + private static OrderStratum of(long[] baselineTimes, long[] candidateTimes, + boolean[] baselineFirstRounds, boolean baselineFirst) { + int size = 0; + for (boolean value : baselineFirstRounds) { + if (value == baselineFirst) { + size++; + } + } + long[] baseline = new long[size]; + long[] candidate = new long[size]; + int output = 0; + for (int index = 0; index < baselineFirstRounds.length; index++) { + if (baselineFirstRounds[index] == baselineFirst) { + baseline[output] = baselineTimes[index]; + candidate[output] = candidateTimes[index]; + output++; + } + } + Samples baselineSamples = Samples.of(baseline); + Samples candidateSamples = Samples.of(candidate); + return new OrderStratum(baselineSamples, candidateSamples, + (double) baselineSamples.median() / Math.max(1L, candidateSamples.median())); + } + } + + private record Comparison(Samples baseline, Samples candidate, OrderStratum baselineFirst, + OrderStratum candidateFirst, String roundOrder, long[] baselineRoundsNs, + long[] candidateRoundsNs, double speedup) { + } + + private record ThreeWayComparison(Comparison legacyCandidate, Comparison controlCandidate) { + } + + private record AdaptivePathProbe(long activeLabels, boolean gridBuilt, + boolean gridActiveAtEnd, boolean boundsActiveAtEnd) { + } + + private record GridPhase(String name, double x, double z) { + } + + /** Strict pure primitive SoA lower-bound control without bounds or an adaptive grid. */ + private static final class PrimitiveViewerIndex { + + private final double[] xCoordinates; + private final double[] yCoordinates; + private final double[] zCoordinates; + private UUID worldId; + private int size; + + private PrimitiveViewerIndex(int capacity) { + xCoordinates = new double[capacity]; + yCoordinates = new double[capacity]; + zCoordinates = new double[capacity]; + } + + private void addViewer(UUID worldId, double x, double y, double z) { + if (this.worldId == null) { + this.worldId = worldId; + } + xCoordinates[size] = x; + yCoordinates[size] = y; + zCoordinates[size] = z; + size++; + } + + private boolean hasViewerWithin(UUID worldId, double x, double y, double z, double range) { + if (!java.util.Objects.equals(this.worldId, worldId)) { + return false; + } + double rangeSquared = range * range; + for (int index = 0; index < size; index++) { + double deltaX = xCoordinates[index] - x; + double deltaY = yCoordinates[index] - y; + double deltaZ = zCoordinates[index] - z; + if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= rangeSquared) { + return true; + } + } + return false; + } + } + + /** Exact copy of the viewer-grid strategy used by production before the optimized candidate. */ + private static final class LegacyViewerIndex { + + private static final double CELL_SIZE = 16.0D; + private final Map> viewerCells = new HashMap<>(); + + private void addViewer(UUID worldId, double x, double y, double z) { + viewerCells.computeIfAbsent(cell(worldId, x, z), ignored -> new ArrayList<>()) + .add(new LegacyViewerPoint(x, y, z)); + } + + private boolean hasViewerWithin(UUID worldId, double x, double y, double z, double range) { + if (range < 0.0D) { + return false; + } + int minimumX = coordinate(x - range); + int maximumX = coordinate(x + range); + int minimumZ = coordinate(z - range); + int maximumZ = coordinate(z + range); + double rangeSquared = range * range; + for (int cellX = minimumX; cellX <= maximumX; cellX++) { + for (int cellZ = minimumZ; cellZ <= maximumZ; cellZ++) { + List points = viewerCells.get(new LegacyViewerCell(worldId, cellX, cellZ)); + if (points == null) { + continue; + } + for (LegacyViewerPoint point : points) { + double deltaX = point.x() - x; + double deltaY = point.y() - y; + double deltaZ = point.z() - z; + if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= rangeSquared) { + return true; + } + } + } + } + return false; + } + + private static LegacyViewerCell cell(UUID worldId, double x, double z) { + return new LegacyViewerCell(worldId, coordinate(x), coordinate(z)); + } + + private static int coordinate(double value) { + return (int) Math.floor(value / CELL_SIZE); + } + } + + private record LegacyViewerCell(UUID worldId, int x, int z) { + } + + private record LegacyViewerPoint(double x, double y, double z) { + } + + private record Point(int id, double x, double y, double z) { + + private double distanceSquared(Point other) { + double deltaX = x - other.x; + double deltaY = y - other.y; + double deltaZ = z - other.z; + return deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ; + } + } + +} diff --git a/benchmark/src/main/resources/plugin.yml b/benchmark/src/main/resources/plugin.yml new file mode 100644 index 00000000..d061923b --- /dev/null +++ b/benchmark/src/main/resources/plugin.yml @@ -0,0 +1,5 @@ +name: InteractionVisualizerBenchmark +version: '1.0' +main: com.loohp.interactionvisualizer.entities.DroppedItemBenchmarkPlugin +api-version: '26.1' +description: Disposable A/B performance harness for InteractionVisualizer diff --git a/build.gradle.kts b/build.gradle.kts index f0af2b33..be787198 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,7 @@ val paper26_1Version = "26.1.2.build.74-stable" val paper26_2Version = "26.2.build.56-alpha" val craftEngineVersion = "26.7.2" val sparrowHeartVersion = "0.72" +val caffeineVersion = "3.2.3" val paper26_2CompileClasspath = configurations.create("paper26_2CompileClasspath") { isCanBeConsumed = false @@ -37,6 +38,7 @@ dependencies { implementation("net.momirealms:sparrow-yaml:1.0.7") implementation("net.momirealms:sparrow-heart:$sparrowHeartVersion") + implementation("com.github.ben-manes.caffeine:caffeine:$caffeineVersion") testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") @@ -70,6 +72,24 @@ sourceSets { } } +val benchmarkSourceSet = sourceSets.create("benchmark") { + java.setSrcDirs(listOf("benchmark/src/main/java")) + resources.setSrcDirs(listOf("benchmark/src/main/resources")) + compileClasspath += sourceSets.main.get().output + configurations.compileClasspath.get() + runtimeClasspath += output + compileClasspath +} + +val benchmarkJar = tasks.register("benchmarkJar") { + description = "Builds the standalone Paper A/B benchmark plugin (never shipped in production)." + group = LifecycleBasePlugin.VERIFICATION_GROUP + dependsOn(benchmarkSourceSet.classesTaskName) + archiveClassifier = "benchmark" + from(benchmarkSourceSet.output) + from(sourceSets.main.get().output) { + include("com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex*.class") + } +} + tasks.withType().configureEach { options.release = 25 options.encoding = "UTF-8" @@ -103,6 +123,22 @@ tasks.withType().configureEach { classpath = files(testClasspathJar.flatMap { it.archiveFile }) } +val legacyTextCacheDisableProperty = "interactionvisualizer.disableLegacyTextComponentCache" + +tasks.named("test") { + systemProperty(legacyTextCacheDisableProperty, "false") + exclude("**/LegacyTextComponentCacheDisabledTest.class") +} + +val testLegacyTextComponentCacheDisabled = tasks.register("testLegacyTextComponentCacheDisabled") { + description = "Verifies the isolated JVM rollback path for legacy text component caching." + group = LifecycleBasePlugin.VERIFICATION_GROUP + testClassesDirs = sourceSets.test.get().output.classesDirs + include("**/LegacyTextComponentCacheDisabledTest.class") + systemProperty(legacyTextCacheDisableProperty, "true") + dependsOn(tasks.testClasses) +} + val compilePaper26_2 = tasks.register("compilePaper26_2") { description = "Compiles the same Paper-API-only sources against Paper 26.2." group = LifecycleBasePlugin.VERIFICATION_GROUP @@ -115,7 +151,7 @@ val compilePaper26_2 = tasks.register("compilePaper26_2") { } val verifyPaperOnlyArchitecture = tasks.register("verifyPaperOnlyArchitecture") { - description = "Confines NMS reflection to the client pickup bridge and rejects legacy layers." + description = "Confines NMS reflection to the isolated client packet bridges and rejects legacy layers." group = LifecycleBasePlugin.VERIFICATION_GROUP val sources = sourceSets.main.get().allJava inputs.files(sources) @@ -141,11 +177,15 @@ val verifyPaperOnlyArchitecture = tasks.register("verifyPaperOnlyArchitecture") val pickupBridge = file( "common/src/main/java/com/loohp/interactionvisualizer/integration/packet/ClientPickupAnimationBridge.java", ).canonicalFile + val textDisplayBridge = file( + "common/src/main/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridge.java", + ).canonicalFile + val packetBridges = setOf(pickupBridge, textDisplayBridge) val bridgeTokens = setOf("net.minecraft", "org.bukkit.craftbukkit") val violations = sources.files.flatMap { source -> val text = source.readText() forbidden.filter(text::contains).mapNotNull { token -> - val allowed = source.canonicalFile == pickupBridge && token in bridgeTokens + val allowed = source.canonicalFile in packetBridges && token in bridgeTokens if (allowed) null else "${source.relativeTo(rootDir)}: $token" } } @@ -196,6 +236,7 @@ tasks.check { dependsOn(compilePaper26_2) dependsOn(verifyPaperOnlyArchitecture) dependsOn(verifyCustomContentIsolation) + dependsOn(testLegacyTextComponentCacheDisabled) } tasks.named("shadowJar") { @@ -204,6 +245,9 @@ tasks.named("shadowJar") { relocate("net.momirealms.sparrow.yaml", "com.loohp.interactionvisualizer.libs.sparrow.yaml") relocate("net.momirealms.sparrow.heart", "com.loohp.interactionvisualizer.libs.sparrow.heart") + relocate("com.github.benmanes.caffeine", "com.loohp.interactionvisualizer.libs.caffeine") + relocate("org.jspecify", "com.loohp.interactionvisualizer.libs.jspecify") + relocate("com.google.errorprone.annotations", "com.loohp.interactionvisualizer.libs.errorprone.annotations") isPreserveFileTimestamps = false isReproducibleFileOrder = true @@ -238,6 +282,49 @@ tasks.named("shadowJar") { check(jar.getEntry("META-INF/licenses/sparrow-heart.txt") != null) { "The Sparrow Heart MIT license notice is missing" } + check(jar.getEntry("META-INF/licenses/caffeine.txt") != null) { + "The Caffeine Apache-2.0 license notice is missing" + } + check(jar.getEntry("META-INF/licenses/jspecify.txt") != null) { + "The JSpecify Apache-2.0 license notice is missing" + } + check(jar.getEntry("META-INF/licenses/error-prone-annotations.txt") != null) { + "The Error Prone Annotations Apache-2.0 license notice is missing" + } + check(jar.getEntry( + "com/loohp/interactionvisualizer/libs/caffeine/cache/Caffeine.class", + ) != null) { + "The shaded Caffeine runtime is missing" + } + val unrelocatedCaffeine = jar.entries().asSequence() + .map { it.name } + .filter { it.startsWith("com/github/benmanes/caffeine/") } + .toList() + check(unrelocatedCaffeine.isEmpty()) { + "Unrelocated Caffeine classes were bundled:\n" + + unrelocatedCaffeine.joinToString("\n") + } + val unrelocatedAnnotationDependencies = jar.entries().asSequence() + .map { it.name } + .filter { + it.startsWith("org/jspecify/") || + it.startsWith("com/google/errorprone/annotations/") + } + .toList() + check(unrelocatedAnnotationDependencies.isEmpty()) { + "Unrelocated Caffeine annotation dependencies were bundled:\n" + + unrelocatedAnnotationDependencies.joinToString("\n") + } + check(jar.getEntry( + "com/loohp/interactionvisualizer/libs/jspecify/annotations/NullMarked.class", + ) != null) { + "The relocated JSpecify annotations are missing" + } + check(jar.getEntry( + "com/loohp/interactionvisualizer/libs/errorprone/annotations/CanIgnoreReturnValue.class", + ) != null) { + "The relocated Error Prone annotations are missing" + } } } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/Commands.java b/common/src/main/java/com/loohp/interactionvisualizer/Commands.java index 8cbdecf8..36909215 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/Commands.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/Commands.java @@ -21,9 +21,12 @@ package com.loohp.interactionvisualizer; import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; +import com.loohp.interactionvisualizer.debug.PerformanceBlockScene; import com.loohp.interactionvisualizer.managers.MaterialManager; import com.loohp.interactionvisualizer.managers.MusicManager; import com.loohp.interactionvisualizer.managers.DisplayManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; +import com.loohp.interactionvisualizer.debug.PerformanceScene; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.updater.Updater; import com.loohp.interactionvisualizer.updater.Updater.UpdaterResponse; @@ -43,6 +46,8 @@ import java.util.LinkedList; import java.util.List; +import java.util.Locale; +import java.util.UUID; public class Commands implements CommandExecutor, TabCompleter { @@ -60,6 +65,10 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String return true; } + if (args[0].equalsIgnoreCase("perf")) { + return handlePerformanceCommand(sender, args); + } + if (args[0].equalsIgnoreCase("reload")) { if (sender.hasPermission("interactionvisualizer.reload")) { plugin.loadConfig(); @@ -261,6 +270,368 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String return true; } + private boolean handlePerformanceCommand(CommandSender sender, String[] args) { + if (!sender.hasPermission("interactionvisualizer.performance")) { + sender.sendMessage(ChatColorUtils.translateAlternateColorCodes('&', plugin.getConfiguration().getString("Messages.NoPermission"))); + return true; + } + if (args.length < 2) { + sender.sendMessage(Component.text("Usage: /iv perf ")); + return true; + } + switch (args[1].toLowerCase()) { + case "start" -> { + String label = args.length >= 3 ? args[2] : "unnamed"; + if (PerformanceMetrics.start(label)) { + sender.sendMessage(Component.text("[InteractionVisualizer] Performance sampling started: " + label)); + } else { + sender.sendMessage(Component.text("[InteractionVisualizer] Performance sampling is already active.")); + } + } + case "stop" -> { + PerformanceMetrics.Snapshot snapshot = PerformanceMetrics.stop(); + if (snapshot == null) { + sender.sendMessage(Component.text("[InteractionVisualizer] Performance sampling is not active.")); + } else { + sender.sendMessage(Component.text("[InteractionVisualizer] " + snapshot.summary())); + } + } + case "status" -> { + PerformanceMetrics.Snapshot snapshot = PerformanceMetrics.snapshot(); + if (snapshot == null) { + sender.sendMessage(Component.text("[InteractionVisualizer] Performance sampling is not active.")); + } else { + sender.sendMessage(Component.text("[InteractionVisualizer] " + snapshot.summary())); + } + } + case "scene" -> { + if (args.length < 4) { + sender.sendMessage(Component.text( + "Usage: /iv perf scene " + + " [lifetimeTicks] [player]")); + return true; + } + boolean moving = args[2].equalsIgnoreCase("motion"); + boolean staticItem = args[2].equalsIgnoreCase("static"); + boolean itemDisplay = args[2].equalsIgnoreCase("itemdisplay"); + boolean textDisplay = args[2].equalsIgnoreCase("textdisplay"); + if (!moving && !staticItem && !itemDisplay && !textDisplay) { + sender.sendMessage(Component.text( + "[InteractionVisualizer] Scene type must be static, motion, itemdisplay, or textdisplay.")); + return true; + } + long defaultLifetime = moving ? 80L : 200L; + long lifetime = defaultLifetime; + String requestedPlayer = args.length >= 6 ? args[5] : null; + if (args.length >= 5) { + try { + lifetime = Long.parseLong(args[4]); + } catch (NumberFormatException ignored) { + if (args.length == 5) { + requestedPlayer = args[4]; + } else { + sender.sendMessage(Component.text( + "[InteractionVisualizer] lifetimeTicks must be an integer.")); + return true; + } + } + } + Player player = performanceScenePlayer(sender, requestedPlayer); + if (player == null) { + sender.sendMessage(Component.text( + "[InteractionVisualizer] Specify an online player for the benchmark scene.")); + return true; + } + int count = parseInteger(args[3], 1); + int spawned = itemDisplay || textDisplay + ? PerformanceScene.spawnDisplay(player, textDisplay, count, lifetime) + : PerformanceScene.spawn(player, moving, count, lifetime); + String sceneName = moving ? "moving" : staticItem ? "static" + : itemDisplay ? "itemdisplay" : "textdisplay"; + String entityLabel = staticItem || moving ? " benchmark items" : " benchmark entities"; + sender.sendMessage(Component.text("[InteractionVisualizer] Spawned " + spawned + " " + + sceneName + entityLabel + " for " + lifetime + " ticks.")); + } + case "clear" -> { + String requestedOwner = args.length >= 3 ? args[2] : null; + Player player = performanceScenePlayer(sender, requestedOwner); + UUID ownerId = player == null ? parseUuid(requestedOwner) : player.getUniqueId(); + if (ownerId == null) { + sender.sendMessage(Component.text( + "[InteractionVisualizer] Specify an online player or owner UUID when clearing a scene from console.")); + return true; + } + PerformanceScene.clear(ownerId); + String ownerName = player == null ? ownerId.toString() : player.getName(); + sender.sendMessage(Component.text("[InteractionVisualizer] Cleared the benchmark scene for " + + ownerName + ".")); + } + case "blockscene" -> handlePerformanceBlockScene(sender, args); + default -> sender.sendMessage(Component.text( + "Usage: /iv perf ")); + } + return true; + } + + private static void handlePerformanceBlockScene(CommandSender sender, String[] args) { + if (args.length < 3) { + sendBlockSceneUsage(sender); + return; + } + String action = args[2].toLowerCase(); + try { + switch (action) { + case "create" -> createPerformanceBlockScene(sender, args); + case "mutate" -> mutatePerformanceBlockScene(sender, args); + case "status" -> inspectPerformanceBlockScene(sender, args, false); + case "clear" -> inspectPerformanceBlockScene(sender, args, true); + default -> { + sendBlockSceneRecord(sender, action, null, + "state=invalid reason=unknown_action"); + sendBlockSceneUsage(sender); + } + } + } catch (RuntimeException exception) { + sendBlockSceneRecordForOwner(sender, action, blockSceneOwnerHint(sender, action, args), + "state=error error=" + summaryToken(exception.getClass().getSimpleName()) + + " detail=" + summaryToken(exception.getMessage())); + } + } + + private static void createPerformanceBlockScene(CommandSender sender, String[] args) { + if (args.length < 5 || args.length > 6) { + sendBlockSceneRecord(sender, "create", null, "state=invalid reason=usage"); + sender.sendMessage(Component.text( + "Usage: /iv perf blockscene create [player]")); + return; + } + PerformanceBlockScene.Mode mode = performanceBlockMode(args[3]); + if (mode == null) { + sendBlockSceneRecord(sender, "create", null, "state=invalid reason=invalid_mode"); + return; + } + Integer count = performanceBlockCount(args[4]); + if (count == null) { + sendBlockSceneRecord(sender, "create", null, + "state=invalid reason=invalid_count min=1 max=" + PerformanceBlockScene.MAX_BLOCKS); + return; + } + Player player = performanceScenePlayer(sender, args.length == 6 ? args[5] : null); + if (player == null) { + sendBlockSceneRecord(sender, "create", null, "state=invalid reason=player_not_online"); + return; + } + PerformanceBlockScene.Snapshot snapshot = PerformanceBlockScene.create(player, count, mode); + sendBlockSceneRecord(sender, "create", player, snapshot.summary()); + } + + private static void mutatePerformanceBlockScene(CommandSender sender, String[] args) { + if (args.length > 6) { + sendBlockSceneRecord(sender, "mutate", null, "state=invalid reason=usage"); + sender.sendMessage(Component.text( + "Usage: /iv perf blockscene mutate [count] [idle|active|direct-write] [player|owner-uuid]")); + return; + } + + Integer count = null; + PerformanceBlockScene.Mode mode = null; + String requestedPlayer = null; + for (int index = 3; index < args.length; index++) { + String argument = args[index]; + PerformanceBlockScene.Mode parsedMode = performanceBlockMode(argument); + if (parsedMode != null) { + if (mode != null) { + sendBlockSceneRecord(sender, "mutate", null, "state=invalid reason=duplicate_mode"); + return; + } + mode = parsedMode; + continue; + } + Integer parsedCount = performanceBlockCount(argument); + if (parsedCount != null) { + if (count != null) { + sendBlockSceneRecord(sender, "mutate", null, "state=invalid reason=duplicate_count"); + return; + } + count = parsedCount; + continue; + } + if (argument.matches("[+-]?\\d+")) { + sendBlockSceneRecord(sender, "mutate", null, + "state=invalid reason=count_out_of_range min=1 max=" + PerformanceBlockScene.MAX_BLOCKS); + return; + } + if (requestedPlayer == null) { + requestedPlayer = argument; + continue; + } + sendBlockSceneRecord(sender, "mutate", null, "state=invalid reason=ambiguous_arguments"); + return; + } + + BlockSceneOwner owner = performanceBlockSceneOwner(sender, requestedPlayer); + if (owner == null) { + sendBlockSceneRecord(sender, "mutate", null, + "state=invalid reason=online_player_or_owner_uuid_required"); + return; + } + long mutationCommandStartNanos = System.nanoTime(); + PerformanceBlockScene.Snapshot current = PerformanceBlockScene.snapshot(owner.ownerId()); + long mutationPreflightElapsedNanos = Math.max( + 0L, System.nanoTime() - mutationCommandStartNanos); + if (current.state() == PerformanceBlockScene.SceneState.ABSENT) { + sendBlockSceneRecordForOwner(sender, "mutate", owner.displayName(), current.summary()); + return; + } + if (count != null && count > current.placedCount()) { + sendBlockSceneRecordForOwner(sender, "mutate", owner.displayName(), + "state=invalid reason=count_exceeds_scene requested=" + count + + " max=" + current.placedCount()); + return; + } + + PerformanceBlockScene.Snapshot snapshot; + if (count != null && mode != null) { + snapshot = PerformanceBlockScene.mutate(owner.ownerId(), count, mode); + } else if (mode != null) { + snapshot = PerformanceBlockScene.mutate(owner.ownerId(), mode); + } else { + snapshot = PerformanceBlockScene.mutate(owner.ownerId(), + count == null ? current.placedCount() : count); + } + long mutationCommandElapsedNanos = Math.max( + 0L, System.nanoTime() - mutationCommandStartNanos); + String commandTiming = " mutationPreflightMs=" + + String.format(Locale.ROOT, "%.6f", mutationPreflightElapsedNanos / 1_000_000.0D) + + " mutationCommandMs=" + + String.format(Locale.ROOT, "%.6f", mutationCommandElapsedNanos / 1_000_000.0D); + sendBlockSceneRecordForOwner(sender, "mutate", owner.displayName(), + snapshot.summary() + commandTiming); + } + + private static void inspectPerformanceBlockScene(CommandSender sender, String[] args, boolean clear) { + String action = clear ? "clear" : "status"; + if (args.length > 4) { + sendBlockSceneRecord(sender, action, null, "state=invalid reason=usage"); + sender.sendMessage(Component.text( + "Usage: /iv perf blockscene " + action + " [player|owner-uuid]")); + return; + } + BlockSceneOwner owner = performanceBlockSceneOwner(sender, args.length == 4 ? args[3] : null); + if (owner == null) { + sendBlockSceneRecord(sender, action, null, + "state=invalid reason=online_player_or_owner_uuid_required"); + return; + } + PerformanceBlockScene.Snapshot snapshot = clear + ? PerformanceBlockScene.clear(owner.ownerId()) + : PerformanceBlockScene.snapshot(owner.ownerId()); + sendBlockSceneRecordForOwner(sender, action, owner.displayName(), snapshot.summary()); + } + + private static PerformanceBlockScene.Mode performanceBlockMode(String value) { + try { + return PerformanceBlockScene.Mode.parse(value); + } catch (IllegalArgumentException exception) { + return null; + } + } + + private static Integer performanceBlockCount(String value) { + try { + int count = Integer.parseInt(value); + return count >= 1 && count <= PerformanceBlockScene.MAX_BLOCKS ? count : null; + } catch (NumberFormatException exception) { + return null; + } + } + + private static void sendBlockSceneRecord(CommandSender sender, String action, Player player, String summary) { + sendBlockSceneRecordForOwner(sender, action, player == null ? null : player.getName(), summary); + } + + private static void sendBlockSceneRecordForOwner(CommandSender sender, String action, String owner, + String summary) { + String record = "IV_BLOCK_SCENE action=" + summaryToken(action) + + " owner=" + summaryToken(owner) + " " + summary; + plugin.getLogger().info(record); + if (sender instanceof Player) { + sender.sendMessage(Component.text("[InteractionVisualizer] " + record)); + } + } + + private static String summaryToken(String value) { + if (value == null || value.isBlank()) { + return "none"; + } + return value.replaceAll("[^A-Za-z0-9_.:-]", "_"); + } + + private static void sendBlockSceneUsage(CommandSender sender) { + sender.sendMessage(Component.text( + "Usage: /iv perf blockscene ")); + } + + private static Player performanceScenePlayer(CommandSender sender, String requestedName) { + if (requestedName != null && !requestedName.isBlank()) { + return Bukkit.getPlayerExact(requestedName); + } + return sender instanceof Player player ? player : null; + } + + private static BlockSceneOwner performanceBlockSceneOwner(CommandSender sender, String requestedName) { + Player online = performanceScenePlayer(sender, requestedName); + if (online != null) { + return new BlockSceneOwner(online.getUniqueId(), online.getName()); + } + UUID ownerId = parseUuid(requestedName); + return ownerId == null ? null : new BlockSceneOwner(ownerId, ownerId.toString()); + } + + private static String blockSceneOwnerHint(CommandSender sender, String action, String[] args) { + String requested = null; + if ("create".equals(action) && args.length >= 6) { + requested = args[5]; + } else if (("status".equals(action) || "clear".equals(action)) && args.length >= 4) { + requested = args[3]; + } else if ("mutate".equals(action)) { + for (int index = 3; index < args.length; index++) { + String argument = args[index]; + if (performanceBlockMode(argument) == null && performanceBlockCount(argument) == null + && !argument.matches("[+-]?\\d+")) { + requested = argument; + break; + } + } + } + if (requested != null && !requested.isBlank()) { + return requested; + } + return sender instanceof Player player ? player.getName() : null; + } + + private static UUID parseUuid(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return UUID.fromString(value); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private record BlockSceneOwner(UUID ownerId, String displayName) { + } + + private static int parseInteger(String value, int defaultValue) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + @Override public List onTabComplete(CommandSender sender, Command cmd, String label, String[] args) { List tab = new LinkedList<>(); @@ -304,8 +675,19 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe tab.add("refresh"); } } + if (sender.hasPermission("interactionvisualizer.performance") && "perf".startsWith(args[0].toLowerCase())) { + tab.add("perf"); + } return tab; case 2: + if (args[0].equalsIgnoreCase("perf") && sender.hasPermission("interactionvisualizer.performance")) { + for (String option : List.of("start", "stop", "status", "scene", "clear", "blockscene")) { + if (option.startsWith(args[1].toLowerCase())) { + tab.add(option); + } + } + return tab; + } if (args[0].equalsIgnoreCase("toggle")) { if (sender.hasPermission("interactionvisualizer.toggle")) { if ("itemstand".startsWith(args[1].toLowerCase())) { @@ -324,6 +706,24 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe } return tab; case 3: + if (args[0].equalsIgnoreCase("perf") && args[1].equalsIgnoreCase("scene") + && sender.hasPermission("interactionvisualizer.performance")) { + for (String option : List.of("static", "motion", "itemdisplay", "textdisplay")) { + if (option.startsWith(args[2].toLowerCase())) { + tab.add(option); + } + } + return tab; + } + if (args[0].equalsIgnoreCase("perf") && args[1].equalsIgnoreCase("blockscene") + && sender.hasPermission("interactionvisualizer.performance")) { + for (String option : List.of("create", "mutate", "status", "clear")) { + if (option.startsWith(args[2].toLowerCase())) { + tab.add(option); + } + } + return tab; + } if (args[0].equalsIgnoreCase("toggle")) { if (sender.hasPermission("interactionvisualizer.toggle")) { if (args[1].equalsIgnoreCase("itemstand") || args[1].equalsIgnoreCase("itemdrop") || args[1].equalsIgnoreCase("hologram") || args[1].equalsIgnoreCase("all")) { @@ -340,6 +740,17 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe } return tab; case 4: + if (args[0].equalsIgnoreCase("perf") && args[1].equalsIgnoreCase("blockscene") + && sender.hasPermission("interactionvisualizer.performance")) { + if (args[2].equalsIgnoreCase("create") || args[2].equalsIgnoreCase("mutate")) { + addMatchingBlockModes(tab, args[3]); + } + if (args[2].equalsIgnoreCase("mutate") || args[2].equalsIgnoreCase("status") + || args[2].equalsIgnoreCase("clear")) { + addMatchingPlayers(tab, args[3]); + } + return tab; + } if (args[0].equalsIgnoreCase("toggle")) { if (sender.hasPermission("interactionvisualizer.toggle")) { if (args[1].equalsIgnoreCase("itemstand") || args[1].equalsIgnoreCase("itemdrop") || args[1].equalsIgnoreCase("hologram") || args[1].equalsIgnoreCase("all")) { @@ -354,6 +765,15 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe } return tab; case 5: + if (args[0].equalsIgnoreCase("perf") && args[1].equalsIgnoreCase("blockscene") + && args[2].equalsIgnoreCase("mutate") + && sender.hasPermission("interactionvisualizer.performance")) { + if (performanceBlockCount(args[3]) != null) { + addMatchingBlockModes(tab, args[4]); + } + addMatchingPlayers(tab, args[4]); + return tab; + } if (args[0].equalsIgnoreCase("toggle")) { if (sender.hasPermission("interactionvisualizer.toggle")) { if (args[1].equalsIgnoreCase("itemstand") || args[1].equalsIgnoreCase("itemdrop") || args[1].equalsIgnoreCase("hologram")) { @@ -368,9 +788,35 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe } } return tab; + case 6: + if (args[0].equalsIgnoreCase("perf") && args[1].equalsIgnoreCase("blockscene") + && sender.hasPermission("interactionvisualizer.performance")) { + if (args[2].equalsIgnoreCase("create") + || args[2].equalsIgnoreCase("mutate")) { + addMatchingPlayers(tab, args[5]); + } + return tab; + } + return tab; default: return tab; } } + private static void addMatchingBlockModes(List tab, String prefix) { + for (String option : List.of("idle", "active", "direct-write")) { + if (option.startsWith(prefix.toLowerCase())) { + tab.add(option); + } + } + } + + private static void addMatchingPlayers(List tab, String prefix) { + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getName().toLowerCase().startsWith(prefix.toLowerCase())) { + tab.add(player.getName()); + } + } + } + } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java index 624f61ed..633efe8e 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java @@ -23,6 +23,8 @@ import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; import com.loohp.interactionvisualizer.config.Config; import com.loohp.interactionvisualizer.database.Database; +import com.loohp.interactionvisualizer.debug.PerformanceBlockScene; +import com.loohp.interactionvisualizer.debug.PerformanceScene; import com.loohp.interactionvisualizer.integration.CustomContentManager; import com.loohp.interactionvisualizer.managers.AsyncExecutorManager; import com.loohp.interactionvisualizer.managers.LangManager; @@ -31,6 +33,7 @@ import com.loohp.interactionvisualizer.managers.MusicManager; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PreferenceManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.managers.TaskManager; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.metrics.Charts; @@ -62,6 +65,7 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.logging.Level; import java.util.stream.Collectors; public class InteractionVisualizer extends JavaPlugin { @@ -102,6 +106,19 @@ public class InteractionVisualizer extends JavaPlugin { public static boolean hideIfObstructed = false; public static boolean defaultDisabledAll = false; + /** A/B switch: virtual item remains authoritative while an invisible tracker stays stationary. */ + public static boolean staticVirtualItemAnchorsDuringAnimation = false; + /** A/B switch: eligible stationary virtual items are tracked and rendered entirely by packets. */ + public static boolean packetOnlyStaticVirtualItems = false; + /** A/B switch: smooths visibility recovery bursts; hides are always immediate. */ + public static boolean visibilityRateLimiting = false; + public static int visibilityRateLimitBucketSize = 128; + public static int visibilityRateLimitRestorePerTick = 32; + /** A/B switch: coalesces block changes and updates tracked blocks from fixed-budget loops. */ + public static boolean eventDrivenBlockUpdates = false; + public static int blockUpdateMaxDirtyPerTick = 64; + + private boolean blockUpdateModeInitialized; public static ILightManager lightManager; public static PreferenceManager preferenceManager; @@ -188,6 +205,7 @@ public void onEnable() { MaterialManager.setup(); getCommand("interactionvisualizer").setExecutor(new Commands()); + PerformanceMetrics.register(this); TaskManager.run(); @@ -232,6 +250,7 @@ public void onEnable() { @Override public void onDisable() { + shutdownPerformanceScenes(); CustomContentManager.shutdown(); if (preferenceManager != null) { preferenceManager.close(); @@ -244,6 +263,33 @@ public void onDisable() { "[InteractionVisualizer] Disabled; all display entities removed.", NamedTextColor.RED)); } + private void shutdownPerformanceScenes() { + try { + PerformanceBlockScene.ShutdownReport report = PerformanceBlockScene.shutdown(); + if (report.unresolvedCount() != 0) { + getLogger().severe("Performance block-scene cleanup left blocks unresolved during disable: " + + report.summary() + "; unloaded or externally modified blocks were left untouched"); + } + } catch (Throwable throwable) { + logPerformanceCleanupFailure("Performance block-scene cleanup failed; core disable will continue", + throwable); + } + try { + PerformanceScene.shutdown(); + } catch (Throwable throwable) { + logPerformanceCleanupFailure("Performance display-scene cleanup failed; core disable will continue", + throwable); + } + } + + private void logPerformanceCleanupFailure(String message, Throwable throwable) { + try { + getLogger().log(Level.SEVERE, message, throwable); + } catch (Throwable ignored) { + // Diagnostic logging must never prevent the plugin's core shutdown. + } + } + public SparrowConfiguration getConfiguration() { return Config.getConfig(CONFIG_ID).getConfiguration(); } @@ -285,6 +331,27 @@ public void loadConfig() { } defaultDisabledAll = getConfiguration().getBoolean("Settings.DefaultDisableAll"); + staticVirtualItemAnchorsDuringAnimation = getConfiguration().getBoolean( + "Settings.Performance.VirtualItems.StaticAnchorDuringAnimation"); + packetOnlyStaticVirtualItems = getConfiguration().getBoolean( + "Settings.Performance.VirtualItems.PacketOnlyStatic"); + visibilityRateLimiting = getConfiguration().getBoolean( + "Settings.Performance.VisibilityRateLimit.Enabled"); + visibilityRateLimitBucketSize = Math.max(1, getConfiguration().getInt( + "Settings.Performance.VisibilityRateLimit.BucketSize")); + visibilityRateLimitRestorePerTick = Math.max(1, getConfiguration().getInt( + "Settings.Performance.VisibilityRateLimit.RestorePerTick")); + boolean configuredEventDrivenBlockUpdates = getConfiguration().getBoolean( + "Settings.Performance.BlockUpdates.EventDriven"); + if (!blockUpdateModeInitialized) { + eventDrivenBlockUpdates = configuredEventDrivenBlockUpdates; + } else if (eventDrivenBlockUpdates != configuredEventDrivenBlockUpdates) { + getLogger().warning("Settings.Performance.BlockUpdates.EventDriven is applied only at startup; " + + "restart the server to change the active block update mode."); + } + blockUpdateMaxDirtyPerTick = Math.max(1, getConfiguration().getInt( + "Settings.Performance.BlockUpdates.MaxDirtyPerTick")); + blockUpdateModeInitialized = true; getServer().getPluginManager().callEvent(new InteractionVisualizerReloadEvent()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/api/InteractionVisualizerAPI.java b/common/src/main/java/com/loohp/interactionvisualizer/api/InteractionVisualizerAPI.java index 5a272f83..e9f62391 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/api/InteractionVisualizerAPI.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/api/InteractionVisualizerAPI.java @@ -120,7 +120,7 @@ public static Collection getPlayerModuleList(Modules module, EntryKey en } else { players = SynchronizedFilteredCollection.filter(players, each -> !excludedPlayers.contains(each)); } - return Collections.unmodifiableCollection(players); + return SynchronizedFilteredCollection.unmodifiableCollection(players); } /** @@ -424,66 +424,54 @@ public static DisplayEntity createDisplayEntityObject(Location location) { /** * Create a logical item display at the given location. - * DOES NOT SPAWN THE ARMORSTAND. + * DOES NOT SPAWN THE DISPLAY. * * @return The InteractionVisualizer DisplayEntity object created. */ public static DisplayEntity createDisplayEntityItemHoldingObject(Location location) { - Vector vector = rotateVectorAroundY(location.clone().getDirection().normalize().multiply(0.19), -100).add(location.clone().getDirection().normalize().multiply(-0.11)); - DisplayEntity stand = new DisplayEntity(location.add(vector)); - setStand(stand, location.getYaw()); + Location displayLocation = itemHoldingDisplayLocation(location); + DisplayEntity stand = new DisplayEntity(displayLocation); + setStand(stand, displayLocation.getYaw()); return stand; } + static Location itemHoldingDisplayLocation(Location location) { + return location.clone(); + } + /** * Get the rotation mode for a mini item holding DisplayEntity. - * ONLY WORKS WITH ARMORSTANDS CREATED USING createDisplayEntityItemHoldingObject(Location location) + * ONLY WORKS WITH DISPLAYS CREATED USING createDisplayEntityItemHoldingObject(Location location) * - * @return The same InteractionVisualizer DisplayEntity object. + * @return The current holding mode, or null when this is not a compatible item display. */ public static DisplayEntityHoldingMode getDisplayEntityItemHoldingObjectMode(DisplayEntity stand, DisplayEntityHoldingMode mode) { - switch (getStandModeRaw(stand).toLowerCase()) { - case "Item": - return DisplayEntityHoldingMode.ITEM; - case "LowBlock": - return DisplayEntityHoldingMode.LOWBLOCK; - case "Tool": - return DisplayEntityHoldingMode.TOOL; - case "Standing": - return DisplayEntityHoldingMode.STANDING; - } - return null; + // Keep the unused parameter for binary and source compatibility with the + // historical public signature. + return getStandMode(stand); } /** * Sets the rotation mode for a mini item holding DisplayEntity. - * ONLY WORKS WITH ARMORSTANDS CREATED USING createDisplayEntityItemHoldingObject(Location location) + * ONLY WORKS WITH DISPLAYS CREATED USING createDisplayEntityItemHoldingObject(Location location) * * @return The same InteractionVisualizer DisplayEntity object. */ public static DisplayEntity rotateDisplayEntityItemHoldingObject(DisplayEntity stand, DisplayEntityHoldingMode mode) { - toggleStandMode(stand, mode.toString()); + String requestedMode = mode.toString(); + if (!stand.isLocked()) { + toggleStandMode(stand, requestedMode); + } return stand; } - private static Vector rotateVectorAroundY(Vector vector, double degrees) { - double rad = Math.toRadians(degrees); - - double currentX = vector.getX(); - double currentZ = vector.getZ(); - - double cosine = Math.cos(rad); - double sine = Math.sin(rad); - - return new Vector((cosine * currentX - sine * currentZ), vector.getY(), (sine * currentX + cosine * currentZ)); - } - private static void setStand(DisplayEntity stand, float yaw) { stand.setArms(true); stand.setBasePlate(false); stand.setMarker(true); stand.setGravity(false); stand.setSmall(true); + stand.setLegacyRightHandItemTransform(true); stand.setInvulnerable(true); stand.setVisible(false); stand.setSilent(true); @@ -510,71 +498,48 @@ public static DisplayEntityHoldingMode getStandMode(DisplayEntity stand) { } private static void toggleStandMode(DisplayEntity stand, String mode) { - String plain = PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()); - if (!plain.equals("IV.Custom.Item")) { - if (plain.equals("IV.Custom.Block")) { - stand.setCustomName("IV.Custom.Item"); - stand.setRotation(stand.getLocation().getYaw() - 45, stand.getLocation().getPitch()); - stand.setRightArmPose(EulerAngle.ZERO); - stand.teleport(stand.getLocation().add(0.0, -0.084, 0.0)); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.102), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.14))); - - } - if (plain.equals("IV.Custom.LowBlock")) { - stand.setCustomName("IV.Custom.Item"); - stand.setRotation(stand.getLocation().getYaw() - 45, stand.getLocation().getPitch()); - stand.setRightArmPose(EulerAngle.ZERO); - stand.teleport(stand.getLocation().add(0.0, -0.02, 0.0)); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.09), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.15))); - - } - if (plain.equals("IV.Custom.Tool")) { - stand.setCustomName("IV.Custom.Item"); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.3), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.1))); - stand.teleport(stand.getLocation().add(0, 0.26, 0)); - stand.setRightArmPose(EulerAngle.ZERO); - } - if (plain.equals("IV.Custom.Standing")) { - stand.setCustomName("IV.Custom.Item"); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().getDirection().normalize().multiply(0.323), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().getDirection().normalize().multiply(-0.115))); - stand.teleport(stand.getLocation().add(0, 0.32, 0)); - stand.setRightArmPose(EulerAngle.ZERO); - } - } - if (mode.equals("Block")) { - stand.setCustomName("IV.Custom.Block"); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.14))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.102), -90))); - stand.teleport(stand.getLocation().add(0.0, 0.084, 0.0)); - stand.setRightArmPose(new EulerAngle(357.9, 0.0, 0.0)); - stand.setRotation(stand.getLocation().getYaw() + 45, stand.getLocation().getPitch()); - } - if (mode.equals("LowBlock")) { - stand.setCustomName("IV.Custom.LowBlock"); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.15))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.09), -90))); - stand.teleport(stand.getLocation().add(0.0, 0.02, 0.0)); - stand.setRightArmPose(new EulerAngle(357.9, 0.0, 0.0)); - stand.setRotation(stand.getLocation().getYaw() + 45, stand.getLocation().getPitch()); + String requestedMode = canonicalHoldingMode(mode); + String previousMode = getStandModeRaw(stand); + if (requestedMode.equals(previousMode)) { + stand.setRightArmPose(holdingPose(requestedMode)); + return; } - if (mode.equals("Tool")) { - stand.setCustomName("IV.Custom.Tool"); - stand.setRightArmPose(new EulerAngle(357.99, 0.0, 300.0)); - stand.teleport(stand.getLocation().add(0, -0.26, 0)); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.1))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.3), -90))); + + // This is the legacy DisplayEntity adapter for the same stable-anchor + // semantics as WorkstationDisplayPositioning#setRenderMode. The public + // return type cannot be changed to Item without breaking API consumers. + Location location = stand.getLocation(); + float baseYaw = location.getYaw() - (usesDiagonalYaw(previousMode) ? 45.0F : 0.0F); + stand.setCustomName("IV.Custom." + requestedMode); + stand.setRightArmPose(holdingPose(requestedMode)); + stand.setRotation(baseYaw + (usesDiagonalYaw(requestedMode) ? 45.0F : 0.0F), location.getPitch()); + } + + private static EulerAngle holdingPose(String mode) { + return switch (mode.toLowerCase(java.util.Locale.ROOT)) { + case "block", "lowblock" -> new EulerAngle(357.9, 0.0, 0.0); + case "tool" -> new EulerAngle(357.99, 0.0, 300.0); + case "standing" -> new EulerAngle(0.0, 4.7, 4.7); + case "item" -> EulerAngle.ZERO; + default -> throw new IllegalArgumentException("Unsupported display entity holding mode: " + mode); + }; + } + + private static String canonicalHoldingMode(String mode) { + DisplayEntityHoldingMode value = DisplayEntityHoldingMode.fromName(mode); + if (value != null) { + return value.toString(); } - if (mode.equals("Standing")) { - stand.setCustomName("IV.Custom.Standing"); - stand.setRightArmPose(new EulerAngle(0.0, 4.7, 4.7)); - stand.teleport(stand.getLocation().add(0, -0.32, 0)); - stand.teleport(stand.getLocation().add(stand.getLocation().getDirection().normalize().multiply(0.115))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().getDirection().normalize().multiply(-0.323), -90))); + // Preserve support for the historical internal Block pose even though + // it was never exposed as a DisplayEntityHoldingMode enum constant. + if ("Block".equalsIgnoreCase(mode)) { + return "Block"; } + throw new IllegalArgumentException("Unsupported display entity holding mode: " + mode); + } + + private static boolean usesDiagonalYaw(String mode) { + return "Block".equalsIgnoreCase(mode) || "LowBlock".equalsIgnoreCase(mode); } /** diff --git a/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityActivatedEvent.java b/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityActivatedEvent.java new file mode 100644 index 00000000..4e0b16fc --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityActivatedEvent.java @@ -0,0 +1,49 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.api.events; + +import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; +import org.bukkit.block.Block; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +/** Called when a previously indexed tile entity of the same type becomes active again. */ +public class TileEntityActivatedEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Block block; + private final TileEntityType type; + + public TileEntityActivatedEvent(Block block, TileEntityType type) { + this.block = block; + this.type = type; + } + + public Block getBlock() { + return block; + } + + public TileEntityType getTileEntityType() { + return type; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } + +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityAddedEvent.java b/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityAddedEvent.java new file mode 100644 index 00000000..7ae55758 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityAddedEvent.java @@ -0,0 +1,49 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.api.events; + +import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; +import org.bukkit.block.Block; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +/** Called when a new tile entity or a different type is added to the active index. */ +public class TileEntityAddedEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Block block; + private final TileEntityType type; + + public TileEntityAddedEvent(Block block, TileEntityType type) { + this.block = block; + this.type = type; + } + + public Block getBlock() { + return block; + } + + public TileEntityType getTileEntityType() { + return type; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } + +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityDeactivatedEvent.java b/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityDeactivatedEvent.java new file mode 100644 index 00000000..b365fd0f --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/api/events/TileEntityDeactivatedEvent.java @@ -0,0 +1,52 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.api.events; + +import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; +import org.bukkit.block.Block; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +/** + * Called when a tile entity leaves the nearby active index without implying + * that the underlying block was broken. + */ +public class TileEntityDeactivatedEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Block block; + private final TileEntityType type; + + public TileEntityDeactivatedEvent(Block block, TileEntityType type) { + this.block = block; + this.type = type; + } + + public Block getBlock() { + return block; + } + + public TileEntityType getTileEntityType() { + return type; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } + +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/AnvilDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/AnvilDisplay.java index 7cbd6bdd..888cd3f9 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/AnvilDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/AnvilDisplay.java @@ -33,14 +33,16 @@ import com.loohp.interactionvisualizer.utils.MaterialUtils; import com.loohp.interactionvisualizer.utils.MaterialUtils.MaterialMode; import com.loohp.interactionvisualizer.utils.VanishUtils; +import com.loohp.interactionvisualizer.utils.WorkstationDisplayPositioning; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.kyori.adventure.text.Component; import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; import org.bukkit.block.Block; +import org.bukkit.entity.Display; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -53,7 +55,6 @@ import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryView; import org.bukkit.inventory.ItemStack; -import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; import java.util.HashMap; @@ -99,6 +100,7 @@ public void process(Player player) { Map map = new HashMap<>(); map.put("Player", player); map.put("2", "N/A"); + map.put("2Label", "N/A"); map.putAll(spawnDisplayEntitys(player, block)); openedAnvil.put(block, map); } @@ -110,44 +112,42 @@ public void process(Player player) { } ItemStack[] items = new ItemStack[] {view.getItem(0), view.getItem(1)}; - if (view.getItem(2) != null) { - ItemStack itemstack = view.getItem(2); - if (itemstack == null || itemstack.getType().equals(Material.AIR)) { - itemstack = null; + ItemStack itemstack = view.getItem(2); + if (itemstack != null && itemstack.getType().equals(Material.AIR)) { + itemstack = null; + } + if (map.get("2") instanceof String) { + if (itemstack != null) { + Item item = new Item(loc.clone().add(0.5, 1.2, 0.5)); + item.setItemStack(itemstack); + item.setVelocity(new Vector(0, 0, 0)); + item.setPickupDelay(32767); + item.setGravity(false); + DisplayEntity label = spawnResultLabel(item.getLocation(), itemstack.getItemMeta().displayName()); + map.put("2", item); + map.put("2Label", label); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); + DisplayManager.updateItem(item); + } else { + map.put("2", "N/A"); } - Item item = null; - if (map.get("2") instanceof String) { - if (itemstack != null) { - item = new Item(loc.clone().add(0.5, 1.2, 0.5)); + } else { + Item item = (Item) map.get("2"); + if (itemstack != null) { + if (!item.getItemStack().equals(itemstack)) { item.setItemStack(itemstack); - item.setVelocity(new Vector(0, 0, 0)); - item.setPickupDelay(32767); - item.setGravity(false); - item.setCustomName(itemstack.getItemMeta().displayName()); - item.setCustomNameVisible(true); - map.put("2", item); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); DisplayManager.updateItem(item); - } else { - map.put("2", "N/A"); + DisplayEntity label = resultLabel(map, item.getLocation(), itemstack.getItemMeta().displayName()); + DisplayManager.updateDisplay(label); } } else { - item = (Item) map.get("2"); - if (itemstack != null) { - if (!item.getItemStack().equals(itemstack)) { - item.setItemStack(itemstack); - item.setCustomName(itemstack.getItemMeta().displayName()); - item.setCustomNameVisible(true); - DisplayManager.updateItem(item); - } - } else { - map.put("2", "N/A"); - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); - } + map.put("2", "N/A"); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); + removeResultLabel(map); } } for (int i = 0; i < 2; i++) { - DisplayEntity stand = (DisplayEntity) map.get(String.valueOf(i)); + Item stand = (Item) map.get(String.valueOf(i)); ItemStack item = items[i]; if (item == null || item.getType().equals(Material.AIR)) { item = null; @@ -156,19 +156,19 @@ public void process(Player player) { MaterialMode materialMode = MaterialUtils.getMaterialType(item); boolean changed = materialMode != standMode(stand); if (changed) { - toggleStandMode(stand, materialMode.toString()); + toggleStandMode(stand, materialMode); } - if (!item.equals(stand.getItemInMainHand())) { + if (!item.equals(stand.getItemStack())) { changed = true; - stand.setItemInMainHand(item); + stand.setItemStack(item); } if (changed) { - DisplayManager.updateDisplay(stand); + DisplayManager.updateItem(stand); } } else { - if (!stand.getItemInMainHand().getType().equals(Material.AIR)) { - stand.setItemInMainHand(new ItemStack(Material.AIR)); - DisplayManager.updateDisplay(stand); + if (!stand.getItemStack().isEmpty()) { + stand.setItemStack(ItemStack.empty()); + DisplayManager.updateItem(stand); } } } @@ -238,35 +238,45 @@ public void onAnvil(InventoryClickEvent event) { return; } - ItemStack itemstack = event.getCurrentItem(); + ItemStack itemstack = event.getCurrentItem().clone(); Location loc = block.getLocation(); Player player = (Player) event.getWhoClicked(); + InventoryView view = event.getView(); - DisplayEntity slot0 = (DisplayEntity) map.get("0"); - DisplayEntity slot1 = (DisplayEntity) map.get("1"); + Item slot0 = (Item) map.get("0"); + Item slot1 = (Item) map.get("1"); if (map.get("2") instanceof String) { - map.put("2", new Item(block.getLocation().clone().add(0.5, 1.2, 0.5))); + Item result = new Item(block.getLocation().clone().add(0.5, 1.2, 0.5)); + result.setItemStack(itemstack); + result.setVelocity(new Vector()); + result.setPickupDelay(32767); + result.setGravity(false); + DisplayEntity label = spawnResultLabel(result.getLocation(), itemstack.getItemMeta().displayName()); + map.put("2", result); + map.put("2Label", label); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), result); } Item item = (Item) map.get("2"); + DisplayEntity resultLabel = resultLabel(map, item.getLocation(), itemstack.getItemMeta().displayName()); + DisplayManager.updateDisplay(resultLabel); Inventory before = Bukkit.createInventory(null, 9); - before.setItem(0, player.getOpenInventory().getItem(0).clone()); - before.setItem(1, player.getOpenInventory().getItem(1).clone()); + before.setItem(0, InventoryUtils.cloneItem(view.getItem(0))); + before.setItem(1, InventoryUtils.cloneItem(view.getItem(1))); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { + if (!player.isOnline() || player.getOpenInventory() != view) { + return; + } Inventory after = Bukkit.createInventory(null, 9); - after.setItem(0, player.getOpenInventory().getItem(0).clone()); - after.setItem(1, player.getOpenInventory().getItem(1).clone()); + after.setItem(0, InventoryUtils.cloneItem(view.getItem(0))); + after.setItem(1, InventoryUtils.cloneItem(view.getItem(1))); if (InventoryUtils.compareContents(before, after)) { return; } - slot0.setLocked(true); - slot1.setLocked(true); - item.setLocked(true); - openedAnvil.remove(block); float yaw = getCardinalDirection(player); @@ -274,8 +284,13 @@ public void onAnvil(InventoryClickEvent event) { slot0.teleport(slot0.getLocation().add(rotateVectorAroundY(vector.clone(), 90).multiply(0.1))); slot1.teleport(slot1.getLocation().add(rotateVectorAroundY(vector.clone(), -90).multiply(0.1))); - DisplayManager.updateDisplay(slot0); - DisplayManager.updateDisplay(slot1); + DisplayManager.updateItem(slot0); + DisplayManager.updateItem(slot1); + + item.setItemStack(itemstack); + slot0.setLocked(true); + slot1.setLocked(true); + item.setLocked(true); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { for (Player each : InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY)) { @@ -284,13 +299,13 @@ public void onAnvil(InventoryClickEvent event) { }, 6); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - item.setItemStack(itemstack); + DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), resultLabel); DisplayManager.updateItem(item); DisplayManager.collectItem(item, player); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot0); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot1); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot0); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot1); }, 8); }, 10); }, 1); @@ -396,121 +411,94 @@ public void onCloseAnvil(InventoryCloseEvent event) { } } } + removeResultLabel(map); openedAnvil.remove(block); } - public MaterialMode standMode(DisplayEntity stand) { - String plain = PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()); - if (plain.startsWith("IV.Anvil.")) { - return MaterialMode.getModeFromName(plain.substring(plain.lastIndexOf(".") + 1)); + private DisplayEntity spawnResultLabel(Location itemLocation, Component name) { + DisplayEntity label = createResultLabel(itemLocation, name); + DisplayManager.spawnDisplay( + InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), label); + return label; + } + + private DisplayEntity resultLabel(Map map, Location itemLocation, Component name) { + Object value = map.get("2Label"); + if (value instanceof DisplayEntity label) { + label.setCustomName(name); + return label; + } + DisplayEntity label = spawnResultLabel(itemLocation, name); + map.put("2Label", label); + return label; + } + + private void removeResultLabel(Map map) { + Object value = map.put("2Label", "N/A"); + if (value instanceof DisplayEntity label) { + DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), label); } - return null; } - public void toggleStandMode(DisplayEntity stand, String mode) { - String plainText = PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()); - if (!plainText.equals("IV.Anvil.Item")) { - if (plainText.equals("IV.Anvil.Block")) { - stand.setCustomName("IV.Anvil.Item"); - stand.setRotation(stand.getLocation().getYaw() - 45, stand.getLocation().getPitch()); - stand.setRightArmPose(EulerAngle.ZERO); - stand.teleport(stand.getLocation().add(0.0, -0.084, 0.0)); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.102), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.14))); + static DisplayEntity createResultLabel(Location itemLocation, Component name) { + DisplayEntity label = new DisplayEntity(resultLabelLocation(itemLocation)); + configureResultLabel(label, name); + return label; + } - } - if (plainText.equals("IV.Anvil.LowBlock")) { - stand.setCustomName("IV.Anvil.Item"); - stand.setRotation(stand.getLocation().getYaw() - 45, stand.getLocation().getPitch()); - stand.setRightArmPose(EulerAngle.ZERO); - stand.teleport(stand.getLocation().add(0.0, -0.02, 0.0)); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.09), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.15))); + static Location resultLabelLocation(Location itemLocation) { + return itemLocation.clone().add(0.0, 0.55, 0.0); + } - } - if (plainText.equals("IV.Anvil.Tool")) { - stand.setCustomName("IV.Anvil.Item"); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.3), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.1))); - stand.teleport(stand.getLocation().add(0, 0.26, 0)); - stand.setRightArmPose(EulerAngle.ZERO); - } - if (plainText.equals("IV.Anvil.Standing")) { - stand.setCustomName("IV.Anvil.Item"); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().getDirection().normalize().multiply(0.323), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().getDirection().normalize().multiply(-0.115))); - stand.teleport(stand.getLocation().add(0, 0.32, 0)); - stand.setRightArmPose(EulerAngle.ZERO); - } - } - if (mode.equals("Block")) { - stand.setCustomName("IV.Anvil.Block"); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.14))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.102), -90))); - stand.teleport(stand.getLocation().add(0.0, 0.084, 0.0)); - stand.setRightArmPose(new EulerAngle(357.9, 0.0, 0.0)); - stand.setRotation(stand.getLocation().getYaw() + 45, stand.getLocation().getPitch()); - } - if (mode.equals("LowBlock")) { - stand.setCustomName("IV.Anvil.LowBlock"); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.15))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.09), -90))); - stand.teleport(stand.getLocation().add(0.0, 0.02, 0.0)); - stand.setRightArmPose(new EulerAngle(357.9, 0.0, 0.0)); - stand.setRotation(stand.getLocation().getYaw() + 45, stand.getLocation().getPitch()); - } - if (mode.equals("Tool")) { - stand.setCustomName("IV.Anvil.Tool"); - stand.setRightArmPose(new EulerAngle(357.99, 0.0, 300.0)); - stand.teleport(stand.getLocation().add(0, -0.26, 0)); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.1))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.3), -90))); - } - if (mode.equals("Standing")) { - stand.setCustomName("IV.Anvil.Standing"); - stand.setRightArmPose(new EulerAngle(0.0, 4.7, 4.7)); - stand.teleport(stand.getLocation().add(0, -0.32, 0)); - stand.teleport(stand.getLocation().add(stand.getLocation().getDirection().normalize().multiply(0.115))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().getDirection().normalize().multiply(-0.323), -90))); - } + static void configureResultLabel(DisplayEntity label, Component name) { + label.setCustomName(name); + label.setCustomNameVisible(true); + label.setBillboard(Display.Billboard.CENTER); + label.setDefaultBackground(true); + label.setTextScale(1.0F); + label.setUnboundedTextWidth(true); + label.setInterpolationDuration(0); + label.setTeleportDuration(0); } - public Map spawnDisplayEntitys(Player player, Block block) { //.add(0.68, 0.600781, 0.35) - Map map = new HashMap<>(); - Location loc = block.getLocation().clone().add(0.5, 0.600781, 0.5); - DisplayEntity center = new DisplayEntity(loc); + public MaterialMode standMode(Item stand) { + return switch (stand.getRenderMode()) { + case ITEM -> MaterialMode.ITEM; + case BLOCK -> MaterialMode.BLOCK; + case LOW_BLOCK -> MaterialMode.LOWBLOCK; + case TOOL -> MaterialMode.TOOL; + case STANDING -> MaterialMode.STANDING; + default -> null; + }; + } + + public void toggleStandMode(Item stand, MaterialMode mode) { + WorkstationDisplayPositioning.setRenderMode(stand, mode); + } + + public Map spawnDisplayEntitys(Player player, Block block) { + Map map = new HashMap<>(); float yaw = getCardinalDirection(player); - center.setRotation(yaw, center.getLocation().getPitch()); - setStand(center); - center.setCustomName("IV.Anvil.Center"); - Vector vector = rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.19), -100).add(center.getLocation().clone().getDirection().normalize().multiply(-0.11)); - DisplayEntity middle = new DisplayEntity(loc.clone().add(vector)); - setStand(middle, yaw); - DisplayEntity slot0 = new DisplayEntity(middle.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), -90))); + Location origin = block.getLocation(); + Item slot0 = WorkstationDisplayPositioning.gridItem(origin, yaw, -0.2, 0.0); setStand(slot0, yaw + 20); - DisplayEntity slot1 = new DisplayEntity(middle.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), 90))); + Item slot1 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.2, 0.0); setStand(slot1, yaw - 20); map.put("0", slot0); map.put("1", slot1); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot0); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot1); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot0); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot1); return map; } - public void setStand(DisplayEntity stand, float yaw) { - stand.setArms(true); - stand.setBasePlate(false); - stand.setMarker(true); + public void setStand(Item stand, float yaw) { stand.setGravity(false); - stand.setInvulnerable(true); - stand.setVisible(false); stand.setSilent(true); - stand.setSmall(true); - stand.setRightArmPose(EulerAngle.ZERO); - stand.setCustomName("IV.Anvil.Item"); + stand.setVelocity(new Vector()); + stand.setPickupDelay(32767); stand.setRotation(yaw, stand.getLocation().getPitch()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BannerDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BannerDisplay.java index f5934a47..f7d70200 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BannerDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BannerDisplay.java @@ -268,6 +268,7 @@ public Vector getDirection(BlockFace face) { } public void setStand(DisplayEntity stand) { + stand.useLegacyNameTagStyle(); stand.setBasePlate(false); stand.setMarker(true); stand.setGravity(false); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BarrelDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BarrelDisplay.java index 97225d33..0cc05236 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BarrelDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BarrelDisplay.java @@ -207,11 +207,10 @@ public void onUseBarrel(InventoryClickEvent event) { vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -308,12 +307,10 @@ public void onDragBarrel(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); - item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeaconDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeaconDisplay.java index 40f12142..cedd3cad 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeaconDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeaconDisplay.java @@ -331,6 +331,7 @@ public Map spawnDisplayEntitys(Block block) { } public void setStand(DisplayEntity stand) { + stand.useLegacyNameTagStyle(); stand.setBasePlate(false); stand.setMarker(true); stand.setGravity(false); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java index cdb3dd15..fe5ad973 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java @@ -25,17 +25,21 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; import com.loohp.interactionvisualizer.utils.ChatColorUtils; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; @@ -45,8 +49,17 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockFadeEvent; +import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.block.BlockIgniteEvent; +import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityChangeBlockEvent; import org.bukkit.event.entity.EntityEnterBlockEvent; +import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; @@ -70,9 +83,12 @@ public class BeeHiveDisplay extends VisualizerRunnableDisplay implements Listene private String filledColor = "&e"; private String noCampfireColor = "&c"; private String beeCountText = "&e{Current}&6/{Max}"; + private final BlockUpdateScheduler blockUpdates; public BeeHiveDisplay() { onReload(new InteractionVisualizerReloadEvent()); + this.blockUpdates = new BlockUpdateScheduler<>(this::nearbyBeehive, + this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -93,6 +109,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = beehiveMap.entrySet().iterator(); int count = 0; @@ -140,6 +159,21 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return legacyRun(); + } + return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), + InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); + if (collecting) { + PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); + } + }, 0, 1); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyBeehive(); for (Block block : list) { @@ -168,50 +202,214 @@ public ScheduledTask run() { } Block block = entry.getKey(); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - updateBlock(block); + measureLegacyUpdate(block); }, delay, block.getLocation()); } }, 0, checkingPeriod); } + private void measureLegacyUpdate(Block block) { + if (!PerformanceMetrics.isCollecting()) { + updateBlock(block); + return; + } + long start = System.nanoTime(); + try { + updateBlock(block); + } finally { + PerformanceMetrics.blockUpdateChecks(1, System.nanoTime() - start); + } + } + + private boolean updateHybridBlock(Block block) { + if (!TileEntityManager.getTileEntities(TileEntityType.BEEHIVE).contains(block)) { + removeTrackedDisplay(block); + return false; + } + if (!block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) { + removeTrackedDisplay(block); + return false; + } + if (block.getType() != Material.BEEHIVE) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + if (!beehiveMap.containsKey(block)) { + beehiveMap.put(block, new HashMap<>(spawnDisplayEntitys(block))); + } + updateBlock(block); + return false; + } + + private void markDirty(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null + && block.getType() == Material.BEEHIVE && beehiveMap.containsKey(block)) { + blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + /** Optimized aggregate-listener entry after the affected vertical column has been scanned once. */ + public void onAffectedBeeBlock(Block block) { + markDirty(block); + } + + private void markAffectedColumnDirty(Block changedBlock) { + if (!InteractionVisualizer.eventDrivenBlockUpdates || changedBlock == null) { + return; + } + for (int distance = 1; distance <= 5; distance++) { + markDirty(changedBlock.getRelative(BlockFace.UP, distance)); + } + } + + public void onAffectedBlockPlace(BlockPlaceEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlockPlaced()); + } + + public void onAffectedBlockBreak(BlockBreakEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + public void onAffectedBlockBurn(BlockBurnEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + public void onAffectedBlockFade(BlockFadeEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + public void onAffectedBlockIgnite(BlockIgniteEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + public void onAffectedFluidFlow(BlockFromToEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getToBlock()); + } + + public void onAffectedBlockExplode(BlockExplodeEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + for (Block block : event.blockList()) { + markAffectedColumnDirty(block); + } + } + + public void onAffectedEntityExplode(EntityExplodeEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + for (Block block : event.blockList()) { + markAffectedColumnDirty(block); + } + } + + public void onDispenserHarvest(BlockDispenseEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + Material dispensed = event.getItem().getType(); + if (dispensed != Material.GLASS_BOTTLE && dispensed != Material.SHEARS) { + return; + } + BlockData data = event.getBlock().getBlockData(); + if (data instanceof Directional directional) { + markDirty(event.getBlock().getRelative(directional.getFacing())); + } + } + + public void onBeehiveAdded(TileEntityAddedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BEEHIVE) { + blockUpdates.markDirty(event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); + } + } + + public void onBeehiveActivated(TileEntityActivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BEEHIVE) { + blockUpdates.markDirty(event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); + } + } + + public void onBeehiveDeactivated(TileEntityDeactivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BEEHIVE) { + removeTrackedDisplay(event.getBlock()); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onBeeEnterBeehive(EntityEnterBlockEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } if (event.isCancelled()) { return; } Block block = event.getBlock(); - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> updateBlock(block), 1, block.getLocation()); + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> measureLegacyUpdate(block), 1, block.getLocation()); } @EventHandler(priority = EventPriority.MONITOR) public void onBeeLeaveBeehive(EntityChangeBlockEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } if (event.isCancelled()) { return; } Block block = event.getBlock(); - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> updateBlock(block), 1, block.getLocation()); + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> measureLegacyUpdate(block), 1, block.getLocation()); } @SuppressWarnings("deprecation") @EventHandler(priority = EventPriority.MONITOR) public void onInteractBeehive(PlayerInteractEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } if (event.isCancelled()) { return; } Block block = event.getClickedBlock(); if (block != null) { - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> updateBlock(block), 1, block.getLocation()); + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> measureLegacyUpdate(block), 1, block.getLocation()); } } @EventHandler(priority = EventPriority.MONITOR) public void onBreakBeehive(TileEntityRemovedEvent event) { - Block block = event.getBlock(); - if (!beehiveMap.containsKey(block)) { + removeTrackedDisplay(event.getBlock()); + } + + private void removeTrackedDisplay(Block block) { + blockUpdates.remove(block); + Map map = beehiveMap.remove(block); + if (map == null) { return; } - - Map map = beehiveMap.get(block); if (map.get("0") instanceof DisplayEntity) { DisplayEntity stand = (DisplayEntity) map.get("0"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); @@ -220,7 +418,6 @@ public void onBreakBeehive(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("1"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - beehiveMap.remove(block); } public void updateBlock(Block block) { @@ -252,14 +449,10 @@ public void updateBlock(Block block) { } String str1 = beeCountText.replace("{Current}", beehiveState.getEntityCount() + "").replace("{Max}", beehiveState.getMaxEntities() + ""); - if (!PlainTextComponentSerializer.plainText().serialize(line0.getCustomName()).equals(str0)) { - line0.setCustomName(str0); - line0.setCustomNameVisible(true); + if (line0.updateCustomName(str0, true)) { DisplayManager.updateDisplay(line0); } - if (!PlainTextComponentSerializer.plainText().serialize(line1.getCustomName()).equals(str1)) { - line1.setCustomName(str1); - line1.setCustomNameVisible(true); + if (line1.updateCustomName(str1, true)) { DisplayManager.updateDisplay(line1); } } @@ -275,19 +468,15 @@ public boolean isActive(Location loc) { public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); - Location origin = block.getLocation(); - BlockData blockData = block.getState().getBlockData(); BlockFace facing = ((Directional) blockData).getFacing(); - Location target = block.getRelative(facing).getLocation(); - Vector direction = target.toVector().subtract(origin.toVector()).multiply(0.7); - Location loc0 = block.getLocation().clone().add(direction).add(0.5, 0.25, 0.5); + Location loc0 = labelLocation(block.getLocation(), facing, 0.25); loc0.setDirection(facing.getDirection()); DisplayEntity line0 = new DisplayEntity(loc0.clone()); setStand(line0); - Location loc1 = block.getLocation().clone().add(direction).add(0.5, 0, 0.5); + Location loc1 = labelLocation(block.getLocation(), facing, 0.0); loc1.setDirection(facing.getDirection()); DisplayEntity line1 = new DisplayEntity(loc1.clone()); setStand(line1); @@ -301,6 +490,11 @@ public Map spawnDisplayEntitys(Block block) { return map; } + static Location labelLocation(Location blockLocation, BlockFace facing, double lineOffset) { + Vector direction = facing.getDirection().multiply(0.7); + return blockLocation.clone().add(direction).add(0.5, lineOffset, 0.5); + } + public void setStand(DisplayEntity stand) { stand.setBasePlate(false); stand.setMarker(true); @@ -311,6 +505,7 @@ public void setStand(DisplayEntity stand) { stand.setVisible(false); stand.setCustomName(""); stand.setRightArmPose(EulerAngle.ZERO); + stand.useLegacyNameTagGeometry(); } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java index 02b848a8..d707eb79 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java @@ -25,17 +25,21 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; import com.loohp.interactionvisualizer.utils.ChatColorUtils; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; @@ -45,8 +49,17 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockFadeEvent; +import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.block.BlockIgniteEvent; +import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityChangeBlockEvent; import org.bukkit.event.entity.EntityEnterBlockEvent; +import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; @@ -70,9 +83,12 @@ public class BeeNestDisplay extends VisualizerRunnableDisplay implements Listene private String filledColor = "&e"; private String noCampfireColor = "&c"; private String beeCountText = "&e{Current}&6/{Max}"; + private final BlockUpdateScheduler blockUpdates; public BeeNestDisplay() { onReload(new InteractionVisualizerReloadEvent()); + this.blockUpdates = new BlockUpdateScheduler<>(this::nearbyBeenest, + this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -93,6 +109,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = beenestMap.entrySet().iterator(); int count = 0; @@ -140,6 +159,21 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return legacyRun(); + } + return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), + InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); + if (collecting) { + PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); + } + }, 0, 1); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyBeenest(); for (Block block : list) { @@ -168,50 +202,214 @@ public ScheduledTask run() { } Block block = entry.getKey(); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - updateBlock(block); + measureLegacyUpdate(block); }, delay, block.getLocation()); } }, 0, checkingPeriod); } + private void measureLegacyUpdate(Block block) { + if (!PerformanceMetrics.isCollecting()) { + updateBlock(block); + return; + } + long start = System.nanoTime(); + try { + updateBlock(block); + } finally { + PerformanceMetrics.blockUpdateChecks(1, System.nanoTime() - start); + } + } + + private boolean updateHybridBlock(Block block) { + if (!TileEntityManager.getTileEntities(TileEntityType.BEE_NEST).contains(block)) { + removeTrackedDisplay(block); + return false; + } + if (!block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) { + removeTrackedDisplay(block); + return false; + } + if (block.getType() != Material.BEE_NEST) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + if (!beenestMap.containsKey(block)) { + beenestMap.put(block, new HashMap<>(spawnDisplayEntitys(block))); + } + updateBlock(block); + return false; + } + + private void markDirty(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null + && block.getType() == Material.BEE_NEST && beenestMap.containsKey(block)) { + blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + /** Optimized aggregate-listener entry after the affected vertical column has been scanned once. */ + public void onAffectedBeeBlock(Block block) { + markDirty(block); + } + + private void markAffectedColumnDirty(Block changedBlock) { + if (!InteractionVisualizer.eventDrivenBlockUpdates || changedBlock == null) { + return; + } + for (int distance = 1; distance <= 5; distance++) { + markDirty(changedBlock.getRelative(BlockFace.UP, distance)); + } + } + + public void onAffectedBlockPlace(BlockPlaceEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlockPlaced()); + } + + public void onAffectedBlockBreak(BlockBreakEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + public void onAffectedBlockBurn(BlockBurnEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + public void onAffectedBlockFade(BlockFadeEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + public void onAffectedBlockIgnite(BlockIgniteEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getBlock()); + } + + public void onAffectedFluidFlow(BlockFromToEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + markAffectedColumnDirty(event.getToBlock()); + } + + public void onAffectedBlockExplode(BlockExplodeEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + for (Block block : event.blockList()) { + markAffectedColumnDirty(block); + } + } + + public void onAffectedEntityExplode(EntityExplodeEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + for (Block block : event.blockList()) { + markAffectedColumnDirty(block); + } + } + + public void onDispenserHarvest(BlockDispenseEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + Material dispensed = event.getItem().getType(); + if (dispensed != Material.GLASS_BOTTLE && dispensed != Material.SHEARS) { + return; + } + BlockData data = event.getBlock().getBlockData(); + if (data instanceof Directional directional) { + markDirty(event.getBlock().getRelative(directional.getFacing())); + } + } + + public void onBeenestAdded(TileEntityAddedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BEE_NEST) { + blockUpdates.markDirty(event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); + } + } + + public void onBeenestActivated(TileEntityActivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BEE_NEST) { + blockUpdates.markDirty(event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); + } + } + + public void onBeenestDeactivated(TileEntityDeactivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BEE_NEST) { + removeTrackedDisplay(event.getBlock()); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onBeeEnterBeenest(EntityEnterBlockEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } if (event.isCancelled()) { return; } Block block = event.getBlock(); - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> updateBlock(block), 1, block.getLocation()); + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> measureLegacyUpdate(block), 1, block.getLocation()); } @EventHandler(priority = EventPriority.MONITOR) public void onBeeLeaveBeenest(EntityChangeBlockEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } if (event.isCancelled()) { return; } Block block = event.getBlock(); - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> updateBlock(block), 1, block.getLocation()); + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> measureLegacyUpdate(block), 1, block.getLocation()); } @SuppressWarnings("deprecation") @EventHandler(priority = EventPriority.MONITOR) public void onInteractBeenest(PlayerInteractEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } if (event.isCancelled()) { return; } Block block = event.getClickedBlock(); if (block != null) { - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> updateBlock(block), 1, block.getLocation()); + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> measureLegacyUpdate(block), 1, block.getLocation()); } } @EventHandler(priority = EventPriority.MONITOR) public void onBreakBeenest(TileEntityRemovedEvent event) { - Block block = event.getBlock(); - if (!beenestMap.containsKey(block)) { + removeTrackedDisplay(event.getBlock()); + } + + private void removeTrackedDisplay(Block block) { + blockUpdates.remove(block); + Map map = beenestMap.remove(block); + if (map == null) { return; } - - Map map = beenestMap.get(block); if (map.get("0") instanceof DisplayEntity) { DisplayEntity stand = (DisplayEntity) map.get("0"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); @@ -220,7 +418,6 @@ public void onBreakBeenest(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("1"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - beenestMap.remove(block); } public void updateBlock(Block block) { @@ -252,14 +449,10 @@ public void updateBlock(Block block) { } String str1 = beeCountText.replace("{Current}", beehiveState.getEntityCount() + "").replace("{Max}", beehiveState.getMaxEntities() + ""); - if (!PlainTextComponentSerializer.plainText().serialize(line0.getCustomName()).equals(str0)) { - line0.setCustomName(str0); - line0.setCustomNameVisible(true); + if (line0.updateCustomName(str0, true)) { DisplayManager.updateDisplay(line0); } - if (!PlainTextComponentSerializer.plainText().serialize(line1.getCustomName()).equals(str1)) { - line1.setCustomName(str1); - line1.setCustomNameVisible(true); + if (line1.updateCustomName(str1, true)) { DisplayManager.updateDisplay(line1); } } @@ -275,19 +468,15 @@ public boolean isActive(Location loc) { public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); - Location origin = block.getLocation(); - BlockData blockData = block.getState().getBlockData(); BlockFace facing = ((Directional) blockData).getFacing(); - Location target = block.getRelative(facing).getLocation(); - Vector direction = target.toVector().subtract(origin.toVector()).multiply(0.7); - Location loc0 = block.getLocation().clone().add(direction).add(0.5, 0.25, 0.5); + Location loc0 = labelLocation(block.getLocation(), facing, 0.25); loc0.setDirection(facing.getDirection()); DisplayEntity line0 = new DisplayEntity(loc0.clone()); setStand(line0); - Location loc1 = block.getLocation().clone().add(direction).add(0.5, 0, 0.5); + Location loc1 = labelLocation(block.getLocation(), facing, 0.0); loc1.setDirection(facing.getDirection()); DisplayEntity line1 = new DisplayEntity(loc1.clone()); setStand(line1); @@ -301,6 +490,11 @@ public Map spawnDisplayEntitys(Block block) { return map; } + static Location labelLocation(Location blockLocation, BlockFace facing, double lineOffset) { + Vector direction = facing.getDirection().multiply(0.7); + return blockLocation.clone().add(direction).add(0.5, lineOffset, 0.5); + } + public void setStand(DisplayEntity stand) { stand.setBasePlate(false); stand.setMarker(true); @@ -311,6 +505,7 @@ public void setStand(DisplayEntity stand) { stand.setVisible(false); stand.setCustomName(""); stand.setRightArmPose(EulerAngle.ZERO); + stand.useLegacyNameTagGeometry(); } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java index 596d4622..a7c1c3d4 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java @@ -25,11 +25,15 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; @@ -38,7 +42,7 @@ import com.loohp.interactionvisualizer.utils.VanishUtils; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -50,9 +54,14 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.inventory.FurnaceBurnEvent; +import org.bukkit.event.inventory.FurnaceExtractEvent; +import org.bukkit.event.inventory.FurnaceSmeltEvent; +import org.bukkit.event.inventory.FurnaceStartSmeltEvent; import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryMoveItemEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.util.EulerAngle; @@ -78,9 +87,12 @@ public class BlastFurnaceDisplay extends VisualizerRunnableDisplay implements Li private String noFuelColor = "&c"; private int progressBarLength = 10; private String amountPending = " &7+{Amount}"; + private final BlockUpdateScheduler blockUpdates; public BlastFurnaceDisplay() { onReload(new InteractionVisualizerReloadEvent()); + this.blockUpdates = new BlockUpdateScheduler<>(this::nearbyBlastFurnace, + this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -102,6 +114,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = blastfurnaceMap.entrySet().iterator(); int count = 0; @@ -149,6 +164,21 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return legacyRun(); + } + return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), + InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); + if (collecting) { + PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); + } + }, 0, 1); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyBlastFurnace(); for (Block block : list) { @@ -178,15 +208,18 @@ public ScheduledTask run() { } Block block = entry.getKey(); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - if (!isActive(block.getLocation())) { - return; - } - if (!block.getType().equals(Material.BLAST_FURNACE)) { - return; - } - org.bukkit.block.BlastFurnace blastfurnace = (org.bukkit.block.BlastFurnace) block.getState(); + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + try { + if (!isActive(block.getLocation())) { + return; + } + if (!block.getType().equals(Material.BLAST_FURNACE)) { + return; + } + org.bukkit.block.BlastFurnace blastfurnace = (org.bukkit.block.BlastFurnace) block.getState(); - { + { Inventory inv = blastfurnace.getInventory(); ItemStack itemstack = inv.getItem(0); if (itemstack != null) { @@ -261,25 +294,18 @@ public ScheduledTask run() { symbol = symbol.replace("{CompletedAmount}", (inv.getItem(2) == null ? 0 : inv.getItem(2).getAmount()) + ""); } if (hasFuel(blastfurnace)) { - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals(symbol) || !stand.isCustomNameVisible()) { - stand.setCustomNameVisible(true); - stand.setCustomName(symbol); - DisplayManager.updateDisplay(stand); - } + FurnaceDisplayUpdater.showProgress(stand, entry.getValue(), symbol); } else { symbol = noFuelColor + ChatColorUtils.stripColor(symbol); - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals(symbol) || !stand.isCustomNameVisible()) { - stand.setCustomNameVisible(true); - stand.setCustomName(symbol); - DisplayManager.updateDisplay(stand); - } + FurnaceDisplayUpdater.showProgress(stand, entry.getValue(), symbol); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals("") || stand.isCustomNameVisible()) { - stand.setCustomNameVisible(false); - stand.setCustomName(""); - DisplayManager.updateDisplay(stand); - } + FurnaceDisplayUpdater.hideProgress(stand, entry.getValue()); + } + } + } finally { + if (collecting) { + PerformanceMetrics.blockUpdateChecks(1, System.nanoTime() - start); } } }, delay, block.getLocation()); @@ -287,6 +313,106 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!TileEntityManager.getTileEntities(TileEntityType.BLAST_FURNACE).contains(block)) { + removeTrackedDisplay(block); + return false; + } + if (!block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) { + removeTrackedDisplay(block); + return false; + } + if (!isBlastFurnace(block.getType())) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = blastfurnaceMap.get(block); + if (values == null) { + values = new HashMap<>(); + values.put("Item", "N/A"); + values.putAll(spawnDisplayEntitys(block)); + blastfurnaceMap.put(block, values); + } + org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); + return FurnaceDisplayUpdater.update(furnace, values, KEY, progressBarCharacter, emptyColor, + filledColor, noFuelColor, progressBarLength, amountPending); + } + + private void markDirty(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isBlastFurnace(block.getType())) { + blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + private void markDirtyUnlessActive(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isBlastFurnace(block.getType())) { + blockUpdates.markDirtyUnlessActive(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + /** Optimized aggregate-listener entry after the inventory location has been resolved once. */ + public void onBlastFurnaceInventoryChanged(Block block) { + markDirty(block); + } + + private void markDirty(Inventory inventory) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + Location location; + try { + location = inventory.getLocation(); + } catch (Exception | AbstractMethodError ignored) { + return; + } + if (location != null) { + markDirty(location.getBlock()); + } + } + + public void onBlastFurnaceBurn(FurnaceBurnEvent event) { + markDirty(event.getBlock()); + } + + public void onBlastFurnaceStartSmelt(FurnaceStartSmeltEvent event) { + markDirtyUnlessActive(event.getBlock()); + } + + public void onBlastFurnaceSmelt(FurnaceSmeltEvent event) { + markDirtyUnlessActive(event.getBlock()); + } + + public void onBlastFurnaceExtract(FurnaceExtractEvent event) { + markDirty(event.getBlock()); + } + + public void onBlastFurnaceMoveItem(InventoryMoveItemEvent event) { + markDirty(event.getSource()); + markDirty(event.getDestination()); + } + + public void onBlastFurnaceAdded(TileEntityAddedEvent event) { + if (event.getTileEntityType() == TileEntityType.BLAST_FURNACE) { + markDirty(event.getBlock()); + } + } + + public void onBlastFurnaceActivated(TileEntityActivatedEvent event) { + if (event.getTileEntityType() == TileEntityType.BLAST_FURNACE) { + markDirty(event.getBlock()); + } + } + + public void onBlastFurnaceDeactivated(TileEntityDeactivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.BLAST_FURNACE) { + removeTrackedDisplay(event.getBlock()); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onBlastFurnace(InventoryClickEvent event) { if (VanishUtils.isVanished((Player) event.getWhoClicked())) { @@ -348,6 +474,7 @@ public void onBlastFurnace(InventoryClickEvent event) { if (!event.getView().getTopInventory().getLocation().getBlock().getType().equals(Material.BLAST_FURNACE)) { return; } + markDirty(event.getView().getTopInventory().getLocation().getBlock()); Block block = event.getView().getTopInventory().getLocation().getBlock(); @@ -438,6 +565,7 @@ public void onDragBlastFurnace(InventoryDragEvent event) { for (int slot : event.getRawSlots()) { if (slot >= 0 && slot <= 2) { + markDirty(event.getView().getTopInventory().getLocation().getBlock()); DisplayManager.sendHandMovement(InteractionVisualizerAPI.getPlayers(), (Player) event.getWhoClicked()); break; } @@ -446,12 +574,15 @@ public void onDragBlastFurnace(InventoryDragEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onBreakBlastFurnace(TileEntityRemovedEvent event) { - Block block = event.getBlock(); - if (!blastfurnaceMap.containsKey(block)) { + removeTrackedDisplay(event.getBlock()); + } + + private void removeTrackedDisplay(Block block) { + blockUpdates.remove(block); + Map map = blastfurnaceMap.remove(block); + if (map == null) { return; } - - Map map = blastfurnaceMap.get(block); if (map.get("Item") instanceof Item) { Item item = (Item) map.get("Item"); DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); @@ -460,7 +591,6 @@ public void onBreakBlastFurnace(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("Stand"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - blastfurnaceMap.remove(block); } public boolean hasItemToCook(org.bukkit.block.BlastFurnace blastfurnace) { @@ -490,6 +620,10 @@ public boolean isActive(Location loc) { return PlayerLocationManager.hasPlayerNearby(loc); } + private boolean isBlastFurnace(Material material) { + return material == Material.BLAST_FURNACE; + } + public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); Location origin = block.getLocation(); @@ -512,6 +646,7 @@ public Map spawnDisplayEntitys(Block block) { } public void setStand(DisplayEntity stand) { + stand.useLegacyNameTagStyle(); stand.setBasePlate(false); stand.setMarker(true); stand.setGravity(false); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlockUpdateScheduler.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlockUpdateScheduler.java new file mode 100644 index 00000000..238ecfbe --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlockUpdateScheduler.java @@ -0,0 +1,306 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.TreeMap; +import java.util.function.Supplier; + +/** + * Main-thread scheduler for event-driven block displays. + * + *

Dirty entries are coalesced by key and become eligible on an explicit + * tick. Active entries are rotated through a reusable queue so each entry is + * checked approximately once per configured update period. Callers can absorb + * synchronized level signals for already-active entries into that cadence + * without weakening urgent interaction edges. A weakly + * consistent iterator performs a bounded safety audit without allocating a + * snapshot of every tracked block. The dirty cap supplied to {@link #tick} + * applies to this scheduler instance (one display type), not globally across + * every display.

+ */ +final class BlockUpdateScheduler { + + @FunctionalInterface + interface Updater { + /** + * @return {@code true} while the value needs periodic active updates + */ + boolean update(T value); + } + + private final Supplier> auditSource; + private final int activePeriod; + private final int auditPeriod; + private final NavigableMap> dirtyByTick; + private final Map dirtyDueTick; + private final NavigableMap> activeByTick; + private final Map activeDueTick; + private final HashSet processedThisTick; + private final HashSet removedValues; + + private Iterator auditIterator; + private long nextAuditTick; + private int auditBudget; + private int activeBudget; + private boolean initialAudit; + + BlockUpdateScheduler(Supplier> auditSource, int activePeriod, int auditPeriod) { + this.auditSource = Objects.requireNonNull(auditSource, "auditSource"); + this.activePeriod = Math.max(1, activePeriod); + this.auditPeriod = Math.max(1, auditPeriod); + this.dirtyByTick = new TreeMap<>(); + this.dirtyDueTick = new HashMap<>(); + this.activeByTick = new TreeMap<>(); + this.activeDueTick = new HashMap<>(); + this.processedThisTick = new HashSet<>(); + this.removedValues = new HashSet<>(); + this.nextAuditTick = Long.MIN_VALUE; + this.activeBudget = 0; + this.initialAudit = true; + } + + void markDirty(T value, long readyTick) { + if (value == null) { + return; + } + this.removedValues.remove(value); + Long previousTick = this.dirtyDueTick.get(value); + if (previousTick != null && previousTick <= readyTick) { + return; + } + if (previousTick != null) { + LinkedHashSet previous = this.dirtyByTick.get(previousTick); + if (previous != null) { + previous.remove(value); + if (previous.isEmpty()) { + this.dirtyByTick.remove(previousTick); + } + } + } + this.dirtyDueTick.put(value, readyTick); + this.dirtyByTick.computeIfAbsent(readyTick, ignored -> new LinkedHashSet<>()).add(value); + } + + /** + * Coalesces a non-urgent level signal into the existing active cadence. + * Inactive values still enter the dirty queue so the signal can bootstrap + * tracking. Urgent player, inventory and lifecycle edges must use + * {@link #markDirty(Object, long)}. + */ + void markDirtyUnlessActive(T value, long readyTick) { + if (value != null && this.activeDueTick.containsKey(value)) { + return; + } + this.markDirty(value, readyTick); + } + + void markActive(T value, long readyTick) { + this.removedValues.remove(value); + this.schedule(value, readyTick, this.activeDueTick, this.activeByTick); + } + + void remove(T value) { + if (value == null) { + return; + } + if (this.auditIterator != null) { + this.removedValues.add(value); + } + this.unschedule(value, this.activeDueTick, this.activeByTick); + this.unschedule(value, this.dirtyDueTick, this.dirtyByTick); + } + + int tick(long tick, int maxDirtyPerTick, Updater updater) { + Objects.requireNonNull(updater, "updater"); + int dirtyBudget = Math.max(1, maxDirtyPerTick); + this.processedThisTick.clear(); + + int checks = this.drainDirty(tick, dirtyBudget, updater); + checks += this.drainActive(tick, updater); + checks += this.drainAudit(tick, dirtyBudget, updater); + return checks; + } + + private int drainDirty(long tick, int budget, Updater updater) { + int checks = 0; + while (checks < budget) { + Map.Entry> entry = this.dirtyByTick.firstEntry(); + if (entry == null || entry.getKey() > tick) { + break; + } + Iterator iterator = entry.getValue().iterator(); + if (!iterator.hasNext()) { + this.dirtyByTick.pollFirstEntry(); + continue; + } + T value = iterator.next(); + iterator.remove(); + this.dirtyDueTick.remove(value, entry.getKey()); + if (entry.getValue().isEmpty()) { + this.dirtyByTick.pollFirstEntry(); + } + if (this.updateOnce(value, tick, updater)) { + checks++; + } + } + return checks; + } + + private int drainActive(long tick, Updater updater) { + if (this.activeDueTick.isEmpty()) { + this.activeBudget = 0; + return 0; + } + this.activeBudget = Math.max(this.activeBudget, + Math.max(1, (this.activeDueTick.size() + this.activePeriod - 1) / this.activePeriod)); + int checks = 0; + while (checks < this.activeBudget) { + Map.Entry> entry = this.activeByTick.firstEntry(); + if (entry == null || entry.getKey() > tick) { + break; + } + Iterator iterator = entry.getValue().iterator(); + if (!iterator.hasNext()) { + this.activeByTick.pollFirstEntry(); + continue; + } + T value = iterator.next(); + iterator.remove(); + this.activeDueTick.remove(value, entry.getKey()); + if (entry.getValue().isEmpty()) { + this.activeByTick.pollFirstEntry(); + } + if (this.removedValues.contains(value)) { + continue; + } + if (this.processedThisTick.contains(value)) { + this.markActive(value, tick + this.activePeriod); + continue; + } + boolean keepActive = this.updateAndRemember(value, updater); + checks++; + if (keepActive) { + this.markActive(value, tick + this.activePeriod); + } + } + return checks; + } + + private int drainAudit(long tick, int initialBudget, Updater updater) { + if (this.auditIterator == null && tick >= this.nextAuditTick) { + Collection source = this.auditSource.get(); + this.auditIterator = source.iterator(); + this.auditBudget = this.initialAudit + ? initialBudget + : Math.max(1, (source.size() + this.auditPeriod - 1) / this.auditPeriod); + this.initialAudit = false; + this.nextAuditTick = tick + this.auditPeriod; + } + if (this.auditIterator == null) { + return 0; + } + + int checks = 0; + int attempts = this.auditBudget; + while (attempts-- > 0 && this.auditIterator.hasNext()) { + T value = this.auditIterator.next(); + if (this.updateOnce(value, tick, updater)) { + checks++; + } + } + if (!this.auditIterator.hasNext()) { + this.auditIterator = null; + this.removedValues.clear(); + } + return checks; + } + + private boolean updateOnce(T value, long tick, Updater updater) { + if (this.removedValues.contains(value)) { + return false; + } + if (!this.processedThisTick.add(value)) { + return false; + } + boolean keepActive = updater.update(value); + if (keepActive) { + this.unschedule(value, this.activeDueTick, this.activeByTick); + this.markActive(value, tick + this.activePeriod); + } else { + this.unschedule(value, this.activeDueTick, this.activeByTick); + } + return true; + } + + private boolean updateAndRemember(T value, Updater updater) { + this.processedThisTick.add(value); + return updater.update(value); + } + + private void schedule(T value, long readyTick, Map dueTicks, + NavigableMap> byTick) { + if (value == null) { + return; + } + Long previousTick = dueTicks.get(value); + if (previousTick != null && previousTick <= readyTick) { + return; + } + if (previousTick != null) { + LinkedHashSet previous = byTick.get(previousTick); + if (previous != null) { + previous.remove(value); + if (previous.isEmpty()) { + byTick.remove(previousTick); + } + } + } + dueTicks.put(value, readyTick); + byTick.computeIfAbsent(readyTick, ignored -> new LinkedHashSet<>()).add(value); + } + + private void unschedule(T value, Map dueTicks, + NavigableMap> byTick) { + Long dueTick = dueTicks.remove(value); + if (dueTick == null) { + return; + } + LinkedHashSet values = byTick.get(dueTick); + if (values != null) { + values.remove(value); + if (values.isEmpty()) { + byTick.remove(dueTick); + } + } + } + + int pendingDirtyCount() { + return this.dirtyDueTick.size(); + } + + int activeCount() { + return this.activeDueTick.size(); + } + + int invalidatedAuditValueCount() { + return this.removedValues.size(); + } + +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BrewingStandDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BrewingStandDisplay.java index d113d3f8..2b4e1c85 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BrewingStandDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BrewingStandDisplay.java @@ -36,7 +36,6 @@ import com.loohp.interactionvisualizer.utils.ChatColorUtils; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -219,17 +218,17 @@ public ScheduledTask run() { if (brewingstand.getFuelLevel() == 0) { DisplayEntity stand = (DisplayEntity) entry.getValue().get("Stand"); if (hasPotion(brewingstand)) { - stand.setCustomNameVisible(true); String name = noFuelColor; for (int i = 0; i < progressBarLength; i++) { name += progressBarCharacter; } - stand.setCustomName(name); - DisplayManager.updateDisplay(stand); + if (stand.updateCustomName(name, true)) { + DisplayManager.updateDisplay(stand); + } } else { - stand.setCustomNameVisible(false); - stand.setCustomName(""); - DisplayManager.updateDisplay(stand); + if (stand.updateCustomName("", false)) { + DisplayManager.updateDisplay(stand); + } } } else { DisplayEntity stand = (DisplayEntity) entry.getValue().get("Stand"); @@ -252,15 +251,11 @@ public ScheduledTask run() { for (i = progressBarLength - 1; i >= percentagescaled; i--) { symbol += emptyColor + progressBarCharacter; } - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals(symbol) || !stand.isCustomNameVisible()) { - stand.setCustomNameVisible(true); - stand.setCustomName(symbol); + if (stand.updateCustomName(symbol, true)) { DisplayManager.updateDisplay(stand); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals("") || stand.isCustomNameVisible()) { - stand.setCustomNameVisible(false); - stand.setCustomName(""); + if (stand.updateCustomName("", false)) { DisplayManager.updateDisplay(stand); } } @@ -393,6 +388,11 @@ public Map spawnDisplayEntitys(Block block) { //.add(0.68 } public void setStand(DisplayEntity stand) { + configureLabel(stand); + } + + static void configureLabel(DisplayEntity stand) { + stand.useLegacyNameTagStyle(); stand.setBasePlate(false); stand.setMarker(true); stand.setGravity(false); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CampfireDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CampfireDisplay.java index 6d769a17..ee36c1e3 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CampfireDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CampfireDisplay.java @@ -35,7 +35,6 @@ import com.loohp.interactionvisualizer.utils.ChatColorUtils; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; @@ -242,15 +241,11 @@ public ScheduledTask run() { symbol += emptyColor + progressBarCharacter; } - if (!PlainTextComponentSerializer.plainText().serialize(stand1.getCustomName()).equals(symbol) || !stand1.isCustomNameVisible()) { - stand1.setCustomNameVisible(true); - stand1.setCustomName(symbol); + if (stand1.updateCustomName(symbol, true)) { DisplayManager.updateDisplay(stand1); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand1.getCustomName()).equals("") || stand1.isCustomNameVisible()) { - stand1.setCustomNameVisible(false); - stand1.setCustomName(""); + if (stand1.updateCustomName("", false)) { DisplayManager.updateDisplay(stand1); } } @@ -275,15 +270,11 @@ public ScheduledTask run() { symbol += emptyColor + progressBarCharacter; } - if (!PlainTextComponentSerializer.plainText().serialize(stand2.getCustomName()).equals(symbol) || !stand2.isCustomNameVisible()) { - stand2.setCustomNameVisible(true); - stand2.setCustomName(symbol); + if (stand2.updateCustomName(symbol, true)) { DisplayManager.updateDisplay(stand2); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand2.getCustomName()).equals("") || stand2.isCustomNameVisible()) { - stand2.setCustomNameVisible(false); - stand2.setCustomName(""); + if (stand2.updateCustomName("", false)) { DisplayManager.updateDisplay(stand2); } } @@ -308,15 +299,11 @@ public ScheduledTask run() { symbol += emptyColor + progressBarCharacter; } - if (!PlainTextComponentSerializer.plainText().serialize(stand3.getCustomName()).equals(symbol) || !stand3.isCustomNameVisible()) { - stand3.setCustomNameVisible(true); - stand3.setCustomName(symbol); + if (stand3.updateCustomName(symbol, true)) { DisplayManager.updateDisplay(stand3); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand3.getCustomName()).equals("") || stand3.isCustomNameVisible()) { - stand3.setCustomNameVisible(false); - stand3.setCustomName(""); + if (stand3.updateCustomName("", false)) { DisplayManager.updateDisplay(stand3); } } @@ -341,15 +328,11 @@ public ScheduledTask run() { symbol += emptyColor + progressBarCharacter; } - if (!PlainTextComponentSerializer.plainText().serialize(stand4.getCustomName()).equals(symbol) || !stand4.isCustomNameVisible()) { - stand4.setCustomNameVisible(true); - stand4.setCustomName(symbol); + if (stand4.updateCustomName(symbol, true)) { DisplayManager.updateDisplay(stand4); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand4.getCustomName()).equals("") || stand4.isCustomNameVisible()) { - stand4.setCustomNameVisible(false); - stand4.setCustomName(""); + if (stand4.updateCustomName("", false)) { DisplayManager.updateDisplay(stand4); } } @@ -403,7 +386,7 @@ public Map spawnDisplayEntitys(Block block) { Location target = block.getRelative(facing).getLocation(); Vector direction = rotateVectorAroundY(target.toVector().subtract(origin.toVector()).multiply(0.44194173), 135); - Location loc = origin.clone().add(0.5, 0.3, 0.5); + Location loc = labelOrigin(origin); DisplayEntity slot1 = new DisplayEntity(loc.clone().add(direction)); setStand(slot1); DisplayEntity slot2 = new DisplayEntity(loc.clone().add(rotateVectorAroundY(direction.clone(), 90))); @@ -426,7 +409,16 @@ public Map spawnDisplayEntitys(Block block) { return map; } + static Location labelOrigin(Location origin) { + return origin.clone().add(0.5, 0.3, 0.5); + } + public void setStand(DisplayEntity stand) { + configureLabel(stand); + } + + static void configureLabel(DisplayEntity stand) { + stand.useLegacyNameTagStyle(); stand.setBasePlate(false); stand.setMarker(true); stand.setGravity(false); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CartographyTableDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CartographyTableDisplay.java index 432833bd..3f34fa79 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CartographyTableDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CartographyTableDisplay.java @@ -24,7 +24,8 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI; import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerInteractDisplay; -import com.loohp.interactionvisualizer.entityholders.ItemFrame; +import com.loohp.interactionvisualizer.entityholders.Item; +import com.loohp.interactionvisualizer.entityholders.Item.RenderMode; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.utils.VanishUtils; @@ -32,10 +33,10 @@ import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import org.bukkit.FluidCollisionMode; import org.bukkit.GameMode; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; -import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -98,11 +99,11 @@ public void run() { } } - if (map.get("Item") instanceof ItemFrame) { - Entity entity = (Entity) map.get("Item"); - DisplayManager.removeItemFrame(InteractionVisualizerAPI.getPlayers(), (ItemFrame) entity); + if (map.get("Item") instanceof Item item) { + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); } openedCTable.remove(block); + playermap.remove((Player) map.get("Player"), block); } }.runTaskLater(InteractionVisualizer.plugin, delay, block.getLocation()); } @@ -168,31 +169,37 @@ public void process(Player player) { itemstack = output; } - ItemFrame item = null; - if (!block.getRelative(BlockFace.UP).getType().isSolid()) { - if (map.get("Item") instanceof String) { - if (itemstack != null) { - item = new ItemFrame(block.getRelative(BlockFace.UP).getLocation()); - item.setItem(itemstack); - item.setFacingDirection(BlockFace.UP); - item.setSilent(true); - map.put("Item", item); - DisplayManager.sendItemFrameSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), item); - DisplayManager.updateItemFrame(item); - } else { - map.put("Item", "N/A"); - } + if (block.getRelative(BlockFace.UP).getType().isSolid()) { + if (map.get("Item") instanceof Item item) { + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); + map.put("Item", "N/A"); + } + return; + } + + if (map.get("Item") instanceof String) { + if (itemstack != null) { + Location location = block.getLocation().clone().add(0.5, 1.03125, 0.5); + location.setPitch(-90.0F); + Item item = new Item(location, RenderMode.FRAME); + item.setItemStack(itemstack); + item.setSilent(true); + map.put("Item", item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), item); + DisplayManager.updateItem(item); } else { - item = (ItemFrame) map.get("Item"); - if (itemstack != null) { - if (!item.getItem().equals(itemstack)) { - item.setItem(itemstack); - DisplayManager.updateItemFrame(item); - } - } else { - map.put("Item", "N/A"); - DisplayManager.removeItemFrame(InteractionVisualizerAPI.getPlayers(), item); + map.put("Item", "N/A"); + } + } else { + Item item = (Item) map.get("Item"); + if (itemstack != null) { + if (!item.getItemStack().equals(itemstack)) { + item.setItemStack(itemstack); + DisplayManager.updateItem(item); } + } else { + map.put("Item", "N/A"); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); } } } @@ -230,27 +237,21 @@ public void onDragCartographyTable(InventoryDragEvent event) { @EventHandler public void onCloseCartographyTable(InventoryCloseEvent event) { - if (!playermap.containsKey((Player) event.getPlayer())) { - return; - } - - Block block = playermap.get((Player) event.getPlayer()); - - if (!openedCTable.containsKey(block)) { + Player player = (Player) event.getPlayer(); + Block block = playermap.remove(player); + if (block == null) { return; } Map map = openedCTable.get(block); - if (!map.get("Player").equals(event.getPlayer())) { + if (map == null || !map.get("Player").equals(player)) { return; } - if (map.get("Item") instanceof ItemFrame) { - ItemFrame entity = (ItemFrame) map.get("Item"); - DisplayManager.removeItemFrame(InteractionVisualizerAPI.getPlayers(), entity); + if (map.get("Item") instanceof Item item) { + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); } openedCTable.remove(block); - playermap.remove((Player) event.getPlayer()); } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/ChestDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/ChestDisplay.java index ccdf426e..5b039c0c 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/ChestDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/ChestDisplay.java @@ -216,11 +216,10 @@ public void onUseChest(InventoryClickEvent event) { vector = loc.clone().add(0.5, 1, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.15).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -323,12 +322,10 @@ public void onDragChest(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 1, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.15).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); - item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/ConduitDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/ConduitDisplay.java index 21098065..8157285d 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/ConduitDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/ConduitDisplay.java @@ -271,6 +271,7 @@ public Map spawnDisplayEntitys(Block block) { } public void setStand(DisplayEntity stand) { + stand.useLegacyNameTagStyle(); stand.setBasePlate(false); stand.setMarker(true); stand.setGravity(false); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CrafterDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CrafterDisplay.java index 691b029b..9ee6cc48 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CrafterDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CrafterDisplay.java @@ -26,16 +26,16 @@ import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; -import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; import com.loohp.interactionvisualizer.utils.MaterialUtils; +import com.loohp.interactionvisualizer.utils.WorkstationDisplayPositioning; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -51,7 +51,6 @@ import org.bukkit.event.inventory.InventoryMoveItemEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; -import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; import java.util.HashMap; @@ -103,9 +102,9 @@ public ScheduledTask gc() { if (!isActive(block.getLocation())) { Map map = entry.getValue(); for (int i = 1; i <= 9; i++) { - if (map.get(String.valueOf(i)) instanceof DisplayEntity) { - DisplayEntity stand = (DisplayEntity) map.get(String.valueOf(i)); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); + if (map.get(String.valueOf(i)) instanceof Item) { + Item stand = (Item) map.get(String.valueOf(i)); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), stand); } } crafterMap.remove(block); @@ -114,9 +113,9 @@ public ScheduledTask gc() { if (!block.getType().equals(Material.CRAFTER) || getCardinalDirection(block) < 0F) { Map map = entry.getValue(); for (int i = 1; i <= 9; i++) { - if (map.get(String.valueOf(i)) instanceof DisplayEntity) { - DisplayEntity stand = (DisplayEntity) map.get(String.valueOf(i)); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); + if (map.get(String.valueOf(i)) instanceof Item) { + Item stand = (Item) map.get(String.valueOf(i)); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), stand); } } crafterMap.remove(block); @@ -160,7 +159,10 @@ public ScheduledTask run() { } public void handleUpdate(Block block, Map map) { - Crafter crafter = (Crafter) block.getState(); + if (block.getType() != Material.CRAFTER || crafterMap.get(block) != map + || !(block.getState() instanceof Crafter crafter)) { + return; + } Inventory inventory = crafter.getInventory(); ItemStack[] items = new ItemStack[] { @@ -176,30 +178,30 @@ public void handleUpdate(Block block, Map map) { }; for (int i = 0; i < 9; i++) { - DisplayEntity stand = (DisplayEntity) map.get(String.valueOf(i + 1)); - ItemStack item = items[i]; + Item stand = (Item) map.get(String.valueOf(i + 1)); + ItemStack itemStack = items[i]; if (crafter.isSlotDisabled(i)) { - item = new ItemStack(Material.BARRIER); - } else if (item == null || item.getType().equals(Material.AIR)) { - item = null; + itemStack = new ItemStack(Material.BARRIER); + } else if (itemStack == null || itemStack.getType().equals(Material.AIR)) { + itemStack = ItemStack.empty(); } - if (item != null) { - MaterialUtils.MaterialMode materialMode = MaterialUtils.getMaterialType(item); - boolean changed = materialMode != standMode(stand); + if (!itemStack.isEmpty()) { + Item.RenderMode renderMode = renderMode(MaterialUtils.getMaterialType(itemStack)); + boolean changed = renderMode != standMode(stand); if (changed) { - toggleStandMode(stand, materialMode.toString()); + toggleStandMode(stand, renderMode); } - if (!item.equals(stand.getItemInMainHand())) { + if (!itemStack.equals(stand.getItemStack())) { changed = true; - stand.setItemInMainHand(item); + stand.setItemStack(itemStack); } if (changed) { - DisplayManager.updateDisplay(stand); + DisplayManager.updateItem(stand); } } else { - if (!stand.getItemInMainHand().getType().equals(Material.AIR)) { - stand.setItemInMainHand(new ItemStack(Material.AIR)); - DisplayManager.updateDisplay(stand); + if (!stand.getItemStack().isEmpty()) { + stand.setItemStack(ItemStack.empty()); + DisplayManager.updateItem(stand); } } } @@ -278,7 +280,7 @@ public void onUseCrafter(InventoryClickEvent event) { return; } - if (event.getRawSlot() >= 0 && event.getRawSlot() <= 2) { + if (event.getRawSlot() >= 0 && event.getRawSlot() <= 8) { DisplayManager.sendHandMovement(InteractionVisualizerAPI.getPlayers(), (Player) event.getWhoClicked()); } Map map = crafterMap.get(block); @@ -296,9 +298,9 @@ public void onBreakCrafter(TileEntityRemovedEvent event) { Map map = crafterMap.get(block); for (int i = 1; i <= 9; i++) { - if (map.get(String.valueOf(i)) instanceof DisplayEntity) { - DisplayEntity stand = (DisplayEntity) map.get(String.valueOf(i)); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); + if (map.get(String.valueOf(i)) instanceof Item) { + Item stand = (Item) map.get(String.valueOf(i)); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), stand); } } crafterMap.remove(block); @@ -312,108 +314,45 @@ public boolean isActive(Location loc) { return PlayerLocationManager.hasPlayerNearby(loc); } - public MaterialUtils.MaterialMode standMode(DisplayEntity stand) { - String plain = PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()); - if (plain.startsWith("IV.Crafter.")) { - return MaterialUtils.MaterialMode.getModeFromName(plain.substring(plain.lastIndexOf(".") + 1)); - } - return null; + public Item.RenderMode standMode(Item stand) { + return stand.getRenderMode(); } - public void toggleStandMode(DisplayEntity stand, String mode) { - String plain = PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()); - if (!plain.equals("IV.Crafter.Item")) { - if (plain.equals("IV.Crafter.Block")) { - stand.setCustomName("IV.Crafter.Item"); - stand.setRotation(stand.getLocation().getYaw() - 45, stand.getLocation().getPitch()); - stand.setRightArmPose(EulerAngle.ZERO); - stand.teleport(stand.getLocation().add(0.0, -0.084, 0.0)); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.102), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.14))); - - } - if (plain.equals("IV.Crafter.LowBlock")) { - stand.setCustomName("IV.Crafter.Item"); - stand.setRotation(stand.getLocation().getYaw() - 45, stand.getLocation().getPitch()); - stand.setRightArmPose(EulerAngle.ZERO); - stand.teleport(stand.getLocation().add(0.0, -0.02, 0.0)); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.09), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.15))); + private static Item.RenderMode renderMode(MaterialUtils.MaterialMode mode) { + return switch (mode) { + case ITEM -> Item.RenderMode.ITEM; + case BLOCK -> Item.RenderMode.BLOCK; + case LOWBLOCK -> Item.RenderMode.LOW_BLOCK; + case TOOL -> Item.RenderMode.TOOL; + case STANDING -> Item.RenderMode.STANDING; + }; + } - } - if (plain.equals("IV.Crafter.Tool")) { - stand.setCustomName("IV.Crafter.Item"); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.3), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.1))); - stand.teleport(stand.getLocation().add(0, 0.26, 0)); - stand.setRightArmPose(EulerAngle.ZERO); - } - if (plain.equals("IV.Crafter.Standing")) { - stand.setCustomName("IV.Crafter.Item"); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().getDirection().normalize().multiply(0.323), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().getDirection().normalize().multiply(-0.115))); - stand.teleport(stand.getLocation().add(0, 0.32, 0)); - stand.setRightArmPose(EulerAngle.ZERO); - } - } - if (mode.equals("Block")) { - stand.setCustomName("IV.Crafter.Block"); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.14))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.102), -90))); - stand.teleport(stand.getLocation().add(0.0, 0.084, 0.0)); - stand.setRightArmPose(new EulerAngle(357.9, 0.0, 0.0)); - stand.setRotation(stand.getLocation().getYaw() + 45, stand.getLocation().getPitch()); - } - if (mode.equals("LowBlock")) { - stand.setCustomName("IV.Crafter.LowBlock"); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.15))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.09), -90))); - stand.teleport(stand.getLocation().add(0.0, 0.02, 0.0)); - stand.setRightArmPose(new EulerAngle(357.9, 0.0, 0.0)); - stand.setRotation(stand.getLocation().getYaw() + 45, stand.getLocation().getPitch()); - } - if (mode.equals("Tool")) { - stand.setCustomName("IV.Crafter.Tool"); - stand.setRightArmPose(new EulerAngle(357.99, 0.0, 300.0)); - stand.teleport(stand.getLocation().add(0, -0.26, 0)); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.1))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.3), -90))); - } - if (mode.equals("Standing")) { - stand.setCustomName("IV.Crafter.Standing"); - stand.setRightArmPose(new EulerAngle(0.0, 4.7, 4.7)); - stand.teleport(stand.getLocation().add(0, -0.32, 0)); - stand.teleport(stand.getLocation().add(stand.getLocation().getDirection().normalize().multiply(0.115))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().getDirection().normalize().multiply(-0.323), -90))); - } + public void toggleStandMode(Item stand, Item.RenderMode mode) { + WorkstationDisplayPositioning.setRenderMode(stand, mode); } - public Map spawnDisplayEntitys(Block block) { //.add(0.68, 0.600781, 0.35) - Map map = new HashMap<>(); - Location loc = block.getLocation().clone().add(0.5, 0.600781, 0.5); - DisplayEntity center = new DisplayEntity(loc); + public Map spawnDisplayEntitys(Block block) { + Map map = new HashMap<>(); float yaw = getCardinalDirection(block); - center.setRotation(yaw, center.getLocation().getPitch()); - setStand(center); - center.setCustomName("IV.Crafter.Center"); - Vector vector = rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.19), -100).add(center.getLocation().clone().getDirection().normalize().multiply(-0.11)); - DisplayEntity slot5 = new DisplayEntity(loc.clone().add(vector)); + Location origin = block.getLocation(); + Item slot5 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.0, 0.0); setStand(slot5, yaw); - DisplayEntity slot2 = new DisplayEntity(slot5.getLocation().clone().add(center.getLocation().clone().getDirection().normalize().multiply(0.2))); + Item slot2 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.0, 0.2); setStand(slot2, yaw); - DisplayEntity slot1 = new DisplayEntity(slot2.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), -90))); + Item slot1 = WorkstationDisplayPositioning.gridItem(origin, yaw, -0.2, 0.2); setStand(slot1, yaw); - DisplayEntity slot3 = new DisplayEntity(slot2.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), 90))); + Item slot3 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.2, 0.2); setStand(slot3, yaw); - DisplayEntity slot4 = new DisplayEntity(slot5.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), -90))); + Item slot4 = WorkstationDisplayPositioning.gridItem(origin, yaw, -0.2, 0.0); setStand(slot4, yaw); - DisplayEntity slot6 = new DisplayEntity(slot5.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), 90))); + Item slot6 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.2, 0.0); setStand(slot6, yaw); - DisplayEntity slot8 = new DisplayEntity(slot5.getLocation().clone().add(center.getLocation().getDirection().clone().normalize().multiply(-0.2))); + Item slot8 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.0, -0.2); setStand(slot8, yaw); - DisplayEntity slot7 = new DisplayEntity(slot8.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), -90))); + Item slot7 = WorkstationDisplayPositioning.gridItem(origin, yaw, -0.2, -0.2); setStand(slot7, yaw); - DisplayEntity slot9 = new DisplayEntity(slot8.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), 90))); + Item slot9 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.2, -0.2); setStand(slot9, yaw); map.put("1", slot1); @@ -426,54 +365,35 @@ public Map spawnDisplayEntitys(Block block) { //.add(0.68 map.put("8", slot8); map.put("9", slot9); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot1); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot2); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot3); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot4); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot5); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot6); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot7); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot8); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot9); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot1); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot2); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot3); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot4); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot5); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot6); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot7); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot8); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot9); return map; } - public void setStand(DisplayEntity stand, float yaw) { - stand.setArms(true); - stand.setBasePlate(false); - stand.setMarker(true); - stand.setGravity(false); - stand.setSmall(true); - stand.setInvulnerable(true); - stand.setVisible(false); - stand.setSilent(true); - stand.setRightArmPose(EulerAngle.ZERO); - stand.setCustomName("IV.Crafter.Item"); + public void setStand(Item stand, float yaw) { stand.setRotation(yaw, stand.getLocation().getPitch()); } - public void setStand(DisplayEntity stand) { - stand.setArms(true); - stand.setBasePlate(false); - stand.setMarker(true); - stand.setGravity(false); - stand.setSmall(true); - stand.setSilent(true); - stand.setInvulnerable(true); - stand.setVisible(false); - } - + /** + * Retained for binary compatibility with integrations compiled against the + * historical public helper. + */ public Vector rotateVectorAroundY(Vector vector, double degrees) { - double rad = Math.toRadians(degrees); - + double radians = Math.toRadians(degrees); double currentX = vector.getX(); double currentZ = vector.getZ(); - - double cosine = Math.cos(rad); - double sine = Math.sin(rad); - - return new Vector((cosine * currentX - sine * currentZ), vector.getY(), (sine * currentX + cosine * currentZ)); + double cosine = Math.cos(radians); + double sine = Math.sin(radians); + return new Vector(cosine * currentX - sine * currentZ, vector.getY(), + sine * currentX + cosine * currentZ); } public float getCardinalDirection(Block block) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CraftingTableDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CraftingTableDisplay.java index 47de2875..8eac80a2 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CraftingTableDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CraftingTableDisplay.java @@ -24,7 +24,6 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI; import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerInteractDisplay; -import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; @@ -33,10 +32,10 @@ import com.loohp.interactionvisualizer.utils.MaterialUtils; import com.loohp.interactionvisualizer.utils.MaterialUtils.MaterialMode; import com.loohp.interactionvisualizer.utils.VanishUtils; +import com.loohp.interactionvisualizer.utils.WorkstationDisplayPositioning; import com.loohp.interactionvisualizer.scheduler.ScheduledRunnable; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Location; @@ -56,8 +55,6 @@ import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryView; import org.bukkit.inventory.ItemStack; -import org.bukkit.scheduler.BukkitRunnable; -import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; import java.util.HashMap; @@ -113,16 +110,15 @@ public void run() { if (!(map.get(String.valueOf(i)) instanceof String)) { Object entity = map.get(String.valueOf(i)); if (i == 5) { - InteractionVisualizer.lightManager.deleteLight(((DisplayEntity) entity).getLocation()); + InteractionVisualizer.lightManager.deleteLight(((Item) entity).getLocation()); } if (entity instanceof Item) { DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), (Item) entity); - } else if (entity instanceof DisplayEntity) { - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), (DisplayEntity) entity); } } } openedBenches.remove(block); + playermap.remove(player, block); } }.runTaskLater(InteractionVisualizer.plugin, delay, block.getLocation()); } @@ -182,68 +178,65 @@ public void process(Player player) { view.getItem(9) }; - if (view.getItem(0) != null) { - ItemStack itemstack = view.getItem(0); - if (itemstack == null || itemstack.getType().equals(Material.AIR)) { - itemstack = null; + ItemStack itemstack = view.getItem(0); + if (itemstack != null && itemstack.getType().equals(Material.AIR)) { + itemstack = null; + } + if (map.get("0") instanceof String) { + if (itemstack != null) { + Item item = new Item(loc.clone().add(0.5, 1.2, 0.5)); + item.setItemStack(itemstack); + item.setVelocity(new Vector(0, 0, 0)); + item.setPickupDelay(32767); + item.setGravity(false); + map.put("0", item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); + DisplayManager.updateItem(item); + } else { + map.put("0", "N/A"); } - Item item = null; - if (map.get("0") instanceof String) { - if (itemstack != null) { - item = new Item(loc.clone().add(0.5, 1.2, 0.5)); + } else { + Item item = (Item) map.get("0"); + if (itemstack != null) { + if (!item.getItemStack().equals(itemstack)) { item.setItemStack(itemstack); - item.setVelocity(new Vector(0, 0, 0)); - item.setPickupDelay(32767); - item.setGravity(false); - map.put("0", item); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); DisplayManager.updateItem(item); - } else { - map.put("0", "N/A"); } } else { - item = (Item) map.get("0"); - if (itemstack != null) { - if (!item.getItemStack().equals(itemstack)) { - item.setItemStack(itemstack); - DisplayManager.updateItem(item); - } - } else { - map.put("0", "N/A"); - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); - } + map.put("0", "N/A"); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); } } for (int i = 0; i < 9; i++) { - DisplayEntity stand = (DisplayEntity) map.get(String.valueOf(i + 1)); - ItemStack item = items[i]; - if (item == null || item.getType().equals(Material.AIR)) { - item = null; + Item stand = (Item) map.get(String.valueOf(i + 1)); + ItemStack itemStack = items[i]; + if (itemStack == null || itemStack.getType().equals(Material.AIR)) { + itemStack = ItemStack.empty(); } - if (item != null) { - MaterialMode materialMode = MaterialUtils.getMaterialType(item); - boolean changed = materialMode != standMode(stand); + if (!itemStack.isEmpty()) { + Item.RenderMode renderMode = renderMode(MaterialUtils.getMaterialType(itemStack)); + boolean changed = renderMode != standMode(stand); if (changed) { - toggleStandMode(stand, materialMode.toString()); + toggleStandMode(stand, renderMode); } - if (!item.equals(stand.getItemInMainHand())) { + if (!itemStack.equals(stand.getItemStack())) { changed = true; - stand.setItemInMainHand(item); + stand.setItemStack(itemStack); } if (changed) { - DisplayManager.updateDisplay(stand); + DisplayManager.updateItem(stand); } } else { - if (!stand.getItemInMainHand().getType().equals(Material.AIR)) { - stand.setItemInMainHand(new ItemStack(Material.AIR)); - DisplayManager.updateDisplay(stand); + if (!stand.getItemStack().isEmpty()) { + stand.setItemStack(ItemStack.empty()); + DisplayManager.updateItem(stand); } } } - Location loc1 = ((DisplayEntity) map.get("5")).getLocation(); + Location loc1 = ((Item) map.get("5")).getLocation(); InteractionVisualizer.lightManager.deleteLight(loc1); - int skylight = loc1.getBlock().getRelative(BlockFace.UP).getLightFromSky(); - int blocklight = loc1.getBlock().getRelative(BlockFace.UP).getLightFromBlocks() - 1; + int skylight = block.getRelative(BlockFace.UP).getLightFromSky(); + int blocklight = block.getRelative(BlockFace.UP).getLightFromBlocks() - 1; blocklight = Math.max(blocklight, 0); if (skylight > 0) { InteractionVisualizer.lightManager.createLight(loc1, skylight, LightType.SKY); @@ -307,48 +300,46 @@ public void onCraft(InventoryClickEvent event) { ItemStack itemstack = event.getCurrentItem().clone(); Location loc = block.getLocation(); Player player = (Player) event.getWhoClicked(); + InventoryView view = event.getView(); + Item item; if (map.get("0") instanceof String) { - map.put("0", new Item(block.getLocation().clone().add(0.5, 1.2, 0.5))); - } - Item item = (Item) map.get("0"); - DisplayEntity slot1 = (DisplayEntity) map.get("1"); - DisplayEntity slot2 = (DisplayEntity) map.get("2"); - DisplayEntity slot3 = (DisplayEntity) map.get("3"); - DisplayEntity slot4 = (DisplayEntity) map.get("4"); - DisplayEntity slot5 = (DisplayEntity) map.get("5"); - DisplayEntity slot6 = (DisplayEntity) map.get("6"); - DisplayEntity slot7 = (DisplayEntity) map.get("7"); - DisplayEntity slot8 = (DisplayEntity) map.get("8"); - DisplayEntity slot9 = (DisplayEntity) map.get("9"); + item = new Item(block.getLocation().clone().add(0.5, 1.2, 0.5)); + item.setItemStack(itemstack); + map.put("0", item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); + } else { + item = (Item) map.get("0"); + } + Item slot1 = (Item) map.get("1"); + Item slot2 = (Item) map.get("2"); + Item slot3 = (Item) map.get("3"); + Item slot4 = (Item) map.get("4"); + Item slot5 = (Item) map.get("5"); + Item slot6 = (Item) map.get("6"); + Item slot7 = (Item) map.get("7"); + Item slot8 = (Item) map.get("8"); + Item slot9 = (Item) map.get("9"); Inventory before = Bukkit.createInventory(null, 9); for (int i = 1; i < 10; i++) { - before.setItem(i - 1, player.getOpenInventory().getItem(i).clone()); + before.setItem(i - 1, InventoryUtils.cloneItem(view.getItem(i))); } Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { + if (!player.isOnline() || player.getOpenInventory() != view) { + return; + } Inventory after = Bukkit.createInventory(null, 9); for (int i = 1; i < 10; i++) { - after.setItem(i - 1, player.getOpenInventory().getItem(i).clone()); + after.setItem(i - 1, InventoryUtils.cloneItem(view.getItem(i))); } if (InventoryUtils.compareContents(before, after)) { return; } - item.setLocked(true); - slot1.setLocked(true); - slot2.setLocked(true); - slot3.setLocked(true); - slot4.setLocked(true); - slot5.setLocked(true); - slot6.setLocked(true); - slot7.setLocked(true); - slot8.setLocked(true); - slot9.setLocked(true); - openedBenches.remove(block); float yaw = getCardinalDirection(player); @@ -363,15 +354,27 @@ public void onCraft(InventoryClickEvent event) { slot8.teleport(slot8.getLocation().add(vector.clone().multiply(0.2))); slot9.teleport(slot9.getLocation().add(rotateVectorAroundY(vector.clone(), -45).multiply(0.2828))); - DisplayManager.updateDisplay(slot1); - DisplayManager.updateDisplay(slot2); - DisplayManager.updateDisplay(slot3); - DisplayManager.updateDisplay(slot4); - DisplayManager.updateDisplay(slot5); - DisplayManager.updateDisplay(slot6); - DisplayManager.updateDisplay(slot7); - DisplayManager.updateDisplay(slot8); - DisplayManager.updateDisplay(slot9); + DisplayManager.updateItem(slot1); + DisplayManager.updateItem(slot2); + DisplayManager.updateItem(slot3); + DisplayManager.updateItem(slot4); + DisplayManager.updateItem(slot5); + DisplayManager.updateItem(slot6); + DisplayManager.updateItem(slot7); + DisplayManager.updateItem(slot8); + DisplayManager.updateItem(slot9); + + item.setItemStack(itemstack, true); + item.setLocked(true); + slot1.setLocked(true); + slot2.setLocked(true); + slot3.setLocked(true); + slot4.setLocked(true); + slot5.setLocked(true); + slot6.setLocked(true); + slot7.setLocked(true); + slot8.setLocked(true); + slot9.setLocked(true); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { for (Player each : InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY)) { @@ -380,20 +383,23 @@ public void onCraft(InventoryClickEvent event) { }, 6); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - item.setItemStack(itemstack); DisplayManager.updateItem(item); DisplayManager.collectItem(item, player); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot1); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot2); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot3); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot4); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot5); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot6); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot7); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot8); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot9); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot1); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot2); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot3); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot4); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot5); + Map current = openedBenches.get(block); + if (current == null || current.get("5") == slot5) { + InteractionVisualizer.lightManager.deleteLight(slot5.getLocation()); + } + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot6); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot7); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot8); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot9); }, 8); }, 10); }, 1); @@ -432,18 +438,14 @@ public void onDragCraftingBench(InventoryDragEvent event) { @EventHandler public void onCloseCraftingBench(InventoryCloseEvent event) { - if (!playermap.containsKey((Player) event.getPlayer())) { - return; - } - - Block block = playermap.get((Player) event.getPlayer()); - - if (!openedBenches.containsKey(block)) { + Player player = (Player) event.getPlayer(); + Block block = playermap.remove(player); + if (block == null) { return; } Map map = openedBenches.get(block); - if (!map.get("Player").equals(event.getPlayer())) { + if (map == null || !map.get("Player").equals(player)) { return; } @@ -451,16 +453,13 @@ public void onCloseCraftingBench(InventoryCloseEvent event) { if (!(map.get(String.valueOf(i)) instanceof String)) { Object entity = map.get(String.valueOf(i)); if (entity instanceof Item) { - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), (Item) entity); - } else if (entity instanceof DisplayEntity) { - if (!((DisplayEntity) entity).isLocked()) { - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), (DisplayEntity) entity); - } + Item item = (Item) entity; + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); int finalI = i; new ScheduledRunnable() { public void run() { if (finalI == 5) { - InteractionVisualizer.lightManager.deleteLight(((DisplayEntity) entity).getLocation()); + InteractionVisualizer.lightManager.deleteLight(item.getLocation()); } } }.runTaskLater(InteractionVisualizer.plugin, 20); @@ -468,111 +467,47 @@ public void run() { } } openedBenches.remove(block); - playermap.remove((Player) event.getPlayer()); } - public MaterialMode standMode(DisplayEntity stand) { - String plain = PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()); - if (plain.startsWith("IV.CraftingTable.")) { - return MaterialMode.getModeFromName(plain.substring(plain.lastIndexOf(".") + 1)); - } - return null; + public Item.RenderMode standMode(Item stand) { + return stand.getRenderMode(); } - public void toggleStandMode(DisplayEntity stand, String mode) { - String plainText = PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()); - if (!plainText.equals("IV.CraftingTable.Item")) { - if (plainText.equals("IV.CraftingTable.Block")) { - stand.setCustomName("IV.CraftingTable.Item"); - stand.setRotation(stand.getLocation().getYaw() - 45, stand.getLocation().getPitch()); - stand.setRightArmPose(EulerAngle.ZERO); - stand.teleport(stand.getLocation().add(0.0, -0.084, 0.0)); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.102), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.14))); - - } - if (plainText.equals("IV.CraftingTable.LowBlock")) { - stand.setCustomName("IV.CraftingTable.Item"); - stand.setRotation(stand.getLocation().getYaw() - 45, stand.getLocation().getPitch()); - stand.setRightArmPose(EulerAngle.ZERO); - stand.teleport(stand.getLocation().add(0.0, -0.02, 0.0)); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.09), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.15))); + private static Item.RenderMode renderMode(MaterialMode mode) { + return switch (mode) { + case ITEM -> Item.RenderMode.ITEM; + case BLOCK -> Item.RenderMode.BLOCK; + case LOWBLOCK -> Item.RenderMode.LOW_BLOCK; + case TOOL -> Item.RenderMode.TOOL; + case STANDING -> Item.RenderMode.STANDING; + }; + } - } - if (plainText.equals("IV.CraftingTable.Tool")) { - stand.setCustomName("IV.CraftingTable.Item"); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.3), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.1))); - stand.teleport(stand.getLocation().add(0, 0.26, 0)); - stand.setRightArmPose(EulerAngle.ZERO); - } - if (plainText.equals("IV.CraftingTable.Standing")) { - stand.setCustomName("IV.CraftingTable.Item"); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().getDirection().normalize().multiply(0.323), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().getDirection().normalize().multiply(-0.115))); - stand.teleport(stand.getLocation().add(0, 0.32, 0)); - stand.setRightArmPose(EulerAngle.ZERO); - } - } - if (mode.equals("Block")) { - stand.setCustomName("IV.CraftingTable.Block"); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.14))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.102), -90))); - stand.teleport(stand.getLocation().add(0.0, 0.084, 0.0)); - stand.setRightArmPose(new EulerAngle(357.9, 0.0, 0.0)); - stand.setRotation(stand.getLocation().getYaw() + 45, stand.getLocation().getPitch()); - } - if (mode.equals("LowBlock")) { - stand.setCustomName("IV.CraftingTable.LowBlock"); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.15))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.09), -90))); - stand.teleport(stand.getLocation().add(0.0, 0.02, 0.0)); - stand.setRightArmPose(new EulerAngle(357.9, 0.0, 0.0)); - stand.setRotation(stand.getLocation().getYaw() + 45, stand.getLocation().getPitch()); - } - if (mode.equals("Tool")) { - stand.setCustomName("IV.CraftingTable.Tool"); - stand.setRightArmPose(new EulerAngle(357.99, 0.0, 300.0)); - stand.teleport(stand.getLocation().add(0, -0.26, 0)); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.1))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.3), -90))); - } - if (mode.equals("Standing")) { - stand.setCustomName("IV.CraftingTable.Standing"); - stand.setRightArmPose(new EulerAngle(0.0, 4.7, 4.7)); - stand.teleport(stand.getLocation().add(0, -0.32, 0)); - stand.teleport(stand.getLocation().add(stand.getLocation().getDirection().normalize().multiply(0.115))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().getDirection().normalize().multiply(-0.323), -90))); - } + public void toggleStandMode(Item stand, Item.RenderMode mode) { + WorkstationDisplayPositioning.setRenderMode(stand, mode); } - public Map spawnDisplayEntitys(Player player, Block block) { //.add(0.68, 0.600781, 0.35) - Map map = new HashMap<>(); - Location loc = block.getLocation().clone().add(0.5, 0.600781, 0.5); - DisplayEntity center = new DisplayEntity(loc); + public Map spawnDisplayEntitys(Player player, Block block) { + Map map = new HashMap<>(); float yaw = getCardinalDirection(player); - center.setRotation(yaw, center.getLocation().getPitch()); - setStand(center); - center.setCustomName("IV.CraftingTable.Center"); - Vector vector = rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.19), -100).add(center.getLocation().clone().getDirection().normalize().multiply(-0.11)); - DisplayEntity slot5 = new DisplayEntity(loc.clone().add(vector)); + Location origin = block.getLocation(); + Item slot5 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.0, 0.0); setStand(slot5, yaw); - DisplayEntity slot2 = new DisplayEntity(slot5.getLocation().clone().add(center.getLocation().clone().getDirection().normalize().multiply(0.2))); + Item slot2 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.0, 0.2); setStand(slot2, yaw); - DisplayEntity slot1 = new DisplayEntity(slot2.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), -90))); + Item slot1 = WorkstationDisplayPositioning.gridItem(origin, yaw, -0.2, 0.2); setStand(slot1, yaw); - DisplayEntity slot3 = new DisplayEntity(slot2.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), 90))); + Item slot3 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.2, 0.2); setStand(slot3, yaw); - DisplayEntity slot4 = new DisplayEntity(slot5.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), -90))); + Item slot4 = WorkstationDisplayPositioning.gridItem(origin, yaw, -0.2, 0.0); setStand(slot4, yaw); - DisplayEntity slot6 = new DisplayEntity(slot5.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), 90))); + Item slot6 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.2, 0.0); setStand(slot6, yaw); - DisplayEntity slot8 = new DisplayEntity(slot5.getLocation().clone().add(center.getLocation().getDirection().clone().normalize().multiply(-0.2))); + Item slot8 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.0, -0.2); setStand(slot8, yaw); - DisplayEntity slot7 = new DisplayEntity(slot8.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), -90))); + Item slot7 = WorkstationDisplayPositioning.gridItem(origin, yaw, -0.2, -0.2); setStand(slot7, yaw); - DisplayEntity slot9 = new DisplayEntity(slot8.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), 90))); + Item slot9 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.2, -0.2); setStand(slot9, yaw); map.put("1", slot1); @@ -585,44 +520,23 @@ public Map spawnDisplayEntitys(Player player, Block block map.put("8", slot8); map.put("9", slot9); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot1); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot2); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot3); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot4); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot5); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot6); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot7); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot8); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot9); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot1); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot2); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot3); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot4); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot5); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot6); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot7); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot8); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot9); return map; } - public void setStand(DisplayEntity stand, float yaw) { - stand.setArms(true); - stand.setBasePlate(false); - stand.setMarker(true); - stand.setGravity(false); - stand.setSmall(true); - stand.setInvulnerable(true); - stand.setVisible(false); - stand.setSilent(true); - stand.setRightArmPose(EulerAngle.ZERO); - stand.setCustomName("IV.CraftingTable.Item"); + public void setStand(Item stand, float yaw) { stand.setRotation(yaw, stand.getLocation().getPitch()); } - public void setStand(DisplayEntity stand) { - stand.setArms(true); - stand.setBasePlate(false); - stand.setMarker(true); - stand.setGravity(false); - stand.setSmall(true); - stand.setSilent(true); - stand.setInvulnerable(true); - stand.setVisible(false); - } - public Vector rotateVectorAroundY(Vector vector, double degrees) { double rad = Math.toRadians(degrees); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/DispenserDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/DispenserDisplay.java index 28efcf96..2fb5b0d6 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/DispenserDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/DispenserDisplay.java @@ -204,11 +204,10 @@ public void onUseDispenser(InventoryClickEvent event) { vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -302,12 +301,10 @@ public void onDragDispenser(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); - item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/DoubleChestDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/DoubleChestDisplay.java index e6aeb9ae..ac67b34c 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/DoubleChestDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/DoubleChestDisplay.java @@ -231,11 +231,10 @@ public void onUseDoubleChest(InventoryClickEvent event) { vector = loc.clone().add(0.5, 1, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.15).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -355,12 +354,10 @@ public void onDragDoubleChest(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 1, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.15).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); - item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/DropperDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/DropperDisplay.java index 8d54db54..6699f35f 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/DropperDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/DropperDisplay.java @@ -207,11 +207,10 @@ public void onUseDropper(InventoryClickEvent event) { vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -308,12 +307,10 @@ public void onDragDropper(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); - item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/EnderchestDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/EnderchestDisplay.java index baa7c26b..c4721711 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/EnderchestDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/EnderchestDisplay.java @@ -212,11 +212,10 @@ public void onUseEnderChest(InventoryClickEvent event) { vector = loc.clone().add(0.5, 1, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.15).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -294,12 +293,10 @@ public void onDragEnderChest(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 1, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.15).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); - item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java index 01fa5472..8db85a5d 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java @@ -25,11 +25,15 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; @@ -38,7 +42,7 @@ import com.loohp.interactionvisualizer.utils.VanishUtils; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -50,9 +54,14 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.inventory.FurnaceBurnEvent; +import org.bukkit.event.inventory.FurnaceExtractEvent; +import org.bukkit.event.inventory.FurnaceSmeltEvent; +import org.bukkit.event.inventory.FurnaceStartSmeltEvent; import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryMoveItemEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.util.EulerAngle; @@ -78,9 +87,11 @@ public class FurnaceDisplay extends VisualizerRunnableDisplay implements Listene private String noFuelColor = "&c"; private int progressBarLength = 10; private String amountPending = " &7+{Amount}"; + private final BlockUpdateScheduler blockUpdates; public FurnaceDisplay() { onReload(new InteractionVisualizerReloadEvent()); + this.blockUpdates = new BlockUpdateScheduler<>(this::nearbyFurnace, this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -102,6 +113,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = furnaceMap.entrySet().iterator(); int count = 0; @@ -149,6 +163,21 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return legacyRun(); + } + return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), + InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); + if (collecting) { + PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); + } + }, 0, 1); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyFurnace(); for (Block block : list) { @@ -178,16 +207,18 @@ public ScheduledTask run() { } Block block = entry.getKey(); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + try { + if (!isActive(block.getLocation())) { + return; + } + if (!isFurnace(block.getType())) { + return; + } + org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); - if (!isActive(block.getLocation())) { - return; - } - if (!isFurnace(block.getType())) { - return; - } - org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); - - { + { Inventory inv = furnace.getInventory(); ItemStack itemstack = inv.getItem(0); if (itemstack != null) { @@ -262,25 +293,18 @@ public ScheduledTask run() { symbol = symbol.replace("{CompletedAmount}", (inv.getItem(2) == null ? 0 : inv.getItem(2).getAmount()) + ""); } if (hasFuel(furnace)) { - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals(symbol) || !stand.isCustomNameVisible()) { - stand.setCustomNameVisible(true); - stand.setCustomName(symbol); - DisplayManager.updateDisplay(stand); - } + FurnaceDisplayUpdater.showProgress(stand, entry.getValue(), symbol); } else { symbol = noFuelColor + ChatColorUtils.stripColor(symbol); - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals(symbol) || !stand.isCustomNameVisible()) { - stand.setCustomNameVisible(true); - stand.setCustomName(symbol); - DisplayManager.updateDisplay(stand); - } + FurnaceDisplayUpdater.showProgress(stand, entry.getValue(), symbol); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals("") || stand.isCustomNameVisible()) { - stand.setCustomNameVisible(false); - stand.setCustomName(""); - DisplayManager.updateDisplay(stand); - } + FurnaceDisplayUpdater.hideProgress(stand, entry.getValue()); + } + } + } finally { + if (collecting) { + PerformanceMetrics.blockUpdateChecks(1, System.nanoTime() - start); } } }, delay, block.getLocation()); @@ -288,6 +312,106 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!TileEntityManager.getTileEntities(TileEntityType.FURNACE).contains(block)) { + removeTrackedDisplay(block); + return false; + } + if (!block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) { + removeTrackedDisplay(block); + return false; + } + if (!isFurnace(block.getType())) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = furnaceMap.get(block); + if (values == null) { + values = new HashMap<>(); + values.put("Item", "N/A"); + values.putAll(spawnDisplayEntitys(block)); + furnaceMap.put(block, values); + } + org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); + return FurnaceDisplayUpdater.update(furnace, values, KEY, progressBarCharacter, emptyColor, + filledColor, noFuelColor, progressBarLength, amountPending); + } + + private void markDirty(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isFurnace(block.getType())) { + blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + private void markDirtyUnlessActive(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isFurnace(block.getType())) { + blockUpdates.markDirtyUnlessActive(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + /** Optimized aggregate-listener entry after the inventory location has been resolved once. */ + public void onFurnaceInventoryChanged(Block block) { + markDirty(block); + } + + private void markDirty(Inventory inventory) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + Location location; + try { + location = inventory.getLocation(); + } catch (Exception | AbstractMethodError ignored) { + return; + } + if (location != null) { + markDirty(location.getBlock()); + } + } + + public void onFurnaceBurn(FurnaceBurnEvent event) { + markDirty(event.getBlock()); + } + + public void onFurnaceStartSmelt(FurnaceStartSmeltEvent event) { + markDirtyUnlessActive(event.getBlock()); + } + + public void onFurnaceSmelt(FurnaceSmeltEvent event) { + markDirtyUnlessActive(event.getBlock()); + } + + public void onFurnaceExtract(FurnaceExtractEvent event) { + markDirty(event.getBlock()); + } + + public void onFurnaceMoveItem(InventoryMoveItemEvent event) { + markDirty(event.getSource()); + markDirty(event.getDestination()); + } + + public void onFurnaceAdded(TileEntityAddedEvent event) { + if (event.getTileEntityType() == TileEntityType.FURNACE) { + markDirty(event.getBlock()); + } + } + + public void onFurnaceActivated(TileEntityActivatedEvent event) { + if (event.getTileEntityType() == TileEntityType.FURNACE) { + markDirty(event.getBlock()); + } + } + + public void onFurnaceDeactivated(TileEntityDeactivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.FURNACE) { + removeTrackedDisplay(event.getBlock()); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onFurnace(InventoryClickEvent event) { if (VanishUtils.isVanished((Player) event.getWhoClicked())) { @@ -407,6 +531,7 @@ public void onUseFurnace(InventoryClickEvent event) { if (!isFurnace(event.getView().getTopInventory().getLocation().getBlock().getType())) { return; } + markDirty(event.getView().getTopInventory().getLocation().getBlock()); if (event.getRawSlot() >= 0 && event.getRawSlot() <= 2) { DisplayManager.sendHandMovement(InteractionVisualizerAPI.getPlayers(), (Player) event.getWhoClicked()); @@ -440,6 +565,7 @@ public void onDragFurnace(InventoryDragEvent event) { for (int slot : event.getRawSlots()) { if (slot >= 0 && slot <= 2) { + markDirty(event.getView().getTopInventory().getLocation().getBlock()); DisplayManager.sendHandMovement(InteractionVisualizerAPI.getPlayers(), (Player) event.getWhoClicked()); break; } @@ -448,11 +574,15 @@ public void onDragFurnace(InventoryDragEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onBreakFurnace(TileEntityRemovedEvent event) { - Block block = event.getBlock(); - if (!furnaceMap.containsKey(block)) { + removeTrackedDisplay(event.getBlock()); + } + + private void removeTrackedDisplay(Block block) { + blockUpdates.remove(block); + Map map = furnaceMap.remove(block); + if (map == null) { return; } - Map map = furnaceMap.get(block); if (map.get("Item") instanceof Item) { Item item = (Item) map.get("Item"); DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); @@ -461,7 +591,6 @@ public void onBreakFurnace(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("Stand"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - furnaceMap.remove(block); } public boolean hasItemToCook(org.bukkit.block.Furnace furnace) { @@ -513,6 +642,7 @@ public Map spawnDisplayEntitys(Block block) { } public void setStand(DisplayEntity stand) { + stand.useLegacyNameTagStyle(); stand.setBasePlate(false); stand.setMarker(true); stand.setGravity(false); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdater.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdater.java new file mode 100644 index 00000000..8a05cbf2 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdater.java @@ -0,0 +1,156 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI; +import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.Item; +import com.loohp.interactionvisualizer.managers.DisplayManager; +import com.loohp.interactionvisualizer.objectholders.EntryKey; +import com.loohp.interactionvisualizer.utils.ChatColorUtils; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Material; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.Vector; + +import java.util.Map; + +/** Shared hybrid update path for the three Bukkit furnace variants. */ +final class FurnaceDisplayUpdater { + + static final String PROGRESS_TEXT_KEY = "ProgressText"; + + private FurnaceDisplayUpdater() { + } + + static boolean update(org.bukkit.block.Furnace furnace, Map values, EntryKey key, + String progressBarCharacter, String emptyColor, String filledColor, + String noFuelColor, int progressBarLength, String amountPending) { + Inventory inventory = furnace.getInventory(); + ItemStack itemStack = normalize(inventory.getItem(0)); + if (itemStack == null) { + itemStack = normalize(inventory.getItem(2)); + } + + if (values.get("Item") instanceof String) { + if (itemStack != null) { + Item item = new Item(furnace.getLocation().clone().add(0.5, 1.0, 0.5)); + item.setItemStack(itemStack); + item.setVelocity(new Vector()); + item.setPickupDelay(32767); + item.setGravity(false); + values.put("Item", item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, key), item); + } else { + values.put("Item", "N/A"); + } + } else { + Item item = (Item) values.get("Item"); + if (itemStack != null) { + if (!item.getItemStack().equals(itemStack)) { + item.setItemStack(itemStack); + DisplayManager.updateItem(item); + } + } else { + values.put("Item", "N/A"); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); + } + } + + DisplayEntity stand = (DisplayEntity) values.get("Stand"); + ItemStack input = normalize(inventory.getItem(0)); + if (input == null) { + hideProgress(stand, values); + return false; + } + + int time = furnace.getCookTime(); + int maximum = furnace.getCookTimeTotal(); + double scaled = scaledProgress(time, maximum, progressBarLength); + StringBuilder symbol = new StringBuilder(Math.max(16, progressBarLength * 3)); + double i = 1; + for (; i < scaled; i++) { + symbol.append(filledColor).append(progressBarCharacter); + } + i--; + if (scaled - i > 0 && scaled - i < 0.67) { + symbol.append(emptyColor).append(progressBarCharacter); + } else if (scaled - i > 0) { + symbol.append(filledColor).append(progressBarCharacter); + } + for (i = progressBarLength - 1; i >= scaled; i--) { + symbol.append(emptyColor).append(progressBarCharacter); + } + + int left = input.getAmount() - 1; + if (left > 0) { + symbol.append(amountPending.replace("{Amount}", Integer.toString(left))); + } + String text = symbol.toString(); + if (text.contains("{CompletedAmount}")) { + ItemStack output = inventory.getItem(2); + text = text.replace("{CompletedAmount}", Integer.toString(output == null ? 0 : output.getAmount())); + } + if (!hasFuel(furnace)) { + text = noFuelColor + ChatColorUtils.stripColor(text); + } + showProgress(stand, values, text); + return furnace.getBurnTime() > 0 || furnace.getCookTime() > 0; + } + + static double scaledProgress(int time, int maximum, int progressBarLength) { + int boundedLength = Math.max(0, progressBarLength); + if (maximum <= 0 || boundedLength == 0) { + return 0.0D; + } + double scaled = (double) time / (double) maximum * (double) boundedLength; + return Math.max(0.0D, Math.min((double) boundedLength, scaled)); + } + + private static ItemStack normalize(ItemStack itemStack) { + return itemStack == null || itemStack.getType() == Material.AIR ? null : itemStack; + } + + private static boolean hasFuel(org.bukkit.block.Furnace furnace) { + if (furnace.getBurnTime() > 0) { + return true; + } + return normalize(furnace.getInventory().getItem(1)) != null; + } + + static boolean shouldUpdateProgress(Map values, String text, boolean visible) { + return !visible || !text.equals(values.get(PROGRESS_TEXT_KEY)); + } + + static void showProgress(DisplayEntity stand, Map values, String text) { + if (shouldUpdateProgress(values, text, stand.isCustomNameVisible())) { + stand.setCustomNameVisible(true); + stand.setCustomName(text); + DisplayManager.updateDisplay(stand); + values.put(PROGRESS_TEXT_KEY, text); + } + } + + static void hideProgress(DisplayEntity stand, Map values) { + if (values.containsKey(PROGRESS_TEXT_KEY) + || !PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).isEmpty() + || stand.isCustomNameVisible()) { + stand.setCustomNameVisible(false); + stand.setCustomName(""); + DisplayManager.updateDisplay(stand); + values.remove(PROGRESS_TEXT_KEY); + } + } + +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/GrindstoneDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/GrindstoneDisplay.java index 37b84012..fc8f95da 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/GrindstoneDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/GrindstoneDisplay.java @@ -33,8 +33,8 @@ import com.loohp.interactionvisualizer.utils.MaterialUtils; import com.loohp.interactionvisualizer.utils.MaterialUtils.MaterialMode; import com.loohp.interactionvisualizer.utils.VanishUtils; +import com.loohp.interactionvisualizer.utils.WorkstationDisplayPositioning; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Location; @@ -53,7 +53,6 @@ import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryView; import org.bukkit.inventory.ItemStack; -import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; import java.util.HashMap; @@ -110,40 +109,37 @@ public void process(Player player) { } ItemStack[] items = new ItemStack[] {view.getItem(0), view.getItem(1)}; - if (view.getItem(2) != null) { - ItemStack itemstack = view.getItem(2); - if (itemstack == null || itemstack.getType().equals(Material.AIR)) { - itemstack = null; + ItemStack itemstack = view.getItem(2); + if (itemstack != null && itemstack.getType().equals(Material.AIR)) { + itemstack = null; + } + if (map.get("2") instanceof String) { + if (itemstack != null) { + Item item = new Item(loc.clone().add(0.5, 1.2, 0.5)); + item.setItemStack(itemstack); + item.setVelocity(new Vector(0, 0, 0)); + item.setPickupDelay(32767); + item.setGravity(false); + map.put("2", item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); + DisplayManager.updateItem(item); + } else { + map.put("2", "N/A"); } - Item item = null; - if (map.get("2") instanceof String) { - if (itemstack != null) { - item = new Item(loc.clone().add(0.5, 1.2, 0.5)); + } else { + Item item = (Item) map.get("2"); + if (itemstack != null) { + if (!item.getItemStack().equals(itemstack)) { item.setItemStack(itemstack); - item.setVelocity(new Vector(0, 0, 0)); - item.setPickupDelay(32767); - item.setGravity(false); - map.put("2", item); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); DisplayManager.updateItem(item); - } else { - map.put("2", "N/A"); } } else { - item = (Item) map.get("2"); - if (itemstack != null) { - if (!item.getItemStack().equals(itemstack)) { - item.setItemStack(itemstack); - DisplayManager.updateItem(item); - } - } else { - map.put("2", "N/A"); - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); - } + map.put("2", "N/A"); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); } } for (int i = 0; i < 2; i++) { - DisplayEntity stand = (DisplayEntity) map.get(String.valueOf(i)); + Item stand = (Item) map.get(String.valueOf(i)); ItemStack item = items[i]; if (item == null || item.getType().equals(Material.AIR)) { item = null; @@ -152,19 +148,19 @@ public void process(Player player) { MaterialMode materialMode = MaterialUtils.getMaterialType(item); boolean changed = materialMode != standMode(stand); if (changed) { - toggleStandMode(stand, materialMode.toString()); + toggleStandMode(stand, materialMode); } - if (!item.equals(stand.getItemInMainHand())) { + if (!item.equals(stand.getItemStack())) { changed = true; - stand.setItemInMainHand(item); + stand.setItemStack(item); } if (changed) { - DisplayManager.updateDisplay(stand); + DisplayManager.updateItem(stand); } } else { - if (!stand.getItemInMainHand().getType().equals(Material.AIR)) { - stand.setItemInMainHand(new ItemStack(Material.AIR)); - DisplayManager.updateDisplay(stand); + if (!stand.getItemStack().isEmpty()) { + stand.setItemStack(ItemStack.empty()); + DisplayManager.updateItem(stand); } } } @@ -237,31 +233,37 @@ public void onGrindstone(InventoryClickEvent event) { ItemStack itemstack = event.getCurrentItem().clone(); Location loc = block.getLocation(); Player player = (Player) event.getWhoClicked(); + InventoryView view = event.getView(); - DisplayEntity slot0 = (DisplayEntity) map.get("0"); - DisplayEntity slot1 = (DisplayEntity) map.get("1"); + Item slot0 = (Item) map.get("0"); + Item slot1 = (Item) map.get("1"); if (map.get("2") instanceof String) { - map.put("2", new Item(block.getLocation().clone().add(0.5, 1.2, 0.5))); + Item result = new Item(block.getLocation().clone().add(0.5, 1.2, 0.5)); + result.setItemStack(itemstack); + result.setVelocity(new Vector()); + result.setPickupDelay(32767); + result.setGravity(false); + map.put("2", result); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), result); } Item item = (Item) map.get("2"); Inventory before = Bukkit.createInventory(null, 9); - before.setItem(0, player.getOpenInventory().getItem(0).clone()); - before.setItem(1, player.getOpenInventory().getItem(1).clone()); + before.setItem(0, InventoryUtils.cloneItem(view.getItem(0))); + before.setItem(1, InventoryUtils.cloneItem(view.getItem(1))); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { + if (!player.isOnline() || player.getOpenInventory() != view) { + return; + } Inventory after = Bukkit.createInventory(null, 9); - after.setItem(0, player.getOpenInventory().getItem(0).clone()); - after.setItem(1, player.getOpenInventory().getItem(1).clone()); + after.setItem(0, InventoryUtils.cloneItem(view.getItem(0))); + after.setItem(1, InventoryUtils.cloneItem(view.getItem(1))); if (InventoryUtils.compareContents(before, after)) { return; } - slot0.setLocked(true); - slot1.setLocked(true); - item.setLocked(true); - openedGrindstone.remove(block); float yaw = getCardinalDirection(player); @@ -269,8 +271,13 @@ public void onGrindstone(InventoryClickEvent event) { slot0.teleport(slot0.getLocation().add(rotateVectorAroundY(vector.clone(), 90).multiply(0.1))); slot1.teleport(slot1.getLocation().add(rotateVectorAroundY(vector.clone(), -90).multiply(0.1))); - DisplayManager.updateDisplay(slot0); - DisplayManager.updateDisplay(slot1); + DisplayManager.updateItem(slot0); + DisplayManager.updateItem(slot1); + + item.setItemStack(itemstack); + slot0.setLocked(true); + slot1.setLocked(true); + item.setLocked(true); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { for (Player each : InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY)) { @@ -279,13 +286,12 @@ public void onGrindstone(InventoryClickEvent event) { }, 6); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - item.setItemStack(itemstack); DisplayManager.updateItem(item); DisplayManager.collectItem(item, player); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot0); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot1); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot0); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot1); }, 8); }, 10); }, 1); @@ -394,118 +400,44 @@ public void onCloseGrindstone(InventoryCloseEvent event) { openedGrindstone.remove(block); } - public MaterialMode standMode(DisplayEntity stand) { - String plain = PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()); - if (plain.startsWith("IV.Grindstone.")) { - return MaterialMode.getModeFromName(plain.substring(plain.lastIndexOf(".") + 1)); - } - return null; + public MaterialMode standMode(Item stand) { + return switch (stand.getRenderMode()) { + case ITEM -> MaterialMode.ITEM; + case BLOCK -> MaterialMode.BLOCK; + case LOW_BLOCK -> MaterialMode.LOWBLOCK; + case TOOL -> MaterialMode.TOOL; + case STANDING -> MaterialMode.STANDING; + default -> null; + }; } - public void toggleStandMode(DisplayEntity stand, String mode) { - String plainText = PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()); - if (!plainText.equals("IV.Grindstone.Item")) { - if (plainText.equals("IV.Grindstone.Block")) { - stand.setCustomName("IV.Grindstone.Item"); - stand.setRotation(stand.getLocation().getYaw() - 45, stand.getLocation().getPitch()); - stand.setRightArmPose(EulerAngle.ZERO); - stand.teleport(stand.getLocation().add(0.0, -0.084, 0.0)); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.102), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.14))); - - } - if (plainText.equals("IV.Grindstone.LowBlock")) { - stand.setCustomName("IV.Grindstone.Item"); - stand.setRotation(stand.getLocation().getYaw() - 45, stand.getLocation().getPitch()); - stand.setRightArmPose(EulerAngle.ZERO); - stand.teleport(stand.getLocation().add(0.0, -0.02, 0.0)); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.09), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.15))); - - } - if (plainText.equals("IV.Grindstone.Tool")) { - stand.setCustomName("IV.Grindstone.Item"); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.3), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.1))); - stand.teleport(stand.getLocation().add(0, 0.26, 0)); - stand.setRightArmPose(EulerAngle.ZERO); - } - if (plainText.equals("IV.Grindstone.Standing")) { - stand.setCustomName("IV.Grindstone.Item"); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().getDirection().normalize().multiply(0.323), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().getDirection().normalize().multiply(-0.115))); - stand.teleport(stand.getLocation().add(0, 0.32, 0)); - stand.setRightArmPose(EulerAngle.ZERO); - } - } - if (mode.equals("Block")) { - stand.setCustomName("IV.Grindstone.Block"); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.14))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.102), -90))); - stand.teleport(stand.getLocation().add(0.0, 0.084, 0.0)); - stand.setRightArmPose(new EulerAngle(357.9, 0.0, 0.0)); - stand.setRotation(stand.getLocation().getYaw() + 45, stand.getLocation().getPitch()); - } - if (mode.equals("LowBlock")) { - stand.setCustomName("IV.Grindstone.LowBlock"); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.15))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.09), -90))); - stand.teleport(stand.getLocation().add(0.0, 0.02, 0.0)); - stand.setRightArmPose(new EulerAngle(357.9, 0.0, 0.0)); - stand.setRotation(stand.getLocation().getYaw() + 45, stand.getLocation().getPitch()); - } - if (mode.equals("Tool")) { - stand.setCustomName("IV.Grindstone.Tool"); - stand.setRightArmPose(new EulerAngle(357.99, 0.0, 300.0)); - stand.teleport(stand.getLocation().add(0, -0.26, 0)); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.1))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.3), -90))); - } - if (mode.equals("Standing")) { - stand.setCustomName("IV.Grindstone.Standing"); - stand.setRightArmPose(new EulerAngle(0.0, 4.7, 4.7)); - stand.teleport(stand.getLocation().add(0, -0.32, 0)); - stand.teleport(stand.getLocation().add(stand.getLocation().getDirection().normalize().multiply(0.115))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().getDirection().normalize().multiply(-0.323), -90))); - } + public void toggleStandMode(Item stand, MaterialMode mode) { + WorkstationDisplayPositioning.setRenderMode(stand, mode); } - public Map spawnDisplayEntitys(Player player, Block block) { //.add(0.68, 0.600781, 0.35) - Map map = new HashMap<>(); - Location loc = block.getLocation().clone().add(0.5, 0.600781, 0.5); - DisplayEntity center = new DisplayEntity(loc); + public Map spawnDisplayEntitys(Player player, Block block) { + Map map = new HashMap<>(); float yaw = getCardinalDirection(player); - center.setRotation(yaw, center.getLocation().getPitch()); - setStand(center); - center.setCustomName("IV.Grindstone.Center"); - Vector vector = rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.19), -100).add(center.getLocation().clone().getDirection().normalize().multiply(-0.11)); - DisplayEntity middle = new DisplayEntity(loc.clone().add(vector)); - setStand(middle, yaw); - DisplayEntity slot0 = new DisplayEntity(middle.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), -90))); + Location origin = block.getLocation(); + Item slot0 = WorkstationDisplayPositioning.gridItem(origin, yaw, -0.2, 0.0); setStand(slot0, yaw + 20); - DisplayEntity slot1 = new DisplayEntity(middle.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.2), 90))); + Item slot1 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.2, 0.0); setStand(slot1, yaw - 20); map.put("0", slot0); map.put("1", slot1); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot0); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot1); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot0); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot1); return map; } - public void setStand(DisplayEntity stand, float yaw) { - stand.setArms(true); - stand.setBasePlate(false); - stand.setMarker(true); + public void setStand(Item stand, float yaw) { stand.setGravity(false); - stand.setInvulnerable(true); stand.setSilent(true); - stand.setVisible(false); - stand.setSmall(true); - stand.setRightArmPose(EulerAngle.ZERO); - stand.setCustomName("IV.Grindstone.Item"); + stand.setVelocity(new Vector()); + stand.setPickupDelay(32767); stand.setRotation(yaw, stand.getLocation().getPitch()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/HopperDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/HopperDisplay.java index 764d215e..9660dcd1 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/HopperDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/HopperDisplay.java @@ -207,11 +207,10 @@ public void onUseHopper(InventoryClickEvent event) { vector = loc.clone().add(0.5, 0.65, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -308,12 +307,10 @@ public void onDragHopper(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 0.65, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); - item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/JukeBoxDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/JukeBoxDisplay.java index 2dbea01b..d1cd6033 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/JukeBoxDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/JukeBoxDisplay.java @@ -26,6 +26,7 @@ import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; @@ -41,6 +42,7 @@ import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; +import org.bukkit.entity.Display; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; @@ -57,6 +59,10 @@ public class JukeBoxDisplay extends VisualizerRunnableDisplay implements Listener { public static final EntryKey KEY = new EntryKey("jukebox"); + private static final String ITEM_KEY = "Item"; + private static final String LABEL_KEY = "Label"; + private static final String EMPTY_VISUAL = "N/A"; + private static final double LABEL_Y_OFFSET = 0.55D; public ConcurrentHashMap> jukeboxMap = new ConcurrentHashMap<>(); private int checkingPeriod = 20; @@ -94,23 +100,21 @@ public ScheduledTask gc() { } Entry> entry = itr.next(); Block block = entry.getKey(); + Map values = entry.getValue(); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { + if (!ownsVisualState(jukeboxMap.get(block), values)) { + return; + } if (!isActive(block.getLocation())) { - Map map = entry.getValue(); - if (map.get("Item") instanceof Item) { - Item item = (Item) map.get("Item"); - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); + if (jukeboxMap.remove(block, values)) { + removeVisuals(values); } - jukeboxMap.remove(block); return; } if (!block.getType().equals(Material.JUKEBOX)) { - Map map = entry.getValue(); - if (map.get("Item") instanceof Item) { - Item item = (Item) map.get("Item"); - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); + if (jukeboxMap.remove(block, values)) { + removeVisuals(values); } - jukeboxMap.remove(block); return; } }, delay, block.getLocation()); @@ -127,7 +131,8 @@ public ScheduledTask run() { if (jukeboxMap.get(block) == null && isActive(block.getLocation())) { if (block.getType().equals(Material.JUKEBOX)) { HashMap map = new HashMap<>(); - map.put("Item", "N/A"); + map.put(ITEM_KEY, EMPTY_VISUAL); + map.put(LABEL_KEY, EMPTY_VISUAL); jukeboxMap.put(block, map); } } @@ -147,7 +152,11 @@ public ScheduledTask run() { delay++; } Block block = entry.getKey(); + Map values = entry.getValue(); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { + if (!ownsVisualState(jukeboxMap.get(block), values)) { + return; + } if (!isActive(block.getLocation())) { return; } @@ -158,65 +167,36 @@ public ScheduledTask run() { { ItemStack itemstack = jukebox.getRecord() == null ? null : (jukebox.getRecord().getType().equals(Material.AIR) ? null : jukebox.getRecord().clone()); + if (itemstack == null) { + removeVisuals(values); + return; + } - if (entry.getValue().get("Item") instanceof String) { - if (itemstack != null) { - Item item = new Item(jukebox.getLocation().clone().add(0.5, 1.0, 0.5)); - - String disc = jukebox.getPlaying().toString(); - Component text; - if (showDiscName) { - Component displayName = itemstack.getItemMeta().displayName(); - if (displayName != null) { - text = ComponentFont.parseFont(displayName.colorIfAbsent(getColor(disc))); - } else { - text = Component.translatable(TranslationUtils.getRecord(disc)); - text = text.color(getColor(disc)); - } - item.setCustomName(text); - item.setCustomNameVisible(true); - } else { - item.setCustomName(""); - item.setCustomNameVisible(false); - } + Item item; + if (values.get(ITEM_KEY) instanceof Item existing) { + item = existing; + boolean changed = suppressNativeName(item); + if (!item.getItemStack().equals(itemstack)) { item.setItemStack(itemstack); - item.setVelocity(new Vector(0, 0, 0)); - item.setPickupDelay(32767); - item.setGravity(false); - entry.getValue().put("Item", item); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); + changed = true; + } + if (changed) { DisplayManager.updateItem(item); - } else { - entry.getValue().put("Item", "N/A"); } } else { - Item item = (Item) entry.getValue().get("Item"); - if (itemstack != null) { - if (!item.getItemStack().equals(itemstack)) { - item.setItemStack(itemstack); - String disc = jukebox.getPlaying().toString(); - Component text; - if (showDiscName) { - Component displayName = itemstack.getItemMeta().displayName(); - if (displayName != null) { - text = ComponentFont.parseFont(displayName.colorIfAbsent(getColor(disc))); - } else { - text = Component.translatable(TranslationUtils.getRecord(disc)); - text = text.color(getColor(disc)); - } - item.setCustomName(text); - item.setCustomNameVisible(true); - } else { - item.setCustomName(""); - item.setCustomNameVisible(false); - } - DisplayManager.updateItem(item); - } - } else { - entry.getValue().put("Item", "N/A"); - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); - } + item = new Item(jukebox.getLocation().clone().add(0.5, 1.0, 0.5)); + item.setItemStack(itemstack); + item.setVelocity(new Vector(0, 0, 0)); + item.setPickupDelay(32767); + item.setGravity(false); + suppressNativeName(item); + values.put(ITEM_KEY, item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); + DisplayManager.updateItem(item); } + + Component text = showDiscName ? discName(itemstack, jukebox.getPlaying().toString()) : null; + reconcileLabel(values, item.getLocation(), text); } }, delay, block.getLocation()); } @@ -230,12 +210,85 @@ public void onBreakJukeBox(TileEntityRemovedEvent event) { return; } - Map map = jukeboxMap.get(block); - if (map.get("Item") instanceof Item) { - Item item = (Item) map.get("Item"); + Map values = jukeboxMap.remove(block); + removeVisuals(values); + } + + private Component discName(ItemStack itemstack, String disc) { + Component displayName = itemstack.getItemMeta().displayName(); + if (displayName != null) { + return ComponentFont.parseFont(displayName.colorIfAbsent(getColor(disc))); + } + return Component.translatable(TranslationUtils.getRecord(disc)).color(getColor(disc)); + } + + private void reconcileLabel(Map values, Location itemLocation, Component text) { + Object current = values.get(LABEL_KEY); + if (text == null) { + if (current instanceof DisplayEntity label) { + DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), label); + } + values.put(LABEL_KEY, EMPTY_VISUAL); + return; + } + + if (current instanceof DisplayEntity label) { + if (!text.equals(label.getCustomName()) || !label.isCustomNameVisible()) { + label.setCustomName(text); + label.setCustomNameVisible(true); + DisplayManager.updateDisplay(label); + } + return; + } + + DisplayEntity label = new DisplayEntity(labelLocation(itemLocation)); + configureLabel(label); + label.setCustomName(text); + values.put(LABEL_KEY, label); + DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), label); + DisplayManager.updateDisplay(label); + } + + private void removeVisuals(Map values) { + if (values == null) { + return; + } + if (values.get(ITEM_KEY) instanceof Item item) { DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); } - jukeboxMap.remove(block); + if (values.get(LABEL_KEY) instanceof DisplayEntity label) { + DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), label); + } + values.put(ITEM_KEY, EMPTY_VISUAL); + values.put(LABEL_KEY, EMPTY_VISUAL); + } + + static Location labelLocation(Location itemLocation) { + return itemLocation.clone().add(0.0, LABEL_Y_OFFSET, 0.0); + } + + static void configureLabel(DisplayEntity label) { + label.setBillboard(Display.Billboard.CENTER); + label.setDefaultBackground(true); + label.setTextScale(1.0F); + label.setUnboundedTextWidth(true); + label.setInterpolationDuration(0); + label.setTeleportDuration(0); + label.setGravity(false); + label.setInvulnerable(true); + label.setSilent(true); + label.setCustomNameVisible(true); + } + + static boolean suppressNativeName(Item item) { + boolean changed = item.getCustomName() != null || item.isCustomNameVisible(); + item.setCustomName((Component) null); + item.setCustomNameVisible(false); + return changed; + } + + static boolean ownsVisualState(Map current, Map scheduled) { + return current == scheduled; } public Set nearbyJukeBox() { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/LecternDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/LecternDisplay.java index c8c99272..de2c6e9a 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/LecternDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/LecternDisplay.java @@ -35,13 +35,13 @@ import com.loohp.interactionvisualizer.utils.ChatColorUtils; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.data.BlockData; import org.bukkit.block.data.Directional; +import org.bukkit.entity.Display; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; @@ -49,7 +49,6 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.BookMeta; import org.bukkit.util.EulerAngle; -import org.bukkit.util.Vector; import java.util.HashMap; import java.util.Iterator; @@ -193,25 +192,17 @@ public ScheduledTask run() { .replace("{Author}", meta.getAuthor() == null ? "" : meta.getAuthor()) .replace("{Page}", lectern.getPage() + ""); - if (!PlainTextComponentSerializer.plainText().serialize(stand1.getCustomName()).equals(line1) || !stand1.isCustomNameVisible()) { - stand1.setCustomNameVisible(true); - stand1.setCustomName(line1); + if (stand1.updateCustomName(line1, true)) { DisplayManager.updateDisplay(stand1); } - if (!PlainTextComponentSerializer.plainText().serialize(stand2.getCustomName()).equals(line2) || !stand2.isCustomNameVisible()) { - stand2.setCustomNameVisible(true); - stand2.setCustomName(line2); + if (stand2.updateCustomName(line2, true)) { DisplayManager.updateDisplay(stand2); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand1.getCustomName()).equals("") || stand1.isCustomNameVisible()) { - stand1.setCustomNameVisible(false); - stand1.setCustomName(""); + if (stand1.updateCustomName("", false)) { DisplayManager.updateDisplay(stand1); } - if (!PlainTextComponentSerializer.plainText().serialize(stand2.getCustomName()).equals("") || stand2.isCustomNameVisible()) { - stand2.setCustomNameVisible(false); - stand2.setCustomName(""); + if (stand2.updateCustomName("", false)) { DisplayManager.updateDisplay(stand2); } } @@ -254,13 +245,10 @@ public Map spawnDisplayEntitys(Block block) { Location origin = block.getLocation(); BlockData blockData = block.getState().getBlockData(); BlockFace facing = ((Directional) blockData).getFacing(); - Location target = block.getRelative(facing).getLocation(); - Vector direction = target.toVector().subtract(origin.toVector()).multiply(0.2); - - Location loc = origin.clone().add(direction).add(0.5, 1.301, 0.5); + Location loc = firstLineLocation(origin, facing); DisplayEntity slot1 = new DisplayEntity(loc.clone()); setStand(slot1); - DisplayEntity slot2 = new DisplayEntity(loc.clone().add(0, -0.3, 0)); + DisplayEntity slot2 = new DisplayEntity(secondLineLocation(origin, facing)); setStand(slot2); map.put("1", slot1); @@ -272,7 +260,17 @@ public Map spawnDisplayEntitys(Block block) { return map; } - public void setStand(DisplayEntity stand) { + static Location firstLineLocation(Location origin, BlockFace facing) { + return origin.clone().add(facing.getDirection().multiply(0.2D)).add(0.5D, 1.301D, 0.5D); + } + + static Location secondLineLocation(Location origin, BlockFace facing) { + return firstLineLocation(origin, facing).add(0.0D, -0.3D, 0.0D); + } + + public static void setStand(DisplayEntity stand) { + stand.useLegacyNameTagStyle(); + stand.setBillboard(labelBillboard()); stand.setBasePlate(false); stand.setMarker(true); stand.setGravity(false); @@ -284,4 +282,10 @@ public void setStand(DisplayEntity stand) { stand.setRightArmPose(EulerAngle.ZERO); } + static Display.Billboard labelBillboard() { + // Legacy marker-entity name tags always faced the viewer. Preserve + // that behavior when the label is rendered by a TextDisplay. + return Display.Billboard.CENTER; + } + } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/LoomDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/LoomDisplay.java index 7b6619ec..3e1d3111 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/LoomDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/LoomDisplay.java @@ -24,11 +24,9 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI; import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerInteractDisplay; -import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; -import com.loohp.interactionvisualizer.objectholders.LightType; import com.loohp.interactionvisualizer.utils.InventoryUtils; import com.loohp.interactionvisualizer.utils.LocationUtils; import com.loohp.interactionvisualizer.utils.VanishUtils; @@ -40,31 +38,34 @@ import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryView; import org.bukkit.inventory.ItemStack; -import org.bukkit.scheduler.BukkitRunnable; -import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; import java.util.HashMap; import java.util.Iterator; import java.util.Map; +import java.util.UUID; public class LoomDisplay extends VisualizerInteractDisplay implements Listener { public static final EntryKey KEY = new EntryKey("loom"); public Map> openedLooms = new HashMap<>(); + private final Map loomBlocksByPlayer = new HashMap<>(); @Override public EntryKey key() { @@ -96,20 +97,15 @@ public void run() { if (block.getType().equals(Material.LOOM)) { Player player = (Player) map.get("Player"); if (!GameMode.SPECTATOR.equals(player.getGameMode())) { - if (player.getOpenInventory() != null) { - if (player.getOpenInventory().getTopInventory() != null) { - if (player.getOpenInventory().getTopInventory().getLocation().getBlock().getType().equals(Material.LOOM)) { - return; - } - } + Block openBlock = resolveLoomBlock(player, player.getOpenInventory()); + if (block.equals(openBlock)) { + return; } } } - if (map.get("Banner") instanceof DisplayEntity) { - DisplayEntity entity = (DisplayEntity) map.get("Banner"); - InteractionVisualizer.lightManager.deleteLight(entity.getLocation()); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), entity); + if (map.get("Banner") instanceof Item item) { + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); } openedLooms.remove(block); } @@ -127,21 +123,11 @@ public void process(Player player) { if (GameMode.SPECTATOR.equals(player.getGameMode())) { return; } - if (player.getOpenInventory().getTopInventory().getLocation() == null) { - return; - } - if (!LocationUtils.isLoaded(player.getOpenInventory().getTopInventory().getLocation())) { - return; - } - if (player.getOpenInventory().getTopInventory().getLocation().getBlock() == null) { - return; - } - if (!player.getOpenInventory().getTopInventory().getLocation().getBlock().getType().equals(Material.LOOM)) { + InventoryView view = player.getOpenInventory(); + Block block = resolveLoomBlock(player, view); + if (block == null) { return; } - - InventoryView view = player.getOpenInventory(); - Block block = view.getTopInventory().getLocation().getBlock(); if (!openedLooms.containsKey(block)) { Map map = new HashMap<>(); map.put("Player", player); @@ -176,30 +162,64 @@ public void process(Player player) { item = output; } - DisplayEntity stand = (DisplayEntity) map.get("Banner"); + Item stand = (Item) map.get("Banner"); if (item != null) { - if (!item.isSimilar(stand.getHelmet())) { - stand.setHelmet(item); - DisplayManager.updateDisplay(stand); + if (!item.isSimilar(stand.getItemStack())) { + stand.setItemStack(item); + DisplayManager.updateItem(stand); } } else { - if (!stand.getHelmet().getType().equals(Material.AIR)) { - stand.setHelmet(new ItemStack(Material.AIR)); - DisplayManager.updateDisplay(stand); + if (!stand.getItemStack().isEmpty()) { + stand.setItemStack(ItemStack.empty()); + DisplayManager.updateItem(stand); } } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onLoomInteract(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK || event.getClickedBlock() == null + || event.getClickedBlock().getType() != Material.LOOM) { + return; + } + loomBlocksByPlayer.put(event.getPlayer().getUniqueId(), event.getClickedBlock()); + } - Location loc1 = ((DisplayEntity) map.get("Banner")).getLocation(); - InteractionVisualizer.lightManager.deleteLight(loc1); - int skylight = loc1.getBlock().getRelative(BlockFace.UP).getLightFromSky(); - int blocklight = loc1.getBlock().getRelative(BlockFace.UP).getLightFromBlocks() - 1; - blocklight = Math.max(blocklight, 0); - if (skylight > 0) { - InteractionVisualizer.lightManager.createLight(loc1, skylight, LightType.SKY); + private Block resolveLoomBlock(Player player, InventoryView view) { + if (view == null || view.getTopInventory().getType() != InventoryType.LOOM) { + return null; } - if (blocklight > 0) { - InteractionVisualizer.lightManager.createLight(loc1, blocklight, LightType.BLOCK); + try { + Location location = view.getTopInventory().getLocation(); + if (location != null && LocationUtils.isLoaded(location)) { + Block block = location.getBlock(); + if (block.getType() == Material.LOOM) { + loomBlocksByPlayer.put(player.getUniqueId(), block); + return block; + } + } + } catch (Exception | AbstractMethodError ignored) { + // Some server versions do not expose a workstation inventory location. } + + UUID playerId = player.getUniqueId(); + Block block = loomBlocksByPlayer.get(playerId); + if (block == null) { + return null; + } + Location blockLocation = block.getLocation(); + if (!block.getWorld().equals(player.getWorld()) + || blockLocation.clone().add(0.5, 0.5, 0.5).distanceSquared(player.getLocation()) > 64.0 + || !LocationUtils.isLoaded(blockLocation) || block.getType() != Material.LOOM) { + loomBlocksByPlayer.remove(playerId, block); + return null; + } + return block; + } + + @EventHandler + public void onLoomPlayerQuit(PlayerQuitEvent event) { + loomBlocksByPlayer.remove(event.getPlayer().getUniqueId()); } @EventHandler(priority = EventPriority.MONITOR) @@ -247,50 +267,39 @@ public void onLoom(InventoryClickEvent event) { } } - if (event.getView().getTopInventory() == null) { - return; - } - try { - if (event.getView().getTopInventory().getLocation() == null) { - return; - } - } catch (Exception | AbstractMethodError e) { - return; - } - if (event.getView().getTopInventory().getLocation().getBlock() == null) { - return; - } - if (!event.getView().getTopInventory().getLocation().getBlock().getType().equals(Material.LOOM)) { + Player player = (Player) event.getWhoClicked(); + Block block = resolveLoomBlock(player, event.getView()); + if (block == null) { return; } - Block block = event.getView().getTopInventory().getLocation().getBlock(); - if (!openedLooms.containsKey(block)) { return; } ItemStack itemstack = event.getCurrentItem().clone(); - Player player = (Player) event.getWhoClicked(); - + InventoryView view = player.getOpenInventory(); Inventory before = Bukkit.createInventory(null, 9); - before.setItem(0, player.getOpenInventory().getItem(0).clone()); - before.setItem(1, player.getOpenInventory().getItem(1).clone()); - before.setItem(2, player.getOpenInventory().getItem(2).clone()); + before.setItem(0, cloneItem(view.getItem(0))); + before.setItem(1, cloneItem(view.getItem(1))); + before.setItem(2, cloneItem(view.getItem(2))); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { + if (!player.isOnline() || player.getOpenInventory() != view) { + return; + } Inventory after = Bukkit.createInventory(null, 9); - after.setItem(0, player.getOpenInventory().getItem(0).clone()); - after.setItem(1, player.getOpenInventory().getItem(1).clone()); - after.setItem(2, player.getOpenInventory().getItem(2).clone()); + after.setItem(0, cloneItem(view.getItem(0))); + after.setItem(1, cloneItem(view.getItem(1))); + after.setItem(2, cloneItem(view.getItem(2))); if (InventoryUtils.compareContents(before, after)) { return; } Map map = openedLooms.get(block); - if (!map.get("Player").equals(event.getWhoClicked())) { + if (map == null || !player.equals(map.get("Player"))) { return; } @@ -303,6 +312,10 @@ public void onLoom(InventoryClickEvent event) { }, 1); } + private static ItemStack cloneItem(ItemStack item) { + return item == null ? null : item.clone(); + } + @EventHandler(priority = EventPriority.MONITOR) public void onUseLoom(InventoryClickEvent event) { if (event.isCancelled()) { @@ -311,20 +324,7 @@ public void onUseLoom(InventoryClickEvent event) { if (GameMode.SPECTATOR.equals(event.getWhoClicked().getGameMode())) { return; } - if (event.getView().getTopInventory() == null) { - return; - } - try { - if (event.getView().getTopInventory().getLocation() == null) { - return; - } - } catch (Exception | AbstractMethodError e) { - return; - } - if (event.getView().getTopInventory().getLocation().getBlock() == null) { - return; - } - if (!event.getView().getTopInventory().getLocation().getBlock().getType().equals(Material.LOOM)) { + if (resolveLoomBlock((Player) event.getWhoClicked(), event.getView()) == null) { return; } @@ -341,20 +341,7 @@ public void onDragLoom(InventoryDragEvent event) { if (GameMode.SPECTATOR.equals(event.getWhoClicked().getGameMode())) { return; } - if (event.getView().getTopInventory() == null) { - return; - } - try { - if (event.getView().getTopInventory().getLocation() == null) { - return; - } - } catch (Exception | AbstractMethodError e) { - return; - } - if (event.getView().getTopInventory().getLocation().getBlock() == null) { - return; - } - if (!event.getView().getTopInventory().getLocation().getBlock().getType().equals(Material.LOOM)) { + if (resolveLoomBlock((Player) event.getWhoClicked(), event.getView()) == null) { return; } @@ -368,22 +355,15 @@ public void onDragLoom(InventoryDragEvent event) { @EventHandler public void onCloseLoom(InventoryCloseEvent event) { - if (event.getView().getTopInventory() == null) { - return; - } - try { - if (event.getView().getTopInventory().getLocation() == null) { - return; - } - } catch (Exception | AbstractMethodError e) { + if (!(event.getPlayer() instanceof Player player)) { return; } - if (event.getView().getTopInventory().getLocation().getBlock() == null) { + Block block = resolveLoomBlock(player, event.getView()); + loomBlocksByPlayer.remove(player.getUniqueId()); + if (block == null) { return; } - Block block = event.getView().getTopInventory().getLocation().getBlock(); - if (!openedLooms.containsKey(block)) { return; } @@ -395,7 +375,6 @@ public void onCloseLoom(InventoryCloseEvent event) { if (event.getView().getItem(0) != null) { if (!event.getView().getItem(0).getType().equals(Material.AIR)) { - Player player = (Player) event.getPlayer(); Item item = new Item(block.getLocation().clone().add(0.5, 1.5, 0.5)); item.setItemStack(event.getView().getItem(0)); item.setLocked(true); @@ -405,41 +384,33 @@ public void onCloseLoom(InventoryCloseEvent event) { } } - if (map.get("Banner") instanceof DisplayEntity) { - DisplayEntity entity = (DisplayEntity) map.get("Banner"); - InteractionVisualizer.lightManager.deleteLight(entity.getLocation()); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), entity); + if (map.get("Banner") instanceof Item item) { + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); } openedLooms.remove(block); } - public Map spawnDisplayEntitys(Player player, Block block) { - Map map = new HashMap<>(); - Location loc = block.getLocation().clone().add(0.5, 0.03, 0.5); + public Map spawnDisplayEntitys(Player player, Block block) { + Map map = new HashMap<>(); + Location loc = block.getLocation().clone().add(0.5, 1.0, 0.5); Location temploc = new Location(loc.getWorld(), loc.getX(), loc.getY(), loc.getZ()).setDirection(player.getLocation().getDirection().normalize().multiply(-1)); float yaw = temploc.getYaw(); - DisplayEntity banner = new DisplayEntity(loc.clone()); + Item banner = new Item(loc.clone(), Item.RenderMode.BANNER); setStand(banner, yaw); map.put("Banner", banner); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), banner); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), banner); return map; } - public void setStand(DisplayEntity stand, float yaw) { - stand.setArms(true); - stand.setBasePlate(false); - stand.setMarker(true); + public void setStand(Item stand, float yaw) { stand.setGravity(false); stand.setSilent(true); - stand.setInvulnerable(true); - stand.setVisible(false); - stand.setSmall(true); - stand.setCustomName("IV.Loom.Banner"); + stand.setVelocity(new Vector()); + stand.setPickupDelay(32767); stand.setRotation(yaw, stand.getLocation().getPitch()); - stand.setHeadPose(EulerAngle.ZERO); } public Vector rotateVectorAroundY(Vector vector, double degrees) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/NoteBlockDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/NoteBlockDisplay.java index 31c6d192..e739fd91 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/NoteBlockDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/NoteBlockDisplay.java @@ -114,8 +114,8 @@ public void onUseNoteBlock(PlayerInteractEvent event) { return; } ConcurrentHashMap map = displayingNotes.get(block); - DisplayEntity stand = map == null ? new DisplayEntity(textLocation.clone().add(0.0, -0.3, 0.0)) : (DisplayEntity) map.get("Stand"); - stand.teleport(textLocation.clone().add(0.0, -0.3, 0.0)); + DisplayEntity stand = map == null ? new DisplayEntity(labelLocation(textLocation)) : (DisplayEntity) map.get("Stand"); + stand.teleport(labelLocation(textLocation)); setStand(stand); map = map == null ? new ConcurrentHashMap() : map; @@ -147,6 +147,7 @@ public void onUseNoteBlock(PlayerInteractEvent event) { } public void setStand(DisplayEntity stand) { + stand.useLegacyNameTagStyle(); stand.setArms(true); stand.setBasePlate(false); stand.setMarker(true); @@ -158,6 +159,12 @@ public void setStand(DisplayEntity stand) { stand.setCustomNameVisible(true); } + static Location labelLocation(Location clickedFaceLocation) { + // Keep the original marker-entity anchor. DisplayManager applies the + // vanilla name-tag attachment and TextDisplay origin compensation. + return clickedFaceLocation.clone().add(0.0, -0.3, 0.0); + } + @SuppressWarnings("DuplicateBranchesInSwitch") public Location getFaceOffset(Block block, BlockFace face) { Location location = block.getLocation().clone().add(0.5, 0.5, 0.5); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/ShulkerBoxDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/ShulkerBoxDisplay.java index 3cea4a34..4bc99155 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/ShulkerBoxDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/ShulkerBoxDisplay.java @@ -208,11 +208,10 @@ public void onUseShulkerbox(InventoryClickEvent event) { vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); } - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } @@ -304,12 +303,10 @@ public void onDragShulkerbox(InventoryDragEvent event) { Vector offset = new Vector(0.0, 0.15, 0.0); Vector vector = loc.clone().add(0.5, 0.5, 0.5).toVector().subtract(event.getWhoClicked().getEyeLocation().clone().add(0.0, InteractionVisualizer.playerPickupYOffset, 0.0).toVector()).multiply(0.13).add(offset); item.setVelocity(vector); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); item.setItemStack(itemstack); - item.setCustomName(System.currentTimeMillis() + ""); item.setPickupDelay(32767); item.setGravity(true); - DisplayManager.updateItem(item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); if (!link.containsKey(player)) { link.put(player, new ArrayList()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmithingTableDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmithingTableDisplay.java index fcafbc16..464da86e 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmithingTableDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmithingTableDisplay.java @@ -33,8 +33,8 @@ import com.loohp.interactionvisualizer.utils.MaterialUtils; import com.loohp.interactionvisualizer.utils.MaterialUtils.MaterialMode; import com.loohp.interactionvisualizer.utils.VanishUtils; +import com.loohp.interactionvisualizer.utils.WorkstationDisplayPositioning; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.Bukkit; import org.bukkit.FluidCollisionMode; import org.bukkit.GameMode; @@ -56,7 +56,6 @@ import org.bukkit.inventory.InventoryView; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.SmithingInventory; -import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; import java.util.HashMap; @@ -120,40 +119,37 @@ public void process(Player player) { items[i] = view.getItem(i); } - if (view.getItem(maxSlot) != null) { - ItemStack itemstack = view.getItem(maxSlot); - if (itemstack == null || itemstack.getType().equals(Material.AIR)) { - itemstack = null; + ItemStack itemstack = view.getItem(maxSlot); + if (itemstack != null && itemstack.getType().equals(Material.AIR)) { + itemstack = null; + } + if (map.get(maxSlotStr) instanceof String) { + if (itemstack != null) { + Item item = new Item(loc.clone().add(0.5, 1.2, 0.5)); + item.setItemStack(itemstack); + item.setVelocity(new Vector(0, 0, 0)); + item.setPickupDelay(32767); + item.setGravity(false); + map.put(maxSlotStr, item); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); + DisplayManager.updateItem(item); + } else { + map.put(maxSlotStr, "N/A"); } - Item item = null; - if (map.get(maxSlotStr) instanceof String) { - if (itemstack != null) { - item = new Item(loc.clone().add(0.5, 1.2, 0.5)); + } else { + Item item = (Item) map.get(maxSlotStr); + if (itemstack != null) { + if (!item.getItemStack().equals(itemstack)) { item.setItemStack(itemstack); - item.setVelocity(new Vector(0, 0, 0)); - item.setPickupDelay(32767); - item.setGravity(false); - map.put(maxSlotStr, item); - DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); DisplayManager.updateItem(item); - } else { - map.put(maxSlotStr, "N/A"); } } else { - item = (Item) map.get(maxSlotStr); - if (itemstack != null) { - if (!item.getItemStack().equals(itemstack)) { - item.setItemStack(itemstack); - DisplayManager.updateItem(item); - } - } else { - map.put(maxSlotStr, "N/A"); - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); - } + map.put(maxSlotStr, "N/A"); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); } } for (int i = 0; i < maxSlot; i++) { - DisplayEntity stand = (DisplayEntity) map.get(String.valueOf(i)); + Item stand = (Item) map.get(String.valueOf(i)); ItemStack item = items[i]; if (item == null || item.getType().equals(Material.AIR)) { item = null; @@ -162,27 +158,27 @@ public void process(Player player) { MaterialMode materialMode = MaterialUtils.getMaterialType(item); boolean changed = materialMode != standMode(stand); if (changed) { - toggleStandMode(stand, materialMode.toString()); + toggleStandMode(stand, materialMode); } - if (!item.equals(stand.getItemInMainHand())) { + if (!item.equals(stand.getItemStack())) { changed = true; - stand.setItemInMainHand(item); + stand.setItemStack(item); } if (changed) { - DisplayManager.updateDisplay(stand); + DisplayManager.updateItem(stand); } } else { - if (!stand.getItemInMainHand().getType().equals(Material.AIR)) { - stand.setItemInMainHand(new ItemStack(Material.AIR)); - DisplayManager.updateDisplay(stand); + if (!stand.getItemStack().isEmpty()) { + stand.setItemStack(ItemStack.empty()); + DisplayManager.updateItem(stand); } } } - Location loc1 = ((DisplayEntity) map.get("0")).getLocation(); + Location loc1 = ((Item) map.get("0")).getLocation(); InteractionVisualizer.lightManager.deleteLight(loc1); - int skylight = loc1.getBlock().getRelative(BlockFace.UP).getLightFromSky(); - int blocklight = loc1.getBlock().getRelative(BlockFace.UP).getLightFromBlocks() - 1; + int skylight = block.getRelative(BlockFace.UP).getLightFromSky(); + int blocklight = block.getRelative(BlockFace.UP).getLightFromBlocks() - 1; blocklight = Math.max(blocklight, 0); if (skylight > 0) { InteractionVisualizer.lightManager.createLight(loc1, skylight, LightType.SKY); @@ -231,7 +227,8 @@ public void onSmithingTable(InventoryClickEvent event) { return; } - Block block = event.getWhoClicked().getTargetBlockExact(7, FluidCollisionMode.NEVER); + Player player = (Player) event.getWhoClicked(); + Block block = playermap.get(player); if (!openedSTables.containsKey(block)) { return; @@ -242,51 +239,54 @@ public void onSmithingTable(InventoryClickEvent event) { return; } - ItemStack itemstack = event.getCurrentItem(); + ItemStack itemstack = event.getCurrentItem().clone(); Location loc = block.getLocation(); - Player player = (Player) event.getWhoClicked(); + InventoryView view = event.getView(); - DisplayEntity slot0; - DisplayEntity slot1; - DisplayEntity slot2; + Item slot0; + Item slot1; + Item slot2; if (maxSlot == 2) { slot0 = null; - slot1 = (DisplayEntity) map.get("0"); - slot2 = (DisplayEntity) map.get("1"); + slot1 = (Item) map.get("0"); + slot2 = (Item) map.get("1"); } else { - slot0 = (DisplayEntity) map.get("0"); - slot1 = (DisplayEntity) map.get("1"); - slot2 =(DisplayEntity) map.get("2"); + slot0 = (Item) map.get("0"); + slot1 = (Item) map.get("1"); + slot2 = (Item) map.get("2"); } if (map.get(maxSlotStr) instanceof String) { - map.put(maxSlotStr, new Item(block.getLocation().clone().add(0.5, 1.2, 0.5))); + Item result = new Item(block.getLocation().clone().add(0.5, 1.2, 0.5)); + result.setItemStack(itemstack); + result.setVelocity(new Vector()); + result.setPickupDelay(32767); + result.setGravity(false); + map.put(maxSlotStr, result); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), result); } Item item = (Item) map.get(maxSlotStr); Inventory before = Bukkit.createInventory(null, 9); for (int i = 0; i <= maxSlot; i++) { - before.setItem(i, player.getOpenInventory().getItem(i).clone()); + before.setItem(i, InventoryUtils.cloneItem(view.getItem(i))); } Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { + if (!player.isOnline() || player.getOpenInventory() != view) { + return; + } Inventory after = Bukkit.createInventory(null, 9); for (int i = 0; i <= maxSlot; i++) { - after.setItem(i, player.getOpenInventory().getItem(i).clone()); + after.setItem(i, InventoryUtils.cloneItem(view.getItem(i))); } if (InventoryUtils.compareContents(before, after)) { return; } - if (slot0 != null) { - slot0.setLocked(true); - } - slot1.setLocked(true); - slot2.setLocked(true); - item.setLocked(true); - openedSTables.remove(block); + InteractionVisualizer.lightManager.deleteLight(((Item) map.get("0")).getLocation()); float yaw = getCardinalDirection(player); Vector vector = new Location(slot1.getWorld(), slot1.getLocation().getX(), slot1.getLocation().getY(), slot1.getLocation().getZ(), yaw, 0).getDirection().normalize(); @@ -294,10 +294,18 @@ public void onSmithingTable(InventoryClickEvent event) { slot2.teleport(slot2.getLocation().add(rotateVectorAroundY(vector.clone(), -90).multiply(0.1))); if (slot0 != null) { - DisplayManager.updateDisplay(slot0); + DisplayManager.updateItem(slot0); + } + DisplayManager.updateItem(slot1); + DisplayManager.updateItem(slot2); + + item.setItemStack(itemstack); + if (slot0 != null) { + slot0.setLocked(true); } - DisplayManager.updateDisplay(slot1); - DisplayManager.updateDisplay(slot2); + slot1.setLocked(true); + slot2.setLocked(true); + item.setLocked(true); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { for (Player each : InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY)) { @@ -306,16 +314,15 @@ public void onSmithingTable(InventoryClickEvent event) { }, 6); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - item.setItemStack(itemstack); DisplayManager.updateItem(item); DisplayManager.collectItem(item, player); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { if (slot0 != null) { - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot0); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot0); } - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot1); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), slot2); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot1); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), slot2); }, 8); }, 10); }, 1); @@ -363,18 +370,14 @@ public void onDragSmithingTable(InventoryDragEvent event) { @EventHandler public void onCloseSmithingTable(InventoryCloseEvent event) { - if (!playermap.containsKey((Player) event.getPlayer())) { - return; - } - - Block block = playermap.get((Player) event.getPlayer()); - - if (!openedSTables.containsKey(block)) { + Player player = (Player) event.getPlayer(); + Block block = playermap.remove(player); + if (block == null) { return; } Map map = openedSTables.get(block); - if (!map.get("Player").equals(event.getPlayer())) { + if (map == null || !map.get("Player").equals(player)) { return; } @@ -391,108 +394,36 @@ public void onCloseSmithingTable(InventoryCloseEvent event) { } } - if (map.get("0") instanceof DisplayEntity) { - DisplayEntity entity = (DisplayEntity) map.get("0"); + if (map.get("0") instanceof Item entity) { InteractionVisualizer.lightManager.deleteLight(entity.getLocation()); - DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), entity); } openedSTables.remove(block); - playermap.remove((Player) event.getPlayer()); } - public MaterialMode standMode(DisplayEntity stand) { - String plain = PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()); - if (plain.startsWith("IV.SmithingTable.")) { - return MaterialMode.getModeFromName(plain.substring(plain.lastIndexOf(".") + 1)); - } - return null; + public MaterialMode standMode(Item stand) { + return switch (stand.getRenderMode()) { + case ITEM -> MaterialMode.ITEM; + case BLOCK -> MaterialMode.BLOCK; + case LOW_BLOCK -> MaterialMode.LOWBLOCK; + case TOOL -> MaterialMode.TOOL; + case STANDING -> MaterialMode.STANDING; + default -> null; + }; } - public void toggleStandMode(DisplayEntity stand, String mode) { - String plainText = PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()); - if (!plainText.equals("IV.SmithingTable.Item")) { - if (plainText.equals("IV.SmithingTable.Block")) { - stand.setCustomName("IV.SmithingTable.Item"); - stand.setRotation(stand.getLocation().getYaw() - 45, stand.getLocation().getPitch()); - stand.setRightArmPose(EulerAngle.ZERO); - stand.teleport(stand.getLocation().add(0.0, -0.084, 0.0)); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.102), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.14))); - - } - if (plainText.equals("IV.SmithingTable.LowBlock")) { - stand.setCustomName("IV.SmithingTable.Item"); - stand.setRotation(stand.getLocation().getYaw() - 45, stand.getLocation().getPitch()); - stand.setRightArmPose(EulerAngle.ZERO); - stand.teleport(stand.getLocation().add(0.0, -0.02, 0.0)); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.09), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.15))); - - } - if (plainText.equals("IV.SmithingTable.Tool")) { - stand.setCustomName("IV.SmithingTable.Item"); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.3), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.1))); - stand.teleport(stand.getLocation().add(0, 0.26, 0)); - stand.setRightArmPose(EulerAngle.ZERO); - } - if (plainText.equals("IV.SmithingTable.Standing")) { - stand.setCustomName("IV.SmithingTable.Item"); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().getDirection().normalize().multiply(0.323), -90))); - stand.teleport(stand.getLocation().add(stand.getLocation().getDirection().normalize().multiply(-0.115))); - stand.teleport(stand.getLocation().add(0, 0.32, 0)); - stand.setRightArmPose(EulerAngle.ZERO); - } - } - if (mode.equals("Block")) { - stand.setCustomName("IV.SmithingTable.Block"); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.14))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.102), -90))); - stand.teleport(stand.getLocation().add(0.0, 0.084, 0.0)); - stand.setRightArmPose(new EulerAngle(357.9, 0.0, 0.0)); - stand.setRotation(stand.getLocation().getYaw() + 45, stand.getLocation().getPitch()); - } - if (mode.equals("LowBlock")) { - stand.setCustomName("IV.SmithingTable.LowBlock"); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(0.15))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(0.09), -90))); - stand.teleport(stand.getLocation().add(0.0, 0.02, 0.0)); - stand.setRightArmPose(new EulerAngle(357.9, 0.0, 0.0)); - stand.setRotation(stand.getLocation().getYaw() + 45, stand.getLocation().getPitch()); - } - if (mode.equals("Tool")) { - stand.setCustomName("IV.SmithingTable.Tool"); - stand.setRightArmPose(new EulerAngle(357.99, 0.0, 300.0)); - stand.teleport(stand.getLocation().add(0, -0.26, 0)); - stand.teleport(stand.getLocation().add(stand.getLocation().clone().getDirection().normalize().multiply(-0.1))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().clone().getDirection().normalize().multiply(-0.3), -90))); - } - if (mode.equals("Standing")) { - stand.setCustomName("IV.SmithingTable.Standing"); - stand.setRightArmPose(new EulerAngle(0.0, 4.7, 4.7)); - stand.teleport(stand.getLocation().add(0, -0.32, 0)); - stand.teleport(stand.getLocation().add(stand.getLocation().getDirection().normalize().multiply(0.115))); - stand.teleport(stand.getLocation().add(rotateVectorAroundY(stand.getLocation().getDirection().normalize().multiply(-0.323), -90))); - } + public void toggleStandMode(Item stand, MaterialMode mode) { + WorkstationDisplayPositioning.setRenderMode(stand, mode); } - public Map spawnDisplayEntitys(Player player, Block block, int maxSlot) { //.add(0.68, 0.600781, 0.35) - Map map = new HashMap<>(); - Location loc = block.getLocation().clone().add(0.5, 0.600781, 0.5); - DisplayEntity center = new DisplayEntity(loc); + public Map spawnDisplayEntitys(Player player, Block block, int maxSlot) { + Map map = new HashMap<>(); float yaw = getCardinalDirection(player); - center.setRotation(yaw, center.getLocation().getPitch()); - setStand(center); - center.setCustomName("IV.SmithingTable.Center"); - Vector vector = rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.19), -100).add(center.getLocation().clone().getDirection().normalize().multiply(-0.11)); - DisplayEntity middle = new DisplayEntity(loc.clone().add(vector)); - setStand(middle, yaw); - - DisplayEntity slot0 = new DisplayEntity(middle.getLocation()); + Location origin = block.getLocation(); + Item slot0 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.0, 0.0); setStand(slot0, yaw); - DisplayEntity slot1 = new DisplayEntity(middle.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.3), -90))); + Item slot1 = WorkstationDisplayPositioning.gridItem(origin, yaw, -0.3, 0.0); setStand(slot1, yaw + 20); - DisplayEntity slot2 = new DisplayEntity(middle.getLocation().clone().add(rotateVectorAroundY(center.getLocation().clone().getDirection().normalize().multiply(0.3), 90))); + Item slot2 = WorkstationDisplayPositioning.gridItem(origin, yaw, 0.3, 0.0); setStand(slot2, yaw - 20); if (maxSlot == 2) { @@ -503,25 +434,19 @@ public Map spawnDisplayEntitys(Player player, Block block map.put("1", slot1); map.put("2", slot2); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot0); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot0); } - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot1); - DisplayManager.spawnDisplay(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot2); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot1); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMSTAND, KEY), slot2); return map; } - public void setStand(DisplayEntity stand, float yaw) { - stand.setArms(true); - stand.setBasePlate(false); - stand.setMarker(true); + public void setStand(Item stand, float yaw) { stand.setGravity(false); - stand.setInvulnerable(true); - stand.setVisible(false); stand.setSilent(true); - stand.setSmall(true); - stand.setRightArmPose(EulerAngle.ZERO); - stand.setCustomName("IV.SmithingTable.Item"); + stand.setVelocity(new Vector()); + stand.setPickupDelay(32767); stand.setRotation(yaw, stand.getLocation().getPitch()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java index 0842d9e6..7b99b12a 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java @@ -25,10 +25,15 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; @@ -37,7 +42,7 @@ import com.loohp.interactionvisualizer.utils.VanishUtils; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -50,9 +55,14 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.inventory.FurnaceBurnEvent; +import org.bukkit.event.inventory.FurnaceExtractEvent; +import org.bukkit.event.inventory.FurnaceSmeltEvent; +import org.bukkit.event.inventory.FurnaceStartSmeltEvent; import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryMoveItemEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.util.EulerAngle; @@ -78,9 +88,12 @@ public class SmokerDisplay extends VisualizerRunnableDisplay implements Listener private String noFuelColor = "&c"; private int progressBarLength = 10; private String amountPending = " &7+{Amount}"; + private final BlockUpdateScheduler blockUpdates; public SmokerDisplay() { onReload(new InteractionVisualizerReloadEvent()); + this.blockUpdates = new BlockUpdateScheduler<>(this::nearbySmoker, + this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -102,6 +115,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = smokerMap.entrySet().iterator(); int count = 0; @@ -149,6 +165,21 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return legacyRun(); + } + return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), + InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); + if (collecting) { + PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); + } + }, 0, 1); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbySmoker(); for (Block block : list) { @@ -178,15 +209,18 @@ public ScheduledTask run() { } Block block = entry.getKey(); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - if (!isActive(block.getLocation())) { - return; - } - if (!block.getType().equals(Material.SMOKER)) { - return; - } - org.bukkit.block.Smoker smoker = (org.bukkit.block.Smoker) block.getState(); + boolean collecting = PerformanceMetrics.isCollecting(); + long start = collecting ? System.nanoTime() : 0L; + try { + if (!isActive(block.getLocation())) { + return; + } + if (!block.getType().equals(Material.SMOKER)) { + return; + } + org.bukkit.block.Smoker smoker = (org.bukkit.block.Smoker) block.getState(); - { + { Inventory inv = smoker.getInventory(); ItemStack itemstack = inv.getItem(0); if (itemstack != null) { @@ -261,25 +295,18 @@ public ScheduledTask run() { symbol = symbol.replace("{CompletedAmount}", (inv.getItem(2) == null ? 0 : inv.getItem(2).getAmount()) + ""); } if (hasFuel(smoker)) { - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals(symbol) || !stand.isCustomNameVisible()) { - stand.setCustomNameVisible(true); - stand.setCustomName(symbol); - DisplayManager.updateDisplay(stand); - } + FurnaceDisplayUpdater.showProgress(stand, entry.getValue(), symbol); } else { symbol = noFuelColor + ChatColorUtils.stripColor(symbol); - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals(symbol) || !stand.isCustomNameVisible()) { - stand.setCustomNameVisible(true); - stand.setCustomName(symbol); - DisplayManager.updateDisplay(stand); - } + FurnaceDisplayUpdater.showProgress(stand, entry.getValue(), symbol); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals("") || stand.isCustomNameVisible()) { - stand.setCustomNameVisible(false); - stand.setCustomName(""); - DisplayManager.updateDisplay(stand); - } + FurnaceDisplayUpdater.hideProgress(stand, entry.getValue()); + } + } + } finally { + if (collecting) { + PerformanceMetrics.blockUpdateChecks(1, System.nanoTime() - start); } } }, delay, block.getLocation()); @@ -287,6 +314,106 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!TileEntityManager.getTileEntities(TileEntityType.SMOKER).contains(block)) { + removeTrackedDisplay(block); + return false; + } + if (!block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) { + removeTrackedDisplay(block); + return false; + } + if (!isSmoker(block.getType())) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = smokerMap.get(block); + if (values == null) { + values = new HashMap<>(); + values.put("Item", "N/A"); + values.putAll(spawnDisplayEntitys(block)); + smokerMap.put(block, values); + } + org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); + return FurnaceDisplayUpdater.update(furnace, values, KEY, progressBarCharacter, emptyColor, + filledColor, noFuelColor, progressBarLength, amountPending); + } + + private void markDirty(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isSmoker(block.getType())) { + blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + private void markDirtyUnlessActive(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isSmoker(block.getType())) { + blockUpdates.markDirtyUnlessActive(block, (long) Bukkit.getCurrentTick() + 1L); + } + } + + /** Optimized aggregate-listener entry after the inventory location has been resolved once. */ + public void onSmokerInventoryChanged(Block block) { + markDirty(block); + } + + private void markDirty(Inventory inventory) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + Location location; + try { + location = inventory.getLocation(); + } catch (Exception | AbstractMethodError ignored) { + return; + } + if (location != null) { + markDirty(location.getBlock()); + } + } + + public void onSmokerBurn(FurnaceBurnEvent event) { + markDirty(event.getBlock()); + } + + public void onSmokerStartSmelt(FurnaceStartSmeltEvent event) { + markDirtyUnlessActive(event.getBlock()); + } + + public void onSmokerSmelt(FurnaceSmeltEvent event) { + markDirtyUnlessActive(event.getBlock()); + } + + public void onSmokerExtract(FurnaceExtractEvent event) { + markDirty(event.getBlock()); + } + + public void onSmokerMoveItem(InventoryMoveItemEvent event) { + markDirty(event.getSource()); + markDirty(event.getDestination()); + } + + public void onSmokerAdded(TileEntityAddedEvent event) { + if (event.getTileEntityType() == TileEntityType.SMOKER) { + markDirty(event.getBlock()); + } + } + + public void onSmokerActivated(TileEntityActivatedEvent event) { + if (event.getTileEntityType() == TileEntityType.SMOKER) { + markDirty(event.getBlock()); + } + } + + public void onSmokerDeactivated(TileEntityDeactivatedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.SMOKER) { + removeTrackedDisplay(event.getBlock()); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onSmoker(InventoryClickEvent event) { if (VanishUtils.isVanished((Player) event.getWhoClicked())) { @@ -348,6 +475,7 @@ public void onSmoker(InventoryClickEvent event) { if (!event.getView().getTopInventory().getLocation().getBlock().getType().equals(Material.SMOKER)) { return; } + markDirty(event.getView().getTopInventory().getLocation().getBlock()); Block block = event.getView().getTopInventory().getLocation().getBlock(); @@ -438,6 +566,7 @@ public void onDragSmoker(InventoryDragEvent event) { for (int slot : event.getRawSlots()) { if (slot >= 0 && slot <= 2) { + markDirty(event.getView().getTopInventory().getLocation().getBlock()); DisplayManager.sendHandMovement(InteractionVisualizerAPI.getPlayers(), (Player) event.getWhoClicked()); break; } @@ -446,12 +575,22 @@ public void onDragSmoker(InventoryDragEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onBreakSmoker(BlockBreakEvent event) { - Block block = event.getBlock(); - if (!smokerMap.containsKey(block)) { - return; + removeTrackedDisplay(event.getBlock()); + } + + public void onRemoveSmoker(TileEntityRemovedEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates + && event.getTileEntityType() == TileEntityType.SMOKER) { + removeTrackedDisplay(event.getBlock()); } + } - Map map = smokerMap.get(block); + private void removeTrackedDisplay(Block block) { + blockUpdates.remove(block); + Map map = smokerMap.remove(block); + if (map == null) { + return; + } if (map.get("Item") instanceof Item) { Item item = (Item) map.get("Item"); DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); @@ -460,7 +599,6 @@ public void onBreakSmoker(BlockBreakEvent event) { DisplayEntity stand = (DisplayEntity) map.get("Stand"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - smokerMap.remove(block); } public boolean hasItemToCook(org.bukkit.block.Smoker smoker) { @@ -490,6 +628,10 @@ public boolean isActive(Location loc) { return PlayerLocationManager.hasPlayerNearby(loc); } + private boolean isSmoker(Material material) { + return material == Material.SMOKER; + } + public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); Location origin = block.getLocation(); @@ -512,6 +654,7 @@ public Map spawnDisplayEntitys(Block block) { } public void setStand(DisplayEntity stand) { + stand.useLegacyNameTagStyle(); stand.setBasePlate(false); stand.setMarker(true); stand.setGravity(false); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SoulCampfireDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SoulCampfireDisplay.java index fd7446ba..eae9d594 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SoulCampfireDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SoulCampfireDisplay.java @@ -25,6 +25,7 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.entityholders.DisplayEntity; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PlayerLocationManager; @@ -34,7 +35,6 @@ import com.loohp.interactionvisualizer.utils.ChatColorUtils; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; @@ -47,7 +47,6 @@ import org.bukkit.event.Listener; import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.inventory.ItemStack; -import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; import java.util.HashMap; @@ -242,15 +241,11 @@ public ScheduledTask run() { symbol += emptyColor + progressBarCharacter; } - if (!PlainTextComponentSerializer.plainText().serialize(stand1.getCustomName()).equals(symbol) || !stand1.isCustomNameVisible()) { - stand1.setCustomNameVisible(true); - stand1.setCustomName(symbol); + if (stand1.updateCustomName(symbol, true)) { DisplayManager.updateDisplay(stand1); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand1.getCustomName()).equals("") || stand1.isCustomNameVisible()) { - stand1.setCustomNameVisible(false); - stand1.setCustomName(""); + if (stand1.updateCustomName("", false)) { DisplayManager.updateDisplay(stand1); } } @@ -275,15 +270,11 @@ public ScheduledTask run() { symbol += emptyColor + progressBarCharacter; } - if (!PlainTextComponentSerializer.plainText().serialize(stand2.getCustomName()).equals(symbol) || !stand2.isCustomNameVisible()) { - stand2.setCustomNameVisible(true); - stand2.setCustomName(symbol); + if (stand2.updateCustomName(symbol, true)) { DisplayManager.updateDisplay(stand2); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand2.getCustomName()).equals("") || stand2.isCustomNameVisible()) { - stand2.setCustomNameVisible(false); - stand2.setCustomName(""); + if (stand2.updateCustomName("", false)) { DisplayManager.updateDisplay(stand2); } } @@ -308,15 +299,11 @@ public ScheduledTask run() { symbol += emptyColor + progressBarCharacter; } - if (!PlainTextComponentSerializer.plainText().serialize(stand3.getCustomName()).equals(symbol) || !stand3.isCustomNameVisible()) { - stand3.setCustomNameVisible(true); - stand3.setCustomName(symbol); + if (stand3.updateCustomName(symbol, true)) { DisplayManager.updateDisplay(stand3); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand3.getCustomName()).equals("") || stand3.isCustomNameVisible()) { - stand3.setCustomNameVisible(false); - stand3.setCustomName(""); + if (stand3.updateCustomName("", false)) { DisplayManager.updateDisplay(stand3); } } @@ -341,15 +328,11 @@ public ScheduledTask run() { symbol += emptyColor + progressBarCharacter; } - if (!PlainTextComponentSerializer.plainText().serialize(stand4.getCustomName()).equals(symbol) || !stand4.isCustomNameVisible()) { - stand4.setCustomNameVisible(true); - stand4.setCustomName(symbol); + if (stand4.updateCustomName(symbol, true)) { DisplayManager.updateDisplay(stand4); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand4.getCustomName()).equals("") || stand4.isCustomNameVisible()) { - stand4.setCustomNameVisible(false); - stand4.setCustomName(""); + if (stand4.updateCustomName("", false)) { DisplayManager.updateDisplay(stand4); } } @@ -360,8 +343,16 @@ public ScheduledTask run() { } @EventHandler(priority = EventPriority.MONITOR) + public void onBreakSoulCampfire(TileEntityRemovedEvent event) { + removeSoulCampfire(event.getBlock()); + } + + /** Retained for callers compiled against the former block-break handler. */ public void onBreakSoulCampfire(BlockBreakEvent event) { - Block block = event.getBlock(); + removeSoulCampfire(event.getBlock()); + } + + private void removeSoulCampfire(Block block) { if (!soulcampfireMap.containsKey(block)) { return; } @@ -403,7 +394,7 @@ public Map spawnDisplayEntitys(Block block) { Location target = block.getRelative(facing).getLocation(); Vector direction = rotateVectorAroundY(target.toVector().subtract(origin.toVector()).multiply(0.44194173), 135); - Location loc = origin.clone().add(0.5, 0.3, 0.5); + Location loc = CampfireDisplay.labelOrigin(origin); DisplayEntity slot1 = new DisplayEntity(loc.clone().add(direction)); setStand(slot1); DisplayEntity slot2 = new DisplayEntity(loc.clone().add(rotateVectorAroundY(direction.clone(), 90))); @@ -427,15 +418,7 @@ public Map spawnDisplayEntitys(Block block) { } public void setStand(DisplayEntity stand) { - stand.setBasePlate(false); - stand.setMarker(true); - stand.setGravity(false); - stand.setSmall(true); - stand.setSilent(true); - stand.setInvulnerable(true); - stand.setVisible(false); - stand.setCustomName(""); - stand.setRightArmPose(EulerAngle.ZERO); + CampfireDisplay.configureLabel(stand); } public Vector rotateVectorAroundY(Vector vector, double degrees) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SpawnerDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SpawnerDisplay.java index 73cc2235..4bfbea94 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SpawnerDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SpawnerDisplay.java @@ -37,7 +37,6 @@ import com.loohp.interactionvisualizer.utils.ChatColorUtils; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; @@ -194,15 +193,11 @@ public ScheduledTask run() { symbol += spawnRange.replace("{SpawnRange}", spawner.getSpawnRange() + ""); - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals(symbol) || !stand.isCustomNameVisible()) { - stand.setCustomNameVisible(true); - stand.setCustomName(symbol); + if (stand.updateCustomName(symbol, true)) { DisplayManager.updateDisplay(stand); } } else { - if (!PlainTextComponentSerializer.plainText().serialize(stand.getCustomName()).equals("") || stand.isCustomNameVisible()) { - stand.setCustomNameVisible(false); - stand.setCustomName(""); + if (stand.updateCustomName("", false)) { DisplayManager.updateDisplay(stand); } } @@ -252,6 +247,7 @@ public Map spawnDisplayEntitys(Block block) { } public void setStand(DisplayEntity stand) { + stand.useLegacyNameTagStyle(); stand.setBasePlate(false); stand.setMarker(true); stand.setGravity(false); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/StonecutterDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/StonecutterDisplay.java index 58e573c2..f271a6fa 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/StonecutterDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/StonecutterDisplay.java @@ -109,6 +109,7 @@ public void run() { DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), entity); } openedStonecutter.remove(block); + playermap.remove((Player) map.get("Player"), block); } }.runTaskLater(InteractionVisualizer.plugin, delay, block.getLocation()); } @@ -271,29 +272,35 @@ public void onStonecutter(InventoryClickEvent event) { ItemStack itemstack = event.getCurrentItem().clone(); Player player = (Player) event.getWhoClicked(); + InventoryView view = event.getView(); if (map.get("Item") instanceof String) { - map.put("Item", new Item(block.getLocation().clone().add(0.5, 1.2, 0.5))); + Item result = new Item(block.getLocation().clone().add(0.5, 0.75, 0.5)); + result.setItemStack(itemstack); + map.put("Item", result); + DisplayManager.sendItemSpawn(InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), result); } Item item = (Item) map.get("Item"); Inventory before = Bukkit.createInventory(null, 9); - before.setItem(0, player.getOpenInventory().getItem(0).clone()); + before.setItem(0, InventoryUtils.cloneItem(view.getItem(0))); Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { + if (!player.isOnline() || player.getOpenInventory() != view) { + return; + } Inventory after = Bukkit.createInventory(null, 9); - after.setItem(0, player.getOpenInventory().getItem(0).clone()); + after.setItem(0, InventoryUtils.cloneItem(view.getItem(0))); if (InventoryUtils.compareContents(before, after)) { return; } - item.setLocked(true); - openedStonecutter.remove(block); item.setItemStack(itemstack); + item.setLocked(true); DisplayManager.updateItem(item); DisplayManager.collectItem(item, player); }, 1); @@ -332,18 +339,14 @@ public void onDragStonecutter(InventoryDragEvent event) { @EventHandler public void onCloseStonecutter(InventoryCloseEvent event) { - if (!playermap.containsKey((Player) event.getPlayer())) { - return; - } - - Block block = playermap.get((Player) event.getPlayer()); - - if (!openedStonecutter.containsKey(block)) { + Player player = (Player) event.getPlayer(); + Block block = playermap.remove(player); + if (block == null) { return; } Map map = openedStonecutter.get(block); - if (!map.get("Player").equals(event.getPlayer())) { + if (map == null || !map.get("Player").equals(player)) { return; } @@ -352,7 +355,6 @@ public void onCloseStonecutter(InventoryCloseEvent event) { DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), entity); } openedStonecutter.remove(block); - playermap.remove((Player) event.getPlayer()); } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceBlockScene.java b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceBlockScene.java new file mode 100644 index 00000000..77b5b049 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceBlockScene.java @@ -0,0 +1,959 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.debug; + +import com.loohp.interactionvisualizer.InteractionVisualizer; +import com.loohp.interactionvisualizer.managers.TileEntityManager; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.BlockState; +import org.bukkit.block.Container; +import org.bukkit.block.Furnace; +import org.bukkit.block.TileState; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Directional; +import org.bukkit.entity.Player; +import org.bukkit.inventory.FurnaceInventory; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** + * Permission-gated, reversible block workload for event-driven display A/B tests. + * + *

This class deliberately has no command or permission surface. The caller must + * keep it behind the existing {@code interactionvisualizer.performance} gate. All + * entry points fail when called off the Bukkit primary thread. A scene only claims + * already-loaded air blocks and tags every claimed tile entity with a session + * marker. Cleanup restores a captured {@link BlockState} only while that marker is + * still present; an externally replaced block is never overwritten. Replacing an + * arbitrary live container cannot be made ownership-safe, so the footprint is + * intentionally restricted to air (fail closed). Inventory snapshots are retained + * by the restoration model defensively, but this policy never claims a pre-existing + * container.

+ */ +public final class PerformanceBlockScene { + + public static final int MAX_BLOCKS = 4_096; + + private static final int SEARCH_PLANES = 8; + private static final long NO_MUTATION_TICK = -1L; + private static final String ENABLE_PROPERTY = "interactionvisualizer.performance.allowBlockScene"; + private static final String OWNER_MARKER = "performance_block_scene"; + private static final Material[] MATERIAL_PATTERN = { + Material.FURNACE, + Material.BLAST_FURNACE, + Material.SMOKER, + Material.BEEHIVE, + Material.BEE_NEST + }; + private static final Map scenes = new HashMap<>(); + + private static long nextRevision; + + private PerformanceBlockScene() { + } + + /** Workload behavior after creation. */ + public enum Mode { + /** Static mixed furnaces and bee blocks. Furnace inputs have no fuel. */ + IDLE, + /** Furnaces are primed so vanilla ticks emit real furnace events. */ + ACTIVE, + /** Mutations use BlockState/inventory writes and intentionally emit no Bukkit event. */ + DIRECT_WRITE; + + public static Mode parse(String value) { + Objects.requireNonNull(value, "value"); + return switch (value.toLowerCase(Locale.ROOT).replace('_', '-')) { + case "idle" -> IDLE; + case "active" -> ACTIVE; + case "direct", "direct-write" -> DIRECT_WRITE; + default -> throw new IllegalArgumentException("Unknown performance block mode: " + value); + }; + } + } + + public enum SceneState { + ABSENT, + READY, + CLEARED, + PARTIAL_CLEAR + } + + /** + * Stable, assertion-friendly scene state. Counts describe the original scene; + * {@code ownedCount} describes blocks that still carry this session's marker. + */ + public record Snapshot( + UUID ownerId, + SceneState state, + Mode mode, + int requestedCount, + int placedCount, + int remainingCount, + int unresolvedCount, + int ownedCount, + int unloadedCount, + int furnaceCount, + int blastFurnaceCount, + int smokerCount, + int beeHiveCount, + int beeNestCount, + long revision, + int lastMutationRequested, + int lastMutationApplied, + long lastMutationStartBukkitTick, + long lastMutationEndBukkitTick, + long lastMutationElapsedNanos, + long lastMutationWriteElapsedNanos, + long lastMutationInspectionElapsedNanos, + int restoredCount, + int skippedExternalCount, + int restoreFailureCount, + int inspectionFailureCount, + String detail + ) { + + public int eventEligibleFurnaceCount() { + return furnaceCount + blastFurnaceCount + smokerCount; + } + + public String summary() { + return "state=" + state.name().toLowerCase(Locale.ROOT) + + " mode=" + (mode == null ? "none" : mode.name().toLowerCase(Locale.ROOT)) + + " requested=" + requestedCount + + " placed=" + placedCount + + " owned=" + ownedCount + + " furnace=" + furnaceCount + + " blastFurnace=" + blastFurnaceCount + + " smoker=" + smokerCount + + " beeHive=" + beeHiveCount + + " beeNest=" + beeNestCount + + " eventEligibleFurnaces=" + eventEligibleFurnaceCount() + + " revision=" + revision + + " mutationRequested=" + lastMutationRequested + + " mutationApplied=" + lastMutationApplied + + " mutationStartBukkitTick=" + lastMutationStartBukkitTick + + " mutationEndBukkitTick=" + lastMutationEndBukkitTick + + " mutationElapsedMs=" + String.format(Locale.ROOT, "%.6f", lastMutationElapsedNanos / 1_000_000.0D) + + " mutationWriteMs=" + String.format(Locale.ROOT, "%.6f", lastMutationWriteElapsedNanos / 1_000_000.0D) + + " mutationInspectionMs=" + String.format(Locale.ROOT, "%.6f", lastMutationInspectionElapsedNanos / 1_000_000.0D) + + " restored=" + restoredCount + + " skippedExternal=" + skippedExternalCount + + " restoreFailures=" + restoreFailureCount + + " remaining=" + remainingCount + + " unresolved=" + unresolvedCount + + " unloaded=" + unloadedCount + + " inspectionFailures=" + inspectionFailureCount + + " detail=" + detail; + } + } + + /** Result of the no-throw, best-effort disable cleanup. */ + public record ShutdownReport( + int trackedCount, + int restoredCount, + int unloadedCount, + int ownershipMismatchCount, + int restoreFailureCount, + int inspectionFailureCount + ) { + + public int unresolvedCount() { + return unloadedCount + ownershipMismatchCount + restoreFailureCount + inspectionFailureCount; + } + + public String summary() { + return "tracked=" + trackedCount + + " restored=" + restoredCount + + " unresolved=" + unresolvedCount() + + " unloaded=" + unloadedCount + + " ownershipMismatch=" + ownershipMismatchCount + + " restoreFailures=" + restoreFailureCount + + " inspectionFailures=" + inspectionFailureCount; + } + } + + /** + * Creates an exact-size scene, clamped to {@link #MAX_BLOCKS}. Preflight only + * selects loaded air blocks; insufficient safe capacity aborts before writes. + */ + public static Snapshot create(Player owner, int requestedCount, Mode mode) { + requirePrimaryThread(); + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(mode, "mode"); + if (!Boolean.getBoolean(ENABLE_PROPERTY)) { + throw new IllegalStateException("Block performance scenes are disabled; use a disposable test server " + + "started with -D" + ENABLE_PROPERTY + "=true"); + } + + Snapshot previous = clear(owner); + if (previous.state() == SceneState.PARTIAL_CLEAR) { + throw new IllegalStateException("Previous performance block scene could not be fully restored: " + + previous.summary()); + } + + int count = Math.max(1, Math.min(MAX_BLOCKS, requestedCount)); + NamespacedKey markerKey = markerKey(); + long revision = ++nextRevision; + String markerValue = owner.getUniqueId() + ":" + revision; + List entries = plan(owner, count); + Counts counts = Counts.of(entries); + Session session = new Session(owner.getUniqueId(), requestedCount, count, mode, revision, + markerKey, markerValue, entries, counts); + + List installed = new ArrayList<>(entries.size()); + try { + for (Entry entry : entries) { + if (entry.block.getType() != entry.originalState.getType() + || !entry.block.getType().isAir()) { + throw new IllegalStateException("Safe scene footprint changed during creation at " + + coordinate(entry.block)); + } + installed.add(entry); + install(session, entry, mode == Mode.ACTIVE ? Mode.ACTIVE : Mode.IDLE); + } + } catch (RuntimeException exception) { + RollbackResult rollback = rollback(session, installed); + if (!rollback.unresolvedEntries().isEmpty()) { + session.entries.clear(); + session.entries.addAll(rollback.unresolvedEntries()); + session.cleanupPending = true; + session.restoredCount = rollback.restoredCount(); + session.skippedExternalCount = rollback.ownershipMismatchCount(); + session.restoreFailureCount = rollback.restoreFailureCount(); + session.inspectionFailureCount = rollback.inspectionFailureCount(); + scenes.put(owner.getUniqueId(), session); + exception.addSuppressed(new IllegalStateException( + rollback.unresolvedEntries().size() + + " installed scene blocks remain unresolved after rollback (unloaded=" + + rollback.unloadedCount() + ", ownershipMismatch=" + + rollback.ownershipMismatchCount() + ", restoreFailures=" + + rollback.restoreFailureCount() + ", inspectionFailures=" + + rollback.inspectionFailureCount() + ")")); + } + throw exception; + } + + scenes.put(owner.getUniqueId(), session); + TileEntityManager.refreshExplicitBlockChanges(blocks(entries)); + return snapshot(session, SceneState.READY, "created"); + } + + /** Mutates up to {@code requestedOperations} entries using the current mode. */ + public static Snapshot mutate(Player owner, int requestedOperations) { + Objects.requireNonNull(owner, "owner"); + return mutate(owner.getUniqueId(), requestedOperations); + } + + /** UUID variant for scenes whose creating player is no longer online. */ + public static Snapshot mutate(UUID ownerId, int requestedOperations) { + requirePrimaryThread(); + Session session = requireSession(ownerId); + return mutate(session, requestedOperations, session.mode); + } + + /** Changes mode and mutates one pass over the scene. */ + public static Snapshot mutate(Player owner, Mode mode) { + Objects.requireNonNull(owner, "owner"); + return mutate(owner.getUniqueId(), mode); + } + + /** UUID variant for scenes whose creating player is no longer online. */ + public static Snapshot mutate(UUID ownerId, Mode mode) { + requirePrimaryThread(); + Session session = requireSession(ownerId); + return mutate(session, session.entries.size(), mode); + } + + /** Changes mode and mutates up to {@code requestedOperations} entries. */ + public static Snapshot mutate(Player owner, int requestedOperations, Mode mode) { + Objects.requireNonNull(owner, "owner"); + return mutate(owner.getUniqueId(), requestedOperations, mode); + } + + /** UUID variant for scenes whose creating player is no longer online. */ + public static Snapshot mutate(UUID ownerId, int requestedOperations, Mode mode) { + requirePrimaryThread(); + Session session = requireSession(ownerId); + return mutate(session, requestedOperations, mode); + } + + /** + * Restores every block still owned by the scene. Blocks whose ownership marker + * disappeared are counted and left untouched. Actual restoration failures stay + * registered so a later call can retry them. + */ + public static Snapshot clear(Player owner) { + Objects.requireNonNull(owner, "owner"); + return clear(owner.getUniqueId()); + } + + /** UUID variant that never requires an {@code OfflinePlayer} or loads a world/chunk. */ + public static Snapshot clear(UUID ownerId) { + requirePrimaryThread(); + Objects.requireNonNull(ownerId, "ownerId"); + Session session = scenes.get(ownerId); + if (session == null) { + return absent(ownerId, "no_scene"); + } + + int restored = 0; + int skipped = 0; + int restoreFailures = 0; + int inspectionFailures = 0; + List affectedEntries = new ArrayList<>(session.entries); + List restoredEntries = new ArrayList<>(); + List unresolved = new ArrayList<>(); + for (Entry entry : affectedEntries) { + if (!isLoaded(entry)) { + unresolved.add(entry); + continue; + } + OwnershipStatus ownership = ownershipStatus(session, entry); + if (ownership != OwnershipStatus.OWNED) { + if (ownership == OwnershipStatus.NOT_OWNED) { + skipped++; + } else { + inspectionFailures++; + } + unresolved.add(entry); + } else if (restore(entry)) { + restored++; + restoredEntries.add(entry); + } else { + restoreFailures++; + unresolved.add(entry); + } + } + + session.restoredCount += restored; + session.skippedExternalCount = skipped; + session.restoreFailureCount = restoreFailures; + session.inspectionFailureCount = inspectionFailures; + session.entries.clear(); + session.entries.addAll(unresolved); + session.cleanupPending = !unresolved.isEmpty(); + boolean cleared = unresolved.isEmpty(); + if (cleared) { + scenes.remove(ownerId, session); + } + if (!restoredEntries.isEmpty()) { + TileEntityManager.refreshExplicitBlockChanges(blocks(restoredEntries)); + } + return cleared + ? snapshot(session, SceneState.CLEARED, + skipped == 0 ? "cleared" : "cleared_with_external_changes") + : snapshot(session, SceneState.PARTIAL_CLEAR, "restore_retry_required"); + } + + public static Snapshot snapshot(Player owner) { + Objects.requireNonNull(owner, "owner"); + return snapshot(owner.getUniqueId()); + } + + /** UUID variant for status checks after the creating player disconnects. */ + public static Snapshot snapshot(UUID ownerId) { + requirePrimaryThread(); + Objects.requireNonNull(ownerId, "ownerId"); + Session session = scenes.get(ownerId); + return session == null ? absent(ownerId, "no_scene") + : snapshot(session, !session.cleanupPending + ? SceneState.READY : SceneState.PARTIAL_CLEAR, "snapshot"); + } + + public static String status(Player owner) { + return snapshot(owner).summary(); + } + + public static String status(UUID ownerId) { + return snapshot(ownerId).summary(); + } + + /** Snapshot of owner ids only; this does not resolve players or load worlds/chunks. */ + public static List activeOwnerIds() { + requirePrimaryThread(); + return List.copyOf(scenes.keySet()); + } + + /** + * Best-effort disable cleanup. Only blocks that still carry the exact + * session marker are restored; externally replaced blocks remain untouched. + * + *

This method never loads chunks. It also deliberately drops its in-memory + * sessions after the report is assembled because plugin disable makes retrying + * impossible; every block that was not proven owned and restored is reported as + * unresolved instead of being presented as a successful cleanup.

+ * + * @return categorized cleanup report; this method is best-effort and does not throw + */ + public static ShutdownReport shutdown() { + int tracked = 0; + int restored = 0; + int unloaded = 0; + int ownershipMismatch = 0; + int restoreFailures = 0; + int inspectionFailures = 0; + try { + List sessions = new ArrayList<>(scenes.values()); + for (Session session : sessions) { + for (Entry entry : new ArrayList<>(session.entries)) { + tracked++; + try { + if (!Bukkit.isPrimaryThread()) { + inspectionFailures++; + continue; + } + if (!isLoaded(entry)) { + unloaded++; + continue; + } + OwnershipStatus ownership = ownershipStatus(session, entry); + if (ownership == OwnershipStatus.NOT_OWNED) { + ownershipMismatch++; + } else if (ownership == OwnershipStatus.INSPECTION_FAILED) { + inspectionFailures++; + } else if (restore(entry)) { + restored++; + } else { + restoreFailures++; + } + } catch (Throwable ignored) { + inspectionFailures++; + } + } + } + } catch (Throwable ignored) { + int uncounted = safeTrackedCount() - tracked; + if (uncounted > 0) { + tracked += uncounted; + inspectionFailures += uncounted; + } + } finally { + try { + scenes.clear(); + } catch (Throwable ignored) { + // The classloader is being discarded; reporting must remain no-throw. + } + } + return new ShutdownReport(tracked, restored, unloaded, ownershipMismatch, + restoreFailures, inspectionFailures); + } + + private static Snapshot mutate(Session session, int requestedOperations, Mode mode) { + Objects.requireNonNull(mode, "mode"); + int operations = Math.max(0, Math.min(session.entries.size(), requestedOperations)); + int applied = 0; + int skipped = 0; + session.mode = mode; + session.revision++; + long startTick = Bukkit.getCurrentTick(); + long startNanos = System.nanoTime(); + + for (int index = 0; index < operations; index++) { + if (session.entries.isEmpty()) { + break; + } + Entry entry = session.entries.get(session.mutationCursor); + session.mutationCursor = (session.mutationCursor + 1) % session.entries.size(); + if (!isOwned(session, entry)) { + skipped++; + continue; + } + if (mutateEntry(entry, mode)) { + applied++; + } + } + // Keep the synchronous write loop separate from the ownership/PDC + // verification performed by snapshot(). Total mutation time still + // includes the small bookkeeping gap between both phases. + long writeEndNanos = System.nanoTime(); + session.lastMutationRequested = operations; + session.lastMutationApplied = applied; + session.skippedExternalCount = skipped; + session.restoredCount = 0; + session.restoreFailureCount = 0; + session.inspectionFailureCount = 0; + return snapshot(session, SceneState.READY, + mode == Mode.ACTIVE ? "vanilla_furnace_events_primed" + : mode == Mode.DIRECT_WRITE ? "eventless_direct_write" + : "idle_normalized", + true, startTick, startNanos, writeEndNanos); + } + + private static List plan(Player owner, int count) { + World world = owner.getWorld(); + int width = (int) Math.ceil(Math.sqrt(count)); + int centerX = owner.getLocation().getBlockX(); + int centerZ = owner.getLocation().getBlockZ(); + int preferredY = Math.max(world.getMinHeight(), + Math.min(world.getMaxHeight() - 1, owner.getLocation().getBlockY() + 4)); + List planes = searchPlanes(world, preferredY); + List entries = new ArrayList<>(count); + + for (int y : planes) { + for (int zIndex = 0; zIndex < width && entries.size() < count; zIndex++) { + int z = centerZ + zIndex - width / 2; + for (int xIndex = 0; xIndex < width && entries.size() < count; xIndex++) { + int x = centerX + xIndex - width / 2; + if (!world.isChunkLoaded(x >> 4, z >> 4)) { + continue; + } + Block block = world.getBlockAt(x, y, z); + if (!block.getType().isAir()) { + continue; + } + BlockState original = block.getState(); + ItemStack[] inventory = captureInventory(original); + int ordinal = entries.size(); + entries.add(new Entry(block, original, inventory, + MATERIAL_PATTERN[ordinal % MATERIAL_PATTERN.length], ordinal)); + } + } + if (entries.size() == count) { + return entries; + } + } + + throw new IllegalStateException("Unable to reserve " + count + + " loaded air blocks without overwriting world content; safeCapacity=" + entries.size()); + } + + private static List searchPlanes(World world, int preferredY) { + List planes = new ArrayList<>(SEARCH_PLANES); + for (int offset = 0; planes.size() < SEARCH_PLANES; offset++) { + int above = preferredY + offset; + if (above >= world.getMinHeight() && above < world.getMaxHeight()) { + planes.add(above); + } + if (offset > 0 && planes.size() < SEARCH_PLANES) { + int below = preferredY - offset; + if (below >= world.getMinHeight() && below < world.getMaxHeight()) { + planes.add(below); + } + } + if (above >= world.getMaxHeight() && preferredY - offset < world.getMinHeight()) { + break; + } + } + return planes; + } + + private static void install(Session session, Entry entry, Mode initialMode) { + Block block = entry.block; + block.setType(entry.sceneMaterial, false); + configureBlockData(block, entry.ordinal, false); + + BlockState state = block.getState(); + if (!(state instanceof TileState tileState)) { + throw new IllegalStateException("Scene material did not create a TileState at " + coordinate(block)); + } + if (state instanceof Furnace furnace) { + configureFurnace(furnace, entry.sceneMaterial, initialMode); + } + tileState.getPersistentDataContainer().set( + session.markerKey, PersistentDataType.STRING, session.markerValue); + if (!state.update(true, false) || !isOwned(session, entry)) { + throw new IllegalStateException("Scene ownership marker did not persist at " + coordinate(block)); + } + } + + private static boolean mutateEntry(Entry entry, Mode mode) { + BlockState state = entry.block.getState(); + if (state instanceof Furnace furnace) { + configureFurnace(furnace, entry.sceneMaterial, mode); + return state.update(true, false); + } + if (mode == Mode.ACTIVE) { + return false; + } + return configureBlockData(entry.block, entry.ordinal, mode == Mode.DIRECT_WRITE); + } + + private static void configureFurnace(Furnace furnace, Material material, Mode mode) { + FurnaceInventory inventory = furnace.getSnapshotInventory(); + Material primaryInput = furnaceInput(material, false); + ItemStack previousInput = inventory.getSmelting(); + boolean alternate = mode == Mode.DIRECT_WRITE && previousInput != null + && previousInput.getType() == primaryInput; + inventory.clear(); + Material input = furnaceInput(material, mode == Mode.DIRECT_WRITE && alternate); + int amount = mode == Mode.ACTIVE ? 64 : mode == Mode.DIRECT_WRITE ? (alternate ? 33 : 17) : 32; + inventory.setSmelting(new ItemStack(input, amount)); + if (mode == Mode.ACTIVE) { + // Vanilla consumes this fuel on its next tick and dispatches the real + // FurnaceStartSmeltEvent edge used by the event-driven updater. + inventory.setFuel(new ItemStack(Material.COAL_BLOCK)); + } + furnace.setBurnTime((short) 0); + furnace.setCookTime((short) 0); + // Keep all 64 inputs active through the longest formal warmup/settle/sample + // window while still letting vanilla complete recipes and emit events. + furnace.setCookSpeedMultiplier(mode == Mode.ACTIVE ? 0.5D : 1.0D); + } + + private static Material furnaceInput(Material furnaceMaterial, boolean alternate) { + if (furnaceMaterial == Material.SMOKER) { + return alternate ? Material.CHICKEN : Material.BEEF; + } + return alternate ? Material.RAW_GOLD : Material.RAW_IRON; + } + + private static boolean configureBlockData(Block block, int ordinal, boolean toggleHoney) { + BlockData data = block.getBlockData(); + boolean changed = false; + if (data instanceof Directional directional && directional.getFaces().contains(BlockFace.NORTH) + && directional.getFacing() != BlockFace.NORTH) { + directional.setFacing(BlockFace.NORTH); + changed = true; + } + if (data instanceof org.bukkit.block.data.type.Beehive beehive) { + int maximum = beehive.getMaximumHoneyLevel(); + int honeyLevel = toggleHoney + ? (beehive.getHoneyLevel() == maximum ? 0 : maximum) + : ordinal % (maximum + 1); + if (beehive.getHoneyLevel() != honeyLevel) { + beehive.setHoneyLevel(honeyLevel); + changed = true; + } + } + if (changed) { + block.setBlockData(data, false); + } + return changed; + } + + private static RollbackResult rollback(Session session, List installed) { + List unresolved = new ArrayList<>(); + int restored = 0; + int unloaded = 0; + int ownershipMismatch = 0; + int restoreFailures = 0; + int inspectionFailures = 0; + for (int index = installed.size() - 1; index >= 0; index--) { + Entry entry = installed.get(index); + if (!isLoaded(entry)) { + unloaded++; + unresolved.add(entry); + continue; + } + OwnershipStatus ownership = ownershipStatus(session, entry); + if (ownership == OwnershipStatus.OWNED) { + if (restore(entry)) { + restored++; + } else { + restoreFailures++; + unresolved.add(entry); + } + } else if (matchesOriginal(entry)) { + // The failed install never displaced the captured air block. + } else { + if (ownership == OwnershipStatus.NOT_OWNED) { + ownershipMismatch++; + } else { + inspectionFailures++; + } + // Marker-free or externally changed blocks are never overwritten. + unresolved.add(entry); + } + } + return new RollbackResult(unresolved, restored, unloaded, ownershipMismatch, + restoreFailures, inspectionFailures); + } + + private static boolean restore(Entry entry) { + if (!isLoaded(entry)) { + return false; + } + try { + if (!entry.originalState.update(true, false)) { + return false; + } + if (entry.originalInventory != null) { + BlockState restored = entry.block.getState(); + if (!(restored instanceof Container container)) { + return false; + } + Inventory inventory = container.getSnapshotInventory(); + inventory.setContents(cloneItems(entry.originalInventory)); + if (!restored.update(true, false)) { + return false; + } + } + return true; + } catch (RuntimeException ignored) { + return false; + } + } + + private static boolean isOwned(Session session, Entry entry) { + return ownershipStatus(session, entry) == OwnershipStatus.OWNED; + } + + private static OwnershipStatus ownershipStatus(Session session, Entry entry) { + if (!isLoaded(entry)) { + return OwnershipStatus.INSPECTION_FAILED; + } + try { + BlockState state = entry.block.getState(); + if (!(state instanceof TileState tileState)) { + return OwnershipStatus.NOT_OWNED; + } + String marker = tileState.getPersistentDataContainer().get( + session.markerKey, PersistentDataType.STRING); + return session.markerValue.equals(marker) + ? OwnershipStatus.OWNED : OwnershipStatus.NOT_OWNED; + } catch (RuntimeException ignored) { + return OwnershipStatus.INSPECTION_FAILED; + } + } + + private static boolean matchesOriginal(Entry entry) { + if (!isLoaded(entry)) { + return false; + } + try { + return entry.block.getType() == entry.originalState.getType(); + } catch (RuntimeException ignored) { + return false; + } + } + + private static ItemStack[] captureInventory(BlockState state) { + if (!(state instanceof Container container)) { + return null; + } + return cloneItems(container.getSnapshotInventory().getContents()); + } + + private static ItemStack[] cloneItems(ItemStack[] items) { + ItemStack[] clone = new ItemStack[items.length]; + for (int index = 0; index < items.length; index++) { + clone[index] = items[index] == null ? null : items[index].clone(); + } + return clone; + } + + private static boolean isLoaded(Entry entry) { + Block block = entry.block; + return block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4); + } + + private static List blocks(List entries) { + List blocks = new ArrayList<>(entries.size()); + for (Entry entry : entries) { + blocks.add(entry.block); + } + return blocks; + } + + private static Snapshot snapshot(Session session, SceneState state, String detail) { + return snapshot(session, state, detail, false, NO_MUTATION_TICK, 0L, 0L); + } + + private static Snapshot snapshot(Session session, SceneState state, String detail, + boolean captureMutationTiming, + long mutationStartBukkitTick, long mutationStartNanos, + long mutationWriteEndNanos) { + int owned = 0; + int unloaded = 0; + long inspectionStartNanos = captureMutationTiming ? System.nanoTime() : 0L; + try { + for (Entry entry : session.entries) { + if (!isLoaded(entry)) { + unloaded++; + } else if (isOwned(session, entry)) { + owned++; + } + } + } finally { + if (captureMutationTiming) { + long endNanos = System.nanoTime(); + session.lastMutationStartBukkitTick = mutationStartBukkitTick; + session.lastMutationEndBukkitTick = Bukkit.getCurrentTick(); + session.lastMutationElapsedNanos = Math.max(0L, endNanos - mutationStartNanos); + session.lastMutationWriteElapsedNanos = Math.max(0L, mutationWriteEndNanos - mutationStartNanos); + session.lastMutationInspectionElapsedNanos = Math.max(0L, endNanos - inspectionStartNanos); + } + } + Counts counts = session.counts; + return new Snapshot(session.ownerId, state, session.mode, session.requestedCount, + session.placedCount, session.entries.size(), session.cleanupPending ? session.entries.size() : 0, + owned, unloaded, counts.furnace, counts.blastFurnace, counts.smoker, + counts.beeHive, counts.beeNest, session.revision, session.lastMutationRequested, + session.lastMutationApplied, session.lastMutationStartBukkitTick, session.lastMutationEndBukkitTick, + session.lastMutationElapsedNanos, session.lastMutationWriteElapsedNanos, + session.lastMutationInspectionElapsedNanos, session.restoredCount, session.skippedExternalCount, + session.restoreFailureCount, session.inspectionFailureCount, detail); + } + + private static Snapshot absent(UUID ownerId, String detail) { + return new Snapshot(ownerId, SceneState.ABSENT, null, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0L, 0, 0, NO_MUTATION_TICK, NO_MUTATION_TICK, + 0L, 0L, 0L, 0, 0, 0, 0, detail); + } + + private static Session requireSession(UUID ownerId) { + Objects.requireNonNull(ownerId, "ownerId"); + Session session = scenes.get(ownerId); + if (session == null) { + throw new IllegalStateException("No performance block scene exists for " + ownerId); + } + if (session.cleanupPending) { + throw new IllegalStateException("Scene is awaiting a restoration retry"); + } + return session; + } + + private static int safeTrackedCount() { + int count = 0; + try { + for (Session session : scenes.values()) { + count += session.entries.size(); + } + } catch (Throwable ignored) { + return count; + } + return count; + } + + private static NamespacedKey markerKey() { + InteractionVisualizer plugin = InteractionVisualizer.plugin; + if (plugin == null) { + throw new IllegalStateException("InteractionVisualizer is not enabled"); + } + return new NamespacedKey(plugin, OWNER_MARKER); + } + + private static void requirePrimaryThread() { + if (!Bukkit.isPrimaryThread()) { + throw new IllegalStateException("Performance block scenes must run on the Bukkit primary thread"); + } + } + + private enum OwnershipStatus { + OWNED, + NOT_OWNED, + INSPECTION_FAILED + } + + private record RollbackResult( + List unresolvedEntries, + int restoredCount, + int unloadedCount, + int ownershipMismatchCount, + int restoreFailureCount, + int inspectionFailureCount + ) { + } + + private static String coordinate(Block block) { + return block.getWorld().getName() + ":" + block.getX() + "," + block.getY() + "," + block.getZ(); + } + + private static final class Session { + + private final UUID ownerId; + private final int requestedCount; + private final int placedCount; + private final NamespacedKey markerKey; + private final String markerValue; + private final List entries; + private final Counts counts; + + private Mode mode; + private long revision; + private int mutationCursor; + private int lastMutationRequested; + private int lastMutationApplied; + private long lastMutationStartBukkitTick; + private long lastMutationEndBukkitTick; + private long lastMutationElapsedNanos; + private long lastMutationWriteElapsedNanos; + private long lastMutationInspectionElapsedNanos; + private int restoredCount; + private int skippedExternalCount; + private int restoreFailureCount; + private int inspectionFailureCount; + private boolean cleanupPending; + + private Session(UUID ownerId, int requestedCount, int placedCount, Mode mode, long revision, + NamespacedKey markerKey, String markerValue, List entries, Counts counts) { + this.ownerId = ownerId; + this.requestedCount = requestedCount; + this.placedCount = placedCount; + this.mode = mode; + this.revision = revision; + this.markerKey = markerKey; + this.markerValue = markerValue; + this.entries = entries; + this.counts = counts; + this.lastMutationStartBukkitTick = NO_MUTATION_TICK; + this.lastMutationEndBukkitTick = NO_MUTATION_TICK; + } + } + + private static final class Entry { + + private final Block block; + private final BlockState originalState; + private final ItemStack[] originalInventory; + private final Material sceneMaterial; + private final int ordinal; + + private Entry(Block block, BlockState originalState, ItemStack[] originalInventory, + Material sceneMaterial, int ordinal) { + this.block = block; + this.originalState = originalState; + this.originalInventory = originalInventory; + this.sceneMaterial = sceneMaterial; + this.ordinal = ordinal; + } + } + + private static final class Counts { + + private int furnace; + private int blastFurnace; + private int smoker; + private int beeHive; + private int beeNest; + + private static Counts of(List entries) { + Counts counts = new Counts(); + for (Entry entry : entries) { + switch (entry.sceneMaterial) { + case FURNACE -> counts.furnace++; + case BLAST_FURNACE -> counts.blastFurnace++; + case SMOKER -> counts.smoker++; + case BEEHIVE -> counts.beeHive++; + case BEE_NEST -> counts.beeNest++; + default -> throw new IllegalStateException("Unexpected scene material: " + entry.sceneMaterial); + } + } + return counts; + } + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java new file mode 100644 index 00000000..1d4052bd --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java @@ -0,0 +1,160 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.debug; + +import com.loohp.interactionvisualizer.InteractionVisualizer; +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.Item; +import com.loohp.interactionvisualizer.entityholders.VisualizerEntity; +import com.loohp.interactionvisualizer.managers.DisplayManager; +import com.loohp.interactionvisualizer.scheduler.Scheduler; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.Vector; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** Permission-gated, disposable visual-item workload for live A/B profiling. */ +public final class PerformanceScene { + + private static final int MAX_ITEMS = 8_192; + private static final long MAX_LIFETIME_TICKS = 12_000L; + private static final Map> scenes = new HashMap<>(); + + private PerformanceScene() { + } + + public static int spawn(Player owner, boolean moving, int requestedCount, long requestedLifetimeTicks) { + int count = Math.max(1, Math.min(MAX_ITEMS, requestedCount)); + long lifetimeTicks = Math.max(1L, Math.min(MAX_LIFETIME_TICKS, requestedLifetimeTicks)); + clear(owner); + + Collection viewers = new ArrayList<>(Bukkit.getOnlinePlayers()); + Location center = owner.getLocation().add(0.0D, 1.5D, 0.0D); + int width = (int) Math.ceil(Math.sqrt(count)); + Set items = new HashSet<>(count); + + for (int index = 0; index < count; index++) { + int xIndex = index % width; + int zIndex = index / width; + Location location = center.clone().add( + (xIndex - (width - 1) / 2.0D) * 0.36D, + (index % 5) * 0.05D, + (zIndex - (width - 1) / 2.0D) * 0.36D); + Item item = new Item(location); + item.setItemStack(new ItemStack(Material.STONE)); + if (moving) { + double angle = index * 2.399963229728653D; + double speed = 0.04D + (index % 7) * 0.005D; + item.setVelocity(new Vector(Math.cos(angle) * speed, 0.11D + (index % 3) * 0.015D, + Math.sin(angle) * speed)); + item.setGravity(true); + } + items.add(item); + DisplayManager.sendItemSpawn(viewers, item); + } + + UUID ownerId = owner.getUniqueId(); + scenes.put(ownerId, items); + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> expire(ownerId, items), + lifetimeTicks, owner.getLocation()); + return count; + } + + /** + * Creates a Paper-owned display workload so the generic visibility queue is + * measured independently from Sparrow's packet-only item branch. + */ + public static int spawnDisplay(Player owner, boolean text, int requestedCount, + long requestedLifetimeTicks) { + int count = Math.max(1, Math.min(MAX_ITEMS, requestedCount)); + long lifetimeTicks = Math.max(1L, Math.min(MAX_LIFETIME_TICKS, requestedLifetimeTicks)); + clear(owner); + + Collection viewers = new ArrayList<>(Bukkit.getOnlinePlayers()); + Location center = owner.getLocation().add(0.0D, 1.5D, 0.0D); + int width = (int) Math.ceil(Math.sqrt(count)); + Set displays = new HashSet<>(count); + + for (int index = 0; index < count; index++) { + int xIndex = index % width; + int zIndex = index / width; + Location location = center.clone().add( + (xIndex - (width - 1) / 2.0D) * 0.36D, + (index % 5) * 0.05D, + (zIndex - (width - 1) / 2.0D) * 0.36D); + DisplayEntity display = new DisplayEntity(location); + if (text) { + display.setCustomName(Component.text("IV")); + display.setCustomNameVisible(true); + } else { + display.setItemInMainHand(new ItemStack(Material.STONE)); + } + displays.add(display); + DisplayManager.spawnDisplay(viewers, display); + } + + UUID ownerId = owner.getUniqueId(); + scenes.put(ownerId, displays); + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> expire(ownerId, displays), + lifetimeTicks, owner.getLocation()); + return count; + } + + public static void clear(Player owner) { + clear(owner.getUniqueId()); + } + + /** Removes a scene even when its owner has disconnected. */ + public static void clear(UUID ownerId) { + Set previous = scenes.remove(ownerId); + if (previous != null) { + removeEntities(previous); + } + } + + /** Deterministically removes every disposable benchmark scene. */ + public static void shutdown() { + List> remaining = new ArrayList<>(scenes.values()); + scenes.clear(); + for (Set entities : remaining) { + removeEntities(entities); + } + } + + private static void expire(UUID ownerId, Set entities) { + if (scenes.remove(ownerId, entities)) { + removeEntities(entities); + } + } + + private static void removeEntities(Set entities) { + for (VisualizerEntity entity : entities) { + if (entity instanceof Item item) { + DisplayManager.removeItem(null, item, true, false); + } else if (entity instanceof DisplayEntity display) { + DisplayManager.removeDisplay(null, display, true, false); + } + } + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java index 584bc3bb..fc103da8 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java @@ -18,6 +18,7 @@ import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; import com.loohp.interactionvisualizer.api.events.InteractionVisualizerReloadEvent; import com.loohp.interactionvisualizer.integration.CustomContentManager; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.utils.ChatColorUtils; import com.loohp.interactionvisualizer.utils.ComponentFont; @@ -56,8 +57,11 @@ import org.joml.Quaternionf; import org.joml.Vector3f; +import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; @@ -71,10 +75,13 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implements Listener { public static final EntryKey KEY = new EntryKey("item"); + private static final int DEFAULT_TRACKING_DISTANCE = 64; + private static final int VIEW_DISTANCE_HYSTERESIS = 16; private final Map trackedItems = new HashMap<>(); private final Map labels = new HashMap<>(); private final Set eligibleViewers = new HashSet<>(); + private final Map visibilityStates = new HashMap<>(); private String regularFormatting; private String singularFormatting; @@ -87,6 +94,7 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen private int updateRate = 20; private int ticksUntilUpdate; private int despawnTicks = 6000; + private DroppedItemVisibilityPolicy visibilityPolicy = DroppedItemVisibilityPolicy.legacyDefaults(); private boolean stripColorBlacklist; private DroppedItemBlacklist blacklist = DroppedItemBlacklist.compile(List.of(), DroppedItemDisplay::warn); @@ -96,6 +104,7 @@ public DroppedItemDisplay() { @EventHandler public void onReload(InteractionVisualizerReloadEvent event) { + DroppedItemVisibilityPolicy previousVisibilityPolicy = visibilityPolicy; regularFormatting = configString("Entities.Item.Options.RegularFormat"); singularFormatting = configString("Entities.Item.Options.SingularFormat"); toolsFormatting = configString("Entities.Item.Options.ToolsFormat"); @@ -107,6 +116,30 @@ public void onReload(InteractionVisualizerReloadEvent event) { .getDouble("Entities.Item.Options.LabelYOffset"); labelYOffset = Double.isFinite(configuredLabelYOffset) ? configuredLabelYOffset : 0.8D; updateRate = Math.max(1, InteractionVisualizer.plugin.getConfiguration().getInt("Entities.Item.Options.UpdateRate")); + int configuredViewDistance = InteractionVisualizer.plugin.getConfiguration() + .getInt("Entities.Item.Options.VisibilityCulling.ViewDistance"); + int configuredBucketSize = InteractionVisualizer.plugin.getConfiguration() + .getInt("Entities.Item.Options.VisibilityRateLimit.BucketSize"); + int configuredRefill = InteractionVisualizer.plugin.getConfiguration() + .getInt("Entities.Item.Options.VisibilityRateLimit.RestorePerTick"); + visibilityPolicy = DroppedItemVisibilityPolicy.create( + InteractionVisualizer.plugin.getConfiguration() + .getBoolean("Entities.Item.Options.VisibilityCulling.Enabled"), + configuredViewDistance, + InteractionVisualizer.plugin.getConfiguration() + .getBoolean("Entities.Item.Options.VisibilityRateLimit.Enabled"), + configuredBucketSize, + configuredRefill); + PerformanceMetrics.droppedLabelVisibilityConfig( + visibilityPolicy.cullingEnabled(), + visibilityPolicy.viewDistance(), + visibilityPolicy.rateLimitEnabled(), + visibilityPolicy.bucketSize(), + visibilityPolicy.restorePerTick()); + if (previousVisibilityPolicy.controlsPerViewerVisibility() + != visibilityPolicy.controlsPerViewerVisibility()) { + switchVisibilityMode(visibilityPolicy.controlsPerViewerVisibility()); + } int configuredDespawnTicks = InteractionVisualizer.plugin.getConfiguration().getInt("Entities.Item.Options.DespawnTicks"); despawnTicks = configuredDespawnTicks > 0 ? configuredDespawnTicks : 6000; stripColorBlacklist = InteractionVisualizer.plugin.getConfiguration() @@ -147,6 +180,9 @@ public ScheduledTask run() { return new ScheduledRunnable() { @Override public void run() { + if (visibilityPolicy.controlsPerViewerVisibility()) { + drainVisibilityQueues(); + } if (--ticksUntilUpdate <= 0) { ticksUntilUpdate = updateRate; tickAll(); @@ -190,7 +226,10 @@ public void onEntityRemove(EntityRemoveEvent event) { if (label != null) { // EntityRemoveEvent is monitoring-only. Defer entity mutation // until Paper has finished removing the item's passengers. - Scheduler.runTask(InteractionVisualizer.plugin, () -> removeLabel(label)); + Scheduler.runTask(InteractionVisualizer.plugin, () -> { + forgetLabelVisibility(itemId, label); + removeLabel(label); + }); } } } @@ -200,18 +239,110 @@ private void track(Item item) { } private void tickAll() { - reconcileEligibleViewers(); - for (Map.Entry entry : new HashMap<>(trackedItems).entrySet()) { + long started = PerformanceMetrics.isCollecting() ? System.nanoTime() : 0L; + try { + tickAllInternal(); + } finally { + if (started != 0L) { + PerformanceMetrics.droppedItemNanos(System.nanoTime() - started); + } + } + } + + private void tickAllInternal() { + Collection viewers = reconcileEligibleViewers(); + List validItems = new ArrayList<>(trackedItems.size()); + UUID singleItemWorldId = null; + int singleWorldItemCount = 0; + Map itemCountsByWorld = null; + Iterator> iterator = trackedItems.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); Item item = entry.getValue(); if (!item.isValid() || item.isDead()) { - remove(entry.getKey()); + iterator.remove(); + removeLabel(entry.getKey()); continue; } - update(item); + Location location = item.getLocation(); + UUID worldId = location.getWorld().getUID(); + validItems.add(new TrackedItem(entry.getKey(), item, worldId, location)); + if (singleItemWorldId == null) { + singleItemWorldId = worldId; + singleWorldItemCount = 1; + } else if (itemCountsByWorld == null && singleItemWorldId.equals(worldId)) { + singleWorldItemCount++; + } else { + if (itemCountsByWorld == null) { + itemCountsByWorld = new HashMap<>(); + itemCountsByWorld.put(singleItemWorldId, singleWorldItemCount); + } + itemCountsByWorld.merge(worldId, 1, Integer::sum); + } + } + + if (viewers.isEmpty() && visibilityPolicy.cullingEnabled()) { + for (UUID itemId : new HashSet<>(labels.keySet())) { + removeLabel(itemId); + } + return; + } + + DroppedItemSpatialIndex.ViewerIndex viewerIndex = null; + if (visibilityPolicy.cullingEnabled()) { + viewerIndex = new DroppedItemSpatialIndex.ViewerIndex(viewers.size()); + for (Player viewer : viewers) { + Location location = viewer.getLocation(); + viewerIndex.addViewer(viewer.getWorld().getUID(), + location.getX(), location.getY(), location.getZ()); + } + } + + DroppedItemSpatialIndex itemIndex = cramp > 0 ? new DroppedItemSpatialIndex() : null; + if (itemIndex != null) { + for (TrackedItem tracked : validItems) { + Location location = tracked.location(); + itemIndex.addItem(tracked.worldId(), + location.getX(), location.getY(), location.getZ()); + } + } + int remainingSingleWorldItems = singleWorldItemCount; + for (int trackedIndex = 0; trackedIndex < validItems.size(); trackedIndex++) { + TrackedItem tracked = validItems.get(trackedIndex); + int remainingWorldItems; + if (itemCountsByWorld == null) { + remainingWorldItems = --remainingSingleWorldItems; + } else { + remainingWorldItems = itemCountsByWorld.get(tracked.worldId()) - 1; + itemCountsByWorld.put(tracked.worldId(), remainingWorldItems); + } + update(tracked, itemIndex, viewerIndex, remainingWorldItems); + } + if (visibilityPolicy.controlsPerViewerVisibility()) { + reconcileLabelVisibility(viewers, validItems); } } - private void update(Item item) { + private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex, + DroppedItemSpatialIndex.ViewerIndex viewerIndex, int remainingWorldItems) { + Item item = tracked.item(); + Location itemLocation = tracked.location(); + TextDisplay label = labels.get(tracked.itemId()); + if (visibilityPolicy.cullingEnabled()) { + int trackingDistance = InteractionVisualizer.playerTrackingRange + .getOrDefault(item.getWorld(), DEFAULT_TRACKING_DISTANCE); + int effectiveViewDistance = visibilityPolicy.effectiveViewDistance(trackingDistance); + int cullingDistance = label == null + ? effectiveViewDistance + : effectiveViewDistance + VIEW_DISTANCE_HYSTERESIS; + if (!viewerIndex.hasViewerWithin(tracked.worldId(), + itemLocation.getX(), itemLocation.getY(), itemLocation.getZ(), + cullingDistance, remainingWorldItems)) { + removeLabel(tracked.itemId()); + return; + } + } + ItemStack stack = item.getItemStack(); String matchingName = matchingName(stack); NamespacedKey customItemId = blacklist.requiresCustomItemId() @@ -219,13 +350,14 @@ private void update(Item item) { : null; int ticksLeft = despawnTicks - item.getTicksLived(); if (stack.isEmpty() || blacklist.matches(matchingName, stack.getType(), customItemId) - || item.getPickupDelay() >= Short.MAX_VALUE || ticksLeft <= 0 || isCramping(item)) { + || item.getPickupDelay() >= Short.MAX_VALUE || ticksLeft <= 0 + || (itemIndex != null && itemIndex.exceedsItemLimit(tracked.worldId(), + itemLocation.getX(), itemLocation.getY(), itemLocation.getZ(), cramp))) { removeLabel(item.getUniqueId()); return; } Component text = format(stack, ticksLeft); - TextDisplay label = labels.get(item.getUniqueId()); boolean created = false; if (label == null || !label.isValid() || !label.getWorld().equals(item.getWorld())) { removeLabel(item.getUniqueId()); @@ -236,6 +368,10 @@ private void update(Item item) { if (!text.equals(label.text())) { label.text(text); } + float targetViewRange = labelViewRange(); + if (Math.abs(label.getViewRange() - targetViewRange) > 1.0E-4F) { + label.setViewRange(targetViewRange); + } boolean mounted = item.equals(label.getVehicle()) || item.addPassenger(label); if (mounted) { // A mounted display follows the item on every client render frame. @@ -257,14 +393,13 @@ private void update(Item item) { } label.teleport(labelLocation(item)); } - if (created) { - // Mount and configure the final render height before revealing the - // label so the first tracking bundle cannot flash at item height. + if (created && !visibilityPolicy.controlsPerViewerVisibility()) { showToEligibleViewers(label); } } private TextDisplay spawnLabel(Item item) { + PerformanceMetrics.bukkitEntitySpawn(); return item.getWorld().spawn(labelLocation(item), TextDisplay.class, display -> { display.setPersistent(false); @@ -274,7 +409,7 @@ private TextDisplay spawnLabel(Item item) { display.setSilent(true); display.setNoPhysics(true); display.setBillboard(Display.Billboard.CENTER); - display.setViewRange(1.0F); + display.setViewRange(labelViewRange()); display.setInterpolationDuration(0); display.setTeleportDuration(0); display.setShadowed(true); @@ -287,6 +422,10 @@ private TextDisplay spawnLabel(Item item) { }); } + private float labelViewRange() { + return visibilityPolicy.labelViewRange(); + } + private Location labelLocation(Item item) { return item.getLocation().add(0.0, labelYOffset, 0.0); } @@ -308,7 +447,7 @@ private static void setLabelVerticalTranslation(TextDisplay label, float targetY new Quaternionf(current.getRightRotation()))); } - private void reconcileEligibleViewers() { + private Collection reconcileEligibleViewers() { Map desired = new HashMap<>(); for (Player player : InteractionVisualizerAPI.getPlayerModuleList(Modules.HOLOGRAM, KEY)) { if (player.isOnline()) { @@ -318,35 +457,160 @@ private void reconcileEligibleViewers() { for (UUID uuid : new HashSet<>(eligibleViewers)) { if (!desired.containsKey(uuid)) { Player player = Bukkit.getPlayer(uuid); - if (player != null) { - for (TextDisplay label : labels.values()) { - if (label.isValid()) { - player.hideEntity(InteractionVisualizer.plugin, label); - } - } + if (visibilityPolicy.controlsPerViewerVisibility()) { + removeVisibilityState(uuid, player); + } else if (player != null) { + setAllLabelsVisible(player, false); } eligibleViewers.remove(uuid); } } for (Map.Entry entry : desired.entrySet()) { - if (eligibleViewers.add(entry.getKey())) { - Player player = entry.getValue(); - for (TextDisplay label : labels.values()) { - if (label.isValid()) { - player.showEntity(InteractionVisualizer.plugin, label); + if (eligibleViewers.add(entry.getKey()) + && !visibilityPolicy.controlsPerViewerVisibility()) { + setAllLabelsVisible(entry.getValue(), true); + } + } + return desired.values(); + } + + private void switchVisibilityMode(boolean controlled) { + for (UUID playerId : eligibleViewers) { + Player player = Bukkit.getPlayer(playerId); + if (player != null && player.isOnline()) { + setAllLabelsVisible(player, !controlled); + } + } + for (VisibilityState state : visibilityStates.values()) { + state.pending.clear(); + } + visibilityStates.clear(); + } + + private void showToEligibleViewers(TextDisplay label) { + for (UUID playerId : eligibleViewers) { + Player player = Bukkit.getPlayer(playerId); + if (player != null && player.isOnline()) { + setLabelVisible(player, label, true); + } + } + } + + private void setAllLabelsVisible(Player player, boolean visible) { + for (TextDisplay label : labels.values()) { + if (label.isValid()) { + setLabelVisible(player, label, visible); + } + } + } + + private static void setLabelVisible(Player player, TextDisplay label, boolean visible) { + if (visible) { + PerformanceMetrics.bukkitShow(); + player.showEntity(InteractionVisualizer.plugin, label); + } else { + PerformanceMetrics.bukkitHide(); + player.hideEntity(InteractionVisualizer.plugin, label); + } + } + + private void reconcileLabelVisibility(Collection viewers, List validItems) { + for (Player player : viewers) { + UUID playerId = player.getUniqueId(); + VisibilityState state = visibilityStates.computeIfAbsent(playerId, + ignored -> new VisibilityState(visibilityPolicy.bucketSize())); + Set desired = new HashSet<>(); + Location playerLocation = player.getLocation(); + int trackingDistance = InteractionVisualizer.playerTrackingRange + .getOrDefault(player.getWorld(), DEFAULT_TRACKING_DISTANCE); + double range = visibilityPolicy.cullingEnabled() + ? visibilityPolicy.effectiveViewDistance(trackingDistance) + : 0.0D; + double rangeSquared = range * range; + + for (TrackedItem tracked : validItems) { + TextDisplay label = labels.get(tracked.itemId()); + if (label == null || !label.isValid() || !tracked.item().getWorld().equals(player.getWorld())) { + continue; + } + if (!visibilityPolicy.cullingEnabled()) { + desired.add(tracked.itemId()); + continue; + } + Location location = tracked.location(); + double deltaX = location.getX() - playerLocation.getX(); + double deltaY = location.getY() - playerLocation.getY(); + double deltaZ = location.getZ() - playerLocation.getZ(); + if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= rangeSquared) { + desired.add(tracked.itemId()); + } + } + + for (UUID itemId : new HashSet<>(state.shown)) { + if (!desired.contains(itemId)) { + TextDisplay label = labels.get(itemId); + if (label != null && label.isValid()) { + setLabelVisible(player, label, false); } + state.shown.remove(itemId); + } + } + for (UUID itemId : state.desired) { + if (!desired.contains(itemId)) { + state.pending.cancel(itemId); + } + } + state.desired = desired; + for (UUID itemId : desired) { + if (!state.shown.contains(itemId)) { + state.pending.request(itemId); } } } } - private void showToEligibleViewers(TextDisplay label) { - for (UUID uuid : eligibleViewers) { - Player player = Bukkit.getPlayer(uuid); - if (player != null) { - player.showEntity(InteractionVisualizer.plugin, label); + private void drainVisibilityQueues() { + for (Map.Entry entry : visibilityStates.entrySet()) { + Player player = Bukkit.getPlayer(entry.getKey()); + VisibilityState state = entry.getValue(); + if (player == null || !player.isOnline() || !eligibleViewers.contains(entry.getKey())) { + continue; + } + List ready = visibilityPolicy.rateLimitEnabled() + ? state.pending.drain( + visibilityPolicy.bucketSize(), visibilityPolicy.restorePerTick(), + id -> isPendingVisibilityWanted(state, id)) + : state.pending.drainAll(id -> isPendingVisibilityWanted(state, id)); + for (UUID itemId : ready) { + TextDisplay label = labels.get(itemId); + if (label != null && label.isValid()) { + setLabelVisible(player, label, true); + state.shown.add(itemId); + } + } + } + } + + private boolean isPendingVisibilityWanted(VisibilityState state, UUID itemId) { + TextDisplay label = labels.get(itemId); + return state.desired.contains(itemId) && !state.shown.contains(itemId) + && label != null && label.isValid(); + } + + private void removeVisibilityState(UUID playerId, Player player) { + VisibilityState state = visibilityStates.remove(playerId); + if (state == null) { + return; + } + if (player != null) { + for (UUID itemId : state.shown) { + TextDisplay label = labels.get(itemId); + if (label != null && label.isValid()) { + setLabelVisible(player, label, false); + } } } + state.pending.clear(); } private Component format(ItemStack stack, int ticksLeft) { @@ -390,32 +654,42 @@ private String matchingName(ItemStack stack) { return stripColorBlacklist ? ChatColorUtils.stripColor(plain) : plain; } - private boolean isCramping(Item item) { - return cramp > 0 && item.getWorld() - .getNearbyEntitiesByType(Item.class, item.getLocation(), 0.5, 0.5, 0.5) - .stream() - .filter(nearby -> !isOwned(nearby)) - .limit(cramp + 1L) - .count() > cramp; - } - private void remove(UUID itemId) { trackedItems.remove(itemId); removeLabel(itemId); } private void removeLabel(UUID itemId) { - removeLabel(labels.remove(itemId)); + TextDisplay label = labels.remove(itemId); + forgetLabelVisibility(itemId, label); + removeLabel(label); + } + + private void forgetLabelVisibility(UUID itemId, TextDisplay label) { + for (Map.Entry entry : visibilityStates.entrySet()) { + VisibilityState state = entry.getValue(); + state.desired.remove(itemId); + state.pending.cancel(itemId); + if (state.shown.remove(itemId) && label != null && label.isValid()) { + Player player = Bukkit.getPlayer(entry.getKey()); + if (player != null) { + setLabelVisible(player, label, false); + } + } + } } private void removeLabel(TextDisplay label) { if (label != null && label.isValid()) { - for (UUID uuid : eligibleViewers) { - Player player = Bukkit.getPlayer(uuid); - if (player != null) { - player.hideEntity(InteractionVisualizer.plugin, label); + if (!visibilityPolicy.controlsPerViewerVisibility()) { + for (UUID playerId : eligibleViewers) { + Player player = Bukkit.getPlayer(playerId); + if (player != null) { + setLabelVisible(player, label, false); + } } } + PerformanceMetrics.bukkitEntityRemove(); label.remove(); } } @@ -427,4 +701,18 @@ private static boolean isOwned(Entity entity) { private static NamespacedKey ownerKey() { return new NamespacedKey(InteractionVisualizer.plugin, "visual_entity"); } + + private record TrackedItem(UUID itemId, Item item, UUID worldId, Location location) { + } + + private static final class VisibilityState { + + private Set desired = new HashSet<>(); + private final Set shown = new HashSet<>(); + private final VisibilityTokenBucket pending; + + private VisibilityState(int initialTokens) { + this.pending = new VisibilityTokenBucket<>(initialTokens); + } + } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java new file mode 100644 index 00000000..34807bf2 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java @@ -0,0 +1,597 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** Per-update spatial indexes for dropped items and eligible viewers. */ +final class DroppedItemSpatialIndex { + + private static final double ITEM_QUERY_RADIUS = 0.5D; + private static final double ITEM_CELL_SIZE = ITEM_QUERY_RADIUS; + + private final Map> itemCells = new HashMap<>(); + + void addItem(UUID worldId, double x, double y, double z) { + itemCells.computeIfAbsent(itemCell(worldId, x, y, z), ignored -> new ArrayList<>()) + .add(new Point(x, y, z)); + } + + boolean exceedsItemLimit(UUID worldId, double x, double y, double z, int maximum) { + if (maximum < 1) { + return false; + } + + int minimumX = itemCoordinate(x - ITEM_QUERY_RADIUS); + int maximumX = itemCoordinate(x + ITEM_QUERY_RADIUS); + int minimumY = itemCoordinate(y - ITEM_QUERY_RADIUS); + int maximumY = itemCoordinate(y + ITEM_QUERY_RADIUS); + int minimumZ = itemCoordinate(z - ITEM_QUERY_RADIUS); + int maximumZ = itemCoordinate(z + ITEM_QUERY_RADIUS); + int matches = 0; + + for (int cellX = minimumX; cellX <= maximumX; cellX++) { + for (int cellY = minimumY; cellY <= maximumY; cellY++) { + for (int cellZ = minimumZ; cellZ <= maximumZ; cellZ++) { + List points = itemCells.get(new Cell(worldId, cellX, cellY, cellZ)); + if (points == null) { + continue; + } + for (Point point : points) { + if (Math.abs(point.x() - x) <= ITEM_QUERY_RADIUS + && Math.abs(point.y() - y) <= ITEM_QUERY_RADIUS + && Math.abs(point.z() - z) <= ITEM_QUERY_RADIUS + && ++matches > maximum) { + return true; + } + } + } + } + } + return false; + } + + private static Cell itemCell(UUID worldId, double x, double y, double z) { + return new Cell(worldId, itemCoordinate(x), itemCoordinate(y), itemCoordinate(z)); + } + + private static int itemCoordinate(double coordinate) { + return (int) Math.floor(coordinate / ITEM_CELL_SIZE); + } + + static final class ViewerIndex { + + private UUID singleWorldId; + private WorldViewerBucket singleWorldViewers; + private Map viewersByWorld; + private final int expectedViewers; + + ViewerIndex() { + this(0); + } + + ViewerIndex(int expectedViewers) { + if (expectedViewers < 0) { + throw new IllegalArgumentException("expectedViewers must be non-negative"); + } + this.expectedViewers = expectedViewers; + } + + void addViewer(UUID worldId, double x, double y, double z) { + if (singleWorldViewers == null) { + singleWorldId = worldId; + singleWorldViewers = new WorldViewerBucket(expectedViewers); + singleWorldViewers.add(x, y, z); + return; + } + if (viewersByWorld == null && java.util.Objects.equals(singleWorldId, worldId)) { + singleWorldViewers.add(x, y, z); + return; + } + if (viewersByWorld == null) { + viewersByWorld = new HashMap<>(); + viewersByWorld.put(singleWorldId, singleWorldViewers); + } + viewersByWorld.computeIfAbsent(worldId, + ignored -> new WorldViewerBucket(0)) + .add(x, y, z); + } + + boolean hasViewerWithin(UUID worldId, double x, double y, double z, double range) { + return hasViewerWithin(worldId, x, y, z, range, Integer.MAX_VALUE); + } + + boolean hasViewerWithin(UUID worldId, double x, double y, double z, + double range, int remainingQueries) { + if (range < 0.0D) { + return false; + } + WorldViewerBucket viewers = viewersByWorld == null + ? java.util.Objects.equals(singleWorldId, worldId) ? singleWorldViewers : null + : viewersByWorld.get(worldId); + if (viewers == null) { + return false; + } + double rangeSquared = range * range; + if (!viewers.boundsActive && !viewers.useGrid) { + double[] xCoordinates = viewers.xCoordinates; + double[] yCoordinates = viewers.yCoordinates; + double[] zCoordinates = viewers.zCoordinates; + int viewerCount = viewers.size; + for (int index = 0; index < viewerCount; index++) { + double deltaX = xCoordinates[index] - x; + double deltaY = yCoordinates[index] - y; + double deltaZ = zCoordinates[index] - z; + if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= rangeSquared) { + int matchingViewer = index + 1; + if (matchingViewer < WorldViewerBucket.MINIMUM_DEEP_SCAN_VIEWERS) { + if (viewers.expensiveQueries != 0 || viewers.persistentGridHits) { + viewers.expensiveQueries = 0; + viewers.persistentGridHits = false; + } + return true; + } + int expensiveQueries = ++viewers.expensiveQueries; + if (expensiveQueries >= WorldViewerBucket.EXPENSIVE_QUERIES_BEFORE_GRID + && (viewers.viewerGrid != null + || remainingQueries >= WorldViewerBucket.MINIMUM_REMAINING_QUERIES)) { + viewers.activateGridIfBeneficial(x, z, range, remainingQueries, + matchingViewer, matchingViewer - 1, true); + } + return true; + } + } + if (!viewers.boundsInitialized) { + viewers.initializeBounds(); + } + if (viewers.isOutsideBounds(x, y, z, range)) { + viewers.boundsActive = true; + return false; + } + int expensiveQueries = ++viewers.expensiveQueries; + if (expensiveQueries >= WorldViewerBucket.EXPENSIVE_QUERIES_BEFORE_GRID + && (viewers.viewerGrid != null + || remainingQueries >= WorldViewerBucket.MINIMUM_REMAINING_QUERIES)) { + viewers.activateGridIfBeneficial(x, z, range, remainingQueries, + viewerCount, -1, false); + } + return false; + } + return viewers.hasViewerWithinAdaptiveState( + x, y, z, range, rangeSquared, remainingQueries); + } + + boolean hasAdaptiveGrid(UUID worldId) { + WorldViewerBucket viewers = viewersByWorld == null + ? java.util.Objects.equals(singleWorldId, worldId) ? singleWorldViewers : null + : viewersByWorld.get(worldId); + return viewers != null && viewers.viewerGrid != null; + } + + boolean isUsingAdaptiveGrid(UUID worldId) { + WorldViewerBucket viewers = viewersByWorld == null + ? java.util.Objects.equals(singleWorldId, worldId) ? singleWorldViewers : null + : viewersByWorld.get(worldId); + return viewers != null && viewers.useGrid; + } + + boolean hasActiveBounds(UUID worldId) { + WorldViewerBucket viewers = viewersByWorld == null + ? java.util.Objects.equals(singleWorldId, worldId) ? singleWorldViewers : null + : viewersByWorld.get(worldId); + return viewers != null && viewers.boundsActive; + } + + private static final class WorldViewerBucket { + + private static final int INITIAL_CAPACITY = 8; + private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; + private static final double[] EMPTY = new double[0]; + private static final double GRID_CELL_SIZE = 16.0D; + private static final int MINIMUM_DEEP_SCAN_VIEWERS = 128; + private static final int EXPENSIVE_QUERIES_BEFORE_GRID = 8; + private static final int MINIMUM_REMAINING_QUERIES = 256; + private static final int CELL_LOOKUP_EQUIVALENT_VIEWERS = 3; + private static final int GRID_BUILD_EQUIVALENT_VIEWERS_PER_VIEWER = 4; + private static final int GRID_LINEAR_PROBE_INTERVAL = 64; + + private double[] xCoordinates = EMPTY; + private double[] yCoordinates = EMPTY; + private double[] zCoordinates = EMPTY; + private double minimumX = Double.POSITIVE_INFINITY; + private double minimumY = Double.POSITIVE_INFINITY; + private double minimumZ = Double.POSITIVE_INFINITY; + private double maximumX = Double.NEGATIVE_INFINITY; + private double maximumY = Double.NEGATIVE_INFINITY; + private double maximumZ = Double.NEGATIVE_INFINITY; + private Map> viewerGrid; + private boolean boundsInitialized; + private boolean boundsActive; + private boolean useGrid; + private boolean persistentGridHits; + private int expensiveQueries; + private int gridQueriesUntilProbe; + private int size; + + private WorldViewerBucket(int initialCapacity) { + if (initialCapacity > 0) { + xCoordinates = new double[initialCapacity]; + yCoordinates = new double[initialCapacity]; + zCoordinates = new double[initialCapacity]; + } + } + + private void add(double x, double y, double z) { + if (size == xCoordinates.length) { + int capacity = grownCapacity(size); + xCoordinates = grow(xCoordinates, capacity); + yCoordinates = grow(yCoordinates, capacity); + zCoordinates = grow(zCoordinates, capacity); + } + xCoordinates[size] = x; + yCoordinates[size] = y; + zCoordinates[size] = z; + size++; + if (boundsInitialized || viewerGrid != null || expensiveQueries != 0) { + resetAdaptiveState(); + } + } + + private boolean isOutsideBounds(double x, double y, double z, double range) { + return x < minimumX - range || x > maximumX + range + || y < minimumY - range || y > maximumY + range + || z < minimumZ - range || z > maximumZ + range; + } + + private boolean hasViewerWithinAdaptiveState(double x, double y, double z, double range, + double rangeSquared, int remainingQueries) { + if (boundsActive && isOutsideBounds(x, y, z, range)) { + return false; + } + if (useGrid) { + ViewerCellWindow window = gridWindow(x, z, range); + if (window == null) { + useGrid = false; + persistentGridHits = false; + expensiveQueries = 0; + gridQueriesUntilProbe = 0; + } else if (--gridQueriesUntilProbe <= 0) { + useGrid = false; + persistentGridHits = false; + expensiveQueries = EXPENSIVE_QUERIES_BEFORE_GRID - 1; + } else { + if (hasViewerWithinGrid(x, y, z, rangeSquared, window)) { + if (boundsActive) { + boundsActive = false; + } + if (!persistentGridHits) { + useGrid = false; + expensiveQueries = 0; + gridQueriesUntilProbe = 0; + } + return true; + } + if (isOutsideBounds(x, y, z, range)) { + boundsActive = true; + } + return false; + } + } + int matchingViewer = firstViewerWithin(x, y, z, rangeSquared); + return resolveLinearResult(x, y, z, range, remainingQueries, matchingViewer); + } + + private boolean resolveLinearResult(double x, double y, double z, double range, + int remainingQueries, int matchingViewer) { + if (matchingViewer != 0) { + if (boundsActive) { + boundsActive = false; + } + if (matchingViewer >= MINIMUM_DEEP_SCAN_VIEWERS) { + expensiveQueries++; + if (expensiveQueries >= EXPENSIVE_QUERIES_BEFORE_GRID + && (viewerGrid != null || remainingQueries >= MINIMUM_REMAINING_QUERIES)) { + activateGridIfBeneficial(x, z, range, remainingQueries, + matchingViewer, matchingViewer - 1, true); + } + } else if (expensiveQueries != 0 || persistentGridHits) { + expensiveQueries = 0; + persistentGridHits = false; + } + return true; + } + initializeBounds(); + if (isOutsideBounds(x, y, z, range)) { + boundsActive = true; + return false; + } + boundsActive = false; + expensiveQueries++; + if (expensiveQueries >= EXPENSIVE_QUERIES_BEFORE_GRID + && (viewerGrid != null || remainingQueries >= MINIMUM_REMAINING_QUERIES)) { + activateGridIfBeneficial(x, z, range, remainingQueries, size, -1, false); + } + return false; + } + + private int firstViewerWithin(double x, double y, double z, double rangeSquared) { + double[] xCoordinates = this.xCoordinates; + double[] yCoordinates = this.yCoordinates; + double[] zCoordinates = this.zCoordinates; + int viewerCount = size; + for (int index = 0; index < viewerCount; index++) { + double deltaX = xCoordinates[index] - x; + double deltaY = yCoordinates[index] - y; + double deltaZ = zCoordinates[index] - z; + if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= rangeSquared) { + return index + 1; + } + } + return 0; + } + + private boolean hasViewerWithinGrid(double x, double y, double z, double rangeSquared, + ViewerCellWindow window) { + int cellX = window.minimumX(); + while (true) { + int cellZ = window.minimumZ(); + while (true) { + List points = viewerGrid.get(new ViewerGridCell(cellX, cellZ)); + if (points != null) { + for (Point point : points) { + double deltaX = point.x() - x; + double deltaY = point.y() - y; + double deltaZ = point.z() - z; + if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= rangeSquared) { + return true; + } + } + } + if (cellZ == window.maximumZ()) { + break; + } + cellZ++; + } + if (cellX == window.maximumX()) { + break; + } + cellX++; + } + return false; + } + + private void activateGridIfBeneficial(double x, double z, double range, + int remainingQueries, int scannedViewers, + int matchingViewerIndex, boolean persistOnHits) { + if (expensiveQueries < EXPENSIVE_QUERIES_BEFORE_GRID + || (viewerGrid == null && remainingQueries < MINIMUM_REMAINING_QUERIES)) { + return; + } + ViewerCellWindow window = gridWindow(x, z, range); + if (window == null || !gridLikelyCheaper( + window, scannedViewers, matchingViewerIndex, remainingQueries)) { + expensiveQueries = EXPENSIVE_QUERIES_BEFORE_GRID - GRID_LINEAR_PROBE_INTERVAL; + return; + } + if (viewerGrid == null) { + buildGrid(); + } + useGrid = true; + persistentGridHits = persistOnHits; + expensiveQueries = 0; + gridQueriesUntilProbe = GRID_LINEAR_PROBE_INTERVAL; + } + + private boolean gridLikelyCheaper(ViewerCellWindow window, int scannedViewers, + int matchingViewerIndex, int remainingQueries) { + long finalCell = window.cellCount(); + if (matchingViewerIndex >= 0) { + long matchingCell = window.ordinal(viewerCoordinate(xCoordinates[matchingViewerIndex]), + viewerCoordinate(zCoordinates[matchingViewerIndex])); + if (matchingCell > 0L) { + finalCell = matchingCell; + } + } + long cellCost = finalCell * CELL_LOOKUP_EQUIVALENT_VIEWERS; + if (cellCost >= scannedViewers) { + return false; + } + long maximumVisitedViewers = scannedViewers - cellCost - 1L; + int visitedViewers = 0; + for (int index = 0; index < size; index++) { + long ordinal = window.ordinal(viewerCoordinate(xCoordinates[index]), + viewerCoordinate(zCoordinates[index])); + if (ordinal > 0L && (ordinal < finalCell + || ordinal == finalCell && (matchingViewerIndex < 0 || index <= matchingViewerIndex))) { + visitedViewers++; + if (visitedViewers > maximumVisitedViewers) { + return false; + } + } + } + long perQuerySaving = scannedViewers - cellCost - visitedViewers; + long futureQueries = Math.max(0L, remainingQueries); + long linearProbeCost = ((futureQueries + GRID_LINEAR_PROBE_INTERVAL - 1L) + / GRID_LINEAR_PROBE_INTERVAL) * scannedViewers; + long totalSaving = perQuerySaving * futureQueries - linearProbeCost; + long buildCost = viewerGrid == null + ? (long) size * GRID_BUILD_EQUIVALENT_VIEWERS_PER_VIEWER : 0L; + return totalSaving > buildCost; + } + + private ViewerCellWindow gridWindow(double x, double z, double range) { + if (!Double.isFinite(x) || !Double.isFinite(z) + || !Double.isFinite(range) || range < 0.0D) { + return null; + } + double minimumCellXValue = Math.floor((x - range) / GRID_CELL_SIZE); + double maximumCellXValue = Math.floor((x + range) / GRID_CELL_SIZE); + double minimumCellZValue = Math.floor((z - range) / GRID_CELL_SIZE); + double maximumCellZValue = Math.floor((z + range) / GRID_CELL_SIZE); + if (!isIntCell(minimumCellXValue) || !isIntCell(maximumCellXValue) + || !isIntCell(minimumCellZValue) || !isIntCell(maximumCellZValue)) { + return null; + } + int minimumCellX = (int) minimumCellXValue; + int maximumCellX = (int) maximumCellXValue; + int minimumCellZ = (int) minimumCellZValue; + int maximumCellZ = (int) maximumCellZValue; + long queriedCellsX = (long) maximumCellX - minimumCellX + 1L; + long queriedCellsZ = (long) maximumCellZ - minimumCellZ + 1L; + long maximumQueriedCells = size; + if (queriedCellsX <= 0L || queriedCellsZ <= 0L || maximumQueriedCells <= 0L + || queriedCellsX > maximumQueriedCells + || queriedCellsZ > maximumQueriedCells + || queriedCellsX > maximumQueriedCells / queriedCellsZ) { + return null; + } + return new ViewerCellWindow(minimumCellX, maximumCellX, minimumCellZ, maximumCellZ); + } + + private void initializeBounds() { + if (boundsInitialized) { + return; + } + double nextMinimumX = Double.POSITIVE_INFINITY; + double nextMinimumY = Double.POSITIVE_INFINITY; + double nextMinimumZ = Double.POSITIVE_INFINITY; + double nextMaximumX = Double.NEGATIVE_INFINITY; + double nextMaximumY = Double.NEGATIVE_INFINITY; + double nextMaximumZ = Double.NEGATIVE_INFINITY; + for (int index = 0; index < size; index++) { + nextMinimumX = Math.min(nextMinimumX, xCoordinates[index]); + nextMinimumY = Math.min(nextMinimumY, yCoordinates[index]); + nextMinimumZ = Math.min(nextMinimumZ, zCoordinates[index]); + nextMaximumX = Math.max(nextMaximumX, xCoordinates[index]); + nextMaximumY = Math.max(nextMaximumY, yCoordinates[index]); + nextMaximumZ = Math.max(nextMaximumZ, zCoordinates[index]); + } + minimumX = nextMinimumX; + minimumY = nextMinimumY; + minimumZ = nextMinimumZ; + maximumX = nextMaximumX; + maximumY = nextMaximumY; + maximumZ = nextMaximumZ; + boundsInitialized = true; + } + + private void buildGrid() { + Map> grid = new HashMap<>(viewerGridCapacity(size)); + boolean calculateBounds = !boundsInitialized; + double nextMinimumX = Double.POSITIVE_INFINITY; + double nextMinimumY = Double.POSITIVE_INFINITY; + double nextMinimumZ = Double.POSITIVE_INFINITY; + double nextMaximumX = Double.NEGATIVE_INFINITY; + double nextMaximumY = Double.NEGATIVE_INFINITY; + double nextMaximumZ = Double.NEGATIVE_INFINITY; + for (int index = 0; index < size; index++) { + ViewerGridCell cell = new ViewerGridCell(viewerCoordinate(xCoordinates[index]), + viewerCoordinate(zCoordinates[index])); + grid.computeIfAbsent(cell, ignored -> new ArrayList<>()) + .add(new Point(xCoordinates[index], yCoordinates[index], zCoordinates[index])); + if (calculateBounds) { + nextMinimumX = Math.min(nextMinimumX, xCoordinates[index]); + nextMinimumY = Math.min(nextMinimumY, yCoordinates[index]); + nextMinimumZ = Math.min(nextMinimumZ, zCoordinates[index]); + nextMaximumX = Math.max(nextMaximumX, xCoordinates[index]); + nextMaximumY = Math.max(nextMaximumY, yCoordinates[index]); + nextMaximumZ = Math.max(nextMaximumZ, zCoordinates[index]); + } + } + if (calculateBounds) { + minimumX = nextMinimumX; + minimumY = nextMinimumY; + minimumZ = nextMinimumZ; + maximumX = nextMaximumX; + maximumY = nextMaximumY; + maximumZ = nextMaximumZ; + boundsInitialized = true; + } + viewerGrid = grid; + } + + private void resetAdaptiveState() { + viewerGrid = null; + boundsInitialized = false; + boundsActive = false; + useGrid = false; + persistentGridHits = false; + expensiveQueries = 0; + gridQueriesUntilProbe = 0; + minimumX = Double.POSITIVE_INFINITY; + minimumY = Double.POSITIVE_INFINITY; + minimumZ = Double.POSITIVE_INFINITY; + maximumX = Double.NEGATIVE_INFINITY; + maximumY = Double.NEGATIVE_INFINITY; + maximumZ = Double.NEGATIVE_INFINITY; + } + + private static int viewerCoordinate(double coordinate) { + return (int) Math.floor(coordinate / GRID_CELL_SIZE); + } + + private static boolean isIntCell(double value) { + return Double.isFinite(value) && value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE; + } + + private static int viewerGridCapacity(int viewers) { + return viewers < (1 << 29) ? Math.max(16, (int) (viewers / 0.75F) + 1) : 1 << 30; + } + + private static int grownCapacity(int size) { + if (size >= MAX_ARRAY_SIZE) { + throw new OutOfMemoryError("Too many viewers"); + } + return size > MAX_ARRAY_SIZE / 2 + ? MAX_ARRAY_SIZE + : Math.max(INITIAL_CAPACITY, size << 1); + } + + private static double[] grow(double[] source, int capacity) { + double[] expanded = new double[capacity]; + System.arraycopy(source, 0, expanded, 0, source.length); + return expanded; + } + } + } + + private record Cell(UUID worldId, int x, int y, int z) { + } + + private record ViewerGridCell(int x, int z) { + } + + private record ViewerCellWindow(int minimumX, int maximumX, int minimumZ, int maximumZ) { + + private long widthZ() { + return (long) maximumZ - minimumZ + 1L; + } + + private long cellCount() { + return ((long) maximumX - minimumX + 1L) * widthZ(); + } + + private long ordinal(int x, int z) { + if (x < minimumX || x > maximumX || z < minimumZ || z > maximumZ) { + return -1L; + } + return ((long) x - minimumX) * widthZ() + (long) z - minimumZ + 1L; + } + } + + private record Point(double x, double y, double z) { + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicy.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicy.java new file mode 100644 index 00000000..c9d987e0 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicy.java @@ -0,0 +1,79 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +/** + * Validated configuration for the opt-in dropped-item visibility controls. + * Disabled culling preserves the legacy server-side label lifecycle; + * rate limiting is independently opt-in. + */ +record DroppedItemVisibilityPolicy( + int viewDistance, + boolean rateLimitEnabled, + int bucketSize, + int restorePerTick +) { + + static final int DEFAULT_BUCKET_SIZE = 128; + static final int DEFAULT_RESTORE_PER_TICK = 32; + static final int DEFAULT_VIEW_DISTANCE = 64; + private static final int MIN_VIEW_DISTANCE = 8; + private static final int MAX_VIEW_DISTANCE = 512; + + static DroppedItemVisibilityPolicy create(boolean cullingEnabled, + int configuredViewDistance, + boolean rateLimitEnabled, + int configuredBucketSize, + int configuredRestorePerTick) { + int requestedViewDistance = configuredViewDistance > 0 + ? configuredViewDistance + : DEFAULT_VIEW_DISTANCE; + int viewDistance = cullingEnabled + ? Math.max(MIN_VIEW_DISTANCE, Math.min(MAX_VIEW_DISTANCE, requestedViewDistance)) + : 0; + int bucketSize = configuredBucketSize > 0 + ? configuredBucketSize + : DEFAULT_BUCKET_SIZE; + int restorePerTick = configuredRestorePerTick > 0 + ? configuredRestorePerTick + : DEFAULT_RESTORE_PER_TICK; + return new DroppedItemVisibilityPolicy( + viewDistance, rateLimitEnabled, bucketSize, restorePerTick); + } + + static DroppedItemVisibilityPolicy legacyDefaults() { + return create(false, DEFAULT_VIEW_DISTANCE, false, + DEFAULT_BUCKET_SIZE, DEFAULT_RESTORE_PER_TICK); + } + + boolean cullingEnabled() { + return viewDistance > 0; + } + + boolean controlsPerViewerVisibility() { + return cullingEnabled() || rateLimitEnabled; + } + + int effectiveViewDistance(int trackingDistance) { + if (!cullingEnabled()) { + throw new IllegalStateException("View-distance culling is disabled"); + } + return Math.min(viewDistance, Math.max(1, trackingDistance)); + } + + float labelViewRange() { + if (!cullingEnabled()) { + return 1.0F; + } + return (float) Math.max(0.125D, Math.min(8.0D, viewDistance / 64.0D)); + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java new file mode 100644 index 00000000..92cd10ca --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java @@ -0,0 +1,82 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; + +/** Deduplicated token bucket used to smooth client visibility bursts. */ +final class VisibilityTokenBucket { + + private final Deque pending = new ArrayDeque<>(); + private final Set queued = new HashSet<>(); + private int tokens; + + VisibilityTokenBucket(int initialTokens) { + this.tokens = Math.max(0, initialTokens); + } + + void request(T value) { + if (queued.add(value)) { + pending.addLast(value); + } + } + + void cancel(T value) { + queued.remove(value); + } + + List drain(int capacity, int refill, Predicate stillWanted) { + int safeCapacity = Math.max(1, capacity); + long replenished = (long) tokens + Math.max(0, refill); + tokens = (int) Math.min(safeCapacity, replenished); + if (tokens == 0 || pending.isEmpty()) { + return List.of(); + } + + List ready = new ArrayList<>(Math.min(tokens, pending.size())); + while (tokens > 0 && !pending.isEmpty()) { + T value = pending.removeFirst(); + if (!queued.remove(value) || !stillWanted.test(value)) { + continue; + } + ready.add(value); + tokens--; + } + return ready; + } + + List drainAll(Predicate stillWanted) { + if (pending.isEmpty()) { + return List.of(); + } + + List ready = new ArrayList<>(pending.size()); + while (!pending.isEmpty()) { + T value = pending.removeFirst(); + if (queued.remove(value) && stillWanted.test(value)) { + ready.add(value); + } + } + return ready; + } + + void clear() { + pending.clear(); + queued.clear(); + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridge.java b/common/src/main/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridge.java new file mode 100644 index 00000000..85e7984b --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridge.java @@ -0,0 +1,403 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration.packet; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; +import net.momirealms.sparrow.heart.util.SelfIncreaseEntityID; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Player; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** + * Isolated Paper 26.1/26.2 packet bridge for a client-only text display. + * + *

The entity never enters a world or Paper's entity tracker. Its initial + * add-entity and complete render metadata packets are sent in one bundle, so + * the client cannot render a frame with vanilla text-display defaults.

+ */ +public final class ClientTextDisplayBridge { + + private static final GsonComponentSerializer COMPONENT_SERIALIZER = GsonComponentSerializer.gson(); + + private final int entityId; + private final UUID uuid; + + private ClientTextDisplayBridge(int entityId, UUID uuid) { + this.entityId = entityId; + this.uuid = uuid; + } + + /** + * Resolves the runtime packet surface without creating an entity. + * + * @return whether this Paper runtime exposes the expected 26.1/26.2 surface + */ + public static boolean initialize() { + return Holder.RESOLUTION.handles() != null; + } + + /** + * Returns the linkage failure captured while probing this runtime. + */ + public static Throwable initializationFailure() { + return Holder.RESOLUTION.failure(); + } + + /** + * Creates a client-only identity using Sparrow Heart's high, process-wide + * entity ID range. Synchronization closes the race in Sparrow's incrementer + * when displays are allocated concurrently. + */ + public static ClientTextDisplayBridge create() { + requireHandles(); + int entityId; + synchronized (SelfIncreaseEntityID.class) { + entityId = SelfIncreaseEntityID.getAndIncrease(); + } + return new ClientTextDisplayBridge(entityId, UUID.randomUUID()); + } + + public int entityId() { + return entityId; + } + + /** + * Spawns this client-only text display for one viewer. + */ + public void spawn(Player viewer, Location location, Component text) { + Objects.requireNonNull(location, "location"); + Handles handles = checkedHandles(viewer); + try { + Object addEntityPacket = handles.addEntityPacketConstructor().newInstance( + entityId, + uuid, + location.getX(), + location.getY(), + location.getZ(), + location.getPitch(), + location.getYaw(), + handles.textDisplayEntityType(), + 0, + handles.zeroMovement(), + (double) location.getYaw()); + Object metadataPacket = createMetadataPacket(handles, text); + Object bundlePacket = handles.bundlePacketConstructor().newInstance( + List.of(addEntityPacket, metadataPacket)); + send(handles, viewer, bundlePacket, "spawn the client text display"); + } catch (ReflectiveOperationException exception) { + throw operationFailure("spawn the client text display", exception); + } + } + + /** + * Re-sends the complete metadata profile with updated text. Reasserting the + * full profile also makes this safe after client resource reloads. + */ + public void updateMetaData(Player viewer, Component text) { + Handles handles = checkedHandles(viewer); + try { + send(handles, viewer, createMetadataPacket(handles, text), + "update the client text display metadata"); + } catch (ReflectiveOperationException exception) { + throw operationFailure("update the client text display metadata", exception); + } + } + + /** + * Moves this client-only entity with the same absolute teleport payload as + * Sparrow Heart, without wrapping the single packet in bundle delimiters. + */ + public void teleport(Player viewer, Location location) { + Objects.requireNonNull(location, "location"); + Handles handles = checkedHandles(viewer); + try { + Object position = handles.vec3Constructor().newInstance( + location.getX(), location.getY(), location.getZ()); + Object change = handles.positionMoveRotationConstructor().newInstance( + position, + handles.zeroMovement(), + location.getYaw(), + location.getPitch()); + Object packet = handles.teleportEntityPacketConstructor().newInstance( + entityId, change, Set.of(), false); + send(handles, viewer, packet, "teleport the client text display"); + } catch (ReflectiveOperationException exception) { + throw operationFailure("teleport the client text display", exception); + } + } + + /** + * Removes this client-only entity for one viewer. + */ + public void destroy(Player viewer) { + Handles handles = checkedHandles(viewer); + try { + Object packet = handles.removeEntitiesPacketConstructor().newInstance( + (Object) new int[]{entityId}); + send(handles, viewer, packet, "destroy the client text display"); + } catch (ReflectiveOperationException exception) { + throw operationFailure("destroy the client text display", exception); + } + } + + private Object createMetadataPacket(Handles handles, Component text) + throws ReflectiveOperationException { + Objects.requireNonNull(text, "text"); + String json = COMPONENT_SERIALIZER.serialize(text); + Object vanillaComponent = handles.componentFromJson().invoke(null, json); + if (vanillaComponent == null) { + throw new IllegalStateException("CraftChatMessage.fromJSON returned null"); + } + + List values = new ArrayList<>(handles.renderMetadata().size() + 1); + values.addAll(handles.renderMetadata()); + values.add(dataValue(handles, handles.textAccessor(), vanillaComponent)); + return handles.metadataPacketConstructor().newInstance(entityId, values); + } + + private static Object dataValue(Handles handles, Object accessor, Object value) + throws ReflectiveOperationException { + return handles.dataValueCreate().invoke(null, accessor, value); + } + + private static void send(Handles handles, Player viewer, Object packet, String operation) + throws ReflectiveOperationException { + try { + Object serverPlayer = handles.getHandle().invoke(viewer); + Object connection = handles.connection().get(serverPlayer); + handles.sendPacket().invoke(connection, packet); + } catch (InvocationTargetException exception) { + throw operationFailure(operation, exception); + } + } + + private static Handles checkedHandles(Player viewer) { + Objects.requireNonNull(viewer, "viewer"); + Handles handles = requireHandles(); + if (!handles.craftPlayerClass().isInstance(viewer)) { + throw new IllegalArgumentException("Unsupported Player implementation: " + viewer.getClass().getName()); + } + return handles; + } + + private static Handles requireHandles() { + Handles handles = Holder.RESOLUTION.handles(); + if (handles == null) { + throw new IllegalStateException("The client text display packet bridge is unavailable", + Holder.RESOLUTION.failure()); + } + return handles; + } + + private static IllegalStateException operationFailure(String operation, ReflectiveOperationException exception) { + Throwable cause = exception instanceof InvocationTargetException invocationTargetException + ? invocationTargetException.getCause() + : exception; + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (cause instanceof LinkageError linkageError) { + return new IllegalStateException("The client text display packet linkage failed while trying to " + + operation, linkageError); + } + if (cause instanceof Error error) { + throw error; + } + return new IllegalStateException("Failed to " + operation, cause); + } + + private static Resolution resolve() { + try { + ClassLoader serverLoader = Bukkit.getServer().getClass().getClassLoader(); + + Class craftPlayerClass = Class.forName( + "org.bukkit.craftbukkit.entity.CraftPlayer", false, serverLoader); + Method getHandle = craftPlayerClass.getMethod("getHandle"); + Field connection = getHandle.getReturnType().getField("connection"); + + Class packetClass = Class.forName( + "net.minecraft.network.protocol.Packet", false, serverLoader); + Method sendPacket = connection.getType().getMethod("send", packetClass); + + Class entityTypeClass = Class.forName( + "net.minecraft.world.entity.EntityType", false, serverLoader); + Object textDisplayEntityType = entityTypeClass.getField("TEXT_DISPLAY").get(null); + Class vec3Class = Class.forName( + "net.minecraft.world.phys.Vec3", false, serverLoader); + Constructor vec3Constructor = vec3Class.getConstructor( + double.class, double.class, double.class); + Object zeroMovement = vec3Class.getField("ZERO").get(null); + + Class positionMoveRotationClass = Class.forName( + "net.minecraft.world.entity.PositionMoveRotation", false, serverLoader); + Constructor positionMoveRotationConstructor = positionMoveRotationClass.getConstructor( + vec3Class, vec3Class, float.class, float.class); + Class teleportEntityPacketClass = Class.forName( + "net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket", false, serverLoader); + Constructor teleportEntityPacketConstructor = teleportEntityPacketClass.getConstructor( + int.class, positionMoveRotationClass, Set.class, boolean.class); + + Class addEntityPacketClass = Class.forName( + "net.minecraft.network.protocol.game.ClientboundAddEntityPacket", false, serverLoader); + Constructor addEntityPacketConstructor = addEntityPacketClass.getConstructor( + int.class, UUID.class, double.class, double.class, double.class, + float.class, float.class, entityTypeClass, int.class, vec3Class, double.class); + + Class metadataPacketClass = Class.forName( + "net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket", false, serverLoader); + Constructor metadataPacketConstructor = metadataPacketClass.getConstructor(int.class, List.class); + Class bundlePacketClass = Class.forName( + "net.minecraft.network.protocol.game.ClientboundBundlePacket", false, serverLoader); + Constructor bundlePacketConstructor = bundlePacketClass.getConstructor(Iterable.class); + Class removePacketClass = Class.forName( + "net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket", false, serverLoader); + Constructor removeEntitiesPacketConstructor = removePacketClass.getConstructor(int[].class); + + Class accessorClass = Class.forName( + "net.minecraft.network.syncher.EntityDataAccessor", false, serverLoader); + Class dataValueClass = Class.forName( + "net.minecraft.network.syncher.SynchedEntityData$DataValue", false, serverLoader); + Method dataValueCreate = dataValueClass.getMethod("create", accessorClass, Object.class); + + Class displayClass = Class.forName( + "net.minecraft.world.entity.Display", false, serverLoader); + Class textDisplayClass = Class.forName( + "net.minecraft.world.entity.Display$TextDisplay", false, serverLoader); + Object positionInterpolationAccessor = accessor( + displayClass, "DATA_POS_ROT_INTERPOLATION_DURATION_ID", accessorClass); + Object translationAccessor = accessor(displayClass, "DATA_TRANSLATION_ID", accessorClass); + Object scaleAccessor = accessor(displayClass, "DATA_SCALE_ID", accessorClass); + Object billboardAccessor = accessor( + displayClass, "DATA_BILLBOARD_RENDER_CONSTRAINTS_ID", accessorClass); + Object textAccessor = accessor(textDisplayClass, "DATA_TEXT_ID", accessorClass); + Object lineWidthAccessor = accessor(textDisplayClass, "DATA_LINE_WIDTH_ID", accessorClass); + Object backgroundAccessor = accessor( + textDisplayClass, "DATA_BACKGROUND_COLOR_ID", accessorClass); + Object opacityAccessor = accessor(textDisplayClass, "DATA_TEXT_OPACITY_ID", accessorClass); + Object styleFlagsAccessor = accessor(textDisplayClass, "DATA_STYLE_FLAGS_ID", accessorClass); + + Class vector3fClass = Class.forName("org.joml.Vector3f", false, serverLoader); + Constructor vector3fConstructor = vector3fClass.getConstructor( + float.class, float.class, float.class); + + Class billboardClass = Class.forName( + "net.minecraft.world.entity.Display$BillboardConstraints", false, serverLoader); + Object center = billboardClass.getField("CENTER").get(null); + Field billboardId = billboardClass.getDeclaredField("id"); + makeAccessible(billboardId); + byte centerId = ((Number) billboardId.get(center)).byteValue(); + byte defaultBackground = ((Number) textDisplayClass + .getField("FLAG_USE_DEFAULT_BACKGROUND").get(null)).byteValue(); + + Class craftChatMessageClass = Class.forName( + "org.bukkit.craftbukkit.util.CraftChatMessage", false, serverLoader); + Method componentFromJson = craftChatMessageClass.getMethod("fromJSON", String.class); + + List renderMetadata = List.of( + dataValue(dataValueCreate, positionInterpolationAccessor, 3), + dataValue(dataValueCreate, translationAccessor, + vector3fConstructor.newInstance(-0.025F, -0.225F, 0.0F)), + dataValue(dataValueCreate, scaleAccessor, + vector3fConstructor.newInstance(1.0F, 1.0F, 1.0F)), + dataValue(dataValueCreate, billboardAccessor, centerId), + dataValue(dataValueCreate, lineWidthAccessor, Integer.MAX_VALUE), + dataValue(dataValueCreate, backgroundAccessor, 0), + dataValue(dataValueCreate, opacityAccessor, (byte) -1), + dataValue(dataValueCreate, styleFlagsAccessor, defaultBackground)); + + return new Resolution(new Handles( + craftPlayerClass, + getHandle, + connection, + sendPacket, + addEntityPacketConstructor, + metadataPacketConstructor, + bundlePacketConstructor, + removeEntitiesPacketConstructor, + dataValueCreate, + componentFromJson, + textDisplayEntityType, + vec3Constructor, + zeroMovement, + positionMoveRotationConstructor, + teleportEntityPacketConstructor, + textAccessor, + renderMetadata), null); + } catch (ReflectiveOperationException | RuntimeException | LinkageError exception) { + return new Resolution(null, exception); + } + } + + private static Object accessor(Class owner, String name, Class accessorClass) + throws ReflectiveOperationException { + Field field = owner.getDeclaredField(name); + makeAccessible(field); + Object accessor = field.get(null); + if (!accessorClass.isInstance(accessor)) { + throw new IllegalStateException(owner.getName() + '.' + name + + " is not an EntityDataAccessor"); + } + return accessor; + } + + private static void makeAccessible(Field field) { + if (!field.trySetAccessible()) { + throw new IllegalStateException("Unable to access " + + field.getDeclaringClass().getName() + '.' + field.getName()); + } + } + + private static Object dataValue(Method create, Object accessor, Object value) + throws ReflectiveOperationException { + return create.invoke(null, accessor, value); + } + + private record Handles( + Class craftPlayerClass, + Method getHandle, + Field connection, + Method sendPacket, + Constructor addEntityPacketConstructor, + Constructor metadataPacketConstructor, + Constructor bundlePacketConstructor, + Constructor removeEntitiesPacketConstructor, + Method dataValueCreate, + Method componentFromJson, + Object textDisplayEntityType, + Constructor vec3Constructor, + Object zeroMovement, + Constructor positionMoveRotationConstructor, + Constructor teleportEntityPacketConstructor, + Object textAccessor, + List renderMetadata) { + } + + private record Resolution(Handles handles, Throwable failure) { + } + + private static final class Holder { + + private static final Resolution RESOLUTION = resolve(); + } + +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/listeners/Events.java b/common/src/main/java/com/loohp/interactionvisualizer/listeners/Events.java index f7cf4a44..75131067 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/listeners/Events.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/listeners/Events.java @@ -20,25 +20,108 @@ package com.loohp.interactionvisualizer.listeners; +import com.destroystokyo.paper.event.inventory.PrepareResultEvent; import com.loohp.interactionvisualizer.InteractionVisualizer; import com.loohp.interactionvisualizer.managers.TaskManager; +import io.papermc.paper.event.player.PlayerLoomPatternSelectEvent; +import io.papermc.paper.event.player.PlayerStonecutterRecipeSelectEvent; import org.bukkit.World; +import org.bukkit.entity.HumanEntity; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.event.inventory.PrepareItemCraftEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.world.WorldLoadEvent; import org.bukkit.event.world.WorldUnloadEvent; public class Events implements Listener { - @EventHandler(ignoreCancelled = true) + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onInventoryOpen(InventoryOpenEvent event) { - if (event.getPlayer() instanceof Player player) { + if (event.getPlayer() instanceof Player player + && TaskManager.hasInventoryDisplays(event.getInventory().getType())) { TaskManager.processOpenInventory(player); } } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryClick(InventoryClickEvent event) { + if (affectsTopInventory(event)) { + queueNativeInventoryRefresh(event.getWhoClicked(), event.getView().getTopInventory().getType()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryDrag(InventoryDragEvent event) { + if (affectsTopInventory(event)) { + queueNativeInventoryRefresh(event.getWhoClicked(), event.getView().getTopInventory().getType()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPrepareInventoryResult(PrepareResultEvent event) { + queueNativeInventoryRefresh(event.getView().getPlayer(), event.getInventory().getType()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPrepareItemCraft(PrepareItemCraftEvent event) { + queueNativeInventoryRefresh(event.getView().getPlayer(), event.getInventory().getType()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onStonecutterRecipeSelect(PlayerStonecutterRecipeSelectEvent event) { + queueNativeInventoryRefresh(event.getPlayer(), event.getStonecutterInventory().getType()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onLoomPatternSelect(PlayerLoomPatternSelectEvent event) { + queueNativeInventoryRefresh(event.getPlayer(), event.getLoomInventory().getType()); + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + TaskManager.clearPendingInventoryProcess(event.getPlayer().getUniqueId()); + } + + private static void queueNativeInventoryRefresh(HumanEntity entity, InventoryType type) { + if (entity instanceof Player player && TaskManager.hasNativeInventoryDisplays(type)) { + TaskManager.refreshOpenInventory(player); + } + } + + private static boolean affectsTopInventory(InventoryClickEvent event) { + return affectsTopInventory(event.getView().getTopInventory().getSize(), + event.getRawSlot(), event.getAction()); + } + + static boolean affectsTopInventory(int topSize, int rawSlot, InventoryAction action) { + if (rawSlot >= 0 && rawSlot < topSize) { + return true; + } + return action == InventoryAction.MOVE_TO_OTHER_INVENTORY + || action == InventoryAction.COLLECT_TO_CURSOR; + } + + private static boolean affectsTopInventory(InventoryDragEvent event) { + return affectsTopInventory(event.getView().getTopInventory().getSize(), event.getRawSlots()); + } + + static boolean affectsTopInventory(int topSize, Iterable rawSlots) { + for (int slot : rawSlots) { + if (slot >= 0 && slot < topSize) { + return true; + } + } + return false; + } + @EventHandler public void onWorldLoad(WorldLoadEvent event) { World world = event.getWorld(); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java index 48d39786..59b545e9 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java @@ -13,22 +13,30 @@ package com.loohp.interactionvisualizer.managers; import com.loohp.interactionvisualizer.InteractionVisualizer; +import com.loohp.interactionvisualizer.entityholders.BillboardDisplayEntity; import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.DynamicVisualizerEntity.PathType; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.entityholders.ItemFrame; import com.loohp.interactionvisualizer.entityholders.VisualizerEntity; import com.loohp.interactionvisualizer.integration.packet.ClientPickupAnimationBridge; +import com.loohp.interactionvisualizer.integration.packet.ClientTextDisplayBridge; +import com.loohp.interactionvisualizer.objectholders.SynchronizedFilteredCollection; import com.loohp.interactionvisualizer.utils.DisplayTransformFactory; +import io.papermc.paper.event.packet.PlayerChunkLoadEvent; +import io.papermc.paper.event.packet.PlayerChunkUnloadEvent; import io.papermc.paper.event.player.PlayerTrackEntityEvent; import io.papermc.paper.event.player.PlayerUntrackEntityEvent; import net.kyori.adventure.text.Component; import net.momirealms.sparrow.heart.SparrowHeart; import org.bukkit.Bukkit; +import org.bukkit.Chunk; import org.bukkit.Color; import org.bukkit.Location; import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.entity.Display; +import org.bukkit.entity.Entity; import org.bukkit.entity.ItemDisplay; import org.bukkit.entity.Player; import org.bukkit.entity.TextDisplay; @@ -38,27 +46,36 @@ import org.bukkit.event.entity.EntityRemoveEvent; import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.vehicle.VehicleMoveEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.inventory.ItemStack; import org.bukkit.persistence.PersistentDataType; import org.bukkit.plugin.Plugin; import org.bukkit.util.Vector; +import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; import java.util.logging.Level; /** * Main-thread Paper entity renderer. * - *

Paper's native tracker owns range and chunk lifecycle. Logical items use - * empty display anchors plus Sparrow's client-side {@code ITEM} packets, so - * the client keeps vanilla bob/spin rendering without server item physics.

+ *

Paper's native tracker owns ordinary displays and animated/name-bearing + * items. Legacy name tags and eligible static items instead follow each + * player's sent-chunk lifecycle as packet-only entities, avoiding server + * entity ticks while retaining the original per-viewer visual behavior.

*/ public final class DisplayManager implements Listener { @@ -67,6 +84,8 @@ public final class DisplayManager implements Listener { private static final double ITEM_HORIZONTAL_DRAG_PER_TICK = 0.98F; private static final double ITEM_VERTICAL_DRAG_PER_TICK = 0.98; private static final double ITEM_VOID_MARGIN = 64.0; + private static final double DYNAMIC_POSITION_EPSILON = 1.0E-12; + private static final int VIRTUAL_REMOVE_BATCH_SIZE = 256; public static final Map> active = new ConcurrentHashMap<>(); public static final Map> playerStatus = new ConcurrentHashMap<>(); @@ -74,6 +93,7 @@ public final class DisplayManager implements Listener { private static final Map renderedRevision = new ConcurrentHashMap<>(); private static final Map> shownViewers = new ConcurrentHashMap<>(); private static final Set scheduled = ConcurrentHashMap.newKeySet(); + private static final Set forceScheduled = ConcurrentHashMap.newKeySet(); private static final Map logicalByActualUuid = new ConcurrentHashMap<>(); private static final Map actualUuidByLogical = new ConcurrentHashMap<>(); private static final Map> logicalsByChunk = new ConcurrentHashMap<>(); @@ -81,7 +101,18 @@ public final class DisplayManager implements Listener { private static final Map itemAnimations = new ConcurrentHashMap<>(); private static final Map> virtualItemIds = new ConcurrentHashMap<>(); private static final Map renderedItemStacks = new ConcurrentHashMap<>(); + private static final Map renderedItemLocations = new ConcurrentHashMap<>(); + private static final Set packetOnlyItems = ConcurrentHashMap.newKeySet(); + private static final Set fixedItemDisplays = ConcurrentHashMap.newKeySet(); + private static final Map virtualTextDisplays = new ConcurrentHashMap<>(); + private static final Map> dynamicViewerPositions = new ConcurrentHashMap<>(); + private static final Set dirtyDynamicViewers = ConcurrentHashMap.newKeySet(); + private static final Map> visibilityShowQueues = new ConcurrentHashMap<>(); private static boolean itemAnimationTickScheduled; + private static boolean dynamicViewerTickScheduled; + private static boolean dynamicTeleportFailureLogged; + private static boolean virtualTextAvailable; + private static boolean visibilityTickScheduled; public DisplayManager() { } @@ -104,15 +135,55 @@ public static void run() { "Vanilla client pickup animations are unavailable; affected items will be removed immediately", ClientPickupAnimationBridge.initializationFailure()); } + virtualTextAvailable = ClientTextDisplayBridge.initialize(); + if (!virtualTextAvailable) { + throw new IllegalStateException( + "This Paper runtime cannot provide exact packet-only legacy text displays", + ClientTextDisplayBridge.initializationFailure()); + } runSync(() -> removeOwnedEntities(false)); } /** One-shot liveness and viewer reconciliation, used after configuration reloads. */ public static void update() { runSync(() -> { + // Active viewer collections are live filtered views. A reload can + // therefore invalidate requests that have not reached shownViewers + // yet; clear them once and rebuild from the new desired sets below. + for (VisibilityShowQueue queue : visibilityShowQueues.values()) { + queue.clear(); + } for (VisualizerEntity logical : new HashSet<>(active.keySet())) { index(logical); - if (logical.getBukkitEntity().isEmpty() && requiresActualEntity(logical)) { + if (logical instanceof Item item) { + // These experiment flags are not part of the logical entity's + // cache code. Force a sync only when a reload actually changes + // the representation; otherwise avoid a full item teleport burst. + ItemAnimationState animation = itemAnimations.get(item); + boolean desiredStaticAnchor = useStaticAnchorForAnimation( + InteractionVisualizer.staticVirtualItemAnchorsDuringAnimation, + item.isCustomNameVisible()); + boolean packetOnly = qualifiesForPacketOnlyStatic(item); + boolean representationChanged = fixedItemDisplays.contains(item) != item.isFixedDisplay() + || packetOnlyItems.contains(item) != packetOnly + || animation != null && animation.staticAnchor != desiredStaticAnchor; + boolean missingRequiredAnchor = requiresActualEntity(item) + && item.getBukkitEntity().isEmpty(); + Location location = item.getLocation(); + boolean chunkLoaded = location.getWorld().isChunkLoaded( + location.getBlockX() >> 4, location.getBlockZ() >> 4); + if ((representationChanged || missingRequiredAnchor) && (packetOnly || chunkLoaded)) { + schedule(logical, true); + } else { + // If packet-only was disabled while the chunk is absent, + // hide its old fake viewers now; ChunkLoadEvent will create + // the real anchor without synchronously loading the chunk. + reconcileViewers(logical); + } + } else if (logical instanceof DisplayEntity display && usesVirtualText(display) + && !virtualTextDisplays.containsKey(display)) { + schedule(logical, true); + } else if (logical.getBukkitEntity().isEmpty() && requiresActualEntity(logical)) { Location location = logical.getLocation(); if (location.getWorld().isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) { schedule(logical, true); @@ -121,16 +192,26 @@ public static void update() { reconcileViewers(logical); } } + if (!InteractionVisualizer.visibilityRateLimiting) { + drainVisibilityShowQueues(); + } }); } - /** Billboard rotation is handled client-side by TextDisplay. */ + /** Retained API hook; viewer movement now drives exact path updates. */ public static void dynamicEntity() { } public static void shutdown() { Runnable cleanup = () -> { - for (VisualizerEntity logical : new HashSet<>(active.keySet())) { + // An asynchronous removal can leave active before its main-thread + // cleanup task runs. Include every representation index so disable + // and hot reload still destroy client-only IDs instead of leaving + // ghosts that no longer have bookkeeping. + Set cleanupCandidates = shutdownCleanupCandidates( + active.keySet(), shownViewers.keySet(), virtualTextDisplays.keySet(), + virtualItemIds.keySet(), actualUuidByLogical.keySet()); + for (VisualizerEntity logical : cleanupCandidates) { org.bukkit.entity.Entity actual = logical.getBukkitEntity().orElse(null); if (actual != null) { discardActual(logical, actual); @@ -143,6 +224,7 @@ public static void shutdown() { renderedRevision.clear(); shownViewers.clear(); scheduled.clear(); + forceScheduled.clear(); logicalByActualUuid.clear(); actualUuidByLogical.clear(); logicalsByChunk.clear(); @@ -150,7 +232,21 @@ public static void shutdown() { itemAnimations.clear(); virtualItemIds.clear(); renderedItemStacks.clear(); + renderedItemLocations.clear(); + packetOnlyItems.clear(); + fixedItemDisplays.clear(); + virtualTextDisplays.clear(); + dynamicViewerPositions.clear(); + dirtyDynamicViewers.clear(); + for (VisibilityShowQueue queue : visibilityShowQueues.values()) { + queue.clear(); + } + visibilityShowQueues.clear(); itemAnimationTickScheduled = false; + dynamicViewerTickScheduled = false; + dynamicTeleportFailureLogged = false; + virtualTextAvailable = false; + visibilityTickScheduled = false; playerStatus.clear(); removeOwnedEntities(true); }; @@ -161,6 +257,16 @@ public static void shutdown() { } } + @SafeVarargs + static Set shutdownCleanupCandidates( + Collection... candidateGroups) { + Set candidates = new HashSet<>(); + for (Collection group : candidateGroups) { + candidates.addAll(group); + } + return candidates; + } + public static void sendHandMovement(Collection viewers, Player player) { runSync(player::swingMainHand); } @@ -239,6 +345,10 @@ public static void collectItem(Item entity, Player collector) { if (entity == null || collector == null) { return; } + if (entity.isFixedDisplay()) { + remove(null, entity, true); + return; + } runSync(() -> collectVirtualItem(entity, collector)); } @@ -278,14 +388,26 @@ public static void reset(Player player) { public static void removeAll(Player player) { runSync(() -> { + clearVisibilityShowQueue(player.getUniqueId()); + VirtualItemIdBuffer virtualItemIdsToRemove = new VirtualItemIdBuffer(); for (Map.Entry> entry : shownViewers.entrySet()) { if (entry.getValue().remove(player.getUniqueId())) { - if (entry.getKey() instanceof Item item) { - removeVirtualItem(item, player.getUniqueId(), player); + if (entry.getKey() instanceof DisplayEntity display + && virtualTextDisplays.containsKey(display)) { + removeVirtualText(display, player.getUniqueId(), player); + } else if (entry.getKey() instanceof Item item) { + Integer id = forgetVirtualItem(item, player.getUniqueId()); + if (id != null) { + virtualItemIdsToRemove.add(id); + } } - entry.getKey().getBukkitEntity().ifPresent(actual -> player.hideEntity(plugin(), actual)); + entry.getKey().getBukkitEntity().ifPresent(actual -> { + PerformanceMetrics.bukkitHide(); + player.hideEntity(plugin(), actual); + }); } } + removeVirtualItems(player, virtualItemIdsToRemove); playerStatus.put(player, ConcurrentHashMap.newKeySet()); }); } @@ -294,8 +416,13 @@ public static void sendPlayerPackets(Player player) { runSync(() -> { playerStatus.putIfAbsent(player, ConcurrentHashMap.newKeySet()); for (VisualizerEntity logical : active.keySet()) { - if (logical.getBukkitEntity().isPresent()) { - reconcileViewers(logical); + boolean virtualTextReady = logical instanceof DisplayEntity display + && usesVirtualText(display) && virtualTextDisplays.containsKey(display); + if (logical.getBukkitEntity().isPresent() || packetOnlyItems.contains(logical) + || virtualTextReady) { + reconcileViewer(logical, player); + } else if (logical instanceof DisplayEntity display && usesVirtualText(display)) { + schedule(logical, true); } else if (requiresActualEntity(logical)) { schedule(logical, true); } @@ -307,13 +434,19 @@ private static void schedule(VisualizerEntity logical, boolean force) { if (logical == null || plugin() == null || !plugin().isEnabled()) { return; } + if (force) { + // A force request may arrive while a normal sync is already queued. + // Preserve the upgrade until whichever task observes it. + forceScheduled.add(logical); + } if (scheduled.add(logical)) { runSync(() -> { try { + boolean forceSync = forceScheduled.remove(logical); if (active.containsKey(logical)) { index(logical); int revision = logical.cacheCode(); - if (force || (requiresActualEntity(logical) && logical.getBukkitEntity().isEmpty()) + if (forceSync || (requiresActualEntity(logical) && logical.getBukkitEntity().isEmpty()) || renderedRevision.getOrDefault(logical, Integer.MIN_VALUE) != revision) { sync(logical, revision); } @@ -321,8 +454,8 @@ private static void schedule(VisualizerEntity logical, boolean force) { } finally { scheduled.remove(logical); } - if (active.containsKey(logical) - && renderedRevision.getOrDefault(logical, Integer.MIN_VALUE) != logical.cacheCode()) { + if (active.containsKey(logical) && (forceScheduled.contains(logical) + || renderedRevision.getOrDefault(logical, Integer.MIN_VALUE) != logical.cacheCode())) { schedule(logical, false); } }); @@ -342,6 +475,11 @@ private static void sync(VisualizerEntity logical, int revision) { } private static void syncDisplay(DisplayEntity logical) { + PerformanceMetrics.displaySync(); + if (usesVirtualText(logical)) { + syncVirtualTextDisplay(logical); + return; + } boolean text = logical.isTextDisplay(); org.bukkit.entity.Entity current = logical.getBukkitEntity().orElse(null); if (current != null && (!current.getWorld().equals(logical.getWorld()) @@ -362,9 +500,13 @@ private static void syncDisplay(DisplayEntity logical) { Display actual; if (current == null) { clearViewerTracking(logical); + Location spawnLocation = text + ? DisplayTransformFactory.textDisplayLocation(logical) + : logical.getLocation(); actual = text - ? logical.getWorld().spawn(logical.getLocation(), TextDisplay.class, DisplayManager::initializeDisplay) - : logical.getWorld().spawn(logical.getLocation(), org.bukkit.entity.ItemDisplay.class, DisplayManager::initializeDisplay); + ? logical.getWorld().spawn(spawnLocation, TextDisplay.class, DisplayManager::initializeDisplay) + : logical.getWorld().spawn(spawnLocation, org.bukkit.entity.ItemDisplay.class, DisplayManager::initializeDisplay); + PerformanceMetrics.bukkitEntitySpawn(); logical.bind(actual); trackActual(logical, actual); } else { @@ -373,22 +515,81 @@ private static void syncDisplay(DisplayEntity logical) { applyBase(actual, logical); if (actual instanceof TextDisplay textDisplay) { - textDisplay.text(logical.getCustomName() == null ? Component.empty() : logical.getCustomName()); - textDisplay.setLineWidth(200); - textDisplay.setAlignment(TextDisplay.TextAlignment.CENTER); - textDisplay.setDefaultBackground(false); - textDisplay.setBackgroundColor(Color.fromARGB(0)); - textDisplay.setShadowed(true); - textDisplay.setSeeThrough(false); - textDisplay.setTransformationMatrix(DisplayTransformFactory.text(logical)); + applyTextDisplay(textDisplay, logical); } else { org.bukkit.entity.ItemDisplay itemDisplay = (org.bukkit.entity.ItemDisplay) actual; itemDisplay.setItemStack(logical.getDisplayItem()); - itemDisplay.setItemDisplayTransform(logical.getItemDisplayTransform()); + itemDisplay.setItemDisplayTransform(DisplayTransformFactory.itemDisplayTransform(logical)); itemDisplay.setTransformationMatrix(DisplayTransformFactory.item(logical)); } } + private static void syncVirtualTextDisplay(DisplayEntity logical) { + org.bukkit.entity.Entity current = logical.getBukkitEntity().orElse(null); + if (current != null) { + discardActual(logical, current); + } else { + untrackActual(logical); + logical.unbind(); + } + + VirtualTextState state = virtualTextDisplays.get(logical); + if (state == null) { + state = new VirtualTextState(ClientTextDisplayBridge.create(), logical); + VirtualTextState raced = virtualTextDisplays.putIfAbsent(logical, state); + if (raced != null) { + state = raced; + } + } + + Component text = logical.getCustomName() == null ? Component.empty() : logical.getCustomName(); + boolean metadataChanged = !Objects.equals(state.text, text); + boolean positionChanged = state.positionProfileChanged(logical); + state.capture(logical, text); + index(logical); + + if (!shouldRender(logical)) { + clearViewerTracking(logical); + return; + } + + Set shown = shownViewers.get(logical); + if (shown == null || shown.isEmpty() || (!metadataChanged && !positionChanged)) { + return; + } + for (UUID viewer : new HashSet<>(shown)) { + Player player = Bukkit.getPlayer(viewer); + if (player == null || !player.isOnline()) { + forgetShownViewer(logical, viewer, player); + continue; + } + try { + if (metadataChanged) { + state.display.updateMetaData(player, text); + } + if (positionChanged) { + synchronizeVirtualTextPosition(logical, player, true); + } + } catch (RuntimeException | LinkageError exception) { + hideViewer(logical, viewer, player); + logVirtualTextFailure("update", logical, exception); + } + } + } + + static void applyTextDisplay(TextDisplay textDisplay, DisplayEntity logical) { + textDisplay.text(logical.getCustomName() == null ? Component.empty() : logical.getCustomName()); + textDisplay.setLineWidth(logical.usesUnboundedTextWidth() ? Integer.MAX_VALUE : 200); + textDisplay.setAlignment(TextDisplay.TextAlignment.CENTER); + textDisplay.setDefaultBackground(logical.isDefaultBackground()); + textDisplay.setBackgroundColor(Color.fromARGB(0)); + textDisplay.setShadowed(false); + // Preserve normal depth testing: floating text must be hidden by + // opaque blocks between it and the viewer. + textDisplay.setSeeThrough(false); + textDisplay.setTransformationMatrix(DisplayTransformFactory.text(logical)); + } + private static void initializeDisplay(Display display) { initializeEntity(display); display.setShadowRadius(0.0F); @@ -397,9 +598,12 @@ private static void initializeDisplay(Display display) { } private static void applyBase(Display actual, DisplayEntity logical) { - Location target = logical.getLocation(); + Location target = actual instanceof TextDisplay + ? DisplayTransformFactory.textDisplayLocation(logical) + : logical.getLocation(); if (!actual.getWorld().equals(target.getWorld()) || actual.getLocation().distanceSquared(target) > 1.0E-8 || actual.getYaw() != target.getYaw() || actual.getPitch() != target.getPitch()) { + PerformanceMetrics.bukkitEntityTeleport(); actual.teleport(target); } actual.setSilent(logical.isSilent()); @@ -411,8 +615,63 @@ private static void applyBase(Display actual, DisplayEntity logical) { } private static void syncItem(Item logical) { + PerformanceMetrics.itemSync(); + if (logical.isFixedDisplay()) { + syncFixedItem(logical); + return; + } + if (fixedItemDisplays.remove(logical)) { + org.bukkit.entity.Entity previous = logical.getBukkitEntity().orElse(null); + if (previous != null) { + discardActual(logical, previous); + } else { + untrackActual(logical); + logical.unbind(); + clearViewerTracking(logical); + } + } + boolean packetOnly = qualifiesForPacketOnlyStatic(logical); + boolean wasPacketOnly = packetOnlyItems.contains(logical); org.bukkit.entity.Entity current = logical.getBukkitEntity().orElse(null); - if (current != null && (!current.getWorld().equals(logical.getWorld()) + Location logicalLocation = logical.getLocation(); + + if (packetOnly) { + PerformanceMetrics.packetOnlyItemSync(); + ItemStack itemStack = logical.getItemStack(); + + if (current != null) { + discardActual(logical, current); + } else if (!wasPacketOnly) { + clearViewerTracking(logical); + logical.unbind(); + untrackActual(logical); + } + + packetOnlyItems.add(logical); + itemAnimations.remove(logical); + // Visualizer Item getters already return defensive copies. Retain those + // snapshots directly instead of cloning them a second time on every sync. + ItemStack previousItemStack = renderedItemStacks.put(logical, itemStack); + Location previousLocation = renderedItemLocations.put(logical, logicalLocation); + boolean itemChanged = previousItemStack == null || !previousItemStack.equals(itemStack); + boolean locationChanged = previousLocation == null || !previousLocation.equals(logicalLocation); + index(logical, logicalLocation); + if (wasPacketOnly && (itemChanged || locationChanged)) { + respawnVirtualItems(logical); + } + return; + } + + if (wasPacketOnly) { + packetOnlyItems.remove(logical); + renderedItemLocations.remove(logical); + itemAnimations.remove(logical); + clearViewerTracking(logical); + logical.unbind(); + untrackActual(logical); + } + + if (current != null && (!current.getWorld().equals(logicalLocation.getWorld()) || !(current instanceof ItemDisplay))) { discardActual(logical, current); current = null; @@ -423,14 +682,16 @@ private static void syncItem(Item logical) { actual = display; } else { clearViewerTracking(logical); - actual = logical.getWorld().spawn(logical.getLocation(), ItemDisplay.class, + actual = logicalLocation.getWorld().spawn(logicalLocation, ItemDisplay.class, DisplayManager::initializeDisplay); + PerformanceMetrics.bukkitEntitySpawn(); logical.bind(actual); trackActual(logical, actual); } ItemStack itemStack = logical.getItemStack(); - ItemStack previousItemStack = renderedItemStacks.put(logical, itemStack.clone()); + ItemStack previousItemStack = renderedItemStacks.put(logical, itemStack); + renderedItemLocations.remove(logical); boolean itemChanged = previousItemStack == null || !previousItemStack.equals(itemStack); // The Paper entity is only an invisible tracker/name anchor. Rendering @@ -444,32 +705,135 @@ private static void syncItem(Item logical) { actual.setInterpolationDelay(0); actual.setInterpolationDuration(0); actual.setTeleportDuration(0); - applyItemBase(actual, logical); Vector velocity = logical.getVelocity(); - boolean animated = requiresItemAnimation(logical.hasGravity(), velocity); + boolean gravity = logical.hasGravity(); + boolean animated = requiresItemAnimation(gravity, velocity); + ItemAnimationState previousAnimation = itemAnimations.get(logical); + Location previousPosition = previousAnimation == null ? null : previousAnimation.position; + Location previousLogicalLocation = previousAnimation == null ? null : previousAnimation.logicalLocation; + boolean applyLogicalLocation = shouldApplyItemLogicalLocation( + animated, previousPosition, previousLogicalLocation, logicalLocation); + boolean anchorMoved = applyItemBase(actual, logical, logicalLocation, applyLogicalLocation); + + ItemAnimationState nextAnimation = null; if (animated) { - itemAnimations.put(logical, new ItemAnimationState(velocity, logical.hasGravity())); + Location position = itemAnimationStartPosition( + actual.getLocation(), previousPosition, previousLogicalLocation, logicalLocation); + nextAnimation = new ItemAnimationState(velocity, gravity, position, + useStaticAnchorForAnimation(InteractionVisualizer.staticVirtualItemAnchorsDuringAnimation, + logical.isCustomNameVisible()), logicalLocation); + itemAnimations.put(logical, nextAnimation); scheduleItemAnimationTick(); } else { itemAnimations.remove(logical); } + Location anchorLocation = actual.getLocation(); + Location visualLocation = itemAnimationIndexLocation( + nextAnimation != null && nextAnimation.staticAnchor, + anchorLocation, nextAnimation == null ? null : nextAnimation.position); + index(logical, visualLocation); + if (itemChanged) { respawnVirtualItems(logical); + } else if (requiresVirtualItemMotionSync(anchorMoved, previousAnimation != null, animated)) { + synchronizeVirtualItemMotion(logical, visualLocation); + } + } + + private static void syncFixedItem(Item logical) { + boolean representationChanged = fixedItemDisplays.add(logical); + org.bukkit.entity.Entity current = logical.getBukkitEntity().orElse(null); + Location logicalLocation = logical.getLocation(); + + if (representationChanged) { + packetOnlyItems.remove(logical); + itemAnimations.remove(logical); + renderedItemLocations.remove(logical); + if (current != null) { + discardActual(logical, current); + } else { + untrackActual(logical); + logical.unbind(); + clearViewerTracking(logical); + } + current = null; + } else if (current != null && (!current.getWorld().equals(logicalLocation.getWorld()) + || !(current instanceof ItemDisplay))) { + discardActual(logical, current); + current = null; + } + + ItemStack itemStack = logical.getItemStack(); + renderedItemStacks.put(logical, itemStack); + renderedItemLocations.remove(logical); + if (itemStack.isEmpty()) { + if (current != null) { + discardActual(logical, current); + } else { + clearViewerTracking(logical); + } + index(logical, logicalLocation); + return; + } + + ItemDisplay actual; + if (current instanceof ItemDisplay display) { + actual = display; } else { - synchronizeVirtualItemMotion(logical, actual.getLocation()); + clearViewerTracking(logical); + actual = logicalLocation.getWorld().spawn(logicalLocation, ItemDisplay.class, + DisplayManager::initializeDisplay); + PerformanceMetrics.bukkitEntitySpawn(); + logical.bind(actual); + trackActual(logical, actual); + } + + applyFixedItemBase(actual, logical, logicalLocation); + actual.setItemStack(itemStack); + actual.setItemDisplayTransform(DisplayTransformFactory.itemDisplayTransform(logical)); + actual.setTransformationMatrix(DisplayTransformFactory.item(logical)); + actual.setBillboard(Display.Billboard.FIXED); + actual.setViewRange(1.0F); + actual.setShadowRadius(0.0F); + actual.setShadowStrength(0.0F); + actual.setInterpolationDelay(0); + actual.setInterpolationDuration(3); + actual.setTeleportDuration(3); + index(logical, logicalLocation); + } + + private static void applyFixedItemBase(ItemDisplay actual, Item logical, Location target) { + actual.setGlowing(logical.isGlowing()); + actual.customName(logical.getCustomName()); + actual.setCustomNameVisible(logical.isCustomNameVisible()); + Location current = actual.getLocation(); + boolean moved = !current.getWorld().equals(target.getWorld()) + || current.distanceSquared(target) > 1.0E-8 + || current.getYaw() != target.getYaw() || current.getPitch() != target.getPitch(); + if (moved) { + PerformanceMetrics.bukkitEntityTeleport(); + actual.teleport(target); } } - private static void applyItemBase(org.bukkit.entity.Entity actual, Item logical) { + static boolean requiresVirtualItemMotionSync(boolean anchorMoved, boolean wasAnimated, boolean animated) { + return anchorMoved || wasAnimated || animated; + } + + private static boolean applyItemBase(org.bukkit.entity.Entity actual, Item logical, + Location target, boolean applyLocation) { actual.setGlowing(logical.isGlowing()); actual.customName(logical.getCustomName()); actual.setCustomNameVisible(logical.isCustomNameVisible()); - if (!actual.getWorld().equals(logical.getWorld()) - || actual.getLocation().distanceSquared(logical.getLocation()) > 1.0E-8) { - actual.teleport(logical.getLocation()); + boolean moved = applyLocation && (!actual.getWorld().equals(target.getWorld()) + || actual.getLocation().distanceSquared(target) > 1.0E-8); + if (moved) { + PerformanceMetrics.bukkitEntityTeleport(); + actual.teleport(target); } + return moved; } private static void syncItemFrame(ItemFrame logical) { @@ -528,16 +892,25 @@ private static boolean isEmpty(Component component) { private static boolean requiresActualEntity(VisualizerEntity logical) { if (logical instanceof DisplayEntity display) { - return shouldRender(display); + return !usesVirtualText(display) && shouldRender(display); } if (logical instanceof ItemFrame frame) { return shouldRender(frame); } - return logical instanceof Item; + if (logical instanceof Item item) { + return item.isFixedDisplay() ? !item.getItemStack().isEmpty() : !qualifiesForPacketOnlyStatic(item); + } + return false; + } + + private static boolean usesVirtualText(DisplayEntity logical) { + return virtualTextAvailable && logical.usesLegacyNameTagStyle(); } - private static boolean shouldRender(DisplayEntity logical) { - return logical.isTextDisplay() ? !isEmpty(logical.getCustomName()) : !logical.getDisplayItem().isEmpty(); + static boolean shouldRender(DisplayEntity logical) { + return logical.isTextDisplay() + ? logical.isCustomNameVisible() && !isEmpty(logical.getCustomName()) + : !logical.getDisplayItem().isEmpty(); } private static boolean shouldRender(ItemFrame logical) { @@ -548,6 +921,60 @@ static boolean requiresItemAnimation(boolean gravity, Vector velocity) { return gravity || velocity.lengthSquared() > ITEM_ANIMATION_EPSILON; } + static boolean itemAnimationLogicalLocationChanged(Location previousLogicalLocation, + Location logicalLocation) { + if (previousLogicalLocation == null || logicalLocation == null) { + return true; + } + return !Objects.equals(previousLogicalLocation.getWorld(), logicalLocation.getWorld()) + || previousLogicalLocation.getX() != logicalLocation.getX() + || previousLogicalLocation.getY() != logicalLocation.getY() + || previousLogicalLocation.getZ() != logicalLocation.getZ(); + } + + static boolean shouldApplyItemLogicalLocation(boolean animated, + Location previousAnimationPosition, + Location previousLogicalLocation, + Location logicalLocation) { + return previousAnimationPosition == null || !animated + || itemAnimationLogicalLocationChanged(previousLogicalLocation, logicalLocation); + } + + static Location itemAnimationStartPosition(Location actualLocation, + Location previousAnimationPosition, + Location previousLogicalLocation, + Location logicalLocation) { + Objects.requireNonNull(actualLocation, "actualLocation"); + if (previousAnimationPosition == null + || itemAnimationLogicalLocationChanged(previousLogicalLocation, logicalLocation)) { + return actualLocation.clone(); + } + return previousAnimationPosition.clone(); + } + + static Location itemAnimationIndexLocation(boolean staticAnchor, Location anchorLocation, + Location animationPosition) { + Objects.requireNonNull(anchorLocation, "anchorLocation"); + return (staticAnchor || animationPosition == null ? anchorLocation : animationPosition).clone(); + } + + static boolean useStaticAnchorForAnimation(boolean configured, boolean customNameVisible) { + return configured && !customNameVisible; + } + + static boolean qualifiesForPacketOnlyStatic(boolean configured, boolean gravity, Vector velocity, + boolean customNameVisible, boolean glowing) { + return configured && !gravity && velocity != null + && velocity.getX() == 0.0D && velocity.getY() == 0.0D && velocity.getZ() == 0.0D + && !customNameVisible && !glowing; + } + + private static boolean qualifiesForPacketOnlyStatic(Item logical) { + return !logical.isFixedDisplay() && qualifiesForPacketOnlyStatic( + InteractionVisualizer.packetOnlyStaticVirtualItems, + logical.hasGravity(), logical.getVelocity(), logical.isCustomNameVisible(), logical.isGlowing()); + } + private static void scheduleItemAnimationTick() { if (itemAnimationTickScheduled || itemAnimations.isEmpty() || plugin() == null || !plugin().isEnabled()) { @@ -565,20 +992,27 @@ private static void scheduleItemAnimationTick() { } private static void tickItemAnimations() { - for (Map.Entry entry : itemAnimations.entrySet()) { - Item logical = entry.getKey(); - ItemAnimationState animation = entry.getValue(); - try { - tickItemAnimation(logical, animation); - } catch (RuntimeException exception) { - itemAnimations.remove(logical, animation); - remove(null, logical, true); - Plugin plugin = plugin(); - if (plugin != null) { - plugin.getLogger().log(Level.WARNING, - "Stopped an invalid visual item animation at " + logical.getLocation(), exception); + long started = PerformanceMetrics.isCollecting() ? System.nanoTime() : 0L; + try { + for (Map.Entry entry : itemAnimations.entrySet()) { + Item logical = entry.getKey(); + ItemAnimationState animation = entry.getValue(); + try { + tickItemAnimation(logical, animation); + } catch (RuntimeException exception) { + itemAnimations.remove(logical, animation); + remove(null, logical, true); + Plugin plugin = plugin(); + if (plugin != null) { + plugin.getLogger().log(Level.WARNING, + "Stopped an invalid visual item animation at " + logical.getLocation(), exception); + } } } + } finally { + if (started != 0L) { + PerformanceMetrics.itemAnimationNanos(System.nanoTime() - started); + } } } @@ -590,7 +1024,7 @@ private static void tickItemAnimation(Item logical, ItemAnimationState animation } Vector movement = itemMovementForTick(animation.gravity, animation.velocity); - Location destination = actual.getLocation().add(movement); + Location destination = animation.position.clone().add(movement); destination.checkFinite(); World world = destination.getWorld(); if (destination.getY() < world.getMinHeight() - ITEM_VOID_MARGIN @@ -600,9 +1034,18 @@ private static void tickItemAnimation(Item logical, ItemAnimationState animation } animation.velocity = itemVelocityAfterMovement(movement); - if (!actual.teleport(destination)) { - remove(null, logical, true); - return; + animation.position = destination.clone(); + if (!animation.staticAnchor) { + PerformanceMetrics.bukkitEntityTeleport(); + if (!actual.teleport(destination)) { + remove(null, logical, true); + return; + } + } + boolean chunkChanged = index(logical, itemAnimationIndexLocation( + animation.staticAnchor, actual.getLocation(), animation.position)); + if (chunkChanged) { + reconcileViewers(logical); } if (animation.gravity) { // Heart's fake item is no-gravity. Absolute correction preserves the @@ -611,6 +1054,16 @@ private static void tickItemAnimation(Item logical, ItemAnimationState animation } if (!animation.gravity && animation.velocity.lengthSquared() <= ITEM_ANIMATION_EPSILON) { itemAnimations.remove(logical, animation); + if (animation.staticAnchor) { + PerformanceMetrics.bukkitEntityTeleport(); + if (!actual.teleport(destination)) { + remove(null, logical, true); + return; + } + if (index(logical, actual.getLocation())) { + reconcileViewers(logical); + } + } synchronizeVirtualItemMotion(logical, destination); } } @@ -632,35 +1085,120 @@ static Vector itemVelocityAfterMovement(Vector movement) { movement.getZ() * ITEM_HORIZONTAL_DRAG_PER_TICK); } - private static void spawnVirtualItem(Item logical, Player player) { - org.bukkit.entity.Entity anchor = logical.getBukkitEntity().orElse(null); + private static boolean spawnVirtualText(DisplayEntity logical, Player player) { + VirtualTextState state = virtualTextDisplays.get(logical); Set shown = shownViewers.get(logical); - if (!(anchor instanceof ItemDisplay) || !active.containsKey(logical) - || shown == null || !shown.contains(player.getUniqueId()) - || !player.isOnline() || !player.getWorld().equals(anchor.getWorld())) { + Location anchor = logical.getLocation(); + UUID viewer = player.getUniqueId(); + if (!usesVirtualText(logical) || state == null || !active.containsKey(logical) + || shown == null || !shown.contains(viewer) || !shouldRender(logical) + || !player.isOnline() || !player.getWorld().equals(anchor.getWorld()) + || !player.isChunkSent(Chunk.getChunkKey(anchor))) { + return false; + } + + Location target = virtualTextLocation(logical, player); + try { + state.display.spawn(player, target, state.text); + PerformanceMetrics.virtualSpawnBundle(); + if (logical instanceof BillboardDisplayEntity billboard) { + rememberDynamicViewer(billboard, viewer, target); + } + return true; + } catch (RuntimeException | LinkageError exception) { + forgetDynamicViewer(logical, viewer); + logVirtualTextFailure("spawn", logical, exception); + return false; + } + } + + private static Location virtualTextLocation(DisplayEntity logical, Player player) { + Location logicalLocation = logical.getLocation(); + if (logical instanceof BillboardDisplayEntity billboard) { + Location eye = player.getEyeLocation(); + logicalLocation = billboard.getViewingLocation(eye, eye.getDirection()); + } + return DisplayTransformFactory.textDisplayLocation(logical, logicalLocation); + } + + private static void removeVirtualText(DisplayEntity logical, UUID viewer, Player player) { + forgetDynamicViewer(logical, viewer); + VirtualTextState state = virtualTextDisplays.get(logical); + if (state == null || player == null || !player.isOnline()) { return; } + try { + state.display.destroy(player); + PerformanceMetrics.virtualRemovePacket(); + } catch (RuntimeException | LinkageError exception) { + logVirtualTextFailure("remove", logical, exception); + } + } + + private static void logVirtualTextFailure(String operation, DisplayEntity logical, + Throwable exception) { + Plugin plugin = plugin(); + if (plugin != null) { + plugin.getLogger().log(Level.WARNING, + "Failed to " + operation + " a packet-only text display at " + + logical.getLocation(), exception); + } + } + + private static boolean spawnVirtualItem(Item logical, Player player) { + // Follow the backend that is currently installed, not a render-mode + // mutation that may still be waiting for its main-thread sync. + if (fixedItemDisplays.contains(logical)) { + return false; + } + org.bukkit.entity.Entity anchor = logical.getBukkitEntity().orElse(null); + Set shown = shownViewers.get(logical); + boolean packetOnly = packetOnlyItems.contains(logical); + ItemAnimationState animation = itemAnimations.get(logical); + Location source; + if (!active.containsKey(logical) || shown == null || !shown.contains(player.getUniqueId()) + || !player.isOnline()) { + return false; + } + if (packetOnly) { + source = logical.getLocation(); + if (!player.getWorld().equals(source.getWorld()) + || !player.isChunkSent(Chunk.getChunkKey(source))) { + return false; + } + } else { + if (!(anchor instanceof ItemDisplay) || !player.getWorld().equals(anchor.getWorld())) { + return false; + } + source = animation == null ? anchor.getLocation() : animation.position.clone(); + } Map ids = virtualItemIds.computeIfAbsent(logical, ignored -> new ConcurrentHashMap<>()); UUID viewer = player.getUniqueId(); if (ids.containsKey(viewer)) { - return; + return true; } Integer spawnedId = null; try { spawnedId = SparrowHeart.getInstance().dropFakeItem( - player, logical.getItemStack(), anchor.getLocation()); + player, logical.getItemStack(), source); + PerformanceMetrics.virtualSpawnBundle(); ids.put(viewer, spawnedId); - Vector motion = nextVirtualItemMotion(logical); - if (motion.lengthSquared() > ITEM_ANIMATION_EPSILON) { - SparrowHeart.getInstance().sendClientSideEntityMotion(player, motion, spawnedId); + if (animation != null) { + Vector motion = itemMovementForTick(animation.gravity, animation.velocity); + if (motion.lengthSquared() > ITEM_ANIMATION_EPSILON) { + SparrowHeart.getInstance().sendClientSideEntityMotion(player, motion, spawnedId); + PerformanceMetrics.virtualMotionBundle(); + } } + return true; } catch (RuntimeException exception) { if (spawnedId != null) { ids.remove(viewer, spawnedId); try { SparrowHeart.getInstance().removeClientSideEntity(player, spawnedId); + PerformanceMetrics.virtualRemovePacket(); } catch (RuntimeException cleanupException) { exception.addSuppressed(cleanupException); } @@ -669,6 +1207,7 @@ private static void spawnVirtualItem(Item logical, Player player) { virtualItemIds.remove(logical, ids); } logVirtualItemFailure("spawn", logical, exception); + return false; } } @@ -696,6 +1235,7 @@ private static void synchronizeVirtualItemMotion(Item logical, Location location // packet both corrects the absolute position and primes the next tick. SparrowHeart.getInstance().sendClientSideTeleportEntity( player, location, motion, false, entry.getValue()); + PerformanceMetrics.virtualTeleportBundle(); } catch (RuntimeException exception) { removeVirtualItem(logical, entry.getKey(), player); logVirtualItemFailure("move", logical, exception); @@ -709,20 +1249,25 @@ private static void respawnVirtualItems(Item logical) { return; } Set viewers = new HashSet<>(ids.keySet()); + boolean packetOnly = packetOnlyItems.contains(logical); for (UUID viewer : viewers) { Player player = Bukkit.getPlayer(viewer); removeVirtualItem(logical, viewer, player); - if (player != null) { - spawnVirtualItem(logical, player); + boolean spawned = player != null && spawnVirtualItem(logical, player); + if (packetOnly && !spawned) { + forgetShownViewer(logical, viewer, player); } } } private static void collectVirtualItem(Item logical, Player collector) { try { - Map ids = virtualItemIds.get(logical); int amount = Math.max(1, logical.getItemStack().getAmount()); boolean pickupPacketAvailable = ClientPickupAnimationBridge.initialize(); + if (pickupPacketAvailable) { + materializeRateLimitedPickupViewers(logical, collector); + } + Map ids = virtualItemIds.get(logical); if (ids != null) { for (UUID viewerId : new HashSet<>(ids.keySet())) { Integer itemEntityId = forgetVirtualItem(logical, viewerId); @@ -742,6 +1287,7 @@ private static void collectVirtualItem(Item logical, Player collector) { // lets the client resolve the collector (including its local // player fallback), so do not add a target-tracking filter. ClientPickupAnimationBridge.send(viewer, itemEntityId, collector, amount); + PerformanceMetrics.virtualPickupPacket(); } catch (RuntimeException | LinkageError exception) { removeClaimedVirtualItem(logical, viewer, itemEntityId); logVirtualItemFailure("collect", logical, exception); @@ -753,6 +1299,58 @@ private static void collectVirtualItem(Item logical, Player collector) { } } + /** + * A native pickup lasts only three client ticks, so it cannot wait behind the + * visibility token bucket. Materialize only eligible viewers that do not yet + * own the fake item, then let the normal pickup path claim and remove it in + * the same server tick. This preserves rate limiting for persistent displays + * without reintroducing a server-side pickup trajectory or delayed task. + */ + private static void materializeRateLimitedPickupViewers(Item logical, Player collector) { + if (!InteractionVisualizer.visibilityRateLimiting) { + return; + } + Collection desiredPlayers = active.get(logical); + if (desiredPlayers == null || !collector.isOnline()) { + return; + } + + boolean packetOnly = packetOnlyItems.contains(logical); + Location packetOnlyLocation = packetOnly ? logical.getLocation() : null; + for (Player player : desiredPlayers) { + if (player == null) { + continue; + } + UUID viewer = player.getUniqueId(); + Map ids = virtualItemIds.get(logical); + boolean hasVirtualItem = ids != null && ids.containsKey(viewer); + boolean collectorReachable = player.isOnline() && player.getWorld().equals(collector.getWorld()); + boolean renderable = collectorReachable + && isViewerRenderable(logical, player, packetOnlyLocation, packetOnly); + if (!shouldMaterializeRateLimitedPickupViewer( + true, renderable, collectorReachable, hasVirtualItem)) { + continue; + } + + cancelVisibilityShow(logical, viewer); + Set shown = shownViewers.get(logical); + if (shown == null || !shown.contains(viewer)) { + showViewerNow(logical, player); + } + // Entity tracking can be deferred even after showEntity(). Claim the + // virtual item synchronously so the following take packet always has + // a source entity that the client already knows about. + spawnVirtualItem(logical, player); + } + } + + static boolean shouldMaterializeRateLimitedPickupViewer(boolean rateLimiting, + boolean renderable, + boolean collectorReachable, + boolean hasVirtualItem) { + return rateLimiting && renderable && collectorReachable && !hasVirtualItem; + } + private static Integer forgetVirtualItem(Item logical, UUID viewer) { Map ids = virtualItemIds.get(logical); if (ids == null) { @@ -776,12 +1374,34 @@ private static void removeClaimedVirtualItem(Item logical, Player player, int id if (player != null && player.isOnline()) { try { SparrowHeart.getInstance().removeClientSideEntity(player, id); + PerformanceMetrics.virtualRemovePacket(); } catch (RuntimeException exception) { logVirtualItemFailure("remove", logical, exception); } } } + private static void removeVirtualItems(Player player, VirtualItemIdBuffer ids) { + if (ids.isEmpty() || player == null || !player.isOnline()) { + return; + } + for (int start = 0; start < ids.size(); start += VIRTUAL_REMOVE_BATCH_SIZE) { + int end = Math.min(ids.size(), start + VIRTUAL_REMOVE_BATCH_SIZE); + int[] batch = new int[end - start]; + ids.copyInto(start, batch); + try { + SparrowHeart.getInstance().removeClientSideEntity(player, batch); + PerformanceMetrics.virtualRemovePacket(); + } catch (RuntimeException exception) { + Plugin plugin = plugin(); + if (plugin != null) { + plugin.getLogger().log(Level.WARNING, + "Failed to batch-remove " + batch.length + " Sparrow virtual items", exception); + } + } + } + } + private static void destroyVirtualItems(Item logical) { Map ids = virtualItemIds.remove(logical); if (ids == null) { @@ -792,6 +1412,7 @@ private static void destroyVirtualItems(Item logical) { if (player != null && player.isOnline()) { try { SparrowHeart.getInstance().removeClientSideEntity(player, entry.getValue()); + PerformanceMetrics.virtualRemovePacket(); } catch (RuntimeException exception) { logVirtualItemFailure("remove", logical, exception); } @@ -815,6 +1436,7 @@ private static void discardActual(VisualizerEntity logical, org.bukkit.entity.En } untrackActual(logical, actual); clearViewerTracking(logical, actual); + PerformanceMetrics.bukkitEntityRemove(); actual.remove(); logical.unbind(); } @@ -842,11 +1464,34 @@ private static void untrackActual(VisualizerEntity logical) { } private static void index(VisualizerEntity logical) { - Location location = logical.getLocation(); + if (logical instanceof Item item) { + ItemAnimationState animation = itemAnimations.get(item); + if (animation != null) { + Location anchorLocation = logical.getBukkitEntity() + .map(org.bukkit.entity.Entity::getLocation) + .orElseGet(logical::getLocation); + index(logical, itemAnimationIndexLocation( + animation.staticAnchor, anchorLocation, animation.position)); + return; + } + } + index(logical, logical.getLocation()); + } + + static boolean index(VisualizerEntity logical, Location location) { + UUID world = location.getWorld().getUID(); + int chunkX = location.getBlockX() >> 4; + int chunkZ = location.getBlockZ() >> 4; + ChunkKey previous = chunkByLogical.get(logical); + if (previous != null && previous.world().equals(world) + && previous.x() == chunkX && previous.z() == chunkZ) { + return false; + } ChunkKey current = new ChunkKey( - location.getWorld().getUID(), location.getBlockX() >> 4, location.getBlockZ() >> 4); - ChunkKey previous = chunkByLogical.put(logical, current); - if (previous != null && !previous.equals(current)) { + world, chunkX, chunkZ); + previous = chunkByLogical.put(logical, current); + boolean migrated = previous != null && !previous.equals(current); + if (migrated) { Set previousEntries = logicalsByChunk.get(previous); if (previousEntries != null) { previousEntries.remove(logical); @@ -856,9 +1501,10 @@ private static void index(VisualizerEntity logical) { } } logicalsByChunk.computeIfAbsent(current, ignored -> ConcurrentHashMap.newKeySet()).add(logical); + return migrated; } - private static void unindex(VisualizerEntity logical) { + static void unindex(VisualizerEntity logical) { ChunkKey key = chunkByLogical.remove(logical); if (key == null) { return; @@ -877,6 +1523,10 @@ private static void clearViewerTracking(VisualizerEntity logical) { } private static void clearViewerTracking(VisualizerEntity logical, org.bukkit.entity.Entity actual) { + cancelVisibilityShows(logical); + if (logical instanceof BillboardDisplayEntity billboard) { + forgetDynamicEntity(billboard); + } if (logical instanceof Item item) { destroyVirtualItems(item); } @@ -886,8 +1536,12 @@ private static void clearViewerTracking(VisualizerEntity logical, org.bukkit.ent } for (UUID uuid : shown) { Player player = Bukkit.getPlayer(uuid); + if (logical instanceof DisplayEntity display && virtualTextDisplays.containsKey(display)) { + removeVirtualText(display, uuid, player); + } if (player != null) { if (actual != null) { + PerformanceMetrics.bukkitHide(); player.hideEntity(plugin(), actual); } Set status = playerStatus.get(player); @@ -898,75 +1552,502 @@ private static void clearViewerTracking(VisualizerEntity logical, org.bukkit.ent } } - private static void removeOwnedEntities(boolean clearVisibilityOverrides) { - NamespacedKey key = ownerKey(); - for (World world : Bukkit.getWorlds()) { - for (org.bukkit.entity.Entity entity : world.getEntities()) { - if (entity.getPersistentDataContainer().has(key, PersistentDataType.STRING)) { - if (clearVisibilityOverrides) { - for (Player player : Bukkit.getOnlinePlayers()) { - player.hideEntity(plugin(), entity); - } - } - entity.remove(); - } + private static boolean isViewerEnabledFor(VisualizerEntity logical, Player player) { + Collection desiredPlayers = active.get(logical); + if (desiredPlayers == null || player == null) { + return false; + } + UUID viewer = player.getUniqueId(); + return SynchronizedFilteredCollection.anyMatch(desiredPlayers, + candidate -> candidate != null && viewer.equals(candidate.getUniqueId())); + } + + private static boolean isViewerDesired(VisualizerEntity logical, Player player) { + return isViewerRenderable(logical, player) && isViewerEnabledFor(logical, player); + } + + private static boolean isQueuedViewerStillRenderable(VisualizerEntity logical, Player player) { + // Eligibility was checked before enqueueing. Every internal eligibility + // mutation (preference reset, reload, world/chunk lifecycle, removal) + // explicitly cancels or reconciles the queue, so the hot drain path only + // needs O(1) liveness/renderability validation. + return active.containsKey(logical) && isViewerRenderable(logical, player); + } + + private static boolean isViewerRenderable(VisualizerEntity logical, Player player) { + if (logical instanceof DisplayEntity display && usesVirtualText(display)) { + return isViewerRenderable(logical, player, display.getLocation(), + virtualTextDisplays.containsKey(display) && shouldRender(display)); + } + if (logical instanceof Item item && packetOnlyItems.contains(item)) { + return isViewerRenderable(logical, player, item.getLocation(), true); + } + return isViewerRenderable(logical, player, null, false); + } + + private static boolean isViewerRenderable(VisualizerEntity logical, Player player, + Location packetOnlyLocation, boolean packetOnlyQualified) { + if (player == null || !player.isOnline()) { + return false; + } + if (logical instanceof DisplayEntity display && usesVirtualText(display)) { + if (packetOnlyLocation == null || !player.getWorld().equals(packetOnlyLocation.getWorld())) { + return false; + } + PerformanceMetrics.virtualViewerChecks(1); + return packetOnlyQualified && player.isChunkSent(Chunk.getChunkKey(packetOnlyLocation)); + } + if (logical instanceof Item && packetOnlyItems.contains((Item) logical)) { + if (packetOnlyLocation == null || !player.getWorld().equals(packetOnlyLocation.getWorld())) { + return false; } + PerformanceMetrics.virtualViewerChecks(1); + return packetOnlyQualified && player.isChunkSent(Chunk.getChunkKey(packetOnlyLocation)); + } + org.bukkit.entity.Entity actual = logical.getBukkitEntity().orElse(null); + if (actual == null || !player.getWorld().equals(actual.getWorld())) { + return false; + } + + // showEntity() records Paper's visibility override even when the + // entity tracker cannot send a spawn yet. In particular, ChunkLoadEvent + // can recreate a non-persistent display before the destination chunk + // packet reaches the player. Waiting for the sent-chunk lifecycle keeps + // shownViewers from turning that lost first attempt into a permanent + // omission. The chunk index avoids allocating a Location in this hot + // eligibility check. + ChunkKey key = chunkByLogical.get(logical); + if (key == null || !key.world().equals(player.getWorld().getUID())) { + return false; } + return player.isChunkSent(Chunk.getChunkKey(key.x(), key.z())); } - private static void reconcileViewers(VisualizerEntity logical) { + private static void requestViewerShow(VisualizerEntity logical, Player player) { + Set shown = shownViewers.get(logical); + if (shown != null && shown.contains(player.getUniqueId())) { + cancelVisibilityShow(logical, player.getUniqueId()); + return; + } + if (!InteractionVisualizer.visibilityRateLimiting) { + cancelVisibilityShow(logical, player.getUniqueId()); + showViewerNow(logical, player); + return; + } + + VisibilityShowQueue queue = visibilityShowQueues.computeIfAbsent( + player.getUniqueId(), ignored -> new VisibilityShowQueue<>( + InteractionVisualizer.visibilityRateLimitBucketSize, currentServerTick())); + if (queue.request(logical)) { + PerformanceMetrics.visibilityShowQueued(); + } + scheduleVisibilityTick(); + } + + private static void showViewerNow(VisualizerEntity logical, Player player) { + // Both callers remove the pending request before entering: the immediate + // path cancels it, while VisibilityShowQueue removes it before invoking + // the drain action. Avoid a second queue lookup on every successful show. + Set shown = shownViewers.computeIfAbsent(logical, ignored -> ConcurrentHashMap.newKeySet()); + UUID viewer = player.getUniqueId(); + if (!shown.add(viewer)) { + return; + } + + boolean shownSuccessfully; + if (logical instanceof DisplayEntity display && usesVirtualText(display)) { + shownSuccessfully = spawnVirtualText(display, player); + } else if (logical instanceof Item item && packetOnlyItems.contains(item)) { + shownSuccessfully = spawnVirtualItem(item, player); + } else { + org.bukkit.entity.Entity actual = logical.getBukkitEntity().orElse(null); + shownSuccessfully = actual != null; + if (shownSuccessfully) { + PerformanceMetrics.bukkitShow(); + player.showEntity(plugin(), actual); + } + } + + if (shownSuccessfully) { + playerStatus.computeIfAbsent(player, ignored -> ConcurrentHashMap.newKeySet()).add(logical); + } else { + forgetShownViewer(logical, viewer, player); + } + } + + private static void hideViewer(VisualizerEntity logical, UUID viewer, Player player) { + cancelVisibilityShow(logical, viewer); + Set shown = shownViewers.get(logical); + boolean wasShown = shown != null && shown.remove(viewer); + if (shown != null && shown.isEmpty()) { + shownViewers.remove(logical, shown); + } + if (wasShown && logical instanceof DisplayEntity display && virtualTextDisplays.containsKey(display)) { + removeVirtualText(display, viewer, player); + } else { + forgetDynamicViewer(logical, viewer); + } + if (logical instanceof Item item) { + removeVirtualItem(item, viewer, player); + } org.bukkit.entity.Entity actual = logical.getBukkitEntity().orElse(null); - Collection desiredPlayers = active.get(logical); - if (actual == null || desiredPlayers == null) { + if (wasShown && actual != null && player != null) { + PerformanceMetrics.bukkitHide(); + player.hideEntity(plugin(), actual); + } + if (player != null) { + Set status = playerStatus.get(player); + if (status != null) { + status.remove(logical); + } + } + } + + private static Integer forgetShownViewer(VisualizerEntity logical, UUID viewer, Player player) { + cancelVisibilityShow(logical, viewer); + forgetDynamicViewer(logical, viewer); + Set shown = shownViewers.get(logical); + if (shown != null) { + shown.remove(viewer); + if (shown.isEmpty()) { + shownViewers.remove(logical, shown); + } + } + Integer forgottenVirtualId = logical instanceof Item item ? forgetVirtualItem(item, viewer) : null; + if (player != null) { + Set status = playerStatus.get(player); + if (status != null) { + status.remove(logical); + } + } + return forgottenVirtualId; + } + + private static void cancelVisibilityShow(VisualizerEntity logical, UUID viewer) { + VisibilityShowQueue queue = visibilityShowQueues.get(viewer); + if (queue != null) { + queue.cancel(logical); + } + } + + private static void cancelVisibilityShows(VisualizerEntity logical) { + for (VisibilityShowQueue queue : visibilityShowQueues.values()) { + queue.cancel(logical); + } + } + + private static void clearVisibilityShowQueue(UUID viewer) { + VisibilityShowQueue queue = visibilityShowQueues.remove(viewer); + if (queue != null) { + queue.clear(); + } + } + + private static void scheduleVisibilityTick() { + if (visibilityTickScheduled || !hasPendingVisibilityShows() + || plugin() == null || !plugin().isEnabled()) { return; } + visibilityTickScheduled = true; + Bukkit.getScheduler().runTaskLater(plugin(), () -> { + try { + drainVisibilityShowQueues(); + } finally { + visibilityTickScheduled = false; + scheduleVisibilityTick(); + } + }, 1L); + } - Set desired = new HashSet<>(); - for (Player player : desiredPlayers) { - if (player != null && player.isOnline() && player.getWorld().equals(actual.getWorld())) { - desired.add(player.getUniqueId()); + private static void drainVisibilityShowQueues() { + boolean limited = InteractionVisualizer.visibilityRateLimiting; + int capacity = InteractionVisualizer.visibilityRateLimitBucketSize; + int refill = InteractionVisualizer.visibilityRateLimitRestorePerTick; + long currentTick = limited ? currentServerTick() : 0L; + for (Map.Entry> entry : visibilityShowQueues.entrySet()) { + UUID viewer = entry.getKey(); + VisibilityShowQueue queue = entry.getValue(); + // Queue states deliberately survive while empty to retain token-bucket + // credit. Skip them before player lookup/lambda/list allocation. + if (!queue.hasPending()) { + continue; + } + Player player = Bukkit.getPlayer(viewer); + if (player == null || !player.isOnline()) { + queue.clear(); + visibilityShowQueues.remove(viewer, queue); + continue; + } + + VisibilityShowQueue.DrainAction showIfRenderable = logical -> { + if (!isQueuedViewerStillRenderable(logical, player)) { + return false; + } + PerformanceMetrics.visibilityShowDrained(); + showViewerNow(logical, player); + return true; + }; + if (limited) { + queue.drainTo(capacity, refill, currentTick, showIfRenderable); + } else { + queue.drainAllTo(showIfRenderable); } } + } - Set shown = shownViewers.computeIfAbsent(logical, ignored -> ConcurrentHashMap.newKeySet()); - for (UUID uuid : new HashSet<>(shown)) { - if (!desired.contains(uuid)) { - Player player = Bukkit.getPlayer(uuid); - if (player != null) { - if (logical instanceof Item item) { - removeVirtualItem(item, uuid, player); + private static boolean hasPendingVisibilityShows() { + for (VisibilityShowQueue queue : visibilityShowQueues.values()) { + if (queue.hasPending()) { + return true; + } + } + return false; + } + + private static long currentServerTick() { + return Integer.toUnsignedLong(Bukkit.getCurrentTick()); + } + + private static void markDynamicViewerDirty(Player player) { + UUID viewer = player.getUniqueId(); + Map positions = dynamicViewerPositions.get(viewer); + if (positions == null || positions.isEmpty()) { + return; + } + dirtyDynamicViewers.add(viewer); + scheduleDynamicViewerTick(); + } + + private static void scheduleDynamicViewerTick() { + if (dynamicViewerTickScheduled || dirtyDynamicViewers.isEmpty()) { + return; + } + dynamicViewerTickScheduled = true; + Bukkit.getScheduler().runTaskLater(plugin(), () -> { + dynamicViewerTickScheduled = false; + Set viewers = new HashSet<>(dirtyDynamicViewers); + dirtyDynamicViewers.removeAll(viewers); + for (UUID viewer : viewers) { + Player player = Bukkit.getPlayer(viewer); + if (player != null && player.isOnline()) { + synchronizeDynamicViewer(player); + } + } + scheduleDynamicViewerTick(); + }, 2L); + } + + private static void synchronizeDynamicViewer(Player player) { + Map positions = + dynamicViewerPositions.get(player.getUniqueId()); + if (positions == null || positions.isEmpty()) { + return; + } + for (BillboardDisplayEntity billboard : positions.keySet()) { + synchronizeDynamicViewer(billboard, player, false); + } + } + + private static void synchronizeDynamicViewer(BillboardDisplayEntity logical, Player player, + boolean force) { + synchronizeVirtualTextPosition(logical, player, force); + } + + private static boolean synchronizeVirtualTextPosition(DisplayEntity logical, Player player, + boolean force) { + UUID viewer = player.getUniqueId(); + Set shown = shownViewers.get(logical); + VirtualTextState state = virtualTextDisplays.get(logical); + Location anchor = logical.getLocation(); + if (shown == null || !shown.contains(viewer) || state == null + || !player.isOnline() || !player.getWorld().equals(anchor.getWorld())) { + forgetDynamicViewer(logical, viewer); + return false; + } + + Location target = virtualTextLocation(logical, player); + if (!force && logical instanceof BillboardDisplayEntity billboard) { + Map positions = dynamicViewerPositions.get(viewer); + DynamicViewerPosition previous = positions == null ? null : positions.get(billboard); + if (previous != null && previous.matches(target)) { + return true; + } + } + + try { + state.display.teleport(player, target); + PerformanceMetrics.virtualTeleportBundle(); + if (logical instanceof BillboardDisplayEntity billboard) { + rememberDynamicViewer(billboard, viewer, target); + } + return true; + } catch (RuntimeException | LinkageError exception) { + forgetDynamicViewer(logical, viewer); + if (!dynamicTeleportFailureLogged) { + dynamicTeleportFailureLogged = true; + plugin().getLogger().log(Level.WARNING, + "Unable to synchronize a per-viewer hologram path; further failures are suppressed", + exception); + } + // A failed teleport must not leave a static label permanently at + // its old anchor. Recreate the same client-only ID at the freshly + // computed position; the normal visibility gate controls retries. + hideViewer(logical, viewer, player); + if (isViewerDesired(logical, player)) { + requestViewerShow(logical, player); + } + return false; + } + } + + private static void rememberDynamicViewer(BillboardDisplayEntity logical, UUID viewer, + Location target) { + dynamicViewerPositions.computeIfAbsent(viewer, ignored -> new ConcurrentHashMap<>()) + .put(logical, DynamicViewerPosition.at(target)); + } + + private static void forgetDynamicViewer(VisualizerEntity logical, UUID viewer) { + if (!(logical instanceof BillboardDisplayEntity billboard)) { + return; + } + Map positions = dynamicViewerPositions.get(viewer); + if (positions != null) { + positions.remove(billboard); + if (positions.isEmpty()) { + dynamicViewerPositions.remove(viewer, positions); + } + } + } + + private static void forgetDynamicViewer(UUID viewer) { + dirtyDynamicViewers.remove(viewer); + dynamicViewerPositions.remove(viewer); + } + + private static void forgetDynamicEntity(BillboardDisplayEntity logical) { + for (Map.Entry> entry + : dynamicViewerPositions.entrySet()) { + Map positions = entry.getValue(); + positions.remove(logical); + if (positions.isEmpty()) { + dynamicViewerPositions.remove(entry.getKey(), positions); + } + } + } + + static boolean dynamicInputChanged(Location from, Location to) { + return to != null && (from.getX() != to.getX() || from.getZ() != to.getZ() + || from.getYaw() != to.getYaw()); + } + + private static void forgetPacketOnlyViewer(Player player) { + UUID viewer = player.getUniqueId(); + clearVisibilityShowQueue(viewer); + Set status = playerStatus.get(player); + if (status == null || status.isEmpty()) { + return; + } + for (VisualizerEntity logical : new HashSet<>(status)) { + if (logical instanceof DisplayEntity display && virtualTextDisplays.containsKey(display)) { + forgetShownViewer(display, viewer, player); + } else if (logical instanceof Item item && packetOnlyItems.contains(item)) { + forgetShownViewer(item, viewer, player); + } + } + } + + private static void removeOwnedEntities(boolean clearVisibilityOverrides) { + NamespacedKey key = ownerKey(); + for (World world : Bukkit.getWorlds()) { + for (org.bukkit.entity.Entity entity : world.getEntities()) { + if (entity.getPersistentDataContainer().has(key, PersistentDataType.STRING)) { + if (clearVisibilityOverrides) { + for (Player player : Bukkit.getOnlinePlayers()) { + PerformanceMetrics.bukkitHide(); + player.hideEntity(plugin(), entity); + } } - player.hideEntity(plugin(), actual); - Set status = playerStatus.get(player); - if (status != null) { - status.remove(logical); + PerformanceMetrics.bukkitEntityRemove(); + entity.remove(); + } + } + } + } + + private static void reconcileViewers(VisualizerEntity logical) { + Collection desiredPlayers = active.get(logical); + Location packetOnlyLocation = null; + boolean packetOnlyQualified = false; + if (logical instanceof DisplayEntity display && usesVirtualText(display)) { + packetOnlyLocation = display.getLocation(); + packetOnlyQualified = virtualTextDisplays.containsKey(display) && shouldRender(display); + } else if (logical instanceof Item item && packetOnlyItems.contains(item)) { + // Qualification and location are entity-invariant for this main-thread + // reconciliation. Evaluate them once rather than once per viewer. + packetOnlyLocation = item.getLocation(); + packetOnlyQualified = true; + } + Set shown = shownViewers.get(logical); + if (shown == null) { + if (desiredPlayers != null) { + for (Player player : desiredPlayers) { + if (isViewerRenderable(logical, player, packetOnlyLocation, packetOnlyQualified)) { + requestViewerShow(logical, player); } } - shown.remove(uuid); + } + return; + } + Set desired = new HashSet<>(); + if (desiredPlayers != null) { + for (Player player : desiredPlayers) { + if (isViewerRenderable(logical, player, packetOnlyLocation, packetOnlyQualified)) { + desired.add(player.getUniqueId()); + } + } + } + for (UUID uuid : new HashSet<>(shown)) { + if (!desired.contains(uuid)) { + hideViewer(logical, uuid, Bukkit.getPlayer(uuid)); } } for (UUID uuid : desired) { - if (shown.add(uuid)) { + shown = shownViewers.get(logical); + if (shown == null || !shown.contains(uuid)) { Player player = Bukkit.getPlayer(uuid); if (player != null) { - player.showEntity(plugin(), actual); - playerStatus.computeIfAbsent(player, ignored -> ConcurrentHashMap.newKeySet()).add(logical); + requestViewerShow(logical, player); } } } } + private static void reconcileViewer(VisualizerEntity logical, Player player) { + UUID viewer = player.getUniqueId(); + if (!isViewerDesired(logical, player)) { + hideViewer(logical, viewer, player); + return; + } + Set shown = shownViewers.get(logical); + if (shown == null || !shown.contains(viewer)) { + requestViewerShow(logical, player); + } + } + private static void remove(Collection players, VisualizerEntity logical, boolean removeFromActive) { if (removeFromActive) { - active.remove(logical); - renderedRevision.remove(logical); - unindex(logical); - if (logical instanceof Item item) { - itemAnimations.remove(item); - renderedItemStacks.remove(item); - } + clearRemovedLogicalState(logical); } runSync(() -> { + if (removeFromActive) { + // A sync that had already passed its active check can finish + // between the caller-side cleanup and this main-thread task. + // Clear the representation state again at the serialization point. + clearRemovedLogicalState(logical); + // Keep both sides of the chunk index on the main thread. An + // asynchronous remove must not interleave unindex() with index(). + unindex(logical); + } org.bukkit.entity.Entity actual = logical.getBukkitEntity().orElse(null); if (removeFromActive) { if (actual != null) { @@ -976,27 +2057,35 @@ private static void remove(Collection players, VisualizerEntity logical, logical.unbind(); clearViewerTracking(logical); } - } else if (actual != null && players != null) { - Set shown = shownViewers.get(logical); - if (shown == null) { - return; + if (logical instanceof DisplayEntity display) { + virtualTextDisplays.remove(display); } + } else if (players != null) { for (Player player : players) { - if (player != null && shown.remove(player.getUniqueId())) { - if (logical instanceof Item item) { - removeVirtualItem(item, player.getUniqueId(), player); - } - player.hideEntity(plugin(), actual); - Set status = playerStatus.get(player); - if (status != null) { - status.remove(logical); - } + if (player != null) { + hideViewer(logical, player.getUniqueId(), player); } } } }); } + private static void clearRemovedLogicalState(VisualizerEntity logical) { + active.remove(logical); + renderedRevision.remove(logical); + forceScheduled.remove(logical); + if (logical instanceof BillboardDisplayEntity billboard) { + forgetDynamicEntity(billboard); + } + if (logical instanceof Item item) { + itemAnimations.remove(item); + renderedItemStacks.remove(item); + renderedItemLocations.remove(item); + packetOnlyItems.remove(item); + fixedItemDisplays.remove(item); + } + } + private static void runSync(Runnable task) { Plugin plugin = plugin(); if (plugin == null) { @@ -1014,7 +2103,7 @@ public void onTrackEntity(PlayerTrackEntityEvent event) { // A successful Track event is the source of truth. Paper can leave // isTrackedBy() true after another listener cancels this event. VisualizerEntity logical = logicalByActualUuid.get(event.getEntity().getUniqueId()); - if (logical instanceof Item item) { + if (logical instanceof Item item && !fixedItemDisplays.contains(item)) { spawnVirtualItem(item, event.getPlayer()); } } @@ -1027,35 +2116,123 @@ public void onUntrackEntity(PlayerUntrackEntityEvent event) { } } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onMove(PlayerMoveEvent event) { + if (dynamicInputChanged(event.getFrom(), event.getTo())) { + markDynamicViewerDirty(event.getPlayer()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onVehicleMove(VehicleMoveEvent event) { + if (dynamicInputChanged(event.getFrom(), event.getTo())) { + markDynamicPassengersDirty(event.getVehicle()); + } + } + + private static void markDynamicPassengersDirty(Entity vehicle) { + for (Entity passenger : vehicle.getPassengers()) { + if (passenger instanceof Player player) { + markDynamicViewerDirty(player); + } else { + markDynamicPassengersDirty(passenger); + } + } + } + @EventHandler public void onJoin(PlayerJoinEvent event) { + forgetDynamicViewer(event.getPlayer().getUniqueId()); + forgetPacketOnlyViewer(event.getPlayer()); Bukkit.getScheduler().runTask(plugin(), () -> sendPlayerPackets(event.getPlayer())); } @EventHandler public void onWorldChange(PlayerChangedWorldEvent event) { + forgetDynamicViewer(event.getPlayer().getUniqueId()); + forgetPacketOnlyViewer(event.getPlayer()); + Bukkit.getScheduler().runTask(plugin(), () -> sendPlayerPackets(event.getPlayer())); + } + + @EventHandler + public void onRespawn(PlayerRespawnEvent event) { + forgetDynamicViewer(event.getPlayer().getUniqueId()); + forgetPacketOnlyViewer(event.getPlayer()); Bukkit.getScheduler().runTask(plugin(), () -> sendPlayerPackets(event.getPlayer())); } @EventHandler public void onLeave(PlayerQuitEvent event) { UUID uuid = event.getPlayer().getUniqueId(); + forgetDynamicViewer(uuid); + clearVisibilityShowQueue(uuid); for (Map.Entry> entry : shownViewers.entrySet()) { entry.getValue().remove(uuid); if (entry.getKey() instanceof Item item) { - removeVirtualItem(item, uuid, event.getPlayer()); + // The connection is closing; discard bookkeeping without + // sending a remove packet that cannot be observed. + forgetVirtualItem(item, uuid); } } playerStatus.remove(event.getPlayer()); } + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerChunkLoad(PlayerChunkLoadEvent event) { + Chunk chunk = event.getChunk(); + ChunkKey key = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); + Set logicals = logicalsByChunk.get(key); + if (logicals == null) { + return; + } + // This event fires after the chunk packet is sent and is Paper's + // supported lifecycle hook for client-side entities. Reconcile both + // packet-only items and Paper-owned displays here: a ChunkLoadEvent can + // otherwise call showEntity() too early and lose an entire chunk's + // display spawns. Chunk index values are concurrent sets, so no + // snapshot allocation is necessary. + for (VisualizerEntity logical : logicals) { + reconcileViewer(logical, event.getPlayer()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerChunkUnload(PlayerChunkUnloadEvent event) { + Chunk chunk = event.getChunk(); + ChunkKey key = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); + Set logicals = logicalsByChunk.get(key); + if (logicals == null) { + return; + } + Player player = event.getPlayer(); + VirtualItemIdBuffer virtualItemIdsToRemove = new VirtualItemIdBuffer(); + for (VisualizerEntity logical : logicals) { + if (logical instanceof Item item && packetOnlyItems.contains(item)) { + Integer id = forgetShownViewer(item, player.getUniqueId(), player); + if (id != null) { + virtualItemIdsToRemove.add(id); + } + } else { + // Revoke visibleByDefault=false's Paper override while the + // chunk is absent. This makes the next PlayerChunkLoadEvent the + // single source of truth and lets its visibility token bucket + // pace even entities whose server chunk stayed loaded. + hideViewer(logical, player.getUniqueId(), player); + } + } + removeVirtualItems(player, virtualItemIdsToRemove); + } + @EventHandler public void onChunkLoad(ChunkLoadEvent event) { ChunkKey key = new ChunkKey(event.getWorld().getUID(), event.getChunk().getX(), event.getChunk().getZ()); Set logicals = logicalsByChunk.get(key); if (logicals != null) { - for (VisualizerEntity logical : new HashSet<>(logicals)) { - if (active.containsKey(logical) && logical.getBukkitEntity().isEmpty()) { + for (VisualizerEntity logical : logicals) { + boolean missingRepresentation = logical instanceof DisplayEntity display && usesVirtualText(display) + ? !virtualTextDisplays.containsKey(display) + : logical.getBukkitEntity().isEmpty(); + if (active.containsKey(logical) && missingRepresentation) { schedule(logical, true); } } @@ -1078,6 +2255,9 @@ public void onEntityRemove(EntityRemoveEvent event) { renderedRevision.remove(logical); unindex(logical); renderedItemStacks.remove((Item) logical); + renderedItemLocations.remove((Item) logical); + packetOnlyItems.remove((Item) logical); + fixedItemDisplays.remove((Item) logical); return; } if (event.getCause() != EntityRemoveEvent.Cause.UNLOAD && active.containsKey(logical)) { @@ -1091,14 +2271,219 @@ public void onEntityRemove(EntityRemoveEvent event) { } } + static final class VisibilityShowQueue { + + private final LinkedHashSet pending; + private int tokens; + private long lastRefillTick; + + VisibilityShowQueue(int initialTokens) { + this(initialTokens, 0L); + } + + VisibilityShowQueue(int initialTokens, long initialTick) { + this.pending = new LinkedHashSet<>(); + this.tokens = Math.max(0, initialTokens); + this.lastRefillTick = initialTick; + } + + boolean request(T value) { + return pending.add(value); + } + + void cancel(T value) { + pending.remove(value); + } + + List drain(int capacity, int refill, Predicate desired) { + return drain(capacity, refill, lastRefillTick + 1L, desired); + } + + List drain(int capacity, int refill, long currentTick, Predicate desired) { + List ready = new ArrayList<>(); + drainTo(capacity, refill, currentTick, value -> { + if (!desired.test(value)) { + return false; + } + ready.add(value); + return true; + }); + return ready; + } + + int drainTo(int capacity, int refill, long currentTick, DrainAction action) { + int boundedCapacity = Math.max(1, capacity); + long elapsedTicks = Math.max(0L, currentTick - lastRefillTick); + long restored = elapsedTicks * (long) Math.max(0, refill); + tokens = (int) Math.min(boundedCapacity, Math.max(0L, tokens + restored)); + lastRefillTick = Math.max(lastRefillTick, currentTick); + if (tokens == 0 || pending.isEmpty()) { + return 0; + } + int drained = 0; + int inspected = 0; + while (tokens > 0 && inspected < boundedCapacity && !pending.isEmpty()) { + T value = pending.removeFirst(); + inspected++; + if (!action.accept(value)) { + continue; + } + tokens--; + drained++; + } + return drained; + } + + List drainAll(Predicate desired) { + List ready = new ArrayList<>(); + drainAllTo(value -> { + if (!desired.test(value)) { + return false; + } + ready.add(value); + return true; + }); + return ready; + } + + int drainAllTo(DrainAction action) { + int drained = 0; + while (!pending.isEmpty()) { + T value = pending.removeFirst(); + if (action.accept(value)) { + drained++; + } + } + return drained; + } + + boolean isEmpty() { + return pending.isEmpty(); + } + + boolean hasPending() { + return !pending.isEmpty(); + } + + void clear() { + pending.clear(); + } + + @FunctionalInterface + interface DrainAction { + + boolean accept(T value); + } + + } + + private static final class VirtualTextState { + + private final ClientTextDisplayBridge display; + private Component text; + private Location anchor; + private double radius; + private PathType path; + + private VirtualTextState(ClientTextDisplayBridge display, DisplayEntity logical) { + this.display = display; + this.text = Component.empty(); + this.anchor = null; + this.radius = Double.NaN; + this.path = null; + } + + private boolean positionProfileChanged(DisplayEntity logical) { + Location current = logical.getLocation(); + if (!sameLocation(anchor, current)) { + return true; + } + if (logical instanceof BillboardDisplayEntity billboard) { + return radius != billboard.getRadius() || path != billboard.getPathType(); + } + return !Double.isNaN(radius) || path != null; + } + + private void capture(DisplayEntity logical, Component text) { + this.text = text; + this.anchor = logical.getLocation(); + if (logical instanceof BillboardDisplayEntity billboard) { + this.radius = billboard.getRadius(); + this.path = billboard.getPathType(); + } else { + this.radius = Double.NaN; + this.path = null; + } + } + + private static boolean sameLocation(Location first, Location second) { + return first != null && second != null + && Objects.equals(first.getWorld(), second.getWorld()) + && first.getX() == second.getX() + && first.getY() == second.getY() + && first.getZ() == second.getZ() + && first.getYaw() == second.getYaw() + && first.getPitch() == second.getPitch(); + } + } + + private record DynamicViewerPosition(double x, double y, double z) { + + private static DynamicViewerPosition at(Location location) { + return new DynamicViewerPosition(location.getX(), location.getY(), location.getZ()); + } + + private boolean matches(Location location) { + return Math.abs(x - location.getX()) <= DYNAMIC_POSITION_EPSILON + && Math.abs(y - location.getY()) <= DYNAMIC_POSITION_EPSILON + && Math.abs(z - location.getZ()) <= DYNAMIC_POSITION_EPSILON; + } + } + private static final class ItemAnimationState { private Vector velocity; private final boolean gravity; + private Location position; + private final boolean staticAnchor; + private final Location logicalLocation; - private ItemAnimationState(Vector velocity, boolean gravity) { + private ItemAnimationState(Vector velocity, boolean gravity, Location position, + boolean staticAnchor, Location logicalLocation) { this.velocity = velocity.clone(); this.gravity = gravity; + this.position = position.clone(); + this.staticAnchor = staticAnchor; + this.logicalLocation = logicalLocation.clone(); + } + } + + private static final class VirtualItemIdBuffer { + + private static final int[] EMPTY = new int[0]; + + private int[] values = EMPTY; + private int size; + + private void add(int value) { + if (size == values.length) { + int[] expanded = new int[Math.max(16, size << 1)]; + System.arraycopy(values, 0, expanded, 0, size); + values = expanded; + } + values[size++] = value; + } + + private boolean isEmpty() { + return size == 0; + } + + private int size() { + return size; + } + + private void copyInto(int sourceOffset, int[] destination) { + System.arraycopy(values, sourceOffset, destination, 0, destination.length); } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListener.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListener.java new file mode 100644 index 00000000..33ac1bf7 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListener.java @@ -0,0 +1,614 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; +import com.loohp.interactionvisualizer.blocks.BeeHiveDisplay; +import com.loohp.interactionvisualizer.blocks.BeeNestDisplay; +import com.loohp.interactionvisualizer.blocks.BlastFurnaceDisplay; +import com.loohp.interactionvisualizer.blocks.FurnaceDisplay; +import com.loohp.interactionvisualizer.blocks.SmokerDisplay; +import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Directional; +import org.bukkit.entity.Bee; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockFadeEvent; +import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.block.BlockIgniteEvent; +import org.bukkit.event.block.BlockPistonExtendEvent; +import org.bukkit.event.block.BlockPistonRetractEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.block.BlockRedstoneEvent; +import org.bukkit.event.entity.EntityChangeBlockEvent; +import org.bukkit.event.entity.EntityEnterBlockEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.inventory.FurnaceExtractEvent; +import org.bukkit.event.inventory.FurnaceSmeltEvent; +import org.bukkit.event.inventory.FurnaceStartSmeltEvent; +import org.bukkit.event.inventory.InventoryMoveItemEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.Inventory; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.function.Consumer; + +/** + * Listener surface used exclusively by the startup-locked event-driven block + * update mode. Keeping these handlers off the display listeners means the + * legacy mode pays no Bukkit dispatch cost for them. + */ +public final class EventDrivenBlockUpdateListener implements Listener { + + private static final BlockFace[] REDSTONE_NEIGHBORS = { + BlockFace.NORTH, BlockFace.EAST, BlockFace.SOUTH, BlockFace.WEST, + BlockFace.UP, BlockFace.DOWN + }; + + private FurnaceDisplay furnace; + private BlastFurnaceDisplay blastFurnace; + private SmokerDisplay smoker; + private BeeHiveDisplay beeHive; + private BeeNestDisplay beeNest; + + public void add(FurnaceDisplay display) { + this.furnace = display; + } + + public void add(BlastFurnaceDisplay display) { + this.blastFurnace = display; + } + + public void add(SmokerDisplay display) { + this.smoker = display; + } + + public void add(BeeHiveDisplay display) { + this.beeHive = display; + } + + public void add(BeeNestDisplay display) { + this.beeNest = display; + } + + public boolean isEmpty() { + return furnace == null && blastFurnace == null && smoker == null && beeHive == null && beeNest == null; + } + + /* + * FurnaceBurnEvent is intentionally not part of this listener. Paper + * 26.1.2 emits it as a per-tick level signal for processing furnaces, so + * treating it as a dirty edge permanently saturates all three furnace + * schedulers. StartSmelt/Smelt/inventory/lifecycle edges bootstrap work; + * the active cadence owns progress and fuel-state refreshes thereafter. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onFurnaceStartSmelt(FurnaceStartSmeltEvent event) { + switch (furnaceTarget(event.getBlock().getType())) { + case FURNACE -> { + if (furnace != null) { + furnace.onFurnaceStartSmelt(event); + } + } + case BLAST_FURNACE -> { + if (blastFurnace != null) { + blastFurnace.onBlastFurnaceStartSmelt(event); + } + } + case SMOKER -> { + if (smoker != null) { + smoker.onSmokerStartSmelt(event); + } + } + case NONE -> { + } + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onFurnaceSmelt(FurnaceSmeltEvent event) { + switch (furnaceTarget(event.getBlock().getType())) { + case FURNACE -> { + if (furnace != null) { + furnace.onFurnaceSmelt(event); + } + } + case BLAST_FURNACE -> { + if (blastFurnace != null) { + blastFurnace.onBlastFurnaceSmelt(event); + } + } + case SMOKER -> { + if (smoker != null) { + smoker.onSmokerSmelt(event); + } + } + case NONE -> { + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onFurnaceExtract(FurnaceExtractEvent event) { + switch (furnaceTarget(event.getBlock().getType())) { + case FURNACE -> { + if (furnace != null) { + furnace.onFurnaceExtract(event); + } + } + case BLAST_FURNACE -> { + if (blastFurnace != null) { + blastFurnace.onBlastFurnaceExtract(event); + } + } + case SMOKER -> { + if (smoker != null) { + smoker.onSmokerExtract(event); + } + } + case NONE -> { + } + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryMoveItem(InventoryMoveItemEvent event) { + Inventory sourceInventory = event.getSource(); + Block source = inventoryBlock(sourceInventory); + routeInventoryBlock(source); + + Inventory destinationInventory = event.getDestination(); + Block destination = destinationInventory == sourceInventory ? source : inventoryBlock(destinationInventory); + if (destination != null && !destination.equals(source)) { + routeInventoryBlock(destination); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onTileEntityAdded(TileEntityAddedEvent event) { + TileEntityType type = event.getTileEntityType(); + if (type == null) { + return; + } + switch (type) { + case FURNACE -> { + if (furnace != null) { + furnace.onFurnaceAdded(event); + } + } + case BLAST_FURNACE -> { + if (blastFurnace != null) { + blastFurnace.onBlastFurnaceAdded(event); + } + } + case SMOKER -> { + if (smoker != null) { + smoker.onSmokerAdded(event); + } + } + case BEEHIVE -> { + if (beeHive != null) { + beeHive.onBeehiveAdded(event); + } + } + case BEE_NEST -> { + if (beeNest != null) { + beeNest.onBeenestAdded(event); + } + } + default -> { + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onTileEntityActivated(TileEntityActivatedEvent event) { + TileEntityType type = event.getTileEntityType(); + if (type == null) { + return; + } + switch (type) { + case FURNACE -> { + if (furnace != null) { + furnace.onFurnaceActivated(event); + } + } + case BLAST_FURNACE -> { + if (blastFurnace != null) { + blastFurnace.onBlastFurnaceActivated(event); + } + } + case SMOKER -> { + if (smoker != null) { + smoker.onSmokerActivated(event); + } + } + case BEEHIVE -> { + if (beeHive != null) { + beeHive.onBeehiveActivated(event); + } + } + case BEE_NEST -> { + if (beeNest != null) { + beeNest.onBeenestActivated(event); + } + } + default -> { + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onTileEntityDeactivated(TileEntityDeactivatedEvent event) { + TileEntityType type = event.getTileEntityType(); + if (type == null) { + return; + } + switch (type) { + case FURNACE -> { + if (furnace != null) { + furnace.onFurnaceDeactivated(event); + } + } + case BLAST_FURNACE -> { + if (blastFurnace != null) { + blastFurnace.onBlastFurnaceDeactivated(event); + } + } + case SMOKER -> { + if (smoker != null) { + smoker.onSmokerDeactivated(event); + } + } + case BEEHIVE -> { + if (beeHive != null) { + beeHive.onBeehiveDeactivated(event); + } + } + case BEE_NEST -> { + if (beeNest != null) { + beeNest.onBeenestDeactivated(event); + } + } + default -> { + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onTileEntityRemoved(TileEntityRemovedEvent event) { + if (event.getTileEntityType() == TileEntityType.SMOKER && smoker != null) { + smoker.onRemoveSmoker(event); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockPlace(BlockPlaceEvent event) { + routeAffectedColumn(event.getBlockPlaced()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockBreak(BlockBreakEvent event) { + routeAffectedColumn(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockBurn(BlockBurnEvent event) { + routeAffectedColumn(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockFade(BlockFadeEvent event) { + routeAffectedColumn(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockIgnite(BlockIgniteEvent event) { + routeAffectedColumn(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedFluidFlow(BlockFromToEvent event) { + routeAffectedColumn(event.getToBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedBlockExplode(BlockExplodeEvent event) { + for (Block block : event.blockList()) { + routeAffectedColumn(block); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedEntityExplode(EntityExplodeEvent event) { + for (Block block : event.blockList()) { + routeAffectedColumn(block); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDispenserHarvest(BlockDispenseEvent event) { + Material dispensed = event.getItem().getType(); + if (dispensed != Material.GLASS_BOTTLE && dispensed != Material.SHEARS) { + return; + } + BlockData data = event.getBlock().getBlockData(); + if (data instanceof Directional directional) { + routeBeeBlock(event.getBlock().getRelative(directional.getFacing())); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBeeEnterBlock(EntityEnterBlockEvent event) { + if (hasBeeDisplays() && event.getEntity() instanceof Bee) { + routeBeeBlock(event.getBlock()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onEntityChangeBlock(EntityChangeBlockEvent event) { + if (!hasBeeDisplays()) { + return; + } + Block block = event.getBlock(); + if (event.getEntity() instanceof Bee && beeTarget(block.getType()) != BeeTarget.NONE) { + routeBeeBlock(block); + return; + } + // Falling blocks, Endermen and other entity-driven changes can alter + // the unobstructed campfire smoke column even when no bee is involved. + routeAffectedColumn(block); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBeeRelevantInteract(PlayerInteractEvent event) { + if (!hasBeeDisplays()) { + return; + } + Block block = event.getClickedBlock(); + if (block == null) { + return; + } + Material material = block.getType(); + if (beeTarget(material) != BeeTarget.NONE) { + routeBeeBlock(block); + } else if (isSmokeColumnInteractable(material)) { + routeAffectedColumn(block); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedPistonExtend(BlockPistonExtendEvent event) { + if (!hasBeeDisplays()) { + return; + } + routeAffectedColumns(pistonAffectedBlocks(event.getBlock(), event.getBlocks(), + event.getDirection(), event.getDirection())); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAffectedPistonRetract(BlockPistonRetractEvent event) { + if (!hasBeeDisplays()) { + return; + } + routeAffectedColumns(pistonAffectedBlocks(event.getBlock(), event.getBlocks(), + event.getDirection(), event.getDirection().getOppositeFace())); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onAffectedRedstone(BlockRedstoneEvent event) { + if (!hasBeeDisplays() || event.getOldCurrent() == event.getNewCurrent()) { + return; + } + Set changedOpenables = null; + Block source = event.getBlock(); + if (isRedstoneOpenable(source.getType())) { + changedOpenables = new LinkedHashSet<>(); + changedOpenables.add(source); + } + for (BlockFace face : REDSTONE_NEIGHBORS) { + Block neighbor = source.getRelative(face); + if (isRedstoneOpenable(neighbor.getType())) { + if (changedOpenables == null) { + changedOpenables = new LinkedHashSet<>(); + } + changedOpenables.add(neighbor); + } + } + if (changedOpenables != null) { + routeAffectedColumns(changedOpenables); + } + } + + private void routeInventoryBlock(Block block) { + if (block == null) { + return; + } + switch (furnaceTarget(block.getType())) { + case FURNACE -> { + if (furnace != null) { + furnace.onFurnaceInventoryChanged(block); + } + } + case BLAST_FURNACE -> { + if (blastFurnace != null) { + blastFurnace.onBlastFurnaceInventoryChanged(block); + } + } + case SMOKER -> { + if (smoker != null) { + smoker.onSmokerInventoryChanged(block); + } + } + case NONE -> { + } + } + } + + private boolean hasBeeDisplays() { + return beeHive != null || beeNest != null; + } + + private void routeAffectedColumn(Block changedBlock) { + if (changedBlock == null || beeHive == null && beeNest == null) { + return; + } + scanAffectedColumn(changedBlock, this::routeBeeBlock); + } + + private void routeAffectedColumns(Collection changedBlocks) { + if (changedBlocks == null || changedBlocks.isEmpty() || beeHive == null && beeNest == null) { + return; + } + scanAffectedColumns(changedBlocks, this::routeBeeBlock); + } + + private void routeBeeBlock(Block block) { + if (block == null) { + return; + } + switch (beeTarget(block.getType())) { + case HIVE -> { + if (beeHive != null) { + beeHive.onAffectedBeeBlock(block); + } + } + case NEST -> { + if (beeNest != null) { + beeNest.onAffectedBeeBlock(block); + } + } + case NONE -> { + } + } + } + + static Block inventoryBlock(Inventory inventory) { + if (inventory == null) { + return null; + } + try { + Location location = inventory.getLocation(); + return location == null ? null : location.getBlock(); + } catch (Exception | AbstractMethodError ignored) { + return null; + } + } + + static FurnaceTarget furnaceTarget(Material material) { + if (material == null) { + return FurnaceTarget.NONE; + } + return switch (material) { + case FURNACE -> FurnaceTarget.FURNACE; + case BLAST_FURNACE -> FurnaceTarget.BLAST_FURNACE; + case SMOKER -> FurnaceTarget.SMOKER; + default -> FurnaceTarget.NONE; + }; + } + + static BeeTarget beeTarget(Material material) { + if (material == null) { + return BeeTarget.NONE; + } + return switch (material) { + case BEEHIVE -> BeeTarget.HIVE; + case BEE_NEST -> BeeTarget.NEST; + default -> BeeTarget.NONE; + }; + } + + static boolean isRedstoneOpenable(Material material) { + if (material == null) { + return false; + } + String name = material.name(); + return name.endsWith("_TRAPDOOR") || name.endsWith("_DOOR") || name.endsWith("_FENCE_GATE"); + } + + static boolean isSmokeColumnInteractable(Material material) { + return material == Material.CAMPFIRE || material == Material.SOUL_CAMPFIRE + || isRedstoneOpenable(material); + } + + static Set pistonAffectedBlocks(Block piston, Collection movedBlocks, + BlockFace movementDirection, BlockFace headDirection) { + Set affected = new LinkedHashSet<>(); + if (piston != null) { + affected.add(piston); + affected.add(piston.getRelative(headDirection)); + } + if (movedBlocks != null) { + for (Block moved : movedBlocks) { + if (moved != null) { + affected.add(moved); + affected.add(moved.getRelative(movementDirection)); + } + } + } + return affected; + } + + static void scanAffectedColumn(Block changedBlock, Consumer route) { + if (changedBlock == null || route == null) { + return; + } + for (int distance = 1; distance <= 5; distance++) { + Block candidate = changedBlock.getRelative(BlockFace.UP, distance); + if (beeTarget(candidate.getType()) != BeeTarget.NONE) { + route.accept(candidate); + } + } + } + + static void scanAffectedColumns(Collection changedBlocks, Consumer route) { + if (changedBlocks == null || changedBlocks.isEmpty() || route == null) { + return; + } + Set routed = new LinkedHashSet<>(); + for (Block changedBlock : changedBlocks) { + scanAffectedColumn(changedBlock, candidate -> { + if (routed.add(candidate)) { + route.accept(candidate); + } + }); + } + } + + enum FurnaceTarget { + FURNACE, + BLAST_FURNACE, + SMOKER, + NONE + } + + enum BeeTarget { + HIVE, + NEST, + NONE + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java new file mode 100644 index 00000000..52f3df41 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java @@ -0,0 +1,552 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import com.destroystokyo.paper.event.server.ServerTickEndEvent; +import com.loohp.interactionvisualizer.InteractionVisualizer; +import com.loohp.interactionvisualizer.utils.LegacyTextComponentCache; +import org.bukkit.Bukkit; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.plugin.Plugin; + +import java.util.Arrays; +import java.util.Locale; + +/** + * Opt-in, allocation-free counters for reproducible live A/B measurements. + * + *

The hot-path methods deliberately compile down to one predictable branch + * while sampling is disabled. They count only packet operations owned by this + * plugin; external on-wire byte accounting remains the responsibility of a + * packet capture tool.

+ */ +public final class PerformanceMetrics implements Listener { + + private static final int MAX_TICK_SAMPLES = 72_000; + private static final PerformanceMetrics INSTANCE = new PerformanceMetrics(); + + private final double[] tickDurations = new double[MAX_TICK_SAMPLES]; + private final SlowestTickTracker slowestTickTracker = new SlowestTickTracker(); + + private volatile boolean collecting; + private String label = ""; + private boolean configStaticAnchor; + private boolean configPacketOnlyStatic; + private boolean configVisibilityRateLimit; + private int configVisibilityBucketSize; + private int configVisibilityRestorePerTick; + private DroppedLabelVisibilityConfig droppedLabelVisibilityConfig = + new DroppedLabelVisibilityConfig(false, 64, false, 128, 32); + private DroppedLabelVisibilityConfig configDroppedLabelVisibility = droppedLabelVisibilityConfig; + private boolean configEventDrivenBlockUpdates; + private int configBlockUpdateMaxDirtyPerTick; + private long startedNanos; + private int tickCount; + private long tickSamplesDropped; + private long virtualSpawnBundles; + private long virtualMotionBundles; + private long virtualTeleportBundles; + private long virtualRemovePackets; + private long virtualPickupPackets; + private long bukkitEntitySpawns; + private long bukkitEntityRemoves; + private long bukkitEntityTeleports; + private long bukkitShowCalls; + private long bukkitHideCalls; + private long displaySyncs; + private long itemSyncs; + private long packetOnlyItemSyncs; + private long virtualViewerChecks; + private long visibilityShowsQueued; + private long visibilityShowsDrained; + private long itemAnimationNanos; + private long droppedItemNanos; + private long blockUpdateChecks; + private long blockUpdateNanos; + + private PerformanceMetrics() { + } + + public static void register(Plugin plugin) { + Bukkit.getPluginManager().registerEvents(INSTANCE, plugin); + } + + public static boolean isCollecting() { + return INSTANCE.collecting; + } + + public static void droppedLabelVisibilityConfig(boolean cullingEnabled, + int viewDistance, + boolean rateLimitEnabled, + int bucketSize, + int restorePerTick) { + INSTANCE.droppedLabelVisibilityConfig = new DroppedLabelVisibilityConfig( + cullingEnabled, viewDistance, rateLimitEnabled, bucketSize, restorePerTick); + } + + public static boolean start(String requestedLabel) { + if (INSTANCE.collecting) { + return false; + } + INSTANCE.label = sanitizeLabel(requestedLabel); + INSTANCE.configStaticAnchor = InteractionVisualizer.staticVirtualItemAnchorsDuringAnimation; + INSTANCE.configPacketOnlyStatic = InteractionVisualizer.packetOnlyStaticVirtualItems; + INSTANCE.configVisibilityRateLimit = InteractionVisualizer.visibilityRateLimiting; + INSTANCE.configVisibilityBucketSize = InteractionVisualizer.visibilityRateLimitBucketSize; + INSTANCE.configVisibilityRestorePerTick = InteractionVisualizer.visibilityRateLimitRestorePerTick; + INSTANCE.configDroppedLabelVisibility = INSTANCE.droppedLabelVisibilityConfig; + INSTANCE.configEventDrivenBlockUpdates = InteractionVisualizer.eventDrivenBlockUpdates; + INSTANCE.configBlockUpdateMaxDirtyPerTick = InteractionVisualizer.blockUpdateMaxDirtyPerTick; + INSTANCE.startedNanos = System.nanoTime(); + INSTANCE.tickCount = 0; + INSTANCE.tickSamplesDropped = 0; + INSTANCE.virtualSpawnBundles = 0; + INSTANCE.virtualMotionBundles = 0; + INSTANCE.virtualTeleportBundles = 0; + INSTANCE.virtualRemovePackets = 0; + INSTANCE.virtualPickupPackets = 0; + INSTANCE.bukkitEntitySpawns = 0; + INSTANCE.bukkitEntityRemoves = 0; + INSTANCE.bukkitEntityTeleports = 0; + INSTANCE.bukkitShowCalls = 0; + INSTANCE.bukkitHideCalls = 0; + INSTANCE.displaySyncs = 0; + INSTANCE.itemSyncs = 0; + INSTANCE.packetOnlyItemSyncs = 0; + INSTANCE.virtualViewerChecks = 0; + INSTANCE.visibilityShowsQueued = 0; + INSTANCE.visibilityShowsDrained = 0; + INSTANCE.itemAnimationNanos = 0; + INSTANCE.droppedItemNanos = 0; + INSTANCE.blockUpdateChecks = 0; + INSTANCE.blockUpdateNanos = 0; + INSTANCE.slowestTickTracker.reset(); + LegacyTextComponentCache.startMeasurement(); + INSTANCE.collecting = true; + return true; + } + + public static Snapshot stop() { + if (!INSTANCE.collecting) { + return null; + } + INSTANCE.collecting = false; + LegacyTextComponentCache.CacheMetrics textCache = LegacyTextComponentCache.stopMeasurement(); + Snapshot snapshot = INSTANCE.createSnapshot(textCache); + InteractionVisualizer.plugin.getLogger().info("IV_PERF " + snapshot.json()); + return snapshot; + } + + public static Snapshot snapshot() { + return INSTANCE.collecting + ? INSTANCE.createSnapshot(LegacyTextComponentCache.metrics()) + : null; + } + + public static void virtualSpawnBundle() { + if (INSTANCE.collecting) { + INSTANCE.virtualSpawnBundles++; + } + } + + public static void virtualMotionBundle() { + if (INSTANCE.collecting) { + INSTANCE.virtualMotionBundles++; + } + } + + public static void virtualTeleportBundle() { + if (INSTANCE.collecting) { + INSTANCE.virtualTeleportBundles++; + } + } + + public static void virtualRemovePacket() { + if (INSTANCE.collecting) { + INSTANCE.virtualRemovePackets++; + } + } + + public static void virtualPickupPacket() { + if (INSTANCE.collecting) { + INSTANCE.virtualPickupPackets++; + } + } + + public static void bukkitEntitySpawn() { + if (INSTANCE.collecting) { + INSTANCE.bukkitEntitySpawns++; + } + } + + public static void bukkitEntityRemove() { + if (INSTANCE.collecting) { + INSTANCE.bukkitEntityRemoves++; + } + } + + public static void bukkitEntityTeleport() { + if (INSTANCE.collecting) { + INSTANCE.bukkitEntityTeleports++; + } + } + + public static void bukkitShow() { + if (INSTANCE.collecting) { + INSTANCE.bukkitShowCalls++; + } + } + + public static void bukkitHide() { + if (INSTANCE.collecting) { + INSTANCE.bukkitHideCalls++; + } + } + + public static void displaySync() { + if (INSTANCE.collecting) { + INSTANCE.displaySyncs++; + } + } + + public static void itemSync() { + if (INSTANCE.collecting) { + INSTANCE.itemSyncs++; + } + } + + public static void packetOnlyItemSync() { + if (INSTANCE.collecting) { + INSTANCE.packetOnlyItemSyncs++; + } + } + + public static void virtualViewerChecks(int checks) { + if (INSTANCE.collecting) { + INSTANCE.virtualViewerChecks += checks; + } + } + + public static void visibilityShowQueued() { + if (INSTANCE.collecting) { + INSTANCE.visibilityShowsQueued++; + } + } + + public static void visibilityShowDrained() { + if (INSTANCE.collecting) { + INSTANCE.visibilityShowsDrained++; + } + } + + public static void itemAnimationNanos(long nanos) { + if (INSTANCE.collecting) { + INSTANCE.itemAnimationNanos += nanos; + } + } + + public static void droppedItemNanos(long nanos) { + if (INSTANCE.collecting) { + INSTANCE.droppedItemNanos += nanos; + } + } + + public static void blockUpdateChecks(int checks, long nanos) { + if (INSTANCE.collecting) { + INSTANCE.blockUpdateChecks += checks; + INSTANCE.blockUpdateNanos += nanos; + INSTANCE.slowestTickTracker.blockUpdateChecks(checks, nanos); + } + } + + @EventHandler + public void onServerTickEnd(ServerTickEndEvent event) { + if (!collecting) { + return; + } + if (tickCount < tickDurations.length) { + // Paper exposes this duration in milliseconds. + double duration = event.getTickDuration(); + tickDurations[tickCount++] = duration; + slowestTickTracker.completeTick(Bukkit.getCurrentTick(), duration); + } else { + tickSamplesDropped++; + slowestTickTracker.discardTick(); + } + } + + private Snapshot createSnapshot(LegacyTextComponentCache.CacheMetrics textCache) { + int samples = tickCount; + double[] sorted = Arrays.copyOf(tickDurations, samples); + Arrays.sort(sorted); + double mean = 0.0D; + long over50 = 0; + for (double duration : sorted) { + mean += duration; + if (duration > 50.0D) { + over50++; + } + } + mean = samples == 0 ? 0.0D : mean / samples; + long elapsedNanos = Math.max(1L, System.nanoTime() - startedNanos); + return new Snapshot(label, configStaticAnchor, configPacketOnlyStatic, configVisibilityRateLimit, + configVisibilityBucketSize, configVisibilityRestorePerTick, configDroppedLabelVisibility, + configEventDrivenBlockUpdates, + configBlockUpdateMaxDirtyPerTick, LegacyTextComponentCache.isEnabled(), + elapsedNanos, samples, tickSamplesDropped, + percentile(sorted, 0.50D), percentile(sorted, 0.95D), percentile(sorted, 0.99D), + percentile(sorted, 0.999D), samples == 0 ? 0.0D : sorted[samples - 1], + slowestTickTracker.slowestBukkitTick(), slowestTickTracker.slowestEndEpochMillis(), + slowestTickTracker.slowestBlockUpdateChecks(), + slowestTickTracker.slowestBlockUpdateNanos(), mean, over50, + virtualSpawnBundles, virtualMotionBundles, virtualTeleportBundles, virtualRemovePackets, + virtualPickupPackets, bukkitEntitySpawns, bukkitEntityRemoves, bukkitEntityTeleports, + bukkitShowCalls, bukkitHideCalls, displaySyncs, itemSyncs, packetOnlyItemSyncs, + virtualViewerChecks, visibilityShowsQueued, visibilityShowsDrained, + itemAnimationNanos, droppedItemNanos, blockUpdateChecks, blockUpdateNanos, + textCache.requests(), textCache.misses(), textCache.sameRawFastPaths()); + } + + private static double percentile(double[] sorted, double percentile) { + if (sorted.length == 0) { + return 0.0D; + } + int index = (int) Math.ceil(percentile * sorted.length) - 1; + return sorted[Math.max(0, Math.min(sorted.length - 1, index))]; + } + + private static String sanitizeLabel(String value) { + if (value == null || value.isBlank()) { + return "unnamed"; + } + String sanitized = value.replaceAll("[^A-Za-z0-9_.-]", "_"); + return sanitized.substring(0, Math.min(48, sanitized.length())); + } + + public record DroppedLabelVisibilityConfig( + boolean cullingEnabled, + int viewDistance, + boolean rateLimitEnabled, + int bucketSize, + int restorePerTick) { + } + + public record Snapshot( + String label, + boolean staticAnchorDuringAnimation, + boolean packetOnlyStatic, + boolean visibilityRateLimit, + int visibilityBucketSize, + int visibilityRestorePerTick, + DroppedLabelVisibilityConfig droppedLabelVisibility, + boolean eventDrivenBlockUpdates, + int blockUpdateMaxDirtyPerTick, + boolean legacyTextComponentCache, + long elapsedNanos, + int tickSamples, + long droppedTickSamples, + double msptP50, + double msptP95, + double msptP99, + double msptP999, + double msptMax, + int msptMaxBukkitTick, + long msptMaxEndEpochMillis, + long msptMaxBlockUpdateChecks, + long msptMaxBlockUpdateNanos, + double msptMean, + long ticksOver50ms, + long virtualSpawnBundles, + long virtualMotionBundles, + long virtualTeleportBundles, + long virtualRemovePackets, + long virtualPickupPackets, + long bukkitEntitySpawns, + long bukkitEntityRemoves, + long bukkitEntityTeleports, + long bukkitShowCalls, + long bukkitHideCalls, + long displaySyncs, + long itemSyncs, + long packetOnlyItemSyncs, + long virtualViewerChecks, + long visibilityShowsQueued, + long visibilityShowsDrained, + long itemAnimationNanos, + long droppedItemNanos, + long blockUpdateChecks, + long blockUpdateNanos, + long legacyTextCacheRequests, + long legacyTextCacheMisses, + long legacyTextSameRawFastPaths) { + + public double seconds() { + return elapsedNanos / 1_000_000_000.0D; + } + + /** Wall-clock completed server ticks per second over this exact sample window. */ + public double observedTps() { + double seconds = seconds(); + return seconds <= 0.0D ? 0.0D : (tickSamples + droppedTickSamples) / seconds; + } + + /** Sparrow outer packets are exact; Bukkit operations remain separately reported. */ + public long knownVirtualPackets() { + return virtualSpawnBundles + virtualMotionBundles + virtualTeleportBundles + + virtualRemovePackets + virtualPickupPackets; + } + + public long legacyTextCacheHits() { + return Math.max(0L, legacyTextCacheRequests - legacyTextCacheMisses); + } + + public double legacyTextCacheHitRate() { + return legacyTextCacheRequests == 0L + ? 0.0D + : (double) legacyTextCacheHits() / (double) legacyTextCacheRequests; + } + + public String summary() { + return String.format(Locale.ROOT, + "label=%s modes=%s/%s/%s/%s textCache=%s/%.1f%% samples=%d tps=%.3f p50/p95/p99=%.3f/%.3f/%.3fms virtualPackets=%d anchors=%d/%d/%d", + label, staticAnchorDuringAnimation, packetOnlyStatic, visibilityRateLimit, + eventDrivenBlockUpdates, legacyTextComponentCache, legacyTextCacheHitRate() * 100.0D, + tickSamples, observedTps(), msptP50, msptP95, msptP99, knownVirtualPackets(), + bukkitEntitySpawns, bukkitEntityTeleports, bukkitEntityRemoves); + } + + public String json() { + return String.format(Locale.ROOT, + "{\"label\":\"%s\",\"staticAnchorDuringAnimation\":%b," + + "\"packetOnlyStatic\":%b,\"visibilityRateLimit\":%b," + + "\"visibilityBucketSize\":%d,\"visibilityRestorePerTick\":%d," + + "\"droppedLabelVisibilityCulling\":%b,\"droppedLabelViewDistance\":%d," + + "\"droppedLabelVisibilityRateLimit\":%b," + + "\"droppedLabelVisibilityBucketSize\":%d," + + "\"droppedLabelVisibilityRestorePerTick\":%d," + + "\"eventDrivenBlockUpdates\":%b,\"blockUpdateMaxDirtyPerTick\":%d," + + "\"legacyTextComponentCache\":%b," + + "\"seconds\":%.3f,\"tickSamples\":%d,\"observedTps\":%.6f," + + "\"droppedTickSamples\":%d,\"msptP50\":%.6f,\"msptP95\":%.6f," + + "\"msptP99\":%.6f,\"msptP999\":%.6f,\"msptMax\":%.6f," + + "\"msptMaxBukkitTick\":%d,\"msptMaxEndEpochMillis\":%d," + + "\"msptMaxBlockUpdateChecks\":%d," + + "\"msptMaxBlockUpdateMs\":%.6f," + + "\"msptMean\":%.6f,\"ticksOver50ms\":%d," + + "\"virtualSpawnBundles\":%d,\"virtualMotionBundles\":%d," + + "\"virtualTeleportBundles\":%d,\"virtualRemovePackets\":%d," + + "\"virtualPickupPackets\":%d,\"knownVirtualPackets\":%d," + + "\"bukkitEntitySpawns\":%d,\"bukkitEntityRemoves\":%d," + + "\"bukkitEntityTeleports\":%d,\"bukkitShowCalls\":%d," + + "\"bukkitHideCalls\":%d,\"displaySyncs\":%d,\"itemSyncs\":%d," + + "\"packetOnlyItemSyncs\":%d,\"virtualViewerChecks\":%d," + + "\"visibilityShowsQueued\":%d,\"visibilityShowsDrained\":%d," + + "\"itemAnimationMs\":%.6f,\"droppedItemMs\":%.6f," + + "\"blockUpdateChecks\":%d,\"blockUpdateMs\":%.6f," + + "\"legacyTextCacheRequests\":%d,\"legacyTextCacheMisses\":%d," + + "\"legacyTextCacheHits\":%d,\"legacyTextCacheHitRate\":%.6f," + + "\"legacyTextSameRawFastPaths\":%d}", + label, staticAnchorDuringAnimation, packetOnlyStatic, visibilityRateLimit, + visibilityBucketSize, visibilityRestorePerTick, + droppedLabelVisibility.cullingEnabled(), droppedLabelVisibility.viewDistance(), + droppedLabelVisibility.rateLimitEnabled(), droppedLabelVisibility.bucketSize(), + droppedLabelVisibility.restorePerTick(), eventDrivenBlockUpdates, + blockUpdateMaxDirtyPerTick, legacyTextComponentCache, + seconds(), tickSamples, observedTps(), droppedTickSamples, + msptP50, msptP95, msptP99, + msptP999, msptMax, msptMaxBukkitTick, msptMaxEndEpochMillis, msptMaxBlockUpdateChecks, + msptMaxBlockUpdateNanos / 1_000_000.0D, msptMean, ticksOver50ms, virtualSpawnBundles, + virtualMotionBundles, virtualTeleportBundles, virtualRemovePackets, virtualPickupPackets, + knownVirtualPackets(), bukkitEntitySpawns, bukkitEntityRemoves, bukkitEntityTeleports, + bukkitShowCalls, bukkitHideCalls, displaySyncs, itemSyncs, packetOnlyItemSyncs, + virtualViewerChecks, visibilityShowsQueued, visibilityShowsDrained, + itemAnimationNanos / 1_000_000.0D, droppedItemNanos / 1_000_000.0D, + blockUpdateChecks, blockUpdateNanos / 1_000_000.0D, + legacyTextCacheRequests, legacyTextCacheMisses, legacyTextCacheHits(), + legacyTextCacheHitRate(), legacyTextSameRawFastPaths); + } + } + + /** + * Constant-space attribution for block-update work performed during the + * slowest completed tick. This tracker is main-thread confined by the + * Paper runtime; package visibility keeps its rollover semantics directly + * unit-testable without starting a server. + */ + static final class SlowestTickTracker { + + private long currentBlockUpdateChecks; + private long currentBlockUpdateNanos; + private double slowestDuration; + private int slowestBukkitTick; + private long slowestEndEpochMillis; + private long slowestBlockUpdateChecks; + private long slowestBlockUpdateNanos; + + SlowestTickTracker() { + reset(); + } + + void reset() { + currentBlockUpdateChecks = 0; + currentBlockUpdateNanos = 0; + slowestDuration = -1.0D; + slowestBukkitTick = -1; + slowestEndEpochMillis = -1L; + slowestBlockUpdateChecks = 0; + slowestBlockUpdateNanos = 0; + } + + void blockUpdateChecks(int checks, long nanos) { + currentBlockUpdateChecks += checks; + currentBlockUpdateNanos += nanos; + } + + void completeTick(int bukkitTick, double duration) { + long endEpochMillis = duration > slowestDuration ? System.currentTimeMillis() : -1L; + completeTick(bukkitTick, duration, endEpochMillis); + } + + void completeTick(int bukkitTick, double duration, long endEpochMillis) { + if (duration > slowestDuration) { + slowestDuration = duration; + slowestBukkitTick = bukkitTick; + slowestEndEpochMillis = endEpochMillis; + slowestBlockUpdateChecks = currentBlockUpdateChecks; + slowestBlockUpdateNanos = currentBlockUpdateNanos; + } + discardTick(); + } + + void discardTick() { + currentBlockUpdateChecks = 0; + currentBlockUpdateNanos = 0; + } + + int slowestBukkitTick() { + return slowestBukkitTick; + } + + long slowestEndEpochMillis() { + return slowestEndEpochMillis; + } + + long slowestBlockUpdateChecks() { + return slowestBlockUpdateChecks; + } + + long slowestBlockUpdateNanos() { + return slowestBlockUpdateNanos; + } + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java index a581d748..4aac7f97 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java @@ -71,6 +71,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; public class TaskManager { @@ -114,9 +115,15 @@ public class TaskManager { public static Map> processes = new ConcurrentHashMap<>(); public static List runnables = new ArrayList<>(); + static final long INVENTORY_OPEN_PROCESS_DELAY_TICKS = 1L; + static final long INVENTORY_PROCESS_DELAY_TICKS = 2L; + private static final Map pendingInventoryOpenProcesses = new ConcurrentHashMap<>(); + private static final Map pendingInventoryRefreshes = new ConcurrentHashMap<>(); @SuppressWarnings("deprecation") public static void setup() { + pendingInventoryOpenProcesses.clear(); + pendingInventoryRefreshes.clear(); anvil = false; banner = false; barrel = false; @@ -162,6 +169,8 @@ public static void setup() { */ List keys = new ArrayList<>(); + EventDrivenBlockUpdateListener eventDrivenBlockUpdateListener = + InteractionVisualizer.eventDrivenBlockUpdates ? new EventDrivenBlockUpdateListener() : null; Bukkit.getPluginManager().registerEvents(new Debug(), plugin); Bukkit.getPluginManager().registerEvents(new Updater(), plugin); @@ -253,6 +262,9 @@ public static void setup() { FurnaceDisplay fd = new FurnaceDisplay(); keys.add(fd.registerNative()); Bukkit.getPluginManager().registerEvents(fd, plugin); + if (eventDrivenBlockUpdateListener != null) { + eventDrivenBlockUpdateListener.add(fd); + } furnace = true; } @@ -260,6 +272,9 @@ public static void setup() { BlastFurnaceDisplay bfd = new BlastFurnaceDisplay(); keys.add(bfd.registerNative()); Bukkit.getPluginManager().registerEvents(bfd, plugin); + if (eventDrivenBlockUpdateListener != null) { + eventDrivenBlockUpdateListener.add(bfd); + } blastfurnace = true; } @@ -267,6 +282,9 @@ public static void setup() { SmokerDisplay sd = new SmokerDisplay(); keys.add(sd.registerNative()); Bukkit.getPluginManager().registerEvents(sd, plugin); + if (eventDrivenBlockUpdateListener != null) { + eventDrivenBlockUpdateListener.add(sd); + } smoker = true; } @@ -337,6 +355,9 @@ public static void setup() { BeeNestDisplay bnd = new BeeNestDisplay(); keys.add(bnd.registerNative()); Bukkit.getPluginManager().registerEvents(bnd, plugin); + if (eventDrivenBlockUpdateListener != null) { + eventDrivenBlockUpdateListener.add(bnd); + } beenest = true; } @@ -344,6 +365,9 @@ public static void setup() { BeeHiveDisplay bhd = new BeeHiveDisplay(); keys.add(bhd.registerNative()); Bukkit.getPluginManager().registerEvents(bhd, plugin); + if (eventDrivenBlockUpdateListener != null) { + eventDrivenBlockUpdateListener.add(bhd); + } beehive = true; } @@ -410,6 +434,10 @@ public static void setup() { villager = true; } + if (eventDrivenBlockUpdateListener != null && !eventDrivenBlockUpdateListener.isEmpty()) { + Bukkit.getPluginManager().registerEvents(eventDrivenBlockUpdateListener, plugin); + } + InteractionVisualizer.preferenceManager.registerEntry(keys); InteractionVisualizer.lightManager.run(); DisplayManager.update(); @@ -422,19 +450,115 @@ public static void run() { } public static void processOpenInventory(Player player) { - Scheduler.runTaskLater(plugin, () -> { - if (!player.isOnline()) { - return; - } - Inventory inventory = player.getOpenInventory().getTopInventory(); - InventoryType type = inventory.getType(); - if (type == InventoryType.CRAFTING || type == InventoryType.CREATIVE) { - return; - } - for (VisualizerInteractDisplay display : processes.getOrDefault(type, List.of())) { + if (!player.isOnline()) { + return; + } + UUID playerId = player.getUniqueId(); + if (!markInventoryOpenProcessQueued(playerId)) { + return; + } + Object request = pendingInventoryOpenProcesses.get(playerId); + try { + Scheduler.runTaskLater(plugin, () -> { + if (pendingInventoryOpenProcesses.remove(playerId, request)) { + processCurrentInventory(player, true); + } + }, INVENTORY_OPEN_PROCESS_DELAY_TICKS, player); + } catch (RuntimeException exception) { + pendingInventoryOpenProcesses.remove(playerId, request); + throw exception; + } + } + + public static void refreshOpenInventory(Player player) { + if (!player.isOnline()) { + return; + } + UUID playerId = player.getUniqueId(); + if (!markInventoryRefreshQueued(playerId)) { + return; + } + Object request = pendingInventoryRefreshes.get(playerId); + try { + Scheduler.runTaskLater(plugin, () -> { + if (pendingInventoryRefreshes.remove(playerId, request)) { + processCurrentInventory(player, false); + } + }, INVENTORY_PROCESS_DELAY_TICKS, player); + } catch (RuntimeException exception) { + pendingInventoryRefreshes.remove(playerId, request); + throw exception; + } + } + + static boolean markInventoryOpenProcessQueued(UUID playerId) { + // The one-tick open callback observes the same or newer state and also + // includes every native display, so an older two-tick refresh is redundant. + pendingInventoryRefreshes.remove(playerId); + return pendingInventoryOpenProcesses.putIfAbsent(playerId, new Object()) == null; + } + + static boolean markInventoryRefreshQueued(UUID playerId) { + if (pendingInventoryOpenProcesses.containsKey(playerId)) { + return false; + } + return pendingInventoryRefreshes.putIfAbsent(playerId, new Object()) == null; + } + + public static void clearPendingInventoryProcess(UUID playerId) { + pendingInventoryOpenProcesses.remove(playerId); + pendingInventoryRefreshes.remove(playerId); + } + + static void processCurrentInventory(Player player) { + processCurrentInventory(player, true); + } + + static void processCurrentInventory(Player player, boolean includeCustomDisplays) { + if (!player.isOnline()) { + return; + } + Inventory inventory = player.getOpenInventory().getTopInventory(); + processInventoryDisplays(player, inventory.getType(), includeCustomDisplays); + } + + static void processInventoryDisplays(Player player, InventoryType type) { + processInventoryDisplays(player, type, true); + } + + static void processInventoryDisplays(Player player, InventoryType type, boolean includeCustomDisplays) { + if (!hasInventoryDisplays(type)) { + return; + } + for (VisualizerInteractDisplay display : processes.getOrDefault(type, List.of())) { + if (includeCustomDisplays || isNativeInventoryDisplay(display)) { display.process(player); } - }, 1, player); + } + } + + public static boolean hasInventoryDisplays(InventoryType type) { + if (type == null || type == InventoryType.CRAFTING || type == InventoryType.CREATIVE) { + return false; + } + List displays = processes.get(type); + return displays != null && !displays.isEmpty(); + } + + public static boolean hasNativeInventoryDisplays(InventoryType type) { + if (type == null || type == InventoryType.CRAFTING || type == InventoryType.CREATIVE) { + return false; + } + for (VisualizerInteractDisplay display : processes.getOrDefault(type, List.of())) { + if (isNativeInventoryDisplay(display)) { + return true; + } + } + return false; + } + + static boolean isNativeInventoryDisplay(VisualizerInteractDisplay display) { + return display.key().isNative(); } private static SparrowConfiguration getConfig() { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java index 1705ca37..6db4bb88 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java @@ -22,6 +22,9 @@ import com.loohp.interactionvisualizer.InteractionVisualizer; import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI; +import com.loohp.interactionvisualizer.api.events.TileEntityAddedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityActivatedEvent; +import com.loohp.interactionvisualizer.api.events.TileEntityDeactivatedEvent; import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; import com.loohp.interactionvisualizer.objectholders.ChunkPosition; import com.loohp.interactionvisualizer.objectholders.TileEntity; @@ -41,11 +44,16 @@ import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityChangeBlockEvent; import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.vehicle.VehicleMoveEvent; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.ChunkUnloadEvent; import org.bukkit.plugin.Plugin; import java.util.Collection; @@ -56,14 +64,30 @@ import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; public class TileEntityManager implements Listener { + enum LifecycleChange { + REMOVED, + ADDED, + ACTIVATED + } + + @FunctionalInterface + interface LifecycleDispatcher { + + void dispatch(T value, LifecycleChange change, TileEntityType type); + } + private static final Plugin plugin = InteractionVisualizer.plugin; private static final TileEntityType[] tileEntityTypes = TileEntityType.values(); private static final Map> active = new EnumMap<>(TileEntityType.class); private static final Map> byChunk = new HashMap<>(); + private static final Map lastActiveTypes = new HashMap<>(); + private static final Map> watchedChunksByPlayer = new HashMap<>(); + private static final Map watcherCounts = new HashMap<>(); public static void _init_() { for (TileEntityType type : tileEntityTypes) { @@ -71,14 +95,34 @@ public static void _init_() { } TileEntityManager instance = new TileEntityManager(); Bukkit.getPluginManager().registerEvents(instance, plugin); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + Bukkit.getPluginManager().registerEvents(new EventDrivenLifecycleListener(instance), plugin); + } Scheduler.runTaskTimer(plugin, () -> { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + Set online = new LinkedHashSet<>(); + for (Player player : Bukkit.getOnlinePlayers()) { + online.add(player.getUniqueId()); + updateWatchedChunks(player.getUniqueId(), getAllChunks(player.getLocation())); + } + for (UUID playerId : new LinkedHashSet<>(watchedChunksByPlayer.keySet())) { + if (!online.contains(playerId)) { + clearWatchedChunks(playerId); + } + } + return; + } for (TileEntityType type : tileEntityTypes) { Set blocks = active.get(type); blocks.removeIf(block -> !PlayerLocationManager.hasPlayerNearby(block.getLocation())); } }, 0, InteractionVisualizerAPI.getGCPeriod()); for (Player player : Bukkit.getOnlinePlayers()) { - addTileEntities(getAllChunks(player.getLocation())); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(player.getUniqueId(), getAllChunks(player.getLocation())); + } else { + addTileEntities(getAllChunks(player.getLocation())); + } } } @@ -87,6 +131,28 @@ public static Set getTileEntities(TileEntityType type) { return set != null ? set : new LinkedHashSet<>(); } + /** + * Reconciles loaded chunks after an explicit bulk block mutation performed + * through the Bukkit block API. Those writes do not emit BlockPlaceEvent or + * BlockBreakEvent, so waiting for the normal event surface would leave both + * the legacy and event-driven registries stale. + * + *

This is an internal main-thread hook for controlled plugin-owned + * mutations. It never loads a chunk.

+ */ + public static void refreshExplicitBlockChanges(Collection blocks) { + if (!Bukkit.isPrimaryThread()) { + throw new IllegalStateException("Tile-entity reconciliation must run on the Bukkit primary thread"); + } + Set chunks = new LinkedHashSet<>(); + for (Block block : blocks) { + if (block != null) { + chunks.add(getChunk(block.getLocation())); + } + } + addTileEntities(chunks); + } + private static Set getAllChunks(Location location) { Set chunks = new LinkedHashSet<>(); World world = location.getWorld(); @@ -125,12 +191,12 @@ private synchronized static void addTileEntities(ChunkPosition chunk) { blocks = new LinkedHashSet<>(); byChunk.put(chunk, blocks); } + boolean activate = !InteractionVisualizer.eventDrivenBlockUpdates || watcherCounts.getOrDefault(chunk, 0) > 0; Map newBlocks = new LinkedHashMap<>(); for (BlockState state : list) { Block block = state.getBlock(); TileEntityType type = TileEntity.getTileEntityType(state.getType()); if (type != null) { - active.get(type).add(block); newBlocks.put(block, type); blocks.add(block); } @@ -141,26 +207,167 @@ private synchronized static void addTileEntities(ChunkPosition chunk) { TileEntityType type = newBlocks.get(block); if (type == null) { itr.remove(); - for (TileEntityType t : tileEntityTypes) { - if (active.get(t).remove(block)) { - Bukkit.getPluginManager().callEvent(new TileEntityRemovedEvent(block, t)); - } + } + reconcileActiveType(block, type, activate, InteractionVisualizer.eventDrivenBlockUpdates, + active, lastActiveTypes, TileEntityManager::dispatchLifecycleChange); + } + } + + static void reconcileActiveType(T value, TileEntityType currentType, boolean activate, + boolean trackLifecycleEvents, + Map> activeByType, + Map lastActiveByValue, + LifecycleDispatcher dispatcher) { + if (currentType == null && trackLifecycleEvents) { + // Preserve the legacy removal contract for re-entrant listeners: + // a removed tile is no longer considered last-active when notified. + lastActiveByValue.remove(value); + } + for (TileEntityType type : tileEntityTypes) { + if (type.equals(currentType)) { + continue; + } + Set values = activeByType.get(type); + if (values != null && values.remove(value)) { + dispatcher.dispatch(value, LifecycleChange.REMOVED, type); + } + } + + if (currentType == null) { + return; + } + + Set currentValues = activeByType.get(currentType); + if (!activate || currentValues == null || !currentValues.add(value) || !trackLifecycleEvents) { + return; + } + TileEntityType lastActiveType = lastActiveByValue.put(value, currentType); + dispatcher.dispatch(value, currentType.equals(lastActiveType) + ? LifecycleChange.ACTIVATED : LifecycleChange.ADDED, currentType); + } + + private static void dispatchLifecycleChange(Block block, LifecycleChange change, TileEntityType type) { + switch (change) { + case REMOVED -> Bukkit.getPluginManager().callEvent(new TileEntityRemovedEvent(block, type)); + case ADDED -> callAddedEvent(block, type); + case ACTIVATED -> callActivatedEvent(block, type); + } + } + + private synchronized static void deactivateTileEntities(ChunkPosition chunk) { + Set blocks = byChunk.get(chunk); + if (blocks == null || blocks.isEmpty()) { + return; + } + for (Block block : blocks) { + for (TileEntityType type : tileEntityTypes) { + if (active.get(type).remove(block)) { + callDeactivatedEvent(block, type); } + } + } + } + + private synchronized static void unloadTileEntities(ChunkPosition chunk) { + deactivateTileEntities(chunk); + Set blocks = byChunk.remove(chunk); + if (blocks != null) { + for (Block block : blocks) { + lastActiveTypes.remove(block); + } + } + } + + private synchronized static void updateWatchedChunks(UUID playerId, Set nextChunks) { + Set previousChunks = watchedChunksByPlayer.put(playerId, nextChunks); + if (previousChunks == null) { + previousChunks = Set.of(); + } + + for (ChunkPosition chunk : previousChunks) { + if (nextChunks.contains(chunk)) { + continue; + } + Integer count = watcherCounts.get(chunk); + if (count == null || count <= 1) { + watcherCounts.remove(chunk); + deactivateTileEntities(chunk); } else { - for (TileEntityType t : tileEntityTypes) { - if (!t.equals(type)) { - if (active.get(t).remove(block)) { - Bukkit.getPluginManager().callEvent(new TileEntityRemovedEvent(block, t)); - } - } - } + watcherCounts.put(chunk, count - 1); + } + } + + for (ChunkPosition chunk : nextChunks) { + if (previousChunks.contains(chunk)) { + continue; + } + int count = watcherCounts.getOrDefault(chunk, 0); + watcherCounts.put(chunk, count + 1); + if (count == 0) { + addTileEntities(chunk); } } } + private synchronized static void clearWatchedChunks(UUID playerId) { + Set chunks = watchedChunksByPlayer.remove(playerId); + if (chunks == null) { + return; + } + for (ChunkPosition chunk : chunks) { + Integer count = watcherCounts.get(chunk); + if (count == null || count <= 1) { + watcherCounts.remove(chunk); + deactivateTileEntities(chunk); + } else { + watcherCounts.put(chunk, count - 1); + } + } + } + + private static void callAddedEvent(Block block, TileEntityType type) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + Bukkit.getPluginManager().callEvent(new TileEntityAddedEvent(block, type)); + } + } + + private static void callActivatedEvent(Block block, TileEntityType type) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + Bukkit.getPluginManager().callEvent(new TileEntityActivatedEvent(block, type)); + } + } + + private static void callDeactivatedEvent(Block block, TileEntityType type) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + Bukkit.getPluginManager().callEvent(new TileEntityDeactivatedEvent(block, type)); + } + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onJoin(PlayerJoinEvent event) { - addTileEntities(getAllChunks(event.getPlayer().getLocation())); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(event.getPlayer().getUniqueId(), getAllChunks(event.getPlayer().getLocation())); + } else { + addTileEntities(getAllChunks(event.getPlayer().getLocation())); + } + } + + public void onQuit(PlayerQuitEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + clearWatchedChunks(event.getPlayer().getUniqueId()); + } + } + + public void onRespawn(PlayerRespawnEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(event.getPlayer().getUniqueId(), getAllChunks(event.getRespawnLocation())); + } + } + + public void onChangedWorld(PlayerChangedWorldEvent event) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(event.getPlayer().getUniqueId(), getAllChunks(event.getPlayer().getLocation())); + } } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @@ -169,6 +376,11 @@ public void onInteract(PlayerInteractEvent event) { if (block != null) { TileEntityType type = TileEntity.getTileEntityType(block.getType()); if (type != null) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + Set chunks = getAllChunks(event.getPlayer().getLocation()); + chunks.add(getChunk(block.getLocation())); + updateWatchedChunks(event.getPlayer().getUniqueId(), chunks); + } if (!active.get(type).contains(block)) { addTileEntities(getChunk(block.getLocation())); } @@ -181,7 +393,11 @@ public void onTeleport(PlayerTeleportEvent event) { Location from = event.getFrom(); Location to = event.getTo(); if (!from.getWorld().equals(to.getWorld()) || from.getBlockX() >> 4 != to.getBlockX() >> 4 || from.getBlockZ() >> 4 != to.getBlockZ() >> 4) { - addTileEntities(getAllChunks(to)); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(event.getPlayer().getUniqueId(), getAllChunks(to)); + } else { + addTileEntities(getAllChunks(to)); + } } } @@ -189,8 +405,20 @@ public void onTeleport(PlayerTeleportEvent event) { public void onPlayerMove(PlayerMoveEvent event) { Location from = event.getFrom(); Location to = event.getTo(); - if (!from.getWorld().equals(to.getWorld()) || ((from.getBlockX() >> 4 != to.getBlockX() >> 4 || from.getBlockZ() >> 4 != to.getBlockZ() >> 4) && !isMovingTooFast(event.getPlayer(), from, to))) { - addTileEntities(getAllChunks(to)); + if (!from.getWorld().equals(to.getWorld())) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(event.getPlayer().getUniqueId(), getAllChunks(to)); + } else { + addTileEntities(getAllChunks(to)); + } + } else if (from.getBlockX() >> 4 != to.getBlockX() >> 4 || from.getBlockZ() >> 4 != to.getBlockZ() >> 4) { + if (!isMovingTooFast(event.getPlayer(), from, to)) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + updateWatchedChunks(event.getPlayer().getUniqueId(), getAllChunks(to)); + } else { + addTileEntities(getAllChunks(to)); + } + } } } @@ -199,12 +427,43 @@ public void onVehicleMove(VehicleMoveEvent event) { if (event.getVehicle().getPassengers().stream().anyMatch(each -> each instanceof Player)) { Location from = event.getFrom(); Location to = event.getTo(); - if (!from.getWorld().equals(to.getWorld()) || ((from.getBlockX() >> 4 != to.getBlockX() >> 4 || from.getBlockZ() >> 4 != to.getBlockZ() >> 4) && !isMovingTooFast(null, from, to))) { - addTileEntities(getAllChunks(to)); + boolean changedWorld = !from.getWorld().equals(to.getWorld()); + boolean changedChunk = from.getBlockX() >> 4 != to.getBlockX() >> 4 || from.getBlockZ() >> 4 != to.getBlockZ() >> 4; + if (changedWorld || changedChunk) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + boolean movingTooFast = !changedWorld && isMovingTooFast(null, from, to); + if (!movingTooFast) { + Set chunks = getAllChunks(to); + for (org.bukkit.entity.Entity passenger : event.getVehicle().getPassengers()) { + if (passenger instanceof Player player) { + updateWatchedChunks(player.getUniqueId(), chunks); + } + } + } + } else if (changedWorld || !isMovingTooFast(null, from, to)) { + addTileEntities(getAllChunks(to)); + } } } } + public void onChunkLoad(ChunkLoadEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + ChunkPosition chunk = new ChunkPosition(event.getWorld(), event.getChunk().getX(), event.getChunk().getZ()); + if (watcherCounts.getOrDefault(chunk, 0) > 0) { + addTileEntities(chunk); + } + } + + public void onChunkUnload(ChunkUnloadEvent event) { + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + return; + } + unloadTileEntities(new ChunkPosition(event.getWorld(), event.getChunk().getX(), event.getChunk().getZ())); + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onBreakBlock(BlockBreakEvent event) { if (TileEntity.isTileEntityType(event.getBlock().getType())) { @@ -251,6 +510,40 @@ public void onEntityChangeBlock(EntityChangeBlockEvent event) { } } + private static final class EventDrivenLifecycleListener implements Listener { + + private final TileEntityManager manager; + + private EventDrivenLifecycleListener(TileEntityManager manager) { + this.manager = manager; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + manager.onQuit(event); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onRespawn(PlayerRespawnEvent event) { + manager.onRespawn(event); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onChangedWorld(PlayerChangedWorldEvent event) { + manager.onChangedWorld(event); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChunkLoad(ChunkLoadEvent event) { + manager.onChunkLoad(event); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChunkUnload(ChunkUnloadEvent event) { + manager.onChunkUnload(event); + } + } + private boolean isMovingTooFast(Player player, Location from, Location to) { double changeX = Math.abs(from.getX() - to.getX()); double changeZ = Math.abs(from.getZ() - to.getZ()); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java b/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java index a2e164b2..4a4e74e3 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java @@ -28,19 +28,18 @@ import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.utils.ComponentFont; +import com.loohp.interactionvisualizer.utils.LegacyTextComponentCache; import com.loohp.interactionvisualizer.utils.RomanNumberUtils; import com.loohp.interactionvisualizer.utils.TranslationUtils; import com.loohp.interactionvisualizer.scheduler.Scheduler; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TranslatableComponent; import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; import org.bukkit.block.Block; import org.bukkit.enchantments.Enchantment; -import org.bukkit.entity.Display; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; @@ -148,9 +147,10 @@ private CompletableFuture playEnchantAnimation(Map playEnchantAnimation(Map playEnchantAnimation(Map "block"; + case LOW_BLOCK -> "lowblock"; + case TOOL -> "tool"; + case STANDING -> "standing"; + case BANNER, FRAME -> throw new IllegalStateException("handled above"); + case ITEM, DROPPED -> "item"; + }; + return item(transformMode, true); + } + + static Matrix4f item(String mode, boolean small) { Matrix4f matrix = new Matrix4f().identity(); switch (mode) { @@ -33,17 +92,164 @@ public static Matrix4f item(DisplayEntity state) { case "lowblock" -> matrix.translate(0.0F, 0.02F, 0.0F).rotateX((float) Math.toRadians(90)).scale(0.36F); case "tool" -> matrix.rotateZ((float) Math.toRadians(-90)).scale(0.48F); case "standing" -> matrix.translate(0.0F, 0.18F, 0.0F).scale(0.5F); - default -> matrix.scale(state.isSmall() ? 0.42F : 0.5F); + default -> matrix.scale(small ? 0.42F : 0.5F); } return matrix; } + public static ItemDisplay.ItemDisplayTransform itemDisplayTransform(Item state) { + if (WorkstationDisplayPositioning.isWorkstationItem(state)) { + return ItemDisplay.ItemDisplayTransform.THIRDPERSON_RIGHTHAND; + } + return itemDisplayTransform(state.getRenderMode()); + } + + public static ItemDisplay.ItemDisplayTransform itemDisplayTransform(DisplayEntity state) { + return state.getItemDisplayTransform(); + } + + static ItemDisplay.ItemDisplayTransform itemDisplayTransform(Item.RenderMode mode) { + return mode == Item.RenderMode.BANNER + ? ItemDisplay.ItemDisplayTransform.HEAD + : ItemDisplay.ItemDisplayTransform.FIXED; + } + + private static Matrix4f legacySmallHeadBanner() { + return new Matrix4f().identity() + // The physical ItemDisplay remains one block above the old + // marker so it samples the same light without a fake light. + .translate(0.0F, -0.97F, 0.0F) + .rotateY((float) Math.PI) + .scale(-1.0F, -1.0F, 1.0F) + .translate(0.0F, -1.501F, 0.0F) + // Small legacy-model head pivot and scale. + .translate(0.0F, 12.75F / 16.0F, 0.0F) + .scale(0.75F) + // Vanilla custom-head layer item transform. + .translate(0.0F, -0.25F, 0.0F) + .rotateY((float) Math.PI) + .scale(0.625F, -0.625F, -0.625F) + // ItemDisplayRenderer adds Y-180 before the HEAD context. + .rotateY((float) Math.PI); + } + + private static Matrix4f legacyRightHand(LegacyHandProfile profile, EulerAngle pose, boolean small, + float commonY, float commonYaw, float modeYaw, + float displayPitch) { + // Bukkit's Location#getDirection includes pitch. The legacy API used + // that full direction for both its common hand anchor and every mode + // teleport, while the legacy stand renderer did not pitch the model. + Vector3f direction = new Vector3f(0.0F, -(float) Math.sin(displayPitch), + (float) Math.cos(displayPitch)); + Vector3f commonOffset = rotateY(new Vector3f(direction).mul(0.19F), LEGACY_COMMON_ROTATION) + .add(new Vector3f(direction).mul(-0.11F)) + .add(0.0F, commonY, 0.0F); + rotateY(commonOffset, commonYaw); + Vector3f modeOffset = new Vector3f(direction).mul(profile.forward()) + .add(rotateY(new Vector3f(direction).mul(profile.lateral()), LEGACY_LATERAL_ROTATION)) + .add(0.0F, profile.vertical(), 0.0F); + rotateY(modeOffset, modeYaw); + Vector3f localOffset = commonOffset.add(modeOffset); + + Matrix4f matrix = new Matrix4f().identity() + // DisplayRenderer FIXED applies +entityPitch before the custom + // transformation. The legacy renderer did not, so cancel it. + .rotateX(-displayPitch) + .translate(localOffset) + .rotateY((float) Math.PI) + .scale(-1.0F, -1.0F, 1.0F) + .translate(0.0F, -1.501F, 0.0F); + + if (small) { + matrix.translate(-2.5F / 16.0F, 13.0F / 16.0F, 0.0F); + } else { + matrix.translate(-5.0F / 16.0F, 2.0F / 16.0F, 0.0F); + } + + matrix.rotateZYX((float) pose.getZ(), (float) pose.getY(), (float) pose.getX()); + if (small) { + matrix.scale(0.5F); + } + + return matrix + .rotateX((float) Math.toRadians(-90.0)) + .rotateY((float) Math.PI) + .translate(1.0F / 16.0F, 2.0F / 16.0F, -10.0F / 16.0F) + // ItemDisplayRenderer adds this Y-180 immediately before the + // selected vanilla item-model context; cancel it here. + .rotateY((float) Math.PI); + } + + private static Vector3f rotateY(Vector3f vector, float angle) { + float sine = (float) Math.sin(angle); + float cosine = (float) Math.cos(angle); + float x = cosine * vector.x + sine * vector.z; + float z = -sine * vector.x + cosine * vector.z; + return vector.set(x, vector.y, z); + } + + private static LegacyHandProfile legacyHandProfile(Item.RenderMode mode) { + return switch (mode) { + case ITEM -> legacyHandProfile("item"); + case BLOCK -> legacyHandProfile("block"); + case LOW_BLOCK -> legacyHandProfile("lowblock"); + case TOOL -> legacyHandProfile("tool"); + case STANDING -> legacyHandProfile("standing"); + case DROPPED, BANNER, FRAME -> throw new IllegalArgumentException("Unsupported legacy hand mode: " + mode); + }; + } + + private static LegacyHandProfile legacyHandProfile(String mode) { + return switch (mode.toLowerCase(java.util.Locale.ROOT)) { + case "item" -> new LegacyHandProfile(0.0F, 0.0F, 0.0F, EulerAngle.ZERO, false); + case "block" -> new LegacyHandProfile(0.102F, 0.084F, 0.14F, + new EulerAngle(357.9, 0.0, 0.0), true); + case "lowblock" -> new LegacyHandProfile(0.09F, 0.02F, 0.15F, + new EulerAngle(357.9, 0.0, 0.0), true); + case "tool" -> new LegacyHandProfile(-0.3F, -0.26F, -0.1F, + new EulerAngle(357.99, 0.0, 300.0), false); + case "standing" -> new LegacyHandProfile(-0.323F, -0.32F, 0.115F, + new EulerAngle(0.0, 4.7, 4.7), false); + // External API consumers may repurpose the public custom name. + // Preserve the historical default-item fallback instead of + // failing the display synchronization task. + default -> new LegacyHandProfile(0.0F, 0.0F, 0.0F, EulerAngle.ZERO, false); + }; + } + + private record LegacyHandProfile(float lateral, float vertical, float forward, + EulerAngle pose, boolean diagonal) { + } + + public static Matrix4f text(DisplayEntity state) { Matrix4f matrix = new Matrix4f().identity(); - if (state instanceof BillboardDisplayEntity billboard && billboard.getRadius() != 0.0) { - matrix.translate(0.0F, 0.0F, (float) -billboard.getRadius()); + float scale = state.getTextScale(); + if (state.usesLegacyNameTagGeometry()) { + /* + * TextDisplayRenderer offsets a centered single line by one pixel + * on X and nine pixels on Y before applying its built-in 0.025 + * scale. A vanilla entity name tag has neither offset. Cancel both + * here; textDisplayLocation supplies the name tag's world-space + * +0.5 Y attachment separately so pitch does not rotate it. + */ + matrix.translate(-TEXT_PIXEL_SCALE * scale, + -TEXT_PIXEL_SCALE * SINGLE_LINE_BASELINE_PIXELS * scale, + 0.0F); + } + return matrix.scale(scale); + } + + public static Location textDisplayLocation(DisplayEntity state) { + return textDisplayLocation(state, state.getLocation()); + } + + public static Location textDisplayLocation(DisplayEntity state, Location logicalLocation) { + Location location = logicalLocation.clone(); + if (state.usesLegacyNameTagGeometry()) { + location.add(0.0D, LEGACY_NAME_TAG_WORLD_Y, 0.0D); } - return matrix.scale(0.5F); + return location; } public static Matrix4f itemFrame(ItemFrame state) { @@ -61,4 +267,16 @@ private static String mode(DisplayEntity state) { int separator = plain.lastIndexOf('.'); return (separator < 0 ? plain : plain.substring(separator + 1)).toLowerCase(java.util.Locale.ROOT); } + + private static String legacyMode(DisplayEntity state) { + if (state.getCustomName() == null) { + return "item"; + } + String plain = PlainTextComponentSerializer.plainText().serialize(state.getCustomName()); + if (!plain.startsWith("IV.Custom.")) { + return "item"; + } + int separator = plain.lastIndexOf('.'); + return plain.substring(separator + 1).toLowerCase(java.util.Locale.ROOT); + } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/utils/InventoryUtils.java b/common/src/main/java/com/loohp/interactionvisualizer/utils/InventoryUtils.java index 7738c823..45b638bf 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/utils/InventoryUtils.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/utils/InventoryUtils.java @@ -27,6 +27,10 @@ public class InventoryUtils { + public static ItemStack cloneItem(ItemStack item) { + return item == null ? null : item.clone(); + } + public static boolean compareContents(Inventory first, Inventory second) { int size = Math.max(first.getSize(), second.getSize()); for (int i = 0; i < size; i++) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/utils/WorkstationDisplayPositioning.java b/common/src/main/java/com/loohp/interactionvisualizer/utils/WorkstationDisplayPositioning.java new file mode 100644 index 00000000..a2ea9f07 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/utils/WorkstationDisplayPositioning.java @@ -0,0 +1,105 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.utils; + +import com.loohp.interactionvisualizer.entityholders.Item; +import com.loohp.interactionvisualizer.utils.MaterialUtils.MaterialMode; +import org.bukkit.Location; +import org.bukkit.util.Vector; + +/** Direct ItemDisplay positioning shared by workstation grid visuals. */ +public final class WorkstationDisplayPositioning { + + /** Places fixed workstation items at the full-block top surface. */ + public static final double SURFACE_ANCHOR_Y = 1.0; + + private WorkstationDisplayPositioning() { + } + + /** + * Identifies workstation items without changing the public {@link Item} + * state model used by furnaces, packet-only dropped items, banners, or + * frames. The layout yaw is retained because side slots may deliberately + * face away from the workstation's grid direction. + */ + static final class WorkstationItem extends Item { + + private final float gridYaw; + + private WorkstationItem(Location location, float gridYaw) { + super(location, RenderMode.ITEM); + this.gridYaw = gridYaw; + } + } + + public static Location gridAnchor(Location blockOrigin) { + return blockOrigin.clone().add(0.5, SURFACE_ANCHOR_Y, 0.5); + } + + /** + * Returns a workstation grid position relative to the block centre. Positive + * forward offset follows the supplied yaw; positive lateral offset follows + * the matching positive 90-degree Y rotation. + */ + public static Location gridSlot(Location blockOrigin, float yaw, double lateralOffset, double forwardOffset) { + Location location = gridAnchor(blockOrigin); + location.setYaw(yaw); + Vector forward = location.getDirection().setY(0.0).normalize(); + Vector lateral = new Vector(-forward.getZ(), 0.0, forward.getX()); + return location.add(forward.multiply(forwardOffset)).add(lateral.multiply(lateralOffset)); + } + + public static Item gridItem(Location blockOrigin, float yaw, double lateralOffset, double forwardOffset) { + return new WorkstationItem(gridSlot(blockOrigin, yaw, lateralOffset, forwardOffset), yaw); + } + + static boolean isWorkstationItem(Item item) { + return item instanceof WorkstationItem; + } + + static float gridYaw(Item item) { + if (!(item instanceof WorkstationItem workstationItem)) { + throw new IllegalArgumentException("Not a workstation item"); + } + return workstationItem.gridYaw; + } + + public static void setRenderMode(Item item, MaterialMode mode) { + setRenderMode(item, switch (mode) { + case ITEM -> Item.RenderMode.ITEM; + case BLOCK -> Item.RenderMode.BLOCK; + case LOWBLOCK -> Item.RenderMode.LOW_BLOCK; + case TOOL -> Item.RenderMode.TOOL; + case STANDING -> Item.RenderMode.STANDING; + }); + } + + public static void setRenderMode(Item item, Item.RenderMode mode) { + if (!mode.isFixedDisplay() || mode == Item.RenderMode.BANNER || mode == Item.RenderMode.FRAME) { + throw new IllegalArgumentException("Unsupported workstation render mode: " + mode); + } + + Item.RenderMode previous = item.getRenderMode(); + if (previous == mode) { + return; + } + + Location location = item.getLocation(); + float baseYaw = location.getYaw() - (usesDiagonalYaw(previous) ? 45.0F : 0.0F); + item.setRenderMode(mode); + item.setRotation(baseYaw + (usesDiagonalYaw(mode) ? 45.0F : 0.0F), location.getPitch()); + } + + private static boolean usesDiagonalYaw(Item.RenderMode mode) { + return mode == Item.RenderMode.BLOCK || mode == Item.RenderMode.LOW_BLOCK; + } +} diff --git a/common/src/main/resources/META-INF/licenses/caffeine.txt b/common/src/main/resources/META-INF/licenses/caffeine.txt new file mode 100644 index 00000000..8d9fe88e --- /dev/null +++ b/common/src/main/resources/META-INF/licenses/caffeine.txt @@ -0,0 +1,205 @@ +Caffeine 3.2.3 +Copyright 2014-2026 Ben Manes and contributors +https://github.com/ben-manes/caffeine + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/common/src/main/resources/META-INF/licenses/error-prone-annotations.txt b/common/src/main/resources/META-INF/licenses/error-prone-annotations.txt new file mode 100644 index 00000000..142bc53a --- /dev/null +++ b/common/src/main/resources/META-INF/licenses/error-prone-annotations.txt @@ -0,0 +1,5 @@ +Error Prone Annotations 2.43.0 +https://github.com/google/error-prone + +Licensed under the Apache License, Version 2.0. +The complete Apache-2.0 terms are included in META-INF/licenses/caffeine.txt. diff --git a/common/src/main/resources/META-INF/licenses/jspecify.txt b/common/src/main/resources/META-INF/licenses/jspecify.txt new file mode 100644 index 00000000..55198559 --- /dev/null +++ b/common/src/main/resources/META-INF/licenses/jspecify.txt @@ -0,0 +1,5 @@ +JSpecify 1.0.0 +https://github.com/jspecify/jspecify + +Licensed under the Apache License, Version 2.0. +The complete Apache-2.0 terms are included in META-INF/licenses/caffeine.txt. diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index 5e7bcf7d..6af5491e 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -281,6 +281,17 @@ Entities: #This is in ticks (20 ticks = 1 second) #Setting this too low might impact performance UpdateRate: 20 + #Experimental server-side distance culling for dropped-item labels + VisibilityCulling: + #Preserves the legacy label lifecycle unless explicitly enabled after validation + Enabled: false + ViewDistance: 64 + #Experimental smoothing for label visibility bursts + VisibilityRateLimit: + #Independent from Settings.Performance.VisibilityRateLimit and disabled by default + Enabled: false + BucketSize: 128 + RestorePerTick: 32 #Paper does not expose its internal per-item despawn rate. This value #drives the public-API timer (6000 ticks = 5 minutes). DespawnTicks: 6000 @@ -321,6 +332,25 @@ Settings: #MIGHT BE RESOURCE INTENSIVE #MIGHT BE RESOURCE INTENSIVE HideIfViewObstructed: false + Performance: + VirtualItems: + #A/B switch: keeps invisible tracker anchors still during short item animations. + #The client-side virtual item remains corrected every tick; turn this on only after profiling your server. + StaticAnchorDuringAnimation: false + #A/B switch: eligible stationary virtual items without a visible custom name use no Bukkit tracker entity. + #Requires separate packet-capture and client compatibility validation before enabling in production. + PacketOnlyStatic: false + VisibilityRateLimit: + #A/B switch: hides remain immediate; newly visible entities are restored through a per-player token bucket. + Enabled: false + BucketSize: 128 + RestorePerTick: 32 + BlockUpdates: + #A/B switch: coalesces event-driven changes and replaces per-block child tasks with fixed-budget loops. + #Restart the server after changing this option so runnable display tasks are recreated. + EventDriven: false + #Dirty-update budget per block display type; active rotation and fallback audit have separate bounded work. + MaxDirtyPerTick: 64 Options: Updater: true diff --git a/common/src/main/resources/plugin.yml b/common/src/main/resources/plugin.yml index 1593f28c..1730aeac 100644 --- a/common/src/main/resources/plugin.yml +++ b/common/src/main/resources/plugin.yml @@ -37,3 +37,6 @@ permissions: interactionvisualizer.update: description: Allows you receive update messages default: op + interactionvisualizer.performance: + description: Allows you to run InteractionVisualizer performance measurements + default: op diff --git a/common/src/test/java/com/loohp/interactionvisualizer/api/InteractionVisualizerAPIFixedDisplayTest.java b/common/src/test/java/com/loohp/interactionvisualizer/api/InteractionVisualizerAPIFixedDisplayTest.java new file mode 100644 index 00000000..1f026a90 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/api/InteractionVisualizerAPIFixedDisplayTest.java @@ -0,0 +1,303 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.api; + +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import com.loohp.interactionvisualizer.entityholders.VisualizerEntity; +import com.loohp.interactionvisualizer.utils.DisplayTransformFactory; +import org.joml.Matrix4f; +import org.joml.Vector3f; +import org.bukkit.Location; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.util.EulerAngle; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +class InteractionVisualizerAPIFixedDisplayTest { + + @Test + void itemHoldingObjectUsesTheProvidedDirectDisplayAnchorWithoutMutatingIt() { + Location source = new Location(null, 12.25, 64.75, -3.5, 27.0F, 11.0F); + Location snapshot = source.clone(); + + Location displayLocation = InteractionVisualizerAPI.itemHoldingDisplayLocation(source); + + assertEquals(snapshot, source, "the compatibility API must not rewrite the caller's Location"); + assertLocationEquals(snapshot, displayLocation); + + source.add(20.0, 20.0, 20.0).setYaw(180.0F); + assertLocationEquals(snapshot, displayLocation); + } + + @Test + void holdingModeChangesPreserveWorldCoordinatesAndOnlyAdjustDiagonalYaw() throws ReflectiveOperationException { + Location anchor = new Location(null, 1.25, 70.0, 4.75, 10.0F, 5.0F); + DisplayEntity display = testDisplay(anchor); + + assertSame(display, InteractionVisualizerAPI.rotateDisplayEntityItemHoldingObject( + display, InteractionVisualizerAPI.DisplayEntityHoldingMode.LOWBLOCK)); + assertModeAndAnchor(display, InteractionVisualizerAPI.DisplayEntityHoldingMode.LOWBLOCK, 55.0F); + assertEquals(InteractionVisualizerAPI.DisplayEntityHoldingMode.LOWBLOCK, + InteractionVisualizerAPI.getDisplayEntityItemHoldingObjectMode( + display, InteractionVisualizerAPI.DisplayEntityHoldingMode.ITEM), + "the historical extra parameter must remain binary-compatible without changing the result"); + + InteractionVisualizerAPI.rotateDisplayEntityItemHoldingObject( + display, InteractionVisualizerAPI.DisplayEntityHoldingMode.LOWBLOCK); + assertModeAndAnchor(display, InteractionVisualizerAPI.DisplayEntityHoldingMode.LOWBLOCK, 55.0F); + + InteractionVisualizerAPI.rotateDisplayEntityItemHoldingObject( + display, InteractionVisualizerAPI.DisplayEntityHoldingMode.TOOL); + assertModeAndAnchor(display, InteractionVisualizerAPI.DisplayEntityHoldingMode.TOOL, 10.0F); + + InteractionVisualizerAPI.rotateDisplayEntityItemHoldingObject( + display, InteractionVisualizerAPI.DisplayEntityHoldingMode.STANDING); + assertModeAndAnchor(display, InteractionVisualizerAPI.DisplayEntityHoldingMode.STANDING, 10.0F); + + InteractionVisualizerAPI.rotateDisplayEntityItemHoldingObject( + display, InteractionVisualizerAPI.DisplayEntityHoldingMode.ITEM); + assertModeAndAnchor(display, InteractionVisualizerAPI.DisplayEntityHoldingMode.ITEM, 10.0F); + } + + @Test + void itemHoldingCompatibilityProfileUsesTheRightHandContextOnlyWhenExplicitlyMarked() + throws ReflectiveOperationException { + DisplayEntity display = testDisplay(new Location(null, 1.0, 2.0, 3.0)); + DisplayEntity ordinary = testPlainDisplay(new Location(null, 1.0, 2.0, 3.0)); + ordinary.setSmall(true); + ordinary.setCustomName("IV.Custom.Item"); + + assertEquals(ItemDisplay.ItemDisplayTransform.THIRDPERSON_RIGHTHAND, + display.getItemDisplayTransform()); + assertEquals(ItemDisplay.ItemDisplayTransform.THIRDPERSON_RIGHTHAND, + DisplayTransformFactory.itemDisplayTransform(display)); + assertEquals(ItemDisplay.ItemDisplayTransform.FIXED, + ordinary.getItemDisplayTransform(), + "reserved-looking text alone must not opt an ordinary display into the legacy hand profile"); + + assertTransformOrigin(display, -0.000386488F, 0.376000009F, -0.080493137F); + } + + @Test + void externalCustomNameFallsBackToTheLegacyItemProfile() throws ReflectiveOperationException { + DisplayEntity display = testDisplay(new Location(null, 1.0, 2.0, 3.0)); + Matrix4f itemProfile = DisplayTransformFactory.item(display); + + for (String externalName : new String[] { + "External.Integration.Label", "External.Integration.Tool", "my.Block", + "third.party.LowBlock", "plugin.Standing" + }) { + display.setCustomName(externalName); + assertDoesNotThrow(() -> DisplayTransformFactory.item(display)); + assertEquals(ItemDisplay.ItemDisplayTransform.THIRDPERSON_RIGHTHAND, + DisplayTransformFactory.itemDisplayTransform(display)); + assertMatrixProbes(itemProfile, DisplayTransformFactory.item(display), 0.0F, externalName); + } + } + + @Test + void nonZeroPitchMatchesTheIndependentLegacyArmorStandWorldTransform() + throws ReflectiveOperationException { + Location anchor = new Location(null, 12.25, 64.75, -3.5, 27.0F, 35.0F); + + for (InteractionVisualizerAPI.DisplayEntityHoldingMode mode + : InteractionVisualizerAPI.DisplayEntityHoldingMode.values()) { + DisplayEntity display = testDisplay(anchor); + InteractionVisualizerAPI.rotateDisplayEntityItemHoldingObject(display, mode); + + Matrix4f actual = renderedItemDisplayWorldTransform(display); + Matrix4f expected = independentLegacyArmorStandWorldTransform(anchor, mode); + assertMatrixProbes(expected, actual, 8.0E-6F, mode.toString()); + } + } + + @Test + void lockedHoldingDisplayRejectsModeChangesWithoutCorruptingItsTransform() + throws ReflectiveOperationException { + DisplayEntity display = testDisplay(new Location(null, 1.25, 70.0, 4.75, 27.0F, 35.0F)); + Location originalLocation = display.getLocation(); + EulerAngle originalPose = display.getRightArmPose(); + int originalRevision = display.cacheCode(); + Matrix4f originalTransform = renderedItemDisplayWorldTransform(display); + display.setLocked(true); + + for (InteractionVisualizerAPI.DisplayEntityHoldingMode mode + : InteractionVisualizerAPI.DisplayEntityHoldingMode.values()) { + assertSame(display, InteractionVisualizerAPI.rotateDisplayEntityItemHoldingObject(display, mode)); + assertEquals(InteractionVisualizerAPI.DisplayEntityHoldingMode.ITEM, + InteractionVisualizerAPI.getStandMode(display)); + assertLocationEquals(originalLocation, display.getLocation()); + assertEquals(originalPose, display.getRightArmPose()); + assertEquals(originalRevision, display.cacheCode()); + assertMatrixProbes(originalTransform, renderedItemDisplayWorldTransform(display), 0.0F, + "locked " + mode); + } + + display.setLocked(false); + InteractionVisualizerAPI.rotateDisplayEntityItemHoldingObject( + display, InteractionVisualizerAPI.DisplayEntityHoldingMode.LOWBLOCK); + assertModeAndAnchor(display, InteractionVisualizerAPI.DisplayEntityHoldingMode.LOWBLOCK, 72.0F, + originalLocation); + } + + private static void assertModeAndAnchor(DisplayEntity display, + InteractionVisualizerAPI.DisplayEntityHoldingMode mode, + float expectedYaw) { + assertModeAndAnchor(display, mode, expectedYaw, + new Location(null, 1.25, 70.0, 4.75, 10.0F, 5.0F)); + } + + private static void assertModeAndAnchor(DisplayEntity display, + InteractionVisualizerAPI.DisplayEntityHoldingMode mode, + float expectedYaw, + Location expectedAnchor) { + Location location = display.getLocation(); + assertEquals(mode, InteractionVisualizerAPI.getStandMode(display)); + assertEquals(expectedAnchor.getX(), location.getX()); + assertEquals(expectedAnchor.getY(), location.getY()); + assertEquals(expectedAnchor.getZ(), location.getZ()); + assertEquals(expectedYaw, location.getYaw()); + assertEquals(expectedAnchor.getPitch(), location.getPitch()); + assertEquals(ItemDisplay.ItemDisplayTransform.THIRDPERSON_RIGHTHAND, + DisplayTransformFactory.itemDisplayTransform(display)); + + EulerAngle expectedPose = switch (mode) { + case ITEM -> EulerAngle.ZERO; + case LOWBLOCK -> new EulerAngle(357.9, 0.0, 0.0); + case TOOL -> new EulerAngle(357.99, 0.0, 300.0); + case STANDING -> new EulerAngle(0.0, 4.7, 4.7); + }; + assertEquals(expectedPose, display.getRightArmPose()); + + } + + private static Matrix4f renderedItemDisplayWorldTransform(DisplayEntity display) { + Location location = display.getLocation(); + return new Matrix4f().identity() + .rotateYXZ((float) Math.toRadians(-location.getYaw()), + (float) Math.toRadians(location.getPitch()), 0.0F) + .mul(DisplayTransformFactory.item(display)) + // ItemDisplayRenderer applies this immediately before the + // selected vanilla item-model context. + .rotateY((float) Math.PI); + } + + private static Matrix4f independentLegacyArmorStandWorldTransform( + Location source, InteractionVisualizerAPI.DisplayEntityHoldingMode mode) { + Vector direction = source.getDirection().normalize(); + Vector offset = legacyRotateAroundY(direction.clone().multiply(0.19), -100.0) + .add(direction.clone().multiply(-0.11)) + .add(independentLegacyModeOffset(direction, mode)); + float displayYaw = source.getYaw() + + (mode == InteractionVisualizerAPI.DisplayEntityHoldingMode.LOWBLOCK ? 45.0F : 0.0F); + EulerAngle pose = switch (mode) { + case ITEM -> EulerAngle.ZERO; + case LOWBLOCK -> new EulerAngle(357.9, 0.0, 0.0); + case TOOL -> new EulerAngle(357.99, 0.0, 300.0); + case STANDING -> new EulerAngle(0.0, 4.7, 4.7); + }; + + return new Matrix4f().identity() + .translate((float) offset.getX(), (float) offset.getY(), (float) offset.getZ()) + .rotateY((float) Math.toRadians(180.0F - displayYaw)) + .scale(-1.0F, -1.0F, 1.0F) + .translate(0.0F, -1.501F, 0.0F) + .translate(-2.5F / 16.0F, 13.0F / 16.0F, 0.0F) + .rotateZYX((float) pose.getZ(), (float) pose.getY(), (float) pose.getX()) + .scale(0.5F) + .rotateX((float) Math.toRadians(-90.0F)) + .rotateY((float) Math.PI) + .translate(1.0F / 16.0F, 2.0F / 16.0F, -10.0F / 16.0F); + } + + private static Vector independentLegacyModeOffset( + Vector direction, InteractionVisualizerAPI.DisplayEntityHoldingMode mode) { + return switch (mode) { + case ITEM -> new Vector(); + case LOWBLOCK -> direction.clone().multiply(0.15) + .add(legacyRotateAroundY(direction.clone().multiply(0.09), -90.0)) + .add(new Vector(0.0, 0.02, 0.0)); + case TOOL -> direction.clone().multiply(-0.1) + .add(legacyRotateAroundY(direction.clone().multiply(-0.3), -90.0)) + .add(new Vector(0.0, -0.26, 0.0)); + case STANDING -> direction.clone().multiply(0.115) + .add(legacyRotateAroundY(direction.clone().multiply(-0.323), -90.0)) + .add(new Vector(0.0, -0.32, 0.0)); + }; + } + + private static Vector legacyRotateAroundY(Vector vector, double degrees) { + double radians = Math.toRadians(degrees); + double x = vector.getX(); + double z = vector.getZ(); + double cosine = Math.cos(radians); + double sine = Math.sin(radians); + return vector.setX(cosine * x - sine * z).setZ(sine * x + cosine * z); + } + + private static void assertMatrixProbes(Matrix4f expected, Matrix4f actual, + float tolerance, String description) { + Vector3f[] probes = { + new Vector3f(), + new Vector3f(1.0F, 0.0F, 0.0F), + new Vector3f(0.0F, 1.0F, 0.0F), + new Vector3f(0.0F, 0.0F, 1.0F) + }; + for (int index = 0; index < probes.length; index++) { + Vector3f expectedProbe = expected.transformPosition(new Vector3f(probes[index])); + Vector3f actualProbe = actual.transformPosition(new Vector3f(probes[index])); + assertEquals(expectedProbe.x, actualProbe.x, tolerance, description + " probe " + index + " x"); + assertEquals(expectedProbe.y, actualProbe.y, tolerance, description + " probe " + index + " y"); + assertEquals(expectedProbe.z, actualProbe.z, tolerance, description + " probe " + index + " z"); + } + } + + private static void assertLocationEquals(Location expected, Location actual) { + assertSame(expected.getWorld(), actual.getWorld()); + assertEquals(expected.getX(), actual.getX()); + assertEquals(expected.getY(), actual.getY()); + assertEquals(expected.getZ(), actual.getZ()); + assertEquals(expected.getYaw(), actual.getYaw()); + assertEquals(expected.getPitch(), actual.getPitch()); + } + + private static DisplayEntity testDisplay(Location location) throws ReflectiveOperationException { + DisplayEntity display = testPlainDisplay(location); + display.setSmall(true); + display.setLegacyRightHandItemTransform(true); + display.setRightArmPose(EulerAngle.ZERO); + display.setCustomName("IV.Custom.Item"); + return display; + } + + private static DisplayEntity testPlainDisplay(Location location) throws ReflectiveOperationException { + DisplayEntity display = EntityHolderTestFactory.allocate(DisplayEntity.class); + Field entityLocation = VisualizerEntity.class.getDeclaredField("location"); + entityLocation.setAccessible(true); + entityLocation.set(display, location.clone()); + return display; + } + + private static void assertTransformOrigin(DisplayEntity display, float x, float y, float z) { + Vector3f origin = DisplayTransformFactory.item(display).transformPosition(new Vector3f()); + assertEquals(x, origin.x, 2.0E-6F); + assertEquals(y, origin.y, 2.0E-6F); + assertEquals(z, origin.z, 2.0E-6F); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/AnvilResultLabelTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/AnvilResultLabelTest.java new file mode 100644 index 00000000..4661e02e --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/AnvilResultLabelTest.java @@ -0,0 +1,48 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import net.kyori.adventure.text.Component; +import org.bukkit.Location; +import org.bukkit.entity.Display; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AnvilResultLabelTest { + + @Test + void resultNameUsesAnOccludableTextDisplayAtTheLegacyHeight() throws ReflectiveOperationException { + Location item = new Location(null, 12.5, 65.2, -2.5); + Component name = Component.text("Renamed sword"); + DisplayEntity label = EntityHolderTestFactory.allocate(DisplayEntity.class); + + AnvilDisplay.configureResultLabel(label, name); + Location labelLocation = AnvilDisplay.resultLabelLocation(item); + + assertEquals(name, label.getCustomName()); + assertTrue(label.isCustomNameVisible()); + assertEquals(Display.Billboard.CENTER, label.getBillboard()); + assertTrue(label.isDefaultBackground()); + assertEquals(1.0F, label.getTextScale()); + assertTrue(label.usesUnboundedTextWidth()); + assertEquals(12.5, labelLocation.getX()); + assertEquals(65.75, labelLocation.getY()); + assertEquals(-2.5, labelLocation.getZ()); + assertEquals(65.2, item.getY(), "the item location must not be mutated"); + assertEquals(0, label.getInterpolationDuration()); + assertEquals(0, label.getTeleportDuration()); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/BeeContainerDisplayVisualParityTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/BeeContainerDisplayVisualParityTest.java new file mode 100644 index 00000000..278dd39e --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/BeeContainerDisplayVisualParityTest.java @@ -0,0 +1,112 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import org.bukkit.Location; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Display; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BeeContainerDisplayVisualParityTest { + + @Test + void hiveAndNestLabelsUseOnlyTheLegacyNameTagGeometry() throws ReflectiveOperationException { + DisplayEntity hiveLabel = EntityHolderTestFactory.allocate(DisplayEntity.class); + DisplayEntity nestLabel = EntityHolderTestFactory.allocate(DisplayEntity.class); + initializeTextDefaults(hiveLabel); + initializeTextDefaults(nestLabel); + + allocateWithoutConstructor(BeeHiveDisplay.class).setStand(hiveLabel); + allocateWithoutConstructor(BeeNestDisplay.class).setStand(nestLabel); + + assertLegacyNameTagGeometry(hiveLabel); + assertLegacyNameTagGeometry(nestLabel); + } + + @Test + void hiveAndNestLabelsKeepTheExactUpstreamLogicalAnchors() { + Location block = new Location(null, 10.0, 64.0, -4.0); + + for (BlockFace facing : new BlockFace[] { + BlockFace.NORTH, BlockFace.SOUTH, BlockFace.WEST, BlockFace.EAST}) { + assertUpstreamAnchors(block, facing, BeeHiveDisplay::labelLocation); + assertUpstreamAnchors(block, facing, BeeNestDisplay::labelLocation); + } + + assertEquals(10.0, block.getX()); + assertEquals(64.0, block.getY()); + assertEquals(-4.0, block.getZ(), "the block anchor must not be mutated"); + } + + private static void assertLegacyNameTagGeometry(DisplayEntity label) { + assertTrue(label.usesLegacyNameTagGeometry()); + assertFalse(label.usesLegacyNameTagStyle()); + assertEquals(1.0F, label.getTextScale()); + assertEquals(Display.Billboard.FIXED, label.getBillboard()); + assertFalse(label.isDefaultBackground()); + } + + private static void assertUpstreamAnchors(Location block, BlockFace facing, + LabelLocationFactory factory) { + double expectedX = switch (facing) { + case WEST -> 9.8; + case EAST -> 11.2; + default -> 10.5; + }; + double expectedZ = switch (facing) { + case NORTH -> -4.2; + case SOUTH -> -2.8; + default -> -3.5; + }; + + Location honey = factory.create(block, facing, 0.25); + Location bees = factory.create(block, facing, 0.0); + + assertEquals(expectedX, honey.getX(), 1.0E-12); + assertEquals(64.25, honey.getY(), 1.0E-12); + assertEquals(expectedZ, honey.getZ(), 1.0E-12); + assertEquals(expectedX, bees.getX(), 1.0E-12); + assertEquals(64.0, bees.getY(), 1.0E-12); + assertEquals(expectedZ, bees.getZ(), 1.0E-12); + } + + @FunctionalInterface + private interface LabelLocationFactory { + Location create(Location blockLocation, BlockFace facing, double lineOffset); + } + + private static T allocateWithoutConstructor(Class type) throws ReflectiveOperationException { + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Method allocateInstance = unsafeClass.getMethod("allocateInstance", Class.class); + return type.cast(allocateInstance.invoke(unsafeField.get(null), type)); + } + + private static void initializeTextDefaults(DisplayEntity label) throws ReflectiveOperationException { + Field billboard = DisplayEntity.class.getDeclaredField("billboard"); + billboard.setAccessible(true); + billboard.set(label, Display.Billboard.FIXED); + Field textScale = DisplayEntity.class.getDeclaredField("textScale"); + textScale.setAccessible(true); + textScale.setFloat(label, 0.5F); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockDisplayFlagIsolationTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockDisplayFlagIsolationTest.java new file mode 100644 index 00000000..593059d8 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockDisplayFlagIsolationTest.java @@ -0,0 +1,58 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.InteractionVisualizer; +import org.bukkit.inventory.Inventory; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BlockDisplayFlagIsolationTest { + + @Test + void disabledEventDrivenModeDoesNotInspectMovedInventoryLocations() throws ReflectiveOperationException { + boolean previous = InteractionVisualizer.eventDrivenBlockUpdates; + AtomicInteger inventoryCalls = new AtomicInteger(); + Inventory inventory = (Inventory) Proxy.newProxyInstance( + Inventory.class.getClassLoader(), new Class[]{Inventory.class}, (proxy, method, arguments) -> { + inventoryCalls.incrementAndGet(); + throw new AssertionError("Disabled event-driven mode inspected inventory via " + method.getName()); + }); + + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Object unsafe = unsafeField.get(null); + Method allocateInstance = unsafeClass.getMethod("allocateInstance", Class.class); + + try { + InteractionVisualizer.eventDrivenBlockUpdates = false; + for (Class displayType : List.of( + FurnaceDisplay.class, BlastFurnaceDisplay.class, SmokerDisplay.class)) { + Object display = allocateInstance.invoke(unsafe, displayType); + Method markDirty = displayType.getDeclaredMethod("markDirty", Inventory.class); + markDirty.setAccessible(true); + markDirty.invoke(display, inventory); + } + assertEquals(0, inventoryCalls.get()); + } finally { + InteractionVisualizer.eventDrivenBlockUpdates = previous; + } + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockUpdateSchedulerTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockUpdateSchedulerTest.java new file mode 100644 index 00000000..53993e58 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/BlockUpdateSchedulerTest.java @@ -0,0 +1,203 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BlockUpdateSchedulerTest { + + @Test + void coalescesDirtyValuesUntilTheirReadyTick() { + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(Set::of, 20, 600); + List updates = new ArrayList<>(); + + scheduler.markDirty("furnace", 11); + scheduler.markDirty("furnace", 11); + scheduler.markDirty("furnace", 12); + + assertEquals(0, scheduler.tick(10, 64, value -> { + updates.add(value); + return false; + })); + assertEquals(1, scheduler.pendingDirtyCount()); + assertEquals(1, scheduler.tick(11, 64, value -> { + updates.add(value); + return false; + })); + assertEquals(List.of("furnace"), updates); + assertEquals(0, scheduler.pendingDirtyCount()); + } + + @Test + void prioritizesUrgentDirtyAndRotatesActiveWithinTheConfiguredPeriod() { + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(Set::of, 2, 600); + List updates = new ArrayList<>(); + scheduler.markActive("a", 1); + scheduler.markActive("b", 1); + scheduler.markActive("c", 1); + scheduler.markDirty("c", 1); + + assertEquals(3, scheduler.tick(1, 64, value -> { + updates.add(value); + return true; + })); + assertEquals("c", updates.getFirst()); + assertEquals(3, scheduler.activeCount()); + + updates.clear(); + assertEquals(0, scheduler.tick(2, 64, value -> { + updates.add(value); + return true; + })); + assertEquals(3, scheduler.activeCount()); + + // Values are not due again until two ticks after their prior update. + assertEquals(2, scheduler.tick(3, 64, value -> { + updates.add(value); + return !value.equals("a"); + })); + assertEquals(2, scheduler.activeCount()); + } + + @Test + void synchronizedLevelSignalsDoNotBurstPastTheActiveCadence() { + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(Set::of, 20, 600); + for (int value = 0; value < 100; value++) { + scheduler.markActive(value, 1); + } + for (int value = 0; value < 100; value++) { + scheduler.markDirtyUnlessActive(value, 1); + } + + assertEquals(0, scheduler.pendingDirtyCount()); + int total = 0; + for (int tick = 1; tick <= 20; tick++) { + int checks = scheduler.tick(tick, 64, value -> true); + assertEquals(5, checks); + total += checks; + } + assertEquals(100, total); + assertEquals(100, scheduler.activeCount()); + } + + @Test + void levelSignalsStillBootstrapInactiveValues() { + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(Set::of, 20, 600); + scheduler.markDirtyUnlessActive("inactive", 2); + + assertEquals(1, scheduler.pendingDirtyCount()); + assertEquals(0, scheduler.tick(1, 64, value -> true)); + assertEquals(1, scheduler.tick(2, 64, value -> false)); + assertEquals(0, scheduler.pendingDirtyCount()); + } + + @Test + void coalescedStopSignalLeavesTheActivePeriodAsTheMaximumDelay() { + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(Set::of, 20, 600); + scheduler.markActive("furnace", 1); + assertEquals(1, scheduler.tick(1, 64, value -> true)); + + scheduler.markDirtyUnlessActive("furnace", 2); + assertEquals(0, scheduler.pendingDirtyCount()); + for (int tick = 2; tick < 21; tick++) { + assertEquals(0, scheduler.tick(tick, 64, value -> false)); + } + assertEquals(1, scheduler.tick(21, 64, value -> false)); + assertEquals(0, scheduler.activeCount()); + } + + @Test + void coalescedSignalDoesNotDiscardAnExistingUrgentInvalidation() { + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(Set::of, 20, 600); + scheduler.markActive("furnace", 10); + scheduler.markDirty("furnace", 2); + scheduler.markDirtyUnlessActive("furnace", 2); + + assertEquals(1, scheduler.pendingDirtyCount()); + assertEquals(1, scheduler.tick(2, 64, value -> true)); + assertEquals(0, scheduler.pendingDirtyCount()); + assertEquals(1, scheduler.activeCount()); + } + + @Test + void initialAndPeriodicAuditsAreBoundedWithoutSnapshots() { + Set source = new LinkedHashSet<>(); + for (int i = 0; i < 10; i++) { + source.add(i); + } + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(() -> source, 20, 5); + + assertEquals(3, scheduler.tick(0, 3, value -> false)); + assertEquals(3, scheduler.tick(1, 3, value -> false)); + assertEquals(3, scheduler.tick(2, 3, value -> false)); + assertEquals(1, scheduler.tick(3, 3, value -> false)); + assertEquals(0, scheduler.tick(4, 3, value -> false)); + + // The next audit begins at tick 5 and uses ceil(10 / 5) = 2 checks. + assertEquals(2, scheduler.tick(5, 3, value -> false)); + } + + @Test + void removalCancelsDirtyAndActiveWork() { + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(Set::of, 1, 600); + scheduler.markDirty("gone", 1); + scheduler.markActive("gone", 1); + scheduler.remove("gone"); + + assertEquals(0, scheduler.tick(1, 64, value -> true)); + assertEquals(0, scheduler.pendingDirtyCount()); + assertEquals(0, scheduler.activeCount()); + assertEquals(0, scheduler.invalidatedAuditValueCount()); + } + + @Test + void removalInvalidatesValuesAlreadyCapturedByAnAuditIterator() { + Set source = new LinkedHashSet<>(List.of("kept", "gone")); + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>( + () -> new ArrayList<>(source), 20, 600); + List updates = new ArrayList<>(); + + assertEquals(1, scheduler.tick(0, 1, value -> { + updates.add(value); + return false; + })); + source.remove("gone"); + scheduler.remove("gone"); + + assertEquals(0, scheduler.tick(1, 1, value -> { + updates.add(value); + return false; + })); + assertEquals(List.of("kept"), updates); + } + + @Test + void shrinkingActiveSetDoesNotReduceTheCurrentRotationBudget() { + BlockUpdateScheduler scheduler = new BlockUpdateScheduler<>(Set::of, 3, 600); + for (int value = 0; value < 6; value++) { + scheduler.markActive(value, 1); + } + + assertEquals(2, scheduler.tick(1, 64, value -> false)); + assertEquals(2, scheduler.tick(2, 64, value -> false)); + assertEquals(2, scheduler.tick(3, 64, value -> false)); + assertEquals(0, scheduler.activeCount()); + } + +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/BrewingStandDisplayCompatibilityTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/BrewingStandDisplayCompatibilityTest.java new file mode 100644 index 00000000..7460b21f --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/BrewingStandDisplayCompatibilityTest.java @@ -0,0 +1,35 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import org.bukkit.entity.Display; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BrewingStandDisplayCompatibilityTest { + + @Test + void progressLabelMatchesTheLegacyNameTagProfile() throws ReflectiveOperationException { + DisplayEntity label = EntityHolderTestFactory.allocate(DisplayEntity.class); + + BrewingStandDisplay.configureLabel(label); + + assertEquals(Display.Billboard.CENTER, label.getBillboard()); + assertEquals(1.0F, label.getTextScale()); + assertTrue(label.isDefaultBackground()); + assertTrue(label.usesLegacyNameTagStyle()); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/CampfireDisplayCompatibilityTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/CampfireDisplayCompatibilityTest.java new file mode 100644 index 00000000..6fcf0821 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/CampfireDisplayCompatibilityTest.java @@ -0,0 +1,70 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import org.bukkit.Location; +import org.bukkit.entity.Display; +import org.bukkit.event.block.BlockBreakEvent; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CampfireDisplayCompatibilityTest { + + @Test + void progressLabelsMatchTheLegacyNameTagProfile() throws ReflectiveOperationException { + DisplayEntity normal = EntityHolderTestFactory.allocate(DisplayEntity.class); + DisplayEntity soul = EntityHolderTestFactory.allocate(DisplayEntity.class); + + CampfireDisplay.configureLabel(normal); + CampfireDisplay.configureLabel(soul); + + assertLabelProfile(normal); + assertLabelProfile(soul); + } + + @Test + void progressLabelsUseTheCompensatedTextAnchor() { + Location block = new Location(null, 12.0, 64.0, -3.0); + + Location label = CampfireDisplay.labelOrigin(block); + + assertEquals(12.5, label.getX()); + assertEquals(64.3, label.getY()); + assertEquals(-2.5, label.getZ()); + assertEquals(64.0, block.getY(), "the caller's block location must remain unchanged"); + } + + @Test + void soulCampfireCleansUpForEveryTileEntityRemoval() throws ReflectiveOperationException { + Method handler = SoulCampfireDisplay.class.getDeclaredMethod( + "onBreakSoulCampfire", TileEntityRemovedEvent.class); + Method compatibleOverload = SoulCampfireDisplay.class.getDeclaredMethod( + "onBreakSoulCampfire", BlockBreakEvent.class); + + assertTrue(handler.isAnnotationPresent(org.bukkit.event.EventHandler.class)); + assertEquals(void.class, compatibleOverload.getReturnType()); + } + + private static void assertLabelProfile(DisplayEntity label) { + assertEquals(Display.Billboard.CENTER, label.getBillboard()); + assertEquals(1.0F, label.getTextScale()); + assertTrue(label.isDefaultBackground()); + assertTrue(label.usesLegacyNameTagStyle()); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdaterTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdaterTest.java new file mode 100644 index 00000000..0aed4336 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplayUpdaterTest.java @@ -0,0 +1,56 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.utils.ComponentFont; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FurnaceDisplayUpdaterTest { + + @Test + void invalidOrExtremeCookTimesStayWithinTheProgressBar() { + assertEquals(0.0D, FurnaceDisplayUpdater.scaledProgress(0, 0, 10)); + assertEquals(0.0D, FurnaceDisplayUpdater.scaledProgress(10, -1, 10)); + assertEquals(0.0D, FurnaceDisplayUpdater.scaledProgress(-20, 200, 10)); + assertEquals(5.0D, FurnaceDisplayUpdater.scaledProgress(100, 200, 10)); + assertEquals(10.0D, FurnaceDisplayUpdater.scaledProgress(Short.MAX_VALUE, 1, 10)); + } + + @Test + void coloredProgressTextIsComparedByItsRawRenderedState() { + Map values = new HashMap<>(); + String colored = "\u00a7e\u258e\u00a77\u258e"; + String plain = PlainTextComponentSerializer.plainText().serialize(ComponentFont.parseFont( + LegacyComponentSerializer.legacySection().deserialize(colored))); + + assertEquals("\u258e\u258e", plain); + assertNotEquals(colored, plain); + assertTrue(FurnaceDisplayUpdater.shouldUpdateProgress(values, colored, true)); + values.put(FurnaceDisplayUpdater.PROGRESS_TEXT_KEY, colored); + assertFalse(FurnaceDisplayUpdater.shouldUpdateProgress(values, new String(colored), true)); + assertTrue(FurnaceDisplayUpdater.shouldUpdateProgress(values, "\u00a7c\u258e\u258e", true)); + assertTrue(FurnaceDisplayUpdater.shouldUpdateProgress(values, colored, false)); + values.remove(FurnaceDisplayUpdater.PROGRESS_TEXT_KEY); + assertTrue(FurnaceDisplayUpdater.shouldUpdateProgress(values, colored, true)); + } + +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/JukeBoxDisplayLabelTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/JukeBoxDisplayLabelTest.java new file mode 100644 index 00000000..a89c0680 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/JukeBoxDisplayLabelTest.java @@ -0,0 +1,84 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import com.loohp.interactionvisualizer.entityholders.Item; +import net.kyori.adventure.text.Component; +import org.bukkit.Location; +import org.bukkit.entity.Display; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JukeBoxDisplayLabelTest { + + @Test + void discLabelMatchesTheLegacyNameTagProfile() throws ReflectiveOperationException { + DisplayEntity label = EntityHolderTestFactory.allocate(DisplayEntity.class); + + JukeBoxDisplay.configureLabel(label); + + assertEquals(Display.Billboard.CENTER, label.getBillboard()); + assertTrue(label.isDefaultBackground()); + assertEquals(1.0F, label.getTextScale()); + assertTrue(label.usesUnboundedTextWidth()); + assertEquals(0, label.getInterpolationDuration()); + assertEquals(0, label.getTeleportDuration()); + assertFalse(label.hasGravity()); + assertTrue(label.isInvulnerable()); + assertTrue(label.isSilent()); + assertTrue(label.isCustomNameVisible()); + } + + @Test + void discLabelUsesTheItemAnchorWithoutMutatingIt() { + Location item = new Location(null, 12.5, 65.0, -3.5); + + Location label = JukeBoxDisplay.labelLocation(item); + + assertNotSame(item, label); + assertEquals(12.5, label.getX()); + assertEquals(65.55, label.getY()); + assertEquals(-3.5, label.getZ()); + assertEquals(65.0, item.getY()); + } + + @Test + void discItemNeverUsesItsNativeNameTag() throws ReflectiveOperationException { + Item item = EntityHolderTestFactory.allocate(Item.class); + item.setCustomName(Component.text("Pigstep")); + item.setCustomNameVisible(true); + + assertTrue(JukeBoxDisplay.suppressNativeName(item)); + assertNull(item.getCustomName()); + assertFalse(item.isCustomNameVisible()); + assertFalse(JukeBoxDisplay.suppressNativeName(item)); + } + + @Test + void delayedWorkCannotReviveAReplacedVisualState() { + Map scheduled = new HashMap<>(); + Map replacement = new HashMap<>(); + + assertTrue(JukeBoxDisplay.ownsVisualState(scheduled, scheduled)); + assertFalse(JukeBoxDisplay.ownsVisualState(replacement, scheduled)); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/LecternDisplayOrientationTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/LecternDisplayOrientationTest.java new file mode 100644 index 00000000..b3a36ea1 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/LecternDisplayOrientationTest.java @@ -0,0 +1,64 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import org.bukkit.Location; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Display; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LecternDisplayOrientationTest { + + @Test + void bookLabelsFaceTheViewerLikeLegacyNameTags() { + assertEquals(Display.Billboard.CENTER, LecternDisplay.labelBillboard()); + } + + @Test + void labelAnchorsMatchLegacyOffsetsForEveryLecternFacing() { + Location origin = new Location(null, 10.0D, 64.0D, 20.0D); + + assertLocation(LecternDisplay.firstLineLocation(origin, BlockFace.NORTH), + 10.5D, 65.301D, 20.3D); + assertLocation(LecternDisplay.firstLineLocation(origin, BlockFace.SOUTH), + 10.5D, 65.301D, 20.7D); + assertLocation(LecternDisplay.firstLineLocation(origin, BlockFace.WEST), + 10.3D, 65.301D, 20.5D); + assertLocation(LecternDisplay.firstLineLocation(origin, BlockFace.EAST), + 10.7D, 65.301D, 20.5D); + assertLocation(LecternDisplay.secondLineLocation(origin, BlockFace.NORTH), + 10.5D, 65.001D, 20.3D); + } + + @Test + void labelsUseTheSharedLegacyNameTagProfile() throws ReflectiveOperationException { + DisplayEntity label = EntityHolderTestFactory.allocate(DisplayEntity.class); + + LecternDisplay.setStand(label); + + assertTrue(label.usesLegacyNameTagStyle()); + assertEquals(1.0F, label.getTextScale()); + assertTrue(label.isDefaultBackground()); + assertEquals(Display.Billboard.CENTER, label.getBillboard()); + } + + private static void assertLocation(Location actual, double x, double y, double z) { + assertEquals(x, actual.getX(), 1.0E-12D); + assertEquals(y, actual.getY(), 1.0E-12D); + assertEquals(z, actual.getZ(), 1.0E-12D); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/LoomDisplayCompatibilityTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/LoomDisplayCompatibilityTest.java new file mode 100644 index 00000000..eb4babc1 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/LoomDisplayCompatibilityTest.java @@ -0,0 +1,45 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.entityholders.Item; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerInteractEvent; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LoomDisplayCompatibilityTest { + + @Test + void remembersTheClickedLoomWhenPaperDoesNotExposeAnInventoryLocation() throws Exception { + Method method = LoomDisplay.class.getDeclaredMethod("onLoomInteract", PlayerInteractEvent.class); + EventHandler annotation = method.getAnnotation(EventHandler.class); + + assertNotNull(annotation); + assertEquals(EventPriority.MONITOR, annotation.priority()); + assertTrue(annotation.ignoreCancelled()); + } + + @Test + void bannerUsesTheSharedItemPipeline() throws Exception { + Method method = LoomDisplay.class.getDeclaredMethod("setStand", Item.class, float.class); + + assertNotNull(method, "Loom must configure a fixed Item rather than a legacy DisplayEntity"); + } + +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/blocks/NoteBlockDisplayOrientationTest.java b/common/src/test/java/com/loohp/interactionvisualizer/blocks/NoteBlockDisplayOrientationTest.java new file mode 100644 index 00000000..bc8b8e21 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/blocks/NoteBlockDisplayOrientationTest.java @@ -0,0 +1,59 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import org.bukkit.Location; +import org.bukkit.entity.Display; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NoteBlockDisplayOrientationTest { + + @Test + void noteLabelsFollowTheCameraAndUseTheLegacyBackground() throws ReflectiveOperationException { + DisplayEntity label = EntityHolderTestFactory.allocate(DisplayEntity.class); + int initialRevision = label.cacheCode(); + + assertFalse(label.isDefaultBackground()); + + NoteBlockDisplay display = new NoteBlockDisplay(); + display.setStand(label); + + assertEquals(Display.Billboard.CENTER, label.getBillboard()); + assertTrue(label.isDefaultBackground()); + assertEquals(1.0F, label.getTextScale()); + assertTrue(label.usesLegacyNameTagStyle()); + assertTrue(label.cacheCode() > initialRevision); + + int configuredRevision = label.cacheCode(); + display.setStand(label); + assertEquals(configuredRevision, label.cacheCode()); + } + + @Test + void textDisplayKeepsTheOriginalArmorStandAnchor() { + Location clickedFace = new Location(null, 12.5, 64.8, -3.5); + + Location label = NoteBlockDisplay.labelLocation(clickedFace); + + assertNotSame(clickedFace, label); + assertEquals(clickedFace.getX(), label.getX()); + assertEquals(clickedFace.getY() - 0.3, label.getY()); + assertEquals(clickedFace.getZ(), label.getZ()); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/debug/PerformanceBlockSceneSnapshotTest.java b/common/src/test/java/com/loohp/interactionvisualizer/debug/PerformanceBlockSceneSnapshotTest.java new file mode 100644 index 00000000..aac7c969 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/debug/PerformanceBlockSceneSnapshotTest.java @@ -0,0 +1,75 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.debug; + +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PerformanceBlockSceneSnapshotTest { + + @Test + void summaryPublishesLastMutationTickWindowAndElapsedNanos() { + PerformanceBlockScene.Snapshot snapshot = snapshot( + 410L, 411L, 12_345_678L, 8_000_000L, 3_000_000L); + + assertEquals(410L, snapshot.lastMutationStartBukkitTick()); + assertEquals(411L, snapshot.lastMutationEndBukkitTick()); + assertEquals(12_345_678L, snapshot.lastMutationElapsedNanos()); + assertEquals(8_000_000L, snapshot.lastMutationWriteElapsedNanos()); + assertEquals(3_000_000L, snapshot.lastMutationInspectionElapsedNanos()); + + Map fields = summaryFields(snapshot); + assertEquals("410", fields.get("mutationStartBukkitTick")); + assertEquals("411", fields.get("mutationEndBukkitTick")); + assertEquals("12.345678", fields.get("mutationElapsedMs")); + assertEquals("8.000000", fields.get("mutationWriteMs")); + assertEquals("3.000000", fields.get("mutationInspectionMs")); + } + + @Test + void summaryKeepsNoMutationSentinelsObservable() { + Map fields = summaryFields(snapshot(-1L, -1L, 0L, 0L, 0L)); + + assertEquals("-1", fields.get("mutationStartBukkitTick")); + assertEquals("-1", fields.get("mutationEndBukkitTick")); + assertEquals("0.000000", fields.get("mutationElapsedMs")); + assertEquals("0.000000", fields.get("mutationWriteMs")); + assertEquals("0.000000", fields.get("mutationInspectionMs")); + } + + private static PerformanceBlockScene.Snapshot snapshot(long startTick, long endTick, long elapsedNanos, + long writeElapsedNanos, + long inspectionElapsedNanos) { + return new PerformanceBlockScene.Snapshot( + UUID.fromString("11111111-2222-3333-4444-555555555555"), + PerformanceBlockScene.SceneState.READY, + PerformanceBlockScene.Mode.DIRECT_WRITE, + 5, 5, 5, 0, 5, 0, + 1, 1, 1, 1, 1, + 7L, 5, 5, + startTick, endTick, elapsedNanos, writeElapsedNanos, inspectionElapsedNanos, + 0, 0, 0, 0, + "eventless_direct_write"); + } + + private static Map summaryFields(PerformanceBlockScene.Snapshot snapshot) { + return Stream.of(snapshot.summary().split(" ")) + .map(field -> field.split("=", 2)) + .collect(Collectors.toMap(parts -> parts[0], parts -> parts[1])); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java new file mode 100644 index 00000000..dfb79947 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java @@ -0,0 +1,346 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DroppedItemSpatialIndexTest { + + @Test + void preservesTheOriginalHalfBlockCrampingBoxAcrossCellBoundaries() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex index = new DroppedItemSpatialIndex(); + index.addItem(world, 0.49D, 64.0D, 0.49D); + index.addItem(world, 0.99D, 64.5D, -0.01D); + + assertTrue(index.exceedsItemLimit(world, 0.49D, 64.0D, 0.49D, 1)); + assertFalse(index.exceedsItemLimit(world, 0.49D, 64.0D, 0.49D, 2)); + } + + @Test + void excludesItemsOutsideTheBoxAndItemsInOtherWorlds() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex index = new DroppedItemSpatialIndex(); + index.addItem(world, 0.0D, 64.0D, 0.0D); + index.addItem(world, 0.5001D, 64.0D, 0.0D); + index.addItem(UUID.randomUUID(), 0.0D, 64.0D, 0.0D); + + assertFalse(index.exceedsItemLimit(world, 0.0D, 64.0D, 0.0D, 1)); + } + + @Test + void findsViewersAcrossChunkBoundariesUsingExactDistance() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(); + viewers.addViewer(world, 16.1D, 64.0D, 0.0D); + + assertTrue(viewers.hasViewerWithin(world, 15.9D, 64.0D, 0.0D, 0.3D)); + assertFalse(viewers.hasViewerWithin(world, 15.9D, 64.0D, 0.0D, 0.1D)); + assertFalse(viewers.hasViewerWithin(UUID.randomUUID(), 15.9D, 64.0D, 0.0D, 1.0D)); + } + + @Test + void primitiveViewerStorageExpandsWithoutLosingExactMatches() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(); + for (int index = 0; index < 257; index++) { + viewers.addViewer(world, -1024.25D + index * 8.5D, 40.0D + index % 7, -index * 3.25D); + } + + assertTrue(viewers.hasViewerWithin(world, -1024.25D, 40.0D, 0.0D, 0.0D)); + assertTrue(viewers.hasViewerWithin(world, + -1024.25D + 256 * 8.5D, 40.0D + 256 % 7, -256 * 3.25D, 0.0D)); + assertFalse(viewers.hasViewerWithin(world, 5000.0D, 5000.0D, 5000.0D, 1.0D)); + } + + @Test + void rejectsNegativeExpectedViewerCapacity() { + assertThrows(IllegalArgumentException.class, + () -> new DroppedItemSpatialIndex.ViewerIndex(-1)); + } + + @Test + void viewerBoundsKeepTouchingSphereEdgesExact() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(2); + viewers.addViewer(world, -10.0D, 64.0D, -10.0D); + viewers.addViewer(world, 10.0D, 70.0D, 10.0D); + + assertTrue(viewers.hasViewerWithin(world, 20.0D, 70.0D, 10.0D, 10.0D)); + assertFalse(viewers.hasViewerWithin(world, 20.0001D, 70.0D, 10.0D, 10.0D)); + assertTrue(viewers.hasViewerWithin(world, -10.0D, 54.0D, -10.0D, 10.0D)); + assertFalse(viewers.hasViewerWithin(world, -10.0D, 53.9999D, -10.0D, 10.0D)); + } + + @Test + void viewerBoundsStillRunExactChecksInsideTheBox() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(4); + viewers.addViewer(world, -100.0D, 64.0D, -100.0D); + viewers.addViewer(world, -100.0D, 64.0D, 100.0D); + viewers.addViewer(world, 100.0D, 64.0D, -100.0D); + viewers.addViewer(world, 100.0D, 64.0D, 100.0D); + + assertFalse(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, 10.0D)); + assertTrue(viewers.hasViewerWithin(world, 90.0D, 64.0D, 100.0D, 10.0D)); + } + + @Test + void internalMissesBuildAndLaterHitsRetireTheAdaptiveGrid() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(768); + addViewerRing(viewers, world, 768, 300.0D); + + for (int query = 0; query < 8; query++) { + assertFalse(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 299 - query)); + } + assertTrue(viewers.hasAdaptiveGrid(world)); + assertTrue(viewers.isUsingAdaptiveGrid(world)); + assertTrue(viewers.hasViewerWithin(world, 300.0D, 64.0D, 0.0D, 0.0D)); + assertFalse(viewers.isUsingAdaptiveGrid(world)); + assertTrue(viewers.hasAdaptiveGrid(world)); + + viewers.addViewer(world, 0.0D, 64.0D, 0.0D); + assertFalse(viewers.hasAdaptiveGrid(world)); + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, 0.0D)); + } + + @Test + void adaptiveGridIsNotBuiltWithoutEnoughRemainingQueries() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(768); + addViewerRing(viewers, world, 768, 300.0D); + + for (int query = 0; query < 8; query++) { + assertFalse(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 7 - query)); + } + assertFalse(viewers.hasAdaptiveGrid(world)); + } + + @Test + void adaptiveGridRechecksChangingQueryRanges() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(768); + addViewerRing(viewers, world, 768, 300.0D); + for (int query = 0; query < 8; query++) { + assertFalse(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 299 - query)); + } + + assertTrue(viewers.isUsingAdaptiveGrid(world)); + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, Double.MAX_VALUE)); + assertFalse(viewers.isUsingAdaptiveGrid(world)); + assertTrue(viewers.hasAdaptiveGrid(world)); + } + + @Test + void lateViewerHitsBuildAndKeepTheAdaptiveGrid() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(191); + addViewerRing(viewers, world, 190, 300.0D); + viewers.addViewer(world, 0.0D, 64.0D, 0.0D); + + for (int query = 0; query < 8; query++) { + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 299 - query)); + } + assertTrue(viewers.hasAdaptiveGrid(world)); + assertTrue(viewers.isUsingAdaptiveGrid(world)); + for (int query = 0; query < 64; query++) { + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 291 - query)); + } + assertTrue(viewers.isUsingAdaptiveGrid(world)); + } + + @Test + void viewerBeforeDeepScanThresholdStaysOnThePrimitiveFastPath() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(127); + addViewerRing(viewers, world, 126, 300.0D); + viewers.addViewer(world, 0.0D, 64.0D, 0.0D); + + for (int query = 0; query < 16; query++) { + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 511 - query)); + } + assertFalse(viewers.hasAdaptiveGrid(world)); + assertFalse(viewers.isUsingAdaptiveGrid(world)); + assertFalse(viewers.hasActiveBounds(world)); + } + + @Test + void viewerAtDeepScanThresholdCanBuildWhenProfitable() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(128); + addViewerRing(viewers, world, 127, 300.0D); + viewers.addViewer(world, 0.0D, 64.0D, 0.0D); + + for (int query = 0; query < 8; query++) { + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 0.0D, 299 - query)); + } + assertTrue(viewers.hasAdaptiveGrid(world)); + assertTrue(viewers.isUsingAdaptiveGrid(world)); + } + + @Test + void gridCostModelRejectsUnprofitableThresholdHits() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(128); + addViewerRing(viewers, world, 127, 300.0D); + viewers.addViewer(world, 0.0D, 64.0D, 0.0D); + + for (int query = 0; query < 72; query++) { + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 511 - query)); + } + assertFalse(viewers.hasAdaptiveGrid(world)); + assertFalse(viewers.isUsingAdaptiveGrid(world)); + } + + @Test + void addingViewerResetsPartiallyPrimedAdaptiveState() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(193); + addViewerRing(viewers, world, 191, 300.0D); + viewers.addViewer(world, 0.0D, 64.0D, 0.0D); + + for (int query = 0; query < 7; query++) { + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 511 - query)); + } + assertFalse(viewers.hasAdaptiveGrid(world)); + + viewers.addViewer(world, 600.0D, 64.0D, 0.0D); + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, 72.0D, 504)); + assertFalse(viewers.hasAdaptiveGrid(world)); + for (int query = 0; query < 7; query++) { + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 503 - query)); + } + assertTrue(viewers.hasAdaptiveGrid(world)); + assertTrue(viewers.isUsingAdaptiveGrid(world)); + } + + @Test + void earlyHitClearsPartiallyPrimedDeepScanState() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(192); + addViewerRing(viewers, world, 191, 300.0D); + viewers.addViewer(world, 0.0D, 64.0D, 0.0D); + + for (int query = 0; query < 7; query++) { + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 511 - query)); + } + assertTrue(viewers.hasViewerWithin(world, 300.0D, 64.0D, 0.0D, 0.0D, 504)); + for (int query = 0; query < 7; query++) { + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 503 - query)); + } + assertFalse(viewers.hasAdaptiveGrid(world)); + assertTrue(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, 72.0D, 496)); + assertTrue(viewers.hasAdaptiveGrid(world)); + } + + @Test + void outsideMissDoesNotAdvanceInternalMissCounter() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(768); + addViewerRing(viewers, world, 768, 300.0D); + + for (int query = 0; query < 6; query++) { + assertFalse(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 511 - query)); + } + assertFalse(viewers.hasViewerWithin(world, 1000.0D, 64.0D, 0.0D, 72.0D, 505)); + assertFalse(viewers.hasAdaptiveGrid(world)); + assertTrue(viewers.hasActiveBounds(world)); + assertFalse(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, 72.0D, 504)); + assertFalse(viewers.hasAdaptiveGrid(world)); + assertFalse(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, 72.0D, 503)); + assertTrue(viewers.hasAdaptiveGrid(world)); + } + + @Test + void denseDiagonalMissesStayOnThePrimitiveScan() { + UUID world = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(384); + for (int index = 0; index < 384; index++) { + double x = (index & 1) == 0 ? -62.0D : 62.0D; + double z = (index & 2) == 0 ? -62.0D : 62.0D; + viewers.addViewer(world, x, 64.0D, z); + } + for (int query = 0; query < 71; query++) { + assertFalse(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, + 72.0D, 499 - query)); + } + assertFalse(viewers.hasAdaptiveGrid(world)); + assertFalse(viewers.hasViewerWithin(world, 0.0D, 64.0D, 0.0D, 0.0D, 428)); + assertTrue(viewers.hasAdaptiveGrid(world)); + assertTrue(viewers.isUsingAdaptiveGrid(world)); + } + + @Test + void adaptiveGridUsesPerWorldQueryBudgets() { + UUID shortWorld = UUID.randomUUID(); + UUID longWorld = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(1536); + addViewerRing(viewers, shortWorld, 768, 300.0D); + addViewerRing(viewers, longWorld, 768, 300.0D); + + for (int query = 0; query < 8; query++) { + assertFalse(viewers.hasViewerWithin(shortWorld, 0.0D, 64.0D, 0.0D, + 72.0D, 7 - query)); + assertFalse(viewers.hasViewerWithin(longWorld, 0.0D, 64.0D, 0.0D, + 72.0D, 299 - query)); + } + assertFalse(viewers.hasAdaptiveGrid(shortWorld)); + assertTrue(viewers.hasAdaptiveGrid(longWorld)); + } + + @Test + void upgradesFromSingleWorldFastPathAndKeepsWorldsIsolated() { + UUID firstWorld = UUID.randomUUID(); + UUID secondWorld = UUID.randomUUID(); + DroppedItemSpatialIndex.ViewerIndex viewers = new DroppedItemSpatialIndex.ViewerIndex(); + viewers.addViewer(firstWorld, -0.01D, 70.0D, -16.1D); + + assertTrue(viewers.hasViewerWithin(firstWorld, 0.0D, 70.0D, -16.0D, 0.15D)); + assertFalse(viewers.hasViewerWithin(secondWorld, 0.0D, 70.0D, -16.0D, 0.15D)); + + viewers.addViewer(secondWorld, 0.0D, 90.0D, 0.0D); + viewers.addViewer(firstWorld, 32.0D, 72.0D, 32.0D); + assertTrue(viewers.hasViewerWithin(secondWorld, 0.0D, 90.0D, 0.0D, 0.0D)); + assertFalse(viewers.hasViewerWithin(firstWorld, 0.0D, 90.0D, 0.0D, 0.0D)); + assertTrue(viewers.hasViewerWithin(firstWorld, 32.0D, 72.0D, 32.0D, 0.0D)); + assertFalse(viewers.hasViewerWithin(firstWorld, 32.0D, 73.0001D, 32.0D, 1.0D)); + } + + private static void addViewerRing(DroppedItemSpatialIndex.ViewerIndex viewers, UUID world, + int count, double radius) { + for (int index = 0; index < count; index++) { + double angle = 2.0D * Math.PI * index / count; + viewers.addViewer(world, Math.cos(angle) * radius, 64.0D, + Math.sin(angle) * radius); + } + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicyTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicyTest.java new file mode 100644 index 00000000..3b69c000 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicyTest.java @@ -0,0 +1,83 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DroppedItemVisibilityPolicyTest { + + @Test + void disabledCullingAndLimiterPreserveLegacyVisibility() { + DroppedItemVisibilityPolicy policy = DroppedItemVisibilityPolicy.create(false, 64, false, 128, 32); + + assertFalse(policy.cullingEnabled()); + assertFalse(policy.rateLimitEnabled()); + assertFalse(policy.controlsPerViewerVisibility()); + assertEquals(1.0F, policy.labelViewRange()); + } + + @Test + void eachExperimentalControlCanEnablePerViewerVisibility() { + DroppedItemVisibilityPolicy culling = DroppedItemVisibilityPolicy.create(true, 64, false, 128, 32); + DroppedItemVisibilityPolicy limiting = DroppedItemVisibilityPolicy.create(false, 64, true, 128, 32); + + assertTrue(culling.controlsPerViewerVisibility()); + assertTrue(limiting.controlsPerViewerVisibility()); + assertEquals(64, culling.effectiveViewDistance(96)); + assertEquals(48, culling.effectiveViewDistance(48)); + } + + @Test + void clampsDistanceAndRepairsInvalidBucketValues() { + DroppedItemVisibilityPolicy minimum = DroppedItemVisibilityPolicy.create(true, 1, true, 0, -1); + DroppedItemVisibilityPolicy maximum = DroppedItemVisibilityPolicy.create(true, 4096, true, 512, 64); + DroppedItemVisibilityPolicy repaired = DroppedItemVisibilityPolicy.create(true, 0, false, 128, 32); + + assertEquals(8, minimum.viewDistance()); + assertEquals(DroppedItemVisibilityPolicy.DEFAULT_BUCKET_SIZE, minimum.bucketSize()); + assertEquals(DroppedItemVisibilityPolicy.DEFAULT_RESTORE_PER_TICK, minimum.restorePerTick()); + assertEquals(512, maximum.viewDistance()); + assertEquals(8.0F, maximum.labelViewRange()); + assertEquals(DroppedItemVisibilityPolicy.DEFAULT_VIEW_DISTANCE, repaired.viewDistance()); + } + + @Test + void bundledConfigurationKeepsBothControlsDisabled() throws IOException { + try (InputStream stream = getClass().getClassLoader().getResourceAsStream("config.yml")) { + assertNotNull(stream); + String config = new String(stream.readAllBytes(), StandardCharsets.UTF_8) + .replace("\r\n", "\n"); + int itemOptions = config.indexOf(" Item:\n"); + int villager = config.indexOf(" Villager:\n", itemOptions); + String droppedItemOptions = config.substring(itemOptions, villager); + + assertTrue(droppedItemOptions.contains( + " VisibilityCulling:\n" + + " #Preserves the legacy label lifecycle unless explicitly enabled after validation\n" + + " Enabled: false\n" + + " ViewDistance: 64\n")); + assertTrue(droppedItemOptions.contains( + " VisibilityRateLimit:\n" + + " #Independent from Settings.Performance.VisibilityRateLimit and disabled by default\n" + + " Enabled: false\n")); + } + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucketTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucketTest.java new file mode 100644 index 00000000..4c2d29b8 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucketTest.java @@ -0,0 +1,66 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class VisibilityTokenBucketTest { + + @Test + void rateLimitsAndRefillsVisibilityChanges() { + VisibilityTokenBucket bucket = new VisibilityTokenBucket<>(2); + bucket.request(1); + bucket.request(2); + bucket.request(3); + bucket.request(4); + + assertEquals(List.of(1, 2), bucket.drain(2, 0, ignored -> true)); + assertEquals(List.of(3), bucket.drain(2, 1, ignored -> true)); + assertEquals(List.of(4), bucket.drain(2, 1, ignored -> true)); + } + + @Test + void deduplicatesAndDropsCancelledOrStaleRequests() { + VisibilityTokenBucket bucket = new VisibilityTokenBucket<>(4); + bucket.request(1); + bucket.request(1); + bucket.request(2); + bucket.request(3); + bucket.cancel(2); + + assertEquals(List.of(1), bucket.drain(4, 0, value -> value != 3)); + } + + @Test + void drainsImmediatelyWhenRateLimitingIsDisabled() { + VisibilityTokenBucket bucket = new VisibilityTokenBucket<>(0); + bucket.request(1); + bucket.request(2); + bucket.request(3); + bucket.cancel(2); + + assertEquals(List.of(1, 3), bucket.drainAll(ignored -> true)); + assertEquals(List.of(), bucket.drainAll(ignored -> true)); + } + + @Test + void largeConfiguredLimitsDoNotOverflowTokenRefill() { + VisibilityTokenBucket bucket = new VisibilityTokenBucket<>(Integer.MAX_VALUE); + bucket.request(1); + + assertEquals(List.of(1), bucket.drain(Integer.MAX_VALUE, Integer.MAX_VALUE, ignored -> true)); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entityholders/BillboardDisplayEntityPathTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entityholders/BillboardDisplayEntityPathTest.java new file mode 100644 index 00000000..af94a8cb --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/entityholders/BillboardDisplayEntityPathTest.java @@ -0,0 +1,144 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entityholders; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Display; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BillboardDisplayEntityPathTest { + + private static final double RADIUS = 0.7D; + + @Test + void facePathSnapsToTheLegacyCardinalPlane() throws ReflectiveOperationException { + World world = world("face"); + BillboardDisplayEntity display = display(world, DynamicVisualizerEntity.PathType.FACE); + + Location north = display.getViewingLocation( + new Location(world, 0.1D, 4.0D, -10.0D), new Vector(0.0D, 0.0D, 1.0D)); + Location east = display.getViewingLocation( + new Location(world, 10.0D, 4.0D, 0.1D), new Vector(0.0D, 0.0D, 1.0D)); + + assertEquals(0.0D, north.getX(), 1.0E-12D); + assertEquals(-RADIUS, north.getZ(), 1.0E-12D); + assertEquals(RADIUS, east.getX(), 1.0E-12D); + assertEquals(0.0D, east.getZ(), 1.0E-12D); + } + + @Test + void circleAndSquarePathsRetainTheirDistinctGeometry() throws ReflectiveOperationException { + World world = world("geometry"); + Location viewer = new Location(world, 3.0D, 4.0D, 4.0D); + + Location circle = display(world, DynamicVisualizerEntity.PathType.CIRCLE) + .getViewingLocation(viewer, new Vector(0.0D, 0.0D, 1.0D)); + Location square = display(world, DynamicVisualizerEntity.PathType.SQUARE) + .getViewingLocation(viewer, new Vector(0.0D, 0.0D, 1.0D)); + + assertEquals(0.42D, circle.getX(), 1.0E-12D); + assertEquals(0.56D, circle.getZ(), 1.0E-12D); + // The legacy implementation routes through Location yaw (a float), + // which intentionally leaves a few nanometres of rounding here. + assertEquals(0.525D, square.getX(), 1.0E-8D); + assertEquals(0.7D, square.getZ(), 1.0E-8D); + } + + @Test + void viewerInsideTheRadiusUsesTheirHorizontalLookDirection() + throws ReflectiveOperationException { + World world = world("inside"); + BillboardDisplayEntity display = display(world, DynamicVisualizerEntity.PathType.CIRCLE); + + Location target = display.getViewingLocation( + new Location(world, 0.1D, 4.0D, 0.1D), new Vector(1.0D, -0.5D, 0.0D)); + + // Legacy recursion first moves the viewer's already-offset leveled + // location by radius+2 along the look vector, then projects that point + // back onto the circle. + double length = Math.hypot(2.8D, 0.1D); + assertEquals(2.8D / length * RADIUS, target.getX(), 1.0E-12D); + assertEquals(0.1D / length * RADIUS, target.getZ(), 1.0E-12D); + } + + @Test + void dynamicTextUsesFullCameraBillboarding() throws ReflectiveOperationException { + World world = world("billboard"); + BillboardDisplayEntity display = display(world, DynamicVisualizerEntity.PathType.FACE); + + display.useLegacyNameTagStyle(); + + assertEquals(Display.Billboard.CENTER, display.getBillboard()); + } + + private static BillboardDisplayEntity display(World world, DynamicVisualizerEntity.PathType path) + throws ReflectiveOperationException { + BillboardDisplayEntity display = EntityHolderTestFactory.allocate(BillboardDisplayEntity.class); + set(VisualizerEntity.class, display, "location", new Location(world, 0.0D, 0.0D, 0.0D)); + set(BillboardDisplayEntity.class, display, "radius", RADIUS); + set(BillboardDisplayEntity.class, display, "path", path); + return display; + } + + private static void set(Class owner, Object target, String name, Object value) + throws ReflectiveOperationException { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static World world(String name) { + return (World) Proxy.newProxyInstance(World.class.getClassLoader(), new Class[] {World.class}, + (proxy, method, args) -> switch (method.getName()) { + case "getName" -> name; + case "equals" -> proxy == args[0]; + case "hashCode" -> System.identityHashCode(proxy); + case "toString" -> name; + default -> primitiveDefault(method.getReturnType()); + }); + } + + private static Object primitiveDefault(Class type) { + if (!type.isPrimitive()) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == char.class) { + return '\0'; + } + if (type == byte.class) { + return (byte) 0; + } + if (type == short.class) { + return (short) 0; + } + if (type == int.class) { + return 0; + } + if (type == long.class) { + return 0L; + } + if (type == float.class) { + return 0.0F; + } + return 0.0D; + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entityholders/EntityCustomNameRawStateTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entityholders/EntityCustomNameRawStateTest.java new file mode 100644 index 00000000..27f45840 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/entityholders/EntityCustomNameRawStateTest.java @@ -0,0 +1,112 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entityholders; + +import com.loohp.interactionvisualizer.utils.LegacyTextComponentCache; +import net.kyori.adventure.text.Component; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ResourceLock("legacy-text-component-cache") +class EntityCustomNameRawStateTest { + + @BeforeEach + void setUp() { + LegacyTextComponentCache.stopMeasurement(); + LegacyTextComponentCache.invalidateAll(); + } + + @AfterEach + void tearDown() { + LegacyTextComponentCache.stopMeasurement(); + LegacyTextComponentCache.invalidateAll(); + } + + @Test + void itemSkipsEqualRawTextAndInvalidatesTheSentinelForComponentAndNullSetters() + throws ReflectiveOperationException { + Item item = EntityHolderTestFactory.allocate(Item.class); + String raw = "§aReady"; + + assertTrue(item.updateCustomName(raw)); + int renderedRevision = item.cacheCode(); + assertFalse(item.updateCustomName(new String(raw))); + assertEquals(renderedRevision, item.cacheCode()); + + item.setCustomName(Component.text("override")); + int overrideRevision = item.cacheCode(); + assertTrue(item.updateCustomName(raw)); + assertEquals(overrideRevision + 1, item.cacheCode()); + + item.setCustomName(Component.text("clear me")); + assertTrue(item.updateCustomName(null)); + assertNull(item.getCustomName()); + int nullRevision = item.cacheCode(); + assertFalse(item.updateCustomName(null)); + assertEquals(nullRevision, item.cacheCode()); + } + + @Test + void displaySkipsEqualRawTextAndInvalidatesTheSentinelForComponentAndNullSetters() + throws ReflectiveOperationException { + DisplayEntity display = EntityHolderTestFactory.allocate(DisplayEntity.class); + String raw = "§bWorking"; + + assertTrue(display.updateCustomName(raw, true)); + assertTrue(display.isCustomNameVisible()); + int renderedRevision = display.cacheCode(); + assertFalse(display.updateCustomName(new String(raw), true)); + assertEquals(renderedRevision, display.cacheCode()); + + display.setCustomName(Component.text("override")); + int overrideRevision = display.cacheCode(); + assertTrue(display.updateCustomName(raw)); + assertEquals(overrideRevision + 1, display.cacheCode()); + + display.setCustomName(Component.text("clear me")); + assertTrue(display.updateCustomName(null)); + assertNull(display.getCustomName()); + int nullRevision = display.cacheCode(); + assertFalse(display.updateCustomName(null)); + assertEquals(nullRevision, display.cacheCode()); + } + + @Test + void displayTreatsEqualPlainTextWithDifferentLegacyColorsAsARealChange() + throws ReflectiveOperationException { + DisplayEntity display = EntityHolderTestFactory.allocate(DisplayEntity.class); + assertTrue(display.updateCustomName("§a||||", true)); + int greenRevision = display.cacheCode(); + + assertTrue(display.updateCustomName("§b||||", true)); + assertEquals(greenRevision + 1, display.cacheCode()); + } + + @Test + void displayTreatsEqualPlainTextWithDifferentFontsAsARealChange() + throws ReflectiveOperationException { + DisplayEntity display = EntityHolderTestFactory.allocate(DisplayEntity.class); + assertTrue(display.updateCustomName("[font=minecraft:default]same", true)); + int defaultFontRevision = display.cacheCode(); + + assertTrue(display.updateCustomName("[font=minecraft:uniform]same", true)); + assertEquals(defaultFontRevision + 1, display.cacheCode()); + } + +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entityholders/EntityHolderTestFactory.java b/common/src/test/java/com/loohp/interactionvisualizer/entityholders/EntityHolderTestFactory.java new file mode 100644 index 00000000..03b61dcf --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/entityholders/EntityHolderTestFactory.java @@ -0,0 +1,37 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entityholders; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicInteger; + +/** Test-only allocation that avoids Paper's server-backed ItemStack factory. */ +public final class EntityHolderTestFactory { + + private EntityHolderTestFactory() { + } + + public static T allocate(Class type) + throws ReflectiveOperationException { + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Method allocateInstance = unsafeClass.getMethod("allocateInstance", Class.class); + Object instance = allocateInstance.invoke(unsafeField.get(null), type); + + Field revision = VisualizerEntity.class.getDeclaredField("revision"); + revision.setAccessible(true); + revision.set(instance, new AtomicInteger(1)); + return type.cast(instance); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entityholders/ItemRenderModeTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entityholders/ItemRenderModeTest.java new file mode 100644 index 00000000..64b7eadd --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/entityholders/ItemRenderModeTest.java @@ -0,0 +1,55 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entityholders; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ItemRenderModeTest { + + @Test + void renderModesSeparateDroppedItemsFromFixedDisplays() { + assertFalse(Item.RenderMode.DROPPED.isFixedDisplay()); + assertTrue(Item.RenderMode.ITEM.isFixedDisplay()); + assertTrue(Item.RenderMode.BLOCK.isFixedDisplay()); + assertTrue(Item.RenderMode.LOW_BLOCK.isFixedDisplay()); + assertTrue(Item.RenderMode.TOOL.isFixedDisplay()); + assertTrue(Item.RenderMode.STANDING.isFixedDisplay()); + assertTrue(Item.RenderMode.BANNER.isFixedDisplay()); + assertTrue(Item.RenderMode.FRAME.isFixedDisplay()); + } + + @Test + void frameRotationUsesVanillaItemFrameBounds() throws ReflectiveOperationException { + Item frame = newUninitializedItem(); + Field rotation = Item.class.getDeclaredField("frameRotation"); + rotation.setAccessible(true); + rotation.setInt(frame, 7); + + assertEquals(7, frame.getFrameRotation()); + assertThrows(IllegalArgumentException.class, () -> frame.setFrameRotation(8)); + } + + private static Item newUninitializedItem() throws ReflectiveOperationException { + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + return (Item) unsafeClass.getMethod("allocateInstance", Class.class) + .invoke(unsafeField.get(null), Item.class); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridgeTest.java b/common/src/test/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridgeTest.java new file mode 100644 index 00000000..8f696e0f --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/integration/packet/ClientTextDisplayBridgeTest.java @@ -0,0 +1,208 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration.packet; + +import net.kyori.adventure.text.Component; +import net.minecraft.network.protocol.game.ClientboundAddEntityPacket; +import net.minecraft.network.protocol.game.ClientboundBundlePacket; +import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket; +import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket; +import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.network.ServerGamePacketListenerImpl; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.phys.Vec3; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.entity.Player; +import org.joml.Vector3f; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ClientTextDisplayBridgeTest { + + private static Field bukkitServer; + + @BeforeAll + static void installTestServer() throws ReflectiveOperationException { + bukkitServer = Bukkit.class.getDeclaredField("server"); + bukkitServer.setAccessible(true); + assertNull(bukkitServer.get(null), "another test already installed Bukkit's singleton server"); + bukkitServer.set(null, proxy(Server.class, null)); + } + + @AfterAll + static void clearTestServer() throws IllegalAccessException { + bukkitServer.set(null, null); + } + + @Test + void bundlesSpawnWithTheCompleteLegacyNameTagProfile() { + assertTrue(ClientTextDisplayBridge.initialize(), () -> + "bridge resolution failed: " + ClientTextDisplayBridge.initializationFailure()); + assertNull(ClientTextDisplayBridge.initializationFailure()); + + ClientTextDisplayBridge display = ClientTextDisplayBridge.create(); + ClientTextDisplayBridge another = ClientTextDisplayBridge.create(); + assertNotEquals(display.entityId(), another.entityId()); + + ServerGamePacketListenerImpl connection = new ServerGamePacketListenerImpl(); + Player viewer = proxy(CraftPlayer.class, new ServerPlayer(connection)); + Location location = new Location(null, 1.25, 2.5, -3.75, 90.0F, 12.0F); + + display.spawn(viewer, location, Component.text("Hello")); + + ClientboundBundlePacket bundle = assertInstanceOf( + ClientboundBundlePacket.class, connection.lastPacket()); + assertEquals(2, bundle.subPackets().size()); + ClientboundAddEntityPacket add = assertInstanceOf( + ClientboundAddEntityPacket.class, bundle.subPackets().get(0)); + assertEquals(display.entityId(), add.id()); + assertNotNull(add.uuid()); + assertEquals(1.25, add.x()); + assertEquals(2.5, add.y()); + assertEquals(-3.75, add.z()); + assertEquals(12.0F, add.xRot()); + assertEquals(90.0F, add.yRot()); + assertEquals(90.0, add.yHeadRot()); + assertSame(EntityType.TEXT_DISPLAY, add.type()); + assertSame(Vec3.ZERO, add.movement()); + + ClientboundSetEntityDataPacket metadata = assertInstanceOf( + ClientboundSetEntityDataPacket.class, bundle.subPackets().get(1)); + assertEquals(display.entityId(), metadata.id()); + assertMetadata(metadata, "Hello"); + } + + @Test + void updatesMetadataAndDestroysTheSameClientEntity() { + ClientTextDisplayBridge display = ClientTextDisplayBridge.create(); + ServerGamePacketListenerImpl connection = new ServerGamePacketListenerImpl(); + Player viewer = proxy(CraftPlayer.class, new ServerPlayer(connection)); + + display.updateMetaData(viewer, Component.text("Updated")); + + ClientboundSetEntityDataPacket metadata = assertInstanceOf( + ClientboundSetEntityDataPacket.class, connection.lastPacket()); + assertEquals(display.entityId(), metadata.id()); + assertMetadata(metadata, "Updated"); + + Location destination = new Location(null, -4.5, 17.25, 8.0, -35.0F, 22.5F); + display.teleport(viewer, destination); + + ClientboundTeleportEntityPacket teleport = assertInstanceOf( + ClientboundTeleportEntityPacket.class, connection.lastPacket()); + assertEquals(display.entityId(), teleport.id()); + assertEquals(new Vec3(-4.5, 17.25, 8.0), teleport.change().position()); + assertSame(Vec3.ZERO, teleport.change().deltaMovement()); + assertEquals(-35.0F, teleport.change().yRot()); + assertEquals(22.5F, teleport.change().xRot()); + assertTrue(teleport.relatives().isEmpty()); + assertFalse(teleport.onGround()); + + display.destroy(viewer); + + ClientboundRemoveEntitiesPacket remove = assertInstanceOf( + ClientboundRemoveEntitiesPacket.class, connection.lastPacket()); + assertArrayEquals(new int[]{display.entityId()}, remove.entityIds()); + } + + private static void assertMetadata(ClientboundSetEntityDataPacket packet, String expectedText) { + assertEquals(9, packet.packedItems().size()); + Map> values = packet.packedItems().stream() + .collect(Collectors.toMap(SynchedEntityData.DataValue::id, Function.identity())); + + assertEquals(3, values.get(10).value()); + Vector3f translation = assertInstanceOf(Vector3f.class, values.get(11).value()); + assertEquals(-0.025F, translation.x()); + assertEquals(-0.225F, translation.y()); + assertEquals(0.0F, translation.z()); + Vector3f scale = assertInstanceOf(Vector3f.class, values.get(12).value()); + assertEquals(1.0F, scale.x()); + assertEquals(1.0F, scale.y()); + assertEquals(1.0F, scale.z()); + assertEquals((byte) 3, values.get(13).value()); + assertEquals(Integer.MAX_VALUE, values.get(21).value()); + assertEquals(0, values.get(22).value()); + assertEquals((byte) -1, values.get(23).value()); + byte flags = assertInstanceOf(Byte.class, values.get(24).value()); + assertEquals((byte) 0x04, flags); + assertFalse((flags & 0x01) != 0, "shadow must stay disabled"); + assertFalse((flags & 0x02) != 0, "see-through must stay disabled"); + + net.minecraft.network.chat.Component text = assertInstanceOf( + net.minecraft.network.chat.Component.class, values.get(20).value()); + assertTrue(text.json().contains(expectedText)); + } + + @SuppressWarnings("unchecked") + private static T proxy(Class type, ServerPlayer handle) { + return (T) Proxy.newProxyInstance( + ClientTextDisplayBridgeTest.class.getClassLoader(), + new Class[]{type}, + (proxy, method, arguments) -> switch (method.getName()) { + case "getHandle" -> handle; + case "equals" -> proxy == arguments[0]; + case "hashCode" -> System.identityHashCode(proxy); + case "toString" -> "Test" + type.getSimpleName(); + default -> defaultValue(method.getReturnType()); + }); + } + + private static Object defaultValue(Class type) { + if (!type.isPrimitive() || type == void.class) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == char.class) { + return '\0'; + } + if (type == byte.class) { + return (byte) 0; + } + if (type == short.class) { + return (short) 0; + } + if (type == int.class) { + return 0; + } + if (type == long.class) { + return 0L; + } + if (type == float.class) { + return 0.0F; + } + return 0.0D; + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java index a34d4594..025fac30 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java @@ -12,11 +12,14 @@ package com.loohp.interactionvisualizer.managers; import com.loohp.interactionvisualizer.entityholders.Item; +import org.bukkit.Location; +import org.bukkit.World; import org.bukkit.util.Vector; import org.junit.jupiter.api.Test; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -39,6 +42,41 @@ void gravityAndVelocityActivateTheAnimationLoop() { assertTrue(DisplayManager.requiresItemAnimation(false, new Vector(0.0, 0.05, 0.0))); } + @Test + void virtualMotionPacketsAreSkippedForUnchangedStaticItems() { + assertFalse(DisplayManager.requiresVirtualItemMotionSync(false, false, false)); + assertTrue(DisplayManager.requiresVirtualItemMotionSync(true, false, false)); + assertTrue(DisplayManager.requiresVirtualItemMotionSync(false, true, false)); + assertTrue(DisplayManager.requiresVirtualItemMotionSync(false, false, true)); + } + + @Test + void staticAnchorExperimentNeverDetachesVisibleNames() { + assertFalse(DisplayManager.useStaticAnchorForAnimation(false, false)); + assertFalse(DisplayManager.useStaticAnchorForAnimation(false, true)); + assertTrue(DisplayManager.useStaticAnchorForAnimation(true, false)); + assertFalse(DisplayManager.useStaticAnchorForAnimation(true, true)); + } + + @Test + void packetOnlyStaticItemsRequireEverySafeProperty() { + assertTrue(DisplayManager.qualifiesForPacketOnlyStatic( + true, false, new Vector(), false, false)); + + assertFalse(DisplayManager.qualifiesForPacketOnlyStatic( + false, false, new Vector(), false, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyStatic( + true, true, new Vector(), false, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyStatic( + true, false, new Vector(0.0, 0.01, 0.0), false, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyStatic( + true, false, new Vector(Double.MIN_VALUE, 0.0, 0.0), false, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyStatic( + true, false, new Vector(), true, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyStatic( + true, false, new Vector(), false, true)); + } + @Test void copiesTheNmsAirMovementOrderAndPrecision() { Vector movement = DisplayManager.itemMovementForTick(true, new Vector(0.18, 0.15, 0.05)); @@ -50,6 +88,68 @@ void copiesTheNmsAirMovementOrderAndPrecision() { assertEquals(movement.getZ() * (double) 0.98F, nextVelocity.getZ()); } + @Test + void sameWorldLogicalTeleportResetsAnExistingAnimationPath() { + Location originalLogical = new Location(null, 1.0, 64.0, 1.0); + Location previousAnimation = new Location(null, 4.0, 65.0, 4.0); + Location teleportedLogical = new Location(null, 20.0, 70.0, 20.0); + Location actualAtTeleport = teleportedLogical.clone(); + + assertFalse(DisplayManager.shouldApplyItemLogicalLocation( + true, previousAnimation, originalLogical, originalLogical.clone())); + assertEquals(previousAnimation, DisplayManager.itemAnimationStartPosition( + actualAtTeleport, previousAnimation, originalLogical, originalLogical.clone())); + + assertTrue(DisplayManager.shouldApplyItemLogicalLocation( + true, previousAnimation, originalLogical, teleportedLogical)); + assertEquals(actualAtTeleport, DisplayManager.itemAnimationStartPosition( + actualAtTeleport, previousAnimation, originalLogical, teleportedLogical)); + } + + @Test + void endingAnAnimationRestoresTheLogicalLocation() { + Location logical = new Location(null, 1.0, 64.0, 1.0); + Location previousAnimation = new Location(null, 4.0, 65.0, 4.0); + + assertTrue(DisplayManager.shouldApplyItemLogicalLocation( + false, previousAnimation, logical, logical.clone())); + } + + @Test + void animatedChunkIndexFollowsTheMovingEntityUnlessTheAnchorIsStatic() { + Location anchor = new Location(null, 15.9, 64.0, 0.0); + Location acrossChunkBoundary = new Location(null, 16.1, 64.0, 0.0); + + assertEquals(acrossChunkBoundary, DisplayManager.itemAnimationIndexLocation( + false, anchor, acrossChunkBoundary)); + assertEquals(anchor, DisplayManager.itemAnimationIndexLocation( + true, anchor, acrossChunkBoundary)); + } + + @Test + void chunkIndexReportsOnlyExistingEntryCrossings() + throws ReflectiveOperationException { + UUID worldId = UUID.randomUUID(); + World world = (World) Proxy.newProxyInstance( + World.class.getClassLoader(), new Class[]{World.class}, + (proxy, method, arguments) -> switch (method.getName()) { + case "getUID" -> worldId; + case "equals" -> proxy == arguments[0]; + case "hashCode" -> System.identityHashCode(proxy); + case "toString" -> "animation-index-test-world"; + default -> throw new UnsupportedOperationException(method.getName()); + }); + Item logical = newUninitializedItem(); + + try { + assertFalse(DisplayManager.index(logical, new Location(world, 1.0, 64.0, 1.0))); + assertFalse(DisplayManager.index(logical, new Location(world, 15.9, 64.0, 1.0))); + assertTrue(DisplayManager.index(logical, new Location(world, 16.1, 64.0, 1.0))); + } finally { + DisplayManager.unindex(logical); + } + } + @Test @SuppressWarnings("unchecked") void claimingPickupIdsRemovesOnlyLocalBookkeeping() throws ReflectiveOperationException { @@ -59,11 +159,7 @@ void claimingPickupIdsRemovesOnlyLocalBookkeeping() throws ReflectiveOperationEx Method forget = DisplayManager.class.getDeclaredMethod("forgetVirtualItem", Item.class, UUID.class); forget.setAccessible(true); - Class unsafeClass = Class.forName("sun.misc.Unsafe"); - Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); - unsafeField.setAccessible(true); - Item logical = (Item) unsafeClass.getMethod("allocateInstance", Class.class) - .invoke(unsafeField.get(null), Item.class); + Item logical = newUninitializedItem(); UUID firstViewer = UUID.randomUUID(); UUID secondViewer = UUID.randomUUID(); Map viewerIds = new ConcurrentHashMap<>(); @@ -81,4 +177,22 @@ void claimingPickupIdsRemovesOnlyLocalBookkeeping() throws ReflectiveOperationEx allIds.remove(logical); } } + + @Test + void rateLimitedOneShotPickupMaterializesOnlyEligibleMissingViewer() { + assertTrue(DisplayManager.shouldMaterializeRateLimitedPickupViewer(true, true, true, false)); + + assertFalse(DisplayManager.shouldMaterializeRateLimitedPickupViewer(false, true, true, false)); + assertFalse(DisplayManager.shouldMaterializeRateLimitedPickupViewer(true, false, true, false)); + assertFalse(DisplayManager.shouldMaterializeRateLimitedPickupViewer(true, true, false, false)); + assertFalse(DisplayManager.shouldMaterializeRateLimitedPickupViewer(true, true, true, true)); + } + + private static Item newUninitializedItem() throws ReflectiveOperationException { + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field unsafeField = unsafeClass.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + return (Item) unsafeClass.getMethod("allocateInstance", Class.class) + .invoke(unsafeField.get(null), Item.class); + } } diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerShutdownLifecycleTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerShutdownLifecycleTest.java new file mode 100644 index 00000000..a672fbc9 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerShutdownLifecycleTest.java @@ -0,0 +1,46 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import com.loohp.interactionvisualizer.entityholders.Item; +import com.loohp.interactionvisualizer.entityholders.VisualizerEntity; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DisplayManagerShutdownLifecycleTest { + + @Test + void shutdownIncludesRepresentationsThatAlreadyLeftActive() throws ReflectiveOperationException { + DisplayEntity active = EntityHolderTestFactory.allocate(DisplayEntity.class); + DisplayEntity detachedVirtualText = EntityHolderTestFactory.allocate(DisplayEntity.class); + Item detachedVirtualItem = EntityHolderTestFactory.allocate(Item.class); + DisplayEntity detachedActualEntity = EntityHolderTestFactory.allocate(DisplayEntity.class); + + Set candidates = DisplayManager.shutdownCleanupCandidates( + Set.of(active), + Set.of(), + Set.of(detachedVirtualText), + Set.of(detachedVirtualItem), + Set.of(detachedActualEntity)); + + assertEquals(4, candidates.size()); + assertTrue(candidates.containsAll(Set.of( + active, detachedVirtualText, detachedVirtualItem, detachedActualEntity))); + } + +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerTextStyleTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerTextStyleTest.java new file mode 100644 index 00000000..5f60ac8d --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerTextStyleTest.java @@ -0,0 +1,105 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import net.kyori.adventure.text.Component; +import org.bukkit.entity.TextDisplay; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DisplayManagerTextStyleTest { + + @Test + void hiddenLegacyNameTagKeepsItsTextKindWithoutRenderingText() + throws ReflectiveOperationException { + DisplayEntity logical = EntityHolderTestFactory.allocate(DisplayEntity.class); + logical.useLegacyNameTagStyle(); + logical.setCustomName(Component.text("lectern title")); + + assertTrue(logical.isTextDisplay()); + assertFalse(logical.isCustomNameVisible()); + assertFalse(DisplayManager.shouldRender(logical)); + + logical.setCustomNameVisible(true); + + assertTrue(DisplayManager.shouldRender(logical)); + } + + @Test + void floatingTextUsesNormalDepthTestingWithoutAForcedShadow() + throws ReflectiveOperationException { + Map setters = new HashMap<>(); + TextDisplay actual = recordingTextDisplay(setters); + DisplayEntity logical = EntityHolderTestFactory.allocate(DisplayEntity.class); + logical.setCustomName(Component.text("blocked by walls")); + logical.setCustomNameVisible(true); + logical.useLegacyNameTagStyle(); + + DisplayManager.applyTextDisplay(actual, logical); + + assertEquals(false, setters.get("setSeeThrough")); + assertEquals(false, setters.get("setShadowed")); + assertEquals(true, setters.get("setDefaultBackground")); + assertEquals(Integer.MAX_VALUE, setters.get("setLineWidth")); + } + + @Test + void itemNameCanDisableWrappingWithoutChangingItsGeometryProfile() + throws ReflectiveOperationException { + Map setters = new HashMap<>(); + TextDisplay actual = recordingTextDisplay(setters); + DisplayEntity logical = EntityHolderTestFactory.allocate(DisplayEntity.class); + logical.setCustomName(Component.text("a deliberately long item name")); + logical.setCustomNameVisible(true); + logical.setUnboundedTextWidth(true); + + DisplayManager.applyTextDisplay(actual, logical); + + assertEquals(Integer.MAX_VALUE, setters.get("setLineWidth")); + assertFalse(logical.usesLegacyNameTagStyle()); + assertFalse(logical.usesLegacyNameTagGeometry()); + } + + private static TextDisplay recordingTextDisplay(Map setters) { + return (TextDisplay) Proxy.newProxyInstance( + TextDisplay.class.getClassLoader(), new Class[] {TextDisplay.class}, + (proxy, method, args) -> { + if (args != null && args.length == 1 && method.getName().startsWith("set")) { + setters.put(method.getName(), args[0]); + } + Class returnType = method.getReturnType(); + if (returnType == boolean.class) { + return false; + } + if (returnType == byte.class || returnType == short.class + || returnType == int.class || returnType == long.class) { + return 0; + } + if (returnType == float.class || returnType == double.class) { + return 0.0; + } + if (returnType == char.class) { + return '\0'; + } + return null; + }); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerVisibilityShowQueueTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerVisibilityShowQueueTest.java new file mode 100644 index 00000000..b52b4223 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerVisibilityShowQueueTest.java @@ -0,0 +1,164 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DisplayManagerVisibilityShowQueueTest { + + @Test + void deduplicatesAndCancelsPendingShows() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(2); + + assertTrue(queue.request("first")); + assertFalse(queue.request("first")); + assertTrue(queue.request("second")); + queue.cancel("first"); + + assertEquals(List.of("second"), queue.drain(2, 0, ignored -> true)); + assertTrue(queue.isEmpty()); + } + + @Test + void cancelThenRequestUsesTheNewQueuePositionExactlyOnce() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(3); + queue.request("first"); + queue.request("second"); + queue.cancel("first"); + assertTrue(queue.request("first")); + + assertEquals(List.of("second", "first"), + queue.drain(3, 0, ignored -> true)); + assertTrue(queue.isEmpty()); + } + + @Test + void enforcesCapacityAndRefillsPerDrain() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(2); + queue.request(1); + queue.request(2); + queue.request(3); + queue.request(4); + + assertEquals(List.of(1, 2), queue.drain(2, 0, ignored -> true)); + assertEquals(List.of(3), queue.drain(2, 1, ignored -> true)); + assertEquals(List.of(4), queue.drain(2, 1, ignored -> true)); + assertTrue(queue.isEmpty()); + } + + @Test + void staleEntriesDoNotConsumeTokens() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(1); + queue.request("stale"); + queue.request("desired"); + + assertEquals(List.of(), queue.drain(1, 0, "desired"::equals)); + assertEquals(List.of("desired"), + queue.drain(1, 0, "desired"::equals)); + assertTrue(queue.isEmpty()); + } + + @Test + void rejectedInspectionWorkIsBoundedAcrossTicks() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(1, 0L); + for (int index = 0; index < 20; index++) { + queue.request("rejected-" + index); + } + queue.request("desired"); + + for (long tick = 1L; tick <= 5L; tick++) { + assertEquals(List.of(), queue.drain(4, 0, tick, "desired"::equals)); + assertFalse(queue.isEmpty()); + } + assertEquals(List.of("desired"), queue.drain(4, 0, 6L, "desired"::equals)); + assertTrue(queue.isEmpty()); + } + + @Test + void cancelledEntriesDoNotLeaveInspectionBacklog() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(1, 0L); + for (int index = 0; index < 20; index++) { + queue.request("cancelled-" + index); + } + queue.request("desired"); + for (int index = 0; index < 20; index++) { + queue.cancel("cancelled-" + index); + } + + assertEquals(List.of("desired"), queue.drain(4, 0, 1L, ignored -> true)); + assertTrue(queue.isEmpty()); + } + + @Test + void anEmptyQueueKeepsItsSpentBucketForTheNextWave() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(2, 0L); + queue.request(1); + queue.request(2); + + assertEquals(List.of(1, 2), queue.drain(2, 1, 1L, ignored -> true)); + assertTrue(queue.isEmpty()); + + queue.request(3); + queue.request(4); + assertEquals(List.of(3), queue.drain(2, 1, 2L, ignored -> true)); + assertEquals(List.of(4), queue.drain(2, 1, 3L, ignored -> true)); + } + + @Test + void disablingLimitCanDrainEverythingStillDesired() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(0); + queue.request("first"); + queue.request("stale"); + queue.request("second"); + + assertEquals(List.of("first", "second"), + queue.drainAll(value -> !value.equals("stale"))); + assertTrue(queue.isEmpty()); + } + + @Test + void callbackDrainConsumesReadyEntriesWithoutBuildingAnIntermediateList() { + DisplayManager.VisibilityShowQueue queue = + new DisplayManager.VisibilityShowQueue<>(2, 0L); + queue.request("first"); + queue.request("stale"); + queue.request("second"); + List shown = new ArrayList<>(); + + int drained = queue.drainTo(2, 0, 1L, value -> { + if (value.equals("stale")) { + return false; + } + shown.add(value); + return true; + }); + + assertEquals(1, drained); + assertEquals(List.of("first"), shown); + assertEquals(List.of("second"), queue.drain(2, 0, 2L, ignored -> true)); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/EventDrivenBeeRoutingTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/EventDrivenBeeRoutingTest.java new file mode 100644 index 00000000..b52718a4 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/EventDrivenBeeRoutingTest.java @@ -0,0 +1,118 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EventDrivenBeeRoutingTest { + + @Test + void pistonRoutingIncludesSourceAndDestinationForBothDirections() { + FakeBlocks world = new FakeBlocks(); + Block piston = world.block(0, 0, 0); + Block moved = world.block(1, 0, 0); + + Set extended = EventDrivenBlockUpdateListener.pistonAffectedBlocks( + piston, List.of(moved), BlockFace.EAST, BlockFace.EAST); + assertEquals(Set.of(world.block(0, 0, 0), world.block(1, 0, 0), world.block(2, 0, 0)), + extended); + + // Bukkit supplies WEST as the actual retract movement, while the old + // piston head that disappears remains on the outward EAST side. + Set retracted = EventDrivenBlockUpdateListener.pistonAffectedBlocks( + piston, List.of(world.block(2, 0, 0)), BlockFace.WEST, BlockFace.EAST); + assertEquals(Set.of(world.block(0, 0, 0), world.block(1, 0, 0), world.block(2, 0, 0)), + retracted); + + Set emptyRetraction = EventDrivenBlockUpdateListener.pistonAffectedBlocks( + piston, List.of(), BlockFace.WEST, BlockFace.EAST); + assertEquals(Set.of(world.block(0, 0, 0), world.block(1, 0, 0)), emptyRetraction); + } + + @Test + void overlappingChangedColumnsRouteEachBeeBlockOnceAndOnlyWithinFiveBlocks() { + FakeBlocks world = new FakeBlocks(); + world.material(0, 2, 0, Material.STONE); + world.material(0, 3, 0, Material.BEEHIVE); + world.material(0, 4, 0, Material.BEE_NEST); + world.material(0, 7, 0, Material.BEEHIVE); + + List routed = new ArrayList<>(); + EventDrivenBlockUpdateListener.scanAffectedColumns( + List.of(world.block(0, 0, 0), world.block(0, 1, 0)), routed::add); + + assertEquals(List.of(world.block(0, 3, 0), world.block(0, 4, 0)), routed); + } + + @Test + void redstoneAndInteractionFiltersOnlyAdmitRelevantBlockFamilies() { + assertTrue(EventDrivenBlockUpdateListener.isRedstoneOpenable(Material.OAK_TRAPDOOR)); + assertTrue(EventDrivenBlockUpdateListener.isRedstoneOpenable(Material.IRON_DOOR)); + assertTrue(EventDrivenBlockUpdateListener.isRedstoneOpenable(Material.OAK_FENCE_GATE)); + assertFalse(EventDrivenBlockUpdateListener.isRedstoneOpenable(Material.REDSTONE_WIRE)); + + assertTrue(EventDrivenBlockUpdateListener.isSmokeColumnInteractable(Material.CAMPFIRE)); + assertTrue(EventDrivenBlockUpdateListener.isSmokeColumnInteractable(Material.SOUL_CAMPFIRE)); + assertTrue(EventDrivenBlockUpdateListener.isSmokeColumnInteractable(Material.IRON_TRAPDOOR)); + assertFalse(EventDrivenBlockUpdateListener.isSmokeColumnInteractable(Material.STONE)); + } + + private record Position(int x, int y, int z) { + } + + private static final class FakeBlocks { + + private final Map materials = new HashMap<>(); + private final Map blocks = new HashMap<>(); + + void material(int x, int y, int z, Material material) { + materials.put(new Position(x, y, z), material); + } + + Block block(int x, int y, int z) { + Position position = new Position(x, y, z); + return blocks.computeIfAbsent(position, ignored -> (Block) Proxy.newProxyInstance( + Block.class.getClassLoader(), new Class[]{Block.class}, + (proxy, method, arguments) -> switch (method.getName()) { + case "getType" -> materials.getOrDefault(position, Material.AIR); + case "getX" -> position.x(); + case "getY" -> position.y(); + case "getZ" -> position.z(); + case "getRelative" -> { + BlockFace face = (BlockFace) arguments[0]; + int distance = arguments.length == 1 ? 1 : (int) arguments[1]; + yield block(position.x() + face.getModX() * distance, + position.y() + face.getModY() * distance, + position.z() + face.getModZ() * distance); + } + case "hashCode" -> position.hashCode(); + case "equals" -> proxy == arguments[0]; + case "toString" -> "FakeBlock" + position; + default -> throw new AssertionError("Unexpected Block method: " + method.getName()); + })); + } + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListenerRegistrationTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListenerRegistrationTest.java new file mode 100644 index 00000000..a1be0778 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListenerRegistrationTest.java @@ -0,0 +1,139 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import com.loohp.interactionvisualizer.api.events.TileEntityRemovedEvent; +import com.loohp.interactionvisualizer.blocks.BeeHiveDisplay; +import com.loohp.interactionvisualizer.blocks.BeeNestDisplay; +import com.loohp.interactionvisualizer.blocks.BlastFurnaceDisplay; +import com.loohp.interactionvisualizer.blocks.FurnaceDisplay; +import com.loohp.interactionvisualizer.blocks.SmokerDisplay; +import org.bukkit.Material; +import org.bukkit.event.EventHandler; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.block.BlockPistonExtendEvent; +import org.bukkit.event.block.BlockPistonRetractEvent; +import org.bukkit.event.block.BlockRedstoneEvent; +import org.bukkit.event.entity.EntityChangeBlockEvent; +import org.bukkit.event.entity.EntityEnterBlockEvent; +import org.bukkit.event.inventory.FurnaceBurnEvent; +import org.bukkit.event.inventory.FurnaceExtractEvent; +import org.bukkit.event.inventory.FurnaceSmeltEvent; +import org.bukkit.event.inventory.FurnaceStartSmeltEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryMoveItemEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.world.ChunkLoadEvent; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class EventDrivenBlockUpdateListenerRegistrationTest { + + @Test + void eventDrivenOnlyHandlersAreAbsentFromAlwaysRegisteredListeners() throws Exception { + assertNotRegistered(FurnaceDisplay.class, "onFurnaceBurn", FurnaceBurnEvent.class); + assertNotRegistered(BlastFurnaceDisplay.class, "onBlastFurnaceBurn", FurnaceBurnEvent.class); + assertNotRegistered(SmokerDisplay.class, "onSmokerBurn", FurnaceBurnEvent.class); + assertNotRegistered(BeeHiveDisplay.class, "onAffectedBlockPlace", BlockPlaceEvent.class); + assertNotRegistered(SmokerDisplay.class, "onRemoveSmoker", TileEntityRemovedEvent.class); + assertNotRegistered(TileEntityManager.class, "onChunkLoad", ChunkLoadEvent.class); + } + + @Test + void legacyHandlersRemainOnAlwaysRegisteredListeners() throws Exception { + assertRegistered(FurnaceDisplay.class, "onUseFurnace", InventoryClickEvent.class); + assertRegistered(BeeHiveDisplay.class, "onBeeEnterBeehive", EntityEnterBlockEvent.class); + assertRegistered(BeeNestDisplay.class, "onBeeEnterBeenest", EntityEnterBlockEvent.class); + assertRegistered(TileEntityManager.class, "onPlayerMove", PlayerMoveEvent.class); + } + + @Test + void conditionalListenerOwnsTheEventDrivenSurface() throws Exception { + // Paper 26.1.2 emits FurnaceBurnEvent for every processing furnace on + // every tick. It is a level signal, not an invalidation edge; routing it + // would permanently saturate each furnace scheduler's dirty budget. + assertNoRegisteredHandler(EventDrivenBlockUpdateListener.class, FurnaceBurnEvent.class); + assertRegistered(EventDrivenBlockUpdateListener.class, "onFurnaceStartSmelt", FurnaceStartSmeltEvent.class); + assertRegistered(EventDrivenBlockUpdateListener.class, "onFurnaceSmelt", FurnaceSmeltEvent.class); + assertRegistered(EventDrivenBlockUpdateListener.class, "onFurnaceExtract", FurnaceExtractEvent.class); + assertRegistered(EventDrivenBlockUpdateListener.class, "onInventoryMoveItem", InventoryMoveItemEvent.class); + assertRegistered(EventDrivenBlockUpdateListener.class, "onAffectedBlockPlace", BlockPlaceEvent.class); + assertRegistered(EventDrivenBlockUpdateListener.class, "onBeeEnterBlock", EntityEnterBlockEvent.class); + assertRegistered(EventDrivenBlockUpdateListener.class, "onEntityChangeBlock", EntityChangeBlockEvent.class); + assertRegistered(EventDrivenBlockUpdateListener.class, "onBeeRelevantInteract", PlayerInteractEvent.class); + assertRegistered(EventDrivenBlockUpdateListener.class, "onAffectedPistonExtend", BlockPistonExtendEvent.class); + assertRegistered(EventDrivenBlockUpdateListener.class, "onAffectedPistonRetract", BlockPistonRetractEvent.class); + assertRegistered(EventDrivenBlockUpdateListener.class, "onAffectedRedstone", BlockRedstoneEvent.class); + assertRegistered(EventDrivenBlockUpdateListener.class, "onTileEntityRemoved", TileEntityRemovedEvent.class); + Class lifecycleListener = findDeclaredClass(TileEntityManager.class, "EventDrivenLifecycleListener"); + assertRegistered(lifecycleListener, "onChunkLoad", ChunkLoadEvent.class); + } + + @Test + void hotPathMaterialRoutingIsExclusive() { + assertEquals(EventDrivenBlockUpdateListener.FurnaceTarget.FURNACE, + EventDrivenBlockUpdateListener.furnaceTarget(Material.FURNACE)); + assertEquals(EventDrivenBlockUpdateListener.FurnaceTarget.BLAST_FURNACE, + EventDrivenBlockUpdateListener.furnaceTarget(Material.BLAST_FURNACE)); + assertEquals(EventDrivenBlockUpdateListener.FurnaceTarget.SMOKER, + EventDrivenBlockUpdateListener.furnaceTarget(Material.SMOKER)); + assertEquals(EventDrivenBlockUpdateListener.FurnaceTarget.NONE, + EventDrivenBlockUpdateListener.furnaceTarget(Material.CHEST)); + assertEquals(EventDrivenBlockUpdateListener.FurnaceTarget.NONE, + EventDrivenBlockUpdateListener.furnaceTarget(null)); + + assertEquals(EventDrivenBlockUpdateListener.BeeTarget.HIVE, + EventDrivenBlockUpdateListener.beeTarget(Material.BEEHIVE)); + assertEquals(EventDrivenBlockUpdateListener.BeeTarget.NEST, + EventDrivenBlockUpdateListener.beeTarget(Material.BEE_NEST)); + assertEquals(EventDrivenBlockUpdateListener.BeeTarget.NONE, + EventDrivenBlockUpdateListener.beeTarget(Material.HONEY_BLOCK)); + assertEquals(EventDrivenBlockUpdateListener.BeeTarget.NONE, + EventDrivenBlockUpdateListener.beeTarget(null)); + assertNull(EventDrivenBlockUpdateListener.inventoryBlock(null)); + } + + private static Class findDeclaredClass(Class owner, String simpleName) { + for (Class candidate : owner.getDeclaredClasses()) { + if (candidate.getSimpleName().equals(simpleName)) { + return candidate; + } + } + throw new AssertionError("Missing " + owner.getSimpleName() + "." + simpleName); + } + + private static void assertRegistered(Class type, String methodName, Class eventType) throws Exception { + Method method = type.getDeclaredMethod(methodName, eventType); + assertNotNull(method.getAnnotation(EventHandler.class), type.getSimpleName() + "." + methodName); + } + + private static void assertNotRegistered(Class type, String methodName, Class eventType) throws Exception { + Method method = type.getDeclaredMethod(methodName, eventType); + assertNull(method.getAnnotation(EventHandler.class), type.getSimpleName() + "." + methodName); + } + + private static void assertNoRegisteredHandler(Class type, Class eventType) { + for (Method method : type.getDeclaredMethods()) { + Class[] parameters = method.getParameterTypes(); + boolean catchesEvent = parameters.length == 1 && parameters[0].isAssignableFrom(eventType); + assertFalse(catchesEvent && method.getAnnotation(EventHandler.class) != null, + type.getSimpleName() + "." + method.getName()); + } + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/InventoryRefreshEventTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/InventoryRefreshEventTest.java new file mode 100644 index 00000000..b0558f5f --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/InventoryRefreshEventTest.java @@ -0,0 +1,122 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import com.destroystokyo.paper.event.inventory.PrepareResultEvent; +import com.loohp.interactionvisualizer.api.VisualizerInteractDisplay; +import com.loohp.interactionvisualizer.listeners.Events; +import com.loohp.interactionvisualizer.objectholders.EntryKey; +import io.papermc.paper.event.player.PlayerLoomPatternSelectEvent; +import io.papermc.paper.event.player.PlayerStonecutterRecipeSelectEvent; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.inventory.PrepareItemCraftEvent; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InventoryRefreshEventTest { + + @Test + void inventoryMutationEventsQueuePostCommitRefreshes() throws Exception { + assertHandler("onInventoryOpen", InventoryOpenEvent.class, true); + assertHandler("onInventoryClick", InventoryClickEvent.class, true); + assertHandler("onInventoryDrag", InventoryDragEvent.class, true); + assertHandler("onPrepareInventoryResult", PrepareResultEvent.class, false); + assertHandler("onPrepareItemCraft", PrepareItemCraftEvent.class, false); + assertHandler("onStonecutterRecipeSelect", PlayerStonecutterRecipeSelectEvent.class, true); + assertHandler("onLoomPatternSelect", PlayerLoomPatternSelectEvent.class, true); + } + + @Test + void coalescesRefreshesWhilePreservingTheOneTickOpenCallback() { + UUID playerId = UUID.randomUUID(); + try { + assertEquals(1L, TaskManager.INVENTORY_OPEN_PROCESS_DELAY_TICKS); + assertEquals(2L, TaskManager.INVENTORY_PROCESS_DELAY_TICKS); + assertTrue(TaskManager.markInventoryRefreshQueued(playerId)); + assertFalse(TaskManager.markInventoryRefreshQueued(playerId)); + + assertTrue(TaskManager.markInventoryOpenProcessQueued(playerId), + "an open callback must replace an older native refresh"); + assertFalse(TaskManager.markInventoryRefreshQueued(playerId), + "the pending open callback already refreshes native displays"); + assertFalse(TaskManager.markInventoryOpenProcessQueued(playerId)); + + TaskManager.clearPendingInventoryProcess(playerId); + assertTrue(TaskManager.markInventoryRefreshQueued(playerId)); + } finally { + TaskManager.clearPendingInventoryProcess(playerId); + } + } + + @Test + void nativeRefreshesDoNotInvokeCustomOpenCallbacks() { + VisualizerInteractDisplay nativeDisplay = display(new EntryKey("native_test")); + VisualizerInteractDisplay customDisplay = display(new EntryKey("example", "custom_test")); + + assertTrue(TaskManager.isNativeInventoryDisplay(nativeDisplay)); + assertFalse(TaskManager.isNativeInventoryDisplay(customDisplay)); + } + + @Test + void filtersBottomInventoryMutationsThatCannotAffectTheTop() throws Exception { + Method method = Events.class.getDeclaredMethod( + "affectsTopInventory", int.class, int.class, InventoryAction.class); + method.setAccessible(true); + + assertTrue((boolean) method.invoke(null, 9, 0, InventoryAction.PICKUP_ALL)); + assertFalse((boolean) method.invoke(null, 9, 12, InventoryAction.PICKUP_ALL)); + assertTrue((boolean) method.invoke(null, 9, 12, InventoryAction.MOVE_TO_OTHER_INVENTORY)); + assertTrue((boolean) method.invoke(null, 9, 12, InventoryAction.COLLECT_TO_CURSOR)); + assertFalse((boolean) method.invoke(null, 9, -999, InventoryAction.DROP_ALL_CURSOR)); + + Method dragMethod = Events.class.getDeclaredMethod( + "affectsTopInventory", int.class, Iterable.class); + dragMethod.setAccessible(true); + assertTrue((boolean) dragMethod.invoke(null, 9, List.of(8, 12))); + assertFalse((boolean) dragMethod.invoke(null, 9, List.of(9, 12))); + } + + private static void assertHandler(String methodName, Class eventType, + boolean ignoreCancelled) throws Exception { + Method method = Events.class.getDeclaredMethod(methodName, eventType); + EventHandler annotation = method.getAnnotation(EventHandler.class); + assertEquals(EventPriority.MONITOR, annotation.priority(), methodName); + assertEquals(ignoreCancelled, annotation.ignoreCancelled(), methodName); + } + + private static VisualizerInteractDisplay display(EntryKey key) { + return new VisualizerInteractDisplay() { + @Override + public EntryKey key() { + return key; + } + + @Override + public void process(Player player) { + } + }; + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java new file mode 100644 index 00000000..aae3795a --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java @@ -0,0 +1,114 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PerformanceMetricsSlowestTickTest { + + @Test + void attributesAggregatedBlockWorkToStrictlySlowestCompletedTick() { + PerformanceMetrics.SlowestTickTracker tracker = new PerformanceMetrics.SlowestTickTracker(); + + tracker.blockUpdateChecks(2, 3_000_000L); + tracker.blockUpdateChecks(3, 5_000_000L); + tracker.completeTick(100, 10.0D, 1_000L); + assertSlowest(tracker, 100, 5, 8_000_000L); + assertEquals(1_000L, tracker.slowestEndEpochMillis()); + + tracker.blockUpdateChecks(7, 9_000_000L); + tracker.completeTick(101, 5.0D, 2_000L); + assertSlowest(tracker, 100, 5, 8_000_000L); + assertEquals(1_000L, tracker.slowestEndEpochMillis()); + + tracker.blockUpdateChecks(11, 13_000_000L); + tracker.completeTick(102, 20.0D, 3_000L); + assertSlowest(tracker, 102, 11, 13_000_000L); + assertEquals(3_000L, tracker.slowestEndEpochMillis()); + + tracker.blockUpdateChecks(17, 19_000_000L); + tracker.completeTick(103, 20.0D, 4_000L); + assertSlowest(tracker, 102, 11, 13_000_000L); + assertEquals(3_000L, tracker.slowestEndEpochMillis()); + } + + @Test + void discardedAndCompletedTicksDoNotLeakCurrentWork() { + PerformanceMetrics.SlowestTickTracker tracker = new PerformanceMetrics.SlowestTickTracker(); + + tracker.blockUpdateChecks(23, 29_000_000L); + tracker.discardTick(); + tracker.completeTick(200, 1.0D); + + assertSlowest(tracker, 200, 0, 0L); + + tracker.blockUpdateChecks(31, 37_000_000L); + tracker.completeTick(201, 2.0D); + tracker.completeTick(202, 3.0D); + + assertSlowest(tracker, 202, 0, 0L); + } + + @Test + void resetRestoresNoSampleSentinel() { + PerformanceMetrics.SlowestTickTracker tracker = new PerformanceMetrics.SlowestTickTracker(); + tracker.blockUpdateChecks(1, 2L); + tracker.completeTick(300, 3.0D); + + tracker.reset(); + + assertSlowest(tracker, -1, 0, 0L); + assertEquals(-1L, tracker.slowestEndEpochMillis()); + } + + @Test + void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { + PerformanceMetrics.Snapshot snapshot = new PerformanceMetrics.Snapshot( + "diagnostic", false, false, false, 128, 32, + new PerformanceMetrics.DroppedLabelVisibilityConfig(true, 64, true, 128, 32), + true, 64, true, + 1_000_000_000L, 20, 0L, + 1.0D, 2.0D, 3.0D, 4.0D, 50.25D, + 4242, 1_783_951_200_123L, 7L, 12_345_678L, + 5.0D, 1L, + 0L, 0L, 0L, 0L, 0L, + 0L, 0L, 0L, 0L, 0L, + 0L, 0L, 0L, 0L, 0L, 0L, + 0L, 0L, 7L, 12_345_678L, 100L, 5L, 200L); + + String json = snapshot.json(); + + assertTrue(json.contains("\"msptMax\":50.250000")); + assertTrue(json.contains("\"msptMaxBukkitTick\":4242")); + assertTrue(json.contains("\"msptMaxEndEpochMillis\":1783951200123")); + assertTrue(json.contains("\"msptMaxBlockUpdateChecks\":7")); + assertTrue(json.contains("\"msptMaxBlockUpdateMs\":12.345678")); + assertTrue(json.contains("\"droppedLabelVisibilityCulling\":true")); + assertTrue(json.contains("\"droppedLabelViewDistance\":64")); + assertTrue(json.contains("\"droppedLabelVisibilityRateLimit\":true")); + assertTrue(json.contains("\"droppedLabelVisibilityBucketSize\":128")); + assertTrue(json.contains("\"droppedLabelVisibilityRestorePerTick\":32")); + assertTrue(json.contains("\"legacyTextCacheHits\":95")); + assertTrue(json.contains("\"legacyTextCacheHitRate\":0.950000")); + assertTrue(json.contains("\"legacyTextSameRawFastPaths\":200")); + } + + private static void assertSlowest(PerformanceMetrics.SlowestTickTracker tracker, + int tick, long checks, long nanos) { + assertEquals(tick, tracker.slowestBukkitTick()); + assertEquals(checks, tracker.slowestBlockUpdateChecks()); + assertEquals(nanos, tracker.slowestBlockUpdateNanos()); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/TileEntityManagerLifecycleOrderTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/TileEntityManagerLifecycleOrderTest.java new file mode 100644 index 00000000..a5ad278f --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/TileEntityManagerLifecycleOrderTest.java @@ -0,0 +1,130 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TileEntityManagerLifecycleOrderTest { + + @Test + void removesTheStaleTypeBeforeAddingTheReplacement() { + Map> active = emptyActiveSets(); + Map lastActive = new HashMap<>(); + List events = new ArrayList<>(); + active.get(TileEntityType.FURNACE).add("block"); + lastActive.put("block", TileEntityType.FURNACE); + + TileEntityManager.reconcileActiveType( + "block", TileEntityType.BLAST_FURNACE, true, true, + active, lastActive, + (value, change, type) -> events.add(change + ":" + type)); + + assertEquals(List.of( + "REMOVED:FURNACE", + "ADDED:BLAST_FURNACE"), events); + assertFalse(active.get(TileEntityType.FURNACE).contains("block")); + assertTrue(active.get(TileEntityType.BLAST_FURNACE).contains("block")); + assertEquals(TileEntityType.BLAST_FURNACE, lastActive.get("block")); + } + + @Test + void reactivationOfTheSameTypeKeepsItsDistinctLifecycleSignal() { + Map> active = emptyActiveSets(); + Map lastActive = new HashMap<>(); + List events = new ArrayList<>(); + lastActive.put("block", TileEntityType.BEEHIVE); + + TileEntityManager.reconcileActiveType( + "block", TileEntityType.BEEHIVE, true, true, + active, lastActive, + (value, change, type) -> events.add(change + ":" + type)); + + assertEquals(List.of("ACTIVATED:BEEHIVE"), events); + assertTrue(active.get(TileEntityType.BEEHIVE).contains("block")); + } + + @Test + void removalClearsTheLastActiveTypeBeforeDispatchingTheRemoval() { + Map> active = emptyActiveSets(); + Map lastActive = new HashMap<>(); + List events = new ArrayList<>(); + active.get(TileEntityType.BEE_NEST).add("block"); + lastActive.put("block", TileEntityType.BEE_NEST); + + TileEntityManager.reconcileActiveType( + "block", null, true, true, + active, lastActive, + (value, change, type) -> { + assertFalse(lastActive.containsKey(value)); + events.add(change + ":" + type); + }); + + assertEquals(List.of("REMOVED:BEE_NEST"), events); + assertFalse(lastActive.containsKey("block")); + } + + @Test + void inactiveCurrentTypeIsNotAddedAndPreservesTheLastActiveType() { + Map> active = emptyActiveSets(); + Map lastActive = new HashMap<>(); + List events = new ArrayList<>(); + lastActive.put("block", TileEntityType.BEEHIVE); + + TileEntityManager.reconcileActiveType( + "block", TileEntityType.BEEHIVE, false, true, + active, lastActive, + (value, change, type) -> events.add(change + ":" + type)); + + assertTrue(events.isEmpty()); + assertFalse(active.get(TileEntityType.BEEHIVE).contains("block")); + assertEquals(TileEntityType.BEEHIVE, lastActive.get("block")); + } + + @Test + void legacyModeReplacesTheActiveTypeWithoutTrackingAnAddedEvent() { + Map> active = emptyActiveSets(); + Map lastActive = new HashMap<>(); + List events = new ArrayList<>(); + active.get(TileEntityType.FURNACE).add("block"); + + TileEntityManager.reconcileActiveType( + "block", TileEntityType.BLAST_FURNACE, true, false, + active, lastActive, + (value, change, type) -> events.add(change + ":" + type)); + + assertEquals(List.of("REMOVED:FURNACE"), events); + assertFalse(active.get(TileEntityType.FURNACE).contains("block")); + assertTrue(active.get(TileEntityType.BLAST_FURNACE).contains("block")); + assertTrue(lastActive.isEmpty()); + } + + private static Map> emptyActiveSets() { + Map> active = new EnumMap<>(TileEntityType.class); + for (TileEntityType type : TileEntityType.values()) { + active.put(type, new HashSet<>()); + } + return active; + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/objectholders/ChunkPositionTest.java b/common/src/test/java/com/loohp/interactionvisualizer/objectholders/ChunkPositionTest.java new file mode 100644 index 00000000..ef209846 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/objectholders/ChunkPositionTest.java @@ -0,0 +1,55 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.objectholders; + +import org.bukkit.World; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +class ChunkPositionTest { + + @Test + void equalityComparesCoordinatesInsteadOfOnlyHashCodes() { + World world = world(UUID.fromString("10000000-0000-0000-0000-000000000001")); + ChunkPosition first = new ChunkPosition(world, 0, 17); + ChunkPosition colliding = new ChunkPosition(world, 1, 0); + + assertEquals(first.hashCode(), colliding.hashCode()); + assertNotEquals(first, colliding); + assertEquals(first, new ChunkPosition(world, 0, 17)); + } + + private static World world(UUID id) { + return (World) Proxy.newProxyInstance( + World.class.getClassLoader(), new Class[]{World.class}, (proxy, method, arguments) -> { + if (method.getName().equals("getUID")) { + return id; + } + if (method.getName().equals("toString")) { + return "World[" + id + "]"; + } + if (method.getName().equals("hashCode")) { + return System.identityHashCode(proxy); + } + if (method.getName().equals("equals")) { + return proxy == arguments[0]; + } + throw new UnsupportedOperationException(method.getName()); + }); + } + +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/objectholders/SynchronizedFilteredCollectionTest.java b/common/src/test/java/com/loohp/interactionvisualizer/objectholders/SynchronizedFilteredCollectionTest.java new file mode 100644 index 00000000..49e606ec --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/objectholders/SynchronizedFilteredCollectionTest.java @@ -0,0 +1,48 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.objectholders; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SynchronizedFilteredCollectionTest { + + @Test + void anyMatchHonorsNestedFiltersWithoutChangingTheLiveView() { + List backing = new ArrayList<>(List.of(1, 2, 3, 4)); + SynchronizedFilteredCollection greaterThanOne = + SynchronizedFilteredCollection.filter(backing, value -> value > 1); + SynchronizedFilteredCollection even = + SynchronizedFilteredCollection.filter(greaterThanOne, value -> value % 2 == 0); + + assertTrue(even.anyMatch(value -> value == 4)); + assertFalse(even.anyMatch(value -> value == 3)); + + backing.add(6); + assertTrue(even.anyMatch(value -> value == 6)); + + Collection readOnly = SynchronizedFilteredCollection.unmodifiableCollection(even); + assertTrue(SynchronizedFilteredCollection.anyMatch(readOnly, value -> value == 6)); + assertFalse(SynchronizedFilteredCollection.anyMatch(readOnly, value -> value == 3)); + assertThrows(UnsupportedOperationException.class, () -> readOnly.add(8)); + assertThrows(UnsupportedOperationException.class, () -> readOnly.remove(999)); + assertThrows(UnsupportedOperationException.class, () -> readOnly.removeIf(value -> false)); + assertThrows(UnsupportedOperationException.class, readOnly::clear); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/utils/DisplayTransformFactoryTest.java b/common/src/test/java/com/loohp/interactionvisualizer/utils/DisplayTransformFactoryTest.java new file mode 100644 index 00000000..40367dba --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/utils/DisplayTransformFactoryTest.java @@ -0,0 +1,111 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.utils; + +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import com.loohp.interactionvisualizer.entityholders.Item; +import com.loohp.interactionvisualizer.entityholders.VisualizerEntity; +import org.joml.Matrix4f; +import org.joml.Vector3f; +import org.bukkit.Location; +import org.bukkit.entity.ItemDisplay; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DisplayTransformFactoryTest { + + @Test + void textTransformUsesThePerDisplayScale() throws ReflectiveOperationException { + DisplayEntity text = EntityHolderTestFactory.allocate(DisplayEntity.class); + text.setTextScale(0.75F); + + Matrix4f transform = DisplayTransformFactory.text(text); + + assertEquals(0.75F, transform.m00(), 1.0E-6F); + assertEquals(0.75F, transform.m11(), 1.0E-6F); + assertEquals(0.75F, transform.m22(), 1.0E-6F); + } + + @Test + void legacyNameTagTransformCancelsTheTextDisplayGlyphOrigin() throws ReflectiveOperationException { + DisplayEntity text = EntityHolderTestFactory.allocate(DisplayEntity.class); + text.useLegacyNameTagStyle(); + + Matrix4f transform = DisplayTransformFactory.text(text); + Vector3f origin = transform.transformPosition(new Vector3f()); + + assertEquals(-0.025F, origin.x, 1.0E-6F); + assertEquals(-0.225F, origin.y, 1.0E-6F); + assertEquals(0.0F, origin.z, 1.0E-6F); + assertEquals(1.0F, transform.m00(), 1.0E-6F); + assertEquals(1.0F, transform.m11(), 1.0E-6F); + assertEquals(1.0F, transform.m22(), 1.0E-6F); + } + + @Test + void geometryOnlyProfileAppliesTheSameSizeAndAnchorCompensation() + throws ReflectiveOperationException { + DisplayEntity text = EntityHolderTestFactory.allocate(DisplayEntity.class); + text.useLegacyNameTagGeometry(); + + Matrix4f transform = DisplayTransformFactory.text(text); + Vector3f origin = transform.transformPosition(new Vector3f()); + + assertEquals(-0.025F, origin.x, 1.0E-6F); + assertEquals(-0.225F, origin.y, 1.0E-6F); + assertEquals(1.0F, transform.m00(), 1.0E-6F); + assertEquals(false, text.usesLegacyNameTagStyle()); + } + + @Test + void legacyNameTagUsesTheVanillaHalfBlockWorldAttachment() throws ReflectiveOperationException { + DisplayEntity text = EntityHolderTestFactory.allocate(DisplayEntity.class); + Location logical = new Location(null, 12.5, 64.2, -3.5); + Field location = VisualizerEntity.class.getDeclaredField("location"); + location.setAccessible(true); + location.set(text, logical); + text.useLegacyNameTagStyle(); + + Location rendered = DisplayTransformFactory.textDisplayLocation(text); + + assertEquals(64.7, rendered.getY(), 1.0E-9); + assertEquals(64.2, logical.getY(), 1.0E-9, + "render compensation must not mutate the logical marker anchor"); + } + + @Test + void bannerModeMatchesTheLegacySmallHeadTransform() { + Matrix4f bannerTransform = DisplayTransformFactory.item(Item.RenderMode.BANNER); + Vector3f bannerOrigin = bannerTransform.transformPosition(new Vector3f()); + assertEquals(-0.078375F, bannerOrigin.y, 1.0E-6F, + "the light-sampling anchor compensation must preserve the old world position"); + assertEquals(0.46875F, bannerTransform.m00(), 1.0E-6F, + "the banner must retain the small legacy head scale"); + assertEquals(0.46875F, bannerTransform.m11(), 1.0E-6F, + "the banner must retain the small legacy head scale"); + assertEquals(0.46875F, bannerTransform.m22(), 1.0E-6F, + "the banner transform must not mirror the HEAD model"); + assertEquals(ItemDisplay.ItemDisplayTransform.HEAD, + DisplayTransformFactory.itemDisplayTransform(Item.RenderMode.BANNER), + "loom banners must use the same vanilla context as head equipment"); + assertEquals(ItemDisplay.ItemDisplayTransform.FIXED, + DisplayTransformFactory.itemDisplayTransform(Item.RenderMode.ITEM), + "other item display contexts must remain unchanged"); + + Vector3f ordinaryOrigin = DisplayTransformFactory.item(Item.RenderMode.ITEM).transformPosition(new Vector3f()); + assertEquals(0.0F, ordinaryOrigin.y, 1.0E-6F, "the Loom offset must not affect other item displays"); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/utils/LegacyTextComponentCacheDisabledTest.java b/common/src/test/java/com/loohp/interactionvisualizer/utils/LegacyTextComponentCacheDisabledTest.java new file mode 100644 index 00000000..18a2c454 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/utils/LegacyTextComponentCacheDisabledTest.java @@ -0,0 +1,71 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.utils; + +import com.loohp.interactionvisualizer.entityholders.Item; +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LegacyTextComponentCacheDisabledTest { + + @BeforeEach + void setUp() { + LegacyTextComponentCache.stopMeasurement(); + LegacyTextComponentCache.invalidateAll(); + } + + @AfterEach + void tearDown() { + LegacyTextComponentCache.stopMeasurement(); + LegacyTextComponentCache.invalidateAll(); + } + + @Test + void disablePropertyBypassesTheSharedCache() { + assertFalse(LegacyTextComponentCache.isEnabled()); + LegacyTextComponentCache.invalidateAll(); + LegacyTextComponentCache.startMeasurement(); + + LegacyTextComponentCache.parse("§auncached"); + LegacyTextComponentCache.parse("§auncached"); + + LegacyTextComponentCache.CacheMetrics metrics = LegacyTextComponentCache.stopMeasurement(); + assertEquals(2L, metrics.requests()); + assertEquals(2L, metrics.misses()); + assertEquals(0L, metrics.hits()); + assertEquals(0L, LegacyTextComponentCache.estimatedSize()); + } + + @Test + void disablePropertyAlsoBypassesThePerEntityRawFastPath() + throws ReflectiveOperationException { + Item item = EntityHolderTestFactory.allocate(Item.class); + LegacyTextComponentCache.startMeasurement(); + + assertTrue(item.updateCustomName("§buncached")); + int revision = item.cacheCode(); + assertFalse(item.updateCustomName(new String("§buncached"))); + + LegacyTextComponentCache.CacheMetrics metrics = LegacyTextComponentCache.stopMeasurement(); + assertEquals(revision, item.cacheCode()); + assertEquals(2L, metrics.requests()); + assertEquals(2L, metrics.misses()); + assertEquals(0L, metrics.sameRawFastPaths()); + } + +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/utils/LegacyTextComponentCacheTest.java b/common/src/test/java/com/loohp/interactionvisualizer/utils/LegacyTextComponentCacheTest.java new file mode 100644 index 00000000..2fb1f65b --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/utils/LegacyTextComponentCacheTest.java @@ -0,0 +1,125 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.utils; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ResourceLock("legacy-text-component-cache") +class LegacyTextComponentCacheTest { + + @BeforeEach + void setUp() { + LegacyTextComponentCache.stopMeasurement(); + LegacyTextComponentCache.invalidateAll(); + } + + @AfterEach + void tearDown() { + LegacyTextComponentCache.stopMeasurement(); + LegacyTextComponentCache.invalidateAll(); + } + + @Test + void preservesLegacyAndFontParsingSemantics() { + String raw = "§eReady §x§1§2§3§4§5§6[font=minecraft:default]Now§r!"; + Component expected = ComponentFont.parseFont( + LegacyComponentSerializer.legacySection().deserialize(raw)); + + assertEquals(expected, LegacyTextComponentCache.parse(raw)); + } + + @Test + void preservesEscapedAndEmptyFontTagSemantics() { + String raw = "\\[font=minecraft:uniform]literal[font=minecraft:uniform]styled[font=]reset"; + Component expected = ComponentFont.parseFont( + LegacyComponentSerializer.legacySection().deserialize(raw)); + + assertEquals(expected, LegacyTextComponentCache.parse(raw)); + } + + @Test + void sharesOneImmutableComponentForTheSameRawText() { + String firstKey = new String("§e||||§7||||"); + String equalKey = new String("§e||||§7||||"); + + Component first = LegacyTextComponentCache.parse(firstKey); + Component second = LegacyTextComponentCache.parse(equalKey); + + assertSame(first, second); + } + + @Test + void reportsRequestsAndObservedCacheMisses() { + LegacyTextComponentCache.startMeasurement(); + + LegacyTextComponentCache.parse("§aone"); + LegacyTextComponentCache.parse("§aone"); + LegacyTextComponentCache.parse("§btwo"); + LegacyTextComponentCache.recordSameRawFastPath(); + + LegacyTextComponentCache.CacheMetrics metrics = LegacyTextComponentCache.stopMeasurement(); + assertEquals(3L, metrics.requests()); + assertEquals(2L, metrics.misses()); + assertEquals(1L, metrics.hits()); + assertEquals(1.0D / 3.0D, metrics.hitRate(), 1.0E-12D); + assertEquals(1L, metrics.sameRawFastPaths()); + } + + @Test + void measurementRoundsDoNotLeakCountersAcrossTheirBoundaries() { + LegacyTextComponentCache.startMeasurement(); + LegacyTextComponentCache.parse("§afirst"); + LegacyTextComponentCache.CacheMetrics first = LegacyTextComponentCache.stopMeasurement(); + + LegacyTextComponentCache.parse("§boutside"); + assertEquals(first, LegacyTextComponentCache.metrics()); + + LegacyTextComponentCache.startMeasurement(); + LegacyTextComponentCache.parse("§afirst"); + LegacyTextComponentCache.CacheMetrics second = LegacyTextComponentCache.stopMeasurement(); + assertEquals(1L, second.requests()); + assertEquals(0L, second.misses()); + } + + @Test + void bypassesOversizedOneOffText() { + String oversized = "x".repeat(LegacyTextComponentCache.MAXIMUM_CACHEABLE_LENGTH + 1); + LegacyTextComponentCache.startMeasurement(); + + Component first = LegacyTextComponentCache.parse(oversized); + Component second = LegacyTextComponentCache.parse(oversized); + + LegacyTextComponentCache.CacheMetrics metrics = LegacyTextComponentCache.stopMeasurement(); + assertEquals(first, second); + assertEquals(2L, metrics.requests()); + assertEquals(2L, metrics.misses()); + assertEquals(0L, LegacyTextComponentCache.estimatedSize()); + } + + @Test + void staysBoundedUnderHighCardinalityNames() { + for (int i = 0; i < LegacyTextComponentCache.MAXIMUM_SIZE * 2; i++) { + LegacyTextComponentCache.parse("unique-" + i); + } + + assertTrue(LegacyTextComponentCache.estimatedSize() <= LegacyTextComponentCache.MAXIMUM_SIZE); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/utils/WorkstationDisplayPositioningTest.java b/common/src/test/java/com/loohp/interactionvisualizer/utils/WorkstationDisplayPositioningTest.java new file mode 100644 index 00000000..222c736f --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/utils/WorkstationDisplayPositioningTest.java @@ -0,0 +1,245 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.utils; + +import com.loohp.interactionvisualizer.entityholders.EntityHolderTestFactory; +import com.loohp.interactionvisualizer.entityholders.Item; +import com.loohp.interactionvisualizer.entityholders.VisualizerEntity; +import org.joml.Matrix4f; +import org.joml.Vector3f; +import org.bukkit.Location; +import org.bukkit.entity.ItemDisplay; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class WorkstationDisplayPositioningTest { + + @Test + void gridAnchorClearsTheBlockTopForDirectItemDisplays() { + Location origin = new Location(null, 10.0, 64.0, -5.0); + + Location anchor = WorkstationDisplayPositioning.gridAnchor(origin); + + assertNotSame(origin, anchor); + assertEquals(10.5, anchor.getX()); + assertEquals(65.0, anchor.getY()); + assertEquals(-4.5, anchor.getZ()); + assertEquals(64.0, origin.getY()); + } + + @Test + void gridSlotsStayCentredAcrossCardinalDirections() { + Location origin = new Location(null, 10.0, 64.0, -5.0); + + assertGridSlot(origin, 0.0F, -0.2, 0.2, 10.7, 65.0, -4.3); + assertGridSlot(origin, 90.0F, -0.2, 0.2, 10.3, 65.0, -4.3); + assertGridSlot(origin, 180.0F, -0.2, 0.2, 10.3, 65.0, -4.7); + assertGridSlot(origin, -90.0F, -0.2, 0.2, 10.7, 65.0, -4.7); + + assertEquals(10.0, origin.getX()); + assertEquals(64.0, origin.getY()); + assertEquals(-5.0, origin.getZ()); + } + + @Test + void crafterNorthGridStartsOnTheBlockTop() { + Location block = new Location(null, 125.0, -52.0, -23.0); + + Location slotOne = WorkstationDisplayPositioning.gridSlot(block, 180.0F, -0.2, 0.2); + + assertEquals(125.3, slotOne.getX(), 1.0E-9); + assertEquals(-51.0, slotOne.getY(), 1.0E-9); + assertEquals(-22.7, slotOne.getZ(), 1.0E-9); + assertEquals(180.0F, slotOne.getYaw()); + } + + @Test + void renderModeChangesPreserveTheGridAnchor() throws ReflectiveOperationException { + Item item = testWorkstationItem(Item.RenderMode.ITEM, + new Location(null, 1.0, 2.0, 3.0, 10.0F, 0.0F), 10.0F); + + WorkstationDisplayPositioning.setRenderMode(item, Item.RenderMode.BLOCK); + assertEquals(Item.RenderMode.BLOCK, item.getRenderMode()); + assertEquals(55.0F, item.getLocation().getYaw()); + assertEquals(1.0, item.getLocation().getX()); + assertEquals(2.0, item.getLocation().getY()); + assertEquals(3.0, item.getLocation().getZ()); + + WorkstationDisplayPositioning.setRenderMode(item, Item.RenderMode.LOW_BLOCK); + assertEquals(Item.RenderMode.LOW_BLOCK, item.getRenderMode()); + assertEquals(55.0F, item.getLocation().getYaw()); + assertEquals(1.0, item.getLocation().getX()); + assertEquals(2.0, item.getLocation().getY()); + assertEquals(3.0, item.getLocation().getZ()); + + WorkstationDisplayPositioning.setRenderMode(item, Item.RenderMode.ITEM); + assertEquals(Item.RenderMode.ITEM, item.getRenderMode()); + assertEquals(10.0F, item.getLocation().getYaw()); + assertEquals(1.0, item.getLocation().getX()); + assertEquals(2.0, item.getLocation().getY()); + assertEquals(3.0, item.getLocation().getZ()); + + WorkstationDisplayPositioning.setRenderMode(item, Item.RenderMode.TOOL); + assertEquals(Item.RenderMode.TOOL, item.getRenderMode()); + assertEquals(10.0F, item.getLocation().getYaw()); + assertEquals(1.0, item.getLocation().getX()); + assertEquals(2.0, item.getLocation().getY()); + assertEquals(3.0, item.getLocation().getZ()); + } + + @Test + void workstationModeRejectsPacketAndSpecializedDisplays() throws ReflectiveOperationException { + Item item = testItem(Item.RenderMode.ITEM, new Location(null, 0.0, 0.0, 0.0)); + + assertThrows(IllegalArgumentException.class, + () -> WorkstationDisplayPositioning.setRenderMode(item, Item.RenderMode.DROPPED)); + assertThrows(IllegalArgumentException.class, + () -> WorkstationDisplayPositioning.setRenderMode(item, Item.RenderMode.BANNER)); + assertThrows(IllegalArgumentException.class, + () -> WorkstationDisplayPositioning.setRenderMode(item, Item.RenderMode.FRAME)); + } + + @Test + void workstationMarkerSelectsTheRightHandContextWithoutChangingOtherFixedItems() + throws ReflectiveOperationException { + Item workstation = testWorkstationItem(Item.RenderMode.ITEM, + new Location(null, 1.0, 2.0, 3.0, 10.0F, 0.0F), 10.0F); + Item ordinary = testItem(Item.RenderMode.ITEM, + new Location(null, 1.0, 2.0, 3.0, 10.0F, 0.0F)); + + assertEquals(ItemDisplay.ItemDisplayTransform.THIRDPERSON_RIGHTHAND, + DisplayTransformFactory.itemDisplayTransform(workstation)); + assertEquals(ItemDisplay.ItemDisplayTransform.FIXED, + DisplayTransformFactory.itemDisplayTransform(ordinary)); + assertEquals(ItemDisplay.ItemDisplayTransform.HEAD, + DisplayTransformFactory.itemDisplayTransform(Item.RenderMode.BANNER)); + assertEquals(ItemDisplay.ItemDisplayTransform.FIXED, + DisplayTransformFactory.itemDisplayTransform(Item.RenderMode.FRAME)); + } + + @Test + void workstationModesMatchTheLegacySmallArmorStandGoldenMatrices() + throws ReflectiveOperationException { + assertGoldenMatrix(Item.RenderMode.ITEM, 10.0F, + new float[] { + -0.000386488F, -0.023218991F, -0.080493137F, + 0.499613523F, -0.023218904F, -0.080493182F, + -0.000386444F, -0.023218991F, 0.419506848F, + -0.000386400F, -0.523218989F, -0.080493137F + }); + assertGoldenMatrix(Item.RenderMode.BLOCK, 55.0F, + new float[] { + 0.014817633F, 0.084806390F, -0.071107179F, + 0.514817655F, 0.084806472F, -0.071107246F, + 0.014817676F, 0.204419374F, 0.414374799F, + 0.014817731F, -0.400675595F, 0.048505813F + }); + assertGoldenMatrix(Item.RenderMode.LOW_BLOCK, 55.0F, + new float[] { + 0.013403438F, 0.020806497F, -0.055550832F, + 0.513403416F, 0.020806583F, -0.055550896F, + 0.013403481F, 0.140419483F, 0.429931134F, + 0.013403536F, -0.464675486F, 0.064062163F + }); + assertGoldenMatrix(Item.RenderMode.TOOL, 10.0F, + new float[] { + 0.030960985F, 0.004655909F, -0.134024560F, + 0.019913029F, 0.504533827F, -0.134024575F, + -0.044516824F, 0.002987763F, 0.360242873F, + 0.525107741F, 0.015577200F, -0.058528319F + }); + assertGoldenMatrix(Item.RenderMode.STANDING, 10.0F, + new float[] { + 0.019560307F, 0.036030862F, 0.002479957F, + 0.019636948F, 0.029835742F, -0.497481644F, + 0.013365188F, 0.535954118F, -0.003715637F, + 0.519521952F, 0.042226456F, 0.002479826F + }); + } + + @Test + void sideFacingSlotsKeepTheCommonAnchorAlignedToTheWorkstationGrid() + throws ReflectiveOperationException { + Item gridFacing = testWorkstationItem(Item.RenderMode.ITEM, + new Location(null, 0.0, 0.0, 0.0, 10.0F, 0.0F), 10.0F); + Item sideFacing = testWorkstationItem(Item.RenderMode.ITEM, + new Location(null, 0.0, 0.0, 0.0, 30.0F, 0.0F), 10.0F); + + Vector3f gridOrigin = DisplayTransformFactory.item(gridFacing).transformPosition(new Vector3f()); + Vector3f sideOrigin = DisplayTransformFactory.item(sideFacing).transformPosition(new Vector3f()); + + assertEquals(-0.06019086F, sideOrigin.x - gridOrigin.x, 2.0E-6F); + assertEquals(0.0F, sideOrigin.y - gridOrigin.y, 2.0E-6F); + assertEquals(-0.05537303F, sideOrigin.z - gridOrigin.z, 2.0E-6F); + } + + private static Item testItem(Item.RenderMode mode, Location location) throws ReflectiveOperationException { + Item item = EntityHolderTestFactory.allocate(Item.class); + initializeItem(item, mode, location); + return item; + } + + private static Item testWorkstationItem(Item.RenderMode mode, Location location, float gridYaw) + throws ReflectiveOperationException { + WorkstationDisplayPositioning.WorkstationItem item = + EntityHolderTestFactory.allocate(WorkstationDisplayPositioning.WorkstationItem.class); + Field workstationYaw = WorkstationDisplayPositioning.WorkstationItem.class.getDeclaredField("gridYaw"); + workstationYaw.setAccessible(true); + workstationYaw.setFloat(item, gridYaw); + initializeItem(item, mode, location); + return item; + } + + private static void initializeItem(Item item, Item.RenderMode mode, Location location) + throws ReflectiveOperationException { + Field renderMode = Item.class.getDeclaredField("renderMode"); + renderMode.setAccessible(true); + renderMode.set(item, mode); + Field entityLocation = VisualizerEntity.class.getDeclaredField("location"); + entityLocation.setAccessible(true); + entityLocation.set(item, location); + } + + private static void assertGoldenMatrix(Item.RenderMode mode, float displayYaw, float[] expected) + throws ReflectiveOperationException { + Item item = testWorkstationItem(mode, + new Location(null, 0.0, 0.0, 0.0, displayYaw, 0.0F), 10.0F); + Matrix4f matrix = DisplayTransformFactory.item(item); + Vector3f[] probes = { + new Vector3f(), + new Vector3f(1.0F, 0.0F, 0.0F), + new Vector3f(0.0F, 1.0F, 0.0F), + new Vector3f(0.0F, 0.0F, 1.0F) + }; + for (int index = 0; index < probes.length; index++) { + Vector3f actual = matrix.transformPosition(probes[index]); + int offset = index * 3; + assertEquals(expected[offset], actual.x, 2.0E-6F, mode + " probe " + index + " x"); + assertEquals(expected[offset + 1], actual.y, 2.0E-6F, mode + " probe " + index + " y"); + assertEquals(expected[offset + 2], actual.z, 2.0E-6F, mode + " probe " + index + " z"); + } + } + + private static void assertGridSlot(Location origin, float yaw, double lateral, double forward, + double expectedX, double expectedY, double expectedZ) { + Location slot = WorkstationDisplayPositioning.gridSlot(origin, yaw, lateral, forward); + assertEquals(expectedX, slot.getX(), 1.0E-9); + assertEquals(expectedY, slot.getY(), 1.0E-9); + assertEquals(expectedZ, slot.getZ(), 1.0E-9); + assertEquals(yaw, slot.getYaw()); + } +} diff --git a/common/src/test/java/net/minecraft/network/chat/Component.java b/common/src/test/java/net/minecraft/network/chat/Component.java new file mode 100644 index 00000000..33ab2352 --- /dev/null +++ b/common/src/test/java/net/minecraft/network/chat/Component.java @@ -0,0 +1,15 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package net.minecraft.network.chat; + +public record Component(String json) { +} diff --git a/common/src/test/java/net/minecraft/network/protocol/game/ClientboundAddEntityPacket.java b/common/src/test/java/net/minecraft/network/protocol/game/ClientboundAddEntityPacket.java new file mode 100644 index 00000000..f5334edd --- /dev/null +++ b/common/src/test/java/net/minecraft/network/protocol/game/ClientboundAddEntityPacket.java @@ -0,0 +1,32 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package net.minecraft.network.protocol.game; + +import net.minecraft.network.protocol.Packet; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.phys.Vec3; + +import java.util.UUID; + +public record ClientboundAddEntityPacket( + int id, + UUID uuid, + double x, + double y, + double z, + float xRot, + float yRot, + EntityType type, + int data, + Vec3 movement, + double yHeadRot) implements Packet { +} diff --git a/common/src/test/java/net/minecraft/network/protocol/game/ClientboundBundlePacket.java b/common/src/test/java/net/minecraft/network/protocol/game/ClientboundBundlePacket.java new file mode 100644 index 00000000..03a47c3f --- /dev/null +++ b/common/src/test/java/net/minecraft/network/protocol/game/ClientboundBundlePacket.java @@ -0,0 +1,31 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package net.minecraft.network.protocol.game; + +import net.minecraft.network.protocol.Packet; + +import java.util.ArrayList; +import java.util.List; + +public final class ClientboundBundlePacket implements Packet { + + private final List> subPackets; + + public ClientboundBundlePacket(Iterable> subPackets) { + this.subPackets = new ArrayList<>(); + subPackets.forEach(this.subPackets::add); + } + + public List> subPackets() { + return List.copyOf(subPackets); + } +} diff --git a/common/src/test/java/net/minecraft/network/protocol/game/ClientboundRemoveEntitiesPacket.java b/common/src/test/java/net/minecraft/network/protocol/game/ClientboundRemoveEntitiesPacket.java new file mode 100644 index 00000000..c8ae5dc0 --- /dev/null +++ b/common/src/test/java/net/minecraft/network/protocol/game/ClientboundRemoveEntitiesPacket.java @@ -0,0 +1,27 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package net.minecraft.network.protocol.game; + +import net.minecraft.network.protocol.Packet; + +public final class ClientboundRemoveEntitiesPacket implements Packet { + + private final int[] entityIds; + + public ClientboundRemoveEntitiesPacket(int... entityIds) { + this.entityIds = entityIds.clone(); + } + + public int[] entityIds() { + return entityIds.clone(); + } +} diff --git a/common/src/test/java/net/minecraft/network/protocol/game/ClientboundSetEntityDataPacket.java b/common/src/test/java/net/minecraft/network/protocol/game/ClientboundSetEntityDataPacket.java new file mode 100644 index 00000000..ee84fb02 --- /dev/null +++ b/common/src/test/java/net/minecraft/network/protocol/game/ClientboundSetEntityDataPacket.java @@ -0,0 +1,22 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package net.minecraft.network.protocol.game; + +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.syncher.SynchedEntityData; + +import java.util.List; + +public record ClientboundSetEntityDataPacket( + int id, + List> packedItems) implements Packet { +} diff --git a/common/src/test/java/net/minecraft/network/protocol/game/ClientboundTeleportEntityPacket.java b/common/src/test/java/net/minecraft/network/protocol/game/ClientboundTeleportEntityPacket.java new file mode 100644 index 00000000..2112c3fe --- /dev/null +++ b/common/src/test/java/net/minecraft/network/protocol/game/ClientboundTeleportEntityPacket.java @@ -0,0 +1,14 @@ +package net.minecraft.network.protocol.game; + +import net.minecraft.network.protocol.Packet; +import net.minecraft.world.entity.PositionMoveRotation; +import net.minecraft.world.entity.Relative; + +import java.util.Set; + +public record ClientboundTeleportEntityPacket( + int id, + PositionMoveRotation change, + Set relatives, + boolean onGround) implements Packet { +} diff --git a/common/src/test/java/net/minecraft/network/syncher/EntityDataAccessor.java b/common/src/test/java/net/minecraft/network/syncher/EntityDataAccessor.java new file mode 100644 index 00000000..e02488d6 --- /dev/null +++ b/common/src/test/java/net/minecraft/network/syncher/EntityDataAccessor.java @@ -0,0 +1,15 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package net.minecraft.network.syncher; + +public record EntityDataAccessor(int id) { +} diff --git a/common/src/test/java/net/minecraft/network/syncher/SynchedEntityData.java b/common/src/test/java/net/minecraft/network/syncher/SynchedEntityData.java new file mode 100644 index 00000000..fb6b8c3e --- /dev/null +++ b/common/src/test/java/net/minecraft/network/syncher/SynchedEntityData.java @@ -0,0 +1,25 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package net.minecraft.network.syncher; + +public final class SynchedEntityData { + + private SynchedEntityData() { + } + + public record DataValue(int id, T value) { + + public static DataValue create(EntityDataAccessor accessor, T value) { + return new DataValue<>(accessor.id(), value); + } + } +} diff --git a/common/src/test/java/net/minecraft/world/entity/Display.java b/common/src/test/java/net/minecraft/world/entity/Display.java new file mode 100644 index 00000000..ca30154c --- /dev/null +++ b/common/src/test/java/net/minecraft/world/entity/Display.java @@ -0,0 +1,51 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package net.minecraft.world.entity; + +import net.minecraft.network.syncher.EntityDataAccessor; + +public abstract class Display { + + public static final EntityDataAccessor DATA_POS_ROT_INTERPOLATION_DURATION_ID = + new EntityDataAccessor<>(10); + private static final EntityDataAccessor DATA_TRANSLATION_ID = new EntityDataAccessor<>(11); + private static final EntityDataAccessor DATA_SCALE_ID = new EntityDataAccessor<>(12); + private static final EntityDataAccessor DATA_BILLBOARD_RENDER_CONSTRAINTS_ID = + new EntityDataAccessor<>(13); + + public enum BillboardConstraints { + FIXED((byte) 0), + VERTICAL((byte) 1), + HORIZONTAL((byte) 2), + CENTER((byte) 3); + + private final byte id; + + BillboardConstraints(byte id) { + this.id = id; + } + } + + public static final class TextDisplay extends Display { + + public static final byte FLAG_USE_DEFAULT_BACKGROUND = 0x04; + + private static final EntityDataAccessor DATA_TEXT_ID = new EntityDataAccessor<>(20); + public static final EntityDataAccessor DATA_LINE_WIDTH_ID = new EntityDataAccessor<>(21); + public static final EntityDataAccessor DATA_BACKGROUND_COLOR_ID = new EntityDataAccessor<>(22); + private static final EntityDataAccessor DATA_TEXT_OPACITY_ID = new EntityDataAccessor<>(23); + private static final EntityDataAccessor DATA_STYLE_FLAGS_ID = new EntityDataAccessor<>(24); + + private TextDisplay() { + } + } +} diff --git a/common/src/test/java/net/minecraft/world/entity/EntityType.java b/common/src/test/java/net/minecraft/world/entity/EntityType.java new file mode 100644 index 00000000..14602a2a --- /dev/null +++ b/common/src/test/java/net/minecraft/world/entity/EntityType.java @@ -0,0 +1,17 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package net.minecraft.world.entity; + +public record EntityType(String key) { + + public static final EntityType TEXT_DISPLAY = new EntityType<>("text_display"); +} diff --git a/common/src/test/java/net/minecraft/world/entity/PositionMoveRotation.java b/common/src/test/java/net/minecraft/world/entity/PositionMoveRotation.java new file mode 100644 index 00000000..03b78d89 --- /dev/null +++ b/common/src/test/java/net/minecraft/world/entity/PositionMoveRotation.java @@ -0,0 +1,6 @@ +package net.minecraft.world.entity; + +import net.minecraft.world.phys.Vec3; + +public record PositionMoveRotation(Vec3 position, Vec3 deltaMovement, float yRot, float xRot) { +} diff --git a/common/src/test/java/net/minecraft/world/entity/Relative.java b/common/src/test/java/net/minecraft/world/entity/Relative.java new file mode 100644 index 00000000..24113fd6 --- /dev/null +++ b/common/src/test/java/net/minecraft/world/entity/Relative.java @@ -0,0 +1,5 @@ +package net.minecraft.world.entity; + +public enum Relative { + X +} diff --git a/common/src/test/java/net/minecraft/world/phys/Vec3.java b/common/src/test/java/net/minecraft/world/phys/Vec3.java new file mode 100644 index 00000000..0d2a6067 --- /dev/null +++ b/common/src/test/java/net/minecraft/world/phys/Vec3.java @@ -0,0 +1,17 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package net.minecraft.world.phys; + +public record Vec3(double x, double y, double z) { + + public static final Vec3 ZERO = new Vec3(0.0, 0.0, 0.0); +} diff --git a/common/src/test/java/org/bukkit/craftbukkit/util/CraftChatMessage.java b/common/src/test/java/org/bukkit/craftbukkit/util/CraftChatMessage.java new file mode 100644 index 00000000..f6ec5d57 --- /dev/null +++ b/common/src/test/java/org/bukkit/craftbukkit/util/CraftChatMessage.java @@ -0,0 +1,24 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package org.bukkit.craftbukkit.util; + +import net.minecraft.network.chat.Component; + +public final class CraftChatMessage { + + private CraftChatMessage() { + } + + public static Component fromJSON(String json) { + return new Component(json); + } +} diff --git a/docs/evidence/b93d990-frameview-manifest.csv b/docs/evidence/b93d990-frameview-manifest.csv new file mode 100644 index 00000000..9196f36f --- /dev/null +++ b/docs/evidence/b93d990-frameview-manifest.csv @@ -0,0 +1,20 @@ +kind,group,run,filename,bytes,sha256 +frame_csv,frameview-factor-fixed-b1-true,1,FvSDKPerFrameStreamDataT202671422749.csv,6306563,d4320664ec2af8c203661c489a312715c45ab237fc75a3029dbd54914ef4a60b +frame_csv,frameview-factor-fixed-b1-true,2,FvSDKPerFrameStreamDataT20267142280.csv,6720934,bc4a57880fd4b27aa3a38127285d49dabec660e14cd5fdb654d9b96057781e47 +frame_csv,frameview-factor-fixed-b1-true,3,FvSDKPerFrameStreamDataT202671422811.csv,6330701,f16dfd8ccae2677e684305efca6f4116e7b4b0526029e018a75d27867578c228 +frame_csv,frameview-factor-fixed-b1-true,4,FvSDKPerFrameStreamDataT202671422856.csv,6510632,90e2a0d973f0d8bc6c90362201f610ff42a65001d1d9b4cd246266aab76ac7cc +frame_csv,frameview-factor-fixed-b1-true,5,FvSDKPerFrameStreamDataT20267142296.csv,6346557,ab8281084fbd67329252ce96dee8ea6c7aa613fba1e0711edd6708b3449beb42 +frame_csv,frameview-factor-fixed-b1-true,6,FvSDKPerFrameStreamDataT202671422917.csv,6448228,a4417984976612dc07100198e6eead419327260b770cd3436dbd0eb34ed1999a +frame_csv,frameview-factor-fixed-a-false,1,FvSDKPerFrameStreamDataT202671423030.csv,5738113,a393cabfe2ea96a47bb5aad5f326758e3f9cb99a0ebfa98a19b40931a51c8455 +frame_csv,frameview-factor-fixed-a-false,2,FvSDKPerFrameStreamDataT202671423041.csv,5901362,32ffcb234ef262cdd70cc140b1b035e47c375faebdb7a3fd438b8788be431a4b +frame_csv,frameview-factor-fixed-a-false,3,FvSDKPerFrameStreamDataT202671423052.csv,4896593,d2d3a1d033420096cc33275494ede656c4d090ffc6c437ad9bf52187fbbb69a2 +frame_csv,frameview-factor-fixed-a-false,4,FvSDKPerFrameStreamDataT202671423129.csv,5837042,9cb1cf1fd6eff142667be94056c8eff344ef51b75deef80628af51c7b674eec5 +frame_csv,frameview-factor-fixed-a-false,5,FvSDKPerFrameStreamDataT202671423140.csv,5829160,dfdfcce44636e62f95bf8b125fcb4c69f30ebd64c432e4f4c9276aacf52bc969 +frame_csv,frameview-factor-fixed-a-false,6,FvSDKPerFrameStreamDataT202671423151.csv,5952325,8460fb70fc46cae9c3e97e0ea601d16192e4ef44836b2c2874cf6c0acbb6a2e3 +frame_csv,frameview-factor-fixed-b2-true,1,FvSDKPerFrameStreamDataT202671423312.csv,6485056,b28d8a55d3fab5e17bc09fadf763e27f017b143cf4f2902b76fe285e33d48625 +frame_csv,frameview-factor-fixed-b2-true,2,FvSDKPerFrameStreamDataT202671423323.csv,6607308,e194516293101015ebc3eed0e95ff0afb7f1fd9956e2fbfe26690bc78dfb7487 +frame_csv,frameview-factor-fixed-b2-true,3,FvSDKPerFrameStreamDataT202671423333.csv,5771658,1a8a162135fa3a60de76bfc734c5154cec4024479c50284fe9d09a2c0a2c5925 +frame_csv,frameview-factor-fixed-b2-true,4,FvSDKPerFrameStreamDataT202671423414.csv,6661103,41e8c16f65488b7a95293f99adb59a7bfc7c88300152b3c37d66d7885075288b +frame_csv,frameview-factor-fixed-b2-true,5,FvSDKPerFrameStreamDataT202671423425.csv,6624424,ee9488513fbf428c3bfa7e577700c8c4b594ba51d31832c24328f29fdb1e343a +frame_csv,frameview-factor-fixed-b2-true,6,FvSDKPerFrameStreamDataT202671423436.csv,6548953,40a1672b2e0ab5d5bdfd448a51a79f1cd19229da89f199d54a97f599709b298e +analysis_db,frameview-factor-fixed-summary,,performance-validation.sqlite,28672,6f7799fb095c0ad3fff2e4da3b2006cd470631df081f9df4abf863a054d48bcc diff --git a/docs/phase2-evidence-b93d990.md b/docs/phase2-evidence-b93d990.md new file mode 100644 index 00000000..201743fc --- /dev/null +++ b/docs/phase2-evidence-b93d990.md @@ -0,0 +1,52 @@ +# Phase 2 evidence snapshot at `b93d990` + +This snapshot separates formal evidence from smoke and local client measurements. It records what was actually +validated for Draft PR #3 before the dropped-label controls were changed to opt-in defaults. + +## GitHub checks on the measured commit + +- [Java 25 build](https://github.com/EllanServer/InteractionVisualizer/actions/runs/29264707559): passed. +- [A/B benchmark](https://github.com/EllanServer/InteractionVisualizer/actions/runs/29264708714): passed. +- [Packet ABBA](https://github.com/EllanServer/InteractionVisualizer/actions/runs/29264707795): passed as a four-run smoke check. +- [Runtime ABBA](https://github.com/EllanServer/InteractionVisualizer/actions/runs/29264710083): passed as a four-run smoke check. +- [Formal 12-run server A/B](https://github.com/EllanServer/InteractionVisualizer/actions/runs/29266067426): passed for the + `legacy-text-component-cache / block-active` factor only. + +The formal server run measured MSPT mean `5.0411 ms -> 3.8049 ms` (`23.85%` lower), MSPT p95 +`7.4554 ms -> 5.0209 ms`, and MSPT p99 `10.2296 ms -> 7.2738 ms`. These numbers do not validate the +dropped-label visibility controls or every Phase 2 feature. + +## Packet smoke boundary + +The four-run static-spawn smoke observed Minecraft protocol events `7168 -> 2048` (`71.43%` lower) and Bukkit +entity spawns `1024 -> 0` for the packet-only candidate. It is useful regression evidence, but it is not the required +12-run packet gate and must not be presented as one. + +## Local client `true-false-true` measurement + +The final usable client comparison kept the same candidate JAR, world, client, fixed noon lighting, disabled mob +spawning, and one 512-item scene. Only `PacketOnlyStatic` changed. Each group contains six successful frame CSVs: + +- B1 `PacketOnlyStatic=true`: six windows. +- A `PacketOnlyStatic=false`: six windows. +- B2 `PacketOnlyStatic=true`: six windows. + +Aggregating B1+B2 against A produced: + +| Metric | `false` | `true` | Observed change | +|---|---:|---:|---:| +| Present FPS | 1098.02 | 1310.50 | +19.42%; paired 95% CI +15.55% to +23.30% | +| Display FPS | 234.43 | 239.20 | Near the 240 Hz ceiling | +| 1% low FPS | 117.94 | 177.53 | Higher | +| Frames over 8.333 ms | 0.961% | 0.112% | 88.34% lower | + +The per-file names, sizes, and SHA-256 values are preserved in +[`evidence/b93d990-frameview-manifest.csv`](evidence/b93d990-frameview-manifest.csv). The raw frame CSVs are not +committed to the repository, so this section is a durable result/provenance snapshot rather than self-contained raw +evidence. It does not replace the compatibility checklist or a formal packet run. + +## Rollout boundary + +`PacketOnlyStatic`, generic visibility limiting, and event-driven block updates remain disabled by default. The +dropped-label server-side distance culling and dedicated rate limiter must also remain opt-in until V2 capture, +multi-viewer, re-entry, pending cancellation, ghost, and production protocol-stack validation are complete. diff --git a/docs/phase2-performance-validation.md b/docs/phase2-performance-validation.md new file mode 100644 index 00000000..fff2aeed --- /dev/null +++ b/docs/phase2-performance-validation.md @@ -0,0 +1,534 @@ +# 第二阶段真实性能与兼容性验证 + +本规范用于验证 InteractionVisualizer 的四类优化:旧 Bukkit 展示实体基线、无服务端锚点的纯发包静态展示、`DisplayManager` 通用可见性限流(另含旧掉落物标签桶回归),以及事件驱动的交互方块更新。目标是用同一套场景同时观察发包、TPS/MSPT、插件热路径和客户端 FPS,避免用单次 microbenchmark 代替真实服务器结论。 + +配套脚本包括 [`tools/perf/phase2-pktmon.ps1`](../tools/perf/phase2-pktmon.ps1)、[`tools/perf/analyze-phase2-pcap.ps1`](../tools/perf/analyze-phase2-pcap.ps1) 和 [`tools/perf/analyze-presentmon.ps1`](../tools/perf/analyze-presentmon.ps1)。它们分别负责安全包装 Windows `pktmon`、只读离线分析 pcapng/字段 CSV,以及分析单一 Minecraft PID/SwapChain 的逐帧 CSV;任何分析脚本都不会替用户启动、停止或改动 Minecraft 服务端。 + +## 1. 执行前提 + +- A/B 两个 jar 必须由 GitHub Actions 构建,记录完整 commit SHA 和 artifact SHA-256;不要在测试机本地构建。 +- 固定 Paper build、JDK、JVM 参数、世界快照、插件集合、配置、客户端版本、资源包和网络压缩阈值。 +- 每轮只运行一个服务端实例。不得同时运行 A、B,因为它们会竞争 CPU、内存、磁盘和网络。 +- 每轮从同一个只读模板复制世界和配置,完整重启服务端;不要依赖 `/reload` 清理旧实体、缓存或 JIT 状态。 +- 先预生成并加载测试区块。测试窗口内禁止自动备份、世界保存、语言下载、更新检查及其他计划任务。 +- `pktmon` 的状态、开始、停止和计数器命令需要管理员 PowerShell。转换已有 ETL 通常不需要管理员权限。 +- 当前部署状态不等于待测定义。执行前必须确认: + + - “旧实体”构建和候选构建都包含完全相同的 `/iv perf` 观测代码;观测层本身不得改变渲染行为。 + - “纯发包静态”只替换满足资格条件的逻辑 `Item`:不创建 Bukkit tracker/`ItemDisplay` 锚点。普通 `DisplayEntity` 与 `TextDisplay` 不在该替换范围内,仍沿用原路径;`/iv perf scene static` 这个场景名称本身也不能证明无锚点。 + - 事件驱动候选已覆盖目标事件,并保留低频兜底审计。 + +## 2. 固定测量窗口 + +每个稳定态 run 使用以下时序: + +1. 启动服务端并等待 `Done`。 +2. 预热 JVM、区块和客户端 120 秒。 +3. 建立场景并等待 20 秒稳定。 +4. 启动 `pktmon`。 +5. 执行 `/iv perf start