From d5a55ccfcf12be93f0e8e139a2dd6d38ec2aa1a4 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 26 Jul 2026 12:12:49 -0500 Subject: [PATCH] API --- .gitignore | 3 +- Iris/golden/overworld-s1337-c0x0-r8.hashes | 298 --------- README.md | 61 ++ adapters/bukkit/plugin/build.gradle | 7 + .../src/main/java/art/arcane/iris/Iris.java | 60 +- .../iris/api/pregen/IrisPregenPhase.java | 11 + .../iris/api/pregen/IrisPregenProgress.java | 31 + .../api/pregen/IrisPregenerationEvent.java | 35 ++ .../iris/api/terrain/IrisColumnField.java | 7 + .../iris/api/terrain/IrisColumnQuery.java | 61 ++ .../iris/api/terrain/IrisColumnSink.java | 6 + .../iris/api/terrain/IrisSurfaceKind.java | 9 + .../iris/api/terrain/IrisTerrainService.java | 32 + .../iris/api/terrain/IrisWorldInfo.java | 24 + .../iris/api/world/IrisWorldEngineEvent.java | 44 ++ .../arcane/iris/api/world/IrisWorldPhase.java | 7 + .../iris/core/link/IrisPapiExpansion.java | 124 +--- .../iris/core/link/IrisPapiInstaller.java | 14 + .../iris/core/link/IrisPapiListener.java | 104 ++++ .../iris/core/link/IrisPapiPosition.java | 9 + .../iris/core/link/IrisPapiPregenView.java | 51 ++ .../arcane/iris/core/link/IrisPapiState.java | 204 ++++++ .../iris/core/link/IrisPapiWorldView.java | 50 ++ .../runtime/BukkitEnginePlatformHooks.java | 3 + .../iris/core/service/IrisApiEventSVC.java | 106 ++++ .../iris/core/service/IrisEngineSVC.java | 31 +- .../iris/core/service/IrisTerrainSVC.java | 316 ++++++++++ .../core/service/IrisWorldPhaseLedger.java | 49 ++ .../service/terrain/IrisApiFaultGuard.java | 31 + .../core/service/terrain/IrisColumnWalk.java | 49 ++ .../service/terrain/IrisSampleLimits.java | 24 + .../terrain/IrisSurfaceClassifier.java | 25 + .../service/terrain/IrisWorldInfoFactory.java | 71 +++ .../iris/IrisApiServiceDiscoveryTest.java | 22 + .../iris/api/IrisApiSurfacePurityTest.java | 154 +++++ .../api/pregen/IrisPregenProgressTest.java | 50 ++ .../iris/api/terrain/IrisColumnQueryTest.java | 77 +++ .../iris/api/terrain/IrisWorldInfoTest.java | 30 + .../api/world/IrisWorldEngineEventTest.java | 64 ++ .../core/link/FakeIrisTerrainService.java | 117 ++++ .../iris/core/link/IrisPapiExpansionTest.java | 249 ++++++++ .../iris/core/link/IrisPapiLifecycleTest.java | 132 ++++ .../iris/core/link/IrisPapiListenerTest.java | 316 ++++++++++ .../iris/core/link/IrisPapiStateTest.java | 394 ++++++++++++ .../iris/core/link/IrisPapiTestSupport.java | 104 ++++ .../core/link/IrisPlaceholderAbsenceTest.java | 82 +++ .../service/IrisApiWiringContractTest.java | 193 ++++++ .../iris/core/service/IrisTerrainSVCTest.java | 100 +++ .../service/IrisWorldPhaseLedgerTest.java | 119 ++++ .../terrain/IrisApiFaultGuardTest.java | 52 ++ .../service/terrain/IrisColumnWalkTest.java | 87 +++ .../service/terrain/IrisSampleLimitsTest.java | 58 ++ .../terrain/IrisSurfaceClassifierTest.java | 61 ++ .../terrain/IrisWorldInfoFactoryTest.java | 52 ++ core/purity-allowlist.txt | 10 +- .../arcane/iris/core/gui/PregeneratorJob.java | 64 +- .../core/pregenerator/PregenApiPhase.java | 29 + .../iris/core/pregenerator/PregenApiSink.java | 25 + .../core/pregenerator/PregenPhaseTracker.java | 80 +++ .../pregenerator/PregenPhaseTrackerTest.java | 72 +++ docs/api/README.md | 196 ++++++ docs/api/placeholders.md | 216 +++++++ docs/api/terrain.md | 584 ++++++++++++++++++ docs/api/tree-feller.md | 517 ++++++++++++++++ docs/api/world-events.md | 459 ++++++++++++++ gradle/libs.versions.toml | 2 +- 66 files changed, 6300 insertions(+), 424 deletions(-) delete mode 100644 Iris/golden/overworld-s1337-c0x0-r8.hashes create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenPhase.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenProgress.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenerationEvent.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnQuery.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisTerrainService.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisWorldInfo.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/world/IrisWorldEngineEvent.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/world/IrisWorldPhase.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiInstaller.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiListener.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiPosition.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiPregenView.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiState.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiWorldView.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisApiEventSVC.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisWorldPhaseLedger.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisApiFaultGuard.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisColumnWalk.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSampleLimits.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactory.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisApiServiceDiscoveryTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/IrisApiSurfacePurityTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/pregen/IrisPregenProgressTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisColumnQueryTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisWorldInfoTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/world/IrisWorldEngineEventTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/FakeIrisTerrainService.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiExpansionTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiLifecycleTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiListenerTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiStateTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiTestSupport.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPlaceholderAbsenceTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisTerrainSVCTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisWorldPhaseLedgerTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisApiFaultGuardTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisColumnWalkTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSampleLimitsTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifierTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactoryTest.java create mode 100644 core/src/main/java/art/arcane/iris/core/pregenerator/PregenApiPhase.java create mode 100644 core/src/main/java/art/arcane/iris/core/pregenerator/PregenApiSink.java create mode 100644 core/src/main/java/art/arcane/iris/core/pregenerator/PregenPhaseTracker.java create mode 100644 core/src/test/java/art/arcane/iris/core/pregenerator/PregenPhaseTrackerTest.java create mode 100644 docs/api/README.md create mode 100644 docs/api/placeholders.md create mode 100644 docs/api/terrain.md create mode 100644 docs/api/tree-feller.md create mode 100644 docs/api/world-events.md diff --git a/.gitignore b/.gitignore index 07ea872af..31e495491 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,8 @@ local.properties credentials.json service-account*.json -docs/ +docs/* +!docs/api/ CROSSPLATFORM_PLAN.md diff --git a/Iris/golden/overworld-s1337-c0x0-r8.hashes b/Iris/golden/overworld-s1337-c0x0-r8.hashes deleted file mode 100644 index bb3220c77..000000000 --- a/Iris/golden/overworld-s1337-c0x0-r8.hashes +++ /dev/null @@ -1,298 +0,0 @@ -#iris-goldenhash v1 -#world=goldentest -#dim=overworld -#seed=1337 -#mc=26.1.2.build.2591-stable -#minY=-256 maxY=512 -#center=0,0 -#radius=8 --8 0 e8c6f44099c05ed375fc14866389bc8c7c14b51b69434c9c516caf53d031c415 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 1 c555764a99e9bb25c1ad95890f24b38447d4104637eb8d1c84629b929dbef7ff 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 2 0d7bfb60c8913b6ee0225faa06c5759c2c1c05646ed2bac3fc924b3e53c0a125 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 3 f682f9939349330bfe2160d9b8d9ce0d43b3c710860107abce8d98a38ee85b7e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 4 e96d9e6b17b59f251023cb70f78a2e4a155d613ddd9208c0bb66c01589f3649c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 5 f3dc77a9e64d2d9639d7f23222bc4c61272e8dab1c93e97420d9667640e6d212 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 6 ad62cd0be871a2bb2d547e64df2ea40b40e38e729b19cbb21d7343d8146dfc51 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 7 4f01547457c8cb8da9b4de50780c8ceffbe8da3781cb171c682e367a46a4006a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 8 0fc9c9d510b070d6b8bc7bd867134f1367a3f5ad0514ffd8925ce089de734cb3 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 -8 01347120983abadd10251a68a3ea131414986aadc5861b9ed2684930998b187f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 -7 65d0f851d4c2467f0f541967b692cd44d95ddb59cd2250af08bfa788af8a3803 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 -6 d1bc22d2912b8555226c19f38f5f8e574331be5901215122a5ffbeb73b419765 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 -5 ba6f58e5f76cbee8058c839ff7e42f4e7c4a07158cf97f959e4f676d4a477b72 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 -4 3e7d2602fff6f0ace932134462745586d7baaed6fbdc5d4285f71219c19d281d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 -3 88c0654c7380d9b616079d7991b41677e7f4c46acc6dcad93024e2c2b0f72288 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 -2 94d591ae106d14204ed910cc1de28de4040f853d6a9bd47fc7fdebec0324f6a9 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --8 -1 26218d2073cbe0cc87e91e789f973444510a49c0e3c3c5f94f32febb1496288a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 0 4549aad82cdfa6356d7de621924f62ca950640e9b8f5e771964fb7a66d648009 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 1 89e7a6a250b09565b2d00050fac709bc6612e16c4a7e0d189595f460ef28f1eb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 2 2da2f65fa337c51d140f339af28faa2987dc09f793b7dd3dcbdb2f801cec76d7 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 3 5d5ec222384ce07398e352f405f23dd3454277c8d5251c32d870659a8c52f6fc 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 4 1839f53ff781c3d133d741d297527a6c864f185be77c6a79ec294de34bb86e45 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 5 9f6f17c5c3e12f1d94e6a7b8a81ebe082e55b548213c605c904554baa864c83d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 6 85482bebafc31f647b56d400e41df1c78555446df9d24c7c9baeaa0760f0ea1e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 7 bb9382d28c0841a6aa6ebfc35ab8df808bea54cad20c858fb58a2652a8ca3e9e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 8 68bba9b6d00765c098cb3d29907977198303c69a39410e5d46ca109d791d22de 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 -8 2c622b6c55a20271a48874ae9e489ce3c938c634eb4cb2c317b66a21ecf36c76 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 -7 1f29f9d700900e966d96f6425c28e6ac5c30f77d6d5bc8efca22937678f6fbfa 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 -6 3b9e3e48cc43167ac8e659409c62ae9bd7cdddca74cf6efc23dfead2913dff2b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 -5 1ed142541d6bb7b11909a69257678277546b72cfdf8a64f44a471f827bbb671a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 -4 d7f808e9d725823ba2b68e54b1985f458ce180fc1c5dda3dac4add38d831a1ce 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 -3 0971e32206dcb07b64ef050c23a06a354f3d776095e10c7e65ca44db999b868e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 -2 25e93be610632dbb353b4673aaf02b17a8153332f5557617ddafbce5332b8bb2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --7 -1 92bb2c7632a54d837db26d79d114fa6626b16289ab9879eca89aa3030633813f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 0 7ecbcbb6be3e4e6d8449f90468d9969204b18fb9746ecad6319741f9f12c6fbc 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 1 3898687b4624ebeeb6e61fc40f39d6b5842f25495bfe63dcf9a4733596bc3c82 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 2 0e3017098a07c8b596f4d3b221b9ebd0d564bb0e2776391031a716c2bf1d01b7 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 3 126fba2b943704a1791648a76e100f5ea263920837e334ecb7d9188c3d76412d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 4 68d6139155e498e561efb77c9f57568684429ada7918c47f44f6a85041478220 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 5 b50c5089e48a84d456120884e90e6a17d8bb02478743a97a0beb1b4eb4fb9890 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 6 0317ed303e7ae37b236bfa0b86d6c00bc2c6b6423f2d1b0228a0f0e4b0f5b6d2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 7 330599a6b02758c52f13792e83173fb8beca89e94f26f7c12d82415d8a0c49e1 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 8 e74f8d776ca319f992298dfb1d0545f9e3796d8c74ff59d0a57e8155eb5d955b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 -8 f65ef781c13b78f1ec46d98b1396c8c716b3752da6315cc805359354917480ab 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 -7 efdef3ed4747552d65982c605365dc6e576ab9030643efe8b1bb2456be5a09a8 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 -6 06f8d72fc8281b0e0fbbbf8f8b91575a1db4af551dc87f459877e3eaeebf363c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 -5 54211524408e92d4518e7409ebaa45647ce7a6645a34de338167d2ff213839f0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 -4 5540bded756e63b331df11f89f57566ba86b711a4b88fb5480e5dda9f2540586 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 -3 601b734e87ee9a74173b780d566f85322c45bdaf03125bbb070e136c9dfe5007 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 -2 1ca8f873f3369f53537cb7b5506f19e1c7ea4fd06c817616508b0c96103b26c4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --6 -1 ea159282ee7c77e10e8506feccc6f8d6798ac414737521565b0f604911939c1f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 0 c1c95112697de045a0add257e6e793f03f1592d451e774eac0757ac7155b48ef 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 1 a5ea2ca87fa5e0dceb6df886d926cd4559c667a83abd86415f1c27dc9ed81ee5 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 2 dc7f16b61d9edc02d5249b8d2ee6354272b03db78af103b149d393517b84bda8 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 3 cee16aa555acbdf22437047b4256f6c768a9a030118f5638a642917657c1ef55 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 4 67681fafbaea4803cc440ec1e8f65d743c3b8a46697be8976d413d32f7a3c72f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 5 fba2f12c44e498b0507d319a43c0f2c967ce63bb77793027deb6d1ca3e6e234a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 6 b72a83a562f7d9e2f684f89de7f2893f8c213e74aadb58cd186ba61c5dc7e45d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 7 7089ebbd8ee10a7d7fc335055a350072414b0ffde6c0b3033df6d1adff1d2169 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 8 185ff9a6c30cfb4b0536b484bced135792f00509751cd006df2c714f53fec7b2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 -8 d1d70baf419895f95f0b4eeb26a7b7510015153c72d47a9a48105ac170688ce6 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 -7 e328bdfec9d335c3dc8079a54b28c776752870004058db0844b2b4ebfc025071 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 -6 70d531e3e5930e2fe203a446f97720f94ac7ce8a7aa5b1f9be733d081b503593 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 -5 bd2b0e65f1c27f14e168f12add29691b2375583e9967f6b85b7f0a4bf5dad848 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 -4 327d84fbaf300d46540d858bf08df637404d80d4de3be3dd0baac2b4939df441 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 -3 c50372e6900776f53862e713ba873fcaec8102d77120460599a75f5aec4ef4ea 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 -2 81ce5424ec2ad445bd0158cbe57137d5a566726a705fe35c7538088bb986526a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --5 -1 ad141fd05004823307416d1d3b4261d39387f2dc6e52b38475e1d9fd526dfe81 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 0 86e9e5ce6e00100fbfb8ea5112a0d86a450fdc297c9e710dc5a26f727c865ecd 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 1 27dc14d872694dc1502d2f9b84a5d1a5dafca624a3972ddad3f25941feaaaa73 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 2 bed812029e6fed53cb5abb9ee76f970c322a9028c6421c6334ade091f2d467f0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 3 b8a6b54438b8980d0ecbe3c2d7d0aebeb2bca228c530651c76213b2f6be90af4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 4 dda62b17f47229a7e1d88f29aaea0c06a98456b3d11a6fcab848f61be6011bc9 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 5 b4b40ef42ec4dc99fbc16149387750bf43a4c0111909693de2ea14b98eb631a0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 6 e946df48cd22fdef70b500519b5f83a7369a014e6e92521df3ef94305ba12541 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 7 9c19d9ed486cf5fd9a8786a0ae4bea850dec20e987b3f34683e09d829b6bff14 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 8 e1851ad3d1e45e2447e581670864f5b31b804727d36ee6ceed956701379b5931 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 -8 5f604232c902d63a68d07da0d381d6c211a0fc5cc5132b57c63eee182b9bb570 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 -7 039905dec3c6e4f37ee946e4b593411f8ccc0b359d2fb0a26ee111b6c79cf242 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 -6 bbbc3fa281dabfc4301c21237b1a3b7fae9bec0ce10545fd566e0e00dcc80043 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 -5 ba1603746f8a4016fe9d1e6f9e58fd49b57c08445e49434624dd3f005b120f18 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 -4 4bcf6ab1960c59eac15286e5adf8efb5140626bd22225bb78bddfe43adb7c1e0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 -3 1ca1fae7c121e55be9ca1e369fe58f09a61f13b981f189598eb46aac03ebadc5 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 -2 a3154b14c6c2472b9e3596bcee51372ddaaf2b2b4225090c77797612b73571cd 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --4 -1 ede17ad65f03e78875b5a96aa33004e0900c0304d56c228cea550f414203c469 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 0 bd87aa58f3fb8d3b974fc46449c776e610f2aff7667419089563dac7c938ca0f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 1 4dc30c498d41c84f8f6deed9accb857c29d759b533bce9b48768092690ac0424 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 2 d46285266abb3d5ca853d70c5549c4fd0fc8f32905feb66a55aec573d045afab 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 3 01d5a13a92f8221047bf8755efbb4a42a6a68582f0bae4e3b081b7c306632bbd 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 4 597746d886a37cdc7d875c6a0df97c3e7d8980d0ea33915fe307828f23686f1a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 5 3510c1c85c72591b00b7f08efa70ec438142f9fd22a89daa1f8981ed2dc2b10e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 6 9d531df8effbb51c64f08e99f1d5e2841eb4ef6c49d1397381a20a6576e16850 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 7 755e79e527c74cdce0d0b1b4dbea74d639ce939c306129e0ebea84cf7d72bd13 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 8 0d1b0b8b1729aa56df6c1bdcc08c9057c4fd2741b9a3d369242774f53944e02f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 -8 4367ce7e43ce3dce053a5aa52b7e7b700a7e266239e91a70a90fc06b63d18ece 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 -7 c59e78e77238284e754406e9e5edc9ab95836de31ea02b3f9d892a97b8cd78a3 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 -6 c63548a3b36db9293dd8bca785b008a3e555cfc28ebd29c3f3f2db6b48ad7d90 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 -5 4680afd2a8a2070863108cf1b6bf2d20526870039194856dbc01a247decf4433 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 -4 697e060186a06cae5ceea0c2022ef734e8515834f154c339c4f858933af34bbb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 -3 2819fced8d0234d52889df26a34816f77e5dcdcbd0ad6a84f14eb76805460ffb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 -2 e07b5b1c0c9fa889833d12b7d4e0d77a6e13562115f7df0821d33d944ffb951c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --3 -1 fe3f09f13704393d2b7b1de01ad1e6019c2026732c7f37fb606e98b8caa8fc57 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 0 51f8a1a38a41616de5065389e8c2d8ab2ef140b84cbfdba5450455b5595b9d3d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 1 e581ea05cc318caeda834761bfd658a9a5c6a7538319a8054828ee9e130bf263 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 2 cd300f15b2ac4e29c99f9259f789f70693e187b47fb4327366d0a2f7e1b43767 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 3 9ca913943094b455fd9ddb2baf4a2d5d9804dd9d6306ed8fe9343f08c28f3699 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 4 22090840e16f339a5f2785d6499a431f00c39fce5d54f87b4cd338b03993729a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 5 62278637c9203e9dc118d6b572fa305988d3b8835e3c6f51317418e02807c569 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 6 fc79a0b5b1c3c5807de4a44f5859d4a9b0a71850ff423f2007f2690e15635eab 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 7 a8369fe6fd76191aac527edcf568b15f6f222a96ded1fea44bf855f0ae9b48ae 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 8 55d37c3ee6dffcf24c0b831d0bbd8bf48391002bae36cb179d89d95ae7a21605 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 -8 0a70e75164d5358712a0e87b6bf1a66c9bf42a43332eb2d1de9bd6661f0501e6 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 -7 60e5e216ff3a4147a4a206861051a773924bf7d59910dc690bf78486ccf0a826 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 -6 00fd0a1144b5a098953d57a1f2b43258c7796b5f023f072c15267e993258de85 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 -5 cafd29e010f6af2ebe580b9b0bb710a54fd23592dade31b7e669b35c3f76f3fb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 -4 622462ae5157344ddfd31919d4f829bd7de499d859e8ea9e7a78aece2948185c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 -3 0298439e5b148523b1a4a39ef0ceb4d46cf05afa0409b0531b44e096ef3cc371 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 -2 ba01546214f5c228ac0d49fd29af7e537e690e6c23ada25ff5ebb8b7cf8900a1 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --2 -1 9ea6b0d37b3b9e81fe0d0384de53793b23075433b7c77bb7dd3b10dcea2ba0c1 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 0 5b945f1828d88ac141f97bbe29587a2841d85a631c19e9575ee33f92d3340813 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 1 df696e4e814a2ede0ef1377cf1a1ab783577d4f328397ddebd8ad87cee12e535 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 2 dc6b7b03f4731400d9e04a20e0a3f6175ea7b5cf48a1dc9ec17d93b3b55d79f0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 3 d9929169dba8ea107712fe27d739d662155b37ee61a54363f029365a89b110ae 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 4 8c6a4a674b635ba2cfd81b5eeb001c9feb27be24c4c63519f1b3bd5e5eedb27a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 5 bf7eda22dfb6b3b4200ffbdb769cd431556a60a6f475482775d2df4a36e6f348 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 6 0fb399a09635cb5b63def5091260c412e2471259f99b4cfa62fd9c636ecbf096 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 7 0b607c01a69c2265f51a6f8064fe74f1a79d92b8cfc4d1079f8d853de407b307 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 8 9cc17e392bb780aa2dbd777f2e639e23acaef1f27c5c60ad2d3d992b78d31d48 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 -8 802a05904797cf3791f398056dbdcf9fa04cf8f30412f330e74a4c4344075558 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 -7 85246d2e6d27b72a997a36b467b6938e0f8d0e9d1ba865bcc02f3391afb07e26 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 -6 3b7485773bdcc52ad4dc09e2aa66aff5d2d339d481c7e8f4041f175486cf112f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 -5 3de97a621cd4c0871e0bcad0bf260151ec13436496912e48d994b938a0b0e4d7 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 -4 6084ca4bd6718289312e4a510635608bdb9a53111f8f855f3f62436eb0eb5c19 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 -3 7d2c092c140c2105a6e1be99c2535a97c240bcdac31a31ae724c0fd1af3d4415 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 -2 040127151932013ebe1894f4204923cd18ad5f80b6b756d32e915d1261476e05 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 --1 -1 d534d548ba184029ad57ed1619cb66af64c8559d2e1916dbdc35352944a92002 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 0 9d634e5914634328e642e3ff4ee25b1d855f485f93af99e8ab3abfe396ad0711 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 1 642706de1286e15f8d63bd5b71c24dbe00906a4f0375e4c74fc65365aae72991 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 2 88a50a0c23489c5b7bc49181b2839ff5d9c0af0699101c778b2c6fcb4a0a8922 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 3 a6e306902412137b51c1f9e49b22874765a9fc474535e2b12fa6e98fcbfeb5df 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 4 b3200e2fcb52fd1e925d043eea822b06bf9628fadd1f58ad58c2656d33d1ae85 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 5 2b8861ca99726fa6cb1b85ccca44426c79b36fe61b9384e4bed568c5104feef2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 6 952eb848f973913ab7c037c4dead78872690720732d751fef6f4777315f6c44d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 7 b93001115c62d7ab2733a86b482b340fa5eb789ddb99039e7e299e14f1c106e4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 8 dad0064ff5ad856d2140e923b771ea92d0bdcf157e3bb813b79565ff9f473b36 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 -8 e5b7f9b4885bbb15d3da42b3343610521e1b03a73bbf208b66b53833b180166b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 -7 b6a2ff710e665cff92200cd77b21e575a3d7db02986aca3054a86d5f10b7f05c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 -6 3ba1c1c28f3b875a40eacc39a74ef819bb52df10b3313c535edf5b973551e8f6 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 -5 905d0c485c49a9f01aec61d859b5548c4ec72eade81ca85c3864294d3677b151 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 -4 33dc036e9420b269dcb5247f2df35e55a42310c6d8a6784ed121d56c7487ee00 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 -3 2c8d81467deb96167f74cd6e1fc51ce97adeaf56596f0bf948f37edb6707bf0b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 -2 64d93e292f86d8701d0ec2e7d099196b94d5fd179ceed471c28ae59e55ee28e2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -0 -1 708b7e4b5c912b10ed5147d2d87bd77b913a37fbde4155aac7ce6260b5471bcf 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 0 8f2cba2e6652796cbab43457666667d8ec5beef356095467fb75ba53e7de38ce 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 1 0b69855d8785d992be6743aba315c2b1014a58dab0b364af3394823652ca62ed 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 2 0db7977d44cbcbee49be1a963b42b60756f21077f04fabf78f3a71c617fc8db7 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 3 c0c5bb35ddcbf2b25c360f100e949629c2533a326feae8d114fe465740eb08db 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 4 54822a01ccf10a646ed9459b82a12d177813bf4a23c55ea4f7aa92bf1afb9974 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 5 f01e402910c0d3760368d2a6486a2e6ecc9f63ca8e58765074fc920d25b0faed 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 6 2cbb7b541d8e8012c64fdc872270fc1400cc510182f4333d6a0b5dd21215d08e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 7 b711c83e157c349c2d3cfac9b6add13e097d507284f6fa62f18c24ae78afa814 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 8 76e802d6e2425307ca4644014b7b03e53fab0f4adc03dfa4baa7623d7d8b7413 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 -8 56b4596f35a47200714e74589f9b89a8eb5a01df53f2e5035a294f4c80d88641 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 -7 ae559e04a1367fe775479c35168382ee74bd450774c6bb785335d2ea468a741e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 -6 e3e4f5ad931e99baecf24895bc7c3d7f8cfae5c3dc485c21ac47a3f5030e3617 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 -5 047a5d16348cbac2e1add22d67268d5f1e43b7d03de0fe85edb6abea470c6cc9 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 -4 53021a6c0ca99c136c1dc7c7365c6a3b7b84dcae506fbf1ea54eb1cf6456dcc4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 -3 83d897f90ec0fc2357c93bf07d43952df23c9d1d0d6d5b22ff73cac0ab338b7b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 -2 afbb2abecfa34904954f2e16ffb56b49dd292170f90dd9552a2f101d101c01ef 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -1 -1 a22df312d77de965860d2bd1ff489d1c38b343c2beb11707949b444725c25a4c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 0 9abdce5ab4c5df4479bf095f5d200174650f4beb3504a99c26f988b3b7675898 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 1 a2cd93c410be4911fe0304da5d2993058ad5957f670df5e5d781e3a7fde37fbb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 2 19606f317ca9aae79e2d7f66c9ab14d9138a7b7d9bcc5d61a2040297fb1580a3 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 3 ae9e0cef6124a2fb1283cb349d698030f90b6f1509b46a88b79d4ad81c5075c3 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 4 ceb1afc0561b22131484f37f704c66fb0be6e62262ddc778de791b3780b5b535 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 5 703003021b68774b61e040bb552f4883793828cdbe75dbcad424afe2220e18ab 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 6 b7e2a7f81ffce5851a996898cacc38b3597426dd473ea6310dca7ffe126f99e3 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 7 e13be39d90d9bd7e395e0763bebdf29e8c08d676ec78d5a16b7511710d302909 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 8 907fc6c166e0a65d37f5276072ccaa3fcab9064660836691e3f459c8a0cf42e6 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 -8 86c8905772609245555d5860066b167773b4cb919a46a7ea7ec2fb8075399753 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 -7 c132100840d3ac286482223957a18729768cff3f4cd0721c794771e4c50ec315 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 -6 9a6becb1d8aa3c329623f53f1a1944e1e2a0f0988acb1d2cdb6c231afa211e90 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 -5 cb8c69a2143e96e0c49c3131f209fee789513246f1afaf95cb01e3088ab299f4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 -4 d256f0a962427bc0624eaf826516d981596fc252ad0d10d7204b1fc06477b07a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 -3 11ddf89eb5ed8e7d5c6c0c98c209f1237260fdaa5f5ba1e9608f412a9b1d1a08 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 -2 9bcc19afed624473a53a7d6492714d7fd36203c731c37fb2a0c483afcac0dd8f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -2 -1 44daf4251ccad570516d5c2490cfa8095da33dbd0f66465db700791db9642919 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 0 31a12ef94005ed541ecc4e83a2fa493f0d4d28a3ba08e9a5d9bfafb30aefe28d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 1 a5cb0ee02b85b4235cc64e5ae388ed0c0749712efe1ee79479b72555f05e4f30 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 2 9047f6e870831d21b794349be2c312a0df67022627e6288dcee60aa4159b5d22 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 3 f8888a1491bf3e2df54d40c3814ef2ea912b88ad40cbecdeb44f1fa5663a5f71 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 4 287401b60ebd6b4e4f7a73dbcd35c919b182fea0e62c41ddb1c1288537b62ca9 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 5 dd885bb7003dd62933dab34145561b99f8035f8041c05ee6ef8346cd31b89933 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 6 8f8ede04cf803d45a24932177ba707688e09cc90d1b2cbabf6298a6b29d19eeb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 7 d825796e3a32cbbb3604aa65fdc417defb5effdc32e05696344d9a3c016ed474 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 8 260a4e2748902a3a2fb8d7af62929100ba6fe9975142286032f33fcfccf51f6c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 -8 d66d74a3e4b2f247cdefeb554299b45cbabc8d879e22b95d4b27cfc8e5141cbe 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 -7 0bfea12ea04c7720f5648c89a01fd175be32e8a77cc4a7a603ed2876c62161dc 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 -6 626380d70931e53f62e83ae8118840eda96107e5f0965daeb199a2740e1c2e5b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 -5 56b49e323ffc4ada99a906dfce0d1e2c11b31dc3e9052c0c468ed6420d31b858 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 -4 f2350cb0d8dde09f298c613f5ad1d97fc382b0c1d5b12826eddb2f2195bc7831 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 -3 8c57558a9a5644c05c5daa58d17e2be1b9de1a65d6f7deb4855297a97523f482 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 -2 336127103c8f6aae0fd993713bd880199eeefb589442fa5ebc2386f0c022dce0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -3 -1 523a07c0a804ede754f78283488eddef94bfe25b72cacaf534263282dfd46be5 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 0 cf509fb497c5d7bda76448d12a1a4fe8ce0fd38036a271fcd7b0c48449f82ab8 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 1 59ab0838a0833b55c37406f68c447c2b084e88f4e17d59cd6dac4bfa30263b8a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 2 3588d7ae2ee246864fad05fd0f03dad8741ffcc0cb7704c11c1bd431b049b235 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 3 f70ae200940e87bc0df540e7921ccf5600b1c323b9c07368e07587f34c55d1c1 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 4 6e98b393eaad8c62f1a2425559556100ca3dff5a13f82887e88093d9ef7d620a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 5 7d6899d1c1a8de24aae6e47ff09cf5f876e247b66f7925943b6bcc5c2fa18b2f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 6 4fd67412489af76fe1c5955685380e31a34475d401b3c7fcbb7c5d088faa42ad 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 7 774c770ffb3179e5b2c6d1fe97ff6ac8d3645aa725b52c4ccdd46260e8505ff5 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 8 3097971d8936ed6d53a74ffb45f4eb98118bb7d364a34494de72a86f0b3cf2d5 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 -8 20ab184d0549d5312eb3c4c4c93c27d3dfaf3cf5dc599f35c291b928218938be 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 -7 b22075a0dd7dd86db2e98414131576d3f28fec55d7e8ad623fdc92d2e5c77f17 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 -6 d4e34b1219c0d25371cedb943dd3ff72fedf96e2d86d4d62e36c29195efbf1d4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 -5 b13ceb0561b22bb5dc346608a63635892771509538f26a214d5cac8213207b99 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 -4 40c1c9e7104508edae785a3534a98e9e74dd2a4f66c5853138fa441fb64f003e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 -3 e4b49f322b501551da69e06e3ce41572ce6af154dffd1e4b27691c8153cd4fc4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 -2 0fdb0c8ac97ac20a56c15058ae46a6e4c734ff6974dd85068005779f5685ea9e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -4 -1 35edd44c1c2f3001316a226dafdcd90aa67de5ae613ae56cad6d3ee985eac4e4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 0 b08c5b7e8d207372eb4be8e01289449e574f1d9e0f882d1af190529d23f3e001 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 1 282aa33dfda7d77a51448969f29f5414d452ab88e4ee7c531465746db049f394 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 2 2a6739e7840576314fbcf347982829160ca4a5fb910746cc7a7f93c457de360a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 3 ad2a08c773242a0f61c0d90a00c166b3de65466d09f02ce5daba95fa8af6ecfb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 4 8ed02630b5641e2efcce55ef70d0387898996d9fdc4979c41ca894a39776b365 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 5 d66c6279267ce407bbe5c0007a50bb48359d08cdf95cce6659be45963148e402 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 6 50bc4674208dde2e0f0124d95fbdeda90cd9d9aebfb584a0ca24680e9275bf46 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 7 e1080da9f31c8cf90e12335cacd75a3439f693f963e4e7da28643a4aeea62a56 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 8 fe11aca2e168ecdca408d4dcbc45b0083f834fee5de4ddbb35227f475925978a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 -8 8f51ef501c2b4b16b808dc7cf3845ada51ea8edaa6f8681a234b592f27937983 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 -7 69937c9e1b63da726e051f0725db5355e2bcb18376df2f3b3028cf804bb24db6 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 -6 c20e2b424f96d572cace671c9063268b764a08b9927bf4ca785dfbf300d4ec63 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 -5 a14bedb28bcb68f69e39257ada66536a1eebd15af6aaf8dc9c868cc4808a079f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 -4 044bf8274f32bad7bdc4a720611faac7ecf961d201790af3b14646cc6ef65280 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 -3 f9e4bf03c9e9a0bb68e22aabffdcd10da64b91f736d1e09a7b43f4bc94a98077 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 -2 fd6394bbe1b01be2f5fb8847f543e69bbbf6ed272c29650d721eb7a4de32b4e2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -5 -1 620d5b20d3cc817044d8e1087cf18cc42fce70ef04a26ff7b3ea6b824b685122 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 0 9f1d84e4b370154ed36eb76bd8feb1a92d4e0d04aed7e1fe55f30ba19248ae8a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 1 65501bdc9af1fa61b6ac7f3016d7db4529b9bf20f39866a569b6e58c73865297 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 2 c4258153706bd0863490040c8af3e32c6d39ce0e664e99069cf7b10f1a1ebebc 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 3 43f539e2d1729422f8c7724f0bc2a0ad1bb750bbded45ee10dfb6f9cf8de9bfe 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 4 329e0c6370d49ef08a3515a6491389a1dd1501a31ab98f9a7b64e87e2e43ca30 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 5 a980add3cfa56e95901b5d693cd741c97cf35e3fcdbde7272c48a39d3785575e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 6 3626dc0c6fb1e8f1c05573e6a309d97fa83cff6af9020948f5471fee24f11969 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 7 1ccdf04b99d41c2fdf5f62fa148f6c9a45285650a7fc0a8438d3bd80ec9a5753 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 8 12aaa847543f538ba7a85aa90edecaede9fa313e881b1dc560ee6e0e6640c8ff 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 -8 683ce017cbc497d1b823090a0ec984c0478ddc4e3ec2077096b6569a54d0186b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 -7 94ea7dea313d1ddb52b086acf681185b45de615ca19b07f9b31863ad50d63df5 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 -6 942e705c68ed7f1ef3c625843a06924dcb1299c10421bdaf3914c3cf091a2ddb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 -5 7a8982f6e99e72e735d32f7685b486447642b80463b9ac09afa4c8d392d1cd8c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 -4 22b2dac5870b720f01d68d38475287a34f0ed21f39dd9717b7dda9f96818ef1c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 -3 20fa5ff56a7e160284fee9efe2731d505b59feac67ccacaf7c33f8cc92d2bece 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 -2 7427be500743e898067c3879844931a0c1894cb62524717547267ed1e69eb4a0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -6 -1 98446f1c88d2efea8314f61f35f8fb01f0478c77fc5204095ba83fb53ac8c7ef 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 0 1196a06655cf67cc0d56ac2281315211a13769b9198bed293aa3813d088e9d44 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 1 2b50aedb8471f269114bb7e8be545c0066869bb7812d96c5019006d8bbfbc250 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 2 d71eea940fa42cf164940ccf32137f76bfbf78087e3bf55827b32c0539e3651a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 3 3b10ae5f226f8bfef16e84cafc24c3d06fcf7e9fe894024ce1bffd9548c2200b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 4 c627e0fdede69245d1d864e93f7d1fdb09bc28a8d1573de2d9bfcc2b3ef865a3 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 5 88ba2b1f11c70e7c4e132ef95d20e5fcddaad3aab614814b303a9af7e29e453a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 6 6bbc34b26da81a0820eb03b848f372f32fd9c09c41c5f4c094f4598b9b495a7d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 7 a7c48618de4cd7bf9cfbfc794514556f028755bc6dcce0a34cfae009d28ad4f1 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 8 32885533d96c8ed9d11a23080df65d08c8360852ee79ea3923767808b06c599b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 -8 3e7c6219d03bcb3d3f5e845735e648a9dc0ddeb4047f3004469e89058e954094 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 -7 7202ef092e139a4f3119e32d827ce1fa0640d02329230a92c7e43f6893e8cfb7 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 -6 f57cac950053f8f2aa978e01473a91655bc32427befbf2f2eaaca5642304abbd 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 -5 e07049ac9a576241138f0c931bce0f737328b9f5cf5e2f076a33d0c4dc96843a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 -4 48e72991ba4d7f30546a4db4e7d6e0eb3908ac2f45150d57aca666ace4e0ecbe 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 -3 7b2c25f93d898720159a89cdcf6fd64d108d3325a4b4b7136ad4054232f7a416 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 -2 fda7e40529cf6ff9c3447d9fff9906c549f2998baba2c7edba22c0641156c3b7 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -7 -1 88ae6027aca5f412c97a28bbb7c7d31dda5616a3f2600764f4ddf608f27ad1f4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 0 62b82563ed14f8182e3e4b83ff54cc41657cf55eec2a5112123cd7cf095f9995 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 1 02797a2b0765485eeea460b65f03a7a94937eb3300b5eb9d09fd1e09cee38894 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 2 c52de795f6ca08db1be71a2a4f518c8b6b70bdc53800d708503118ae95893f86 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 3 378b7f06bb7dace3bac1566f3148256a0d8365d2918926dc73186b40c25e1518 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 4 ddef83c202a59cb14b5b75bef83d678c11d1d0a33fa803f662c9f9cb435d3110 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 5 ab3866ec28e7eac2b18b12b539337cf3343816885e18a1498b7f7106288cc670 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 6 45e9ea668e339e9bccdb103eb6a055f10bb8dc49e7177a37b9a19a862f13e861 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 7 1871c550876139b2a533c43a3b6b6afbbdc4c3aa3aa903cca98e3b67f23cc4d2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 8 5a9a452a44f385b53ac52f5fc6921f2f9e865bb63b1f0958b2314f5526156ef8 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 -8 819d7f6becf5f5acf6aef8b750353ad867289aa8a8a6b3ced77e999c7958a47b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 -7 57ac4798c2301dc72028f4704fbe897a2e871cc2d88957840a7f9264261cb6f1 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 -6 6da3519f7893f32772681a6e12f06edabb774e9e8d5a9293002608a8cc40e9fe 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 -5 2e2e6e327e0e27d6d6de0ea6618f219fd080f6f9de05b6d6602ebb90bad6a077 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 -4 01989f7b6de2ec03426e2b68190d6fc75e2d57b24225305377338294ca2d14e6 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 -3 e57520d1b8c17e9de905510adda0d94fe4d8309245db26ecfc6dedbdd7702719 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 -2 35ba48632ddfcbe171d457105142dee5f1123f528f1a02c127377b80e83ca58f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -8 -1 ab693d879e0a8e294d19057eb9540fcf4d2daae5c4bd1b5df649b74dd86697a9 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 -#combined=28b777380f2a5e467cb387f9c3fb08c8a3e6bdc6bc226c38ee6e14cc7e380da4 diff --git a/README.md b/README.md index 21529dfd4..211cc3976 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,67 @@ registries, so on modded servers block, item, entity, enchantment, and potion-ef includes installed mod content (for example `create:brass_ingot`). Editing an open studio's pack files hotloads the changes and regenerates the schemas. +## PlaceholderAPI + +Iris registers the `iris` expansion when PlaceholderAPI is enabled. Paths are dot-separated, +lowercase, and never contain an underscore. Every value is plain text: no colour codes, no unit +suffixes, no `%` character, `.` as the decimal separator, and no thousands grouping. + +Three answers are possible. A path that is not in the list below returns nothing, so PlaceholderAPI +re-emits the literal `%iris_...%` and a typo stays visible. A known path with no value right now +returns `---`. A real zero returns `0`. + +| Placeholder | Value | +|---|---| +| `%iris_available%` | `true` when the Iris terrain service is live | +| `%iris_world.available%` | `true` when the reading player is in an Iris world and a reading exists | +| `%iris_world.biome%` | Surface biome display name at the player, e.g. `Hot Desert Dunes` | +| `%iris_world.biome-key%` | Surface biome load key, e.g. `desert/hot-dunes` | +| `%iris_world.region%` | Region display name at the player | +| `%iris_world.region-key%` | Region load key | +| `%iris_world.dimension%` | Dimension (pack) load key of the player's world | +| `%iris_pregen.available%` | `true` while a pregeneration job is running | +| `%iris_pregen.world%` | World name the running job is pregenerating | +| `%iris_pregen.percent%` | Completion, `0.00` to `100.00`, no `%` character | +| `%iris_pregen.eta%` | Estimated seconds remaining, whole number | +| `%iris_pregen.eta-text%` | Same estimate as `2m 5s` or `1h 30m` | +| `%iris_pregen.chunks%` | Chunks generated so far | +| `%iris_pregen.total%` | Chunks in the job | +| `%iris_pregen.chunks-per-second%` | Current rate | +| `%iris_pregen.paused%` | `true` while the job is paused | + +The world values are the surface reading at the player's block column. Walking refreshes them at most +once per second per player, so a whole board of `world.*` keys costs one refresh per player per +second no matter how many of them are on it, and a value may lag a sprinting player by up to a +second. A jump that is not walking — joining, respawning, changing worlds, stepping through a portal, +or any teleport including `/iris goto`, `/tp`, an ender pearl and a random teleport — is published +immediately, so a player who arrives somewhere and then stands still never keeps reading the biome, +region or dimension of where they came from. `pregen.*` is global: there is one pregeneration job per +server, and `%iris_pregen.world%` says which world it is. + +### Migration from the pre-2.0 keys + +The old underscore keys are gone. There is no alias and no dual-accept window; an old key now +renders literally so it is visible rather than silently wrong. + +| Old key | New key | Why | +|---|---|---| +| `%iris_biome_name%` | `%iris_world.biome%` | Renamed onto the dot grammar | +| `%iris_biome_id%` | `%iris_world.biome-key%` | Renamed; `id` was always the load key | +| `%iris_region_name%` | `%iris_world.region%` | Renamed onto the dot grammar | +| `%iris_region_id%` | `%iris_world.region-key%` | Renamed; `id` was always the load key | +| `%iris_biome_file%` | removed | Rendered an absolute server path into player-visible text, and threw on packs with no backing file | +| `%iris_region_file%` | removed | Same as `biome_file` | +| `%iris_world_seed%` | removed | Handed the world seed to anyone who could read a scoreboard, and a placeholder has no permission context to gate on | +| `%iris_terrain_height%` | removed | Reported the *generated* height, before objects and player edits, so it disagreed with the block under the player's feet | +| `%iris_terrain_slope%` | removed | Three extra noise samples per read for an unformatted pack-authoring diagnostic | +| `%iris_world_mode%` | removed | Studio or Production; a studio world exists for seconds during authoring and is never on a live board | +| `%iris_world_speed%` | removed | Mutated engine rate-window state every time it was read. `%iris_pregen.chunks-per-second%` answers the same question from a snapshot | + +The old keys also read the *cave* biome for a player standing under an overhang, because they +sampled two blocks above the player's feet. The new `world.biome` is always the surface biome, +which is what a board reader means. + ## Building from source Requirements: JDK 25 (set `JAVA_HOME` to it). The Gradle wrapper handles everything else. diff --git a/adapters/bukkit/plugin/build.gradle b/adapters/bukkit/plugin/build.gradle index 9f327c664..749b176da 100644 --- a/adapters/bukkit/plugin/build.gradle +++ b/adapters/bukkit/plugin/build.gradle @@ -23,6 +23,9 @@ dependencies { transitive = false } compileOnly(libs.placeholderApi) + testImplementation(libs.placeholderApi) { + transitive = false + } } tasks.named('processResources').configure { @@ -50,4 +53,8 @@ tasks.named('test').configure { systemProperty('iris.pregeneratorJobSource', rootProject.file('core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java').absolutePath) systemProperty('iris.bukkitEnginePlatformHooksSource', file('src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java').absolutePath) systemProperty('iris.engineSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java').absolutePath) + systemProperty('iris.terrainSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java').absolutePath) + systemProperty('iris.apiEventSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisApiEventSVC.java').absolutePath) + systemProperty('iris.worldInfoFactorySource', file('src/main/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactory.java').absolutePath) + systemProperty('iris.readmeSource', rootProject.file('README.md').absolutePath) } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index b99d25579..46e83cf07 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -37,7 +37,10 @@ import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks; import art.arcane.iris.core.runtime.TransientWorldCleanupSupport; import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.core.lifecycle.WorldLifecycleStaging; -import art.arcane.iris.core.link.IrisPapiExpansion; +import art.arcane.iris.api.terrain.IrisTerrainService; +import art.arcane.iris.core.link.IrisPapiInstaller; +import art.arcane.iris.core.link.IrisPapiListener; +import art.arcane.iris.core.link.IrisPapiState; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.link.MultiverseCoreLink; import art.arcane.iris.core.loader.IrisData; @@ -71,6 +74,7 @@ import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.LogLevel; import art.arcane.volmlib.integration.ReloadAware; import art.arcane.volmlib.util.bukkit.WorldIdentity; +import art.arcane.volmlib.util.bukkit.papi.PlaceholderRegistration; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.exceptions.IrisException; @@ -173,6 +177,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } private final AtomicBoolean alreadyDrained = new AtomicBoolean(false); + private volatile PlaceholderRegistration papiRegistration; + private volatile IrisPapiListener papiListener; + private volatile IrisPapiState papiState; private KMap, IrisService> services; public static VolmitSender getSender() { @@ -1000,6 +1007,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } public void onDisable() { + teardownPapi(); if (IrisSafeguard.isForceShutdown()) return; if (alreadyDrained.compareAndSet(false, true)) { drainWorldGenerators("onDisable", 30L); @@ -1023,6 +1031,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { @Override public void onPreUnload(ReloadAware.PreUnloadReason reason) { + teardownPapi(); if (!alreadyDrained.compareAndSet(false, true)) { Iris.info("Pre-unload hook skipped; Iris already drained."); return; @@ -1083,8 +1092,53 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } private void setupPapi() { - if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) { - new IrisPapiExpansion().register(); + if (!PlaceholderRegistration.isPlaceholderApiEnabled()) { + return; + } + + IrisPapiState state = new IrisPapiState(() -> IrisServices.getOrNull(IrisTerrainService.class)); + PlaceholderRegistration registration = new PlaceholderRegistration(getLogger()); + + if (!IrisPapiInstaller.install(registration, state, getLogger())) { + return; + } + + IrisPapiListener listener = new IrisPapiListener(state); + + try { + Bukkit.getPluginManager().registerEvents(listener, this); + } catch (Throwable failure) { + registration.unregister(); + Iris.warn("Failed to attach the Iris PlaceholderAPI listener: " + + failure.getClass().getName() + ": " + failure.getMessage()); + return; + } + + papiState = state; + papiListener = listener; + papiRegistration = registration; + } + + private void teardownPapi() { + IrisPapiListener listener = papiListener; + papiListener = null; + + if (listener != null) { + HandlerList.unregisterAll(listener); + } + + PlaceholderRegistration registration = papiRegistration; + papiRegistration = null; + + if (registration != null) { + registration.unregister(); + } + + IrisPapiState state = papiState; + papiState = null; + + if (state != null) { + state.clear(); } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenPhase.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenPhase.java new file mode 100644 index 000000000..32d916e50 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenPhase.java @@ -0,0 +1,11 @@ +package art.arcane.iris.api.pregen; + +public enum IrisPregenPhase { + STARTED, + TICK, + PAUSED, + RESUMED, + SAVING, + COMPLETED, + CANCELLED +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenProgress.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenProgress.java new file mode 100644 index 000000000..e0365d053 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenProgress.java @@ -0,0 +1,31 @@ +package art.arcane.iris.api.pregen; + +import java.util.Objects; + +public record IrisPregenProgress( + String worldName, + String worldIdentity, + double percent, + long generatedChunks, + long totalChunks, + long remainingChunks, + long failedChunks, + double chunksPerSecond, + long etaMillis, + long elapsedMillis, + String method, + boolean paused) { + public IrisPregenProgress { + Objects.requireNonNull(worldIdentity, "worldIdentity"); + worldName = worldName == null ? worldIdentity : worldName; + method = method == null ? "" : method; + percent = Double.isFinite(percent) ? Math.clamp(percent, 0D, 100D) : 0D; + generatedChunks = Math.max(0L, generatedChunks); + totalChunks = Math.max(0L, totalChunks); + remainingChunks = Math.max(0L, remainingChunks); + failedChunks = Math.max(0L, failedChunks); + chunksPerSecond = Double.isFinite(chunksPerSecond) ? Math.max(0D, chunksPerSecond) : 0D; + etaMillis = Math.max(0L, etaMillis); + elapsedMillis = Math.max(0L, elapsedMillis); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenerationEvent.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenerationEvent.java new file mode 100644 index 000000000..f8b08037f --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/pregen/IrisPregenerationEvent.java @@ -0,0 +1,35 @@ +package art.arcane.iris.api.pregen; + +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +import java.util.Objects; + +public class IrisPregenerationEvent extends Event { + private static final HandlerList HANDLERS = new HandlerList(); + + private final IrisPregenPhase phase; + private final IrisPregenProgress progress; + + public IrisPregenerationEvent(IrisPregenPhase phase, IrisPregenProgress progress) { + this.phase = Objects.requireNonNull(phase, "phase"); + this.progress = Objects.requireNonNull(progress, "progress"); + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } + + public IrisPregenPhase getPhase() { + return phase; + } + + public IrisPregenProgress getProgress() { + return progress; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java new file mode 100644 index 000000000..1d3748e42 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java @@ -0,0 +1,7 @@ +package art.arcane.iris.api.terrain; + +public enum IrisColumnField { + SURFACE_HEIGHT, + SURFACE_KIND, + BIOME_KEY +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnQuery.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnQuery.java new file mode 100644 index 000000000..b1ce3aa3b --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnQuery.java @@ -0,0 +1,61 @@ +package art.arcane.iris.api.terrain; + +import java.util.EnumSet; +import java.util.Objects; + +public record IrisColumnQuery( + int minBlockX, + int minBlockZ, + int maxBlockX, + int maxBlockZ, + int strideBlocks, + EnumSet fields) { + public IrisColumnQuery { + Objects.requireNonNull(fields, "fields"); + if (fields.isEmpty()) { + throw new IllegalArgumentException("at least one field is required"); + } + if (maxBlockX < minBlockX || maxBlockZ < minBlockZ) { + throw new IllegalArgumentException("query bounds are inverted"); + } + if (strideBlocks < 1) { + throw new IllegalArgumentException("strideBlocks must be at least 1"); + } + fields = EnumSet.copyOf(fields); + } + + public static IrisColumnQuery rect( + int minBlockX, + int minBlockZ, + int maxBlockX, + int maxBlockZ, + int strideBlocks, + EnumSet fields) { + return new IrisColumnQuery(minBlockX, minBlockZ, maxBlockX, maxBlockZ, strideBlocks, fields); + } + + public long columnCount() { + long columnsX = (((long) maxBlockX - (long) minBlockX) / strideBlocks) + 1L; + long columnsZ = (((long) maxBlockZ - (long) minBlockZ) / strideBlocks) + 1L; + return saturatedProduct(columnsX, columnsZ); + } + + public long chunkCount() { + long chunksX = ((long) (maxBlockX >> 4) - (long) (minBlockX >> 4)) + 1L; + long chunksZ = ((long) (maxBlockZ >> 4) - (long) (minBlockZ >> 4)) + 1L; + return saturatedProduct(chunksX, chunksZ); + } + + private static long saturatedProduct(long left, long right) { + try { + return Math.multiplyExact(left, right); + } catch (ArithmeticException overflow) { + return Long.MAX_VALUE; + } + } + + @Override + public EnumSet fields() { + return EnumSet.copyOf(fields); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java new file mode 100644 index 000000000..4c6bccffe --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java @@ -0,0 +1,6 @@ +package art.arcane.iris.api.terrain; + +@FunctionalInterface +public interface IrisColumnSink { + void accept(int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey); +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java new file mode 100644 index 000000000..be60360b3 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java @@ -0,0 +1,9 @@ +package art.arcane.iris.api.terrain; + +public enum IrisSurfaceKind { + UNKNOWN, + LAND, + SHORE, + OCEAN, + VOID +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisTerrainService.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisTerrainService.java new file mode 100644 index 000000000..dc4dd7666 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisTerrainService.java @@ -0,0 +1,32 @@ +package art.arcane.iris.api.terrain; + +import org.bukkit.World; + +import java.util.Optional; +import java.util.OptionalInt; + +public interface IrisTerrainService { + boolean isIrisWorld(World world); + + Optional worldInfo(World world); + + OptionalInt surfaceHeight(World world, int blockX, int blockZ); + + IrisSurfaceKind surfaceKind(World world, int blockX, int blockZ); + + Optional surfaceBiomeKey(World world, int blockX, int blockZ); + + Optional surfaceBiomeName(World world, int blockX, int blockZ); + + Optional biomeKey(World world, int blockX, int blockY, int blockZ); + + Optional regionKey(World world, int blockX, int blockZ); + + Optional regionName(World world, int blockX, int blockZ); + + int maxSampleColumns(); + + int maxSampleChunks(); + + boolean sampleColumns(World world, IrisColumnQuery query, IrisColumnSink sink); +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisWorldInfo.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisWorldInfo.java new file mode 100644 index 000000000..617021983 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisWorldInfo.java @@ -0,0 +1,24 @@ +package art.arcane.iris.api.terrain; + +import java.util.Objects; + +public record IrisWorldInfo( + String dimensionKey, + String worldIdentity, + long seed, + int minHeight, + int maxHeight, + int fluidHeight, + boolean studio) { + public IrisWorldInfo { + Objects.requireNonNull(dimensionKey, "dimensionKey"); + Objects.requireNonNull(worldIdentity, "worldIdentity"); + if (maxHeight <= minHeight) { + throw new IllegalArgumentException("maxHeight must exceed minHeight"); + } + } + + public int height() { + return maxHeight - minHeight; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/world/IrisWorldEngineEvent.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/world/IrisWorldEngineEvent.java new file mode 100644 index 000000000..162057879 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/world/IrisWorldEngineEvent.java @@ -0,0 +1,44 @@ +package art.arcane.iris.api.world; + +import art.arcane.iris.api.terrain.IrisWorldInfo; +import org.bukkit.World; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +import java.util.Objects; +import java.util.Optional; + +public class IrisWorldEngineEvent extends Event { + private static final HandlerList HANDLERS = new HandlerList(); + + private final World world; + private final IrisWorldPhase phase; + private final IrisWorldInfo info; + + public IrisWorldEngineEvent(World world, IrisWorldPhase phase, IrisWorldInfo info) { + this.world = Objects.requireNonNull(world, "world"); + this.phase = Objects.requireNonNull(phase, "phase"); + this.info = info; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } + + public World getWorld() { + return world; + } + + public IrisWorldPhase getPhase() { + return phase; + } + + public Optional getInfo() { + return Optional.ofNullable(info); + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/world/IrisWorldPhase.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/world/IrisWorldPhase.java new file mode 100644 index 000000000..51d63f515 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/world/IrisWorldPhase.java @@ -0,0 +1,7 @@ +package art.arcane.iris.api.world; + +public enum IrisWorldPhase { + ENGINE_READY, + ENGINE_HOTLOADED, + ENGINE_CLOSING +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiExpansion.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiExpansion.java index 4b0c9db96..fb3b0c377 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiExpansion.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiExpansion.java @@ -18,100 +18,42 @@ package art.arcane.iris.core.link; -import art.arcane.iris.Iris; -import art.arcane.iris.core.tools.IrisToolbelt; -import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.platform.EngineBukkitOps; -import art.arcane.iris.engine.platform.PlatformChunkGenerator; -import me.clip.placeholderapi.expansion.PlaceholderExpansion; -import org.bukkit.Location; -import org.bukkit.OfflinePlayer; -import org.jetbrains.annotations.NotNull; +import art.arcane.volmlib.util.bukkit.papi.PlaceholderKeyRegistry; +import art.arcane.volmlib.util.bukkit.papi.VolmitPlaceholderExpansion; -// See/update https://app.gitbook.com/@volmitsoftware/s/iris/compatability/papi/ -public class IrisPapiExpansion extends PlaceholderExpansion { - @Override - public @NotNull String getIdentifier() { - return "iris"; +import java.util.Objects; +import java.util.logging.Logger; + +public final class IrisPapiExpansion extends VolmitPlaceholderExpansion { + public static final String IDENTIFIER = "iris"; + public static final String AUTHOR = "Volmit Software"; + public static final String VERSION = "2.0.0"; + public static final String REQUIRED_PLUGIN = "Iris"; + + public IrisPapiExpansion(IrisPapiState state, Logger logger) { + super(IDENTIFIER, AUTHOR, VERSION, REQUIRED_PLUGIN, registry(state), logger); } - @Override - public @NotNull String getAuthor() { - return "Volmit Software"; - } + public static PlaceholderKeyRegistry registry(IrisPapiState state) { + Objects.requireNonNull(state, "state"); - @Override - public @NotNull String getVersion() { - return Iris.instance.getDescription().getVersion(); - } - - @Override - public boolean persist() { - return true; - } - - @Override - public String onRequest(OfflinePlayer player, String p) { - Location l = null; - PlatformChunkGenerator a = null; - - if (player.isOnline() && player.getPlayer() != null) { - l = player.getPlayer().getLocation().add(0, 2, 0); - a = IrisToolbelt.access(l.getWorld()); - } - - if (p.equalsIgnoreCase("biome_name")) { - if (a != null) { - return getBiome(a, l).getName(); - } - } else if (p.equalsIgnoreCase("biome_id")) { - if (a != null) { - return getBiome(a, l).getLoadKey(); - } - } else if (p.equalsIgnoreCase("biome_file")) { - if (a != null) { - return getBiome(a, l).getLoadFile().getPath(); - } - } else if (p.equalsIgnoreCase("region_name")) { - if (a != null) { - return EngineBukkitOps.getRegion(a.getEngine(), l).getName(); - } - } else if (p.equalsIgnoreCase("region_id")) { - if (a != null) { - return EngineBukkitOps.getRegion(a.getEngine(), l).getLoadKey(); - } - } else if (p.equalsIgnoreCase("region_file")) { - if (a != null) { - return EngineBukkitOps.getRegion(a.getEngine(), l).getLoadFile().getPath(); - } - } else if (p.equalsIgnoreCase("terrain_slope")) { - if (a != null) { - return (a.getEngine()) - .getComplex().getSlopeStream() - .get(l.getX(), l.getZ()) + ""; - } - } else if (p.equalsIgnoreCase("terrain_height")) { - if (a != null) { - return Math.round(a.getEngine().getHeight(l.getBlockX(), l.getBlockZ())) + ""; - } - } else if (p.equalsIgnoreCase("world_mode")) { - if (a != null) { - return a.isStudio() ? "Studio" : "Production"; - } - } else if (p.equalsIgnoreCase("world_seed")) { - if (a != null) { - return a.getEngine().getSeedManager().getSeed() + ""; - } - } else if (p.equalsIgnoreCase("world_speed")) { - if (a != null) { - return a.getEngine().getGeneratedPerSecond() + "/s"; - } - } - - return null; - } - - private IrisBiome getBiome(PlatformChunkGenerator a, Location l) { - return a.getEngine().getBiome(l.getBlockX(), l.getBlockY() - l.getWorld().getMinHeight(), l.getBlockZ()); + return PlaceholderKeyRegistry.builder() + .key("available", state::available) + .key("world.available", state::worldAvailable) + .key("world.biome", state::biome) + .key("world.biome-key", state::biomeKey) + .key("world.region", state::region) + .key("world.region-key", state::regionKey) + .key("world.dimension", state::dimension) + .key("pregen.available", state::pregenAvailable) + .key("pregen.world", state::pregenWorld) + .key("pregen.percent", state::pregenPercent) + .key("pregen.eta", state::pregenEta) + .key("pregen.eta-text", state::pregenEtaText) + .key("pregen.chunks", state::pregenChunks) + .key("pregen.total", state::pregenTotal) + .key("pregen.chunks-per-second", state::pregenChunksPerSecond) + .key("pregen.paused", state::pregenPaused) + .build(); } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiInstaller.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiInstaller.java new file mode 100644 index 000000000..5341c02b2 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiInstaller.java @@ -0,0 +1,14 @@ +package art.arcane.iris.core.link; + +import art.arcane.volmlib.util.bukkit.papi.PlaceholderRegistration; + +import java.util.logging.Logger; + +public final class IrisPapiInstaller { + private IrisPapiInstaller() { + } + + public static boolean install(PlaceholderRegistration registration, IrisPapiState state, Logger logger) { + return registration.register(() -> new IrisPapiExpansion(state, logger)); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiListener.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiListener.java new file mode 100644 index 000000000..3256659a3 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiListener.java @@ -0,0 +1,104 @@ +package art.arcane.iris.core.link; + +import art.arcane.iris.api.pregen.IrisPregenerationEvent; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerPortalEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerTeleportEvent; + +import java.util.Objects; +import java.util.UUID; + +public final class IrisPapiListener implements Listener { + private final IrisPapiState state; + + public IrisPapiListener(IrisPapiState state) { + this.state = Objects.requireNonNull(state, "state"); + } + + static void track(IrisPapiState state, UUID playerId, Location location) { + publish(state, playerId, location, false); + } + + static void trackNow(IrisPapiState state, UUID playerId, Location location) { + publish(state, playerId, location, true); + } + + private static void publish(IrisPapiState state, UUID playerId, Location location, boolean immediate) { + if (state == null || playerId == null || location == null) { + return; + } + + World world = location.getWorld(); + + if (world == null) { + return; + } + + if (immediate) { + state.trackPositionNow(playerId, world, location.getBlockX(), location.getBlockZ()); + return; + } + + state.trackPosition(playerId, world, location.getBlockX(), location.getBlockZ()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerMove(PlayerMoveEvent event) { + Player player = event.getPlayer(); + track(state, player == null ? null : player.getUniqueId(), event.getTo()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerTeleport(PlayerTeleportEvent event) { + Player player = event.getPlayer(); + trackNow(state, player == null ? null : player.getUniqueId(), event.getTo()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerPortal(PlayerPortalEvent event) { + Player player = event.getPlayer(); + trackNow(state, player == null ? null : player.getUniqueId(), event.getTo()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerRespawn(PlayerRespawnEvent event) { + Player player = event.getPlayer(); + trackNow(state, player == null ? null : player.getUniqueId(), event.getRespawnLocation()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + trackNow(state, player == null ? null : player.getUniqueId(), player == null ? null : player.getLocation()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerChangedWorld(PlayerChangedWorldEvent event) { + Player player = event.getPlayer(); + trackNow(state, player == null ? null : player.getUniqueId(), player == null ? null : player.getLocation()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerQuit(PlayerQuitEvent event) { + Player player = event.getPlayer(); + + if (player != null) { + state.release(player.getUniqueId()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPregeneration(IrisPregenerationEvent event) { + state.publishPregen(event.getPhase(), event.getProgress()); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiPosition.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiPosition.java new file mode 100644 index 000000000..b6df177dd --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiPosition.java @@ -0,0 +1,9 @@ +package art.arcane.iris.core.link; + +import org.bukkit.World; + +public record IrisPapiPosition(World world, int blockX, int blockZ, long publishedAtMs) { + public boolean sameColumn(World other, int otherBlockX, int otherBlockZ) { + return world == other && blockX == otherBlockX && blockZ == otherBlockZ; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiPregenView.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiPregenView.java new file mode 100644 index 000000000..2358daa68 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiPregenView.java @@ -0,0 +1,51 @@ +package art.arcane.iris.core.link; + +import art.arcane.iris.api.pregen.IrisPregenProgress; +import art.arcane.volmlib.util.bukkit.papi.PlaceholderValues; + +public record IrisPapiPregenView( + String world, + String percent, + String eta, + String etaText, + String chunks, + String total, + String chunksPerSecond, + String paused) { + private static final long MILLIS_PER_SECOND = 1_000L; + private static final long SECONDS_PER_MINUTE = 60L; + private static final long SECONDS_PER_HOUR = 3_600L; + + public static IrisPapiPregenView of(IrisPregenProgress progress) { + if (progress == null) { + return null; + } + + return new IrisPapiPregenView( + PlaceholderValues.text(progress.worldName()), + PlaceholderValues.num(progress.percent()), + PlaceholderValues.count(progress.etaMillis() / MILLIS_PER_SECOND), + duration(progress.etaMillis()), + PlaceholderValues.count(progress.generatedChunks()), + PlaceholderValues.count(progress.totalChunks()), + PlaceholderValues.num(progress.chunksPerSecond()), + PlaceholderValues.bool(progress.paused())); + } + + static String duration(long millis) { + long totalSeconds = millis / MILLIS_PER_SECOND; + long hours = totalSeconds / SECONDS_PER_HOUR; + long minutes = (totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE; + long seconds = totalSeconds % SECONDS_PER_MINUTE; + + if (hours > 0L) { + return hours + "h " + minutes + "m"; + } + + if (minutes > 0L) { + return minutes + "m " + seconds + "s"; + } + + return seconds + "s"; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiState.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiState.java new file mode 100644 index 000000000..13c282bec --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiState.java @@ -0,0 +1,204 @@ +package art.arcane.iris.core.link; + +import art.arcane.iris.api.pregen.IrisPregenPhase; +import art.arcane.iris.api.pregen.IrisPregenProgress; +import art.arcane.iris.api.terrain.IrisTerrainService; +import art.arcane.iris.api.terrain.IrisWorldInfo; +import art.arcane.volmlib.util.bukkit.papi.PlaceholderSnapshot; +import art.arcane.volmlib.util.bukkit.papi.PlaceholderValues; +import art.arcane.volmlib.util.bukkit.papi.PlayerSnapshotStore; +import org.bukkit.World; + +import java.util.Objects; +import java.util.UUID; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +public final class IrisPapiState { + static final long VIEW_TTL_MS = 1_000L; + static final long POSITION_INTERVAL_MS = 1_000L; + + private final Supplier terrain; + private final LongSupplier clock; + private final PlayerSnapshotStore positions = new PlayerSnapshotStore<>(); + private final PlayerSnapshotStore views = new PlayerSnapshotStore<>(); + private final PlaceholderSnapshot pregen = new PlaceholderSnapshot<>(); + + public IrisPapiState(Supplier terrain) { + this(terrain, System::currentTimeMillis); + } + + public IrisPapiState(Supplier terrain, LongSupplier clock) { + this.terrain = Objects.requireNonNull(terrain, "terrain"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + public void trackPosition(UUID playerId, World world, int blockX, int blockZ) { + publishPosition(playerId, world, blockX, blockZ, false); + } + + public void trackPositionNow(UUID playerId, World world, int blockX, int blockZ) { + publishPosition(playerId, world, blockX, blockZ, true); + } + + private void publishPosition(UUID playerId, World world, int blockX, int blockZ, boolean immediate) { + if (playerId == null || world == null) { + return; + } + + IrisPapiPosition current = positions.get(playerId); + + if (current != null && current.sameColumn(world, blockX, blockZ)) { + return; + } + + long now = clock.getAsLong(); + + if (!immediate && current != null && current.world() == world + && now - current.publishedAtMs() < POSITION_INTERVAL_MS) { + return; + } + + positions.publish(playerId, new IrisPapiPosition(world, blockX, blockZ, now)); + } + + public void release(UUID playerId) { + positions.publish(playerId, null); + views.publish(playerId, null); + } + + public void publishPregen(IrisPregenPhase phase, IrisPregenProgress progress) { + if (phase == null || progress == null) { + return; + } + + pregen.publish(switch (phase) { + case STARTED, TICK, PAUSED, RESUMED, SAVING -> IrisPapiPregenView.of(progress); + case COMPLETED, CANCELLED -> null; + }); + } + + public void clear() { + positions.clear(); + views.clear(); + pregen.publish(null); + } + + public String available(UUID playerId) { + return PlaceholderValues.bool(terrain.get() != null); + } + + public String worldAvailable(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.FALSE : view.available(); + } + + public String biome(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.biome(); + } + + public String biomeKey(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.biomeKey(); + } + + public String region(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.region(); + } + + public String regionKey(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.regionKey(); + } + + public String dimension(UUID playerId) { + IrisPapiWorldView view = viewOf(playerId); + return view == null ? PlaceholderValues.UNAVAILABLE : view.dimension(); + } + + public String pregenAvailable(UUID playerId) { + return pregen.available(); + } + + public String pregenWorld(UUID playerId) { + IrisPapiPregenView view = pregen.get(); + return view == null ? PlaceholderValues.UNAVAILABLE : view.world(); + } + + public String pregenPercent(UUID playerId) { + IrisPapiPregenView view = pregen.get(); + return view == null ? PlaceholderValues.UNAVAILABLE : view.percent(); + } + + public String pregenEta(UUID playerId) { + IrisPapiPregenView view = pregen.get(); + return view == null ? PlaceholderValues.UNAVAILABLE : view.eta(); + } + + public String pregenEtaText(UUID playerId) { + IrisPapiPregenView view = pregen.get(); + return view == null ? PlaceholderValues.UNAVAILABLE : view.etaText(); + } + + public String pregenChunks(UUID playerId) { + IrisPapiPregenView view = pregen.get(); + return view == null ? PlaceholderValues.UNAVAILABLE : view.chunks(); + } + + public String pregenTotal(UUID playerId) { + IrisPapiPregenView view = pregen.get(); + return view == null ? PlaceholderValues.UNAVAILABLE : view.total(); + } + + public String pregenChunksPerSecond(UUID playerId) { + IrisPapiPregenView view = pregen.get(); + return view == null ? PlaceholderValues.UNAVAILABLE : view.chunksPerSecond(); + } + + public String pregenPaused(UUID playerId) { + IrisPapiPregenView view = pregen.get(); + return view == null ? PlaceholderValues.UNAVAILABLE : view.paused(); + } + + private IrisPapiWorldView viewOf(UUID playerId) { + IrisPapiPosition position = positions.get(playerId); + + if (position == null) { + return null; + } + + IrisPapiWorldView cached = views.get(playerId); + long now = clock.getAsLong(); + + if (cached != null && cached.position() == position && now - cached.builtAtMs() < VIEW_TTL_MS) { + return cached; + } + + IrisPapiWorldView built = build(position, now); + views.publish(playerId, built); + return built; + } + + private IrisPapiWorldView build(IrisPapiPosition position, long now) { + IrisTerrainService service = terrain.get(); + World world = position.world(); + + if (service == null || !service.isIrisWorld(world)) { + return IrisPapiWorldView.absent(position, now); + } + + int blockX = position.blockX(); + int blockZ = position.blockZ(); + + return IrisPapiWorldView.present( + position, + now, + service.surfaceBiomeName(world, blockX, blockZ), + service.surfaceBiomeKey(world, blockX, blockZ), + service.regionName(world, blockX, blockZ), + service.regionKey(world, blockX, blockZ), + service.worldInfo(world).map(IrisWorldInfo::dimensionKey)); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiWorldView.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiWorldView.java new file mode 100644 index 000000000..b5692f485 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/link/IrisPapiWorldView.java @@ -0,0 +1,50 @@ +package art.arcane.iris.core.link; + +import art.arcane.volmlib.util.bukkit.papi.PlaceholderValues; + +import java.util.Optional; + +public record IrisPapiWorldView( + IrisPapiPosition position, + long builtAtMs, + String available, + String biome, + String biomeKey, + String region, + String regionKey, + String dimension) { + public static IrisPapiWorldView absent(IrisPapiPosition position, long builtAtMs) { + return new IrisPapiWorldView( + position, + builtAtMs, + PlaceholderValues.FALSE, + PlaceholderValues.UNAVAILABLE, + PlaceholderValues.UNAVAILABLE, + PlaceholderValues.UNAVAILABLE, + PlaceholderValues.UNAVAILABLE, + PlaceholderValues.UNAVAILABLE); + } + + public static IrisPapiWorldView present( + IrisPapiPosition position, + long builtAtMs, + Optional biome, + Optional biomeKey, + Optional region, + Optional regionKey, + Optional dimension) { + return new IrisPapiWorldView( + position, + builtAtMs, + PlaceholderValues.TRUE, + text(biome), + text(biomeKey), + text(region), + text(regionKey), + text(dimension)); + } + + static String text(Optional value) { + return value == null || value.isEmpty() ? PlaceholderValues.UNAVAILABLE : PlaceholderValues.text(value.get()); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java index 9f5b2de2e..4b4d8adfc 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java @@ -18,11 +18,13 @@ package art.arcane.iris.core.runtime; +import art.arcane.iris.api.world.IrisWorldPhase; import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.datapack.DatapackIngestService; import art.arcane.iris.core.events.IrisEngineHotloadEvent; import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.project.IrisProject; +import art.arcane.iris.core.service.IrisApiEventSVC; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.core.tools.WorldMaintenance; import art.arcane.iris.engine.framework.Engine; @@ -57,6 +59,7 @@ public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { @Override public void fireHotloadEvent(Engine engine) { IrisPlatforms.get().callEvent(new IrisEngineHotloadEvent(engine)); + IrisApiEventSVC.fireWorldPhase(BukkitWorldBinding.world(engine.getWorld()), IrisWorldPhase.ENGINE_HOTLOADED); } @Override diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisApiEventSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisApiEventSVC.java new file mode 100644 index 000000000..b90df0706 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisApiEventSVC.java @@ -0,0 +1,106 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.Iris; +import art.arcane.iris.api.pregen.IrisPregenPhase; +import art.arcane.iris.api.pregen.IrisPregenProgress; +import art.arcane.iris.api.pregen.IrisPregenerationEvent; +import art.arcane.iris.api.terrain.IrisWorldInfo; +import art.arcane.iris.api.world.IrisWorldEngineEvent; +import art.arcane.iris.api.world.IrisWorldPhase; +import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.core.pregenerator.PregenApiPhase; +import art.arcane.iris.core.pregenerator.PregenApiSink; +import art.arcane.iris.core.service.terrain.IrisWorldInfoFactory; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.plugin.IrisService; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.event.Event; + +public class IrisApiEventSVC implements IrisService, PregenApiSink { + public static void fireWorldPhase(World world, IrisWorldPhase phase) { + if (phase == null) { + return; + } + if (world == null) { + IrisLogging.debug("Iris world API skipped phase " + phase + + " because the engine has no bound platform world."); + return; + } + + try { + deliver(new IrisWorldEngineEvent(world, phase, describe(world, phase))); + } catch (Throwable error) { + IrisLogging.reportError("Iris world API dispatch failed for phase " + phase + + " on world \"" + world.getName() + "\".", error); + } + } + + private static IrisWorldInfo describe(World world, IrisWorldPhase phase) { + try { + return IrisWorldInfoFactory.forWorld(world); + } catch (Throwable error) { + IrisLogging.reportError("Iris world API could not describe world \"" + world.getName() + + "\" for phase " + phase + "; the event is delivered without world info.", error); + return null; + } + } + + private static void deliver(Event event) { + if (Bukkit.isPrimaryThread()) { + Bukkit.getPluginManager().callEvent(event); + return; + } + + Iris.callEvent(event); + } + + private static IrisPregenPhase toApi(PregenApiPhase phase) { + return switch (phase) { + case STARTED -> IrisPregenPhase.STARTED; + case TICK -> IrisPregenPhase.TICK; + case PAUSED -> IrisPregenPhase.PAUSED; + case RESUMED -> IrisPregenPhase.RESUMED; + case SAVING -> IrisPregenPhase.SAVING; + case COMPLETED -> IrisPregenPhase.COMPLETED; + case CANCELLED -> IrisPregenPhase.CANCELLED; + }; + } + + private static IrisPregenProgress toApi(PregeneratorJob.PregenProgress progress) { + return new IrisPregenProgress( + progress.worldName(), + progress.worldIdentity(), + progress.percent(), + progress.generated(), + progress.totalChunks(), + progress.chunksRemaining(), + progress.failed(), + progress.chunksPerSecond(), + progress.eta(), + progress.elapsed(), + progress.method(), + progress.paused() + ); + } + + @Override + public void onEnable() { + IrisServices.register(PregenApiSink.class, this); + } + + @Override + public void onDisable() { + IrisServices.remove(PregenApiSink.class); + } + + @Override + public void pregen(PregenApiPhase phase, PregeneratorJob.PregenProgress progress) { + if (phase == null || progress == null || progress.worldIdentity() == null) { + return; + } + + Iris.callEvent(new IrisPregenerationEvent(toApi(phase), toApi(progress))); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java index 42cae3ca6..dfcbbe035 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java @@ -61,6 +61,7 @@ public final class IrisEngineSVC implements IrisService { new AtomicReference<>(IrisTelemetrySnapshot.EMPTY); private final Map> pendingRegistrations = new HashMap<>(); private final Map worlds = new ConcurrentHashMap<>(); + private final IrisWorldPhaseLedger phases = new IrisWorldPhaseLedger(); private volatile ScheduledThreadPoolExecutor service; private volatile ScheduledFuture metricsTask; @@ -105,26 +106,28 @@ public final class IrisEngineSVC implements IrisService { activeMetricsTask.cancel(false); } - List registeredWorlds; - List reservedCloses = new ArrayList<>(); + List teardowns = new ArrayList<>(); List> generatorCloses; synchronized (registrationLock) { - registeredWorlds = List.copyOf(worlds.values()); + for (Map.Entry entry : worlds.entrySet()) { + Registered registered = entry.getValue(); + registered.close(); + teardowns.add(new Teardown(entry.getKey(), registered, reserveClose(registered))); + } worlds.clear(); pendingRegistrations.clear(); - for (Registered registered : registeredWorlds) { - registered.close(); - reservedCloses.add(reserveClose(registered)); - } generatorCloses = new ArrayList<>(closingGenerators.size()); for (ClosingGenerator closing : closingGenerators) { generatorCloses.add(closing.completion()); } } + for (Teardown teardown : teardowns) { + phases.closing(teardown.world()); + } shutdownAndDrain(activeService); - for (int index = 0; index < registeredWorlds.size(); index++) { - startClose(registeredWorlds.get(index), reservedCloses.get(index)); + for (Teardown teardown : teardowns) { + startClose(teardown.registered(), teardown.closing()); } awaitGeneratorShutdown(generatorCloses); resetMetrics(); @@ -177,6 +180,7 @@ public final class IrisEngineSVC implements IrisService { Registered replaced = null; ClosingGenerator replacementClose = null; CompletableFuture retryAfter = null; + boolean registered = false; try { synchronized (registrationLock) { if (service != activeService || activeService.isShutdown() || !isCurrentWorld(world)) { @@ -202,6 +206,7 @@ public final class IrisEngineSVC implements IrisService { Registration registration = new Registration(world.getName(), access, registrationIdentity); worlds.put(world, new Registered(registration, activeService)); pendingRegistrations.remove(world); + registered = true; } } } @@ -211,7 +216,11 @@ public final class IrisEngineSVC implements IrisService { } } + if (registered) { + phases.ready(world); + } if (replacementClose != null) { + phases.closing(world); retryRegistrationAfterClose(world, retryAfter); startClose(replaced, replacementClose); return; @@ -237,6 +246,7 @@ public final class IrisEngineSVC implements IrisService { } } if (closing != null) { + phases.closing(world); startClose(registered, closing); } } @@ -697,4 +707,7 @@ public final class IrisEngineSVC implements IrisService { private record ClosingGenerator(RegistrationIdentity registrationIdentity, CompletableFuture completion) { } + + private record Teardown(World world, Registered registered, ClosingGenerator closing) { + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java new file mode 100644 index 000000000..ee1ed09ec --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java @@ -0,0 +1,316 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.api.terrain.IrisColumnField; +import art.arcane.iris.api.terrain.IrisColumnQuery; +import art.arcane.iris.api.terrain.IrisColumnSink; +import art.arcane.iris.api.terrain.IrisSurfaceKind; +import art.arcane.iris.api.terrain.IrisTerrainService; +import art.arcane.iris.api.terrain.IrisWorldInfo; +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.service.terrain.IrisApiFaultGuard; +import art.arcane.iris.core.service.terrain.IrisColumnWalk; +import art.arcane.iris.core.service.terrain.IrisSampleLimits; +import art.arcane.iris.core.service.terrain.IrisSurfaceClassifier; +import art.arcane.iris.core.service.terrain.IrisWorldInfoFactory; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.plugin.IrisService; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.plugin.ServicePriority; + +import java.util.EnumSet; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.concurrent.atomic.AtomicBoolean; + +public class IrisTerrainSVC implements IrisService, IrisTerrainService { + private static final long FAULT_REPORT_INTERVAL_MILLIS = 60_000L; + + private final AtomicBoolean serviceEnabled = new AtomicBoolean(); + private final IrisApiFaultGuard queryFaults = new IrisApiFaultGuard(FAULT_REPORT_INTERVAL_MILLIS); + private final IrisApiFaultGuard sinkFaults = new IrisApiFaultGuard(FAULT_REPORT_INTERVAL_MILLIS); + + @Override + public void onEnable() { + serviceEnabled.set(true); + Bukkit.getServicesManager().register( + IrisTerrainService.class, + this, + BukkitPlatform.plugin(), + ServicePriority.Normal + ); + IrisServices.register(IrisTerrainService.class, this); + } + + @Override + public void onDisable() { + serviceEnabled.set(false); + Bukkit.getServicesManager().unregister(IrisTerrainService.class, this); + IrisServices.remove(IrisTerrainService.class); + } + + @Override + public boolean isIrisWorld(World world) { + return generatorOf(world) != null; + } + + @Override + public Optional worldInfo(World world) { + PlatformChunkGenerator generator = liveGeneratorOf(world); + if (generator == null) { + return Optional.empty(); + } + + try { + return Optional.ofNullable(IrisWorldInfoFactory.from(generator)); + } catch (Throwable error) { + reportQueryFault("worldInfo", world, error); + return Optional.empty(); + } + } + + @Override + public OptionalInt surfaceHeight(World world, int blockX, int blockZ) { + Engine engine = liveEngineOf(world); + if (engine == null) { + return OptionalInt.empty(); + } + + try { + return OptionalInt.of(engine.getHeight(blockX, blockZ) + engine.getMinHeight()); + } catch (Throwable error) { + reportQueryFault("surfaceHeight", world, error); + return OptionalInt.empty(); + } + } + + @Override + public IrisSurfaceKind surfaceKind(World world, int blockX, int blockZ) { + Engine engine = liveEngineOf(world); + if (engine == null) { + return IrisSurfaceKind.UNKNOWN; + } + + try { + int surface = engine.getHeight(blockX, blockZ); + int fluid = engine.getDimension().getFluidHeight(); + InferredType inferredType = null; + if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) { + IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ); + inferredType = biome == null ? null : biome.getInferredType(); + } + return IrisSurfaceClassifier.classify(surface, fluid, inferredType); + } catch (Throwable error) { + reportQueryFault("surfaceKind", world, error); + return IrisSurfaceKind.UNKNOWN; + } + } + + @Override + public Optional surfaceBiomeKey(World world, int blockX, int blockZ) { + Engine engine = liveEngineOf(world); + if (engine == null) { + return Optional.empty(); + } + + try { + return key(engine.getSurfaceBiome(blockX, blockZ)); + } catch (Throwable error) { + reportQueryFault("surfaceBiomeKey", world, error); + return Optional.empty(); + } + } + + @Override + public Optional surfaceBiomeName(World world, int blockX, int blockZ) { + Engine engine = liveEngineOf(world); + if (engine == null) { + return Optional.empty(); + } + + try { + return name(engine.getSurfaceBiome(blockX, blockZ)); + } catch (Throwable error) { + reportQueryFault("surfaceBiomeName", world, error); + return Optional.empty(); + } + } + + @Override + public Optional biomeKey(World world, int blockX, int blockY, int blockZ) { + Engine engine = liveEngineOf(world); + if (engine == null) { + return Optional.empty(); + } + + try { + return key(engine.getBiome(blockX, blockY - engine.getMinHeight(), blockZ)); + } catch (Throwable error) { + reportQueryFault("biomeKey", world, error); + return Optional.empty(); + } + } + + @Override + public Optional regionKey(World world, int blockX, int blockZ) { + Engine engine = liveEngineOf(world); + if (engine == null) { + return Optional.empty(); + } + + try { + IrisRegion region = engine.getRegion(blockX, blockZ); + String loadKey = region == null ? null : region.getLoadKey(); + return loadKey == null || loadKey.isEmpty() ? Optional.empty() : Optional.of(loadKey); + } catch (Throwable error) { + reportQueryFault("regionKey", world, error); + return Optional.empty(); + } + } + + @Override + public Optional regionName(World world, int blockX, int blockZ) { + Engine engine = liveEngineOf(world); + if (engine == null) { + return Optional.empty(); + } + + try { + IrisRegion region = engine.getRegion(blockX, blockZ); + String name = region == null ? null : region.getName(); + return name == null || name.isEmpty() ? Optional.empty() : Optional.of(name); + } catch (Throwable error) { + reportQueryFault("regionName", world, error); + return Optional.empty(); + } + } + + @Override + public int maxSampleColumns() { + return IrisSampleLimits.maxColumns(noiseCacheChunks()); + } + + @Override + public int maxSampleChunks() { + return IrisSampleLimits.maxChunks(noiseCacheChunks()); + } + + @Override + public boolean sampleColumns(World world, IrisColumnQuery query, IrisColumnSink sink) { + if (query == null || sink == null) { + return false; + } + + Engine engine = liveEngineOf(world); + if (engine == null) { + return false; + } + + int noiseCacheChunks = noiseCacheChunks(); + if (!IrisSampleLimits.withinLimits( + query, + IrisSampleLimits.maxColumns(noiseCacheChunks), + IrisSampleLimits.maxChunks(noiseCacheChunks))) { + return false; + } + + EnumSet fields = query.fields(); + boolean wantHeight = fields.contains(IrisColumnField.SURFACE_HEIGHT); + boolean wantKind = fields.contains(IrisColumnField.SURFACE_KIND); + boolean wantBiome = fields.contains(IrisColumnField.BIOME_KEY); + + try { + int minHeight = engine.getMinHeight(); + int fluid = engine.getDimension().getFluidHeight(); + long visited = IrisColumnWalk.walk(query, (int blockX, int blockZ) -> { + if (engine.isClosed()) { + return false; + } + + int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0; + boolean needsBiome = wantBiome + || (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)); + IrisBiome biome = needsBiome ? engine.getSurfaceBiome(blockX, blockZ) : null; + IrisSurfaceKind kind = wantKind + ? IrisSurfaceClassifier.classify(surface, fluid, biome == null ? null : biome.getInferredType()) + : IrisSurfaceKind.UNKNOWN; + String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null; + sink.accept(blockX, blockZ, wantHeight ? surface + minHeight : -1, kind, biomeKey); + return true; + }); + return visited == query.columnCount(); + } catch (Throwable error) { + reportSinkFault(world, error); + return false; + } + } + + private static Optional key(IrisBiome biome) { + String loadKey = biome == null ? null : biome.getLoadKey(); + return loadKey == null || loadKey.isEmpty() ? Optional.empty() : Optional.of(loadKey); + } + + private static Optional name(IrisBiome biome) { + String name = biome == null ? null : biome.getName(); + return name == null || name.isEmpty() ? Optional.empty() : Optional.of(name); + } + + private static int noiseCacheChunks() { + return IrisSettings.get().getPerformance().getNoiseCacheSize(); + } + + static boolean answerable(boolean serviceEnabled, boolean worldPresent) { + return serviceEnabled && worldPresent; + } + + private PlatformChunkGenerator generatorOf(World world) { + if (!answerable(serviceEnabled.get(), world != null)) { + return null; + } + + ChunkGenerator generator = world.getGenerator(); + return generator instanceof PlatformChunkGenerator platform ? platform : null; + } + + private PlatformChunkGenerator liveGeneratorOf(World world) { + PlatformChunkGenerator generator = generatorOf(world); + return generator == null || generator.isClosing() ? null : generator; + } + + private static Engine engineOf(PlatformChunkGenerator generator) { + if (generator == null) { + return null; + } + + Engine engine = generator.getEngine(); + return engine == null || engine.isClosed() ? null : engine; + } + + private Engine liveEngineOf(World world) { + return engineOf(liveGeneratorOf(world)); + } + + private void reportQueryFault(String operation, World world, Throwable error) { + if (queryFaults.record(System.currentTimeMillis())) { + IrisLogging.reportError("Iris terrain API query \"" + operation + "\" failed for world \"" + + (world == null ? "null" : world.getName()) + "\" (" + queryFaults.faults() + + " terrain API query faults so far).", error); + } + } + + private void reportSinkFault(World world, Throwable error) { + if (sinkFaults.record(System.currentTimeMillis())) { + IrisLogging.reportError("Iris terrain API column sample failed for world \"" + + (world == null ? "null" : world.getName()) + "\" (" + sinkFaults.faults() + + " terrain API sample faults so far). A third-party sink that throws is treated as a refusal.", error); + } + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisWorldPhaseLedger.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisWorldPhaseLedger.java new file mode 100644 index 000000000..76a2d3849 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisWorldPhaseLedger.java @@ -0,0 +1,49 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.api.world.IrisWorldPhase; +import org.bukkit.World; + +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +final class IrisWorldPhaseLedger { + @FunctionalInterface + interface Dispatch { + void fire(World world, IrisWorldPhase phase); + } + + private final Set announced = ConcurrentHashMap.newKeySet(); + private final Dispatch dispatch; + + IrisWorldPhaseLedger() { + this(IrisApiEventSVC::fireWorldPhase); + } + + IrisWorldPhaseLedger(Dispatch dispatch) { + this.dispatch = Objects.requireNonNull(dispatch, "dispatch"); + } + + void ready(World world) { + UUID identity = identityOf(world); + if (identity == null || !announced.add(identity)) { + return; + } + + dispatch.fire(world, IrisWorldPhase.ENGINE_READY); + } + + void closing(World world) { + UUID identity = identityOf(world); + if (identity == null || !announced.remove(identity)) { + return; + } + + dispatch.fire(world, IrisWorldPhase.ENGINE_CLOSING); + } + + private static UUID identityOf(World world) { + return world == null ? null : world.getUID(); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisApiFaultGuard.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisApiFaultGuard.java new file mode 100644 index 000000000..9d1aa8950 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisApiFaultGuard.java @@ -0,0 +1,31 @@ +package art.arcane.iris.core.service.terrain; + +import java.util.concurrent.atomic.AtomicLong; + +public final class IrisApiFaultGuard { + private static final long NEVER = Long.MIN_VALUE; + + private final long reportIntervalMillis; + private final AtomicLong faults = new AtomicLong(); + private final AtomicLong lastReportedAt = new AtomicLong(NEVER); + + public IrisApiFaultGuard(long reportIntervalMillis) { + if (reportIntervalMillis < 0L) { + throw new IllegalArgumentException("reportIntervalMillis must not be negative"); + } + this.reportIntervalMillis = reportIntervalMillis; + } + + public long faults() { + return faults.get(); + } + + public boolean record(long nowMillis) { + faults.incrementAndGet(); + long last = lastReportedAt.get(); + if (last != NEVER && nowMillis - last < reportIntervalMillis) { + return false; + } + return lastReportedAt.compareAndSet(last, nowMillis); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisColumnWalk.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisColumnWalk.java new file mode 100644 index 000000000..11beae8d8 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisColumnWalk.java @@ -0,0 +1,49 @@ +package art.arcane.iris.core.service.terrain; + +import art.arcane.iris.api.terrain.IrisColumnQuery; + +public final class IrisColumnWalk { + private IrisColumnWalk() { + } + + public static long walk(IrisColumnQuery query, ColumnVisitor visitor) { + int stride = query.strideBlocks(); + int minChunkX = query.minBlockX() >> 4; + int maxChunkX = query.maxBlockX() >> 4; + int minChunkZ = query.minBlockZ() >> 4; + int maxChunkZ = query.maxBlockZ() >> 4; + long visited = 0L; + + for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) { + int chunkMinBlockZ = Math.max(query.minBlockZ(), chunkZ << 4); + int chunkMaxBlockZ = Math.min(query.maxBlockZ(), (chunkZ << 4) + 15); + + for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) { + int chunkMinBlockX = Math.max(query.minBlockX(), chunkX << 4); + int chunkMaxBlockX = Math.min(query.maxBlockX(), (chunkX << 4) + 15); + + for (int blockZ = align(query.minBlockZ(), chunkMinBlockZ, stride); blockZ <= chunkMaxBlockZ; blockZ += stride) { + for (int blockX = align(query.minBlockX(), chunkMinBlockX, stride); blockX <= chunkMaxBlockX; blockX += stride) { + if (!visitor.visit(blockX, blockZ)) { + return visited; + } + visited++; + } + } + } + } + + return visited; + } + + private static int align(int origin, int lowerBound, int stride) { + long offset = (long) lowerBound - (long) origin; + long steps = (offset + stride - 1L) / stride; + return (int) (origin + steps * stride); + } + + @FunctionalInterface + public interface ColumnVisitor { + boolean visit(int blockX, int blockZ); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSampleLimits.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSampleLimits.java new file mode 100644 index 000000000..0409a77f3 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSampleLimits.java @@ -0,0 +1,24 @@ +package art.arcane.iris.core.service.terrain; + +import art.arcane.iris.api.terrain.IrisColumnQuery; + +public final class IrisSampleLimits { + public static final int MINIMUM_CHUNKS = 64; + public static final int CACHE_SHARE_DIVISOR = 4; + + private IrisSampleLimits() { + } + + public static int maxChunks(int noiseCacheChunks) { + return Math.max(MINIMUM_CHUNKS, noiseCacheChunks / CACHE_SHARE_DIVISOR); + } + + public static int maxColumns(int noiseCacheChunks) { + long columns = (long) maxChunks(noiseCacheChunks) * 256L; + return (int) Math.min(columns, Integer.MAX_VALUE); + } + + public static boolean withinLimits(IrisColumnQuery query, int maxColumns, int maxChunks) { + return query.columnCount() <= maxColumns && query.chunkCount() <= maxChunks; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java new file mode 100644 index 000000000..75be7325a --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java @@ -0,0 +1,25 @@ +package art.arcane.iris.core.service.terrain; + +import art.arcane.iris.api.terrain.IrisSurfaceKind; +import art.arcane.iris.engine.object.InferredType; + +public final class IrisSurfaceClassifier { + private IrisSurfaceClassifier() { + } + + public static boolean requiresSurfaceBiome(int engineSurfaceHeight, int engineFluidHeight) { + return engineSurfaceHeight > 0 && engineSurfaceHeight > engineFluidHeight; + } + + public static IrisSurfaceKind classify(int engineSurfaceHeight, int engineFluidHeight, InferredType inferredType) { + if (engineSurfaceHeight <= 0) { + return IrisSurfaceKind.VOID; + } + + if (engineSurfaceHeight <= engineFluidHeight) { + return IrisSurfaceKind.OCEAN; + } + + return inferredType == InferredType.SHORE ? IrisSurfaceKind.SHORE : IrisSurfaceKind.LAND; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactory.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactory.java new file mode 100644 index 000000000..3226a70ef --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactory.java @@ -0,0 +1,71 @@ +package art.arcane.iris.core.service.terrain; + +import art.arcane.iris.api.terrain.IrisWorldInfo; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import org.bukkit.World; +import org.bukkit.generator.ChunkGenerator; + +public final class IrisWorldInfoFactory { + private IrisWorldInfoFactory() { + } + + public static IrisWorldInfo forWorld(World world) { + if (world == null) { + return null; + } + + ChunkGenerator generator = world.getGenerator(); + return generator instanceof PlatformChunkGenerator platform ? from(platform) : null; + } + + public static IrisWorldInfo from(PlatformChunkGenerator generator) { + if (generator == null) { + return null; + } + + Engine engine = generator.getEngine(); + if (engine == null || engine.isClosed()) { + return null; + } + + IrisWorld irisWorld = engine.getWorld(); + IrisDimension dimension = engine.getDimension(); + if (irisWorld == null || dimension == null) { + return null; + } + + return build( + dimension.getLoadKey(), + irisWorld.identity(), + irisWorld.getRawWorldSeed(), + engine.getMinHeight(), + engine.getMaxHeight(), + dimension.getFluidHeight(), + generator.isStudio()); + } + + static IrisWorldInfo build( + String dimensionKey, + String worldIdentity, + long seed, + int minHeight, + int maxHeight, + int fluidHeightAboveMinimum, + boolean studio) { + if (dimensionKey == null || worldIdentity == null || maxHeight <= minHeight) { + return null; + } + + return new IrisWorldInfo( + dimensionKey, + worldIdentity, + seed, + minHeight, + maxHeight, + fluidHeightAboveMinimum + minHeight, + studio); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisApiServiceDiscoveryTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisApiServiceDiscoveryTest.java new file mode 100644 index 000000000..805be1530 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisApiServiceDiscoveryTest.java @@ -0,0 +1,22 @@ +package art.arcane.iris; + +import art.arcane.iris.core.pregenerator.PregenApiSink; +import art.arcane.iris.core.service.IrisApiEventSVC; +import art.arcane.iris.core.service.IrisTerrainSVC; +import art.arcane.iris.util.common.plugin.IrisService; +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +public class IrisApiServiceDiscoveryTest { + @Test + public void bothApiServicesSatisfyTheServiceLoaderRule() { + assertTrue(Iris.isConcreteImplementation(IrisTerrainSVC.class, IrisService.class)); + assertTrue(Iris.isConcreteImplementation(IrisApiEventSVC.class, IrisService.class)); + } + + @Test + public void theEventServiceIsTheSinkThePregeneratorLooksUp() { + assertTrue(PregenApiSink.class.isAssignableFrom(IrisApiEventSVC.class)); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/IrisApiSurfacePurityTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/IrisApiSurfacePurityTest.java new file mode 100644 index 000000000..164935583 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/IrisApiSurfacePurityTest.java @@ -0,0 +1,154 @@ +package art.arcane.iris.api; + +import art.arcane.iris.api.terrain.IrisTerrainService; +import org.junit.Test; + +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.GenericArrayType; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class IrisApiSurfacePurityTest { + private static final String API_PACKAGE = "art.arcane.iris.api"; + private static final List ALLOWED_PREFIXES = List.of( + "java.", + "javax.", + "org.bukkit.", + API_PACKAGE + "." + ); + + @Test + public void everyApiTypeIsReachableWithoutIris() throws IOException, URISyntaxException, ClassNotFoundException { + List> apiTypes = apiTypes(); + assertTrue("the api package must contain types", apiTypes.size() >= 11); + + Set violations = new LinkedHashSet<>(); + for (Class apiType : apiTypes) { + collect(apiType.getGenericSuperclass(), violations, apiType); + for (Type implemented : apiType.getGenericInterfaces()) { + collect(implemented, violations, apiType); + } + for (Method method : apiType.getDeclaredMethods()) { + if (!isExported(method.getModifiers())) { + continue; + } + collect(method.getGenericReturnType(), violations, apiType); + for (Type parameter : method.getGenericParameterTypes()) { + collect(parameter, violations, apiType); + } + for (Type thrown : method.getGenericExceptionTypes()) { + collect(thrown, violations, apiType); + } + } + for (Constructor constructor : apiType.getDeclaredConstructors()) { + if (!isExported(constructor.getModifiers())) { + continue; + } + for (Type parameter : constructor.getGenericParameterTypes()) { + collect(parameter, violations, apiType); + } + } + for (Field field : apiType.getDeclaredFields()) { + if (!isExported(field.getModifiers())) { + continue; + } + collect(field.getGenericType(), violations, apiType); + } + } + + assertEquals("Iris API types must only expose java, bukkit and Iris API types: " + violations, + Set.of(), violations); + } + + @Test + public void theTerrainServiceIsPartOfTheScannedSurface() throws IOException, URISyntaxException, ClassNotFoundException { + assertTrue(apiTypes().contains(IrisTerrainService.class)); + } + + private static boolean isExported(int modifiers) { + return Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers); + } + + private static void collect(Type type, Set violations, Class owner) { + if (type == null) { + return; + } + + switch (type) { + case Class raw -> { + Class component = raw; + while (component.isArray()) { + component = component.getComponentType(); + } + if (component.isPrimitive()) { + return; + } + String name = component.getName(); + if (ALLOWED_PREFIXES.stream().noneMatch(name::startsWith)) { + violations.add(owner.getName() + " -> " + name); + } + } + case ParameterizedType parameterized -> { + collect(parameterized.getRawType(), violations, owner); + for (Type argument : parameterized.getActualTypeArguments()) { + collect(argument, violations, owner); + } + } + case GenericArrayType array -> collect(array.getGenericComponentType(), violations, owner); + case WildcardType wildcard -> { + for (Type bound : wildcard.getUpperBounds()) { + collect(bound, violations, owner); + } + for (Type bound : wildcard.getLowerBounds()) { + collect(bound, violations, owner); + } + } + case TypeVariable variable -> { + for (Type bound : variable.getBounds()) { + collect(bound, violations, owner); + } + } + default -> violations.add(owner.getName() + " -> unresolvable type " + type); + } + } + + private static List> apiTypes() throws IOException, URISyntaxException, ClassNotFoundException { + Path classesRoot = Path.of(IrisTerrainService.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + Path apiRoot = classesRoot.resolve(API_PACKAGE.replace('.', '/')); + assertTrue("compiled api package not found at " + apiRoot, Files.isDirectory(apiRoot)); + + List names = new ArrayList<>(); + try (Stream files = Files.walk(apiRoot)) { + files.filter(path -> path.getFileName().toString().endsWith(".class")) + .forEach(path -> names.add(classesRoot.relativize(path).toString() + .replace('/', '.') + .replace('\\', '.') + .replaceAll("\\.class$", ""))); + } + + names.sort(String::compareTo); + List> types = new ArrayList<>(names.size()); + for (String name : names) { + types.add(Class.forName(name, false, IrisApiSurfacePurityTest.class.getClassLoader())); + } + return types; + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/pregen/IrisPregenProgressTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/pregen/IrisPregenProgressTest.java new file mode 100644 index 000000000..e16b259e5 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/pregen/IrisPregenProgressTest.java @@ -0,0 +1,50 @@ +package art.arcane.iris.api.pregen; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class IrisPregenProgressTest { + @Test + public void anAbsentWorldNameFallsBackToTheIdentity() { + IrisPregenProgress progress = new IrisPregenProgress( + null, "minecraft:world", 12D, 1L, 2L, 1L, 0L, 3D, 4L, 5L, null, false); + + assertEquals("minecraft:world", progress.worldName()); + assertEquals("", progress.method()); + } + + @Test + public void hostileNumbersAreNormalisedRatherThanPropagated() { + IrisPregenProgress progress = new IrisPregenProgress( + "world", "minecraft:world", 400D, -1L, -2L, -3L, -4L, -5D, -6L, -7L, "hybrid", true); + + assertEquals(100D, progress.percent(), 0D); + assertEquals(0L, progress.generatedChunks()); + assertEquals(0L, progress.totalChunks()); + assertEquals(0L, progress.remainingChunks()); + assertEquals(0L, progress.failedChunks()); + assertEquals(0D, progress.chunksPerSecond(), 0D); + assertEquals(0L, progress.etaMillis()); + assertEquals(0L, progress.elapsedMillis()); + } + + @Test + public void nonFiniteRatesCollapseToZeroInsteadOfLeaking() { + IrisPregenProgress nan = new IrisPregenProgress( + "world", "minecraft:world", Double.NaN, 0L, 0L, 0L, 0L, Double.NaN, 0L, 0L, "hybrid", false); + IrisPregenProgress infinite = new IrisPregenProgress( + "world", "minecraft:world", Double.POSITIVE_INFINITY, 0L, 0L, 0L, 0L, + Double.POSITIVE_INFINITY, 0L, 0L, "hybrid", false); + + assertEquals(0D, nan.percent(), 0D); + assertEquals(0D, nan.chunksPerSecond(), 0D); + assertEquals(0D, infinite.percent(), 0D); + assertEquals(0D, infinite.chunksPerSecond(), 0D); + } + + @Test(expected = NullPointerException.class) + public void aProgressWithoutAWorldIdentityIsRejected() { + new IrisPregenProgress("world", null, 0D, 0L, 0L, 0L, 0L, 0D, 0L, 0L, "hybrid", false); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisColumnQueryTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisColumnQueryTest.java new file mode 100644 index 000000000..edf9403f0 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisColumnQueryTest.java @@ -0,0 +1,77 @@ +package art.arcane.iris.api.terrain; + +import org.junit.Test; + +import java.util.EnumSet; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class IrisColumnQueryTest { + @Test + public void fieldsAreCopiedOnBothSidesOfTheBoundary() { + EnumSet supplied = EnumSet.of(IrisColumnField.SURFACE_KIND); + IrisColumnQuery query = IrisColumnQuery.rect(0, 0, 15, 15, 1, supplied); + + supplied.add(IrisColumnField.BIOME_KEY); + assertEquals(EnumSet.of(IrisColumnField.SURFACE_KIND), query.fields()); + + EnumSet returned = query.fields(); + returned.add(IrisColumnField.SURFACE_HEIGHT); + assertEquals(EnumSet.of(IrisColumnField.SURFACE_KIND), query.fields()); + } + + @Test + public void columnCountAndChunkCountAreDecoupledByStride() { + IrisColumnQuery query = IrisColumnQuery.rect( + 0, 0, 6399, 6399, 64, EnumSet.of(IrisColumnField.SURFACE_KIND)); + + assertEquals(10_000L, query.columnCount()); + assertEquals(160_000L, query.chunkCount()); + assertTrue("chunk span must dominate the column count for a strided query", + query.chunkCount() > query.columnCount()); + } + + @Test + public void countsAreExactForASingleChunk() { + IrisColumnQuery query = IrisColumnQuery.rect( + 0, 0, 15, 15, 1, EnumSet.of(IrisColumnField.SURFACE_HEIGHT)); + + assertEquals(256L, query.columnCount()); + assertEquals(1L, query.chunkCount()); + } + + @Test + public void countsSurviveNegativeCoordinates() { + IrisColumnQuery query = IrisColumnQuery.rect( + -32, -32, -1, -1, 8, EnumSet.of(IrisColumnField.SURFACE_HEIGHT)); + + assertEquals(16L, query.columnCount()); + assertEquals(4L, query.chunkCount()); + } + + @Test + public void countsSaturateInsteadOfWrappingNegativePastTheCaps() { + IrisColumnQuery query = IrisColumnQuery.rect( + Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, 1, + EnumSet.of(IrisColumnField.SURFACE_HEIGHT)); + + assertEquals(Long.MAX_VALUE, query.columnCount()); + assertTrue(query.chunkCount() > 0L); + } + + @Test(expected = IllegalArgumentException.class) + public void emptyFieldSetIsRejected() { + IrisColumnQuery.rect(0, 0, 15, 15, 1, EnumSet.noneOf(IrisColumnField.class)); + } + + @Test(expected = IllegalArgumentException.class) + public void invertedBoundsAreRejected() { + IrisColumnQuery.rect(16, 0, 0, 15, 1, EnumSet.of(IrisColumnField.SURFACE_KIND)); + } + + @Test(expected = IllegalArgumentException.class) + public void zeroStrideIsRejected() { + IrisColumnQuery.rect(0, 0, 15, 15, 0, EnumSet.of(IrisColumnField.SURFACE_KIND)); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisWorldInfoTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisWorldInfoTest.java new file mode 100644 index 000000000..81906fa12 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisWorldInfoTest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.api.terrain; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class IrisWorldInfoTest { + @Test + public void heightIsDerivedFromTheAbsoluteBounds() { + IrisWorldInfo info = new IrisWorldInfo("overworld", "minecraft:world", 42L, -64, 320, 63, false); + + assertEquals(384, info.height()); + assertEquals(63, info.fluidHeight()); + } + + @Test(expected = IllegalArgumentException.class) + public void collapsedHeightRangeIsRejected() { + new IrisWorldInfo("overworld", "minecraft:world", 42L, 0, 0, 63, false); + } + + @Test(expected = NullPointerException.class) + public void nullDimensionKeyIsRejected() { + new IrisWorldInfo(null, "minecraft:world", 42L, -64, 320, 63, false); + } + + @Test(expected = NullPointerException.class) + public void nullWorldIdentityIsRejected() { + new IrisWorldInfo("overworld", null, 42L, -64, 320, 63, false); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/world/IrisWorldEngineEventTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/world/IrisWorldEngineEventTest.java new file mode 100644 index 000000000..f8ba91376 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/world/IrisWorldEngineEventTest.java @@ -0,0 +1,64 @@ +package art.arcane.iris.api.world; + +import art.arcane.iris.api.terrain.IrisWorldInfo; +import org.bukkit.World; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public class IrisWorldEngineEventTest { + private static final IrisWorldInfo INFO = + new IrisWorldInfo("overworld", "minecraft:world", 7L, -64, 320, -1, false); + + @Test + public void aPhaseIsStillReportableWhenTheEngineCannotBeDescribed() { + IrisWorldEngineEvent event = new IrisWorldEngineEvent(world(), IrisWorldPhase.ENGINE_CLOSING, null); + + assertEquals(IrisWorldPhase.ENGINE_CLOSING, event.getPhase()); + assertFalse(event.getInfo().isPresent()); + } + + @Test + public void aDescribableEngineCarriesItsInfo() { + World world = world(); + IrisWorldEngineEvent event = new IrisWorldEngineEvent(world, IrisWorldPhase.ENGINE_READY, INFO); + + assertSame(world, event.getWorld()); + assertEquals(Optional.of(INFO), event.getInfo()); + } + + @Test + public void theWorldAndPhaseAreAlwaysRequired() { + assertThrows(NullPointerException.class, + () -> new IrisWorldEngineEvent(null, IrisWorldPhase.ENGINE_READY, INFO)); + assertThrows(NullPointerException.class, + () -> new IrisWorldEngineEvent(world(), null, INFO)); + } + + @Test + public void everyPhaseSharesOneHandlerList() { + assertNotNull(IrisWorldEngineEvent.getHandlerList()); + assertSame(IrisWorldEngineEvent.getHandlerList(), + new IrisWorldEngineEvent(world(), IrisWorldPhase.ENGINE_HOTLOADED, null).getHandlers()); + } + + private static World world() { + return (World) Proxy.newProxyInstance( + IrisWorldEngineEventTest.class.getClassLoader(), + new Class[]{World.class}, + (Object proxy, Method method, Object[] arguments) -> switch (method.getName()) { + case "getName", "toString" -> "world"; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + default -> throw new UnsupportedOperationException(method.getName()); + }); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/FakeIrisTerrainService.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/FakeIrisTerrainService.java new file mode 100644 index 000000000..de2890d3c --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/FakeIrisTerrainService.java @@ -0,0 +1,117 @@ +package art.arcane.iris.core.link; + +import art.arcane.iris.api.terrain.IrisColumnQuery; +import art.arcane.iris.api.terrain.IrisColumnSink; +import art.arcane.iris.api.terrain.IrisSurfaceKind; +import art.arcane.iris.api.terrain.IrisTerrainService; +import art.arcane.iris.api.terrain.IrisWorldInfo; +import org.bukkit.World; + +import java.util.HashSet; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.Set; + +final class FakeIrisTerrainService implements IrisTerrainService { + private final Set irisWorlds = new HashSet<>(); + + private String dimensionKey = "overworld"; + private String biomeName = "Hot Desert Dunes"; + private String biomeKey = "desert/hot-dunes"; + private String regionName = "Scorched Expanse"; + private String regionKey = "scorched"; + private int builds; + private int lastBlockX; + private int lastBlockZ; + + void addIrisWorld(World world) { + irisWorlds.add(world); + } + + void describe(String dimensionKey, String biomeName, String biomeKey, String regionName, String regionKey) { + this.dimensionKey = dimensionKey; + this.biomeName = biomeName; + this.biomeKey = biomeKey; + this.regionName = regionName; + this.regionKey = regionKey; + } + + int builds() { + return builds; + } + + String sampledColumn() { + return lastBlockX + "," + lastBlockZ; + } + + @Override + public boolean isIrisWorld(World world) { + return irisWorlds.contains(world); + } + + @Override + public Optional worldInfo(World world) { + if (!isIrisWorld(world)) { + return Optional.empty(); + } + + return Optional.of(new IrisWorldInfo(dimensionKey, "identity", 42L, -64, 320, 63, false)); + } + + @Override + public OptionalInt surfaceHeight(World world, int blockX, int blockZ) { + throw new AssertionError("a placeholder must never sample terrain height"); + } + + @Override + public IrisSurfaceKind surfaceKind(World world, int blockX, int blockZ) { + throw new AssertionError("a placeholder must never classify the surface"); + } + + @Override + public Optional surfaceBiomeKey(World world, int blockX, int blockZ) { + return isIrisWorld(world) ? Optional.ofNullable(biomeKey) : Optional.empty(); + } + + @Override + public Optional surfaceBiomeName(World world, int blockX, int blockZ) { + if (!isIrisWorld(world)) { + return Optional.empty(); + } + + builds++; + lastBlockX = blockX; + lastBlockZ = blockZ; + return Optional.ofNullable(biomeName); + } + + @Override + public Optional biomeKey(World world, int blockX, int blockY, int blockZ) { + throw new AssertionError("a placeholder must never resolve a three dimensional biome"); + } + + @Override + public Optional regionKey(World world, int blockX, int blockZ) { + return isIrisWorld(world) ? Optional.ofNullable(regionKey) : Optional.empty(); + } + + @Override + public Optional regionName(World world, int blockX, int blockZ) { + return isIrisWorld(world) ? Optional.ofNullable(regionName) : Optional.empty(); + } + + @Override + public int maxSampleColumns() { + return 0; + } + + @Override + public int maxSampleChunks() { + return 0; + } + + @Override + public boolean sampleColumns(World world, IrisColumnQuery query, IrisColumnSink sink) { + throw new AssertionError("a placeholder must never run a column sample"); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiExpansionTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiExpansionTest.java new file mode 100644 index 000000000..1a7514328 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiExpansionTest.java @@ -0,0 +1,249 @@ +package art.arcane.iris.core.link; + +import art.arcane.iris.api.pregen.IrisPregenPhase; +import art.arcane.iris.api.pregen.IrisPregenProgress; +import art.arcane.iris.api.terrain.IrisTerrainService; +import org.bukkit.OfflinePlayer; +import org.bukkit.World; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class IrisPapiExpansionTest { + private static final UUID PLAYER_ID = UUID.fromString("00000000-0000-0000-0000-0000000000b2"); + private static final String DASH = "---"; + + private static final List PUBLISHED_KEYS = List.of( + "available", + "pregen.available", + "pregen.chunks", + "pregen.chunks-per-second", + "pregen.eta", + "pregen.eta-text", + "pregen.paused", + "pregen.percent", + "pregen.total", + "pregen.world", + "world.available", + "world.biome", + "world.biome-key", + "world.dimension", + "world.region", + "world.region-key"); + + private static final List RETIRED_KEYS = List.of( + "biome_name", + "biome_id", + "biome_file", + "region_name", + "region_id", + "region_file", + "terrain_slope", + "terrain_height", + "world_mode", + "world_seed", + "world_speed"); + + private static Logger quietLogger() { + Logger logger = Logger.getLogger("IrisPapiExpansionTest"); + logger.setUseParentHandlers(false); + logger.setLevel(Level.OFF); + return logger; + } + + private static IrisPapiExpansion expansion(IrisPapiState state) { + return new IrisPapiExpansion(state, quietLogger()); + } + + private static IrisPapiState populatedState() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiState state = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + state.trackPosition(PLAYER_ID, world, 64, 64); + state.publishPregen(IrisPregenPhase.TICK, new IrisPregenProgress( + "sandbox", "sandbox:identity", 42.5D, 1234L, 4096L, 2862L, 0L, 12.5D, 125_000L, 60_000L, "async", false)); + return state; + } + + @Test + public void theExpansionPublishesExactlyTheDocumentedKeySet() { + assertEquals(PUBLISHED_KEYS, expansion(populatedState()).getPlaceholders()); + } + + @Test + public void everyPublishedKeyResolvesToAValue() { + IrisPapiExpansion expansion = expansion(populatedState()); + OfflinePlayer player = IrisPapiTestSupport.player(PLAYER_ID); + + for (String key : PUBLISHED_KEYS) { + String value = expansion.onRequest(player, key); + assertNotNull("published key " + key + " must resolve", value); + assertFalse("published key " + key + " must not resolve to an empty string", value.isEmpty()); + } + } + + @Test + public void aFullyPopulatedBoardRendersRealValues() { + IrisPapiExpansion expansion = expansion(populatedState()); + OfflinePlayer player = IrisPapiTestSupport.player(PLAYER_ID); + + assertEquals("true", expansion.onRequest(player, "available")); + assertEquals("true", expansion.onRequest(player, "world.available")); + assertEquals("Hot Desert Dunes", expansion.onRequest(player, "world.biome")); + assertEquals("desert/hot-dunes", expansion.onRequest(player, "world.biome-key")); + assertEquals("Scorched Expanse", expansion.onRequest(player, "world.region")); + assertEquals("scorched", expansion.onRequest(player, "world.region-key")); + assertEquals("overworld", expansion.onRequest(player, "world.dimension")); + assertEquals("true", expansion.onRequest(player, "pregen.available")); + assertEquals("sandbox", expansion.onRequest(player, "pregen.world")); + assertEquals("42.50", expansion.onRequest(player, "pregen.percent")); + assertEquals("125", expansion.onRequest(player, "pregen.eta")); + assertEquals("2m 5s", expansion.onRequest(player, "pregen.eta-text")); + assertEquals("1234", expansion.onRequest(player, "pregen.chunks")); + assertEquals("4096", expansion.onRequest(player, "pregen.total")); + assertEquals("12.50", expansion.onRequest(player, "pregen.chunks-per-second")); + assertEquals("false", expansion.onRequest(player, "pregen.paused")); + } + + @Test + public void everyPublishedKeyObeysTheSuiteGrammar() { + for (String key : expansion(populatedState()).getPlaceholders()) { + assertFalse("a placeholder path may never contain '_': " + key, key.indexOf('_') >= 0); + assertEquals("a placeholder path is lowercase ascii: " + key, key.toLowerCase(Locale.ROOT), key); + + for (String segment : key.split("\\.", -1)) { + assertFalse("empty segment in " + key, segment.isEmpty()); + assertTrue("segment must match [a-z0-9-] in " + key, segment.matches("[a-z0-9-]+")); + } + } + } + + @Test + public void anUnknownPathReturnsNullSoTheTypoStaysVisible() { + IrisPapiExpansion expansion = expansion(populatedState()); + OfflinePlayer player = IrisPapiTestSupport.player(PLAYER_ID); + + assertNull(expansion.onRequest(player, "world.biom")); + assertNull(expansion.onRequest(player, "world")); + assertNull(expansion.onRequest(player, "world.")); + assertNull(expansion.onRequest(player, ".biome")); + assertNull(expansion.onRequest(player, "definitely-not-a-key")); + } + + @Test + public void theRetiredUnderscoreGrammarNoLongerResolves() { + IrisPapiExpansion expansion = expansion(populatedState()); + OfflinePlayer player = IrisPapiTestSupport.player(PLAYER_ID); + + for (String retired : RETIRED_KEYS) { + assertNull("the old key " + retired + " must render literally, not silently answer", + expansion.onRequest(player, retired)); + } + } + + @Test + public void blankParamsReturnNull() { + IrisPapiExpansion expansion = expansion(populatedState()); + OfflinePlayer player = IrisPapiTestSupport.player(PLAYER_ID); + + assertNull(expansion.onRequest(player, null)); + assertNull(expansion.onRequest(player, "")); + assertNull(expansion.onRequest(player, " ")); + } + + @Test + public void pathsAreLowercasedBeforeDispatch() { + IrisPapiExpansion expansion = expansion(populatedState()); + OfflinePlayer player = IrisPapiTestSupport.player(PLAYER_ID); + + assertEquals("Hot Desert Dunes", expansion.onRequest(player, "WORLD.BIOME")); + assertEquals("Hot Desert Dunes", expansion.onRequest(player, "World.Biome")); + } + + @Test + public void anUntrackedPlayerGetsTheUnavailableSentinelRatherThanALie() { + IrisPapiExpansion expansion = expansion(populatedState()); + OfflinePlayer stranger = IrisPapiTestSupport.player(UUID.fromString("00000000-0000-0000-0000-0000000000c3")); + + assertEquals("false", expansion.onRequest(stranger, "world.available")); + assertEquals(DASH, expansion.onRequest(stranger, "world.biome")); + assertEquals("true", expansion.onRequest(stranger, "available")); + } + + @Test + public void aNullPlayerStillAnswersTheGlobalKeys() { + IrisPapiExpansion expansion = expansion(populatedState()); + + assertEquals("true", expansion.onRequest(null, "available")); + assertEquals("42.50", expansion.onRequest(null, "pregen.percent")); + assertEquals(DASH, expansion.onRequest(null, "world.biome")); + } + + @Test + public void aResolverThatThrowsIsCaughtAndReportedAsUnavailable() { + IrisPapiState exploding = new IrisPapiState(() -> { + throw new IllegalStateException("terrain service exploded"); + }, new IrisPapiTestSupport.Clock()); + + assertEquals(DASH, expansion(exploding).onRequest(IrisPapiTestSupport.player(PLAYER_ID), "available")); + } + + @Test + public void metadataIsHardcodedAndTheOwningPluginIsDeclared() { + IrisPapiExpansion expansion = expansion(populatedState()); + + assertEquals("iris", expansion.getIdentifier()); + assertEquals("Volmit Software", expansion.getAuthor()); + assertEquals("2.0.0", expansion.getVersion()); + assertEquals("Iris", expansion.getRequiredPlugin()); + assertTrue(expansion.persist()); + } + + @Test + public void thePlaceholderPathNeverTouchesTheEngineOrAPluginStatic() throws Exception { + for (String file : List.of("IrisPapiExpansion.java", "IrisPapiState.java", "IrisPapiWorldView.java")) { + String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/link/" + file)); + + assertFalse(file + " must not import the engine", source.contains("art.arcane.iris.engine.")); + assertFalse(file + " must not reach into the toolbelt", source.contains("IrisToolbelt")); + assertFalse(file + " must not read the plugin static", source.contains("Iris.instance")); + assertFalse(file + " must not derive metadata from the description", source.contains("getDescription()")); + assertFalse(file + " must not take a lock", source.contains("synchronized")); + } + } + + @Test + public void theReadmeDocumentsEveryPublishedKeyAndEveryRetiredOne() throws Exception { + String readme = Files.readString(Path.of(System.getProperty("iris.readmeSource"))); + + for (String key : PUBLISHED_KEYS) { + assertTrue("README must document %iris_" + key + "%", readme.contains("%iris_" + key + "%")); + } + + for (String retired : RETIRED_KEYS) { + assertTrue("README must carry the migration row for %iris_" + retired + "%", + readme.contains("%iris_" + retired + "%")); + } + } + + @Test + public void theTerrainServiceIsTheOnlyDoorIntoIris() { + IrisTerrainService service = new FakeIrisTerrainService(); + IrisPapiState state = new IrisPapiState(() -> service, new IrisPapiTestSupport.Clock()); + + assertEquals("true", expansion(state).onRequest(IrisPapiTestSupport.player(PLAYER_ID), "available")); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiLifecycleTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiLifecycleTest.java new file mode 100644 index 000000000..415f160bb --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiLifecycleTest.java @@ -0,0 +1,132 @@ +package art.arcane.iris.core.link; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisPapiLifecycleTest { + private static final Path PLUGIN_SOURCE = Path.of("src/main/java/art/arcane/iris/Iris.java"); + private static final String SETUP = "private void setupPapi() {"; + private static final String TEARDOWN = "private void teardownPapi() {"; + + private static String source() throws Exception { + return Files.readString(PLUGIN_SOURCE); + } + + private static String body(String declaration) throws Exception { + String source = source(); + int start = source.indexOf(declaration); + + assertTrue("Iris.java must declare " + declaration, start >= 0); + + int open = source.indexOf('{', start); + int depth = 0; + + for (int index = open; index < source.length(); index++) { + char character = source.charAt(index); + + if (character == '{') { + depth++; + continue; + } + + if (character != '}') { + continue; + } + + depth--; + + if (depth == 0) { + return source.substring(open + 1, index); + } + } + + throw new AssertionError(declaration + " is not brace balanced"); + } + + @Test + public void registrationIsGatedOnPlaceholderApiBeingEnabled() throws Exception { + assertTrue("setupPapi must bail out when PlaceholderAPI is absent", + body(SETUP).contains("if (!PlaceholderRegistration.isPlaceholderApiEnabled()) {")); + } + + @Test + public void theRegistrationTheListenerAndTheStateAreAllRetained() throws Exception { + String source = source(); + + assertTrue(source.contains("private volatile PlaceholderRegistration papiRegistration;")); + assertTrue(source.contains("private volatile IrisPapiListener papiListener;")); + assertTrue(source.contains("private volatile IrisPapiState papiState;")); + } + + @Test + public void teardownUnregistersTheExpansionTheListenerAndClearsTheState() throws Exception { + String teardown = body(TEARDOWN); + + assertTrue("the retained registration must be unregistered inside teardownPapi", + teardown.contains("registration.unregister();")); + assertTrue("the retained listener must be detached inside teardownPapi", + teardown.contains("HandlerList.unregisterAll(listener);")); + assertTrue("the retained state must drop every held world reference inside teardownPapi", + teardown.contains("state.clear();")); + assertTrue("teardownPapi must drop the retained listener", teardown.contains("papiListener = null;")); + assertTrue("teardownPapi must drop the retained registration", teardown.contains("papiRegistration = null;")); + assertTrue("teardownPapi must drop the retained state", teardown.contains("papiState = null;")); + } + + @Test + public void bothDisablePathsTearThePlaceholderSurfaceDown() throws Exception { + String source = source(); + + assertTrue("onDisable must tear the expansion down", + source.contains("public void onDisable() {\n teardownPapi();")); + assertTrue("the BileTools pre-unload hook must tear the expansion down", + source.contains("public void onPreUnload(ReloadAware.PreUnloadReason reason) {\n teardownPapi();")); + } + + @Test + public void aFailedListenerAttachDoesNotLeaveTheExpansionRegistered() throws Exception { + String setup = body(SETUP); + int attach = setup.indexOf("registerEvents(listener, this)"); + + assertTrue("the listener must be attached inside setupPapi", attach >= 0); + + int rescue = setup.indexOf("} catch (Throwable failure) {", attach); + + assertTrue("the attach must be guarded inside setupPapi", rescue > attach); + + int rollback = setup.indexOf("registration.unregister();", rescue); + + assertTrue("a failed attach must roll the registration back inside setupPapi", rollback > rescue); + + int bail = setup.indexOf("return;", rollback); + + assertTrue("a failed attach must leave setupPapi", bail > rollback); + + int retained = setup.indexOf("papiRegistration = registration;"); + + assertTrue("setupPapi must retain the registration", retained > 0); + assertTrue("the registration may only be retained after a successful attach", retained > bail); + } + + @Test + public void theExpansionIsNeverConstructedFromAPluginStatic() throws Exception { + String setup = body(SETUP); + + assertFalse("the plugin class must not construct the expansion: that forces PlaceholderExpansion to load during enable and crashes a server without PlaceholderAPI", + setup.contains("new IrisPapiExpansion(")); + assertTrue("the expansion takes its state as a constructor argument, built inside the installer", + installerSource().contains("new IrisPapiExpansion(state, logger)")); + assertFalse("the hand rolled PlaceholderAPI presence check must be gone", + source().contains("isPluginEnabled(\"PlaceholderAPI\")")); + } + + private static String installerSource() throws Exception { + return java.nio.file.Files.readString(java.nio.file.Path.of( + "src/main/java/art/arcane/iris/core/link/IrisPapiInstaller.java")); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiListenerTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiListenerTest.java new file mode 100644 index 000000000..7a2f9fdfa --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiListenerTest.java @@ -0,0 +1,316 @@ +package art.arcane.iris.core.link; + +import art.arcane.iris.api.pregen.IrisPregenPhase; +import art.arcane.iris.api.pregen.IrisPregenProgress; +import art.arcane.iris.api.pregen.IrisPregenerationEvent; +import net.kyori.adventure.text.Component; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerPortalEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class IrisPapiListenerTest { + private static final UUID PLAYER = UUID.fromString("00000000-0000-0000-0000-0000000000d4"); + private static final String DASH = "---"; + + private static IrisPregenProgress progress(double percent) { + return new IrisPregenProgress( + "sandbox", + "sandbox:identity", + percent, + 1234L, + 4096L, + 2862L, + 0L, + 12.5D, + 125_000L, + 60_000L, + "async", + false); + } + + private static final class Harness { + private final FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + private final IrisPapiTestSupport.Clock clock = new IrisPapiTestSupport.Clock(); + private final IrisPapiState state = new IrisPapiState(() -> terrain, clock); + private final IrisPapiListener listener = new IrisPapiListener(state); + private final World sandbox = IrisPapiTestSupport.world("sandbox"); + private final World hub = IrisPapiTestSupport.world("hub"); + private final IrisPapiTestSupport.StandingPlayer player; + + private Harness() { + terrain.addIrisWorld(sandbox); + player = new IrisPapiTestSupport.StandingPlayer(PLAYER, at(sandbox, 10, 10)); + } + + private static Location at(World world, int blockX, int blockZ) { + return new Location(world, blockX + 0.5D, 71.0D, blockZ + 0.5D); + } + } + + @Test + public void theListenerIsABukkitListener() { + assertTrue(Listener.class.isAssignableFrom(IrisPapiListener.class)); + } + + @Test + public void everyPositionSourceIsHandledAtMonitorPriority() { + Set> handled = new HashSet<>(); + + for (Method method : IrisPapiListener.class.getDeclaredMethods()) { + EventHandler handler = method.getAnnotation(EventHandler.class); + + if (handler == null) { + continue; + } + + assertEquals("placeholder bookkeeping must never influence an event: " + method.getName(), + EventPriority.MONITOR, handler.priority()); + assertEquals("a handler takes exactly one event: " + method.getName(), + 1, method.getParameterCount()); + handled.add(method.getParameterTypes()[0]); + } + + assertEquals(Set.of( + PlayerMoveEvent.class, + PlayerTeleportEvent.class, + PlayerPortalEvent.class, + PlayerRespawnEvent.class, + PlayerJoinEvent.class, + PlayerChangedWorldEvent.class, + PlayerQuitEvent.class, + IrisPregenerationEvent.class), + handled); + } + + @Test + public void everyStepOfTheMoveHierarchyOwnsItsHandlerListSoNoneIsCoveredByItsParent() throws Exception { + List> hierarchy = + List.of(PlayerMoveEvent.class, PlayerTeleportEvent.class, PlayerPortalEvent.class); + Set> handled = handledEventTypes(); + + for (int index = 1; index < hierarchy.size(); index++) { + Class child = hierarchy.get(index); + Class parent = hierarchy.get(index - 1); + + assertSame(child.getName() + " no longer extends " + parent.getName(), + parent, child.getSuperclass()); + assertNotSame(child.getName() + " has its own HandlerList, so a fired " + child.getSimpleName() + + " never reaches a " + parent.getSimpleName() + " handler", + handlerList(parent), handlerList(child)); + assertTrue(child.getName() + " is a position source that no other handler can cover", + handled.contains(child)); + } + + assertTrue(PlayerMoveEvent.class.getName() + " must still be handled", + handled.contains(PlayerMoveEvent.class)); + } + + private static HandlerList handlerList(Class type) throws Exception { + return (HandlerList) type.getDeclaredMethod("getHandlerList").invoke(null); + } + + private static Set> handledEventTypes() { + Set> handled = new HashSet<>(); + + for (Method method : IrisPapiListener.class.getDeclaredMethods()) { + if (method.getAnnotation(EventHandler.class) != null) { + handled.add(method.getParameterTypes()[0]); + } + } + + return handled; + } + + @Test + public void joiningPublishesTheColumnThePlayerLandsOn() { + Harness harness = new Harness(); + + assertEquals("false", harness.state.worldAvailable(PLAYER)); + + harness.listener.onPlayerJoin(new PlayerJoinEvent(harness.player.handle(), Component.empty())); + + assertEquals("true", harness.state.worldAvailable(PLAYER)); + assertEquals("Hot Desert Dunes", harness.state.biome(PLAYER)); + assertEquals("10,10", harness.terrain.sampledColumn()); + } + + @Test + public void movingPublishesTheDestinationColumn() { + Harness harness = new Harness(); + Location from = harness.player.standing(); + Location to = Harness.at(harness.sandbox, 40, -80); + + harness.listener.onPlayerMove(new PlayerMoveEvent(harness.player.handle(), from, to)); + + assertEquals("true", harness.state.worldAvailable(PLAYER)); + assertEquals("Hot Desert Dunes", harness.state.biome(PLAYER)); + assertEquals("40,-80", harness.terrain.sampledColumn()); + } + + @Test + public void aSameWorldTeleportUpdatesTheBoardWithoutAnyFurtherMovement() { + Harness harness = new Harness(); + + harness.listener.onPlayerJoin(new PlayerJoinEvent(harness.player.handle(), Component.empty())); + assertEquals("Hot Desert Dunes", harness.state.biome(PLAYER)); + assertEquals("10,10", harness.terrain.sampledColumn()); + + harness.terrain.describe("overworld", "Frozen Shelf", "cold/shelf", "Glacier", "glacier"); + + Location from = harness.player.standing(); + Location to = Harness.at(harness.sandbox, 2000, -2000); + harness.player.standAt(to); + harness.listener.onPlayerTeleport(new PlayerTeleportEvent( + harness.player.handle(), from, to, PlayerTeleportEvent.TeleportCause.COMMAND)); + + assertEquals("a same world teleport must republish the position immediately," + + " with no move, no world change and no clock advance", + "Frozen Shelf", harness.state.biome(PLAYER)); + assertEquals("2000,-2000", harness.terrain.sampledColumn()); + assertEquals("cold/shelf", harness.state.biomeKey(PLAYER)); + assertEquals("Glacier", harness.state.region(PLAYER)); + } + + @Test + public void aSameWorldPortalUpdatesTheBoardWithoutAnyFurtherMovement() { + Harness harness = new Harness(); + + harness.listener.onPlayerJoin(new PlayerJoinEvent(harness.player.handle(), Component.empty())); + assertEquals("Hot Desert Dunes", harness.state.biome(PLAYER)); + + harness.terrain.describe("overworld", "Frozen Shelf", "cold/shelf", "Glacier", "glacier"); + + Location from = harness.player.standing(); + Location to = Harness.at(harness.sandbox, 512, 512); + harness.player.standAt(to); + harness.listener.onPlayerPortal(new PlayerPortalEvent( + harness.player.handle(), from, to, PlayerTeleportEvent.TeleportCause.NETHER_PORTAL)); + + assertEquals("Frozen Shelf", harness.state.biome(PLAYER)); + assertEquals("512,512", harness.terrain.sampledColumn()); + } + + @Test + public void aSameWorldRespawnUpdatesTheBoardWithoutAnyFurtherMovement() { + Harness harness = new Harness(); + + harness.listener.onPlayerJoin(new PlayerJoinEvent(harness.player.handle(), Component.empty())); + assertEquals("Hot Desert Dunes", harness.state.biome(PLAYER)); + + harness.terrain.describe("overworld", "Frozen Shelf", "cold/shelf", "Glacier", "glacier"); + + Location bed = Harness.at(harness.sandbox, -333, 777); + harness.player.standAt(bed); + harness.listener.onPlayerRespawn(new PlayerRespawnEvent( + harness.player.handle(), bed, true, false, false, PlayerRespawnEvent.RespawnReason.DEATH)); + + assertEquals("Frozen Shelf", harness.state.biome(PLAYER)); + assertEquals("-333,777", harness.terrain.sampledColumn()); + } + + @Test + public void changingWorldsRepublishesAgainstTheNewWorld() { + Harness harness = new Harness(); + + harness.listener.onPlayerJoin(new PlayerJoinEvent(harness.player.handle(), Component.empty())); + assertEquals("true", harness.state.worldAvailable(PLAYER)); + + harness.player.standAt(Harness.at(harness.hub, 0, 0)); + harness.listener.onPlayerChangedWorld(new PlayerChangedWorldEvent(harness.player.handle(), harness.sandbox)); + + assertEquals("false", harness.state.worldAvailable(PLAYER)); + assertEquals(DASH, harness.state.biome(PLAYER)); + assertEquals(DASH, harness.state.dimension(PLAYER)); + } + + @Test + public void quittingEvictsThePositionAndTheViewSoNoWorldIsHeld() { + Harness harness = new Harness(); + + harness.listener.onPlayerJoin(new PlayerJoinEvent(harness.player.handle(), Component.empty())); + assertEquals("Hot Desert Dunes", harness.state.biome(PLAYER)); + + harness.listener.onPlayerQuit(new PlayerQuitEvent( + harness.player.handle(), Component.empty(), PlayerQuitEvent.QuitReason.DISCONNECTED)); + + assertEquals("false", harness.state.worldAvailable(PLAYER)); + assertEquals(DASH, harness.state.biome(PLAYER)); + assertEquals(DASH, harness.state.region(PLAYER)); + assertEquals(DASH, harness.state.dimension(PLAYER)); + } + + @Test + public void thePregenHandlerLatchesProgressAndRetiresItOnATerminalPhase() { + Harness harness = new Harness(); + + assertEquals("false", harness.state.pregenAvailable(PLAYER)); + + harness.listener.onPregeneration(new IrisPregenerationEvent(IrisPregenPhase.TICK, progress(42.5D))); + + assertEquals("true", harness.state.pregenAvailable(PLAYER)); + assertEquals("sandbox", harness.state.pregenWorld(PLAYER)); + assertEquals("42.50", harness.state.pregenPercent(PLAYER)); + assertEquals("2m 5s", harness.state.pregenEtaText(PLAYER)); + + harness.listener.onPregeneration(new IrisPregenerationEvent(IrisPregenPhase.COMPLETED, progress(100.0D))); + + assertEquals("false", harness.state.pregenAvailable(PLAYER)); + assertEquals(DASH, harness.state.pregenPercent(PLAYER)); + } + + @Test + public void trackingPublishesTheBlockColumnOfTheLocation() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiState state = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + + IrisPapiListener.track(state, PLAYER, new Location(world, 128.7D, 71.0D, -512.2D)); + + assertEquals("true", state.worldAvailable(PLAYER)); + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + assertEquals("128,-513", terrain.sampledColumn()); + } + + @Test + public void trackingIgnoresMissingInputsInsteadOfThrowing() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiState state = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + + IrisPapiListener.track(null, PLAYER, new Location(world, 0.0D, 0.0D, 0.0D)); + IrisPapiListener.track(state, null, new Location(world, 0.0D, 0.0D, 0.0D)); + IrisPapiListener.track(state, PLAYER, null); + IrisPapiListener.track(state, PLAYER, new Location(null, 0.0D, 0.0D, 0.0D)); + IrisPapiListener.trackNow(null, PLAYER, new Location(world, 0.0D, 0.0D, 0.0D)); + IrisPapiListener.trackNow(state, null, new Location(world, 0.0D, 0.0D, 0.0D)); + IrisPapiListener.trackNow(state, PLAYER, null); + IrisPapiListener.trackNow(state, PLAYER, new Location(null, 0.0D, 0.0D, 0.0D)); + + assertEquals("false", state.worldAvailable(PLAYER)); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiStateTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiStateTest.java new file mode 100644 index 000000000..0e2d568e6 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiStateTest.java @@ -0,0 +1,394 @@ +package art.arcane.iris.core.link; + +import art.arcane.iris.api.pregen.IrisPregenPhase; +import art.arcane.iris.api.pregen.IrisPregenProgress; +import art.arcane.iris.api.terrain.IrisTerrainService; +import org.bukkit.World; +import org.junit.Test; + +import java.util.List; +import java.util.UUID; +import java.util.function.Function; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; + +public class IrisPapiStateTest { + private static final UUID PLAYER = UUID.fromString("00000000-0000-0000-0000-0000000000a1"); + private static final String DASH = "---"; + private static final long POSITION_INTERVAL = IrisPapiState.POSITION_INTERVAL_MS; + + private static IrisPregenProgress progress(double percent, long etaMillis, boolean paused) { + return new IrisPregenProgress( + "sandbox", + "sandbox:identity", + percent, + 1234L, + 4096L, + 2862L, + 0L, + 12.5D, + etaMillis, + 60_000L, + "async", + paused); + } + + @Test + public void availableReportsWhetherTheTerrainServiceIsRegistered() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + IrisPapiState absent = new IrisPapiState(() -> null, new IrisPapiTestSupport.Clock()); + IrisPapiState present = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + + assertEquals("false", absent.available(PLAYER)); + assertEquals("true", present.available(PLAYER)); + } + + @Test + public void worldKeysAreUnavailableUntilAPositionIsTracked() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + IrisPapiState state = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + + assertEquals("false", state.worldAvailable(PLAYER)); + assertEquals(DASH, state.biome(PLAYER)); + assertEquals(DASH, state.biomeKey(PLAYER)); + assertEquals(DASH, state.region(PLAYER)); + assertEquals(DASH, state.regionKey(PLAYER)); + assertEquals(DASH, state.dimension(PLAYER)); + assertEquals(0, terrain.builds()); + } + + @Test + public void worldKeysResolveTheBiomeRegionAndDimensionAtTheTrackedColumn() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiState state = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + + state.trackPosition(PLAYER, world, 128, -512); + + assertEquals("true", state.worldAvailable(PLAYER)); + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + assertEquals("desert/hot-dunes", state.biomeKey(PLAYER)); + assertEquals("Scorched Expanse", state.region(PLAYER)); + assertEquals("scorched", state.regionKey(PLAYER)); + assertEquals("overworld", state.dimension(PLAYER)); + } + + @Test + public void aNonIrisWorldAnswersFalseAndDashesWithoutQueryingTerrain() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World hub = IrisPapiTestSupport.world("hub"); + IrisPapiState state = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + + state.trackPosition(PLAYER, hub, 0, 0); + + assertEquals("false", state.worldAvailable(PLAYER)); + assertEquals(DASH, state.biome(PLAYER)); + assertEquals(DASH, state.biomeKey(PLAYER)); + assertEquals(DASH, state.region(PLAYER)); + assertEquals(DASH, state.regionKey(PLAYER)); + assertEquals(DASH, state.dimension(PLAYER)); + assertEquals(0, terrain.builds()); + } + + @Test + public void aWholeBoardOfKeysBuildsTheViewExactlyOncePerSecond() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiTestSupport.Clock clock = new IrisPapiTestSupport.Clock(); + IrisPapiState state = new IrisPapiState(() -> terrain, clock); + + state.trackPosition(PLAYER, world, 10, 10); + + for (int pass = 0; pass < 6; pass++) { + state.worldAvailable(PLAYER); + state.biome(PLAYER); + state.biomeKey(PLAYER); + state.region(PLAYER); + state.regionKey(PLAYER); + state.dimension(PLAYER); + } + + assertEquals(1, terrain.builds()); + } + + @Test + public void theViewIsRebuiltOnceTheOneSecondTtlElapses() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiTestSupport.Clock clock = new IrisPapiTestSupport.Clock(); + IrisPapiState state = new IrisPapiState(() -> terrain, clock); + + state.trackPosition(PLAYER, world, 10, 10); + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + + terrain.describe("overworld", "Frozen Shelf", "cold/shelf", "Glacier", "glacier"); + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + + clock.advance(IrisPapiState.VIEW_TTL_MS); + assertEquals("Frozen Shelf", state.biome(PLAYER)); + assertEquals(2, terrain.builds()); + } + + @Test + public void asecondColumnWithinTheSameSecondDoesNotRepublishThePosition() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiTestSupport.Clock clock = new IrisPapiTestSupport.Clock(); + IrisPapiState state = new IrisPapiState(() -> terrain, clock); + + state.trackPosition(PLAYER, world, 10, 10); + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + + terrain.describe("overworld", "Frozen Shelf", "cold/shelf", "Glacier", "glacier"); + + for (int step = 1; step <= 30; step++) { + clock.advance(40L); + state.trackPosition(PLAYER, world, 10 + step, 10); + state.biome(PLAYER); + } + + assertEquals("a sprinting player must not force more than one view build per second", + 2, terrain.builds()); + } + + @Test + public void aMoveInsideTheSameBlockColumnNeverRepublishesThePosition() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiTestSupport.Clock clock = new IrisPapiTestSupport.Clock(); + IrisPapiState state = new IrisPapiState(() -> terrain, clock); + + state.trackPosition(PLAYER, world, 10, 10); + clock.advance(POSITION_INTERVAL / 2L); + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + + terrain.describe("overworld", "Frozen Shelf", "cold/shelf", "Glacier", "glacier"); + clock.advance(POSITION_INTERVAL / 2L); + state.trackPosition(PLAYER, world, 10, 10); + clock.advance(POSITION_INTERVAL / 5L); + + assertEquals("a look around inside one block column must not invalidate the memoised view", + "Hot Desert Dunes", state.biome(PLAYER)); + assertEquals(1, terrain.builds()); + } + + @Test + public void anImmediatePublishIgnoresTheOneSecondPositionGate() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiTestSupport.Clock clock = new IrisPapiTestSupport.Clock(); + IrisPapiState state = new IrisPapiState(() -> terrain, clock); + + state.trackPosition(PLAYER, world, 10, 10); + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + assertEquals("10,10", terrain.sampledColumn()); + + terrain.describe("overworld", "Frozen Shelf", "cold/shelf", "Glacier", "glacier"); + state.trackPositionNow(PLAYER, world, 2000, -2000); + + assertEquals("a discrete jump must not be swallowed by the move rate limiter", + "Frozen Shelf", state.biome(PLAYER)); + assertEquals("2000,-2000", terrain.sampledColumn()); + } + + @Test + public void anImmediatePublishInsideTheSameBlockColumnStillCostsNothing() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiTestSupport.Clock clock = new IrisPapiTestSupport.Clock(); + IrisPapiState state = new IrisPapiState(() -> terrain, clock); + + state.trackPosition(PLAYER, world, 10, 10); + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + + terrain.describe("overworld", "Frozen Shelf", "cold/shelf", "Glacier", "glacier"); + state.trackPositionNow(PLAYER, world, 10, 10); + + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + assertEquals(1, terrain.builds()); + } + + @Test + public void crossingIntoAnotherWorldRepublishesImmediately() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World sandbox = IrisPapiTestSupport.world("sandbox"); + World hub = IrisPapiTestSupport.world("hub"); + terrain.addIrisWorld(sandbox); + IrisPapiTestSupport.Clock clock = new IrisPapiTestSupport.Clock(); + IrisPapiState state = new IrisPapiState(() -> terrain, clock); + + state.trackPosition(PLAYER, sandbox, 10, 10); + assertEquals("true", state.worldAvailable(PLAYER)); + + state.trackPosition(PLAYER, hub, 0, 0); + assertEquals("false", state.worldAvailable(PLAYER)); + assertEquals(DASH, state.biome(PLAYER)); + } + + @Test + public void releasingAPlayerClearsTheTrackedPositionAndView() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiState state = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + + state.trackPosition(PLAYER, world, 10, 10); + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + + state.release(PLAYER); + + assertEquals("false", state.worldAvailable(PLAYER)); + assertEquals(DASH, state.biome(PLAYER)); + assertEquals(DASH, state.dimension(PLAYER)); + } + + @Test + public void clearingTheStateDropsEveryTrackedPlayerAndTheLatchedPregen() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisPapiState state = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + + state.trackPosition(PLAYER, world, 10, 10); + state.publishPregen(IrisPregenPhase.TICK, progress(42.5D, 125_000L, false)); + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + assertEquals("true", state.pregenAvailable(PLAYER)); + + state.clear(); + + assertEquals(DASH, state.biome(PLAYER)); + assertEquals("false", state.pregenAvailable(PLAYER)); + } + + @Test + public void pregenKeysAreUnavailableUntilAnEventArrives() { + IrisPapiState state = new IrisPapiState(FakeIrisTerrainService::new, new IrisPapiTestSupport.Clock()); + + assertEquals("false", state.pregenAvailable(PLAYER)); + assertEquals(DASH, state.pregenWorld(PLAYER)); + assertEquals(DASH, state.pregenPercent(PLAYER)); + assertEquals(DASH, state.pregenEta(PLAYER)); + assertEquals(DASH, state.pregenEtaText(PLAYER)); + assertEquals(DASH, state.pregenChunks(PLAYER)); + assertEquals(DASH, state.pregenTotal(PLAYER)); + assertEquals(DASH, state.pregenChunksPerSecond(PLAYER)); + assertEquals(DASH, state.pregenPaused(PLAYER)); + } + + @Test + public void pregenKeysRenderTheLatchedProgress() { + IrisPapiState state = new IrisPapiState(FakeIrisTerrainService::new, new IrisPapiTestSupport.Clock()); + + state.publishPregen(IrisPregenPhase.TICK, progress(42.5D, 125_000L, false)); + + assertEquals("true", state.pregenAvailable(PLAYER)); + assertEquals("sandbox", state.pregenWorld(PLAYER)); + assertEquals("42.50", state.pregenPercent(PLAYER)); + assertEquals("125", state.pregenEta(PLAYER)); + assertEquals("2m 5s", state.pregenEtaText(PLAYER)); + assertEquals("1234", state.pregenChunks(PLAYER)); + assertEquals("4096", state.pregenTotal(PLAYER)); + assertEquals("12.50", state.pregenChunksPerSecond(PLAYER)); + assertEquals("false", state.pregenPaused(PLAYER)); + } + + @Test + public void aPausedPregenReportsPaused() { + IrisPapiState state = new IrisPapiState(FakeIrisTerrainService::new, new IrisPapiTestSupport.Clock()); + + state.publishPregen(IrisPregenPhase.PAUSED, progress(42.5D, 125_000L, true)); + + assertEquals("true", state.pregenPaused(PLAYER)); + } + + @Test + public void pregenIsClearedWhenTheJobFinishesOrIsCancelled() { + for (IrisPregenPhase terminal : List.of(IrisPregenPhase.COMPLETED, IrisPregenPhase.CANCELLED)) { + IrisPapiState state = new IrisPapiState(FakeIrisTerrainService::new, new IrisPapiTestSupport.Clock()); + state.publishPregen(IrisPregenPhase.TICK, progress(99.0D, 1_000L, false)); + assertEquals("true", state.pregenAvailable(PLAYER)); + + state.publishPregen(terminal, progress(100.0D, 0L, false)); + + assertEquals(terminal + " must retire the pregen snapshot", "false", state.pregenAvailable(PLAYER)); + assertEquals(DASH, state.pregenPercent(PLAYER)); + } + } + + @Test + public void noPublishedValueEverCarriesAPercentOrSectionCharacter() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + terrain.describe("over%world", "Hot \u00A7cDesert", "hot%key", "Region\u00A7a", "reg%ion"); + IrisPapiState state = new IrisPapiState(() -> terrain, new IrisPapiTestSupport.Clock()); + + state.trackPosition(PLAYER, world, 0, 0); + state.publishPregen(IrisPregenPhase.TICK, progress(100.0D, 3_600_000L, false)); + + List> resolvers = List.of( + state::available, + state::worldAvailable, + state::biome, + state::biomeKey, + state::region, + state::regionKey, + state::dimension, + state::pregenAvailable, + state::pregenWorld, + state::pregenPercent, + state::pregenEta, + state::pregenEtaText, + state::pregenChunks, + state::pregenTotal, + state::pregenChunksPerSecond, + state::pregenPaused); + + for (Function resolver : resolvers) { + String value = resolver.apply(PLAYER); + assertFalse("a placeholder value may never contain '%': " + value, value.indexOf('%') >= 0); + assertFalse("a placeholder value may never contain a legacy colour code: " + value, + value.indexOf('\u00A7') >= 0); + } + + assertNotEquals("Hot \u00A7cDesert", state.biome(PLAYER)); + } + + @Test + public void etaTextCollapsesSecondsMinutesAndHours() { + assertEquals("0s", IrisPapiPregenView.duration(0L)); + assertEquals("45s", IrisPapiPregenView.duration(45_000L)); + assertEquals("2m 5s", IrisPapiPregenView.duration(125_000L)); + assertEquals("1h 0m", IrisPapiPregenView.duration(3_600_000L)); + assertEquals("2h 3m", IrisPapiPregenView.duration(7_380_000L)); + } + + @Test + public void aTerrainServiceThatDisappearsStopsAnsweringWithoutThrowing() { + FakeIrisTerrainService terrain = new FakeIrisTerrainService(); + World world = IrisPapiTestSupport.world("sandbox"); + terrain.addIrisWorld(world); + IrisTerrainService[] holder = new IrisTerrainService[]{terrain}; + IrisPapiTestSupport.Clock clock = new IrisPapiTestSupport.Clock(); + IrisPapiState state = new IrisPapiState(() -> holder[0], clock); + + state.trackPosition(PLAYER, world, 10, 10); + assertEquals("Hot Desert Dunes", state.biome(PLAYER)); + + holder[0] = null; + clock.advance(IrisPapiState.VIEW_TTL_MS); + + assertEquals("false", state.available(PLAYER)); + assertEquals("false", state.worldAvailable(PLAYER)); + assertEquals(DASH, state.biome(PLAYER)); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiTestSupport.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiTestSupport.java new file mode 100644 index 000000000..24dc2c8c7 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPapiTestSupport.java @@ -0,0 +1,104 @@ +package art.arcane.iris.core.link; + +import org.bukkit.Location; +import org.bukkit.OfflinePlayer; +import org.bukkit.World; +import org.bukkit.entity.Player; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.LongSupplier; + +final class IrisPapiTestSupport { + private IrisPapiTestSupport() { + } + + static World world(String name) { + InvocationHandler handler = (Object proxy, Method method, Object[] args) -> switch (method.getName()) { + case "getName" -> name; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + case "toString" -> "World[" + name + "]"; + default -> throw new AssertionError("a placeholder must not call World#" + method.getName()); + }; + + return (World) Proxy.newProxyInstance( + World.class.getClassLoader(), + new Class[]{World.class}, + handler); + } + + static OfflinePlayer player(UUID id) { + InvocationHandler handler = (Object proxy, Method method, Object[] args) -> switch (method.getName()) { + case "getUniqueId" -> id; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + case "toString" -> "OfflinePlayer[" + id + "]"; + default -> throw new AssertionError("a placeholder must not call OfflinePlayer#" + method.getName()); + }; + + return (OfflinePlayer) Proxy.newProxyInstance( + OfflinePlayer.class.getClassLoader(), + new Class[]{OfflinePlayer.class}, + handler); + } + + static final class StandingPlayer { + private final UUID id; + private final AtomicReference standing = new AtomicReference<>(); + private final Player handle; + + StandingPlayer(UUID id, Location location) { + this.id = id; + this.standing.set(location); + InvocationHandler handler = (Object proxy, Method method, Object[] args) -> switch (method.getName()) { + case "getUniqueId" -> this.id; + case "getLocation" -> this.standing.get(); + case "getWorld" -> this.standing.get().getWorld(); + case "getName" -> "StandingPlayer"; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + case "toString" -> "Player[" + this.id + "]"; + default -> throw new AssertionError("a placeholder listener must not call Player#" + method.getName()); + }; + + this.handle = (Player) Proxy.newProxyInstance( + Player.class.getClassLoader(), + new Class[]{Player.class}, + handler); + } + + UUID id() { + return id; + } + + Player handle() { + return handle; + } + + Location standing() { + return standing.get(); + } + + void standAt(Location location) { + standing.set(location); + } + } + + static final class Clock implements LongSupplier { + private final AtomicLong now = new AtomicLong(1_000_000L); + + void advance(long millis) { + now.addAndGet(millis); + } + + @Override + public long getAsLong() { + return now.get(); + } + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPlaceholderAbsenceTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPlaceholderAbsenceTest.java new file mode 100644 index 000000000..d83f2fa46 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/link/IrisPlaceholderAbsenceTest.java @@ -0,0 +1,82 @@ +package art.arcane.iris.core.link; + +import org.junit.Test; + +import java.net.URL; +import java.net.URLClassLoader; + + +public class IrisPlaceholderAbsenceTest { + private static final String[] LOADS_WITHOUT_PLACEHOLDER_API = { + "art.arcane.iris.core.link.IrisPapiState", + "art.arcane.iris.core.link.IrisPapiListener", + "art.arcane.volmlib.util.bukkit.papi.PlaceholderRegistration" + }; + + private static final String[] REQUIRES_PLACEHOLDER_API = { + "art.arcane.iris.core.link.IrisPapiInstaller", + "art.arcane.iris.core.link.IrisPapiExpansion" + }; + + @Test + public void everyEnablePathClassLoadsWhenPlaceholderApiIsAbsent() { + ClassLoader hidden = new PlaceholderApiHidingLoader(); + + for (String name : LOADS_WITHOUT_PLACEHOLDER_API) { + try { + Class.forName(name, true, hidden); + } catch (Throwable failure) { + throw new AssertionError(name + " must load when PlaceholderAPI is not installed", failure); + } + } + } + + @Test + public void theExpansionItselfStillDependsOnPlaceholderApi() { + ClassLoader hidden = new PlaceholderApiHidingLoader(); + + for (String name : REQUIRES_PLACEHOLDER_API) { + boolean threw = false; + + try { + Class.forName(name, true, hidden); + } catch (Throwable failure) { + threw = true; + } + + if (!threw) { + throw new AssertionError(name + " is expected to depend on PlaceholderAPI, so the split above is what keeps the plugin loadable"); + } + } + } + + private static final class PlaceholderApiHidingLoader extends URLClassLoader { + private PlaceholderApiHidingLoader() { + super(classpath(), ClassLoader.getPlatformClassLoader()); + } + + private static URL[] classpath() { + String[] entries = System.getProperty("java.class.path").split(java.io.File.pathSeparator); + URL[] resolved = new URL[entries.length]; + + for (int i = 0; i < entries.length; i++) { + try { + resolved[i] = new java.io.File(entries[i]).toURI().toURL(); + } catch (Throwable failure) { + throw new IllegalStateException(entries[i], failure); + } + } + + return resolved; + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("me.clip.")) { + throw new ClassNotFoundException(name); + } + + return super.loadClass(name, resolve); + } + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java new file mode 100644 index 000000000..8b1f47262 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java @@ -0,0 +1,193 @@ +package art.arcane.iris.core.service; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisApiWiringContractTest { + @Test + public void theTerrainServiceRegistersOnBothRegistriesAndReleasesBoth() throws IOException { + String source = source("iris.terrainSvcSource"); + String onEnable = method(source, "public void onEnable()"); + String onDisable = method(source, "public void onDisable()"); + + assertTrue(onEnable.contains("Bukkit.getServicesManager().register(")); + assertTrue(onEnable.contains("IrisTerrainService.class")); + assertTrue(onEnable.contains("IrisServices.register(IrisTerrainService.class, this)")); + assertTrue(onDisable.contains("Bukkit.getServicesManager().unregister(IrisTerrainService.class, this)")); + assertTrue(onDisable.contains("IrisServices.remove(IrisTerrainService.class)")); + assertBefore(onDisable, "serviceEnabled.set(false)", "Bukkit.getServicesManager().unregister("); + } + + @Test + public void theTerrainServiceNeverForcesEngineInitialisationOrLoadsTheMantle() throws IOException { + String source = source("iris.terrainSvcSource"); + + assertFalse("isIrisWorld must not touch the generator", source.contains("IrisToolbelt")); + assertFalse("isIrisWorld must not touch the generator", source.contains(".touch(")); + assertFalse("terrain queries must not load mantle chunks", source.contains("getMantle()")); + assertFalse("terrain queries must not load mantle chunks", source.contains("getObjectsAt(")); + assertFalse("terrain queries must not load mantle chunks", source.contains("getPOIsAt(")); + assertFalse("terrain queries must not load mantle chunks", source.contains("getCaveOrMantleBiome(")); + assertFalse("terrain queries must never block", source.contains(".join()")); + assertFalse("terrain queries must never block", source.contains("synchronized")); + assertFalse("terrain queries must never block", source.contains("J.sfut(")); + } + + @Test + public void sampleColumnsBoundsTheQueryBeforeItWalksAndTreatsAThrowingSinkAsARefusal() throws IOException { + String sampleColumns = method(source("iris.terrainSvcSource"), + "public boolean sampleColumns(World world, IrisColumnQuery query, IrisColumnSink sink)"); + + assertBefore(sampleColumns, "IrisSampleLimits.withinLimits(", "IrisColumnWalk.walk("); + assertBefore(sampleColumns, "IrisColumnWalk.walk(", "catch (Throwable error)"); + assertTrue(sampleColumns.contains("reportSinkFault(world, error)")); + assertTrue(sampleColumns.contains("return false;")); + assertTrue("the walk must abort when the engine closes underneath it", + sampleColumns.contains("engine.isClosed()")); + } + + @Test + public void thePregeneratorDispatchesPhasesFromItsExistingTickAndNeverBlocksOnTheSink() throws IOException { + String source = source("iris.pregeneratorJobSource"); + String dispatch = method(source, "private void dispatchApiPhases(List phases)"); + + assertTrue(dispatch.contains("IrisServices.getOrNull(PregenApiSink.class)")); + assertTrue(dispatch.contains("catch (Throwable error)")); + assertBefore(dispatch, "sink.pregen(phase, progress)", "catch (Throwable error)"); + assertFalse(dispatch.contains(".join()")); + + assertTrue(method(source, "public void onTick(").contains("dispatchApiPhases(apiPhases.onTick(paused()))")); + assertTrue(method(source, "public void onSaving()").contains("dispatchApiPhases(apiPhases.onSaving())")); + assertTrue(method(source, "public void onClose()").contains("dispatchApiPhases(apiPhases.onClose(reachedTotal()))")); + } + + @Test + public void worldPhasesAreFiredOutsideTheRegistrationLock() throws IOException { + String source = source("iris.engineSvcSource"); + + String add = method(source, "private void add(World world)"); + assertBefore(add, "catch (RejectedExecutionException exception)", "phases.ready(world)"); + assertBefore(add, "registered = true;", "phases.ready(world)"); + + String remove = method(source, "private void remove(World world)"); + assertBefore(remove, "registered = worlds.remove(world)", "phases.closing(world)"); + assertBefore(remove, "phases.closing(world)", "startClose(registered, closing)"); + } + + @Test + public void everyRegisteredWorldIsAnnouncedClosingWhenTheServiceShutsDown() throws IOException { + String onDisable = method(source("iris.engineSvcSource"), "public void onDisable()"); + + assertTrue("service shutdown must announce ENGINE_CLOSING for every registered world", + onDisable.contains("phases.closing(teardown.world())")); + assertBefore(onDisable, "worlds.clear()", "phases.closing(teardown.world())"); + assertBefore(onDisable, "phases.closing(teardown.world())", "shutdownAndDrain(activeService)"); + assertBefore(onDisable, "phases.closing(teardown.world())", + "startClose(teardown.registered(), teardown.closing())"); + } + + @Test + public void aReplacedEngineIsAnnouncedClosingBeforeTheRetryReRegistersTheWorld() throws IOException { + String add = method(source("iris.engineSvcSource"), "private void add(World world)"); + + assertBefore(add, "catch (RejectedExecutionException exception)", "phases.closing(world)"); + assertBefore(add, "phases.closing(world)", "retryRegistrationAfterClose(world, retryAfter)"); + assertBefore(add, "phases.closing(world)", "startClose(replaced, replacementClose)"); + } + + @Test + public void aWorldPhaseIsNeverConditionalOnAnotherServicesRegistration() throws IOException { + String source = source("iris.apiEventSvcSource"); + String fire = method(source, "public static void fireWorldPhase(World world, IrisWorldPhase phase)"); + + assertFalse("world lifecycle must not resolve a swappable service to build its payload", + fire.contains("IrisServices")); + assertFalse("world lifecycle must not resolve a swappable service to build its payload", + fire.contains("IrisTerrainService")); + assertTrue(fire.contains("deliver(new IrisWorldEngineEvent(world, phase, describe(world, phase)))")); + + String describe = method(source, "private static IrisWorldInfo describe(World world, IrisWorldPhase phase)"); + assertTrue(describe.contains("IrisWorldInfoFactory.forWorld(world)")); + assertTrue("an undescribable world must be reported, not swallowed", + describe.contains("IrisLogging.reportError(")); + assertTrue("an undescribable world must still deliver the phase", describe.contains("return null;")); + } + + @Test + public void aWorldPhaseRaisedFromAServerThreadIsDeliveredBeforeThatThreadMovesOn() throws IOException { + String deliver = method(source("iris.apiEventSvcSource"), "private static void deliver(Event event)"); + + assertBefore(deliver, "Bukkit.isPrimaryThread()", "Bukkit.getPluginManager().callEvent(event)"); + assertBefore(deliver, "Bukkit.getPluginManager().callEvent(event)", "Iris.callEvent(event)"); + } + + @Test + public void theWorldInfoFactoryNeverForcesEngineInitialisationOrLoadsTheMantle() throws IOException { + String source = source("iris.worldInfoFactorySource"); + + assertFalse("the factory must not touch the generator", source.contains("IrisToolbelt")); + assertFalse("the factory must not touch the generator", source.contains(".touch(")); + assertFalse("the factory must not load mantle chunks", source.contains("getMantle()")); + assertFalse("the factory must never block", source.contains(".join()")); + assertFalse("the factory must never block", source.contains("synchronized")); + assertTrue("the factory must refuse a closed engine", source.contains("engine.isClosed()")); + } + + @Test + public void theHotloadHookStillFiresTheLegacyEventAlongsideTheApiEvent() throws IOException { + String hook = method(source("iris.bukkitEnginePlatformHooksSource"), "public void fireHotloadEvent(Engine engine)"); + + assertBefore(hook, "new IrisEngineHotloadEvent(engine)", + "IrisApiEventSVC.fireWorldPhase(BukkitWorldBinding.world(engine.getWorld()), IrisWorldPhase.ENGINE_HOTLOADED)"); + } + + @Test + public void theEventServiceNeverLetsAThirdPartyFailureEscapeALifecyclePath() throws IOException { + String fire = method(source("iris.apiEventSvcSource"), + "public static void fireWorldPhase(World world, IrisWorldPhase phase)"); + + assertTrue(fire.contains("catch (Throwable error)")); + assertTrue(fire.contains("IrisLogging.reportError(")); + assertFalse(fire.contains("printStackTrace")); + } + + private static String source(String property) throws IOException { + String path = System.getProperty(property); + assertTrue("missing source property " + property, path != null && !path.isBlank()); + return Files.readString(Path.of(path)); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue("Missing source contract token: " + first, firstIndex >= 0); + assertTrue("Missing source contract token: " + second, secondIndex >= 0); + assertTrue(first + " must occur before " + second, firstIndex < secondIndex); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("Missing source contract signature: " + signature, start >= 0); + int openBrace = source.indexOf('{', start); + assertTrue("Missing source contract method body: " + signature, openBrace >= 0); + int depth = 0; + for (int index = openBrace; index < source.length(); index++) { + char current = source.charAt(index); + if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + if (depth == 0) { + return source.substring(start, index + 1); + } + } + } + throw new IllegalArgumentException("Unclosed source contract method: " + signature); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisTerrainSVCTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisTerrainSVCTest.java new file mode 100644 index 000000000..5e9a2dec1 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisTerrainSVCTest.java @@ -0,0 +1,100 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.api.terrain.IrisColumnField; +import art.arcane.iris.api.terrain.IrisColumnQuery; +import art.arcane.iris.api.terrain.IrisSurfaceKind; +import art.arcane.iris.api.terrain.IrisTerrainService; +import art.arcane.iris.util.common.plugin.IrisService; +import org.bukkit.World; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisTerrainSVCTest { + private static final IrisColumnQuery SMALL = IrisColumnQuery.rect( + 0, 0, 15, 15, 4, EnumSet.of(IrisColumnField.SURFACE_HEIGHT, IrisColumnField.SURFACE_KIND)); + + @Test + public void theServiceImplementsBothContracts() throws NoSuchMethodException { + assertTrue(IrisService.class.isAssignableFrom(IrisTerrainSVC.class)); + assertTrue(IrisTerrainService.class.isAssignableFrom(IrisTerrainSVC.class)); + IrisTerrainSVC.class.getDeclaredConstructor(); + } + + @Test + public void aQueryIsOnlyAnswerableWhileEnabledAndAgainstARealWorld() { + assertTrue(IrisTerrainSVC.answerable(true, true)); + assertFalse("a disabled service must never resolve a generator", + IrisTerrainSVC.answerable(false, true)); + assertFalse(IrisTerrainSVC.answerable(true, false)); + assertFalse(IrisTerrainSVC.answerable(false, false)); + } + + @Test + public void aServiceThatIsNotEnabledAnswersAbsenceInsteadOfThrowing() { + IrisTerrainSVC service = new IrisTerrainSVC(); + + assertFalse(service.isIrisWorld(null)); + assertTrue(service.worldInfo(null).isEmpty()); + assertEquals(OptionalInt.empty(), service.surfaceHeight(null, 0, 0)); + assertEquals(IrisSurfaceKind.UNKNOWN, service.surfaceKind(null, 0, 0)); + assertTrue(service.surfaceBiomeKey(null, 0, 0).isEmpty()); + assertTrue(service.surfaceBiomeName(null, 0, 0).isEmpty()); + assertTrue(service.biomeKey(null, 0, 64, 0).isEmpty()); + assertTrue(service.regionKey(null, 0, 0).isEmpty()); + assertTrue(service.regionName(null, 0, 0).isEmpty()); + } + + @Test + public void theDisplayNameAccessorsReadNamesRatherThanLoadKeys() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.terrainSvcSource"))); + + assertTrue("surfaceBiomeName must read the biome display name", + source.contains("return name(engine.getSurfaceBiome(blockX, blockZ));")); + assertTrue("the biome name helper must read getName()", + source.contains("String name = biome == null ? null : biome.getName();")); + assertTrue("regionName must read the region display name", + source.contains("String name = region == null ? null : region.getName();")); + } + + @Test + public void theServiceExposesDisplayNamesAlongsideLoadKeys() throws NoSuchMethodException { + assertEquals(Optional.class, + IrisTerrainService.class.getMethod("surfaceBiomeName", World.class, int.class, int.class) + .getReturnType()); + assertEquals(Optional.class, + IrisTerrainService.class.getMethod("regionName", World.class, int.class, int.class) + .getReturnType()); + } + + @Test + public void anUnanswerableSampleNeverTouchesTheSink() { + IrisTerrainSVC service = new IrisTerrainSVC(); + AtomicInteger sinkCalls = new AtomicInteger(); + + boolean answered = service.sampleColumns(null, SMALL, + (int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey) + -> sinkCalls.incrementAndGet()); + + assertFalse(answered); + assertEquals(0, sinkCalls.get()); + } + + @Test + public void nullArgumentsAreRefusedRatherThanDereferenced() { + IrisTerrainSVC service = new IrisTerrainSVC(); + + assertFalse(service.sampleColumns(null, null, null)); + assertFalse(service.sampleColumns(null, SMALL, null)); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisWorldPhaseLedgerTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisWorldPhaseLedgerTest.java new file mode 100644 index 000000000..0d6573a11 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisWorldPhaseLedgerTest.java @@ -0,0 +1,119 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.api.world.IrisWorldPhase; +import org.bukkit.World; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; + +public class IrisWorldPhaseLedgerTest { + @Test + public void aWorldIsAnnouncedReadyOnceNoMatterHowOftenRegistrationRuns() { + List fired = new ArrayList<>(); + IrisWorldPhaseLedger ledger = ledger(fired); + World world = world("alpha"); + + ledger.ready(world); + ledger.ready(world); + ledger.ready(world); + + assertEquals(List.of("alpha:ENGINE_READY"), fired); + } + + @Test + public void closingIsNeverAnnouncedForAWorldThatWasNeverAnnouncedReady() { + List fired = new ArrayList<>(); + IrisWorldPhaseLedger ledger = ledger(fired); + World world = world("alpha"); + + ledger.closing(world); + + assertEquals(List.of(), fired); + } + + @Test + public void everyReadyWorldIsAnnouncedClosingExactlyOnce() { + List fired = new ArrayList<>(); + IrisWorldPhaseLedger ledger = ledger(fired); + World world = world("alpha"); + + ledger.ready(world); + ledger.closing(world); + ledger.closing(world); + + assertEquals(List.of("alpha:ENGINE_READY", "alpha:ENGINE_CLOSING"), fired); + } + + @Test + public void anEngineReplacementClosesBeforeItIsAnnouncedReadyAgain() { + List fired = new ArrayList<>(); + IrisWorldPhaseLedger ledger = ledger(fired); + World world = world("alpha"); + + ledger.ready(world); + ledger.closing(world); + ledger.ready(world); + ledger.closing(world); + + assertEquals(List.of( + "alpha:ENGINE_READY", + "alpha:ENGINE_CLOSING", + "alpha:ENGINE_READY", + "alpha:ENGINE_CLOSING"), fired); + } + + @Test + public void worldsAreTrackedIndependently() { + List fired = new ArrayList<>(); + IrisWorldPhaseLedger ledger = ledger(fired); + World first = world("alpha"); + World second = world("beta"); + + ledger.ready(first); + ledger.ready(second); + ledger.closing(first); + ledger.ready(first); + + assertEquals(List.of( + "alpha:ENGINE_READY", + "beta:ENGINE_READY", + "alpha:ENGINE_CLOSING", + "alpha:ENGINE_READY"), fired); + } + + @Test + public void aWorldThatIsNoLongerAddressableIsNeverAnnounced() { + List fired = new ArrayList<>(); + IrisWorldPhaseLedger ledger = ledger(fired); + + ledger.ready(null); + ledger.closing(null); + + assertEquals(List.of(), fired); + } + + private static IrisWorldPhaseLedger ledger(List fired) { + return new IrisWorldPhaseLedger((World world, IrisWorldPhase phase) -> + fired.add(world.getName() + ":" + phase.name())); + } + + private static World world(String name) { + UUID identity = UUID.nameUUIDFromBytes(name.getBytes()); + return (World) Proxy.newProxyInstance( + IrisWorldPhaseLedgerTest.class.getClassLoader(), + new Class[]{World.class}, + (Object proxy, Method method, Object[] arguments) -> switch (method.getName()) { + case "getUID" -> identity; + case "getName", "toString" -> name; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + default -> throw new UnsupportedOperationException(method.getName()); + }); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisApiFaultGuardTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisApiFaultGuardTest.java new file mode 100644 index 000000000..8da069dd6 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisApiFaultGuardTest.java @@ -0,0 +1,52 @@ +package art.arcane.iris.core.service.terrain; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisApiFaultGuardTest { + @Test + public void theFirstFaultIsAlwaysReported() { + IrisApiFaultGuard guard = new IrisApiFaultGuard(60_000L); + + assertTrue(guard.record(0L)); + assertEquals(1L, guard.faults()); + } + + @Test + public void faultsInsideTheIntervalAreCountedButNotReported() { + IrisApiFaultGuard guard = new IrisApiFaultGuard(60_000L); + guard.record(1_000L); + + assertFalse(guard.record(2_000L)); + assertFalse(guard.record(60_999L)); + assertEquals(3L, guard.faults()); + } + + @Test + public void reportingResumesOnceTheIntervalElapses() { + IrisApiFaultGuard guard = new IrisApiFaultGuard(60_000L); + guard.record(1_000L); + guard.record(2_000L); + + assertTrue(guard.record(61_000L)); + assertFalse(guard.record(61_001L)); + assertEquals(4L, guard.faults()); + } + + @Test + public void aZeroIntervalReportsEveryFault() { + IrisApiFaultGuard guard = new IrisApiFaultGuard(0L); + + assertTrue(guard.record(5L)); + assertTrue(guard.record(5L)); + assertEquals(2L, guard.faults()); + } + + @Test(expected = IllegalArgumentException.class) + public void aNegativeIntervalIsRejected() { + new IrisApiFaultGuard(-1L); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisColumnWalkTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisColumnWalkTest.java new file mode 100644 index 000000000..25794ecb9 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisColumnWalkTest.java @@ -0,0 +1,87 @@ +package art.arcane.iris.core.service.terrain; + +import art.arcane.iris.api.terrain.IrisColumnField; +import art.arcane.iris.api.terrain.IrisColumnQuery; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class IrisColumnWalkTest { + private static final EnumSet ANY = EnumSet.of(IrisColumnField.SURFACE_HEIGHT); + + @Test + public void visitsExactlyTheAdvertisedColumnCount() { + IrisColumnQuery query = IrisColumnQuery.rect(-40, -40, 39, 39, 8, ANY); + List visited = new ArrayList<>(); + + long count = IrisColumnWalk.walk(query, (int blockX, int blockZ) -> visited.add(new long[]{blockX, blockZ})); + + assertEquals(query.columnCount(), count); + assertEquals(query.columnCount(), visited.size()); + } + + @Test + public void everyColumnIsStrideAlignedAndInsideTheRect() { + IrisColumnQuery query = IrisColumnQuery.rect(-37, -21, 60, 44, 7, ANY); + + IrisColumnWalk.walk(query, (int blockX, int blockZ) -> { + assertTrue(blockX >= query.minBlockX() && blockX <= query.maxBlockX()); + assertTrue(blockZ >= query.minBlockZ() && blockZ <= query.maxBlockZ()); + assertEquals(0, (blockX - query.minBlockX()) % query.strideBlocks()); + assertEquals(0, (blockZ - query.minBlockZ()) % query.strideBlocks()); + return true; + }); + } + + @Test + public void everyChunkIsVisitedOnceAsAContiguousRun() { + IrisColumnQuery query = IrisColumnQuery.rect(-33, -33, 47, 47, 4, ANY); + List chunkRuns = new ArrayList<>(); + + IrisColumnWalk.walk(query, (int blockX, int blockZ) -> { + long chunkKey = (((long) (blockX >> 4)) << 32) ^ ((blockZ >> 4) & 0xFFFFFFFFL); + if (chunkRuns.isEmpty() || chunkRuns.get(chunkRuns.size() - 1) != chunkKey) { + chunkRuns.add(chunkKey); + } + return true; + }); + + Set distinct = new LinkedHashSet<>(chunkRuns); + assertEquals("a chunk must never be revisited after the walk leaves it", + distinct.size(), chunkRuns.size()); + } + + @Test + public void aRefusingVisitorStopsTheWalkAndReportsWhatItSaw() { + IrisColumnQuery query = IrisColumnQuery.rect(0, 0, 63, 63, 1, ANY); + int[] seen = new int[1]; + + long count = IrisColumnWalk.walk(query, (int blockX, int blockZ) -> { + seen[0]++; + return seen[0] < 10; + }); + + assertEquals(9L, count); + assertEquals(10, seen[0]); + assertTrue(count < query.columnCount()); + } + + @Test + public void aSingleColumnRectVisitsExactlyThatColumn() { + IrisColumnQuery query = IrisColumnQuery.rect(-1, -1, -1, -1, 16, ANY); + List visited = new ArrayList<>(); + + long count = IrisColumnWalk.walk(query, (int blockX, int blockZ) -> visited.add(new long[]{blockX, blockZ})); + + assertEquals(1L, count); + assertEquals(-1L, visited.get(0)[0]); + assertEquals(-1L, visited.get(0)[1]); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSampleLimitsTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSampleLimitsTest.java new file mode 100644 index 000000000..13bd2029a --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSampleLimitsTest.java @@ -0,0 +1,58 @@ +package art.arcane.iris.core.service.terrain; + +import art.arcane.iris.api.terrain.IrisColumnField; +import art.arcane.iris.api.terrain.IrisColumnQuery; +import org.junit.Test; + +import java.util.EnumSet; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisSampleLimitsTest { + @Test + public void chunkCapIsAShareOfTheNoiseCacheWithAFloor() { + assertEquals(256, IrisSampleLimits.maxChunks(1_024)); + assertEquals(IrisSampleLimits.MINIMUM_CHUNKS, IrisSampleLimits.maxChunks(0)); + assertEquals(IrisSampleLimits.MINIMUM_CHUNKS, IrisSampleLimits.maxChunks(16)); + } + + @Test + public void columnCapIsDerivedFromTheChunkCapAndDoesNotOverflow() { + assertEquals(65_536, IrisSampleLimits.maxColumns(1_024)); + assertTrue(IrisSampleLimits.maxColumns(Integer.MAX_VALUE) > 0); + } + + @Test + public void strideCannotSmuggleAQueryPastTheChunkCap() { + IrisColumnQuery smuggled = IrisColumnQuery.rect( + 0, 0, 6399, 6399, 64, EnumSet.of(IrisColumnField.SURFACE_KIND)); + + int maxColumns = IrisSampleLimits.maxColumns(1_024); + int maxChunks = IrisSampleLimits.maxChunks(1_024); + + assertTrue("this query must pass a column-only cap", smuggled.columnCount() <= maxColumns); + assertFalse("but it must be refused on chunk span", + IrisSampleLimits.withinLimits(smuggled, maxColumns, maxChunks)); + } + + @Test + public void aQueryInsideBothCapsIsAccepted() { + IrisColumnQuery accepted = IrisColumnQuery.rect( + 0, 0, 255, 255, 4, EnumSet.of(IrisColumnField.SURFACE_KIND)); + + assertTrue(IrisSampleLimits.withinLimits( + accepted, IrisSampleLimits.maxColumns(1_024), IrisSampleLimits.maxChunks(1_024))); + } + + @Test + public void aDenseQueryOverManyColumnsIsRefused() { + IrisColumnQuery dense = IrisColumnQuery.rect( + 0, 0, 1023, 1023, 1, EnumSet.of(IrisColumnField.SURFACE_HEIGHT)); + + assertTrue(dense.columnCount() > IrisSampleLimits.maxColumns(1_024)); + assertFalse(IrisSampleLimits.withinLimits( + dense, IrisSampleLimits.maxColumns(1_024), IrisSampleLimits.maxChunks(1_024))); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifierTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifierTest.java new file mode 100644 index 000000000..a9368289b --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifierTest.java @@ -0,0 +1,61 @@ +package art.arcane.iris.core.service.terrain; + +import art.arcane.iris.api.terrain.IrisSurfaceKind; +import art.arcane.iris.engine.object.InferredType; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisSurfaceClassifierTest { + private static final int FLUID = 127; + + @Test + public void columnAtOrBelowWorldMinimumIsVoid() { + assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(0, FLUID, InferredType.LAND)); + assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(-8, FLUID, InferredType.SEA)); + } + + @Test + public void oceanIsExactlyTheEngineUnderwaterPredicate() { + assertEquals(IrisSurfaceKind.OCEAN, IrisSurfaceClassifier.classify(FLUID, FLUID, InferredType.LAND)); + assertEquals(IrisSurfaceKind.OCEAN, IrisSurfaceClassifier.classify(FLUID - 1, FLUID, InferredType.LAND)); + assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(FLUID + 1, FLUID, InferredType.LAND)); + } + + @Test + public void shoreOnlyAppliesAboveTheFluidLine() { + assertEquals(IrisSurfaceKind.SHORE, IrisSurfaceClassifier.classify(FLUID + 1, FLUID, InferredType.SHORE)); + assertEquals(IrisSurfaceKind.OCEAN, IrisSurfaceClassifier.classify(FLUID, FLUID, InferredType.SHORE)); + } + + @Test + public void caveAndAbsentTypesFallBackToLandAboveWater() { + assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(FLUID + 10, FLUID, InferredType.CAVE)); + assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(FLUID + 10, FLUID, InferredType.SEA)); + assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(FLUID + 10, FLUID, null)); + } + + @Test + public void biomeIsOnlyRequiredWhenTheAnswerCanDependOnIt() { + assertFalse(IrisSurfaceClassifier.requiresSurfaceBiome(0, FLUID)); + assertFalse(IrisSurfaceClassifier.requiresSurfaceBiome(FLUID, FLUID)); + assertTrue(IrisSurfaceClassifier.requiresSurfaceBiome(FLUID + 1, FLUID)); + } + + @Test + public void whenBiomeIsNotRequiredEveryInferredTypeYieldsTheSameKind() { + for (int surface = -4; surface <= FLUID; surface++) { + if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, FLUID)) { + continue; + } + + IrisSurfaceKind expected = IrisSurfaceClassifier.classify(surface, FLUID, null); + for (InferredType inferredType : InferredType.values()) { + assertEquals("surface=" + surface + " type=" + inferredType, + expected, IrisSurfaceClassifier.classify(surface, FLUID, inferredType)); + } + } + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactoryTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactoryTest.java new file mode 100644 index 000000000..9227ee86e --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactoryTest.java @@ -0,0 +1,52 @@ +package art.arcane.iris.core.service.terrain; + +import art.arcane.iris.api.terrain.IrisWorldInfo; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class IrisWorldInfoFactoryTest { + @Test + public void aDescribableWorldReportsFluidHeightInAbsoluteWorldY() { + IrisWorldInfo info = IrisWorldInfoFactory.build( + "overworld", "minecraft:world", 42L, -64, 320, 63, false); + + assertNotNull(info); + assertEquals("overworld", info.dimensionKey()); + assertEquals("minecraft:world", info.worldIdentity()); + assertEquals(42L, info.seed()); + assertEquals(-64, info.minHeight()); + assertEquals(320, info.maxHeight()); + assertEquals(-1, info.fluidHeight()); + assertEquals(384, info.height()); + assertFalse(info.studio()); + } + + @Test + public void aStudioWorldIsReportedAsStudio() { + IrisWorldInfo info = IrisWorldInfoFactory.build( + "overworld", "minecraft:studio", 1L, 0, 256, 63, true); + + assertNotNull(info); + assertTrue(info.studio()); + assertEquals(63, info.fluidHeight()); + } + + @Test + public void anIndescribableWorldIsAbsentRatherThanHalfBuilt() { + assertNull(IrisWorldInfoFactory.build(null, "minecraft:world", 1L, 0, 256, 63, false)); + assertNull(IrisWorldInfoFactory.build("overworld", null, 1L, 0, 256, 63, false)); + assertNull(IrisWorldInfoFactory.build("overworld", "minecraft:world", 1L, 256, 256, 63, false)); + assertNull(IrisWorldInfoFactory.build("overworld", "minecraft:world", 1L, 320, 0, 63, false)); + } + + @Test + public void anAbsentGeneratorOrWorldIsDescribedAsNothing() { + assertNull(IrisWorldInfoFactory.from(null)); + assertNull(IrisWorldInfoFactory.forWorld(null)); + } +} diff --git a/core/purity-allowlist.txt b/core/purity-allowlist.txt index 0d1ad24b0..7ddbbc60e 100644 --- a/core/purity-allowlist.txt +++ b/core/purity-allowlist.txt @@ -1,4 +1,5 @@ art/arcane/iris/core/IrisRuntimeSchedulerMode.java +art/arcane/iris/core/IrisWorldStorage.java art/arcane/iris/core/IrisWorlds.java art/arcane/iris/core/ServerConfigurator.java art/arcane/iris/core/datapack/DatapackIngestService.java @@ -18,7 +19,6 @@ art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java art/arcane/iris/core/lifecycle/WorldsProviderBackend.java art/arcane/iris/core/link/ExternalDataProvider.java -art/arcane/iris/core/link/Identifier.java art/arcane/iris/core/link/MultiverseCoreLink.java art/arcane/iris/core/link/WorldEditLink.java art/arcane/iris/core/link/data/CraftEngineDataProvider.java @@ -52,6 +52,7 @@ art/arcane/iris/core/runtime/WorldRuntimeControlService.java art/arcane/iris/core/safeguard/Mode.java art/arcane/iris/core/safeguard/task/Tasks.java art/arcane/iris/core/service/BoardSVC.java +art/arcane/iris/core/service/EntityRiseSVC.java art/arcane/iris/core/service/ExternalDataSVC.java art/arcane/iris/core/service/GlobalCacheSVC.java art/arcane/iris/core/service/ObjectSVC.java @@ -69,15 +70,14 @@ art/arcane/iris/core/tools/IrisPackBenchmarking.java art/arcane/iris/core/tools/IrisReflectiveAPI.java art/arcane/iris/core/tools/IrisToolbelt.java art/arcane/iris/core/tools/IrisWorldCreator.java -art/arcane/iris/core/tools/TreePlausibilizer.java art/arcane/iris/engine/IrisEngineEffects.java art/arcane/iris/engine/IrisWorldManager.java art/arcane/iris/engine/data/chunk/LinkedTerrainChunk.java art/arcane/iris/engine/data/chunk/TerrainChunk.java art/arcane/iris/engine/decorator/DecoratorCore.java +art/arcane/iris/engine/framework/BukkitEngineWorldManager.java art/arcane/iris/engine/framework/EngineAssignedWorldManager.java art/arcane/iris/engine/framework/EnginePlayer.java -art/arcane/iris/engine/framework/EngineWorldManager.java art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java art/arcane/iris/engine/mantle/MantleWriter.java art/arcane/iris/engine/object/BlockDataMergeSupport.java @@ -91,7 +91,6 @@ art/arcane/iris/engine/object/IrisCommandRegistry.java art/arcane/iris/engine/object/IrisCompat.java art/arcane/iris/engine/object/IrisCompatabilityBlockFilter.java art/arcane/iris/engine/object/IrisCompatabilityItemFilter.java -art/arcane/iris/engine/object/IrisDimension.java art/arcane/iris/engine/object/IrisDirection.java art/arcane/iris/engine/object/IrisEffect.java art/arcane/iris/engine/object/IrisEnchantment.java @@ -108,7 +107,6 @@ art/arcane/iris/engine/object/IrisTree.java art/arcane/iris/engine/object/IrisVanillaLootTable.java art/arcane/iris/engine/object/IrisVillagerOverride.java art/arcane/iris/engine/object/IrisVillagerTrade.java -art/arcane/iris/engine/object/IrisWorld.java art/arcane/iris/engine/object/LegacyTileData.java art/arcane/iris/engine/object/PotionEffectTypes.java art/arcane/iris/engine/object/TileData.java @@ -122,11 +120,13 @@ art/arcane/iris/platform/bukkit/BukkitBiome.java art/arcane/iris/platform/bukkit/BukkitBlockResolution.java art/arcane/iris/platform/bukkit/BukkitBlockState.java art/arcane/iris/platform/bukkit/BukkitEntityType.java +art/arcane/iris/platform/bukkit/BukkitEnvironment.java art/arcane/iris/platform/bukkit/BukkitItem.java art/arcane/iris/platform/bukkit/BukkitPlatform.java art/arcane/iris/platform/bukkit/BukkitRegistries.java art/arcane/iris/platform/bukkit/BukkitScheduler.java art/arcane/iris/platform/bukkit/BukkitWorld.java +art/arcane/iris/platform/bukkit/BukkitWorldBinding.java art/arcane/iris/util/common/data/IrisCustomData.java art/arcane/iris/util/common/data/registry/Attributes.java art/arcane/iris/util/common/data/registry/KeyedRegistry.java diff --git a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java index 316b39812..6b830a0b6 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java +++ b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java @@ -26,7 +26,10 @@ import art.arcane.iris.spi.protocol.IrisMessage; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.protocol.IrisProtocolServer; import art.arcane.iris.core.pregenerator.IrisPregenerator; +import art.arcane.iris.core.pregenerator.PregenApiPhase; +import art.arcane.iris.core.pregenerator.PregenApiSink; import art.arcane.iris.core.pregenerator.PregenListener; +import art.arcane.iris.core.pregenerator.PregenPhaseTracker; import art.arcane.iris.core.pregenerator.PregenTask; import art.arcane.iris.core.pregenerator.PregeneratorMethod; import art.arcane.iris.engine.framework.Engine; @@ -41,6 +44,7 @@ import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.iris.util.common.scheduling.J; import java.awt.Color; +import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -70,6 +74,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { private final Engine engine; private final ExecutorService service; private final Thread worker; + private final PregenPhaseTracker apiPhases = new PregenPhaseTracker(); private PregenRenderer renderer; private Consumer2 drawFunction; private int rgc = 0; @@ -211,24 +216,24 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { public static PregenProgress progressSnapshot() { PregeneratorJob inst = instance.get(); - if (inst == null) { - return null; - } + return inst == null ? null : inst.snapshot(); + } - double percent = inst.lastTotalChunks <= 0 ? 0D : ((double) inst.lastGenerated / (double) inst.lastTotalChunks) * 100D; + public PregenProgress snapshot() { + double percent = lastTotalChunks <= 0 ? 0D : ((double) lastGenerated / (double) lastTotalChunks) * 100D; return new PregenProgress( percent, - inst.lastGenerated, - inst.lastTotalChunks, - Math.max(0D, inst.lastChunksPerSecond), - Math.max(0L, inst.lastChunksRemaining), - inst.lastEta, - inst.lastElapsed, - inst.lastMethod, - inst.paused(), - inst.pregenerator.getFailedChunks(), - inst.worldName(), - inst.worldIdentity()); + lastGenerated, + lastTotalChunks, + Math.max(0D, lastChunksPerSecond), + Math.max(0L, lastChunksRemaining), + lastEta, + lastElapsed, + lastMethod, + paused(), + pregenerator.getFailedChunks(), + worldName(), + worldIdentity()); } public String worldName() { @@ -372,6 +377,32 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { for (Consumer i : onProgress) { i.accept(percent); } + + dispatchApiPhases(apiPhases.onTick(paused())); + } + + private void dispatchApiPhases(List phases) { + if (phases.isEmpty()) { + return; + } + + PregenApiSink sink = IrisServices.getOrNull(PregenApiSink.class); + if (sink == null) { + return; + } + + PregenProgress progress = snapshot(); + for (PregenApiPhase phase : phases) { + try { + sink.pregen(phase, progress); + } catch (Throwable error) { + IrisLogging.reportError("Iris pregeneration API dispatch failed for phase " + phase + ".", error); + } + } + } + + private boolean reachedTotal() { + return lastTotalChunks > 0L && lastGenerated >= lastTotalChunks; } @Override @@ -455,6 +486,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { @Override public void onClose() { + dispatchApiPhases(apiPhases.onClose(reachedTotal())); close(); instance.compareAndSet(this, null); whenDone.forEach(Runnable::run); @@ -463,7 +495,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { @Override public void onSaving() { - + dispatchApiPhases(apiPhases.onSaving()); } @Override diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenApiPhase.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenApiPhase.java new file mode 100644 index 000000000..a2c1ffa68 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenApiPhase.java @@ -0,0 +1,29 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.pregenerator; + +public enum PregenApiPhase { + STARTED, + TICK, + PAUSED, + RESUMED, + SAVING, + COMPLETED, + CANCELLED +} diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenApiSink.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenApiSink.java new file mode 100644 index 000000000..8c844553e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenApiSink.java @@ -0,0 +1,25 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.pregenerator; + +import art.arcane.iris.core.gui.PregeneratorJob; + +public interface PregenApiSink { + void pregen(PregenApiPhase phase, PregeneratorJob.PregenProgress progress); +} diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPhaseTracker.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPhaseTracker.java new file mode 100644 index 000000000..df68dbae7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPhaseTracker.java @@ -0,0 +1,80 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.pregenerator; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +public final class PregenPhaseTracker { + private final AtomicReference state = new AtomicReference<>(new State(false, false, false, false)); + + public List onTick(boolean pausedNow) { + while (true) { + State current = state.get(); + if (current.finished()) { + return List.of(); + } + + State next = new State(true, pausedNow, false, false); + List phases; + if (!current.started()) { + phases = List.of(PregenApiPhase.STARTED, PregenApiPhase.TICK); + } else if (pausedNow != current.paused()) { + phases = List.of(pausedNow ? PregenApiPhase.PAUSED : PregenApiPhase.RESUMED, PregenApiPhase.TICK); + } else { + phases = List.of(PregenApiPhase.TICK); + } + + if (state.compareAndSet(current, next)) { + return phases; + } + } + } + + public List onSaving() { + while (true) { + State current = state.get(); + if (current.finished() || current.saving()) { + return List.of(); + } + + State next = new State(current.started(), current.paused(), true, false); + if (state.compareAndSet(current, next)) { + return List.of(PregenApiPhase.SAVING); + } + } + } + + public List onClose(boolean completed) { + while (true) { + State current = state.get(); + if (current.finished()) { + return List.of(); + } + + State next = new State(current.started(), current.paused(), current.saving(), true); + if (state.compareAndSet(current, next)) { + return List.of(completed ? PregenApiPhase.COMPLETED : PregenApiPhase.CANCELLED); + } + } + } + + private record State(boolean started, boolean paused, boolean saving, boolean finished) { + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPhaseTrackerTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPhaseTrackerTest.java new file mode 100644 index 000000000..37470dace --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPhaseTrackerTest.java @@ -0,0 +1,72 @@ +package art.arcane.iris.core.pregenerator; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; + +public class PregenPhaseTrackerTest { + @Test + public void theFirstTickAnnouncesTheJobAndStillReportsProgress() { + PregenPhaseTracker tracker = new PregenPhaseTracker(); + + assertEquals(List.of(PregenApiPhase.STARTED, PregenApiPhase.TICK), tracker.onTick(false)); + assertEquals(List.of(PregenApiPhase.TICK), tracker.onTick(false)); + } + + @Test + public void pauseAndResumeAreEmittedOnTransitionOnly() { + PregenPhaseTracker tracker = new PregenPhaseTracker(); + tracker.onTick(false); + + assertEquals(List.of(PregenApiPhase.PAUSED, PregenApiPhase.TICK), tracker.onTick(true)); + assertEquals(List.of(PregenApiPhase.TICK), tracker.onTick(true)); + assertEquals(List.of(PregenApiPhase.RESUMED, PregenApiPhase.TICK), tracker.onTick(false)); + assertEquals(List.of(PregenApiPhase.TICK), tracker.onTick(false)); + } + + @Test + public void aJobThatStartsPausedDoesNotAlsoEmitPaused() { + PregenPhaseTracker tracker = new PregenPhaseTracker(); + + assertEquals(List.of(PregenApiPhase.STARTED, PregenApiPhase.TICK), tracker.onTick(true)); + assertEquals(List.of(PregenApiPhase.TICK), tracker.onTick(true)); + assertEquals(List.of(PregenApiPhase.RESUMED, PregenApiPhase.TICK), tracker.onTick(false)); + } + + @Test + public void savingIsAStateNotAPulse() { + PregenPhaseTracker tracker = new PregenPhaseTracker(); + tracker.onTick(false); + + assertEquals(List.of(PregenApiPhase.SAVING), tracker.onSaving()); + assertEquals(List.of(), tracker.onSaving()); + assertEquals(List.of(PregenApiPhase.TICK), tracker.onTick(false)); + assertEquals(List.of(PregenApiPhase.SAVING), tracker.onSaving()); + } + + @Test + public void closeIsTerminalAndDistinguishesCompletionFromCancellation() { + PregenPhaseTracker completed = new PregenPhaseTracker(); + completed.onTick(false); + assertEquals(List.of(PregenApiPhase.COMPLETED), completed.onClose(true)); + assertEquals(List.of(), completed.onClose(true)); + assertEquals(List.of(), completed.onClose(false)); + assertEquals(List.of(), completed.onTick(false)); + assertEquals(List.of(), completed.onSaving()); + + PregenPhaseTracker cancelled = new PregenPhaseTracker(); + cancelled.onTick(false); + assertEquals(List.of(PregenApiPhase.CANCELLED), cancelled.onClose(false)); + assertEquals(List.of(), cancelled.onClose(true)); + } + + @Test + public void aJobThatNeverTickedStillReportsItsOutcomeExactlyOnce() { + PregenPhaseTracker tracker = new PregenPhaseTracker(); + + assertEquals(List.of(PregenApiPhase.CANCELLED), tracker.onClose(false)); + assertEquals(List.of(), tracker.onClose(false)); + } +} diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 000000000..0716e8bb9 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,196 @@ +# Iris API + +`art.arcane.iris.api` is the surface another plugin compiles against. It answers three questions: +what does Iris terrain look like at a coordinate, when does an Iris world engine come up and go +down, and how do I hand an axe-swing to the Iris tree feller and get told what it cost. It is built +from Bukkit types, `java.*` types and its own types only — no VolmLib, no Adventure, no shaded +types — so it links against a plain Spigot or Paper compile classpath. A test in the Iris build +walks every class in the package and fails the build if any exported signature mentions anything +else. + +| Package | What it is for | Document | +|---|---|---| +| `art.arcane.iris.api.terrain` | Ask what the generator says about a coordinate: is this an Iris world, what biome, what region, how high is the surface, what kind of surface | [terrain.md](terrain.md) | +| `art.arcane.iris.api.world` | Learn when an engine becomes usable and when it stops being usable | [world-events.md](world-events.md) | +| `art.arcane.iris.api.pregen` | Follow a pregeneration job | [world-events.md](world-events.md) | +| `art.arcane.iris.api.tree` | Drive the tree feller and charge for it | [tree-feller.md](tree-feller.md) | + +PlaceholderAPI keys are not a compile surface, but they are a contract an operator depends on: +[placeholders.md](placeholders.md). + +Anything outside `art.arcane.iris.api` is internal. `art.arcane.iris.core.*`, +`art.arcane.iris.engine.*`, `art.arcane.iris.util.*` and `art.arcane.iris.spi.*` change without +notice and without a deprecation cycle. If you find yourself importing `Engine`, `IrisBiome` or +`IrisToolbelt`, you are outside the contract. + +--- + +## Platform limitation + +`art.arcane.iris.api` ships in the **Bukkit plugin jar only**. The Fabric, Forge and NeoForge mod +jars contain the same generator but not this package — there is no Bukkit `World`, no +`ServicesManager` and no `Event` bus to hang it on. A mod that wants generator data uses the mod +loader's own registries. + +The mod jars carry a separate, unrelated surface at `art.arcane.iris.modded.api`, for supplying +custom block data to the generator from a mod. It is not covered by these documents, is absent from +the Bukkit plugin jar, and shares no types with `art.arcane.iris.api`. + +Everything in these documents assumes Paper, Purpur, Leaf, Canvas, Folia or Spigot, Minecraft 26.2, +Java 25. + +--- + +## Depending on Iris + +Iris is not published to Maven Central. Two routes work. + +**Against the jar you already have.** This is the route that cannot go wrong: the jar you compile +against is the jar you run against. + +```gradle +dependencies { + compileOnly(files('libs/Iris.jar')) +} +``` + +**Against JitPack.** This is what Volmit's own plugins do. `transitive = false` is required — the +Iris build declares a large dependency graph you do not want on your compile classpath. + +```gradle +repositories { + maven { url = uri('https://jitpack.io') } +} + +dependencies { + compileOnly('com.github.VolmitSoftware:Iris:') { + changing = true + transitive = false + } +} +``` + +Bukkit plugin (`plugin.yml`): + +```yaml +softdepend: [Iris] +``` + +Paper plugin (`paper-plugin.yml`): + +```yaml +dependencies: + server: + Iris: + load: BEFORE + required: false + join-classpath: true +``` + +`join-classpath: true` is mandatory on Paper. Plugin classloaders are isolated, and without it you +get `NoClassDefFoundError` on `art.arcane.iris.api.*` even though the classes ship unrelocated. + +Iris declares `load: STARTUP` and registers its services during its own `onEnable`. Do not resolve +an Iris service in a static initialiser or a constructor. Resolve it lazily, at the point of use, +and handle `null` — see below. + +--- + +## Acquiring a service + +Two services are registered with the Bukkit `ServicesManager` at `ServicePriority.Normal`: +`IrisTerrainService` and `IrisTreeFellerService`. Both are unregistered on Iris shutdown. + +```java +package com.example.integration; + +import art.arcane.iris.api.terrain.IrisTerrainService; +import org.bukkit.Bukkit; +import org.bukkit.plugin.RegisteredServiceProvider; + +public final class IrisLookup { + private IrisLookup() { + } + + public static IrisTerrainService terrain() { + RegisteredServiceProvider provider = + Bukkit.getServicesManager().getRegistration(IrisTerrainService.class); + return provider == null ? null : provider.getProvider(); + } +} +``` + +Resolve on every use, or cache and invalidate on `PluginDisableEvent`. A cached reference to a +service whose plugin has been disabled does not throw — every terrain query answers "absent" and +every tree-feller call returns `false` — but it will never answer usefully again, and the +replacement instance registered by a later enable is a different object. + +Neither service is a functional interface and neither is meant to be implemented by a third party. +`ServicesManager#getRegistration` hands back the highest-priority registration, so registering your +own `IrisTerrainService` above `Normal` shadows Iris's for every other plugin on the server. Do not. +It does not shadow it for Iris — Iris resolves its own services from an internal registry, so its +PlaceholderAPI expansion keeps reading the real one, and the two would then disagree. + +--- + +## The shared library is not relocated + +Iris bundles `art.arcane.volmlib` **unrelocated**, at its real package name. Several sibling Volmit +plugins do relocate it — Adapt shades it to `art.arcane.adapt.util.arcane.volmlib`, React to +`art.arcane.react.util.arcane.volmlib`. Three consequences, in order of how likely they are to bite: + +1. **You do not need VolmLib to use this API.** No type in `art.arcane.iris.api` mentions it. You + never import it, never shade it, never declare it. + +2. **If you also use VolmLib yourself, shade and relocate your own copy.** Do not compile against + `art.arcane.volmlib` expecting Iris's copy to satisfy it at runtime. Under Paper's isolated + classloaders you would need `join-classpath: true` on the Iris dependency and you would be + binding to whatever VolmLib version Iris happens to ship, which changes on Iris's release + schedule and not yours. Relocating your copy costs nothing and removes the coupling entirely. + +3. **A relocated sibling and Iris do not share those classes.** `art.arcane.adapt.util.arcane.volmlib.X` + and `art.arcane.volmlib.X` are unrelated types to the JVM. Never pass an object obtained from one + plugin's shaded copy into another's; the cast fails at runtime, not at compile time. + +--- + +## Threading, at a glance + +This suite runs on Folia, where region threads own chunks and entity schedulers own entities. Each +document states its own contract; this is the summary. + +| Call | Which thread may call it | Where the callback lands | +|---|---|---| +| Every `IrisTerrainService` read | Any thread, including async | Returns inline | +| `IrisColumnSink.accept` | — | The thread that called `sampleColumns` | +| `IrisTreeFellerService.tryFell` | The region thread delivering the `BlockBreakEvent` | Returns inline | +| `IrisTreeFellerService.isManagedBreak` | Any thread | Returns inline | +| `IrisTreeFellerService.isTreeBlock` | The region thread owning the block; it can also block on disk — see [tree-feller.md](tree-feller.md#istreeblock-is-the-expensive-one) | Returns inline | +| `TreeFellerRunHooks.onActivationAccepted` | — | The region thread that owns the broken block | +| `TreeFellerRunHooks.reserveLogCost` / `commitLogCost` / `refundLogCost` | — | The feller's entity scheduler thread | +| `IrisWorldEngineEvent` handlers | — | Main thread; on Folia, the global region thread | +| `IrisPregenerationEvent` handlers | — | Main thread; on Folia, the global region thread | + +"Any thread" is claimed for the terrain reads because they are justified in doing so: they read the +world's generator reference and evaluate cached procedural noise, and touch no chunk, no block +state, no entity and no mantle storage. See [terrain.md](terrain.md#threading) for the full +argument. It is not a claim any other part of this API makes. + +--- + +## Switching over the enums + +`IrisSurfaceKind`, `IrisColumnField`, `IrisWorldPhase`, `IrisPregenPhase` and `TreeFellerAccess` may +gain constants in a future release. A `switch` **expression** over them is exhaustive, so it stops +compiling — and throws `IncompatibleClassChangeError` on an already-compiled jar — the moment one is +added. + +**Always write a `default` arm** in third-party code: + +```java +String label = switch (kind) { + case LAND -> "land"; + case OCEAN -> "water"; + default -> ""; +}; +``` diff --git a/docs/api/placeholders.md b/docs/api/placeholders.md new file mode 100644 index 000000000..a51d7ff3e --- /dev/null +++ b/docs/api/placeholders.md @@ -0,0 +1,216 @@ +# Iris placeholders + +Iris registers the `iris` PlaceholderAPI expansion when PlaceholderAPI is enabled. It publishes +sixteen keys: seven in the world family, describing the generator around the reading player, and +nine in the pregeneration family, describing the server's running pregeneration job. + +This is an operator-facing contract, not a compile surface. Nothing here needs a dependency, a +`softdepend`, or a line of Java. If you are writing a plugin rather than a scoreboard, the same data +is available with more precision through the [terrain API](terrain.md) and the +[pregeneration events](world-events.md). + +**The pre-2.0 underscore keys are gone.** There is no alias and no dual-accept window. If you are +upgrading an existing board, go straight to the [migration table](#migration-from-the-pre-20-keys). + +--- + +## The value grammar + +Every key follows the same rules, so a board never has to special-case Iris: + +- Paths are **dot-separated and lowercase** and never contain an underscore. Iris lowercases the + path before resolving it, so `%iris_WORLD.BIOME%` works, but write it lowercase. +- Values are **plain text**: no colour codes, no unit suffixes, no `%` character, `.` as the decimal + separator, no thousands grouping. +- Any section sign or `%` character that appears inside a pack-authored name — a biome display name, + a world name — is stripped before you see it, so a pack cannot inject formatting or a nested + placeholder into your board. + +There are exactly three possible answers: + +| Answer | When | What PlaceholderAPI shows | +|---|---|---| +| A value | The key is known and has data | The value | +| `---` | The key is known and has no data right now | `---` | +| Nothing | The key is not one Iris publishes | The literal `%iris_...%` | + +The third row is deliberate. A typo stays visible on the board instead of quietly rendering as +blank, which is why there is no catch-all fallback. + +A real zero is `0`, never `---`. `---` means "no reading", not "zero". + +--- + +## World keys + +| Placeholder | Value | +|---|---| +| `%iris_available%` | `true` when the Iris terrain service is live, `false` otherwise | +| `%iris_world.available%` | `true` when the reading player is in an Iris world and a reading exists | +| `%iris_world.biome%` | Surface biome display name at the player, for example `Hot Desert Dunes` | +| `%iris_world.biome-key%` | Surface biome load key, for example `desert/hot-dunes` | +| `%iris_world.region%` | Region display name at the player | +| `%iris_world.region-key%` | Region load key | +| `%iris_world.dimension%` | Dimension (pack) load key of the player's world | + +`%iris_available%` is the only world-family key that does not need a player. It answers for the +console and for an offline player. + +Every other `world.*` key needs a tracked online player. For the console, an offline player, or a +player Iris has no position for yet, `world.available` is `false` and the rest are `---`. + +### They are surface readings, and they are cached + +`world.biome`, `world.biome-key`, `world.region` and `world.region-key` describe the **surface** at +the player's block column — the biome and region the generator places at ground level. A player +standing in a cave under an overhang reads the biome of the sky above them, not the cave they are +in. That is what a board reader means by "what biome am I in". + +The reading is rebuilt at most **once per second per player**, and only when something actually reads +one of these keys. Consequences: + +- A whole board of `world.*` keys costs one rebuild per player per second, however many of them are + on it. +- A value can lag a sprinting player by up to a second. +- A board that nobody is looking at costs nothing. + +Position tracking has two speeds. Walking republishes a player's column at most once per second, and +not at all while they stand still. Anything that is not walking — joining, respawning, changing +world, stepping through a portal, or **any** teleport including `/iris goto`, `/tp`, an ender pearl +and a random teleport — publishes immediately. A player who arrives somewhere and stops moving +therefore never keeps showing the biome of where they came from. + +--- + +## Pregeneration keys + +| Placeholder | Value | +|---|---| +| `%iris_pregen.available%` | `true` while a pregeneration job is running | +| `%iris_pregen.world%` | World name the running job is pregenerating | +| `%iris_pregen.percent%` | Completion, `0.00` to `100.00`, with no `%` character | +| `%iris_pregen.eta%` | Estimated seconds remaining, whole number | +| `%iris_pregen.eta-text%` | The same estimate as `45s`, `2m 5s` or `1h 30m` | +| `%iris_pregen.chunks%` | Chunks generated so far | +| `%iris_pregen.total%` | Chunks in the job | +| `%iris_pregen.chunks-per-second%` | Current rate, two decimal places | +| `%iris_pregen.paused%` | `true` while the job is paused | + +`pregen.*` is **global**, not per player. Iris runs one pregeneration job per server, so these keys +read the same for everyone, including the console. `%iris_pregen.world%` says which world it is. + +The snapshot is published when the job reports progress, once per second, and cleared the moment the +job completes or is cancelled. After that every `pregen.*` key except `pregen.available` reads `---`, +and `pregen.available` reads `false`. There is no lingering "last job" state to mistake for a running +one. + +`pregen.eta` and `pregen.eta-text` are two renderings of the same estimate: use `eta` for arithmetic +and `eta-text` for display. Both read `0` and `0s` respectively before the job has generated enough +chunks to estimate from. + +--- + +## Availability + +The expansion is registered only if PlaceholderAPI is enabled when Iris starts. It sets `persist()`, +so it survives `/papi reload` without Iris restarting. + +`%iris_available%` distinguishes "Iris is installed but its terrain service is not up" from "Iris is +not installed at all" — in the second case the expansion does not exist, no key resolves, and every +`%iris_...%` on the board renders literally. Gate a conditional board on `%iris_available%` if you +want it to disappear cleanly rather than show `---` rows on a server where Iris is present but still +starting. + +Iris never gates a placeholder on a permission. A placeholder has no permission context to check +against — the player reading a scoreboard is not necessarily the player the value describes — so +values that should not be public are not published at all. That is why there is no seed key. + +--- + +## Migration from the pre-2.0 keys + +The old underscore keys are gone. There is no alias and no dual-accept window: an old key now +renders literally, so it is visible rather than silently wrong. This table is complete — every key +the old expansion published appears in it. + +| Old key | New key | Why | +|---|---|---| +| `%iris_biome_name%` | `%iris_world.biome%` | Renamed onto the dot grammar | +| `%iris_biome_id%` | `%iris_world.biome-key%` | Renamed; `id` was always the load key | +| `%iris_region_name%` | `%iris_world.region%` | Renamed onto the dot grammar | +| `%iris_region_id%` | `%iris_world.region-key%` | Renamed; `id` was always the load key | +| `%iris_biome_file%` | removed | Rendered an absolute server path into player-visible text, and threw on packs with no backing file | +| `%iris_region_file%` | removed | Same as `biome_file` | +| `%iris_world_seed%` | removed | Handed the world seed to anyone who could read a scoreboard, and a placeholder has no permission context to gate on | +| `%iris_terrain_height%` | removed | Reported the *generated* height, before objects and player edits, so it disagreed with the block under the player's feet | +| `%iris_terrain_slope%` | removed | Three extra noise samples per read for an unformatted pack-authoring diagnostic | +| `%iris_world_mode%` | removed | Studio or Production; a studio world exists for seconds during authoring and is never on a live board | +| `%iris_world_speed%` | removed | Mutated engine rate-window state every time it was read. `%iris_pregen.chunks-per-second%` answers the same question from a snapshot | + +There is one behaviour change inside the four renames, and it will be visible on a board that has +been in service for a while. The old keys sampled **two blocks above the player's feet** and asked +for the biome at that exact Y, which meant a player standing under an overhang or inside a cave read +the *cave* biome. `%iris_world.biome%` and `%iris_world.biome-key%` are always the surface biome for +the column. If your board is checked against a screenshot from before the rename, expect +underground readings to differ. + +`%iris_world.dimension%` is new. It has no pre-2.0 equivalent. + +The three removals worth a replacement plan: + +- **`world_seed`** has no replacement and will not get one. A plugin that legitimately needs the seed + can read it from `IrisWorldInfo.seed()` through the [terrain API](terrain.md), where there is a + caller to hold responsible. +- **`terrain_height`** has no replacement. If you want the ground height for a coordinate, use + `IrisTerrainService#surfaceHeight`, which is the same number with its limitations documented. If + you want the block under the player, use the player's own Y. +- **`world_speed`** is replaced by `%iris_pregen.chunks-per-second%` for the pregeneration case, + which is what it was almost always used for. There is no per-world live generation rate key. + +--- + +## Failure policy + +| Situation | What Iris shows | +|---|---| +| An unknown path | Nothing. PlaceholderAPI re-emits the literal `%iris_...%` | +| A known path with no data | `---` | +| No player context, on a `world.*` key | `---`, and `world.available` is `false` | +| Player is not in an Iris world | `---`, and `world.available` is `false` | +| The terrain service is not registered | `---`, `world.available` is `false`, `%iris_available%` is `false` | +| No pregeneration job running | `---`, and `pregen.available` is `false` | +| A resolver throws | `---`, and one warning is logged naming the exact placeholder | + +A resolver that throws is logged **once per distinct path**, up to 64 distinct paths, so a broken +key cannot flood the console from a scoreboard that re-renders every tick. The value shown is always +`---` — a failure never renders a stack trace, a class name, or an empty string. + +Iris does not disable a placeholder after repeated failures. There is no fault limit and no +quarantine; a key that fails keeps being asked and keeps answering `---`. + +--- + +## Key reference + +The full published list, as PlaceholderAPI reports it under `/papi info iris`: + +``` +available +pregen.available +pregen.chunks +pregen.chunks-per-second +pregen.eta +pregen.eta-text +pregen.paused +pregen.percent +pregen.total +pregen.world +world.available +world.biome +world.biome-key +world.dimension +world.region +world.region-key +``` + +Prefix each with `%iris_` and suffix with `%`. diff --git a/docs/api/terrain.md b/docs/api/terrain.md new file mode 100644 index 000000000..baa308547 --- /dev/null +++ b/docs/api/terrain.md @@ -0,0 +1,584 @@ +# Iris terrain query API + +`art.arcane.iris.api.terrain` answers what the Iris generator says about a coordinate: whether a +world is an Iris world at all, what biome and region the pack places there, how high the terrain +generates and whether that surface is land, shore, ocean or nothing. It is a read of the +**generator**, not of the world. It never loads a chunk, never forces generation, never reads a +placed block, and never tells you what a player has since built. + +Everything here is cheap and non-blocking, and this document says exactly how cheap and exactly why +non-blocking, because a terrain API where the reader has to guess is a terrain API that ends up in a +per-tick loop. + +--- + +## Depending on Iris and acquiring the service + +See [README.md](README.md#depending-on-iris) for the build and plugin-descriptor setup. The service +is registered with the Bukkit `ServicesManager` at `ServicePriority.Normal` for the duration of the +Iris plugin's enabled lifetime. + +```java +package com.example.integration; + +import art.arcane.iris.api.terrain.IrisTerrainService; +import org.bukkit.Bukkit; +import org.bukkit.plugin.RegisteredServiceProvider; + +public final class TerrainAccess { + private TerrainAccess() { + } + + public static IrisTerrainService service() { + RegisteredServiceProvider provider = + Bukkit.getServicesManager().getRegistration(IrisTerrainService.class); + return provider == null ? null : provider.getProvider(); + } +} +``` + +There is no `Iris` class to import, no static accessor and no reflection. If the registration is +missing, Iris is absent or has not enabled yet; that is a `null` and not an exception. + +--- + +## The read surface + +```java +public interface IrisTerrainService { + boolean isIrisWorld(World world); + + Optional worldInfo(World world); + + OptionalInt surfaceHeight(World world, int blockX, int blockZ); + + IrisSurfaceKind surfaceKind(World world, int blockX, int blockZ); + + Optional surfaceBiomeKey(World world, int blockX, int blockZ); + + Optional surfaceBiomeName(World world, int blockX, int blockZ); + + Optional biomeKey(World world, int blockX, int blockY, int blockZ); + + Optional regionKey(World world, int blockX, int blockZ); + + Optional regionName(World world, int blockX, int blockZ); + + int maxSampleColumns(); + + int maxSampleChunks(); + + boolean sampleColumns(World world, IrisColumnQuery query, IrisColumnSink sink); +} +``` + +All coordinates are **absolute block coordinates in world space**, including `blockY` and including +the value returned by `surfaceHeight`. There is no engine-space offset for a caller to apply. + +`*Key` returns a pack load key — `desert/hot-dunes`, `overworld` — which is stable, lowercase and +what you store. `*Name` returns the author's display string — `Hot Desert Dunes` — which is what you +show and which can change when the pack author edits it. Both are `Optional` and both are empty when +the underlying value is absent or the empty string. + +--- + +## Cost and blocking + +This is the whole story. Read it before you write a loop. + +Iris's generator is a stack of procedural noise streams. Every read below evaluates that stack for +one column and memoises the result in a shared per-chunk noise cache. A **cold** column runs the +pack's noise; a **warm** column is an array index. Nothing on this page reads chunk storage, reads a +block, loads a region file, takes a lock, waits on a future, or asks the server to generate +anything. + +| Call | Cost when cold | Cost when warm | Forces generation | Can block | When the data is not there | +|---|---|---|---|---|---| +| `isIrisWorld` | one `World#getGenerator()` and an `instanceof` | same | No | No | `false` | +| `worldInfo` | field reads off the live engine and dimension | same | No | No | `Optional.empty()` | +| `surfaceHeight` | one height sample, which pulls the region and base-biome streams for that column | array read | No | No | `OptionalInt.empty()` | +| `surfaceKind` | one height sample, plus one surface-biome sample **only** for columns above fluid level | array read | No | No | `IrisSurfaceKind.UNKNOWN` | +| `surfaceBiomeKey` / `surfaceBiomeName` | one surface-biome sample, which pulls height, base biome and region | array read | No | No | `Optional.empty()` | +| `biomeKey` at or near the surface | as `surfaceBiomeKey`, plus one height sample to decide surface vs cave | array read | No | No | `Optional.empty()` | +| `biomeKey` well below the surface | the above, plus the cave-biome stream and the dimension's carving resolution | array reads | No | No | `Optional.empty()` | +| `regionKey` / `regionName` | one region sample — the cheapest of the biome family | array read | No | No | `Optional.empty()` | +| `maxSampleColumns` / `maxSampleChunks` | reads two settings fields | same | No | No | a positive number, always | +| `sampleColumns` | one of the above per column, in chunk-local order | array reads | No | No | `false`, sink untouched | + +Two consequences that matter more than the per-call cost: + +**Calling in a tight main-thread loop is survivable but wasteful.** Nothing will deadlock and +nothing will stall on I/O. What you will do is evict the generator's own working set: the noise +cache is shared with live chunk generation, and a scan across unrelated coordinates pushes out the +columns the generator was about to reuse. The visible symptom is chunk generation slowing down, not +your loop slowing down. Use `sampleColumns` for anything wider than a handful of columns — it walks +chunk by chunk so each cached chunk is filled and finished with before the next one starts. + +**These values are the generator's opinion, not the world's.** `surfaceHeight` is the height of the +generated terrain column. It does not include objects, decorations, structures, trees, snow, or +anything a player has placed or broken since. In an already-generated world the block at that Y may +be different, and in a world that has never generated there you still get an answer, because the +answer comes from noise and not from storage. If you need the real block, use Bukkit's +`World#getHighestBlockYAt` and accept its chunk-loading cost. If you need to know where the pack +*intends* the ground to be — which is the useful question for a pregeneration planner, a map +renderer or a spawn picker — use this. + +### Surface height, precisely + +`surfaceHeight` returns the absolute Y of the **topmost generated terrain block**. A player stands +at `surfaceHeight + 1`. Fluid is ignored: under an ocean you get the sea floor, not the water +surface. Compare against `IrisWorldInfo.fluidHeight()` to tell the difference, or use +`surfaceKind`, which does exactly that comparison for you. + +--- + +## Threading + +**Every read on this interface may be called from any thread, including an async task.** That is an +unusual claim in a Folia-aware suite and it is made deliberately, so here is the justification: + +- The only Bukkit call Iris makes on your behalf is `World#getGenerator()`, an accessor on the world + object itself. No chunk is touched, no block state is read, no entity is looked at, no world list + is walked. +- Everything after that is engine-internal noise evaluation over concurrent caches. There is no + region-owned state involved, so there is no region thread with a claim on it. +- No method here takes a lock you can contend on, calls `join`, or schedules onto another thread. + +There is nothing to gain from hopping to a region thread first, and on Folia there is no region +thread that would be the *correct* one for a coordinate scan spanning many regions anyway. Run wide +scans on your own async executor. + +The one rule: **`IrisColumnSink.accept` runs on the thread that called `sampleColumns`, inline, +once per column.** If you called from an async thread, your sink is on that async thread and must +not touch Bukkit state. If you called from a region thread, your sink is holding that region thread +for the entire walk. Collect into a local structure inside the sink and do the Bukkit work +afterwards. + +--- + +## Column sampling + +`sampleColumns` is the bulk read. It walks a rectangle at a stride, chunk by chunk, and pushes each +column into your sink. + +```java +public record IrisColumnQuery( + int minBlockX, + int minBlockZ, + int maxBlockX, + int maxBlockZ, + int strideBlocks, + EnumSet fields) { + + public static IrisColumnQuery rect( + int minBlockX, + int minBlockZ, + int maxBlockX, + int maxBlockZ, + int strideBlocks, + EnumSet fields); + + public long columnCount(); + + public long chunkCount(); + + public EnumSet fields(); +} +``` + +The bounds are **inclusive on both ends**. The sampled lattice is anchored at +`(minBlockX, minBlockZ)` and steps by `strideBlocks`; a stride of `1` visits every column. + +The constructor rejects, with `IllegalArgumentException`: + +- an empty `fields` set, +- `maxBlockX < minBlockX` or `maxBlockZ < minBlockZ`, +- `strideBlocks < 1`. + +`fields` is defensively copied on the way in and on every call to `fields()`, so a set you mutate +after construction does not change the query, and a set you get back and mutate does not either. +`fields()` allocates a fresh `EnumSet` each call — hoist it out of loops. + +`columnCount()` and `chunkCount()` saturate at `Long.MAX_VALUE` instead of overflowing, so a query +over the whole coordinate space reports an absurd number rather than a negative one. + +### The hard limits + +```java +int maxSampleColumns(); +int maxSampleChunks(); +``` + +Both are derived from the generator's noise cache size, so a large-cache server permits larger +queries and a small-cache server permits smaller ones. The rule is fixed: + +``` +maxSampleChunks = max(64, noiseCacheSize / 4) +maxSampleColumns = maxSampleChunks * 256 +``` + +With the default `noiseCacheSize` of 1024 that is **256 chunks and 65 536 columns**. The divisor of +four is the point of the whole mechanism: one API query may never consume more than a quarter of the +cache the live generator is using. + +**A query that exceeds either limit returns `false` and never calls your sink — not once.** There is +no partial answer, no truncation, no exception, and no log line. If you get `false` before any +column arrives, check the counts. + +The two limits are checked independently, and this is where callers get caught: + +```java +IrisColumnQuery wide = IrisColumnQuery.rect( + 0, 0, 6399, 6399, 64, EnumSet.of(IrisColumnField.SURFACE_KIND)); +``` + +That query reports `columnCount() == 10_000`, well under the 65 536 column limit, and +`chunkCount() == 160_000`, far over the 256 chunk limit. It is refused. + +**`chunkCount()` counts the chunk span of the rectangle, not the chunks you actually sample.** +Striding does not reduce it. A coarse sweep across a large area is refused on chunks even though it +touches very few columns. Split it into tiles, or accept a smaller rectangle: + +```java +long maxColumns = terrain.maxSampleColumns(); +long maxChunks = terrain.maxSampleChunks(); + +if (query.columnCount() > maxColumns || query.chunkCount() > maxChunks) { + return; +} +``` + +Ask the service every time. Both values change when an operator edits the setting and reloads. + +### The sink + +```java +@FunctionalInterface +public interface IrisColumnSink { + void accept(int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey); +} +``` + +Every column produces exactly one `accept`. What arrives depends on the `fields` you asked for, and +the placeholders for fields you did **not** ask for are not distinguishable from real data: + +| Field requested | Parameter | If you asked for it | If you did not | +|---|---|---|---| +| `SURFACE_HEIGHT` | `surfaceHeight` | absolute world Y of the topmost terrain block | `-1` | +| `SURFACE_KIND` | `kind` | `LAND`, `SHORE`, `OCEAN` or `VOID` | `IrisSurfaceKind.UNKNOWN` | +| `BIOME_KEY` | `biomeKey` | the biome load key | `null` | + +`-1` is a legal absolute Y in any world with a negative minimum height, so **never treat `-1` as +"absent"**. Branch on your own field set, which you already have. `biomeKey` may also be `null` when +you *did* ask for it, if the column resolves to no biome; test for `null` regardless. + +Requesting fewer fields genuinely costs less. `SURFACE_KIND` alone does not evaluate the biome +stream for a column that is at or below fluid level, because the classification is already decided. +Ask for `BIOME_KEY` and every column pays for the biome stream. + +### Visit order + +Columns arrive grouped by chunk. The walk iterates chunks with Z as the outer loop and X as the +inner loop, and within each chunk iterates its lattice points the same way, Z outer and X inner. +Order is deterministic for a given query, but it is **not** a row-major sweep of the rectangle: you +receive all of one chunk's columns before any of the next chunk's. If your consumer needs raster +order, sort afterwards or index into an array by `(blockX, blockZ)`. + +### The return value + +`sampleColumns` returns `true` if and only if every column in the query was delivered. It returns +`false` when: + +- `world`, `query` or `sink` is `null`, or the world has no live Iris engine — sink untouched; +- a limit was exceeded — sink untouched; +- **your sink threw** — the walk stops at that column; +- **the engine closed underneath the walk** — the walk stops at that column. + +In the last two cases the columns already delivered were delivered. `false` does not mean "nothing +happened"; it means "do not trust this result set as complete". Treat a `false` as a signal to +discard the partial data, not as a signal that there is none. + +--- + +## Worked example: finding the flattest buildable spot + +A plugin that places a settlement wants the flattest patch of land inside a radius, and wants none +of that work on a region thread. It samples on an async task, then hands the answer to the player's +entity scheduler, which is the correct thread to touch a player on Folia and on Paper alike. + +```java +package com.example.settlement; + +import art.arcane.iris.api.terrain.IrisColumnField; +import art.arcane.iris.api.terrain.IrisColumnQuery; +import art.arcane.iris.api.terrain.IrisColumnSink; +import art.arcane.iris.api.terrain.IrisSurfaceKind; +import art.arcane.iris.api.terrain.IrisTerrainService; +import art.arcane.iris.api.terrain.IrisWorldInfo; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.RegisteredServiceProvider; + +import java.util.EnumSet; +import java.util.Optional; +import java.util.concurrent.Executor; + +public final class SettlementSiteFinder { + private static final int RADIUS_BLOCKS = 512; + private static final int STRIDE_BLOCKS = 8; + + private final Plugin plugin; + private final Executor background; + + public SettlementSiteFinder(Plugin plugin, Executor background) { + this.plugin = plugin; + this.background = background; + } + + public void findFor(Player player) { + World world = player.getWorld(); + Location origin = player.getLocation(); + int centreX = origin.getBlockX(); + int centreZ = origin.getBlockZ(); + + background.execute(() -> { + String result = search(world, centreX, centreZ); + player.getScheduler().run(plugin, task -> player.sendMessage(result), null); + }); + } + + private String search(World world, int centreX, int centreZ) { + IrisTerrainService terrain = service(); + + if (terrain == null || !terrain.isIrisWorld(world)) { + return "That world is not generated by Iris."; + } + + Optional info = terrain.worldInfo(world); + + if (info.isEmpty()) { + return "The Iris engine for that world is not available right now."; + } + + IrisColumnQuery query = IrisColumnQuery.rect( + centreX - RADIUS_BLOCKS, + centreZ - RADIUS_BLOCKS, + centreX + RADIUS_BLOCKS, + centreZ + RADIUS_BLOCKS, + STRIDE_BLOCKS, + EnumSet.of(IrisColumnField.SURFACE_HEIGHT, IrisColumnField.SURFACE_KIND)); + + if (query.columnCount() > terrain.maxSampleColumns() + || query.chunkCount() > terrain.maxSampleChunks()) { + return "That search area is larger than this server allows."; + } + + int fluidHeight = info.get().fluidHeight(); + Best best = new Best(); + + IrisColumnSink sink = (int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey) -> { + if (kind != IrisSurfaceKind.LAND || surfaceHeight <= fluidHeight) { + return; + } + + long score = (long) Math.abs(surfaceHeight - fluidHeight) * 1024L + + Math.abs(blockX - centreX) + Math.abs(blockZ - centreZ); + + if (score < best.score) { + best.score = score; + best.x = blockX; + best.y = surfaceHeight; + best.z = blockZ; + } + }; + + if (!terrain.sampleColumns(world, query, sink)) { + return "The terrain scan did not complete. Try again."; + } + + if (best.score == Long.MAX_VALUE) { + return "No dry land within " + RADIUS_BLOCKS + " blocks."; + } + + return "Best site: " + best.x + ", " + (best.y + 1) + ", " + best.z; + } + + private IrisTerrainService service() { + RegisteredServiceProvider provider = + plugin.getServer().getServicesManager().getRegistration(IrisTerrainService.class); + return provider == null ? null : provider.getProvider(); + } + + private static final class Best { + private long score = Long.MAX_VALUE; + private int x; + private int y; + private int z; + } +} +``` + +`Best` needs no synchronisation: the sink runs inline on the thread that called `sampleColumns`, so +every `accept` for this walk is on the background thread that started it, and no other thread reads +the holder until the walk has returned. The `+ 1` on the reported Y is the standing height, since +`surfaceHeight` is the topmost solid block. `player.getScheduler()` is Paper's entity scheduler and +is the correct hop on both Paper and Folia; on Folia it resumes on whichever region owns the player +at that moment, which may not be the region they were in when the scan started. + +--- + +## The minimum: one coordinate + +Most integrations want one biome name at one place. That is three lines and needs none of the above. + +```java +IrisTerrainService terrain = service(); + +String biome = terrain == null + ? "unknown" + : terrain.surfaceBiomeName(player.getWorld(), player.getLocation().getBlockX(), + player.getLocation().getBlockZ()).orElse("unknown"); +``` + +`surfaceBiomeName` on a non-Iris world, a null world, a closing engine or a disabled Iris returns +`Optional.empty()`. You do not need to call `isIrisWorld` first unless you want to distinguish +"not an Iris world" from "Iris has nothing to say". + +--- + +## What `IrisWorldInfo` tells you + +```java +public record IrisWorldInfo( + String dimensionKey, + String worldIdentity, + long seed, + int minHeight, + int maxHeight, + int fluidHeight, + boolean studio) { + + public int height(); +} +``` + +| Component | What it is | +|---|---| +| `dimensionKey` | Pack load key of the dimension, for example `overworld` | +| `worldIdentity` | The world's namespaced key rendered as a string, for example `minecraft:overworld` | +| `seed` | The raw seed the engine was built with | +| `minHeight` | Absolute Y of the world floor, for example `-64` | +| `maxHeight` | Absolute Y of the world ceiling, exclusive, for example `320` | +| `fluidHeight` | Absolute Y of the pack's sea level | +| `studio` | `true` only for a transient studio world | +| `height()` | `maxHeight - minHeight` | + +`minHeight`, `maxHeight` and `fluidHeight` are all absolute world Y, directly comparable with +`surfaceHeight` and with `blockY`. The record's own constructor rejects a null `dimensionKey` or +`worldIdentity` with `NullPointerException` and a non-positive height range with +`IllegalArgumentException`, so an instance you receive is always internally consistent. + +`worldIdentity` is the string form of the world's `NamespacedKey`, and it is the key Iris itself +persists per-world state under. It is the right key for you to persist too, because it is namespaced +and unambiguous where a bare name is not. It is **not** independent of the world's name: outside the +three vanilla dimensions the server derives the key from the world folder, so renaming that folder +changes `worldIdentity` exactly as it changes `World#getName()`. + +`studio` is `true` for a world Iris created for pack authoring — those exist for seconds and are +deleted, so a persistence layer should skip them. + +`seed` is the generator seed. Treat it as privileged: it is enough to reproduce the entire world +offline, including every ore vein and structure. Iris deliberately does not expose it through +PlaceholderAPI for that reason. Do not put it anywhere a player can read. + +--- + +## Failure policy + +Iris assumes the caller will pass nulls, hand it a world it does not own, keep a stale service +reference, and throw from a sink. + +| Situation | What Iris does | +|---|---| +| `world` is `null` | Every query answers absent; `sampleColumns` returns `false` | +| The world has no Iris generator | Same | +| Iris is disabled, or disabled between your two calls | Same. Nothing throws | +| The generator is closing, or the engine is closed | `isIrisWorld` still returns **`true`**; every other query answers absent | +| A query throws inside the engine | Counted, logged with the stack trace, answered as absent | +| `query` or `sink` is `null` | `sampleColumns` returns `false`, sink never called | +| The query exceeds `maxSampleColumns` or `maxSampleChunks` | `sampleColumns` returns `false`, sink never called, nothing logged | +| Your sink throws | Walk aborts at that column, fault counted and logged, `sampleColumns` returns `false`. Columns already delivered stay delivered | +| The engine closes mid-walk | Walk stops at that column, `sampleColumns` returns `false` | + +Two deliberate asymmetries worth internalising: + +**`isIrisWorld` does not check liveness.** It answers "was this world created by Iris", not "can +Iris answer questions about it right now". During world unload and during plugin shutdown you will +see `isIrisWorld(world) == true` alongside `worldInfo(world).isEmpty()`. That is correct behaviour, +not a race you can win. Code that branches on `isIrisWorld` and then dereferences an +`Optional#get()` will throw eventually; use `orElse` or check the `Optional`. + +**Iris never quarantines a caller.** There is no fault limit and no disable-after-N. A sink that +throws on every column will be logged and refused on every call, forever, and will never be muted or +blacklisted. The internal fault counters exist only to throttle the log line to at most one report +per minute per category — the count in that line tells you how many faults have occurred in total, +so a "3 faults" line followed by a "9000 faults" line means you have a loop, not two incidents. + +Nothing in this API ever throws a checked exception, and nothing throws an unchecked one except the +argument validation on `IrisColumnQuery` and `IrisWorldInfo` constructors described above. + +--- + +## Configuration + +`plugins/Iris/settings.json`: + +| Key | Default | Effect on this API | +|---|---|---| +| `performance.noiseCacheSize` | `1024` | The chunk capacity of the shared noise cache. `maxSampleChunks` is `max(64, this / 4)` and `maxSampleColumns` is `maxSampleChunks * 256`. Raising it raises both limits and the memory the generator holds | + +There is no on/off switch for the terrain API and no per-world gate. It answers for every world with +a live Iris engine, and answers absent for everything else. + +--- + +## Enum reference + +### `IrisSurfaceKind` + +Returned by `surfaceKind` and delivered to `IrisColumnSink`. + +| Constant | Meaning | Test Iris applies | +|---|---|---| +| `LAND` | Dry ground | Surface above sea level, and the biome is not a shore biome | +| `SHORE` | Beach or bank | Surface above sea level, and the pack classifies the biome as shore | +| `OCEAN` | Under water | Surface at or below `IrisWorldInfo.fluidHeight()` | +| `VOID` | Nothing generated | Surface at or below `IrisWorldInfo.minHeight()` | +| `UNKNOWN` | No answer | Not an Iris world, the engine is unavailable, a query faulted, or `SURFACE_KIND` was not requested | + +**`VOID` is tested first and wins.** A column at or below `minHeight()` reports `VOID` whatever the +sea level is; only a column above the floor is then tested against the fluid level, and only a column +above the fluid level is then tested for a shore biome. The four are mutually exclusive. + +`OCEAN` is inclusive at the boundary: a column whose topmost terrain block sits exactly at sea level +reports `OCEAN` even though no water block is generated above it. If that one-block distinction +matters, compare `surfaceHeight` against `fluidHeight` yourself. + +`UNKNOWN` is overloaded on purpose — it is the single "no data" value, so a caller never has to +handle both a sentinel and an exception. Distinguish the causes with `isIrisWorld` and `worldInfo` +if you need to. + +### `IrisColumnField` + +Selects what `sampleColumns` computes and passes to the sink. At least one is required. + +| Constant | Fills | Extra work | +|---|---|---| +| `SURFACE_HEIGHT` | the `surfaceHeight` parameter | one height sample per column | +| `SURFACE_KIND` | the `kind` parameter | one height sample, plus a biome sample only for columns above sea level | +| `BIOME_KEY` | the `biomeKey` parameter | one biome sample per column, unconditionally | + +`SURFACE_HEIGHT` and `SURFACE_KIND` share their height sample — asking for both costs barely more +than asking for either. diff --git a/docs/api/tree-feller.md b/docs/api/tree-feller.md new file mode 100644 index 000000000..2557bd9c7 --- /dev/null +++ b/docs/api/tree-feller.md @@ -0,0 +1,517 @@ +# Iris tree feller API + +`art.arcane.iris.api.tree` lets another plugin **drive** the Iris tree feller and **charge** for it. +The feller removes a whole Iris-generated tree, block by block, when a sneaking survival player +breaks one of its logs with an axe. This API lets you turn it on for a player who would not +otherwise be allowed it, override the durability rules, and take something from that player for each +log removed — with a reservation you can get back if the log turns out not to be removable. + +There are two things you can do, and they are independent: + +| You want to… | Use | +|---|---| +| start a felling run that Iris would not have started, or price it | `IrisTreeFellerService#tryFell` with `TreeFellerOptions.integrationOverride(...)` | +| avoid double-handling the block breaks Iris generates while felling | `IrisTreeFellerService#isManagedBreak` | +| ask whether a block belongs to an Iris tree at all | `IrisTreeFellerService#isTreeBlock` | + +**The tree feller is off by default.** `treeFeller.enabled` in Iris's settings is `false` out of the +box, and the standalone path additionally requires the `iris.treefeller` permission. An +`INTEGRATION_OVERRIDE` request bypasses **both** — that is what the mode is for, and it means your +plugin is now the thing that decides who may fell trees. + +--- + +## Depending on Iris and acquiring the service + +See [README.md](README.md#depending-on-iris) for the build and plugin-descriptor setup. The service +is registered with the Bukkit `ServicesManager` at `ServicePriority.Normal`. + +```java +package com.example.woodcutting; + +import art.arcane.iris.api.tree.IrisTreeFellerService; +import org.bukkit.Bukkit; +import org.bukkit.plugin.RegisteredServiceProvider; + +public final class FellerAccess { + private FellerAccess() { + } + + public static IrisTreeFellerService service() { + RegisteredServiceProvider provider = + Bukkit.getServicesManager().getRegistration(IrisTreeFellerService.class); + return provider == null ? null : provider.getProvider(); + } +} +``` + +```java +public interface IrisTreeFellerService { + boolean tryFell(BlockBreakEvent event, TreeFellerOptions options); + + boolean isManagedBreak(BlockBreakEvent event); + + boolean isTreeBlock(Block block); +} +``` + +--- + +## The lifecycle + +``` +your BlockBreakEvent handler + | + v +tryFell(event, options) register a felling request against this break. + | Returns true when YOUR request is pending. Nothing has + | happened yet and no hook has fired. + | + | (Iris re-checks everything at EventPriority.MONITOR) + v +onActivationAccepted() the run is real. Fires exactly once, if at all. + | + | (per LOG block, in the order the tree comes apart) + v +reserveLogCost() -> false you refuse. The run ends. Nothing to give back. + | + | true + v + +--> commitLogCost() the log is gone. The charge is yours. FINAL. + +--> refundLogCost() the log could not be removed. Give it back. +``` + +Rules Iris guarantees: + +- `onActivationAccepted` fires **at most once per run**, and only after Iris has re-validated the + break at `MONITOR`: the event was not cancelled, the block still resolves to the same Iris tree, + and no other run already claims that tree. +- `reserveLogCost` is called **once per log block**, not once per run. A twelve-log tree calls it up + to twelve times. **Leaves never reserve** — they are removed without consulting you. +- `reserveLogCost` is called **before** Iris charges the axe's durability, so a refusal costs the + player nothing at all. +- Exactly **one** of `commitLogCost` or `refundLogCost` follows a `reserveLogCost` that returned + `true`, with the one exception described under [Failure policy](#failure-policy). +- **`commitLogCost` is final.** There is no reversal after it, and Iris will not call + `refundLogCost` for a block it has already committed. +- A `reserveLogCost` that returns `false` ends the whole run immediately. It does not skip that log + and continue. +- A tree can only be felled by one run at a time, server-wide. A second player breaking the same + tree while a run is in flight has their break cancelled with drops suppressed, and no hook of + yours is called for it. + +There is **no terminal callback.** `TreeFellerRunHooks` has no "the run finished" method. If your +accounting needs to know when a run ended, count `commitLogCost` and `refundLogCost` calls against +the `onActivationAccepted` that opened the run, and treat a run with no activity as over. + +--- + +## Threading + +Three different threads are involved and the distinction matters, because two of them are region +threads on Folia and one is an entity scheduler. + +| Call | Thread | +|---|---| +| `tryFell` | You call it. It must be the thread delivering the `BlockBreakEvent` — the region thread that owns the broken block | +| `isManagedBreak` | Any thread. It is a set lookup and touches nothing else | +| `isTreeBlock` | The region thread that owns the block. It reads block state **and** can block on disk — see below | +| `onActivationAccepted` | The region thread that owns the broken block, inline in the `MONITOR` dispatch | +| `reserveLogCost` | The **feller's entity scheduler thread** | +| `commitLogCost` | The feller's entity scheduler thread | +| `refundLogCost` | The feller's entity scheduler thread | + +The three cost hooks run on the player's entity scheduler, which is the thread that owns that player +on Folia. Reading and mutating the feller's inventory, experience and effects is legal there. The +player's *world* is not yours on that thread — do not read or write blocks from a cost hook. + +`onActivationAccepted` runs on the block's region thread, inline inside the `BlockBreakEvent` +dispatch at `MONITOR`. Blocks and the player are both legal to touch there, but you are inside event +dispatch: return promptly. + +**Do not block, in any of the four.** No I/O, no `CompletableFuture#join`, no locks held across the +call. Iris does not interrupt a hook that hangs and does not time it out; the contract is the only +protection. If a cost decision needs remote data, cache it — prime it on `PlayerJoinEvent`. + +### `isTreeBlock` is the expensive one + +`isTreeBlock` reads Iris's mantle — the generator's persistent per-region metadata store — to find +out whether the block was placed by an Iris tree. If the mantle region covering that block is not +resident in memory, **this call loads it from disk, synchronously, on your thread.** It also reads +the block's type and block data, so the chunk must be loaded and you must be on the region thread +that owns it. + +Concretely: on a first touch in a cold area it does a filesystem stat, and possibly a full region +load and decompress, before it answers. On a warm area it is a couple of map lookups. + +Do not call it per block in a loop, per tick, or on a large area. Nothing else in this API touches +the mantle; if you are calling `isTreeBlock` speculatively rather than about a block a player just +interacted with, you are using it wrong. + +--- + +## Worked example: charging stamina per log + +A plugin with its own stamina pool. It lets players fell trees regardless of Iris's permission and +enabled switch, charges 4 stamina per log, gives it back when a log turns out not to be removable, +and preserves the axe 50% of the time. + +### The hooks + +```java +package com.example.woodcutting; + +import art.arcane.iris.api.tree.TreeFellerRunHooks; + +import java.util.UUID; + +public final class StaminaFellHooks implements TreeFellerRunHooks { + private static final int COST_PER_LOG = 4; + + private final StaminaPool pool; + private final UUID fellerId; + + public StaminaFellHooks(StaminaPool pool, UUID fellerId) { + this.pool = pool; + this.fellerId = fellerId; + } + + @Override + public void onActivationAccepted() { + pool.beginRun(fellerId); + } + + @Override + public boolean reserveLogCost() { + return pool.withdraw(fellerId, COST_PER_LOG); + } + + @Override + public void commitLogCost() { + pool.recordSpend(fellerId, COST_PER_LOG); + } + + @Override + public void refundLogCost() { + pool.deposit(fellerId, COST_PER_LOG); + } +} +``` + +`TreeFellerRunHooks` declares all four methods and none of them has a default, so an implementation +must provide all four even when three are empty. `TreeFellerRunHooks.NONE` is the shared no-op +implementation whose `reserveLogCost` returns `true`; use it when you want the override behaviour +without a cost. + +The hooks instance is **per run**, not per plugin. Build a new one for each `tryFell` call and put +the feller's identity in it — Iris hands the same instance back for every callback of that run and +never inspects it, so it is the natural place to carry run state. + +### The listener + +```java +package com.example.woodcutting; + +import art.arcane.iris.api.tree.IrisTreeFellerService; +import art.arcane.iris.api.tree.TreeFellerOptions; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; + +public final class WoodcuttingListener implements Listener { + private static final int PRESERVE_PERCENT = 50; + + private final StaminaPool pool; + + public WoodcuttingListener(StaminaPool pool) { + this.pool = pool; + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onBreak(BlockBreakEvent event) { + IrisTreeFellerService feller = FellerAccess.service(); + + if (feller == null || feller.isManagedBreak(event)) { + return; + } + + Player player = event.getPlayer(); + + if (!pool.hasWoodcutting(player.getUniqueId())) { + return; + } + + TreeFellerOptions options = TreeFellerOptions.integrationOverride( + PRESERVE_PERCENT, new StaminaFellHooks(pool, player.getUniqueId())); + + feller.tryFell(event, options); + } +} +``` + +The `isManagedBreak` guard is not optional. While a run is in progress Iris fires a +`BlockBreakEvent` for **every block it removes**, so that protection plugins and loggers see the +removals. Without the guard your listener would call `tryFell` on Iris's own break events and your +stamina check would run once per block of the tree. + +`EventPriority.HIGH` is a deliberate choice, and it is load-bearing. It is after the priorities a +protection plugin normally uses to cancel, and — the part that matters — strictly before `HIGHEST`, +which is where Iris asks for its own standalone run. A break is claimed by the first `tryFell` that +succeeds against it, so a handler at `HIGHEST` or `MONITOR` can find that Iris has already taken it. +See [What `tryFell` actually promises](#what-tryfell-actually-promises). + +Registration is ordinary: + +```java +@Override +public void onEnable() { + getServer().getPluginManager().registerEvents(new WoodcuttingListener(pool), this); +} +``` + +--- + +## The minimum: turn the feller on, charge nothing + +If you only want players in your woodcutting class to fell trees, with Iris's own durability +behaviour and no cost: + +```java +IrisTreeFellerService feller = FellerAccess.service(); + +if (feller != null && !feller.isManagedBreak(event) && classes.isWoodcutter(event.getPlayer())) { + feller.tryFell(event, TreeFellerOptions.integrationOverride(0, TreeFellerRunHooks.NONE)); +} +``` + +Put that in a `BlockBreakEvent` handler at a priority earlier than `HIGHEST` — Iris asks for its own +standalone run at `HIGHEST`, and the first request to succeed claims the break. + +`TreeFellerRunHooks.NONE` never refuses and never charges. A `durabilityPreservationChance` of `0` +means every log costs one point of axe durability, which is vanilla-equivalent. + +`TreeFellerOptions.standalone()` exists for completeness — it is the request Iris makes for itself — +and there is almost never a reason for a third party to pass it. It respects the enabled switch and +the permission, so it can only ever do what Iris would already have done. + +--- + +## What `tryFell` actually promises + +```java +boolean tryFell(BlockBreakEvent event, TreeFellerOptions options); +``` + +`true` means **your felling request is pending against this break**. It does not mean a tree will +fall — Iris re-validates everything at `MONITOR` and can still drop the request there. + +**A break is claimed by the first `tryFell` that succeeds against it.** The moment a request is +accepted, Iris marks that `BlockBreakEvent` as managed, and every later `tryFell` for the same event +returns `false` immediately, without looking at your `access` at all. There is no displacement and no +last-writer-wins: whoever asks first, in event-priority order, owns the break. + +| State when you call | Your `access` | Result | +|---|---|---| +| Nothing pending | either | Your request becomes pending. Returns `true` | +| A request already accepted for this break | either | The existing one stays. Returns `false` | + +That has one consequence you must design around. **Iris makes its own `STANDALONE` request from a +listener at `EventPriority.HIGHEST`.** If your handler runs at `HIGHEST` and happens to be registered +after Iris's, or at `MONITOR`, Iris has already claimed the break and your override is refused. Call +`tryFell` from a handler at a priority strictly earlier than `HIGHEST` — `LOWEST`, `LOW`, `NORMAL` or +`HIGH` — and your `INTEGRATION_OVERRIDE` is the one that lands. `HIGH` is the usual choice. + +Two plugins that both want to override the same break resolve the same way: the earlier priority +wins, and the later one gets `false` and knows it lost. Nothing is silently discarded. + +`false` means no request of yours is pending. Iris returns `false` when: + +- the service is disabled, or `event` or `options` is `null`; +- the event is already cancelled; +- the event is one Iris is already managing — either a break another request has already claimed, or + one of the per-block probe events Iris fires during a run. `isManagedBreak` answers both; +- `canUse` failed — for `STANDALONE` that means `treeFeller.enabled` is `false` or the player lacks + `iris.treefeller`; an `INTEGRATION_OVERRIDE` never fails this check; +- the break is not a fellable candidate. + +`true` is still not a run. The `MONITOR` re-validation drops the request if the event was cancelled +after you asked, if the block no longer resolves to the same Iris tree, or if another run already +claims that tree — and in none of those cases does a hook fire. Open your run state in +`onActivationAccepted`, not at `tryFell`. + +### What makes a break a candidate + +An `INTEGRATION_OVERRIDE` bypasses the enabled switch and the permission. It does **not** bypass any +of these, and there is no option to: + +- the player is in `GameMode.SURVIVAL`; +- the player is sneaking; +- the broken block is tagged `Tag.LOGS`; +- the item in the player's main hand is an axe; +- the block carries Iris tree provenance in the mantle — it was placed by an Iris tree, has not been + replaced since, and is not part of a structure. + +A tree the player planted with a vanilla sapling is not an Iris tree and will never fell. Neither is +a log a player placed by hand: Iris clears the provenance record for a block as soon as it is broken +or built over. + +--- + +## How a run comes apart + +Once activated, Iris discovers the tree by walking the mantle provenance markers outward from the +broken block in all 26 directions, breadth-first. Members are then removed in that discovery order — +the block the player broke first, then outward — with ties broken by Y, then X, then Z. + +Discovery is bounded. If any bound is hit the discovery is **incomplete**, and Iris falls back to +removing only the block the player actually broke: + +| Bound | Value | +|---|---| +| Members collected | 131 072 | +| Positions visited | 1 000 000 | +| Distance from the broken block on any axis | 256 blocks | + +Removal is paced: Iris removes a batch of blocks, then yields for a tick before the next batch, so a +large tree takes several ticks and does not stall a region. Batch size scales with the tree. + +A run ends immediately, with no further hooks, when the player: + +- stops sneaking, +- changes their held hotbar slot, +- swaps their hands, +- goes offline, leaves survival mode, or changes world, +- breaks their axe (the run ends after the log that broke it is committed), +- or replaces the axe in that slot with a different item. + +Each removed block fires its own `BlockBreakEvent`, marked so that `isManagedBreak` returns `true` +for it during dispatch. Other plugins can cancel that event to protect a block. A cancelled probe on +a **log** refunds that log's reservation and ends the run; a cancelled probe on a **leaf** has no +reservation to give back and the run simply carries on to the next member. Drops for each block are +computed with the axe **as it was before that block's durability charge**, so enchantments like Silk +Touch and Fortune apply normally. + +The original break event is cancelled by Iris with drops and experience suppressed, because Iris +delivers them itself per block instead. + +--- + +## What the options carry + +```java +public record TreeFellerOptions( + TreeFellerAccess access, + int durabilityPreservationChance, + TreeFellerRunHooks runHooks) { + + public static TreeFellerOptions standalone(); + + public static TreeFellerOptions integrationOverride( + int durabilityPreservationChance, + TreeFellerRunHooks runHooks); +} +``` + +The canonical constructor throws `NullPointerException` for a null `access` or `runHooks`, and +`IllegalArgumentException` for a `durabilityPreservationChance` outside `0 .. 100`. Both factory +methods go through it, so `TreeFellerOptions.integrationOverride(101, hooks)` throws at the call +site rather than clamping silently. + +`durabilityPreservationChance` is a percentage: the chance that removing one log costs the axe no +durability at all. `0` charges every log; `100` never charges. It is rolled independently per log. +An unbreakable axe is never charged whatever the value. + +**The value is only honoured for `INTEGRATION_OVERRIDE`.** A `STANDALONE` request ignores whatever +you passed and uses `treeFeller.durabilityPreservationChance` from Iris's settings — +`TreeFellerOptions.standalone()` hard-codes `0` in the record for exactly that reason. + +```java +public interface TreeFellerRunHooks { + TreeFellerRunHooks NONE; + + void onActivationAccepted(); + + boolean reserveLogCost(); + + void commitLogCost(); + + void refundLogCost(); +} +``` + +Iris never calls anything else on your hooks object — not `equals`, not `hashCode`, not `toString`. +It holds the reference for the duration of the run and drops it when the run ends. + +--- + +## Failure policy + +Iris assumes a hooks implementation will throw, refuse late, or be handed a player who logs out +mid-run. + +| Misbehaviour | What Iris does | +|---|---| +| `onActivationAccepted` throws | Logged with the stack trace. **The run continues** — activation is a notification, not a veto | +| `reserveLogCost` throws | Logged, treated as `false`. The run ends. Nothing is refunded, because nothing was reserved | +| `reserveLogCost` returns `false` | Not a fault. The run ends cleanly at that log | +| `commitLogCost` throws | Logged. The run ends. **The block is already gone and is not restored** | +| `refundLogCost` throws | Logged. The run ends | +| A hook blocks for a long time | Nothing. Iris does not time hooks out, does not warn, and cannot interrupt them | +| `tryFell` is passed a null event or options | Returns `false`. No hook is called | +| Two plugins request an override for one break | The one whose handler ran first wins. The other gets `false` and no hook of its own fires | +| Resolving the candidate throws | Logged. `tryFell` returns `false` | +| `isTreeBlock` throws | Logged. Returns `false` | +| Iris is disabled mid-run | Every active run is finished immediately. **No refund is issued for anything outstanding** | + +**Iris does not quarantine a misbehaving integration.** There is no fault limit, no disable-after-N, +and no automatic unregistration. A hooks implementation that throws on every log will be logged on +every log, forever. + +### The one place a refund can be missed + +A refund is delivered by scheduling onto the feller's entity scheduler. If that scheduling fails — +the player has logged out, or been removed from the world, between the reservation and the failure +that triggers the refund — Iris finishes the run **without calling `refundLogCost`**. The same +applies to a plugin shutdown that ends runs in flight. + +The exposure is at most one log's worth of cost per run, and only in the window between reserving a +log and resolving it, which is a single block removal. If a stricter guarantee matters to you, do +not settle the charge inside the hooks: accumulate reservations in your own per-run state keyed by +the feller, and reconcile on `PlayerQuitEvent` and on your own `onDisable`. The hooks tell you what +happened; they are not a transaction log you can rely on being complete across a disconnect. + +--- + +## Configuration + +`plugins/Iris/settings.json`: + +| Key | Default | Meaning | +|---|---|---| +| `treeFeller.enabled` | `false` | Master switch for the **standalone** path only. When `false`, Iris never fells a tree on its own. An `INTEGRATION_OVERRIDE` request is unaffected | +| `treeFeller.durabilityPreservationChance` | `0` | Percentage chance a log costs no axe durability, for the standalone path only. Clamped to `0 .. 100` on read | + +Permission, declared in the plugin descriptor: + +| Node | Default | Meaning | +|---|---|---| +| `iris.treefeller` | `op` | Required for the standalone path. An `INTEGRATION_OVERRIDE` request does not check it | + +--- + +## Enum reference + +### `TreeFellerAccess` + +| Constant | Enabled switch | `iris.treefeller` | `durabilityPreservationChance` source | +|---|---|---|---| +| `STANDALONE` | Required | Required | Iris settings; the value in your options is ignored | +| `INTEGRATION_OVERRIDE` | Bypassed | Bypassed | The value in your options | + +Neither mode bypasses the candidate checks — survival, sneaking, an axe, a log, and Iris tree +provenance. + +Write a `default` arm when switching over this enum; see +[README.md](README.md#switching-over-the-enums). diff --git a/docs/api/world-events.md b/docs/api/world-events.md new file mode 100644 index 000000000..ae5410e11 --- /dev/null +++ b/docs/api/world-events.md @@ -0,0 +1,459 @@ +# Iris world engine and pregeneration events + +Two Bukkit events tell you what Iris is doing over time. `IrisWorldEngineEvent` marks the points at +which an Iris world's engine becomes usable, is rebuilt under you, or is about to stop being usable. +`IrisPregenerationEvent` reports the progress of a pregeneration job. Both are pure observation: +neither is cancellable, and nothing you do in a handler changes what Iris does next. + +Use `IrisWorldEngineEvent` instead of `WorldLoadEvent` if you care about the *generator* rather than +the world. A world exists before its Iris engine is ready to answer questions, and it still exists +after the engine has been told to close. + +--- + +## Depending on Iris + +See [README.md](README.md#depending-on-iris) for the build and plugin-descriptor setup. Events need +no service lookup — register a `Listener` in your `onEnable` as usual and Bukkit unregisters you +when your plugin disables. + +Both events have their own `HandlerList`. There is no shared base class and no common interface; +`IrisWorldEngineEvent` and `IrisPregenerationEvent` extend `org.bukkit.event.Event` directly. + +Neither implements `Cancellable`. `ignoreCancelled = true` on a handler for either is meaningless +and will not do what you expect. + +--- + +## The world engine lifecycle + +```java +public enum IrisWorldPhase { + ENGINE_READY, + ENGINE_HOTLOADED, + ENGINE_CLOSING +} +``` + +``` +ENGINE_READY the engine for this world is registered and answering. + | Terrain queries work from here on. + | + +--> ENGINE_HOTLOADED the pack was edited and the engine rebuilt in place. + | Same world, same engine object, different pack contents. + | Can fire any number of times, or never. + | + v +ENGINE_CLOSING the engine is about to be torn down. Last call. +``` + +Guarantees Iris makes: + +- `ENGINE_READY` fires **at most once per world** for a given registration. It is keyed on the + world's UUID, so a world that unloads and loads again gets a fresh `ENGINE_READY`. +- `ENGINE_CLOSING` is **never delivered without a preceding `ENGINE_READY`** for that world. If Iris + never announced a world ready, it never announces it closing. +- `ENGINE_CLOSING` is dispatched **before** Iris starts closing the generator, not after. When your + handler runs, the engine has not been shut down yet. +- If Iris replaces a world's engine — the generator was swapped out and a new one registered — you + get `ENGINE_CLOSING` for the old one, and a later `ENGINE_READY` when the replacement finishes + registering. You never get two consecutive `ENGINE_READY` without a `CLOSING` between them. +- On Iris shutdown, **every** world that was announced ready is announced closing, before Iris drains + its worker pool and before any generator is closed. +- `ENGINE_HOTLOADED` is not deduplicated and does not participate in the ready/closing pairing. It + is a notification that the pack data behind a live engine was reloaded and the engine rebuilt + around it. The world, the world object and the seed are unchanged; the pack contents may not be. + Treat any pack-derived value you cached at `ENGINE_READY` as stale when it arrives. + +### The one thing `ENGINE_CLOSING` does not promise + +`ENGINE_CLOSING` is fired before the *generator* closes, but during a full plugin shutdown the +terrain service may already have been withdrawn by the time your handler runs — Iris tears down its +services in an unspecified order. So: + +> Do not treat `ENGINE_CLOSING` as a window in which to run terrain queries. Capture whatever you +> need at `ENGINE_READY` and use `ENGINE_CLOSING` only to drop it. + +A terrain query in a closing handler does not throw. It returns absent, which is worse, because it +looks like data. + +--- + +## The event + +```java +public class IrisWorldEngineEvent extends Event { + public IrisWorldEngineEvent(World world, IrisWorldPhase phase, IrisWorldInfo info); + + public static HandlerList getHandlerList(); + + public World getWorld(); + + public IrisWorldPhase getPhase(); + + public Optional getInfo(); + + @Override + public HandlerList getHandlers(); +} +``` + +`getWorld()` and `getPhase()` are never `null` — the constructor rejects both. + +`getInfo()` is `Optional` and can be empty. It is empty when Iris could not describe the engine at +dispatch time: the generator was already closing, the engine was already closed, or building the +description threw (which is logged with a stack trace, and does not suppress the event). Handle the +empty case; do not call `get()` unconditionally. + +`IrisWorldInfo` is documented in [terrain.md](terrain.md#what-irisworldinfo-tells-you). The short +version is that it carries the dimension load key, the world's namespaced identity, the seed, the +world height bounds, the pack's sea level, and whether this is a transient studio world. + +### Threading + +**Handlers always run on the main thread. On Folia, that is the global region thread.** + +Iris raises these phases from several places — the world load and unload handlers, its own enable +and disable, and a pack hotload that can originate from a file watcher thread. The dispatch +normalises all of them: + +- Raised from the primary thread: the event is called **inline**, before the raising code continues. + A `WorldLoadEvent` handler of yours that registers state, and an `ENGINE_READY` handler that reads + it, will see a consistent picture. +- Raised from any other thread: the event is handed to the server scheduler and delivered on the + main or global region thread on a later tick. + +So your handler is always on a thread where touching Bukkit is legal, and never on the file-watcher +or worker thread that caused the phase. + +What is forbidden: blocking. These phases run on the thread the server ticks on. No I/O, no +`CompletableFuture#join`, no waiting on another scheduler. If you need to persist something, hand it +to your own executor. + +--- + +## Worked example: caching pack metadata per world + +A plugin that shows the dimension a player is in wants that string without asking Iris for it on +every render. It captures it once when the engine is ready and drops it when the engine closes. + +```java +package com.example.hud; + +import art.arcane.iris.api.terrain.IrisWorldInfo; +import art.arcane.iris.api.world.IrisWorldEngineEvent; +import art.arcane.iris.api.world.IrisWorldPhase; +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class IrisWorldRegistry implements Listener { + private final Map dimensionKeys = new ConcurrentHashMap<>(); + + public String dimensionKeyOf(World world) { + return dimensionKeys.get(world.getUID()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onEngine(IrisWorldEngineEvent event) { + UUID worldId = event.getWorld().getUID(); + + switch (event.getPhase()) { + case ENGINE_READY, ENGINE_HOTLOADED -> { + Optional info = event.getInfo(); + + if (info.isEmpty()) { + dimensionKeys.remove(worldId); + return; + } + + dimensionKeys.put(worldId, info.get().dimensionKey()); + } + case ENGINE_CLOSING -> dimensionKeys.remove(worldId); + default -> { + } + } + } +} +``` + +`ENGINE_HOTLOADED` is handled alongside `ENGINE_READY` because a hotload can change the pack's +dimension key. The `default` arm is there because the enum can grow; see +[README.md](README.md#switching-over-the-enums). + +The map is a `ConcurrentHashMap` even though the handler is single-threaded, because +`dimensionKeyOf` is read from wherever your HUD renders. + +--- + +## Pregeneration + +```java +public enum IrisPregenPhase { + STARTED, + TICK, + PAUSED, + RESUMED, + SAVING, + COMPLETED, + CANCELLED +} +``` + +```java +public class IrisPregenerationEvent extends Event { + public IrisPregenerationEvent(IrisPregenPhase phase, IrisPregenProgress progress); + + public static HandlerList getHandlerList(); + + public IrisPregenPhase getPhase(); + + public IrisPregenProgress getProgress(); + + @Override + public HandlerList getHandlers(); +} +``` + +Both accessors are never `null`; the constructor rejects both. + +### The order phases arrive in + +``` +STARTED -> TICK -> TICK -> ... -> COMPLETED + | + +-- PAUSED -> TICK -> ... -> RESUMED -> TICK -> ... + | + +-- SAVING (once, near the end) + | + +-- CANCELLED (instead of COMPLETED, if the job was stopped early) +``` + +- **One job at a time, server-wide.** Iris runs a single pregeneration job per server. There is no + job identifier on the event because there is nothing to disambiguate; `IrisPregenProgress` names + the world the running job is working on. +- `STARTED` is dispatched exactly once per job, immediately before that job's first `TICK`, in that + order. +- `TICK` fires **once per second** while the job runs. It fires while paused too. +- `PAUSED` and `RESUMED` fire on the transition only, each immediately followed by a `TICK`. A job + that is never paused never emits either. +- `SAVING` fires at most once per job. +- Exactly one of `COMPLETED` or `CANCELLED` is dispatched, and it is terminal. `COMPLETED` means the + job reached its chunk total; `CANCELLED` means it stopped before that, whether by operator action + or by shutdown. **No phase is ever dispatched for a job after its terminal phase.** + +### Threading + +**Handlers always run on the main thread. On Folia, that is the global region thread.** + +The pregenerator ticks on its own worker thread, so every pregeneration phase is scheduled rather +than called inline. It arrives on a later tick than the moment the numbers were sampled. For a +progress bar this is invisible; for anything that correlates pregeneration against another timeline, +assume up to one tick of skew. + +Do not block. The job does not wait for your handler — the dispatch is fire-and-forget and a +throwing handler is logged and skipped — but you are on the server's tick thread and everything else +does wait for you. + +### What `IrisPregenProgress` tells you + +```java +public record IrisPregenProgress( + String worldName, + String worldIdentity, + double percent, + long generatedChunks, + long totalChunks, + long remainingChunks, + long failedChunks, + double chunksPerSecond, + long etaMillis, + long elapsedMillis, + String method, + boolean paused) { +} +``` + +| Component | What it is | +|---|---| +| `worldName` | Never null; falls back to `worldIdentity` | +| `worldIdentity` | The world's namespaced key rendered as a string | +| `percent` | `0.0` to `100.0` | +| `generatedChunks` | Chunks the job has finished | +| `totalChunks` | Chunks in the job | +| `remainingChunks` | Chunks still to do | +| `failedChunks` | Chunks the job could not generate | +| `chunksPerSecond` | Current rate | +| `etaMillis` | Estimated milliseconds remaining | +| `elapsedMillis` | Milliseconds since the job started | +| `method` | Never null; `""` when unknown | +| `paused` | `true` while the job is paused | + +The record's constructor sanitises everything before you see it, so you never have to defend against +the generator's arithmetic: + +- `percent` is clamped to `0.0 .. 100.0`. `NaN` and infinity become `0.0`. +- `chunksPerSecond` is clamped to at least `0.0`. `NaN` and infinity become `0.0`. +- `generatedChunks`, `totalChunks`, `remainingChunks`, `failedChunks`, `etaMillis` and + `elapsedMillis` are clamped to at least `0`. +- `worldName` falls back to `worldIdentity` when the world has no name. +- `method` becomes `""` rather than `null`. + +The only rejection is a `null` `worldIdentity`, which throws `NullPointerException` at construction — +so an instance delivered to you always identifies a world. + +`etaMillis` is an estimate derived from the running rate and is `0` before enough chunks have +completed to compute one. `failedChunks` counts chunks the job could not generate; a non-zero value +on `COMPLETED` means the job finished with holes. + +--- + +## Worked example: mirroring pregeneration into a boss bar + +```java +package com.example.pregenbar; + +import art.arcane.iris.api.pregen.IrisPregenProgress; +import art.arcane.iris.api.pregen.IrisPregenerationEvent; +import org.bukkit.Bukkit; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.bukkit.boss.BossBar; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +public final class PregenBar implements Listener { + private BossBar bar; + + @EventHandler(priority = EventPriority.MONITOR) + public void onPregen(IrisPregenerationEvent event) { + IrisPregenProgress progress = event.getProgress(); + + switch (event.getPhase()) { + case STARTED -> open(progress); + case TICK, PAUSED, RESUMED, SAVING -> update(progress); + case COMPLETED, CANCELLED -> close(); + default -> { + } + } + } + + private void open(IrisPregenProgress progress) { + close(); + bar = Bukkit.createBossBar( + "Pregenerating " + progress.worldName(), BarColor.BLUE, BarStyle.SEGMENTED_10); + + for (Player player : Bukkit.getOnlinePlayers()) { + bar.addPlayer(player); + } + + update(progress); + } + + private void update(IrisPregenProgress progress) { + if (bar == null) { + return; + } + + bar.setProgress(progress.percent() / 100.0D); + bar.setColor(progress.paused() ? BarColor.YELLOW : BarColor.BLUE); + bar.setTitle(progress.worldName() + + " " + progress.generatedChunks() + "/" + progress.totalChunks() + + " at " + Math.round(progress.chunksPerSecond()) + "/s"); + } + + private void close() { + if (bar == null) { + return; + } + + bar.removeAll(); + bar = null; + } +} +``` + +`bar` needs no synchronisation: every phase is delivered on the same thread. + +`percent()` is already clamped, so dividing by 100 always yields a legal boss-bar progress value. + +--- + +## The minimum: knowing a world is usable + +If all you want is "run this once, when Iris can answer for this world": + +```java +@EventHandler +public void onEngine(IrisWorldEngineEvent event) { + if (event.getPhase() == IrisWorldPhase.ENGINE_READY) { + prepare(event.getWorld()); + } +} +``` + +No `switch`, no `Optional`, no service lookup. Do not add `ignoreCancelled = true`; the event is not +cancellable. + +--- + +## Failure policy + +| Situation | What Iris does | +|---|---| +| Your handler throws | Logged with the stack trace. The remaining handlers still run, and Iris's own lifecycle continues unaffected | +| Iris cannot describe a world for a phase | The failure is logged and the event is **still delivered**, with `getInfo()` empty | +| The event dispatch itself throws | Logged, naming the phase and world. The engine registration or teardown that raised it proceeds | +| The pregeneration sink is not registered | No `IrisPregenerationEvent` is fired at all. This is the state before Iris finishes enabling and after it starts disabling | +| A pregeneration handler throws | Logged, naming the phase. The job is not slowed, paused or stopped | +| Iris shuts down mid-pregeneration | The job's terminal phase is `CANCELLED` | +| Iris shuts down with worlds registered | Every announced world receives `ENGINE_CLOSING` before the worker pool drains | + +Iris does not quarantine a listener. A handler that throws on every event will be logged on every +event, forever. There is no fault limit and no automatic unregistration. + +Iris never suppresses a lifecycle phase because a third party misbehaved. A logged failure is always +accompanied by delivery, or by the lifecycle step proceeding without delivery — never by a silent +stall. + +--- + +## Configuration + +There are no configuration keys for either event. They are always on when Iris is enabled, cannot be +disabled, and have no per-world gate. + +--- + +## Enum reference + +### `IrisWorldPhase` + +| Constant | Meaning | Fires | +|---|---|---| +| `ENGINE_READY` | The engine is registered and answering queries | Once per world registration | +| `ENGINE_HOTLOADED` | A live engine's pack data was reloaded in place | Any number of times, or never. It is dispatched straight from the hotload, not through the ready/closing bookkeeping, so it is not paired with either | +| `ENGINE_CLOSING` | The engine is about to be torn down | Once per world registration, always after a `READY` | + +### `IrisPregenPhase` + +| Constant | Meaning | Fires | +|---|---|---| +| `STARTED` | A job began | Once per job, immediately before its first `TICK` | +| `TICK` | Periodic progress sample | Once per second while the job exists, including while paused | +| `PAUSED` | The job was paused | On the transition only, followed by a `TICK` | +| `RESUMED` | The job was resumed | On the transition only, followed by a `TICK` | +| `SAVING` | The job is flushing to disk | At most once per job | +| `COMPLETED` | The job reached its chunk total | Terminal; mutually exclusive with `CANCELLED` | +| `CANCELLED` | The job stopped before its total | Terminal; mutually exclusive with `COMPLETED` | + +Write a `default` arm when switching over either; see +[README.md](README.md#switching-over-the-enums). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6db7990d..a2ee01bf7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,7 @@ jaxen = "2.0.6" # https://central.sonatype.com/artifact/jaxen/jaxen # Third Party Integrations nexo = "1.25.0" # https://repo.nexomc.com/#/releases/com/nexomc/nexo itemsadder = "4.0.10" # https://github.com/LoneDev6/API-ItemsAdder -placeholderApi = "2.12.3" # https://repo.extendedclip.com/#/releases/me/clip/placeholderapi +placeholderApi = "2.11.6" # https://repo.extendedclip.com/#/releases/me/clip/placeholderapi score = "5.25.3.9" # https://github.com/Ssomar-Developement/SCore mmoitems = "6.10.1-SNAPSHOT" # https://nexus.phoenixdevt.fr/repository/maven-public/net/Indyuce/MMOItems-API/maven-metadata.xml mythiclib = "1.7.1-SNAPSHOT" # https://nexus.phoenixdevt.fr/repository/maven-public/io/lumine/MythicLib-dist/maven-metadata.xml