diff --git a/.github/workflows/performance-ab.yml b/.github/workflows/performance-ab.yml index 7876ad5..184644a 100644 --- a/.github/workflows/performance-ab.yml +++ b/.github/workflows/performance-ab.yml @@ -40,6 +40,10 @@ on: - layout - scheduler-reflection - ray-proxy + - riichi-reaction + - sichuan-hu + - region-apply + - layout-cache - infra forks: description: Trusted CI override for JMH forks (blank uses base config) @@ -157,6 +161,10 @@ jobs: "performance-layout": "layout", "performance-scheduler-reflection": "scheduler-reflection", "performance-ray-proxy": "ray-proxy", + "performance-riichi-reaction": "riichi-reaction", + "performance-sichuan-hu": "sichuan-hu", + "performance-region-apply": "region-apply", + "performance-layout-cache": "layout-cache", } selected = [profile for label, profile in choices.items() if label in labels] if len(selected) != 1: diff --git a/docs/performance-testing.md b/docs/performance-testing.md index 41dc009..f1efb3e 100644 --- a/docs/performance-testing.md +++ b/docs/performance-testing.md @@ -362,16 +362,16 @@ The dependency surface is deliberately fixed: | External client runtime | Node `22.23.1` | | Protocol implementation | `minecraft-protocol` `1.66.2`, locked by npm integrity | | Paper | `1.20.1` build `196`, SHA-256 `234a9b32098100c6fc116664d64e36ccdb58b5b649af0f80bcccb08b0255eaea` | -| CraftEngine for plugin runs | Paper `26.7.3` (`Len451or`), SHA-512 from the committed artifact lock | +| CraftEngine for plugin runs | Paper `26.7.4` (`aINSQrXC`), SHA-512 from the committed artifact lock | `perf/live/artifacts.lock.json` contains immutable URLs, byte sizes and hashes. Downloads are written to a temporary file, size/hash verified, fsynced, then atomically moved into place. The Paper URL is the official Paper downloads-service object. The optional CraftEngine entry -is artifact `craftengine-paper-26.7.3`, the latest Paper/Folia/Purpur file published for that +is artifact `craftengine-paper-26.7.4`, the latest Paper/Folia/Purpur file published for that version. Do not substitute the same-version Bukkit/Spigot file: Modrinth marks that separate artifact for Minecraft 26.x only, while the locked Paper file explicitly includes Paper 1.20.1 through 26.2. Its committed metadata source is -`https://api.modrinth.com/v2/version/Len451or`. +`https://api.modrinth.com/v2/version/aINSQrXC`. ### What is actually counted @@ -424,13 +424,16 @@ or substitutes a stub named `CraftEngine`. ```powershell python perf/live/download_locked.py ` - --artifact craftengine-paper-26.7.3 ` - --destination build/live-cache/craft-engine-paper-plugin-26.7.3.jar + --artifact craftengine-paper-26.7.4 ` + --destination build/live-cache/craft-engine-paper-plugin-26.7.4.jar python perf/live/run_server.py ` --paper-jar build/live-cache/server.jar ` --plugin-jar build/libs/mahjong-paper-1.5.0.jar ` - --craftengine-jar build/live-cache/craft-engine-paper-plugin-26.7.3.jar ` + --plugin-config perf/live/fixtures/mahjongpaper-smoke-config.yml ` + --craftengine-jar build/live-cache/craft-engine-paper-plugin-26.7.4.jar ` --required-plugin MahjongPaper ` + --setup-command 'execute at MahjongPerfBot run fill ~-16 ~-1 ~-16 ~16 ~-1 ~16 minecraft:stone' ` + --setup-command 'execute at MahjongPerfBot run fill ~-16 ~ ~-16 ~16 ~10 ~16 minecraft:air' ` --scenario-name majsoul-hanchan-four-bot-spectator ` --scenario-command "/mahjong botmatch MAJSOUL_HANCHAN" ` --scenario-ready-pattern 'Round .+ \| Turn .+ \| Wall [1-9][0-9]* \| Spectators 1' ` @@ -449,11 +452,11 @@ and must observe the committed ready pattern before warmup and measurement begin traffic is a hard failure, not a fallback to an empty-server keepalive run. The standalone measurement-infrastructure PR deliberately does not execute this plugin profile -as its required pull-request check. The current foundation baseline does not expose Caffeine to -the plugin classloader on a fresh Paper instance, so the real plugin profile correctly stops at -the required-plugin enablement check. That dependency blocker must be fixed in the foundation -change and then exercised by a stacked run; this harness does not patch the plugin descriptor, -inject a fake dependency, or turn the known failure green. +as its required pull-request check. Plugin runs use the committed fixture config to disable game +room placement restrictions, persistence and ranking equally for base and candidate, then create +a flat, clear table footprint relative to the connected protocol client before starting the +botmatch. The harness still loads the real locked CraftEngine plugin and does not patch plugin +metadata or inject a fake dependency. ### Measurement rules @@ -480,6 +483,7 @@ run-manifest.json hashes, versions, ports, timings, shutdown res paper-summary.json derived TPS/MSPT sample distributions raw/paper-samples.jsonl exact timestamped Paper command responses plus parsed values raw/server-stdout.log complete server console output +raw/inputs/plugin-config.yml exact staged plugin fixture configuration, when supplied raw/instance/logs/latest.log Paper's own latest.log raw/runtime-jars.json SHA-256 of server/plugin/runtime jars actually loaded raw/protocol-client/packets.jsonl one record per externally observed Minecraft frame diff --git a/perf/ab/gate-config.json b/perf/ab/gate-config.json index f438505..11457ec 100644 --- a/perf/ab/gate-config.json +++ b/perf/ab/gate-config.json @@ -189,6 +189,88 @@ "top/ellan/mahjong/runtime/ServerScheduler.class" ] }, + "riichi-reaction": { + "include": "^top\\.ellan\\.mahjong\\.riichi\\.RiichiReactionBenchmark\\.(?:structurallyImpossibleCall|structurallyImpossibleRonOnly|firstPossiblePonAnalysis)$", + "benchmark_ids": [ + "riichi.impossible.time", + "riichi.impossible.alloc", + "riichi.ron-only.time", + "riichi.ron-only.alloc", + "riichi.pon.time", + "riichi.pon.alloc" + ], + "minimum_passes": 2, + "must_pass": [ + "riichi.impossible.time", + "riichi.ron-only.time" + ], + "required_classes": [ + "top/ellan/mahjong/riichi/RiichiPlayerAnalysisState.class", + "top/ellan/mahjong/riichi/RiichiPlayerState.class" + ] + }, + "sichuan-hu": { + "include": "^top\\.ellan\\.mahjong\\.table\\.core\\.round\\.SichuanHuEvaluatorBenchmark\\.(?:waitingTilesStandardHand|evaluateStandardWin|canWinWithFixedMeld)$", + "benchmark_ids": [ + "sichuan.waiting.time", + "sichuan.waiting.alloc", + "sichuan.evaluate.time", + "sichuan.evaluate.alloc", + "sichuan.can-win.time", + "sichuan.can-win.alloc" + ], + "minimum_passes": 3, + "must_pass": [ + "sichuan.waiting.time", + "sichuan.evaluate.time", + "sichuan.can-win.time" + ], + "required_classes": [ + "top/ellan/mahjong/table/core/round/SichuanHuEvaluator.class" + ] + }, + "region-apply": { + "include": "^top\\.ellan\\.mahjong\\.table\\.render\\.RegionRenderApplyBenchmark\\.(?:planAndDeferRepresentativeTableRegions|applyStableStartedTable|precomputeCompleteStartedTableRegions)$", + "benchmark_ids": [ + "region.apply-deferred.time", + "region.apply-deferred.alloc", + "region.apply-stable.time", + "region.apply-stable.alloc", + "region.complete-fingerprints.time", + "region.complete-fingerprints.alloc" + ], + "minimum_passes": 3, + "must_pass": [ + "region.apply-deferred.time", + "region.apply-stable.time", + "region.complete-fingerprints.time" + ], + "required_classes": [ + "top/ellan/mahjong/table/render/TableRegionDisplayCoordinator.class", + "top/ellan/mahjong/table/render/TableRegionFingerprintService.class" + ] + }, + "layout-cache": { + "include": "^top\\.ellan\\.mahjong\\.table\\.core\\.SessionRenderLayoutCacheBenchmark\\.(?:stableSessionCacheHit|representativeSeatComponentMiss|representativeWallComponentMiss)$", + "benchmark_ids": [ + "layout-cache.hit.time", + "layout-cache.hit.alloc", + "layout-cache.seat-miss.time", + "layout-cache.seat-miss.alloc", + "layout-cache.wall-miss.time", + "layout-cache.wall-miss.alloc" + ], + "minimum_passes": 3, + "must_pass": [ + "layout-cache.hit.time", + "layout-cache.seat-miss.time", + "layout-cache.wall-miss.time" + ], + "required_classes": [ + "top/ellan/mahjong/render/layout/TableRenderLayout.class", + "top/ellan/mahjong/table/core/MahjongTableSession.class" + ] + }, "ray-proxy": { "include": "^top\\.ellan\\.mahjong\\.perf\\.RayProxyCoordinatorBenchmark\\.proxyPlan(?:1Viewer|4Viewers|32Viewers)$", "benchmark_ids": [ @@ -414,6 +496,150 @@ "metric": "gc.alloc.rate.norm", "direction": "lower" }, + { + "id": "riichi.impossible.time", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.structurallyImpossibleCall", + "metric": "primary", + "direction": "lower" + }, + { + "id": "riichi.impossible.alloc", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.structurallyImpossibleCall", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "riichi.ron-only.time", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.structurallyImpossibleRonOnly", + "metric": "primary", + "direction": "lower" + }, + { + "id": "riichi.ron-only.alloc", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.structurallyImpossibleRonOnly", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "riichi.pon.time", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.firstPossiblePonAnalysis", + "metric": "primary", + "direction": "lower" + }, + { + "id": "riichi.pon.alloc", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.firstPossiblePonAnalysis", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "sichuan.waiting.time", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.waitingTilesStandardHand", + "metric": "primary", + "direction": "lower" + }, + { + "id": "sichuan.waiting.alloc", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.waitingTilesStandardHand", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "sichuan.evaluate.time", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.evaluateStandardWin", + "metric": "primary", + "direction": "lower" + }, + { + "id": "sichuan.evaluate.alloc", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.evaluateStandardWin", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "sichuan.can-win.time", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.canWinWithFixedMeld", + "metric": "primary", + "direction": "lower" + }, + { + "id": "sichuan.can-win.alloc", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.canWinWithFixedMeld", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "region.apply-deferred.time", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.planAndDeferRepresentativeTableRegions", + "metric": "primary", + "direction": "lower" + }, + { + "id": "region.apply-deferred.alloc", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.planAndDeferRepresentativeTableRegions", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "region.apply-stable.time", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.applyStableStartedTable", + "metric": "primary", + "direction": "lower" + }, + { + "id": "region.apply-stable.alloc", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.applyStableStartedTable", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "region.complete-fingerprints.time", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.precomputeCompleteStartedTableRegions", + "metric": "primary", + "direction": "lower" + }, + { + "id": "region.complete-fingerprints.alloc", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.precomputeCompleteStartedTableRegions", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "layout-cache.hit.time", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.stableSessionCacheHit", + "metric": "primary", + "direction": "lower" + }, + { + "id": "layout-cache.hit.alloc", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.stableSessionCacheHit", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "layout-cache.seat-miss.time", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.representativeSeatComponentMiss", + "metric": "primary", + "direction": "lower" + }, + { + "id": "layout-cache.seat-miss.alloc", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.representativeSeatComponentMiss", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "layout-cache.wall-miss.time", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.representativeWallComponentMiss", + "metric": "primary", + "direction": "lower" + }, + { + "id": "layout-cache.wall-miss.alloc", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.representativeWallComponentMiss", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, { "id": "ray.viewers1.time", "benchmark": "top.ellan.mahjong.perf.RayProxyCoordinatorBenchmark.proxyPlan1Viewer", diff --git a/perf/live/artifacts.lock.json b/perf/live/artifacts.lock.json index 0372637..3aee941 100644 --- a/perf/live/artifacts.lock.json +++ b/perf/live/artifacts.lock.json @@ -16,13 +16,13 @@ }, "source_metadata": "https://fill.papermc.io/v3/projects/paper/versions/1.20.1/builds" }, - "craftengine-paper-26.7.3": { + "craftengine-paper-26.7.4": { "kind": "server-plugin", "project": "craftengine", "distribution": "paper", - "version": "26.7.3", + "version": "26.7.4", "release_type": "beta", - "modrinth_version_id": "Len451or", + "modrinth_version_id": "aINSQrXC", "loaders": [ "folia", "paper", @@ -53,18 +53,18 @@ "26.1.2", "26.2" ], - "filename": "craft-engine-paper-plugin-26.7.3.jar", - "url": "https://cdn.modrinth.com/data/tRX6FMfQ/versions/Len451or/craft-engine-paper-plugin-26.7.3.jar", - "size": 8907932, + "filename": "craft-engine-paper-plugin-26.7.4.jar", + "url": "https://cdn.modrinth.com/data/tRX6FMfQ/versions/aINSQrXC/craft-engine-paper-plugin-26.7.4.jar", + "size": 8970694, "hash": { "algorithm": "sha512", - "digest": "e87351417e4f99504cf0a8868bad03a88b12bc9bda5c36f0ee4c7451e3525f08146b2130b61cd34b222ee41c38ca75aa9ced9ada65bc3540c80aa66c3d39d689" + "digest": "1d6982e325552fd51a9a481252e0c055c32a5b6e3c2d816cf69ca4b2f355a5f7af1abed00ff9d29c08fb32ff293d9d11837ad4372c34675b73682a98b1ec86b0" }, "published_hashes": { - "sha1": "467fa7317635cf9f76812bd73a47d984c57cf53f", - "sha512": "e87351417e4f99504cf0a8868bad03a88b12bc9bda5c36f0ee4c7451e3525f08146b2130b61cd34b222ee41c38ca75aa9ced9ada65bc3540c80aa66c3d39d689" + "sha1": "179e7b8b191093e41beca56e3d52ae608f46e161", + "sha512": "1d6982e325552fd51a9a481252e0c055c32a5b6e3c2d816cf69ca4b2f355a5f7af1abed00ff9d29c08fb32ff293d9d11837ad4372c34675b73682a98b1ec86b0" }, - "source_metadata": "https://api.modrinth.com/v2/version/Len451or" + "source_metadata": "https://api.modrinth.com/v2/version/aINSQrXC" } } } diff --git a/perf/live/fixtures/mahjongpaper-smoke-config.yml b/perf/live/fixtures/mahjongpaper-smoke-config.yml new file mode 100644 index 0000000..79ef9d4 --- /dev/null +++ b/perf/live/fixtures/mahjongpaper-smoke-config.yml @@ -0,0 +1,19 @@ +# Deterministic live-performance fixture. Base and candidate runs must use this exact file. +database: + enabled: false + +tables: + persistence: + enabled: false + +gameRooms: + enabled: false + restrictNewTables: false + enterExitMessages: false + +ranking: + enabled: false + +integrations: + craftengine: + exportBundleOnEnable: true diff --git a/perf/live/run_server.py b/perf/live/run_server.py index bf3baae..0b6352f 100644 --- a/perf/live/run_server.py +++ b/perf/live/run_server.py @@ -33,7 +33,7 @@ TPS_VALUES = re.compile(r"\*?(\d+(?:\.\d+)?)") MSPT_TRIPLE = re.compile(r"(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)") READY_LINE = re.compile(r"Done \([^)]+\)! For help, type") -DEFAULT_CRAFTENGINE_ARTIFACT = "craftengine-paper-26.7.3" +DEFAULT_CRAFTENGINE_ARTIFACT = "craftengine-paper-26.7.4" MAHJONG_TRAFFIC_COMMAND = re.compile( r"^/mahjong botmatch (MAJSOUL_HANCHAN|MAJSOUL_TONPUU|GB|SICHUAN)$" ) @@ -474,6 +474,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--paper-lock", type=Path, default=script_dir / "artifacts.lock.json") parser.add_argument("--paper-artifact", default="paper-1.20.1-196") parser.add_argument("--plugin-jar", type=Path) + parser.add_argument("--plugin-config", type=Path) parser.add_argument("--craftengine-jar", type=Path) parser.add_argument("--craftengine-artifact", default=DEFAULT_CRAFTENGINE_ARTIFACT) parser.add_argument("--required-plugin", action="append", default=[]) @@ -505,6 +506,10 @@ def validate_args(args: argparse.Namespace) -> None: raise ValueError(f"Paper jar does not exist: {args.paper_jar}") if args.plugin_jar is not None and not args.plugin_jar.is_file(): raise ValueError(f"Plugin jar does not exist: {args.plugin_jar}") + if args.plugin_config is not None and not args.plugin_config.is_file(): + raise ValueError(f"Plugin config does not exist: {args.plugin_config}") + if args.plugin_config is not None and args.plugin_jar is None: + raise ValueError("--plugin-config requires --plugin-jar") if args.plugin_jar is not None and args.craftengine_jar is None: raise ValueError("--plugin-jar requires the real locked --craftengine-jar; a stub dependency is not accepted") if args.craftengine_jar is not None and not args.craftengine_jar.is_file(): @@ -558,6 +563,7 @@ def main() -> int: paper_verification = verify_file(args.paper_jar, paper_spec) input_artifacts: dict[str, Any] = {"paper": paper_verification} plugin_name = None + plugin_configuration = None if args.plugin_jar is not None: plugin_name = jar_plugin_name(args.plugin_jar) if not plugin_name: @@ -565,6 +571,11 @@ def main() -> int: input_artifacts["plugin"] = hash_description(args.plugin_jar) craftengine_spec = artifact_from_lock(lock, args.craftengine_artifact) input_artifacts["craftengine"] = verify_file(args.craftengine_jar, craftengine_spec) + if args.plugin_config is not None: + plugin_configuration = hash_description(args.plugin_config) + input_copy = raw_dir / "inputs" / "plugin-config.yml" + input_copy.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(args.plugin_config, input_copy) ports = reserve_ports(2) server_port, rcon_port = ports @@ -604,6 +615,10 @@ def main() -> int: plugins_dir.mkdir() shutil.copy2(args.craftengine_jar, plugins_dir / str(artifact_from_lock(lock, args.craftengine_artifact)["filename"])) shutil.copy2(args.plugin_jar, plugins_dir / args.plugin_jar.name) + if args.plugin_config is not None: + plugin_data_dir = plugins_dir / plugin_name + plugin_data_dir.mkdir() + shutil.copy2(args.plugin_config, plugin_data_dir / "config.yml") java_command = [ args.java, @@ -785,6 +800,7 @@ def main() -> int: "artifact_lock": hash_description(args.paper_lock.resolve()), "world_template": world_description, "plugin_name": plugin_name, + "plugin_configuration": plugin_configuration, "setup_commands": args.setup_command, "scenario": { "name": args.scenario_name, diff --git a/perf/live/tests/test_download_locked.py b/perf/live/tests/test_download_locked.py index 41cea48..9e9dbcd 100644 --- a/perf/live/tests/test_download_locked.py +++ b/perf/live/tests/test_download_locked.py @@ -28,34 +28,34 @@ def test_committed_paper_lock_has_expected_immutable_object(self) -> None: def test_committed_craftengine_lock_matches_latest_paper_release(self) -> None: lock = load_lock(LIVE_DIR / "artifacts.lock.json") - craftengine = artifact_from_lock(lock, "craftengine-paper-26.7.3") + craftengine = artifact_from_lock(lock, "craftengine-paper-26.7.4") self.assertEqual("craftengine", craftengine["project"]) self.assertEqual("paper", craftengine["distribution"]) - self.assertEqual("26.7.3", craftengine["version"]) - self.assertEqual("Len451or", craftengine["modrinth_version_id"]) + self.assertEqual("26.7.4", craftengine["version"]) + self.assertEqual("aINSQrXC", craftengine["modrinth_version_id"]) self.assertIn("paper", craftengine["loaders"]) self.assertIn("1.20.1", craftengine["minecraft_versions"]) self.assertIn("26.1.2", craftengine["minecraft_versions"]) - self.assertEqual("craft-engine-paper-plugin-26.7.3.jar", craftengine["filename"]) + self.assertEqual("craft-engine-paper-plugin-26.7.4.jar", craftengine["filename"]) self.assertEqual( - "https://cdn.modrinth.com/data/tRX6FMfQ/versions/Len451or/" - "craft-engine-paper-plugin-26.7.3.jar", + "https://cdn.modrinth.com/data/tRX6FMfQ/versions/aINSQrXC/" + "craft-engine-paper-plugin-26.7.4.jar", craftengine["url"], ) - self.assertEqual(8_907_932, craftengine["size"]) + self.assertEqual(8_970_694, craftengine["size"]) self.assertEqual("sha512", craftengine["hash"]["algorithm"]) self.assertEqual( - "e87351417e4f99504cf0a8868bad03a88b12bc9bda5c36f0ee4c7451e3525f0" - "8146b2130b61cd34b222ee41c38ca75aa9ced9ada65bc3540c80aa66c3d39d689", + "1d6982e325552fd51a9a481252e0c055c32a5b6e3c2d816cf69ca4b2f355a5f7" + "af1abed00ff9d29c08fb32ff293d9d11837ad4372c34675b73682a98b1ec86b0", craftengine["hash"]["digest"], ) self.assertEqual(craftengine["hash"]["digest"], craftengine["published_hashes"]["sha512"]) self.assertEqual( - "467fa7317635cf9f76812bd73a47d984c57cf53f", + "179e7b8b191093e41beca56e3d52ae608f46e161", craftengine["published_hashes"]["sha1"], ) self.assertEqual( - "https://api.modrinth.com/v2/version/Len451or", + "https://api.modrinth.com/v2/version/aINSQrXC", craftengine["source_metadata"], ) diff --git a/perf/live/tests/test_run_server.py b/perf/live/tests/test_run_server.py index 6336e8c..844e3e6 100644 --- a/perf/live/tests/test_run_server.py +++ b/perf/live/tests/test_run_server.py @@ -47,7 +47,7 @@ def test_percentile_uses_linear_interpolation(self) -> None: class LiveInputTest(unittest.TestCase): def test_uses_latest_paper_compatible_craftengine_by_default(self) -> None: - self.assertEqual("craftengine-paper-26.7.3", DEFAULT_CRAFTENGINE_ARTIFACT) + self.assertEqual("craftengine-paper-26.7.4", DEFAULT_CRAFTENGINE_ARTIFACT) def test_reserved_ports_are_distinct(self) -> None: ports = reserve_ports(3) diff --git a/src/main/java/top/ellan/mahjong/bootstrap/MahjongPaperPlugin.java b/src/main/java/top/ellan/mahjong/bootstrap/MahjongPaperPlugin.java index a1bd3b1..8af3d7d 100644 --- a/src/main/java/top/ellan/mahjong/bootstrap/MahjongPaperPlugin.java +++ b/src/main/java/top/ellan/mahjong/bootstrap/MahjongPaperPlugin.java @@ -33,12 +33,18 @@ import java.lang.reflect.Proxy; import java.io.IOException; import java.util.Collection; +import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; +import org.bukkit.command.Command; +import org.bukkit.command.CommandMap; +import org.bukkit.command.CommandSender; import org.bukkit.command.PluginCommand; +import org.bukkit.command.PluginIdentifiableCommand; +import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; public final class MahjongPaperPlugin extends JavaPlugin { @@ -58,6 +64,8 @@ public final class MahjongPaperPlugin extends JavaPlugin { private GameRoomSelectionService gameRoomSelectionService; private GameRoomSelectionPreviewService gameRoomSelectionPreviewService; private PluginTask gameRoomTickTask; + private CommandMap legacyPaperCommandMap; + private Command legacyPaperCommand; @Override public void onEnable() { @@ -153,6 +161,7 @@ public void onEnable() { @Override public void onDisable() { + this.unregisterLegacyPaperCommand(); if (this.craftEngine != null) { this.craftEngine.disableFurnitureInteractionBridge(); this.craftEngine.clearTrackedCullables(); @@ -226,7 +235,12 @@ private boolean registerMahjongCommand(MahjongCommand mahjongCommand) { PluginCommand command = this.getCommand("mahjong"); if (command == null) { - this.getLogger().severe("MahjongPaper command is missing from plugin.yml; disabling plugin."); + if (this.registerLegacyPaperCommand(mahjongCommand)) { + return true; + } + this.getLogger().severe( + "MahjongPaper could not register its command through Paper lifecycle events, plugin.yml, or the legacy Paper command map; disabling plugin." + ); this.getServer().getPluginManager().disablePlugin(this); return false; } @@ -235,6 +249,34 @@ private boolean registerMahjongCommand(MahjongCommand mahjongCommand) { return true; } + private boolean registerLegacyPaperCommand(MahjongCommand delegate) { + CommandMap commandMap = this.getServer().getCommandMap(); + LegacyPaperMahjongCommand command = new LegacyPaperMahjongCommand(this, delegate); + boolean registeredPrimaryLabel = commandMap.register("mahjong", "mahjongpaper", command); + if (!registeredPrimaryLabel) { + removeLegacyPaperCommand(commandMap, command); + return false; + } + this.legacyPaperCommandMap = commandMap; + this.legacyPaperCommand = command; + return true; + } + + private void unregisterLegacyPaperCommand() { + CommandMap commandMap = this.legacyPaperCommandMap; + Command command = this.legacyPaperCommand; + this.legacyPaperCommandMap = null; + this.legacyPaperCommand = null; + if (commandMap != null && command != null) { + removeLegacyPaperCommand(commandMap, command); + } + } + + private static void removeLegacyPaperCommand(CommandMap commandMap, Command command) { + commandMap.getKnownCommands().entrySet().removeIf(entry -> entry.getValue() == command); + command.unregister(commandMap); + } + private PaperCommandRegistrationResult registerPaperCommand(MahjongCommand mahjongCommand) { try { Class lifecycleEventsClass = Class.forName("io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents"); @@ -321,6 +363,40 @@ private enum PaperCommandRegistrationResult { FAILED } + /** Command-map adapter for Paper versions that support paper-plugin.yml but predate lifecycle commands. */ + private static final class LegacyPaperMahjongCommand extends Command implements PluginIdentifiableCommand { + private final MahjongPaperPlugin plugin; + private final MahjongCommand delegate; + + private LegacyPaperMahjongCommand(MahjongPaperPlugin plugin, MahjongCommand delegate) { + super( + "mahjong", + "Manage MahjongPaper tables and rounds. Use /mahjong help for command explanations.", + "/mahjong ", + java.util.List.of() + ); + this.plugin = plugin; + this.delegate = delegate; + this.setPermission(delegate.permission()); + } + + @Override + public boolean execute(CommandSender sender, String commandLabel, String[] args) { + return this.delegate.onCommand(sender, this, commandLabel, args); + } + + @Override + public List tabComplete(CommandSender sender, String alias, String[] args) { + List suggestions = this.delegate.onTabComplete(sender, this, alias, args); + return suggestions == null ? List.of() : suggestions; + } + + @Override + public Plugin getPlugin() { + return this.plugin; + } + } + public String reloadMahjongConfiguration() { PluginSettings previousSettings = this.settings; PluginSettings reloadedSettings; diff --git a/src/main/java/top/ellan/mahjong/render/display/DisplayInteractionRayRegistry.java b/src/main/java/top/ellan/mahjong/render/display/DisplayInteractionRayRegistry.java index 2516236..15929a5 100644 --- a/src/main/java/top/ellan/mahjong/render/display/DisplayInteractionRayRegistry.java +++ b/src/main/java/top/ellan/mahjong/render/display/DisplayInteractionRayRegistry.java @@ -46,15 +46,19 @@ public static void replaceRegion( return; } VIEWER_INTERACTIONS.compute(viewerId, (ignored, current) -> { - if (current != null && !tableId.equals(current.tableId())) { - return interactions == null || interactions.isEmpty() - ? current - : ViewerInteractions.single(tableId, regionKey, interactions); + boolean empty = interactions == null || interactions.isEmpty(); + if (current == null) { + return empty ? null : ViewerInteractions.single(tableId, regionKey, interactions); } - Map> regions = current == null - ? new LinkedHashMap<>() - : new LinkedHashMap<>(current.regions()); - if (interactions == null || interactions.isEmpty()) { + if (!tableId.equals(current.tableId())) { + return empty ? current : ViewerInteractions.single(tableId, regionKey, interactions); + } + List previous = current.regions().get(regionKey); + if ((empty && previous == null) || (!empty && Objects.equals(previous, interactions))) { + return current; + } + Map> regions = new LinkedHashMap<>(current.regions()); + if (empty) { regions.remove(regionKey); } else { regions.put(regionKey, List.copyOf(interactions)); @@ -129,11 +133,11 @@ public static synchronized void replacePublicJoinRegion( return; } PublicRegionKey key = new PublicRegionKey(tableId, regionKey); - List publicJoins = interactions == null - ? List.of() - : interactions.stream() - .filter(interaction -> isPublicJoinForTable(interaction, tableId)) - .toList(); + List previous = PUBLIC_JOIN_REGIONS.get(key); + if (samePublicJoinInteractions(previous, tableId, interactions)) { + return; + } + List publicJoins = filterPublicJoinInteractions(tableId, interactions); if (publicJoins.isEmpty()) { PUBLIC_JOIN_REGIONS.remove(key); } else { @@ -211,6 +215,44 @@ private static void rebuildPublicJoinSnapshot() { publicJoinInteractions = List.copyOf(flattened); } + private static boolean samePublicJoinInteractions( + List previous, + String tableId, + List interactions + ) { + int previousIndex = 0; + if (interactions != null) { + for (RayInteraction interaction : interactions) { + if (!isPublicJoinForTable(interaction, tableId)) { + continue; + } + if (previous == null + || previousIndex >= previous.size() + || !Objects.equals(previous.get(previousIndex), interaction)) { + return false; + } + previousIndex++; + } + } + return previous == null ? previousIndex == 0 : previousIndex == previous.size(); + } + + private static List filterPublicJoinInteractions( + String tableId, + List interactions + ) { + if (interactions == null || interactions.isEmpty()) { + return List.of(); + } + List publicJoins = new ArrayList<>(interactions.size()); + for (RayInteraction interaction : interactions) { + if (isPublicJoinForTable(interaction, tableId)) { + publicJoins.add(interaction); + } + } + return publicJoins.isEmpty() ? List.of() : List.copyOf(publicJoins); + } + private static boolean isPublicJoinForTable(RayInteraction interaction, String tableId) { return interaction != null && interaction.action().actionType() == DisplayClickAction.ActionType.JOIN_SEAT diff --git a/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java b/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java index eb0498c..5ebc8df 100644 --- a/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java +++ b/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java @@ -12,6 +12,7 @@ import java.util.Collections; import java.util.EnumMap; import java.util.List; +import java.util.Objects; public final class TableRenderLayout { private static final double ONE_SIXTEENTH = 1.0D / 16.0D; @@ -42,6 +43,7 @@ public final class TableRenderLayout { private static final int DEAD_WALL_SIZE = 14; private static final int LIVE_WALL_SIZE = TOTAL_WALL_TILES - DEAD_WALL_SIZE; private static final int DISCARDS_PER_ROW = 6; + private static final SeatWind[] SEAT_WINDS = SeatWind.values(); private TableRenderLayout() { } @@ -53,9 +55,12 @@ public static LayoutPlan precompute(TableRenderSnapshot snapshot) { Point tableVisualAnchor = new Point(tableCenter.x(), tableCenter.y() + TABLE_VISUAL_Y_OFFSET, tableCenter.z()); double borderSpanX = bounds.width() + TABLE_TOP_SIZE_EXPANSION + TABLE_BORDER_THICKNESS; double borderSpanZ = bounds.depth() + TABLE_TOP_SIZE_EXPANSION + TABLE_BORDER_THICKNESS; + List deadWallPlacements = snapshot.started() && snapshot.usesDeadWall() + ? deadWallPlacements(displayCenter, snapshot) + : List.of(); EnumMap seats = new EnumMap<>(SeatWind.class); - for (SeatWind wind : SeatWind.values()) { + for (SeatWind wind : SEAT_WINDS) { TableSeatRenderSnapshot seat = snapshot.seat(wind); seats.put(wind, precomputeSeat(displayCenter, snapshot, seat)); } @@ -67,11 +72,127 @@ public static LayoutPlan precompute(TableRenderSnapshot snapshot) { borderSpanX, borderSpanZ, seats, - precomputeWall(displayCenter, snapshot), - precomputeDora(displayCenter, snapshot) + precomputeWall(displayCenter, snapshot, deadWallPlacements), + precomputeDora(snapshot, deadWallPlacements) ); } + /** Reuses immutable center, seat and wall geometry from the immediately preceding layout. */ + public static LayoutPlan precompute( + TableRenderSnapshot snapshot, + TableRenderSnapshot previousSnapshot, + LayoutPlan previousLayout + ) { + if (previousSnapshot == null || previousLayout == null) { + return precompute(snapshot); + } + boolean reusableCenter = sameCenter(snapshot, previousSnapshot); + Point displayCenter; + Point tableCenter; + Point tableVisualAnchor; + double borderSpanX; + double borderSpanZ; + if (reusableCenter) { + displayCenter = previousLayout.displayCenter(); + tableCenter = previousLayout.tableCenter(); + tableVisualAnchor = previousLayout.tableVisualAnchor(); + borderSpanX = previousLayout.borderSpanX(); + borderSpanZ = previousLayout.borderSpanZ(); + } else { + displayCenter = new Point(snapshot.centerX(), snapshot.centerY() + DISPLAY_CENTER_Y_OFFSET, snapshot.centerZ()); + TableBounds bounds = tableBoundsFromTiles(displayCenter); + tableCenter = new Point(bounds.centerX(), displayCenter.y(), bounds.centerZ()); + tableVisualAnchor = new Point(tableCenter.x(), tableCenter.y() + TABLE_VISUAL_Y_OFFSET, tableCenter.z()); + borderSpanX = bounds.width() + TABLE_TOP_SIZE_EXPANSION + TABLE_BORDER_THICKNESS; + borderSpanZ = bounds.depth() + TABLE_TOP_SIZE_EXPANSION + TABLE_BORDER_THICKNESS; + } + + EnumMap seats = new EnumMap<>(SeatWind.class); + for (SeatWind wind : SEAT_WINDS) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + if (reusableCenter && sameSeatLayoutInputs(snapshot, previousSnapshot, wind)) { + seats.put(wind, previousLayout.seat(wind)); + } else { + seats.put(wind, precomputeSeat(displayCenter, snapshot, seat)); + } + } + + List wallTiles; + List doraTiles; + if (reusableCenter && sameWallLayoutInputs(snapshot, previousSnapshot)) { + wallTiles = previousLayout.wallTiles(); + doraTiles = previousLayout.doraTiles(); + } else { + List deadWallPlacements = snapshot.started() && snapshot.usesDeadWall() + ? deadWallPlacements(displayCenter, snapshot) + : List.of(); + wallTiles = precomputeWall(displayCenter, snapshot, deadWallPlacements); + doraTiles = precomputeDora(snapshot, deadWallPlacements); + } + + return new LayoutPlan( + displayCenter, + tableCenter, + tableVisualAnchor, + borderSpanX, + borderSpanZ, + seats, + wallTiles, + doraTiles + ); + } + + private static boolean sameCenter(TableRenderSnapshot left, TableRenderSnapshot right) { + return Double.doubleToLongBits(left.centerX()) == Double.doubleToLongBits(right.centerX()) + && Double.doubleToLongBits(left.centerY()) == Double.doubleToLongBits(right.centerY()) + && Double.doubleToLongBits(left.centerZ()) == Double.doubleToLongBits(right.centerZ()); + } + + private static boolean sameSeatLayoutInputs( + TableRenderSnapshot leftSnapshot, + TableRenderSnapshot rightSnapshot, + SeatWind wind + ) { + TableSeatRenderSnapshot left = leftSnapshot.seat(wind); + TableSeatRenderSnapshot right = rightSnapshot.seat(wind); + boolean leftOccupied = left.playerId() != null; + boolean rightOccupied = right.playerId() != null; + if (leftOccupied != rightOccupied) { + return false; + } + if (!leftOccupied) { + return true; + } + return left.hand().size() == right.hand().size() + && left.selectedHandTileIndices().equals(right.selectedHandTileIndices()) + && left.riichiDiscardIndex() == right.riichiDiscardIndex() + && left.stickLayoutCount() == right.stickLayoutCount() + && left.discards().equals(right.discards()) + && left.melds().equals(right.melds()) + && left.cornerSticks().equals(right.cornerSticks()) + && left.riichi() == right.riichi() + && leftSnapshot.openDoorSeat() == rightSnapshot.openDoorSeat(); + } + + private static boolean sameWallLayoutInputs(TableRenderSnapshot left, TableRenderSnapshot right) { + if (left.started() != right.started() + || left.remainingWallCount() != right.remainingWallCount() + || left.kanCount() != right.kanCount() + || left.dicePoints() != right.dicePoints() + || left.breakDicePoints() != right.breakDicePoints() + || left.dealerSeat() != right.dealerSeat() + || left.variant() != right.variant() + || !left.doraIndicators().equals(right.doraIndicators())) { + return false; + } + for (SeatWind wind : SEAT_WINDS) { + if (!Objects.equals(left.seat(wind).melds(), right.seat(wind).melds())) { + return false; + } + } + return true; + } + public static SeatLayoutPlan precomputeSeatOnly(TableRenderSnapshot snapshot, SeatWind wind) { if (snapshot == null || wind == null) { throw new IllegalArgumentException("snapshot and wind are required"); @@ -115,12 +236,18 @@ private static SeatLayoutPlan precomputeSeat( ); } - List publicHandPoints = new ArrayList<>(seat.hand().size()); - List privateHandPoints = new ArrayList<>(seat.hand().size()); + int handSize = seat.hand().size(); + List publicHandPoints = new ArrayList<>(handSize); + List privateHandPoints = new ArrayList<>(handSize); List selectedHandTileIndices = seat.selectedHandTileIndices(); - for (int tileIndex = 0; tileIndex < seat.hand().size(); tileIndex++) { - publicHandPoints.add(handTilePoint(displayCenter, seat, wind, tileIndex, false)); - privateHandPoints.add(handTilePoint(displayCenter, seat, wind, tileIndex, selectedHandTileIndices.contains(tileIndex))); + double startingPos = handStartingPosition(seat, handSize); + SeatWind direction = displayDirection(wind); + for (int tileIndex = 0; tileIndex < handSize; tileIndex++) { + Point publicPoint = handTilePoint(handBase, direction, handSize, startingPos, tileIndex, false); + publicHandPoints.add(publicPoint); + privateHandPoints.add(selectedHandTileIndices.contains(tileIndex) + ? handTilePoint(handBase, direction, handSize, startingPos, tileIndex, true) + : publicPoint); } return new SeatLayoutPlan( @@ -130,8 +257,8 @@ private static SeatLayoutPlan precomputeSeat( playerNameLocation, interactionLocation, yaw, - List.copyOf(publicHandPoints), - List.copyOf(privateHandPoints), + immutableList(publicHandPoints), + immutableList(privateHandPoints), precomputeDiscards(displayCenter, seat, snapshot.openDoorSeat()), precomputeMelds(displayCenter, seat), precomputeSticks(displayCenter, seat) @@ -157,10 +284,20 @@ private static SeatLayoutPlan precomputePrivateHandSeat(Point displayCenter, Tab List.of() ); } - List privateHandPoints = new ArrayList<>(seat.hand().size()); + int handSize = seat.hand().size(); + List privateHandPoints = new ArrayList<>(handSize); List selectedHandTileIndices = seat.selectedHandTileIndices(); - for (int tileIndex = 0; tileIndex < seat.hand().size(); tileIndex++) { - privateHandPoints.add(handTilePoint(displayCenter, seat, wind, tileIndex, selectedHandTileIndices.contains(tileIndex))); + double startingPos = handStartingPosition(seat, handSize); + SeatWind direction = displayDirection(wind); + for (int tileIndex = 0; tileIndex < handSize; tileIndex++) { + privateHandPoints.add(handTilePoint( + handBase, + direction, + handSize, + startingPos, + tileIndex, + selectedHandTileIndices.contains(tileIndex) + )); } return new SeatLayoutPlan( wind, @@ -170,14 +307,18 @@ private static SeatLayoutPlan precomputePrivateHandSeat(Point displayCenter, Tab handBase, yaw, List.of(), - List.copyOf(privateHandPoints), + immutableList(privateHandPoints), List.of(), List.of(), List.of() ); } - private static List precomputeWall(Point displayCenter, TableRenderSnapshot snapshot) { + private static List precomputeWall( + Point displayCenter, + TableRenderSnapshot snapshot, + List deadWallPlacements + ) { if (!snapshot.started()) { return List.of(); } @@ -195,7 +336,6 @@ private static List precomputeWall(Point displayCenter, TableRend } } - List deadWallPlacements = deadWallPlacements(displayCenter, snapshot); List placements = Arrays.asList(new TilePlacement[TOTAL_WALL_TILES]); boolean[] occupiedSlots = new boolean[TOTAL_WALL_TILES]; int breakTileIndex = wallBreakTileIndex(snapshot); @@ -261,7 +401,7 @@ private static List precomputeLiveWallWithoutDeadWall(Point displ private static int exposedFlowerCount(TableRenderSnapshot snapshot) { int count = 0; - for (SeatWind wind : SeatWind.values()) { + for (SeatWind wind : SEAT_WINDS) { TableSeatRenderSnapshot seat = snapshot.seat(wind); if (seat == null) { continue; @@ -277,17 +417,19 @@ private static int exposedFlowerCount(TableRenderSnapshot snapshot) { return count; } - private static List precomputeDora(Point displayCenter, TableRenderSnapshot snapshot) { + private static List precomputeDora( + TableRenderSnapshot snapshot, + List deadWallPlacements + ) { if (!snapshot.started() || !snapshot.usesDeadWall()) { return List.of(); } - List deadWallPlacements = deadWallPlacements(displayCenter, snapshot); List placements = new ArrayList<>(snapshot.doraIndicators().size()); for (int i = 0; i < snapshot.doraIndicators().size(); i++) { DeadWallPlacement placement = deadWallPlacements.get(doraIndicatorDeadWallIndex(snapshot.kanCount(), i)); placements.add(new TilePlacement(placement.point(), placement.yaw(), snapshot.doraIndicators().get(i), DisplayEntities.TileRenderPose.FLAT_FACE_UP)); } - return List.copyOf(placements); + return immutableList(placements); } private static List precomputeSticks(Point displayCenter, TableSeatRenderSnapshot seat) { @@ -301,7 +443,7 @@ private static List precomputeSticks(Point displayCenter, TableS if (seat.riichi()) { placements.add(new StickPlacement(riichiStickCenter(displayCenter, seat.wind()), riichiStickLongOnX(seat.wind()), ScoringStick.P1000)); } - return List.copyOf(placements); + return immutableList(placements); } private static List precomputeDiscards( @@ -350,7 +492,7 @@ private static List precomputeDiscards( DisplayEntities.TileRenderPose.FLAT_FACE_UP )); } - return List.copyOf(placements); + return immutableList(placements); } private static List precomputeMelds(Point displayCenter, TableSeatRenderSnapshot seat) { @@ -440,27 +582,31 @@ private static List precomputeMelds(Point displayCenter, TableSea lastTileWasHorizontal = false; } } - return List.copyOf(placements); + return immutableList(placements); } - private static Point handTilePoint( - Point displayCenter, - TableSeatRenderSnapshot seat, - SeatWind wind, - int tileIndex, - boolean selected - ) { - Point handBase = handDirectionBase(displayCenter, wind); - int handSize = seat.hand().size(); + private static double handStartingPosition(TableSeatRenderSnapshot seat, int handSize) { int meldCount = seat.melds().size(); double fuuroOffset = meldCount < 3 ? 0.0D : (meldCount - 2.0D) * TILE_WIDTH; int stickCount = seat.stickLayoutCount(); double sticksOffset = stickCount < 3 ? 0.0D : (stickCount - 2.0D) * STICK_DEPTH; - double startingPos = (handSize * TILE_WIDTH + Math.max(0, handSize - 1) * TILE_PADDING) / 2.0D + fuuroOffset + sticksOffset; + return (handSize * TILE_WIDTH + Math.max(0, handSize - 1) * TILE_PADDING) / 2.0D + + fuuroOffset + + sticksOffset; + } + + private static Point handTilePoint( + Point handBase, + SeatWind direction, + int handSize, + double startingPos, + int tileIndex, + boolean selected + ) { double drawGap = tileIndex == handSize - 1 && handSize % 3 == 2 ? TILE_PADDING * 15.0D : 0.0D; double stackOffset = tileIndex * (TILE_WIDTH + TILE_PADDING) + drawGap; double tileYOffset = selected ? SELECTED_HAND_TILE_Y_OFFSET : 0.0D; - return switch (displayDirection(wind)) { + return switch (direction) { case EAST -> handBase.add(0.0D, UPRIGHT_TILE_Y + tileYOffset, startingPos - stackOffset); case SOUTH -> handBase.add(-startingPos + stackOffset, UPRIGHT_TILE_Y + tileYOffset, 0.0D); case WEST -> handBase.add(0.0D, UPRIGHT_TILE_Y + tileYOffset, -startingPos + stackOffset); @@ -468,8 +614,12 @@ private static Point handTilePoint( }; } + private static List immutableList(List values) { + return values.isEmpty() ? List.of() : Collections.unmodifiableList(values); + } + private static int wallBreakTileIndex(TableRenderSnapshot snapshot) { - int seatCount = SeatWind.values().length; + int seatCount = SEAT_WINDS.length; int dicePoints = snapshot.dicePoints(); int breakDice = snapshot.breakDicePoints(); int openDoorIndex = Math.floorMod(snapshot.dealerSeat().index() + dicePoints - 1, seatCount); @@ -500,7 +650,7 @@ private static TableBounds tableBoundsFromTiles(Point center) { } private static Point meldStartByDisplayDirection(Point center, SeatWind direction) { - for (SeatWind wind : SeatWind.values()) { + for (SeatWind wind : SEAT_WINDS) { if (displayDirection(wind) == direction) { return meldStart(center, wind); } diff --git a/src/main/java/top/ellan/mahjong/runtime/ServerScheduler.java b/src/main/java/top/ellan/mahjong/runtime/ServerScheduler.java index 7a984b0..6046992 100644 --- a/src/main/java/top/ellan/mahjong/runtime/ServerScheduler.java +++ b/src/main/java/top/ellan/mahjong/runtime/ServerScheduler.java @@ -8,11 +8,18 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; -import net.momirealms.sparrow.reflection.method.SMethod; +import net.momirealms.sparrow.reflection.method.SMethod0; +import net.momirealms.sparrow.reflection.method.SMethod1; +import net.momirealms.sparrow.reflection.method.SMethod2; +import net.momirealms.sparrow.reflection.method.SMethod3; +import net.momirealms.sparrow.reflection.method.SMethod4; +import net.momirealms.sparrow.reflection.method.SMethod5; import net.momirealms.sparrow.reflection.method.SparrowMethod; import org.bukkit.Location; +import org.bukkit.Server; import org.bukkit.entity.Entity; import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; import org.bukkit.scheduler.BukkitTask; public final class ServerScheduler { @@ -77,6 +84,38 @@ protected Cache computeValue(Class type) { return Caffeine.newBuilder().build(); } }; + private static final ClassValue GLOBAL_SCHEDULER_DISPATCH_PLAN_CACHE = + new ClassValue<>() { + @Override + protected GlobalSchedulerDispatchPlan computeValue(Class type) { + return new GlobalSchedulerDispatchPlan( + resolveMethod(type, RUN_GLOBAL), + resolveMethod(type, RUN_GLOBAL_DELAYED), + resolveMethod(type, RUN_GLOBAL_TIMER) + ); + } + }; + private static final ClassValue REGION_SCHEDULER_DISPATCH_PLAN_CACHE = + new ClassValue<>() { + @Override + protected RegionSchedulerDispatchPlan computeValue(Class type) { + return new RegionSchedulerDispatchPlan( + resolveMethod(type, RUN_REGION), + resolveMethod(type, RUN_REGION_DELAYED), + resolveMethod(type, RUN_REGION_TIMER) + ); + } + }; + private static final ClassValue ENTITY_SCHEDULER_DISPATCH_PLAN_CACHE = + new ClassValue<>() { + @Override + protected EntitySchedulerDispatchPlan computeValue(Class type) { + return new EntitySchedulerDispatchPlan( + resolveMethod(type, RUN_ENTITY), + resolveMethod(type, RUN_ENTITY_DELAYED) + ); + } + }; private static final PluginTask NO_OP_TASK = new PluginTask() { @Override @@ -91,6 +130,17 @@ public boolean isCancelled() { private final Plugin plugin; + /** + * Each capability is published as one immutable entry so readers can never + * observe a target from one resolution paired with another target's scheduler + * or invocation plan. Null scheduler values intentionally negative-cache + * unavailable Folia capabilities on standard Paper runtimes. + */ + private volatile GlobalSchedulerCapability globalSchedulerCapability; + private volatile RegionSchedulerCapability regionSchedulerCapability; + private volatile EntitySchedulerCapability entitySchedulerCapability; + private volatile BukkitSchedulerCapability bukkitSchedulerCapability; + public ServerScheduler(Plugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); } @@ -99,71 +149,32 @@ public PluginTask runGlobal(Runnable runnable) { if (runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.globalRegionScheduler(); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_GLOBAL, - this.plugin, - taskConsumer(runnable) - ); - if (task != null) { - return task; - } - } - return wrap(this.plugin.getServer().getScheduler().runTask(this.plugin, runnable)); + return this.runGlobalValidated(this.plugin.getServer(), runnable); } public PluginTask runGlobalDelayed(Runnable runnable, long delayTicks) { if (runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.globalRegionScheduler(); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_GLOBAL_DELAYED, - this.plugin, - taskConsumer(runnable), - delayTicks - ); - if (task != null) { - return task; - } - } - return wrap(this.plugin.getServer().getScheduler().runTaskLater(this.plugin, runnable, delayTicks)); + return this.runGlobalDelayedValidated(this.plugin.getServer(), runnable, delayTicks); } public PluginTask runGlobalTimer(Runnable runnable, long delayTicks, long periodTicks) { if (runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.globalRegionScheduler(); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_GLOBAL_TIMER, - this.plugin, - taskConsumer(runnable), - delayTicks, - periodTicks - ); - if (task != null) { - return task; - } - } - return wrap(this.plugin.getServer().getScheduler().runTaskTimer(this.plugin, runnable, delayTicks, periodTicks)); + return this.runGlobalTimerValidated(this.plugin.getServer(), runnable, delayTicks, periodTicks); } public PluginTask runRegion(Location location, Runnable runnable) { if (location == null || location.getWorld() == null || runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.regionScheduler(); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_REGION, + Server server = this.plugin.getServer(); + RegionSchedulerCapability capability = this.regionSchedulerCapability(server); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeRun( + capability.scheduler(), this.plugin, location, taskConsumer(runnable) @@ -172,18 +183,18 @@ public PluginTask runRegion(Location location, Runnable runnable) { return task; } } - return this.runGlobal(runnable); + return this.runGlobalValidated(server, runnable); } public PluginTask runRegionDelayed(Location location, Runnable runnable, long delayTicks) { if (location == null || location.getWorld() == null || runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.regionScheduler(); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_REGION_DELAYED, + Server server = this.plugin.getServer(); + RegionSchedulerCapability capability = this.regionSchedulerCapability(server); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeDelayed( + capability.scheduler(), this.plugin, location, taskConsumer(runnable), @@ -193,18 +204,18 @@ public PluginTask runRegionDelayed(Location location, Runnable runnable, long de return task; } } - return this.runGlobalDelayed(runnable, delayTicks); + return this.runGlobalDelayedValidated(server, runnable, delayTicks); } public PluginTask runRegionTimer(Location location, Runnable runnable, long delayTicks, long periodTicks) { if (location == null || location.getWorld() == null || runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.regionScheduler(); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_REGION_TIMER, + Server server = this.plugin.getServer(); + RegionSchedulerCapability capability = this.regionSchedulerCapability(server); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeTimer( + capability.scheduler(), this.plugin, location, taskConsumer(runnable), @@ -215,18 +226,17 @@ public PluginTask runRegionTimer(Location location, Runnable runnable, long dela return task; } } - return this.runGlobalTimer(runnable, delayTicks, periodTicks); + return this.runGlobalTimerValidated(server, runnable, delayTicks, periodTicks); } public PluginTask runEntity(Entity entity, Runnable runnable) { if (entity == null || runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.entityScheduler(entity); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_ENTITY, + EntitySchedulerCapability capability = this.entitySchedulerCapability(entity); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeRun( + capability.scheduler(), this.plugin, taskConsumer(runnable), NO_OP_RUNNABLE @@ -235,18 +245,17 @@ public PluginTask runEntity(Entity entity, Runnable runnable) { return task; } } - return this.runGlobal(runnable); + return this.runGlobalValidated(this.plugin.getServer(), runnable); } public PluginTask runEntityDelayed(Entity entity, Runnable runnable, long delayTicks) { if (entity == null || runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.entityScheduler(entity); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_ENTITY_DELAYED, + EntitySchedulerCapability capability = this.entitySchedulerCapability(entity); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeDelayed( + capability.scheduler(), this.plugin, taskConsumer(runnable), NO_OP_RUNNABLE, @@ -256,7 +265,7 @@ public PluginTask runEntityDelayed(Entity entity, Runnable runnable, long delayT return task; } } - return this.runGlobalDelayed(runnable, delayTicks); + return this.runGlobalDelayedValidated(this.plugin.getServer(), runnable, delayTicks); } @SuppressWarnings("unchecked") @@ -267,7 +276,7 @@ public CompletableFuture teleport(Entity entity, Location location) { MethodResolution teleportAsync = resolveMethod(entity.getClass(), TELEPORT_ASYNC); if (teleportAsync.isAvailable()) { try { - Object result = teleportAsync.invoke(entity, location); + Object result = teleportAsync.invoke1(entity, location); if (result instanceof CompletableFuture future) { return (CompletableFuture) future; } @@ -312,23 +321,111 @@ public PluginTask removeEntity(Entity entity, long delayTicks) { return this.runEntityDelayed(entity, removeTask, delayTicks); } - private Object globalRegionScheduler() { - return this.invokeNoArgs(this.plugin.getServer(), GET_GLOBAL_REGION_SCHEDULER); + private PluginTask runGlobalValidated(Server server, Runnable runnable) { + GlobalSchedulerCapability capability = this.globalSchedulerCapability(server); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeRun( + capability.scheduler(), + this.plugin, + taskConsumer(runnable) + ); + if (task != null) { + return task; + } + } + return wrap(this.bukkitScheduler(server).runTask(this.plugin, runnable)); + } + + private PluginTask runGlobalDelayedValidated(Server server, Runnable runnable, long delayTicks) { + GlobalSchedulerCapability capability = this.globalSchedulerCapability(server); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeDelayed( + capability.scheduler(), + this.plugin, + taskConsumer(runnable), + delayTicks + ); + if (task != null) { + return task; + } + } + return wrap(this.bukkitScheduler(server).runTaskLater(this.plugin, runnable, delayTicks)); + } + + private PluginTask runGlobalTimerValidated(Server server, Runnable runnable, long delayTicks, long periodTicks) { + GlobalSchedulerCapability capability = this.globalSchedulerCapability(server); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeTimer( + capability.scheduler(), + this.plugin, + taskConsumer(runnable), + delayTicks, + periodTicks + ); + if (task != null) { + return task; + } + } + return wrap(this.bukkitScheduler(server).runTaskTimer(this.plugin, runnable, delayTicks, periodTicks)); + } + + private GlobalSchedulerCapability globalSchedulerCapability(Server server) { + GlobalSchedulerCapability capability = this.globalSchedulerCapability; + if (capability != null && capability.target() == server) { + return capability; + } + Object scheduler = invokeNoArgs(server, GET_GLOBAL_REGION_SCHEDULER); + GlobalSchedulerDispatchPlan dispatchPlan = scheduler == null + ? null + : GLOBAL_SCHEDULER_DISPATCH_PLAN_CACHE.get(scheduler.getClass()); + capability = new GlobalSchedulerCapability(server, scheduler, dispatchPlan); + this.globalSchedulerCapability = capability; + return capability; + } + + private RegionSchedulerCapability regionSchedulerCapability(Server server) { + RegionSchedulerCapability capability = this.regionSchedulerCapability; + if (capability != null && capability.target() == server) { + return capability; + } + Object scheduler = invokeNoArgs(server, GET_REGION_SCHEDULER); + RegionSchedulerDispatchPlan dispatchPlan = scheduler == null + ? null + : REGION_SCHEDULER_DISPATCH_PLAN_CACHE.get(scheduler.getClass()); + capability = new RegionSchedulerCapability(server, scheduler, dispatchPlan); + this.regionSchedulerCapability = capability; + return capability; } - private Object regionScheduler() { - return this.invokeNoArgs(this.plugin.getServer(), GET_REGION_SCHEDULER); + private EntitySchedulerCapability entitySchedulerCapability(Entity entity) { + EntitySchedulerCapability capability = this.entitySchedulerCapability; + if (capability != null && capability.target() == entity) { + return capability; + } + Object scheduler = invokeNoArgs(entity, GET_ENTITY_SCHEDULER); + EntitySchedulerDispatchPlan dispatchPlan = scheduler == null + ? null + : ENTITY_SCHEDULER_DISPATCH_PLAN_CACHE.get(scheduler.getClass()); + capability = new EntitySchedulerCapability(entity, scheduler, dispatchPlan); + this.entitySchedulerCapability = capability; + return capability; } - private Object entityScheduler(Entity entity) { - return this.invokeNoArgs(entity, GET_ENTITY_SCHEDULER); + private BukkitScheduler bukkitScheduler(Server server) { + BukkitSchedulerCapability capability = this.bukkitSchedulerCapability; + if (capability != null && capability.target() == server) { + return capability.scheduler(); + } + BukkitScheduler scheduler = server.getScheduler(); + this.bukkitSchedulerCapability = new BukkitSchedulerCapability(server, scheduler); + return scheduler; } private boolean isPluginEnabled() { return this.plugin.isEnabled(); } - private Object invokeNoArgs(Object target, MethodSignature signature) { + private static Object invokeNoArgs(Object target, MethodSignature signature) { if (target == null) { return null; } @@ -337,19 +434,77 @@ private Object invokeNoArgs(Object target, MethodSignature signature) { return null; } try { - return resolution.invoke(target); + return resolution.invoke0(target); + } catch (IllegalAccessException exception) { + return null; + } + } + + private static PluginTask invokeSchedulerTask( + Object scheduler, + MethodResolution resolution, + Object argument0, + Object argument1 + ) { + if (!resolution.isAvailable()) { + return null; + } + try { + return wrap(resolution.invoke2(scheduler, argument0, argument1)); + } catch (IllegalAccessException exception) { + return null; + } + } + + private static PluginTask invokeSchedulerTask( + Object scheduler, + MethodResolution resolution, + Object argument0, + Object argument1, + Object argument2 + ) { + if (!resolution.isAvailable()) { + return null; + } + try { + return wrap(resolution.invoke3(scheduler, argument0, argument1, argument2)); + } catch (IllegalAccessException exception) { + return null; + } + } + + private static PluginTask invokeSchedulerTask( + Object scheduler, + MethodResolution resolution, + Object argument0, + Object argument1, + Object argument2, + Object argument3 + ) { + if (!resolution.isAvailable()) { + return null; + } + try { + return wrap(resolution.invoke4(scheduler, argument0, argument1, argument2, argument3)); } catch (IllegalAccessException exception) { return null; } } - private PluginTask invokeSchedulerTask(Object scheduler, MethodSignature signature, Object... args) { - MethodResolution resolution = resolveMethod(scheduler.getClass(), signature); + private static PluginTask invokeSchedulerTask( + Object scheduler, + MethodResolution resolution, + Object argument0, + Object argument1, + Object argument2, + Object argument3, + Object argument4 + ) { if (!resolution.isAvailable()) { return null; } try { - return wrap(resolution.invoke(scheduler, args)); + return wrap(resolution.invoke5(scheduler, argument0, argument1, argument2, argument3, argument4)); } catch (IllegalAccessException exception) { return null; } @@ -367,6 +522,119 @@ private static PluginTask wrap(Object task) { return task == null ? NO_OP_TASK : new ScheduledTaskHandle(task); } + private record GlobalSchedulerDispatchPlan( + MethodResolution runMethod, + MethodResolution delayedMethod, + MethodResolution timerMethod + ) { + private PluginTask invokeRun(Object scheduler, Plugin plugin, Consumer consumer) { + return invokeSchedulerTask(scheduler, this.runMethod, plugin, consumer); + } + + private PluginTask invokeDelayed(Object scheduler, Plugin plugin, Consumer consumer, long delayTicks) { + return invokeSchedulerTask(scheduler, this.delayedMethod, plugin, consumer, delayTicks); + } + + private PluginTask invokeTimer( + Object scheduler, + Plugin plugin, + Consumer consumer, + long delayTicks, + long periodTicks + ) { + return invokeSchedulerTask(scheduler, this.timerMethod, plugin, consumer, delayTicks, periodTicks); + } + } + + private record RegionSchedulerDispatchPlan( + MethodResolution runMethod, + MethodResolution delayedMethod, + MethodResolution timerMethod + ) { + private PluginTask invokeRun( + Object scheduler, + Plugin plugin, + Location location, + Consumer consumer + ) { + return invokeSchedulerTask(scheduler, this.runMethod, plugin, location, consumer); + } + + private PluginTask invokeDelayed( + Object scheduler, + Plugin plugin, + Location location, + Consumer consumer, + long delayTicks + ) { + return invokeSchedulerTask(scheduler, this.delayedMethod, plugin, location, consumer, delayTicks); + } + + private PluginTask invokeTimer( + Object scheduler, + Plugin plugin, + Location location, + Consumer consumer, + long delayTicks, + long periodTicks + ) { + return invokeSchedulerTask( + scheduler, + this.timerMethod, + plugin, + location, + consumer, + delayTicks, + periodTicks + ); + } + } + + private record EntitySchedulerDispatchPlan(MethodResolution runMethod, MethodResolution delayedMethod) { + private PluginTask invokeRun( + Object scheduler, + Plugin plugin, + Consumer consumer, + Runnable retired + ) { + return invokeSchedulerTask(scheduler, this.runMethod, plugin, consumer, retired); + } + + private PluginTask invokeDelayed( + Object scheduler, + Plugin plugin, + Consumer consumer, + Runnable retired, + long delayTicks + ) { + return invokeSchedulerTask(scheduler, this.delayedMethod, plugin, consumer, retired, delayTicks); + } + } + + private record GlobalSchedulerCapability( + Server target, + Object scheduler, + GlobalSchedulerDispatchPlan dispatchPlan + ) { + } + + private record RegionSchedulerCapability( + Server target, + Object scheduler, + RegionSchedulerDispatchPlan dispatchPlan + ) { + } + + private record EntitySchedulerCapability( + Entity target, + Object scheduler, + EntitySchedulerDispatchPlan dispatchPlan + ) { + } + + private record BukkitSchedulerCapability(Server target, BukkitScheduler scheduler) { + } + private record BukkitTaskHandle(BukkitTask task) implements PluginTask { @Override public void cancel() { @@ -401,7 +669,7 @@ private static Object invokeTaskMethod(Object task, MethodSignature signature) { return null; } try { - return resolution.invoke(task); + return resolution.invoke0(task); } catch (IllegalAccessException exception) { return null; } @@ -448,32 +716,147 @@ private Class[] parameterArray() { } } - private record MethodResolution(Method method, SMethod invoker) { - private static final MethodResolution UNAVAILABLE = new MethodResolution(null, null); + private static final class MethodResolution { + private static final MethodResolution UNAVAILABLE = new MethodResolution( + null, + null, + null, + null, + null, + null, + null + ); + + private final Method method; + private final SMethod0 invoker0; + private final SMethod1 invoker1; + private final SMethod2 invoker2; + private final SMethod3 invoker3; + private final SMethod4 invoker4; + private final SMethod5 invoker5; + + private MethodResolution( + Method method, + SMethod0 invoker0, + SMethod1 invoker1, + SMethod2 invoker2, + SMethod3 invoker3, + SMethod4 invoker4, + SMethod5 invoker5 + ) { + this.method = method; + this.invoker0 = invoker0; + this.invoker1 = invoker1; + this.invoker2 = invoker2; + this.invoker3 = invoker3; + this.invoker4 = invoker4; + this.invoker5 = invoker5; + } private static MethodResolution available(Method method) { - SMethod invoker = null; + SMethod0 invoker0 = null; + SMethod1 invoker1 = null; + SMethod2 invoker2 = null; + SMethod3 invoker3 = null; + SMethod4 invoker4 = null; + SMethod5 invoker5 = null; try { - invoker = SparrowMethod.of(method).asm(); + SparrowMethod sparrowMethod = SparrowMethod.of(method); + switch (method.getParameterCount()) { + case 0 -> invoker0 = sparrowMethod.asm$0(); + case 1 -> invoker1 = sparrowMethod.asm$1(); + case 2 -> invoker2 = sparrowMethod.asm$2(); + case 3 -> invoker3 = sparrowMethod.asm$3(); + case 4 -> invoker4 = sparrowMethod.asm$4(); + case 5 -> invoker5 = sparrowMethod.asm$5(); + default -> { + } + } } catch (RuntimeException | LinkageError exception) { // Some generated or strongly encapsulated runtime classes reject hidden invoker generation. } - return new MethodResolution(method, invoker); + return new MethodResolution(method, invoker0, invoker1, invoker2, invoker3, invoker4, invoker5); + } + + private Method method() { + return this.method; } private boolean isAvailable() { return this.method != null; } - private Object invoke(Object target, Object... args) throws IllegalAccessException { + private Object invoke0(Object target) throws IllegalAccessException { + this.requireAvailable(); + if (this.invoker0 != null) { + return this.invoker0.invoke(target); + } + return this.invokeReflectively(target); + } + + private Object invoke1(Object target, Object argument0) throws IllegalAccessException { + this.requireAvailable(); + if (this.invoker1 != null) { + return this.invoker1.invoke(target, argument0); + } + return this.invokeReflectively(target, argument0); + } + + private Object invoke2(Object target, Object argument0, Object argument1) throws IllegalAccessException { + this.requireAvailable(); + if (this.invoker2 != null) { + return this.invoker2.invoke(target, argument0, argument1); + } + return this.invokeReflectively(target, argument0, argument1); + } + + private Object invoke3(Object target, Object argument0, Object argument1, Object argument2) + throws IllegalAccessException { + this.requireAvailable(); + if (this.invoker3 != null) { + return this.invoker3.invoke(target, argument0, argument1, argument2); + } + return this.invokeReflectively(target, argument0, argument1, argument2); + } + + private Object invoke4( + Object target, + Object argument0, + Object argument1, + Object argument2, + Object argument3 + ) throws IllegalAccessException { + this.requireAvailable(); + if (this.invoker4 != null) { + return this.invoker4.invoke(target, argument0, argument1, argument2, argument3); + } + return this.invokeReflectively(target, argument0, argument1, argument2, argument3); + } + + private Object invoke5( + Object target, + Object argument0, + Object argument1, + Object argument2, + Object argument3, + Object argument4 + ) throws IllegalAccessException { + this.requireAvailable(); + if (this.invoker5 != null) { + return this.invoker5.invoke(target, argument0, argument1, argument2, argument3, argument4); + } + return this.invokeReflectively(target, argument0, argument1, argument2, argument3, argument4); + } + + private void requireAvailable() { if (this.method == null) { throw new IllegalStateException("Cannot invoke an unavailable method"); } - if (this.invoker != null) { - return this.invoker.invoke(target, args); - } + } + + private Object invokeReflectively(Object target, Object... arguments) throws IllegalAccessException { try { - return this.method.invoke(target, args); + return this.method.invoke(target, arguments); } catch (InvocationTargetException exception) { Throwable cause = exception.getCause(); if (cause instanceof RuntimeException runtimeException) { diff --git a/src/main/java/top/ellan/mahjong/table/core/MahjongTableManager.java b/src/main/java/top/ellan/mahjong/table/core/MahjongTableManager.java index eebe0ee..14e78b7 100644 --- a/src/main/java/top/ellan/mahjong/table/core/MahjongTableManager.java +++ b/src/main/java/top/ellan/mahjong/table/core/MahjongTableManager.java @@ -46,8 +46,8 @@ public final class MahjongTableManager implements Listener { private static final String ADMIN_PERMISSION = "mahjongpaper.admin"; - private static final long DUPLICATE_DISPLAY_ACTION_WINDOW_NANOS = 40_000_000L; - private static final long DUPLICATE_HAND_TILE_CLICK_WINDOW_NANOS = 40_000_000L; + private static final long DUPLICATE_DISPLAY_ACTION_WINDOW_NANOS = 150_000_000L; + private static final long DUPLICATE_HAND_TILE_CLICK_WINDOW_NANOS = 150_000_000L; private static final long RECENT_DISPLAY_ACTION_TTL_SECONDS = 60L; private static final long RECENT_HAND_TILE_CLICK_TTL_SECONDS = 60L; private static final double PERSISTED_TABLE_CLEANUP_RADIUS_XZ = 4.5D; diff --git a/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java b/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java index 210bd7d..9a48202 100644 --- a/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java +++ b/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java @@ -79,6 +79,7 @@ public final class MahjongTableSession implements TableSessionMutator, TableMemb private final TableRenderer renderer = new TableRenderer(); private final TableRenderSnapshotFactory renderSnapshotFactory = new TableRenderSnapshotFactory(); private final TableRegionFingerprintService regionFingerprintService = new TableRegionFingerprintService(); + private final SessionRenderLayoutCache renderLayoutCache = new SessionRenderLayoutCache(); private MahjongRule configuredRule; // These three fields are written on the table's region thread (during // startRound / completeRoundStartInternal / setRoundControllerInternal) @@ -1635,10 +1636,11 @@ public TableRenderSnapshot captureRenderSnapshot(long version, long cancellation } public TableRenderPrecomputeResult precomputeRender(TableRenderSnapshot snapshot) { + TableRenderLayout.LayoutPlan layout = this.renderLayoutCache.precompute(snapshot); return new TableRenderPrecomputeResult( snapshot, this.regionFingerprintService.precomputeRegionFingerprints(this, snapshot), - TableRenderLayout.precompute(snapshot) + layout ); } diff --git a/src/main/java/top/ellan/mahjong/table/core/SessionRenderLayoutCache.java b/src/main/java/top/ellan/mahjong/table/core/SessionRenderLayoutCache.java new file mode 100644 index 0000000..881156c --- /dev/null +++ b/src/main/java/top/ellan/mahjong/table/core/SessionRenderLayoutCache.java @@ -0,0 +1,109 @@ +package top.ellan.mahjong.table.core; + +import top.ellan.mahjong.model.MahjongTile; +import top.ellan.mahjong.model.MahjongVariant; +import top.ellan.mahjong.model.SeatWind; +import top.ellan.mahjong.render.layout.TableRenderLayout; +import top.ellan.mahjong.render.scene.MeldView; +import top.ellan.mahjong.render.snapshot.TableRenderSnapshot; +import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot; +import top.ellan.mahjong.riichi.model.ScoringStick; +import java.util.List; + +/** Single-entry, immutable layout cache owned by one table session. */ +final class SessionRenderLayoutCache { + private volatile Entry cached; + + TableRenderLayout.LayoutPlan precompute(TableRenderSnapshot snapshot) { + LayoutKey key = LayoutKey.from(snapshot); + Entry current = this.cached; + if (current != null && current.key().equals(key)) { + return current.layout(); + } + TableRenderLayout.LayoutPlan layout = current == null + ? TableRenderLayout.precompute(snapshot) + : TableRenderLayout.precompute(snapshot, current.snapshot(), current.layout()); + this.cached = new Entry(key, snapshot, layout); + return layout; + } + + void clear() { + this.cached = null; + } + + private record Entry(LayoutKey key, TableRenderSnapshot snapshot, TableRenderLayout.LayoutPlan layout) { + } + + private record LayoutKey( + double centerX, + double centerY, + double centerZ, + boolean started, + int remainingWallCount, + int kanCount, + int dicePoints, + int breakDicePoints, + SeatWind dealerSeat, + SeatWind openDoorSeat, + List doraIndicators, + MahjongVariant variant, + SeatLayoutKey east, + SeatLayoutKey south, + SeatLayoutKey west, + SeatLayoutKey north + ) { + private static LayoutKey from(TableRenderSnapshot snapshot) { + return new LayoutKey( + snapshot.centerX(), + snapshot.centerY(), + snapshot.centerZ(), + snapshot.started(), + snapshot.remainingWallCount(), + snapshot.kanCount(), + snapshot.dicePoints(), + snapshot.breakDicePoints(), + snapshot.dealerSeat(), + snapshot.openDoorSeat(), + snapshot.doraIndicators(), + snapshot.variant(), + SeatLayoutKey.from(snapshot.seat(SeatWind.EAST)), + SeatLayoutKey.from(snapshot.seat(SeatWind.SOUTH)), + SeatLayoutKey.from(snapshot.seat(SeatWind.WEST)), + SeatLayoutKey.from(snapshot.seat(SeatWind.NORTH)) + ); + } + } + + private record SeatLayoutKey( + SeatWind wind, + boolean occupied, + int handSize, + List selectedHandTileIndices, + int riichiDiscardIndex, + int stickLayoutCount, + List discards, + List melds, + List cornerSticks, + boolean riichi + ) { + private static SeatLayoutKey from(TableSeatRenderSnapshot seat) { + if (seat.playerId() == null) { + return new SeatLayoutKey( + seat.wind(), false, 0, List.of(), -1, 0, List.of(), List.of(), List.of(), false + ); + } + return new SeatLayoutKey( + seat.wind(), + true, + seat.hand().size(), + seat.selectedHandTileIndices(), + seat.riichiDiscardIndex(), + seat.stickLayoutCount(), + seat.discards(), + seat.melds(), + seat.cornerSticks(), + seat.riichi() + ); + } + } +} diff --git a/src/main/java/top/ellan/mahjong/table/core/TableEventCoordinator.java b/src/main/java/top/ellan/mahjong/table/core/TableEventCoordinator.java index f1e6304..b5a86ca 100644 --- a/src/main/java/top/ellan/mahjong/table/core/TableEventCoordinator.java +++ b/src/main/java/top/ellan/mahjong/table/core/TableEventCoordinator.java @@ -41,13 +41,13 @@ import org.bukkit.util.RayTraceResult; final class TableEventCoordinator implements Listener { - private static final long DUPLICATE_DISPLAY_ACTION_WINDOW_NANOS = 40_000_000L; + private static final long DUPLICATE_DISPLAY_ACTION_WINDOW_NANOS = 150_000_000L; private static final long OVERHEAD_EXIT_GUARD_SECONDS = 2L; private static final double FLAT_INTERACTION_MAX_DISTANCE = 6.0D; private static final double FLAT_INTERACTION_BLOCK_EPSILON = 0.05D; /** * Bounded TTL on the per-player recent-action cache. The dedup window is - * 40ms (DUPLICATE_DISPLAY_ACTION_WINDOW_NANOS), so anything older than a + * 150ms (DUPLICATE_DISPLAY_ACTION_WINDOW_NANOS), so anything older than a * few seconds is by definition not a duplicate of a fresh click — but the * previous bare ConcurrentHashMap only evicted on PlayerQuitEvent, which * on Folia can occasionally be missed when the player's entity region diff --git a/src/main/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactory.java b/src/main/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactory.java index 1229838..fc282cb 100644 --- a/src/main/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactory.java +++ b/src/main/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactory.java @@ -45,7 +45,7 @@ public Component createStateSummary(Player player) { Locale locale = this.session.plugin().messages().resolveLocale(player); UUID viewerId = player.getUniqueId(); PlayerActionSnapshot actionSnapshot = this.actionSnapshotFactory.capture(viewerId); - ViewerSummarySnapshot summary = this.captureViewerSummarySnapshot(locale, viewerId, actionSnapshot, false); + ViewerSummarySnapshot summary = this.captureViewerSummarySnapshot(locale, viewerId, actionSnapshot, true); return this.session.plugin().messages().render( player, "command.rule_summary", diff --git a/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java b/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java index b92da00..10a91d3 100644 --- a/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java +++ b/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java @@ -62,6 +62,9 @@ synchronized void replace( } Map previous = this.regions.getOrDefault(regionKey, Map.of()); + if (this.canReuseRegion(previous, interactionsByViewer)) { + return; + } Map next = new LinkedHashMap<>(); List pendingSpawns = new ArrayList<>(); try { @@ -72,23 +75,21 @@ synchronized void replace( if (viewerId == null || interactions == null || interactions.isEmpty()) { continue; } - Player viewer = this.session.onlinePlayer(viewerId); - if (viewer == null || !viewer.isOnline()) { - continue; - } ActiveProxies current = previous.get(viewerId); - if (this.canReuse(viewerId, viewer, current, interactions)) { + if (current != null + && current.viewer().isOnline() + && this.canReuse(viewerId, current, interactions)) { next.put(viewerId, current); continue; } + Player viewer = this.session.onlinePlayer(viewerId); + if (viewer == null || !viewer.isOnline()) { + continue; + } List geometry = interactionGeometry(interactions); List proxies = this.backend.create(viewer, interactions); if (!proxies.isEmpty()) { - ActiveProxies created = new ActiveProxies( - viewer, - List.copyOf(proxies), - geometry - ); + ActiveProxies created = new ActiveProxies(viewer, List.copyOf(proxies), geometry); next.put(viewerId, created); pendingSpawns.add(new PendingSpawn(viewerId, created)); } @@ -132,14 +133,35 @@ synchronized void replace( } } + private boolean canReuseRegion( + Map previous, + Map> interactionsByViewer + ) { + int reusableViewers = 0; + for (Map.Entry> entry + : interactionsByViewer.entrySet()) { + UUID viewerId = entry.getKey(); + List interactions = entry.getValue(); + if (viewerId == null || interactions == null || interactions.isEmpty()) { + continue; + } + ActiveProxies active = previous.get(viewerId); + if (active == null + || !active.viewer().isOnline() + || !this.canReuse(viewerId, active, interactions)) { + return false; + } + reusableViewers++; + } + return reusableViewers == previous.size(); + } + private boolean canReuse( UUID viewerId, - Player viewer, ActiveProxies active, List interactions ) { if (active == null - || active.viewer() != viewer || !sameGeometry(active.geometry(), interactions) || active.proxies().isEmpty()) { return false; diff --git a/src/main/java/top/ellan/mahjong/table/render/TableRenderSnapshotFactory.java b/src/main/java/top/ellan/mahjong/table/render/TableRenderSnapshotFactory.java index f12695d..41cac27 100644 --- a/src/main/java/top/ellan/mahjong/table/render/TableRenderSnapshotFactory.java +++ b/src/main/java/top/ellan/mahjong/table/render/TableRenderSnapshotFactory.java @@ -17,52 +17,19 @@ import org.bukkit.entity.Player; public final class TableRenderSnapshotFactory { + private static final SeatWind[] SEAT_WINDS = SeatWind.values(); + + private ViewerMembershipPlan cachedViewerMembershipPlan; + public TableRenderSnapshot create(TableRenderSubject session, long version, long cancellationNonce) { Location tableCenter = session.center(); boolean started = session.isStarted(); - List serializedOnlineViewerIds = session.viewers().stream() - .map(Player::getUniqueId) - .distinct() - .map(viewerId -> new SerializedViewerId(viewerId, viewerId.toString())) - .sorted(Comparator.comparing(SerializedViewerId::serializedId)) - .toList(); - List onlineViewerIds = serializedOnlineViewerIds.stream() - .map(SerializedViewerId::id) - .toList(); - Set onlineViewerIdSet = new HashSet<>(onlineViewerIds); - SeatWind[] seatWinds = SeatWind.values(); - EnumMap seatPlayerIds = new EnumMap<>(SeatWind.class); - for (SeatWind wind : seatWinds) { - seatPlayerIds.put(wind, session.playerAt(wind)); - } - Map viewerMembershipSignatures = new HashMap<>(); - Map> viewerIdsExcluding = new HashMap<>(); - for (UUID playerId : seatPlayerIds.values()) { - if (viewerMembershipSignatures.containsKey(playerId) - || playerId != null && !onlineViewerIdSet.contains(playerId)) { - continue; - } - viewerMembershipSignatures.put( - playerId, - this.viewerMembershipSignature(serializedOnlineViewerIds, playerId) - ); - viewerIdsExcluding.put( - playerId, - playerId == null ? List.copyOf(onlineViewerIds) : this.viewerIdsExcluding(onlineViewerIds, playerId) - ); - } + ViewerMembershipPlan viewerMembershipPlan = this.resolveViewerMembershipPlan(session); EnumMap seats = new EnumMap<>(SeatWind.class); - for (SeatWind wind : seatWinds) { + for (SeatWind wind : SEAT_WINDS) { seats.put( wind, - this.captureSeatSnapshot( - session, - wind, - seatPlayerIds.get(wind), - onlineViewerIdSet, - viewerMembershipSignatures, - viewerIdsExcluding - ) + this.captureSeatSnapshot(session, wind, viewerMembershipPlan.seat(wind)) ); } return new TableRenderSnapshot( @@ -127,14 +94,72 @@ public TableSeatRenderSnapshot createPrivateHandSeat(TableRenderSubject session, ); } + private ViewerMembershipPlan resolveViewerMembershipPlan(TableRenderSubject session) { + List viewers = session.viewers(); + ViewerMembershipPlan cached = this.cachedViewerMembershipPlan; + if (cached != null && cached.matches(session, viewers)) { + return cached; + } + ViewerMembershipPlan captured = this.captureViewerMembershipPlan(session, viewers); + this.cachedViewerMembershipPlan = captured; + return captured; + } + + private ViewerMembershipPlan captureViewerMembershipPlan(TableRenderSubject session, List viewers) { + List sourceViewerIds = viewers.stream() + .map(Player::getUniqueId) + .toList(); + List serializedOnlineViewerIds = sourceViewerIds.stream() + .distinct() + .map(viewerId -> new SerializedViewerId(viewerId, viewerId.toString())) + .sorted(Comparator.comparing(SerializedViewerId::serializedId)) + .toList(); + List onlineViewerIds = serializedOnlineViewerIds.stream() + .map(SerializedViewerId::id) + .toList(); + Set onlineViewerIdSet = new HashSet<>(onlineViewerIds); + Map membershipsByPlayerId = new HashMap<>(); + EnumMap seatMemberships = new EnumMap<>(SeatWind.class); + for (SeatWind wind : SEAT_WINDS) { + UUID playerId = session.playerAt(wind); + SeatViewerMembership membership = membershipsByPlayerId.get(playerId); + if (membership == null && !membershipsByPlayerId.containsKey(playerId)) { + membership = this.captureSeatViewerMembership( + playerId, + serializedOnlineViewerIds, + onlineViewerIds, + onlineViewerIdSet + ); + membershipsByPlayerId.put(playerId, membership); + } + seatMemberships.put(wind, membership); + } + return new ViewerMembershipPlan(session, sourceViewerIds, seatMemberships); + } + + private SeatViewerMembership captureSeatViewerMembership( + UUID playerId, + List serializedOnlineViewerIds, + List onlineViewerIds, + Set onlineViewerIdSet + ) { + if (playerId != null && !onlineViewerIdSet.contains(playerId)) { + return new SeatViewerMembership(playerId, false, "", List.of()); + } + return new SeatViewerMembership( + playerId, + playerId != null, + this.viewerMembershipSignature(serializedOnlineViewerIds, playerId), + playerId == null ? List.copyOf(onlineViewerIds) : this.viewerIdsExcluding(onlineViewerIds, playerId) + ); + } + private TableSeatRenderSnapshot captureSeatSnapshot( TableRenderSubject session, SeatWind wind, - UUID playerId, - Set onlineViewerIdSet, - Map viewerMembershipSignatures, - Map> viewerIdsExcluding + SeatViewerMembership viewerMembership ) { + UUID playerId = viewerMembership.playerId(); boolean occupied = playerId != null; return new TableSeatRenderSnapshot( wind, @@ -145,13 +170,13 @@ private TableSeatRenderSnapshot captureSeatSnapshot( occupied && session.isRiichi(playerId), occupied && session.isReady(playerId), occupied && session.isQueuedToLeave(playerId), - occupied && onlineViewerIdSet.contains(playerId), - viewerMembershipSignatures.getOrDefault(playerId, ""), + viewerMembership.online(), + viewerMembership.signature(), occupied ? session.selectedHandTileIndex(playerId) : -1, occupied ? session.selectedHandTileIndices(playerId) : List.of(), occupied ? session.riichiDiscardIndex(playerId) : -1, session.stickLayoutCount(wind), - viewerIdsExcluding.getOrDefault(playerId, List.of()), + viewerMembership.viewerIdsExcluding(), occupied ? session.hand(playerId) : List.of(), occupied ? session.discards(playerId) : List.of(), occupied ? session.fuuro(playerId) : List.of(), @@ -176,5 +201,39 @@ private List viewerIdsExcluding(List onlineViewerIds, UUID excludedP .toList(); } + private record ViewerMembershipPlan( + TableRenderSubject subject, + List sourceViewerIds, + EnumMap seatMemberships + ) { + private boolean matches(TableRenderSubject session, List viewers) { + if (this.subject != session || this.sourceViewerIds.size() != viewers.size()) { + return false; + } + for (SeatWind wind : SEAT_WINDS) { + if (!Objects.equals(this.seat(wind).playerId(), session.playerAt(wind))) { + return false; + } + } + for (int index = 0; index < this.sourceViewerIds.size(); index++) { + if (!this.sourceViewerIds.get(index).equals(viewers.get(index).getUniqueId())) { + return false; + } + } + return true; + } + + private SeatViewerMembership seat(SeatWind wind) { + return this.seatMemberships.get(wind); + } + } + + private record SeatViewerMembership( + UUID playerId, + boolean online, + String signature, + List viewerIdsExcluding + ) {} + private record SerializedViewerId(UUID id, String serializedId) {} } diff --git a/src/perfTest/java/top/ellan/mahjong/riichi/RiichiReactionBenchmark.java b/src/perfTest/java/top/ellan/mahjong/riichi/RiichiReactionBenchmark.java new file mode 100644 index 0000000..83a2180 --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/riichi/RiichiReactionBenchmark.java @@ -0,0 +1,94 @@ +package top.ellan.mahjong.riichi; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import top.ellan.mahjong.riichi.model.MahjongTile; +import top.ellan.mahjong.riichi.model.TileInstance; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class RiichiReactionBenchmark { + @Benchmark + public ReactionOptions structurallyImpossibleCall(NoCallState state) { + return state.player.reactionOptionsFor(state.discard, false, false); + } + + @Benchmark + public ReactionOptions structurallyImpossibleRonOnly(NoCallState state) { + return state.player.reactionOptionsFor(state.discard, false, true); + } + + @Benchmark + public ReactionOptions firstPossiblePonAnalysis(PossibleCallState state) { + return state.player.reactionOptionsFor(state.discard, false, false); + } + + @State(Scope.Thread) + public static class NoCallState { + private RiichiPlayerState player; + private TileInstance discard; + private Map furoReactions; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + this.player = player(List.of( + MahjongTile.M1, MahjongTile.M4, MahjongTile.M7, + MahjongTile.P1, MahjongTile.P4, MahjongTile.P7, + MahjongTile.S1, MahjongTile.S4, MahjongTile.S7, + MahjongTile.SOUTH, MahjongTile.WEST, MahjongTile.WHITE_DRAGON, MahjongTile.GREEN_DRAGON + )); + this.discard = tile(100, MahjongTile.EAST); + if (this.player.reactionOptionsFor(this.discard, false, false) != null) { + throw new IllegalStateException("No-call reaction fixture changed"); + } + Field cacheField = RiichiPlayerAnalysisState.class.getDeclaredField("cachedFuroReactions"); + cacheField.setAccessible(true); + this.furoReactions = (Map) cacheField.get(this.player); + } + + @Setup(Level.Invocation) + public void clearFuroReaction() { + this.furoReactions.clear(); + } + } + + @State(Scope.Thread) + public static class PossibleCallState { + private RiichiPlayerState player; + private TileInstance discard; + + @Setup(Level.Invocation) + public void setUp() { + this.player = player(List.of( + MahjongTile.M5_RED, MahjongTile.M5, MahjongTile.M1, MahjongTile.M9, + MahjongTile.P1, MahjongTile.P9, MahjongTile.S1, MahjongTile.S9, + MahjongTile.EAST, MahjongTile.SOUTH, MahjongTile.WEST, + MahjongTile.WHITE_DRAGON, MahjongTile.GREEN_DRAGON + )); + this.discard = tile(200, MahjongTile.M5); + } + } + + private static RiichiPlayerState player(List hand) { + RiichiPlayerState player = new RiichiPlayerState("benchmark", "benchmark", false); + for (int index = 0; index < hand.size(); index++) { + player.getHands().add(tile(index, hand.get(index))); + } + return player; + } + + private static TileInstance tile(int id, MahjongTile tile) { + return new TileInstance(new UUID(0L, id + 1L), tile); + } +} diff --git a/src/perfTest/java/top/ellan/mahjong/table/core/SessionRenderLayoutCacheBenchmark.java b/src/perfTest/java/top/ellan/mahjong/table/core/SessionRenderLayoutCacheBenchmark.java new file mode 100644 index 0000000..8a5c26f --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/table/core/SessionRenderLayoutCacheBenchmark.java @@ -0,0 +1,208 @@ +package top.ellan.mahjong.table.core; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.EnumMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import top.ellan.mahjong.model.MahjongTile; +import top.ellan.mahjong.model.MahjongVariant; +import top.ellan.mahjong.model.SeatWind; +import top.ellan.mahjong.render.layout.TableRenderLayout; +import top.ellan.mahjong.render.scene.MeldView; +import top.ellan.mahjong.render.snapshot.TableRenderSnapshot; +import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot; +import top.ellan.mahjong.riichi.model.ScoringStick; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class SessionRenderLayoutCacheBenchmark { + @Benchmark + public TableRenderLayout.LayoutPlan stableSessionCacheHit(HitState state) { + state.alternate = !state.alternate; + return state.precompute(state.alternate ? state.first : state.second); + } + + @Benchmark + public TableRenderLayout.LayoutPlan representativeSeatComponentMiss(SeatMissState state) { + state.alternate = !state.alternate; + return state.precompute(state.alternate ? state.first : state.second); + } + + @Benchmark + public TableRenderLayout.LayoutPlan representativeWallComponentMiss(WallMissState state) { + state.alternate = !state.alternate; + return state.precompute(state.alternate ? state.first : state.second); + } + + private abstract static class CacheState { + private MethodHandle precompute; + + final void initializePrecompute() throws ReflectiveOperationException { + try { + Class cacheType = Class.forName("top.ellan.mahjong.table.core.SessionRenderLayoutCache"); + Constructor constructor = cacheType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object cache = constructor.newInstance(); + Method method = cacheType.getDeclaredMethod("precompute", TableRenderSnapshot.class); + method.setAccessible(true); + this.precompute = MethodHandles.lookup() + .unreflect(method) + .bindTo(cache) + .asType(MethodType.methodType(TableRenderLayout.LayoutPlan.class, TableRenderSnapshot.class)); + } catch (ClassNotFoundException ignored) { + this.precompute = MethodHandles.lookup().findStatic( + TableRenderLayout.class, + "precompute", + MethodType.methodType(TableRenderLayout.LayoutPlan.class, TableRenderSnapshot.class) + ); + } catch (NoSuchMethodException | IllegalAccessException exception) { + throw new ReflectiveOperationException(exception); + } + } + + final TableRenderLayout.LayoutPlan precompute(TableRenderSnapshot snapshot) { + try { + return (TableRenderLayout.LayoutPlan) this.precompute.invokeExact(snapshot); + } catch (Throwable throwable) { + throw new IllegalStateException("Unable to invoke the layout precompute benchmark target", throwable); + } + } + } + + @State(Scope.Thread) + public static class HitState extends CacheState { + private TableRenderSnapshot first; + private TableRenderSnapshot second; + private boolean alternate; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + this.initializePrecompute(); + this.first = snapshot(1L); + this.second = snapshot(2L); + this.precompute(this.first); + } + } + + @State(Scope.Thread) + public static class SeatMissState extends CacheState { + private TableRenderSnapshot first; + private TableRenderSnapshot second; + private boolean alternate; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + this.initializePrecompute(); + this.first = snapshot(1L, 70, List.of(4)); + this.second = snapshot(2L, 70, List.of(3)); + this.precompute(this.first); + } + } + + @State(Scope.Thread) + public static class WallMissState extends CacheState { + private TableRenderSnapshot first; + private TableRenderSnapshot second; + private boolean alternate; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + this.initializePrecompute(); + this.first = snapshot(1L, 70, List.of(4)); + this.second = snapshot(2L, 69, List.of(4)); + this.precompute(this.first); + } + } + + private static TableRenderSnapshot snapshot(long version) { + return snapshot(version, 70, List.of(4)); + } + + private static TableRenderSnapshot snapshot(long version, int remainingWallCount, List selectedIndices) { + List hand = List.of( + MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, MahjongTile.M4, MahjongTile.M5_RED, + MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, MahjongTile.S6, MahjongTile.S7, + MahjongTile.S8, MahjongTile.RED_DRAGON, MahjongTile.RED_DRAGON + ); + List discards = List.of( + MahjongTile.EAST, MahjongTile.SOUTH, MahjongTile.WEST, MahjongTile.NORTH, + MahjongTile.WHITE_DRAGON, MahjongTile.GREEN_DRAGON, MahjongTile.RED_DRAGON, + MahjongTile.M1, MahjongTile.M9, MahjongTile.P1, MahjongTile.P9, + MahjongTile.S1, MahjongTile.S9, MahjongTile.M5_RED, MahjongTile.P5_RED, + MahjongTile.S5_RED, MahjongTile.M4, MahjongTile.P6 + ); + MeldView meld = new MeldView( + List.of(MahjongTile.P2, MahjongTile.P2, MahjongTile.P2), + List.of(false, false, false), + 1, + 90, + MahjongTile.P2 + ); + EnumMap seats = new EnumMap<>(SeatWind.class); + for (SeatWind wind : SeatWind.values()) { + seats.put(wind, new TableSeatRenderSnapshot( + wind, + new UUID(0L, wind.index() + 1L), + wind.name(), + wind.name(), + 25_000, + wind == SeatWind.EAST, + true, + false, + true, + "viewer-" + wind.name(), + selectedIndices.get(0), + selectedIndices, + wind == SeatWind.EAST ? 5 : -1, + wind.index() + 1, + List.of(), + hand, + discards, + List.of(meld), + List.of(ScoringStick.P1000), + List.of(ScoringStick.P100) + )); + } + return new TableRenderSnapshot( + version, + 0L, + "benchmark_world", + 12.75D, + 64.0D, + -7.25D, + true, + false, + false, + remainingWallCount, + 1, + 7, + 5, + 1, + 0, + SeatWind.EAST, + SeatWind.SOUTH, + SeatWind.WEST, + "waiting", + "rules", + "center-" + version, + null, + null, + List.of(MahjongTile.M1), + MahjongVariant.RIICHI, + seats + ); + } +} diff --git a/src/perfTest/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluatorBenchmark.java b/src/perfTest/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluatorBenchmark.java new file mode 100644 index 0000000..29448b1 --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluatorBenchmark.java @@ -0,0 +1,56 @@ +package top.ellan.mahjong.table.core.round; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import top.ellan.mahjong.model.MahjongTile; + +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class SichuanHuEvaluatorBenchmark { + private List waitingHand; + private List fixedMeldHand; + + @Setup(Level.Trial) + public void setUp() { + this.waitingHand = List.of( + MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, + MahjongTile.M4, MahjongTile.M5_RED, MahjongTile.M6, + MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, + MahjongTile.S7, MahjongTile.S8, MahjongTile.S9, MahjongTile.P5 + ); + this.fixedMeldHand = List.of( + MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, + MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, + MahjongTile.S9, MahjongTile.S9, MahjongTile.S5_RED, MahjongTile.S5 + ); + if (!SichuanHuEvaluator.waitingTiles(this.waitingHand, 0) + .equals(List.of(MahjongTile.P2, MahjongTile.P5)) + || !SichuanHuEvaluator.canWin(this.fixedMeldHand, MahjongTile.S5, 1)) { + throw new IllegalStateException("Sichuan benchmark fixture changed"); + } + } + + @Benchmark + public List waitingTilesStandardHand() { + return SichuanHuEvaluator.waitingTiles(this.waitingHand, 0); + } + + @Benchmark + public SichuanHuEvaluator.Result evaluateStandardWin() { + return SichuanHuEvaluator.evaluate(this.waitingHand, MahjongTile.P5_RED, 0); + } + + @Benchmark + public boolean canWinWithFixedMeld() { + return SichuanHuEvaluator.canWin(this.fixedMeldHand, MahjongTile.S5, 1); + } +} diff --git a/src/perfTest/java/top/ellan/mahjong/table/render/RayProxyLifecycleBenchmark.java b/src/perfTest/java/top/ellan/mahjong/table/render/RayProxyLifecycleBenchmark.java new file mode 100644 index 0000000..88a9ad2 --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/table/render/RayProxyLifecycleBenchmark.java @@ -0,0 +1,210 @@ +package top.ellan.mahjong.table.render; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.bukkit.entity.Player; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import top.ellan.mahjong.render.display.DisplayClickAction; +import top.ellan.mahjong.render.display.DisplayInteractionRayRegistry; +import top.ellan.mahjong.table.core.TableSessionContext; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class RayProxyLifecycleBenchmark { + @Benchmark + public int initialCreate32Viewers(InitialCreateState state) { + state.coordinator.replace("viewer-actions", state.interactions); + return state.coordinator.entityCount(); + } + + @Benchmark + public int unchanged32Viewers(LifecycleState state) { + state.coordinator.replace("viewer-actions", state.geometryA); + return state.coordinator.entityCount(); + } + + @Benchmark + public int changedGeometry32Viewers(LifecycleState state) { + state.geometryBActive = !state.geometryBActive; + state.coordinator.replace("viewer-actions", state.geometryBActive ? state.geometryB : state.geometryA); + return state.coordinator.entityCount(); + } + + @Benchmark + public int oneViewerChurn(LifecycleState state) { + state.lastViewerActive = !state.lastViewerActive; + state.coordinator.replace("viewer-actions", state.lastViewerActive ? state.geometryA : state.geometryA31); + return state.coordinator.entityCount(); + } + + @State(Scope.Thread) + public static class InitialCreateState { + private SparrowRayInteractionProxyCoordinator coordinator; + private Map> interactions; + + @Setup(Level.Invocation) + public void setUp() { + Fixture fixture = fixture(); + this.coordinator = fixture.coordinator; + this.interactions = fixture.geometryA; + } + + @TearDown(Level.Invocation) + public void tearDown() { + this.coordinator.shutdown(); + } + } + + @State(Scope.Thread) + public static class LifecycleState { + private SparrowRayInteractionProxyCoordinator coordinator; + private Map> geometryA; + private Map> geometryB; + private Map> geometryA31; + private boolean geometryBActive; + private boolean lastViewerActive = true; + + @Setup(Level.Trial) + public void setUp() { + Fixture fixture = fixture(); + this.coordinator = fixture.coordinator; + this.geometryA = fixture.geometryA; + this.geometryB = fixture.geometryB; + this.geometryA31 = withoutLast(this.geometryA); + this.coordinator.replace("viewer-actions", this.geometryA); + if (this.coordinator.entityCount() != 32) { + throw new IllegalStateException("Ray lifecycle fixture changed"); + } + } + + @TearDown(Level.Trial) + public void tearDown() { + this.coordinator.shutdown(); + } + } + + private static Fixture fixture() { + Map players = players(); + TableSessionContext session = session(players); + return new Fixture( + new SparrowRayInteractionProxyCoordinator(session, new CountingBackend()), + interactions(players.keySet(), 0.0D), + interactions(players.keySet(), 0.5D) + ); + } + + private static Map> interactions( + Iterable viewers, + double centerX + ) { + Map> result = new LinkedHashMap<>(); + UUID worldId = new UUID(0x52415950524F5859L, 1L); + for (UUID viewerId : viewers) { + result.put(viewerId, List.of(new DisplayInteractionRayRegistry.RayInteraction( + worldId, centerX, 65.0D, 0.0D, 1.0D, 0.0D, + 0.24F, 0.90F, 0.075F, + DisplayClickAction.playerCommand("perf-ray", viewerId, "noop") + ))); + } + return Map.copyOf(result); + } + + private static Map> withoutLast( + Map> source + ) { + Map> result = new LinkedHashMap<>(source); + UUID last = null; + for (UUID viewerId : result.keySet()) last = viewerId; + result.remove(last); + return Map.copyOf(result); + } + + private static Map players() { + Map players = new LinkedHashMap<>(); + for (int index = 0; index < 32; index++) { + UUID id = new UUID(0x5241595649455700L, index + 1L); + InvocationHandler handler = (proxy, method, args) -> switch (method.getName()) { + case "getUniqueId" -> id; + case "isOnline" -> true; + case "hashCode" -> id.hashCode(); + case "equals" -> proxy == args[0]; + case "toString" -> "RayViewer[" + id + "]"; + default -> defaultValue(method); + }; + players.put(id, (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class[] {Player.class}, handler)); + } + return players; + } + + private static TableSessionContext session(Map players) { + InvocationHandler handler = (proxy, method, args) -> switch (method.getName()) { + case "id" -> "perf-ray-lifecycle"; + case "onlinePlayer" -> players.get(args[0]); + case "runForViewer" -> { ((Runnable) args[1]).run(); yield null; } + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + case "toString" -> "RayLifecycleSession"; + default -> defaultValue(method); + }; + return (TableSessionContext) Proxy.newProxyInstance( + TableSessionContext.class.getClassLoader(), new Class[] {TableSessionContext.class}, handler + ); + } + + private static Object defaultValue(Method method) { + Class type = method.getReturnType(); + 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; + if (type == double.class) return 0.0D; + return null; + } + + private record Fixture( + SparrowRayInteractionProxyCoordinator coordinator, + Map> geometryA, + Map> geometryB + ) {} + + private static final class CountingBackend implements SparrowRayInteractionProxyCoordinator.Backend { + private int nextEntityId = 1_000_000; + + @Override public boolean available() { return true; } + + @Override + public List create( + Player viewer, + List interactions + ) { + List proxies = new ArrayList<>(interactions.size()); + for (int index = 0; index < interactions.size(); index++) { + int entityId = ++this.nextEntityId; + proxies.add(() -> entityId); + } + return List.copyOf(proxies); + } + + @Override public void spawn(Player viewer, List proxies) {} + @Override public void destroy(Player viewer, List proxies) {} + } +} diff --git a/src/perfTest/java/top/ellan/mahjong/table/render/RegionRenderApplyBenchmark.java b/src/perfTest/java/top/ellan/mahjong/table/render/RegionRenderApplyBenchmark.java new file mode 100644 index 0000000..1467324 --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/table/render/RegionRenderApplyBenchmark.java @@ -0,0 +1,292 @@ +package top.ellan.mahjong.table.render; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.bukkit.Location; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import top.ellan.mahjong.config.PluginSettings; +import top.ellan.mahjong.model.MahjongTile; +import top.ellan.mahjong.model.MahjongVariant; +import top.ellan.mahjong.model.SeatWind; +import top.ellan.mahjong.render.TableRenderSubject; +import top.ellan.mahjong.render.layout.TableRenderLayout; +import top.ellan.mahjong.render.scene.MeldView; +import top.ellan.mahjong.render.scene.TableRenderer; +import top.ellan.mahjong.render.snapshot.TableRenderPrecomputeResult; +import top.ellan.mahjong.render.snapshot.TableRenderSnapshot; +import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot; +import top.ellan.mahjong.riichi.model.ScoringStick; +import top.ellan.mahjong.table.core.TableSessionContext; + +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class RegionRenderApplyBenchmark { + private TableRegionDisplayCoordinator planningCoordinator; + private TableRegionDisplayCoordinator stableCoordinator; + private TableRenderPrecomputeResult planningResult; + private TableRenderPrecomputeResult stableResult; + private MethodHandle completeFingerprintPrecompute; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + TableSessionContext session = session(); + TableRenderSnapshot representativeSnapshot = snapshot(true); + TableRenderLayout.LayoutPlan representativeLayout = TableRenderLayout.precompute(representativeSnapshot); + this.completeFingerprintPrecompute = completeFingerprintHandle( + session, representativeSnapshot, representativeLayout + ); + this.planningResult = new TableRenderPrecomputeResult( + representativeSnapshot, + fingerprints(session, representativeSnapshot, representativeLayout), + representativeLayout + ); + this.planningCoordinator = new TableRegionDisplayCoordinator(session, new TableRegionFingerprintService(), 0, 0); + + TableRenderSnapshot stableSnapshot = snapshot(false); + TableRenderLayout.LayoutPlan stableLayout = TableRenderLayout.precompute(stableSnapshot); + Map fingerprints = fingerprints(session, stableSnapshot, stableLayout); + this.stableResult = new TableRenderPrecomputeResult(stableSnapshot, fingerprints, stableLayout); + this.stableCoordinator = new TableRegionDisplayCoordinator(session, new TableRegionFingerprintService()); + seedStableState(this.stableCoordinator, fingerprints); + int planned = plannedRegionCount(representativeSnapshot, representativeLayout); + if (planned != 327 + || !this.planningCoordinator.applyRenderPrecompute(this.planningResult) + || this.stableCoordinator.applyRenderPrecompute(this.stableResult)) { + throw new IllegalStateException("Region benchmark contract changed: planned=" + planned); + } + } + + @Benchmark + public boolean planAndDeferRepresentativeTableRegions() { + return this.planningCoordinator.applyRenderPrecompute(this.planningResult); + } + + @Benchmark + public boolean applyStableStartedTable() { + return this.stableCoordinator.applyRenderPrecompute(this.stableResult); + } + + @Benchmark + @SuppressWarnings("unchecked") + public Map precomputeCompleteStartedTableRegions() throws Throwable { + return (Map) this.completeFingerprintPrecompute.invokeExact(); + } + + private static MethodHandle completeFingerprintHandle( + TableSessionContext session, + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan layout + ) throws ReflectiveOperationException { + TableRegionFingerprintService service = new TableRegionFingerprintService(); + try { + Method method = TableRegionFingerprintService.class.getDeclaredMethod( + "precomputeRegionFingerprints", + TableRenderSubject.class, + TableRenderSnapshot.class, + TableRenderLayout.LayoutPlan.class + ); + return MethodHandles.lookup() + .unreflect(method) + .bindTo(service) + .bindTo(session) + .bindTo(snapshot) + .bindTo(layout) + .asType(MethodType.methodType(Map.class)); + } catch (NoSuchMethodException ignored) { + try { + return MethodHandles.lookup() + .findStatic( + RegionRenderApplyBenchmark.class, + "fingerprints", + MethodType.methodType( + Map.class, + TableSessionContext.class, + TableRenderSnapshot.class, + TableRenderLayout.LayoutPlan.class + ) + ) + .bindTo(session) + .bindTo(snapshot) + .bindTo(layout); + } catch (NoSuchMethodException | IllegalAccessException exception) { + throw new ReflectiveOperationException(exception); + } + } catch (IllegalAccessException exception) { + throw new ReflectiveOperationException(exception); + } + } + + private static Map fingerprints( + TableSessionContext session, + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan layout + ) { + TableRegionFingerprintService service = new TableRegionFingerprintService(); + Map values = new HashMap<>(service.precomputeRegionFingerprints(session, snapshot)); + for (SeatWind wind : SeatWind.values()) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + TableRenderLayout.SeatLayoutPlan seatPlan = layout.seat(wind); + for (int index = 0; index < seat.hand().size(); index++) { + values.put( + handPublicRegionKey(wind, index), + service.handPublicTileFingerprint(snapshot, seat, seatPlan, index) + ); + values.put( + handPrivateRegionKey(wind, index), + service.handPrivateTileFingerprint(seat, seatPlan, index) + ); + } + for (int index = 0; index < seatPlan.discardPlacements().size(); index++) { + values.put(discardRegionKey(wind, index), service.discardTileFingerprint(seat, seatPlan, index)); + } + for (int index = 0; index < seatPlan.meldPlacements().size(); index++) { + values.put(meldRegionKey(wind, index), service.meldTileFingerprint(seat, seatPlan, index)); + } + } + for (int index = 0; index < layout.wallTiles().size(); index++) { + values.put(wallRegionKey(index), service.wallTileFingerprint(layout, index)); + } + return Map.copyOf(values); + } + + private static int plannedRegionCount(TableRenderSnapshot snapshot, TableRenderLayout.LayoutPlan layout) { + int count = 3 + layout.wallTiles().size(); + for (SeatWind wind : SeatWind.values()) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + TableRenderLayout.SeatLayoutPlan seatPlan = layout.seat(wind); + if (seat.playerId() == null) { + count += 3; + continue; + } + count += 3 + seat.hand().size() * 2 + seatPlan.discardPlacements().size() + seatPlan.meldPlacements().size(); + } + return count; + } + + @SuppressWarnings("unchecked") + private static void seedStableState(TableRegionDisplayCoordinator coordinator, Map fingerprints) + throws ReflectiveOperationException { + Field fingerprintField = TableRegionDisplayCoordinator.class.getDeclaredField("regionFingerprints"); + fingerprintField.setAccessible(true); + ((Map) fingerprintField.get(coordinator)).putAll(fingerprints); + Field displayField = TableRegionDisplayCoordinator.class.getDeclaredField("regionDisplays"); + displayField.setAccessible(true); + Map> displays = + (Map>) displayField.get(coordinator); + displays.put("table", List.of()); + for (SeatWind wind : SeatWind.values()) { + displays.put(seatRegionKey("visual", wind), List.of()); + } + } + + private static String seatRegionKey(String region, SeatWind wind) { + return region + ":" + wind.name(); + } + + private static String discardRegionKey(SeatWind wind, int index) { + return seatRegionKey("discards-" + index, wind); + } + + private static String handPublicRegionKey(SeatWind wind, int index) { + return seatRegionKey("hand-public-" + index, wind); + } + + private static String handPrivateRegionKey(SeatWind wind, int index) { + return seatRegionKey("hand-private-" + index, wind); + } + + private static String meldRegionKey(SeatWind wind, int index) { + return seatRegionKey("melds-" + index, wind); + } + + private static String wallRegionKey(int index) { + return "wall-" + index; + } + + private static TableSessionContext session() { + TableRenderer renderer = new TableRenderer(); + InvocationHandler handler = (proxy, method, arguments) -> switch (method.getName()) { + case "id" -> "perf-region-apply"; + case "plugin" -> null; + case "renderer" -> renderer; + case "center" -> new Location(null, 12.75D, 64.0D, -7.25D); + case "currentVariant" -> MahjongVariant.RIICHI; + case "settings" -> PluginSettings.defaults(); + case "toString" -> "RegionApplySession"; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + default -> defaultValue(method); + }; + return (TableSessionContext) Proxy.newProxyInstance( + TableSessionContext.class.getClassLoader(), new Class[] {TableSessionContext.class}, handler + ); + } + + private static TableRenderSnapshot snapshot(boolean withHands) { + List hand = withHands + ? List.of( + MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, MahjongTile.M4, MahjongTile.M5_RED, + MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, MahjongTile.S6, MahjongTile.S7, MahjongTile.RED_DRAGON + ) + : List.of(); + List discards = List.of( + MahjongTile.EAST, MahjongTile.SOUTH, MahjongTile.WEST, MahjongTile.NORTH, + MahjongTile.WHITE_DRAGON, MahjongTile.GREEN_DRAGON, MahjongTile.RED_DRAGON, + MahjongTile.M1, MahjongTile.M9, MahjongTile.P1, MahjongTile.P9, + MahjongTile.S1, MahjongTile.S9, MahjongTile.M5_RED, MahjongTile.P5_RED, + MahjongTile.S5_RED, MahjongTile.M4, MahjongTile.P6 + ); + MeldView meld = new MeldView( + List.of(MahjongTile.P2, MahjongTile.P2, MahjongTile.P2), + List.of(false, false, false), 1, 90, MahjongTile.P2 + ); + EnumMap seats = new EnumMap<>(SeatWind.class); + for (SeatWind wind : SeatWind.values()) { + seats.put(wind, new TableSeatRenderSnapshot( + wind, new UUID(0L, wind.index() + 1L), wind.name(), wind.name(), 25_000, + false, true, false, true, "viewer-" + wind.name(), -1, List.of(), -1, 0, + List.of(), hand, discards, List.of(meld), List.of(), List.of(ScoringStick.P100) + )); + } + return new TableRenderSnapshot( + 1L, 0L, "benchmark_world", 12.75D, 64.0D, -7.25D, + true, false, false, 70, 1, 7, 5, 1, 0, + SeatWind.EAST, SeatWind.SOUTH, SeatWind.WEST, + "waiting", "rules", "center", null, MahjongTile.RED_DRAGON, + List.of(MahjongTile.M1), MahjongVariant.RIICHI, seats + ); + } + + private static Object defaultValue(Method method) { + Class type = method.getReturnType(); + 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; + if (type == double.class) return 0.0D; + return null; + } +} diff --git a/src/test/java/top/ellan/mahjong/render/display/FlatDisplayInteractionRegistryTest.java b/src/test/java/top/ellan/mahjong/render/display/FlatDisplayInteractionRegistryTest.java index 7fba400..b3c8292 100644 --- a/src/test/java/top/ellan/mahjong/render/display/FlatDisplayInteractionRegistryTest.java +++ b/src/test/java/top/ellan/mahjong/render/display/FlatDisplayInteractionRegistryTest.java @@ -3,6 +3,7 @@ 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.assertSame; import java.util.List; import java.util.UUID; @@ -132,6 +133,109 @@ void independentActionAndHandRegionsCoexistAndClearSeparately() { assertEquals(List.of(tile), DisplayInteractionRayRegistry.snapshot(VIEWER_ID)); } + @Test + void identicalRegionReplacementKeepsTheExistingSnapshotAndActionChangesStillApply() { + DisplayInteractionRayRegistry.RayInteraction first = interaction( + 0.0D, + 3.0D, + 1.0D, + 0.0D, + action("first") + ); + DisplayInteractionRayRegistry.replaceRegion( + VIEWER_ID, + "table-a", + "viewer-actions", + List.of(first) + ); + List firstSnapshot = + DisplayInteractionRayRegistry.snapshot(VIEWER_ID); + + DisplayInteractionRayRegistry.replaceRegion( + VIEWER_ID, + "table-a", + "viewer-actions", + List.of(first) + ); + + assertSame(firstSnapshot, DisplayInteractionRayRegistry.snapshot(VIEWER_ID)); + + DisplayInteractionRayRegistry.RayInteraction changed = interaction( + 0.0D, + 3.0D, + 1.0D, + 0.0D, + action("second") + ); + DisplayInteractionRayRegistry.replaceRegion( + VIEWER_ID, + "table-a", + "viewer-actions", + List.of(changed) + ); + + assertEquals(List.of(changed), DisplayInteractionRayRegistry.snapshot(VIEWER_ID)); + } + + @Test + void identicalPublicJoinReplacementKeepsTheExistingFlattenedSnapshot() { + DisplayInteractionRayRegistry.RayInteraction join = interaction( + 0.0D, + 3.0D, + 1.0D, + 0.0D, + DisplayClickAction.joinSeat("table-a", SeatWind.EAST) + ); + DisplayInteractionRayRegistry.RayInteraction privateDecision = interaction( + 0.0D, + 4.0D, + 1.0D, + 0.0D, + action("private") + ); + List input = List.of(join, privateDecision); + + DisplayInteractionRayRegistry.replacePublicJoinRegion("table-a", "seat-label:EAST", input); + List firstSnapshot = + DisplayInteractionRayRegistry.publicJoinSnapshot(); + + DisplayInteractionRayRegistry.replacePublicJoinRegion("table-a", "seat-label:EAST", input); + + assertSame(firstSnapshot, DisplayInteractionRayRegistry.publicJoinSnapshot()); + assertEquals(List.of(join), firstSnapshot); + } + + @Test + void replacingPublicJoinActionStillUpdatesTheFlattenedSnapshot() { + DisplayInteractionRayRegistry.RayInteraction east = interaction( + 0.0D, + 3.0D, + 1.0D, + 0.0D, + DisplayClickAction.joinSeat("table-a", SeatWind.EAST) + ); + DisplayInteractionRayRegistry.RayInteraction south = interaction( + 0.0D, + 3.0D, + 1.0D, + 0.0D, + DisplayClickAction.joinSeat("table-a", SeatWind.SOUTH) + ); + + DisplayInteractionRayRegistry.replacePublicJoinRegion( + "table-a", + "seat-label:EAST", + List.of(east) + ); + DisplayInteractionRayRegistry.replacePublicJoinRegion( + "table-a", + "seat-label:EAST", + List.of(south) + ); + + assertEquals(List.of(south), DisplayInteractionRayRegistry.publicJoinSnapshot()); + } + @Test void tableAndViewerCleanupCannotLeaveStaleControls() { DisplayInteractionRayRegistry.RayInteraction interaction = interaction( diff --git a/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerDispatchPlanTest.java b/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerDispatchPlanTest.java new file mode 100644 index 0000000..32e06dd --- /dev/null +++ b/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerDispatchPlanTest.java @@ -0,0 +1,300 @@ +package top.ellan.mahjong.runtime; + +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; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.papermc.paper.threadedregions.scheduler.EntityScheduler; +import io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler; +import io.papermc.paper.threadedregions.scheduler.RegionScheduler; +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.Test; + +final class ServerSchedulerDispatchPlanTest { + private static final Runnable NO_OP = () -> { + }; + + @Test + void dispatchesEveryFoliaMethodShapeThroughCachedPlans() { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); + World world = mock(World.class); + Location location = new Location(world, 8.0, 64.0, -8.0); + Entity entity = mock(Entity.class); + GlobalRegionScheduler global = mock(GlobalRegionScheduler.class); + RegionScheduler region = mock(RegionScheduler.class); + EntityScheduler entityScheduler = mock(EntityScheduler.class); + ScheduledTask task = mock(ScheduledTask.class); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenReturn(server); + when(server.getGlobalRegionScheduler()).thenReturn(global); + when(server.getRegionScheduler()).thenReturn(region); + when(entity.getScheduler()).thenReturn(entityScheduler); + when(global.run(eq(plugin), any())).thenReturn(task); + when(global.runDelayed(eq(plugin), any(), anyLong())).thenReturn(task); + when(global.runAtFixedRate(eq(plugin), any(), anyLong(), anyLong())).thenReturn(task); + when(region.run(eq(plugin), eq(location), any())).thenReturn(task); + when(region.runDelayed(eq(plugin), eq(location), any(), anyLong())).thenReturn(task); + when(region.runAtFixedRate(eq(plugin), eq(location), any(), anyLong(), anyLong())).thenReturn(task); + when(entityScheduler.run(eq(plugin), any(), any(Runnable.class))).thenReturn(task); + when(entityScheduler.runDelayed(eq(plugin), any(), any(Runnable.class), anyLong())).thenReturn(task); + + ServerScheduler scheduler = new ServerScheduler(plugin); + + assertFalse(scheduler.runGlobal(NO_OP).isCancelled()); + assertFalse(scheduler.runGlobalDelayed(NO_OP, 11L).isCancelled()); + assertFalse(scheduler.runGlobalTimer(NO_OP, 13L, 17L).isCancelled()); + assertFalse(scheduler.runRegion(location, NO_OP).isCancelled()); + assertFalse(scheduler.runRegionDelayed(location, NO_OP, 19L).isCancelled()); + assertFalse(scheduler.runRegionTimer(location, NO_OP, 23L, 29L).isCancelled()); + assertFalse(scheduler.runEntity(entity, NO_OP).isCancelled()); + assertFalse(scheduler.runEntityDelayed(entity, NO_OP, 31L).isCancelled()); + + verify(server, times(1)).getGlobalRegionScheduler(); + verify(server, times(1)).getRegionScheduler(); + verify(entity, times(1)).getScheduler(); + verify(global).run(eq(plugin), any()); + verify(global).runDelayed(eq(plugin), any(), eq(11L)); + verify(global).runAtFixedRate(eq(plugin), any(), eq(13L), eq(17L)); + verify(region).run(eq(plugin), eq(location), any()); + verify(region).runDelayed(eq(plugin), eq(location), any(), eq(19L)); + verify(region).runAtFixedRate(eq(plugin), eq(location), any(), eq(23L), eq(29L)); + verify(entityScheduler).run(eq(plugin), any(), any(Runnable.class)); + verify(entityScheduler).runDelayed(eq(plugin), any(), any(Runnable.class), eq(31L)); + } + + @Test + void missingRegionAndEntityCapabilitiesFallBackToGlobalFoliaScheduler() { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); + World world = mock(World.class); + Location location = new Location(world, 0.0, 64.0, 0.0); + Entity entity = mock(Entity.class); + GlobalRegionScheduler global = mock(GlobalRegionScheduler.class); + ScheduledTask task = mock(ScheduledTask.class); + AtomicInteger executions = new AtomicInteger(); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenReturn(server); + when(server.getGlobalRegionScheduler()).thenReturn(global); + when(global.run(eq(plugin), any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + Consumer consumer = invocation.getArgument(1, Consumer.class); + consumer.accept(task); + return task; + }); + + ServerScheduler scheduler = new ServerScheduler(plugin); + scheduler.runRegion(location, executions::incrementAndGet); + scheduler.runEntity(entity, executions::incrementAndGet); + + assertEquals(2, executions.get()); + verify(server, times(1)).getRegionScheduler(); + verify(entity, times(1)).getScheduler(); + verify(server, times(1)).getGlobalRegionScheduler(); + verify(global, times(2)).run(eq(plugin), any()); + verify(server, never()).getScheduler(); + } + + @Test + void cachesBukkitFallbackByServerIdentityAndInvalidatesOnServerChange() { + Plugin plugin = mock(Plugin.class); + Server firstServer = mock(Server.class); + Server secondServer = mock(Server.class); + BukkitScheduler firstScheduler = mock(BukkitScheduler.class); + BukkitScheduler secondScheduler = mock(BukkitScheduler.class); + BukkitTask firstTask = mock(BukkitTask.class); + BukkitTask secondTask = mock(BukkitTask.class); + World world = mock(World.class); + Location location = new Location(world, 0.0, 64.0, 0.0); + Entity entity = mock(Entity.class); + AtomicReference currentServer = new AtomicReference<>(firstServer); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenAnswer(invocation -> currentServer.get()); + when(firstServer.getScheduler()).thenReturn(firstScheduler); + when(secondServer.getScheduler()).thenReturn(secondScheduler); + when(firstScheduler.runTask(eq(plugin), any(Runnable.class))).thenReturn(firstTask); + when(secondScheduler.runTask(eq(plugin), any(Runnable.class))).thenReturn(secondTask); + + ServerScheduler scheduler = new ServerScheduler(plugin); + scheduler.runGlobal(NO_OP); + scheduler.runRegion(location, NO_OP); + scheduler.runEntity(entity, NO_OP); + currentServer.set(secondServer); + scheduler.runGlobal(NO_OP); + scheduler.runRegion(location, NO_OP); + scheduler.runEntity(entity, NO_OP); + + verify(plugin, times(6)).getServer(); + verify(firstServer, times(1)).getScheduler(); + verify(secondServer, times(1)).getScheduler(); + verify(firstScheduler, times(3)).runTask(eq(plugin), any(Runnable.class)); + verify(secondScheduler, times(3)).runTask(eq(plugin), any(Runnable.class)); + } + + @Test + void nullFoliaTaskDoesNotScheduleAgainThroughBukkit() { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); + GlobalRegionScheduler global = mock(GlobalRegionScheduler.class); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenReturn(server); + when(server.getGlobalRegionScheduler()).thenReturn(global); + when(global.run(eq(plugin), any())).thenReturn(null); + + PluginTask task = new ServerScheduler(plugin).runGlobal(NO_OP); + + assertTrue(task.isCancelled()); + verify(global, times(1)).run(eq(plugin), any()); + verify(server, never()).getScheduler(); + } + + @Test + void regionSchedulerFailureNeverFallsBackOrSchedulesTwice() { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); + World world = mock(World.class); + Location location = new Location(world, 0.0, 64.0, 0.0); + RegionScheduler region = mock(RegionScheduler.class); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenReturn(server); + when(server.getRegionScheduler()).thenReturn(region); + when(region.run(eq(plugin), eq(location), any())).thenThrow(new IllegalStateException("region failed")); + + assertThrows(IllegalStateException.class, () -> new ServerScheduler(plugin).runRegion(location, NO_OP)); + + verify(region, times(1)).run(eq(plugin), eq(location), any()); + verify(server, never()).getGlobalRegionScheduler(); + verify(server, never()).getScheduler(); + } + + @Test + void entitySchedulerFailureNeverFallsBackOrSchedulesTwice() { + Plugin plugin = mock(Plugin.class); + Entity entity = mock(Entity.class); + EntityScheduler entityScheduler = mock(EntityScheduler.class); + + when(plugin.isEnabled()).thenReturn(true); + when(entity.getScheduler()).thenReturn(entityScheduler); + when(entityScheduler.run(eq(plugin), any(), any(Runnable.class))) + .thenThrow(new IllegalStateException("entity failed")); + + assertThrows(IllegalStateException.class, () -> new ServerScheduler(plugin).runEntity(entity, NO_OP)); + + verify(entityScheduler, times(1)).run(eq(plugin), any(), any(Runnable.class)); + verify(plugin, never()).getServer(); + } + + @Test + void neverRoutesConcurrentGlobalCallsThroughAnotherServersScheduler() throws InterruptedException { + Plugin plugin = mock(Plugin.class); + Server firstServer = mock(Server.class); + Server secondServer = mock(Server.class); + GlobalRegionScheduler firstScheduler = mock(GlobalRegionScheduler.class); + GlobalRegionScheduler secondScheduler = mock(GlobalRegionScheduler.class); + ScheduledTask task = mock(ScheduledTask.class); + ThreadLocal threadServer = new ThreadLocal<>(); + AtomicInteger wrongRoutes = new AtomicInteger(); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenAnswer(invocation -> threadServer.get()); + when(firstServer.getGlobalRegionScheduler()).thenReturn(firstScheduler); + when(secondServer.getGlobalRegionScheduler()).thenReturn(secondScheduler); + when(firstScheduler.run(eq(plugin), any())).thenAnswer(invocation -> { + if (!Thread.currentThread().getName().equals("scheduler-server-first")) { + wrongRoutes.incrementAndGet(); + } + return task; + }); + when(secondScheduler.run(eq(plugin), any())).thenAnswer(invocation -> { + if (!Thread.currentThread().getName().equals("scheduler-server-second")) { + wrongRoutes.incrementAndGet(); + } + return task; + }); + + ServerScheduler scheduler = new ServerScheduler(plugin); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(2); + AtomicReference failure = new AtomicReference<>(); + Thread firstThread = new Thread( + () -> runGlobalLoop(scheduler, threadServer, firstServer, ready, start, done, failure), + "scheduler-server-first" + ); + Thread secondThread = new Thread( + () -> runGlobalLoop(scheduler, threadServer, secondServer, ready, start, done, failure), + "scheduler-server-second" + ); + + firstThread.start(); + secondThread.start(); + try { + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS)); + } finally { + start.countDown(); + firstThread.interrupt(); + secondThread.interrupt(); + firstThread.join(); + secondThread.join(); + } + + if (failure.get() != null) { + throw new AssertionError("Concurrent scheduler invocation failed", failure.get()); + } + assertEquals(0, wrongRoutes.get()); + } + + private static void runGlobalLoop( + ServerScheduler scheduler, + ThreadLocal threadServer, + Server server, + CountDownLatch ready, + CountDownLatch start, + CountDownLatch done, + AtomicReference failure + ) { + threadServer.set(server); + ready.countDown(); + try { + if (!start.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Concurrent scheduler start timed out"); + } + for (int iteration = 0; iteration < 20_000; iteration++) { + scheduler.runGlobal(NO_OP); + } + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + threadServer.remove(); + done.countDown(); + } + } +} diff --git a/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerReflectionCacheTest.java b/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerReflectionCacheTest.java index 6464bcc..34a7031 100644 --- a/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerReflectionCacheTest.java +++ b/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerReflectionCacheTest.java @@ -7,21 +7,37 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import io.papermc.paper.threadedregions.scheduler.EntityScheduler; import io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler; +import io.papermc.paper.threadedregions.scheduler.RegionScheduler; import io.papermc.paper.threadedregions.scheduler.ScheduledTask; import java.lang.reflect.Method; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -import org.junit.jupiter.api.Test; +import org.bukkit.Location; import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.entity.Entity; import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.Test; final class ServerSchedulerReflectionCacheTest { + private static final Runnable NO_OP = () -> { + }; + @Test void cachesAvailableAndUnavailableMethodResolutionsPerRuntimeClass() { assertEquals(0, ServerScheduler.cachedMethodResolutionCount(ReflectionProbe.class)); @@ -38,7 +54,7 @@ void cachesAvailableAndUnavailableMethodResolutionsPerRuntimeClass() { } @Test - void invokesPaperSchedulerAndWrapsItsTaskThroughCachedMethods() { + void invokesPaperSchedulerAndReusesItsCapabilityByServerIdentity() { Plugin plugin = mock(Plugin.class); Server server = mock(Server.class); GlobalRegionScheduler globalScheduler = mock(GlobalRegionScheduler.class); @@ -55,16 +71,193 @@ void invokesPaperSchedulerAndWrapsItsTaskThroughCachedMethods() { return scheduledTask; }); - PluginTask task = new ServerScheduler(plugin).runGlobal(executions::incrementAndGet); + ServerScheduler scheduler = new ServerScheduler(plugin); + PluginTask first = scheduler.runGlobal(executions::incrementAndGet); + PluginTask second = scheduler.runGlobal(executions::incrementAndGet); - assertEquals(1, executions.get()); - assertFalse(task.isCancelled()); + assertEquals(2, executions.get()); + verify(server, times(1)).getGlobalRegionScheduler(); + assertFalse(first.isCancelled()); + assertFalse(second.isCancelled()); when(scheduledTask.isCancelled()).thenReturn(true); - assertTrue(task.isCancelled()); - task.cancel(); + assertTrue(first.isCancelled()); + first.cancel(); verify(scheduledTask).cancel(); } + @Test + void negativelyCachesUnavailableCapabilitiesOnPaper() { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); + World world = mock(World.class); + Entity entity = mock(Entity.class); + BukkitScheduler bukkitScheduler = mock(BukkitScheduler.class); + BukkitTask bukkitTask = mock(BukkitTask.class); + Location location = new Location(world, 0.0, 64.0, 0.0); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(bukkitScheduler); + when(bukkitScheduler.runTask(eq(plugin), any(Runnable.class))).thenReturn(bukkitTask); + + ServerScheduler scheduler = new ServerScheduler(plugin); + scheduler.runGlobal(NO_OP); + scheduler.runGlobal(NO_OP); + scheduler.runRegion(location, NO_OP); + scheduler.runRegion(location, NO_OP); + scheduler.runEntity(entity, NO_OP); + scheduler.runEntity(entity, NO_OP); + + verify(server, times(1)).getGlobalRegionScheduler(); + verify(server, times(1)).getRegionScheduler(); + verify(entity, times(1)).getScheduler(); + verify(bukkitScheduler, times(6)).runTask(eq(plugin), any(Runnable.class)); + } + + @Test + void invalidatesCapabilitiesWhenTargetIdentityChanges() { + Plugin globalPlugin = mock(Plugin.class); + Server firstServer = mock(Server.class); + Server secondServer = mock(Server.class); + GlobalRegionScheduler firstGlobal = mock(GlobalRegionScheduler.class); + GlobalRegionScheduler secondGlobal = mock(GlobalRegionScheduler.class); + ScheduledTask task = mock(ScheduledTask.class); + + when(globalPlugin.isEnabled()).thenReturn(true); + when(globalPlugin.getServer()).thenReturn(firstServer, firstServer, secondServer, secondServer); + when(firstServer.getGlobalRegionScheduler()).thenReturn(firstGlobal); + when(secondServer.getGlobalRegionScheduler()).thenReturn(secondGlobal); + when(firstGlobal.run(any(Plugin.class), any())).thenReturn(task); + when(secondGlobal.run(any(Plugin.class), any())).thenReturn(task); + + ServerScheduler globalScheduler = new ServerScheduler(globalPlugin); + globalScheduler.runGlobal(NO_OP); + globalScheduler.runGlobal(NO_OP); + globalScheduler.runGlobal(NO_OP); + globalScheduler.runGlobal(NO_OP); + + verify(firstServer, times(1)).getGlobalRegionScheduler(); + verify(secondServer, times(1)).getGlobalRegionScheduler(); + + Plugin regionPlugin = mock(Plugin.class); + RegionScheduler firstRegion = mock(RegionScheduler.class); + RegionScheduler secondRegion = mock(RegionScheduler.class); + World world = mock(World.class); + Location location = new Location(world, 0.0, 64.0, 0.0); + + when(regionPlugin.isEnabled()).thenReturn(true); + when(regionPlugin.getServer()).thenReturn(firstServer, firstServer, secondServer, secondServer); + when(firstServer.getRegionScheduler()).thenReturn(firstRegion); + when(secondServer.getRegionScheduler()).thenReturn(secondRegion); + when(firstRegion.run(any(Plugin.class), any(Location.class), any())).thenReturn(task); + when(secondRegion.run(any(Plugin.class), any(Location.class), any())).thenReturn(task); + + ServerScheduler regionScheduler = new ServerScheduler(regionPlugin); + regionScheduler.runRegion(location, NO_OP); + regionScheduler.runRegion(location, NO_OP); + regionScheduler.runRegion(location, NO_OP); + regionScheduler.runRegion(location, NO_OP); + + verify(firstServer, times(1)).getRegionScheduler(); + verify(secondServer, times(1)).getRegionScheduler(); + + Plugin entityPlugin = mock(Plugin.class); + Entity firstEntity = mock(Entity.class); + Entity secondEntity = mock(Entity.class); + EntityScheduler firstEntityScheduler = mock(EntityScheduler.class); + EntityScheduler secondEntityScheduler = mock(EntityScheduler.class); + + when(entityPlugin.isEnabled()).thenReturn(true); + when(firstEntity.getScheduler()).thenReturn(firstEntityScheduler); + when(secondEntity.getScheduler()).thenReturn(secondEntityScheduler); + when(firstEntityScheduler.run(any(Plugin.class), any(), any(Runnable.class))).thenReturn(task); + when(secondEntityScheduler.run(any(Plugin.class), any(), any(Runnable.class))).thenReturn(task); + + ServerScheduler entityScheduler = new ServerScheduler(entityPlugin); + entityScheduler.runEntity(firstEntity, NO_OP); + entityScheduler.runEntity(firstEntity, NO_OP); + entityScheduler.runEntity(secondEntity, NO_OP); + entityScheduler.runEntity(secondEntity, NO_OP); + entityScheduler.runEntity(firstEntity, NO_OP); + + verify(firstEntity, times(2)).getScheduler(); + verify(secondEntity, times(1)).getScheduler(); + } + + @Test + void neverRoutesConcurrentEntityCallsThroughAnotherEntityScheduler() throws InterruptedException { + Plugin plugin = mock(Plugin.class); + Entity firstEntity = mock(Entity.class); + Entity secondEntity = mock(Entity.class); + EntityScheduler firstEntityScheduler = mock(EntityScheduler.class); + EntityScheduler secondEntityScheduler = mock(EntityScheduler.class); + ScheduledTask task = mock(ScheduledTask.class); + AtomicInteger wrongRoutes = new AtomicInteger(); + + when(plugin.isEnabled()).thenReturn(true); + when(firstEntity.getScheduler()).thenReturn(firstEntityScheduler); + when(secondEntity.getScheduler()).thenReturn(secondEntityScheduler); + when(firstEntityScheduler.run(any(Plugin.class), any(), any(Runnable.class))).thenAnswer(invocation -> { + if (!Thread.currentThread().getName().equals("scheduler-entity-first")) { + wrongRoutes.incrementAndGet(); + } + return task; + }); + when(firstEntityScheduler.runDelayed(any(Plugin.class), any(), any(Runnable.class), anyLong())) + .thenAnswer(invocation -> { + if (!Thread.currentThread().getName().equals("scheduler-entity-first")) { + wrongRoutes.incrementAndGet(); + } + return task; + }); + when(secondEntityScheduler.run(any(Plugin.class), any(), any(Runnable.class))).thenAnswer(invocation -> { + if (!Thread.currentThread().getName().equals("scheduler-entity-second")) { + wrongRoutes.incrementAndGet(); + } + return task; + }); + when(secondEntityScheduler.runDelayed(any(Plugin.class), any(), any(Runnable.class), anyLong())) + .thenAnswer(invocation -> { + if (!Thread.currentThread().getName().equals("scheduler-entity-second")) { + wrongRoutes.incrementAndGet(); + } + return task; + }); + + ServerScheduler scheduler = new ServerScheduler(plugin); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(2); + AtomicReference failure = new AtomicReference<>(); + Thread firstThread = new Thread( + () -> runEntityLoop(scheduler, firstEntity, ready, start, done, failure), + "scheduler-entity-first" + ); + Thread secondThread = new Thread( + () -> runEntityLoop(scheduler, secondEntity, ready, start, done, failure), + "scheduler-entity-second" + ); + + firstThread.start(); + secondThread.start(); + try { + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS)); + } finally { + start.countDown(); + firstThread.interrupt(); + secondThread.interrupt(); + firstThread.join(); + secondThread.join(); + } + + if (failure.get() != null) { + throw new AssertionError("Concurrent scheduler invocation failed", failure.get()); + } + assertEquals(0, wrongRoutes.get()); + } + @Test void doesNotFallbackAndScheduleTwiceWhenPaperSchedulerThrows() { Plugin plugin = mock(Plugin.class); @@ -80,13 +273,36 @@ void doesNotFallbackAndScheduleTwiceWhenPaperSchedulerThrows() { throw new IllegalStateException("scheduler failed after entry"); }); - assertThrows(IllegalStateException.class, () -> new ServerScheduler(plugin).runGlobal(() -> { - })); + assertThrows(IllegalStateException.class, () -> new ServerScheduler(plugin).runGlobal(NO_OP)); assertEquals(1, schedulerCalls.get()); verify(server, never()).getScheduler(); } + private static void runEntityLoop( + ServerScheduler scheduler, + Entity entity, + CountDownLatch ready, + CountDownLatch start, + CountDownLatch done, + AtomicReference failure + ) { + ready.countDown(); + try { + if (!start.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Concurrent scheduler start timed out"); + } + for (int iteration = 0; iteration < 20_000; iteration++) { + scheduler.runEntity(entity, NO_OP); + scheduler.runEntityDelayed(entity, NO_OP, 1L); + } + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + done.countDown(); + } + } + private static final class ReflectionProbe { public void accept(String value) { } diff --git a/src/test/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactoryOverheadTest.java b/src/test/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactoryOverheadTest.java index 3f8dc20..abcaa4e 100644 --- a/src/test/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactoryOverheadTest.java +++ b/src/test/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactoryOverheadTest.java @@ -53,6 +53,17 @@ void activeRoundAddsViewRiverButtonWhenNoTurnOrReactionActionsExist() { } } + @Test + void commandStateSummaryIncludesTheLiveRoundTurnWallAndSpectatorCount() { + Fixture fixture = fixture(false, true, SeatWind.SOUTH, false, MahjongVariant.RIICHI); + + String summary = PlainTextComponentSerializer.plainText().serialize( + new TableViewerSnapshotFactory(fixture.session()).createStateSummary(fixture.viewer()) + ); + + assertEquals("Round East 1 | Turn Other player | Wall 42 | Spectators 0", summary); + } + @Test void activeOverheadViewIsReadOnlyAndUsesShiftToReturn() { Fixture fixture = fixture(true); diff --git a/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java b/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java index 484e03b..b0ecf06 100644 --- a/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java +++ b/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java @@ -123,7 +123,7 @@ void clearedViewerOwnershipMakesAnUnchangedRegionStaleForReconnect() { } @Test - void unchangedGeometryReusesClientProxyWithoutMorePackets() { + void unchangedGeometryReusesClientProxyWithoutMorePacketsOrViewerLookups() { TableSessionContext session = mock(TableSessionContext.class); SparrowRayInteractionProxyCoordinator.Backend backend = mock( SparrowRayInteractionProxyCoordinator.Backend.class @@ -153,6 +153,56 @@ void unchangedGeometryReusesClientProxyWithoutMorePackets() { verify(backend, times(1)).create(eq(viewer), any()); verify(backend, times(1)).spawn(viewer, List.of(proxy)); verify(backend, never()).destroy(viewer, List.of(proxy)); + verify(session, times(1)).onlinePlayer(VIEWER_ID); + assertEquals(1, coordinator.entityCount()); + } + + @Test + void offlineCachedViewerUsesFreshSessionPlayer() { + TableSessionContext session = mock(TableSessionContext.class); + SparrowRayInteractionProxyCoordinator.Backend backend = mock( + SparrowRayInteractionProxyCoordinator.Backend.class + ); + SparrowRayInteractionProxyCoordinator.ClientProxy firstProxy = mock( + SparrowRayInteractionProxyCoordinator.ClientProxy.class + ); + SparrowRayInteractionProxyCoordinator.ClientProxy reconnectedProxy = mock( + SparrowRayInteractionProxyCoordinator.ClientProxy.class + ); + Player firstViewer = mock(Player.class); + Player reconnectedViewer = mock(Player.class); + when(session.id()).thenReturn("table-a"); + when(session.onlinePlayer(VIEWER_ID)).thenReturn(firstViewer).thenReturn(reconnectedViewer); + when(firstViewer.isOnline()).thenReturn(true); + when(reconnectedViewer.isOnline()).thenReturn(true); + when(backend.available()).thenReturn(true); + when(backend.create(eq(firstViewer), any())).thenReturn(List.of(firstProxy)); + when(backend.create(eq(reconnectedViewer), any())).thenReturn(List.of(reconnectedProxy)); + when(firstProxy.entityId()).thenReturn(1205); + when(reconnectedProxy.entityId()).thenReturn(1206); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(session).runForViewer(any(Player.class), any(Runnable.class)); + SparrowRayInteractionProxyCoordinator coordinator = new SparrowRayInteractionProxyCoordinator( + session, + backend + ); + Map> interactions = Map.of( + VIEWER_ID, + List.of(interaction()) + ); + + coordinator.replace("actions", interactions); + when(firstViewer.isOnline()).thenReturn(false); + coordinator.replace("actions", interactions); + + verify(backend).spawn(firstViewer, List.of(firstProxy)); + verify(backend).spawn(reconnectedViewer, List.of(reconnectedProxy)); + verify(backend, never()).destroy(firstViewer, List.of(firstProxy)); + verify(session, times(2)).onlinePlayer(VIEWER_ID); + assertNull(ClientInteractionProxyRegistry.tableIdFor(1205, VIEWER_ID)); + assertEquals("table-a", ClientInteractionProxyRegistry.tableIdFor(1206, VIEWER_ID)); assertEquals(1, coordinator.entityCount()); } diff --git a/src/test/kotlin/top/ellan/mahjong/render/VariantWallRenderLayoutTest.kt b/src/test/kotlin/top/ellan/mahjong/render/VariantWallRenderLayoutTest.kt index 751b5bc..ed782a2 100644 --- a/src/test/kotlin/top/ellan/mahjong/render/VariantWallRenderLayoutTest.kt +++ b/src/test/kotlin/top/ellan/mahjong/render/VariantWallRenderLayoutTest.kt @@ -17,8 +17,11 @@ import java.util.EnumMap import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull +import kotlin.test.assertNotSame import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue class VariantWallRenderLayoutTest { @@ -66,6 +69,31 @@ class VariantWallRenderLayoutTest { assertNull(plan.wallTiles()[87]) } + @Test + fun `unselected private hand points reuse public coordinates and layout lists remain immutable`() { + val plan = + TableRenderLayout.precompute( + snapshot( + MahjongVariant.GB, + remainingWallCount = 91, + eastHand = listOf(MahjongTile.M1, MahjongTile.M2, MahjongTile.M3), + eastSelectedHandTileIndices = listOf(1), + ), + ) + val east = plan.seat(SeatWind.EAST) + + assertSame(east.publicHandPoints()[0], east.privateHandPoints()[0]) + assertNotSame(east.publicHandPoints()[1], east.privateHandPoints()[1]) + assertEquals( + east.publicHandPoints()[1].y() + 0.06, + east.privateHandPoints()[1].y(), + 1.0e-9, + ) + assertFailsWith { + east.publicHandPoints().add(east.publicHandPoints()[0]) + } + } + @Test fun `wall slots use variant specific tiles per side`() { assertEquals(SeatWind.EAST, WallLayout.wallSeat(35, 36)) @@ -157,6 +185,8 @@ class VariantWallRenderLayoutTest { kanCount: Int = 0, dora: List = emptyList(), eastFlowers: List = emptyList(), + eastHand: List = emptyList(), + eastSelectedHandTileIndices: List = emptyList(), dealerSeat: SeatWind = SeatWind.EAST, roundIndex: Int = 0, dicePoints: Int = 3, @@ -183,11 +213,11 @@ class VariantWallRenderLayoutTest { true, "", -1, - emptyList(), + if (wind == SeatWind.EAST) eastSelectedHandTileIndices else emptyList(), -1, 0, emptyList(), - emptyList(), + if (wind == SeatWind.EAST) eastHand else emptyList(), emptyList(), melds, emptyList(), diff --git a/src/test/kotlin/top/ellan/mahjong/table/core/SessionRenderLayoutCacheTest.kt b/src/test/kotlin/top/ellan/mahjong/table/core/SessionRenderLayoutCacheTest.kt new file mode 100644 index 0000000..da90092 --- /dev/null +++ b/src/test/kotlin/top/ellan/mahjong/table/core/SessionRenderLayoutCacheTest.kt @@ -0,0 +1,200 @@ +package top.ellan.mahjong.table.core + +import top.ellan.mahjong.model.MahjongTile +import top.ellan.mahjong.model.MahjongVariant +import top.ellan.mahjong.model.SeatWind +import top.ellan.mahjong.render.scene.MeldView +import top.ellan.mahjong.render.snapshot.TableRenderSnapshot +import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot +import top.ellan.mahjong.riichi.model.ScoringStick +import java.util.EnumMap +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +class SessionRenderLayoutCacheTest { + @Test + fun `equivalent layout inputs reuse the immutable plan`() { + val cache = SessionRenderLayoutCache() + assertSame(cache.precompute(snapshot(version = 1)), cache.precompute(snapshot(version = 2))) + } + + @Test + fun `every geometry dependency invalidates the cached plan`() { + val baseline = snapshot() + val changed = + listOf( + snapshot(centerX = 4.0), + snapshot(centerY = 65.0), + snapshot(centerZ = -3.0), + snapshot(started = false), + snapshot(remainingWallCount = 69), + snapshot(kanCount = 2), + snapshot(dicePoints = 8), + snapshot(breakDicePoints = 6), + snapshot(dealerSeat = SeatWind.SOUTH), + snapshot(openDoorSeat = SeatWind.NORTH), + snapshot(doraIndicators = listOf(MahjongTile.P1)), + snapshot(variant = MahjongVariant.GB), + snapshot(hand = defaultHand() + MahjongTile.NORTH), + snapshot(selectedIndices = listOf(3)), + snapshot(riichiDiscardIndex = 2), + snapshot(stickLayoutCount = 4), + snapshot(discards = listOf(MahjongTile.EAST, MahjongTile.WEST)), + snapshot(melds = listOf(defaultMeld(), defaultMeld())), + snapshot(cornerSticks = listOf(ScoringStick.P1000)), + snapshot(riichi = false), + snapshot(occupied = false), + ) + changed.forEachIndexed { index, candidate -> + val cache = SessionRenderLayoutCache() + val first = cache.precompute(baseline) + val incremental = cache.precompute(candidate) + assertNotSame(first, incremental, "dependency index=$index") + assertEquals( + top.ellan.mahjong.render.layout.TableRenderLayout + .precompute(candidate), + incremental, + "incremental layout dependency index=$index", + ) + } + } + + @Test + fun `seat-only changes reuse immutable center and wall geometry`() { + val cache = SessionRenderLayoutCache() + val first = cache.precompute(snapshot(selectedIndices = listOf(4))) + val second = cache.precompute(snapshot(version = 2, selectedIndices = listOf(3))) + + assertNotSame(first, second) + assertSame(first.displayCenter(), second.displayCenter()) + assertSame(first.tableCenter(), second.tableCenter()) + assertSame(first.wallTiles(), second.wallTiles()) + SeatWind.values().forEach { wind -> assertNotSame(first.seat(wind), second.seat(wind)) } + } + + @Test + fun `wall-only changes reuse every immutable seat plan`() { + val cache = SessionRenderLayoutCache() + val first = cache.precompute(snapshot(remainingWallCount = 70)) + val second = cache.precompute(snapshot(version = 2, remainingWallCount = 69)) + + assertNotSame(first.wallTiles(), second.wallTiles()) + SeatWind.values().forEach { wind -> assertSame(first.seat(wind), second.seat(wind)) } + } + + @Test + fun `clear forces recomputation`() { + val cache = SessionRenderLayoutCache() + val input = snapshot() + val first = cache.precompute(input) + cache.clear() + assertNotSame(first, cache.precompute(input)) + } +} + +private fun snapshot( + version: Long = 1, + centerX: Double = 3.0, + centerY: Double = 64.0, + centerZ: Double = -2.0, + started: Boolean = true, + remainingWallCount: Int = 70, + kanCount: Int = 1, + dicePoints: Int = 7, + breakDicePoints: Int = 5, + dealerSeat: SeatWind = SeatWind.EAST, + openDoorSeat: SeatWind = SeatWind.WEST, + doraIndicators: List = listOf(MahjongTile.M1), + variant: MahjongVariant = MahjongVariant.RIICHI, + hand: List = defaultHand(), + selectedIndices: List = listOf(4), + riichiDiscardIndex: Int = 1, + stickLayoutCount: Int = 3, + discards: List = listOf(MahjongTile.EAST, MahjongTile.SOUTH), + melds: List = listOf(defaultMeld()), + cornerSticks: List = listOf(ScoringStick.P100), + riichi: Boolean = true, + occupied: Boolean = true, +): TableRenderSnapshot { + val seats = EnumMap(SeatWind::class.java) + SeatWind.values().forEach { wind -> + seats[wind] = + TableSeatRenderSnapshot( + wind, + if (occupied) UUID(0, wind.index().toLong() + 1) else null, + wind.name, + wind.name, + 25_000, + riichi, + true, + false, + true, + "viewer-${wind.name}", + selectedIndices.lastOrNull() ?: -1, + selectedIndices, + riichiDiscardIndex, + stickLayoutCount, + emptyList(), + hand, + discards, + melds, + emptyList(), + cornerSticks, + ) + } + return TableRenderSnapshot( + version, + 0, + "world", + centerX, + centerY, + centerZ, + started, + false, + false, + remainingWallCount, + kanCount, + dicePoints, + breakDicePoints, + 1, + 0, + dealerSeat, + SeatWind.SOUTH, + openDoorSeat, + "waiting", + "rules", + "center-$version", + null, + null, + doraIndicators, + variant, + seats, + ) +} + +private fun defaultHand(): List = + listOf( + MahjongTile.M1, + MahjongTile.M2, + MahjongTile.M3, + MahjongTile.P1, + MahjongTile.P2, + MahjongTile.P3, + MahjongTile.S1, + MahjongTile.S2, + MahjongTile.S3, + MahjongTile.EAST, + MahjongTile.EAST, + ) + +private fun defaultMeld(): MeldView = + MeldView( + listOf(MahjongTile.P7, MahjongTile.P7, MahjongTile.P7), + listOf(false, false, false), + 1, + 90, + null, + ) diff --git a/src/test/kotlin/top/ellan/mahjong/table/render/TableRenderSnapshotFactoryTest.kt b/src/test/kotlin/top/ellan/mahjong/table/render/TableRenderSnapshotFactoryTest.kt index 26aeeb8..5545532 100644 --- a/src/test/kotlin/top/ellan/mahjong/table/render/TableRenderSnapshotFactoryTest.kt +++ b/src/test/kotlin/top/ellan/mahjong/table/render/TableRenderSnapshotFactoryTest.kt @@ -2,6 +2,7 @@ package top.ellan.mahjong.table.render import org.bukkit.Location import org.bukkit.entity.Player +import org.mockito.AdditionalAnswers import org.mockito.ArgumentMatchers import org.mockito.Mockito.doAnswer import org.mockito.Mockito.mock @@ -15,6 +16,8 @@ import top.ellan.mahjong.table.core.MahjongTableSession import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame import kotlin.test.assertTrue class TableRenderSnapshotFactoryTest { @@ -117,8 +120,55 @@ class TableRenderSnapshotFactoryTest { assertEquals(144, snapshot.wallCapacity()) assertTrue(!snapshot.usesDeadWall()) + val reusedSnapshot = factory.create(session, 2L, 0L) + for (wind in SeatWind.values()) { + assertSame(snapshot.seat(wind).viewerIdsExcluding(), reusedSnapshot.seat(wind).viewerIdsExcluding()) + assertSame( + snapshot.seat(wind).viewerMembershipSignature(), + reusedSnapshot.seat(wind).viewerMembershipSignature(), + ) + } + + `when`(session.viewers()).thenReturn( + listOf(eastViewer, duplicateEastViewer, westViewer, southViewer), + ) + val reorderedSnapshot = factory.create(session, 3L, 0L) + assertEquals(eastSeat.viewerIdsExcluding(), reorderedSnapshot.seat(SeatWind.EAST).viewerIdsExcluding()) + assertEquals( + eastSeat.viewerMembershipSignature(), + reorderedSnapshot.seat(SeatWind.EAST).viewerMembershipSignature(), + ) + assertNotSame( + reusedSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + reorderedSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + ) + + `when`(session.viewers()).thenReturn( + listOf(southViewer, westViewer, duplicateEastViewer, eastViewer), + ) + val restoredSnapshot = factory.create(session, 4L, 0L) + val secondSession = + mock( + MahjongTableSession::class.java, + AdditionalAnswers.delegatesTo(session), + ) + val secondSessionSnapshot = factory.create(secondSession, 5L, 0L) + assertEquals( + restoredSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + secondSessionSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + ) + assertNotSame( + restoredSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + secondSessionSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + ) + assertNotSame( + restoredSnapshot.seat(SeatWind.EAST).viewerMembershipSignature(), + secondSessionSnapshot.seat(SeatWind.EAST).viewerMembershipSignature(), + ) + + factory.create(session, 6L, 0L) `when`(session.playerAt(SeatWind.NORTH)).thenReturn(null) - val emptyNorthSeat = factory.create(session, 2L, 0L).seat(SeatWind.NORTH) + val emptyNorthSeat = factory.create(session, 7L, 0L).seat(SeatWind.NORTH) assertEquals(listOf(eastId, westId, southId), emptyNorthSeat.viewerIdsExcluding()) assertEquals( "00000000-0000-0000-0000-000000000011" + @@ -127,6 +177,24 @@ class TableRenderSnapshotFactoryTest { emptyNorthSeat.viewerMembershipSignature(), ) + `when`(session.viewers()).thenReturn(listOf(westViewer, eastViewer)) + val removedViewerSnapshot = factory.create(session, 8L, 0L) + assertTrue(!removedViewerSnapshot.seat(SeatWind.SOUTH).online()) + assertTrue(removedViewerSnapshot.seat(SeatWind.SOUTH).viewerIdsExcluding().isEmpty()) + assertTrue(removedViewerSnapshot.seat(SeatWind.SOUTH).viewerMembershipSignature().isEmpty()) + assertEquals( + listOf(westId), + removedViewerSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + ) + assertEquals( + westId.toString(), + removedViewerSnapshot.seat(SeatWind.EAST).viewerMembershipSignature(), + ) + assertEquals( + listOf(eastId, westId), + removedViewerSnapshot.seat(SeatWind.NORTH).viewerIdsExcluding(), + ) + verify(session, never()).onlinePlayer(ArgumentMatchers.any(UUID::class.java)) verify(session, never()).viewerIdsExcluding(ArgumentMatchers.any(UUID::class.java)) verify(session, never()).viewerMembershipSignatureFor(ArgumentMatchers.any(UUID::class.java))