From 8e14bf6f38062c87c87e977417859268d9d8d5ee Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 17:38:54 +0800 Subject: [PATCH] perf(live): make plugin scenario reproducible --- docs/performance-testing.md | 26 ++++--- perf/live/artifacts.lock.json | 20 ++--- .../fixtures/mahjongpaper-smoke-config.yml | 19 +++++ perf/live/run_server.py | 18 ++++- perf/live/tests/test_download_locked.py | 22 +++--- perf/live/tests/test_run_server.py | 2 +- .../mahjong/bootstrap/MahjongPaperPlugin.java | 78 ++++++++++++++++++- .../TableViewerSnapshotFactory.java | 2 +- ...ableViewerSnapshotFactoryOverheadTest.java | 11 +++ 9 files changed, 162 insertions(+), 36 deletions(-) create mode 100644 perf/live/fixtures/mahjongpaper-smoke-config.yml 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/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/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/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);