This commit is contained in:
Brian Neumann-Fopiano
2026-07-26 12:12:49 -05:00
parent de25b7d7ab
commit d5a55ccfcf
66 changed files with 6300 additions and 424 deletions
+2 -1
View File
@@ -39,7 +39,8 @@ local.properties
credentials.json
service-account*.json
docs/
docs/*
!docs/api/
CROSSPLATFORM_PLAN.md
-298
View File
@@ -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
+61
View File
@@ -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.
+7
View File
@@ -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)
}
@@ -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<Class<? extends IrisService>, 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();
}
}
@@ -0,0 +1,11 @@
package art.arcane.iris.api.pregen;
public enum IrisPregenPhase {
STARTED,
TICK,
PAUSED,
RESUMED,
SAVING,
COMPLETED,
CANCELLED
}
@@ -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);
}
}
@@ -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;
}
}
@@ -0,0 +1,7 @@
package art.arcane.iris.api.terrain;
public enum IrisColumnField {
SURFACE_HEIGHT,
SURFACE_KIND,
BIOME_KEY
}
@@ -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<IrisColumnField> 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<IrisColumnField> 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<IrisColumnField> fields() {
return EnumSet.copyOf(fields);
}
}
@@ -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);
}
@@ -0,0 +1,9 @@
package art.arcane.iris.api.terrain;
public enum IrisSurfaceKind {
UNKNOWN,
LAND,
SHORE,
OCEAN,
VOID
}
@@ -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<IrisWorldInfo> worldInfo(World world);
OptionalInt surfaceHeight(World world, int blockX, int blockZ);
IrisSurfaceKind surfaceKind(World world, int blockX, int blockZ);
Optional<String> surfaceBiomeKey(World world, int blockX, int blockZ);
Optional<String> surfaceBiomeName(World world, int blockX, int blockZ);
Optional<String> biomeKey(World world, int blockX, int blockY, int blockZ);
Optional<String> regionKey(World world, int blockX, int blockZ);
Optional<String> regionName(World world, int blockX, int blockZ);
int maxSampleColumns();
int maxSampleChunks();
boolean sampleColumns(World world, IrisColumnQuery query, IrisColumnSink sink);
}
@@ -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;
}
}
@@ -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<IrisWorldInfo> getInfo() {
return Optional.ofNullable(info);
}
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
}
@@ -0,0 +1,7 @@
package art.arcane.iris.api.world;
public enum IrisWorldPhase {
ENGINE_READY,
ENGINE_HOTLOADED,
ENGINE_CLOSING
}
@@ -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();
}
}
@@ -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));
}
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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";
}
}
@@ -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<IrisTerrainService> terrain;
private final LongSupplier clock;
private final PlayerSnapshotStore<IrisPapiPosition> positions = new PlayerSnapshotStore<>();
private final PlayerSnapshotStore<IrisPapiWorldView> views = new PlayerSnapshotStore<>();
private final PlaceholderSnapshot<IrisPapiPregenView> pregen = new PlaceholderSnapshot<>();
public IrisPapiState(Supplier<IrisTerrainService> terrain) {
this(terrain, System::currentTimeMillis);
}
public IrisPapiState(Supplier<IrisTerrainService> 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));
}
}
@@ -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<String> biome,
Optional<String> biomeKey,
Optional<String> region,
Optional<String> regionKey,
Optional<String> dimension) {
return new IrisPapiWorldView(
position,
builtAtMs,
PlaceholderValues.TRUE,
text(biome),
text(biomeKey),
text(region),
text(regionKey),
text(dimension));
}
static String text(Optional<String> value) {
return value == null || value.isEmpty() ? PlaceholderValues.UNAVAILABLE : PlaceholderValues.text(value.get());
}
}
@@ -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
@@ -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)));
}
}
@@ -61,6 +61,7 @@ public final class IrisEngineSVC implements IrisService {
new AtomicReference<>(IrisTelemetrySnapshot.EMPTY);
private final Map<World, CompletableFuture<Void>> pendingRegistrations = new HashMap<>();
private final Map<World, Registered> 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<Registered> registeredWorlds;
List<ClosingGenerator> reservedCloses = new ArrayList<>();
List<Teardown> teardowns = new ArrayList<>();
List<CompletableFuture<Void>> generatorCloses;
synchronized (registrationLock) {
registeredWorlds = List.copyOf(worlds.values());
for (Map.Entry<World, Registered> 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<Void> 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<Void> completion) {
}
private record Teardown(World world, Registered registered, ClosingGenerator closing) {
}
}
@@ -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<IrisWorldInfo> 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<String> 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<String> 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<String> 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<String> 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<String> 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<IrisColumnField> 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<String> key(IrisBiome biome) {
String loadKey = biome == null ? null : biome.getLoadKey();
return loadKey == null || loadKey.isEmpty() ? Optional.empty() : Optional.of(loadKey);
}
private static Optional<String> 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);
}
}
}
@@ -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<UUID> 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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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<String> ALLOWED_PREFIXES = List.of(
"java.",
"javax.",
"org.bukkit.",
API_PACKAGE + "."
);
@Test
public void everyApiTypeIsReachableWithoutIris() throws IOException, URISyntaxException, ClassNotFoundException {
List<Class<?>> apiTypes = apiTypes();
assertTrue("the api package must contain types", apiTypes.size() >= 11);
Set<String> 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<String> 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<Class<?>> 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<String> names = new ArrayList<>();
try (Stream<Path> 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<Class<?>> types = new ArrayList<>(names.size());
for (String name : names) {
types.add(Class.forName(name, false, IrisApiSurfacePurityTest.class.getClassLoader()));
}
return types;
}
}
@@ -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);
}
}
@@ -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<IrisColumnField> 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<IrisColumnField> 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));
}
}
@@ -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);
}
}
@@ -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());
});
}
}
@@ -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<World> 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<IrisWorldInfo> 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<String> surfaceBiomeKey(World world, int blockX, int blockZ) {
return isIrisWorld(world) ? Optional.ofNullable(biomeKey) : Optional.empty();
}
@Override
public Optional<String> 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<String> biomeKey(World world, int blockX, int blockY, int blockZ) {
throw new AssertionError("a placeholder must never resolve a three dimensional biome");
}
@Override
public Optional<String> regionKey(World world, int blockX, int blockZ) {
return isIrisWorld(world) ? Optional.ofNullable(regionKey) : Optional.empty();
}
@Override
public Optional<String> 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");
}
}
@@ -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<String> 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<String> 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"));
}
}
@@ -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"));
}
}
@@ -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<Class<?>> 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<Class<? extends Event>> hierarchy =
List.of(PlayerMoveEvent.class, PlayerTeleportEvent.class, PlayerPortalEvent.class);
Set<Class<?>> handled = handledEventTypes();
for (int index = 1; index < hierarchy.size(); index++) {
Class<? extends Event> child = hierarchy.get(index);
Class<? extends Event> 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<? extends Event> type) throws Exception {
return (HandlerList) type.getDeclaredMethod("getHandlerList").invoke(null);
}
private static Set<Class<?>> handledEventTypes() {
Set<Class<?>> 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));
}
}
@@ -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<Function<UUID, String>> 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<UUID, String> 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));
}
}
@@ -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<Location> 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();
}
}
}
@@ -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);
}
}
}
@@ -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<PregenApiPhase> 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);
}
}
@@ -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));
}
}
@@ -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<String> 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<String> fired = new ArrayList<>();
IrisWorldPhaseLedger ledger = ledger(fired);
World world = world("alpha");
ledger.closing(world);
assertEquals(List.of(), fired);
}
@Test
public void everyReadyWorldIsAnnouncedClosingExactlyOnce() {
List<String> 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<String> 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<String> 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<String> fired = new ArrayList<>();
IrisWorldPhaseLedger ledger = ledger(fired);
ledger.ready(null);
ledger.closing(null);
assertEquals(List.of(), fired);
}
private static IrisWorldPhaseLedger ledger(List<String> 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());
});
}
}
@@ -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);
}
}
@@ -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<IrisColumnField> ANY = EnumSet.of(IrisColumnField.SURFACE_HEIGHT);
@Test
public void visitsExactlyTheAdvertisedColumnCount() {
IrisColumnQuery query = IrisColumnQuery.rect(-40, -40, 39, 39, 8, ANY);
List<long[]> 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<Long> 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<Long> 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<long[]> 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]);
}
}
@@ -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)));
}
}
@@ -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));
}
}
}
}
@@ -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));
}
}
+5 -5
View File
@@ -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
@@ -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<Position2, Color> 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<Double> i : onProgress) {
i.accept(percent);
}
dispatchApiPhases(apiPhases.onTick(paused()));
}
private void dispatchApiPhases(List<PregenApiPhase> 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
@@ -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 <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pregenerator;
public enum PregenApiPhase {
STARTED,
TICK,
PAUSED,
RESUMED,
SAVING,
COMPLETED,
CANCELLED
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pregenerator;
import art.arcane.iris.core.gui.PregeneratorJob;
public interface PregenApiSink {
void pregen(PregenApiPhase phase, PregeneratorJob.PregenProgress progress);
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pregenerator;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
public final class PregenPhaseTracker {
private final AtomicReference<State> state = new AtomicReference<>(new State(false, false, false, false));
public List<PregenApiPhase> onTick(boolean pausedNow) {
while (true) {
State current = state.get();
if (current.finished()) {
return List.of();
}
State next = new State(true, pausedNow, false, false);
List<PregenApiPhase> 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<PregenApiPhase> 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<PregenApiPhase> 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) {
}
}
@@ -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));
}
}
+196
View File
@@ -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:<tag-or-branch-SNAPSHOT>') {
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<IrisTerrainService> 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 -> "";
};
```
+216
View File
@@ -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 `%`.
+584
View File
@@ -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<IrisTerrainService> 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<IrisWorldInfo> worldInfo(World world);
OptionalInt surfaceHeight(World world, int blockX, int blockZ);
IrisSurfaceKind surfaceKind(World world, int blockX, int blockZ);
Optional<String> surfaceBiomeKey(World world, int blockX, int blockZ);
Optional<String> surfaceBiomeName(World world, int blockX, int blockZ);
Optional<String> biomeKey(World world, int blockX, int blockY, int blockZ);
Optional<String> regionKey(World world, int blockX, int blockZ);
Optional<String> 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<IrisColumnField> fields) {
public static IrisColumnQuery rect(
int minBlockX,
int minBlockZ,
int maxBlockX,
int maxBlockZ,
int strideBlocks,
EnumSet<IrisColumnField> fields);
public long columnCount();
public long chunkCount();
public EnumSet<IrisColumnField> 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<IrisWorldInfo> 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<IrisTerrainService> 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.
+517
View File
@@ -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<IrisTreeFellerService> 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).
+459
View File
@@ -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<IrisWorldInfo> 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<UUID, String> 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<IrisWorldInfo> 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).
+1 -1
View File
@@ -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