mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
API
This commit is contained in:
+2
-1
@@ -39,7 +39,8 @@ local.properties
|
||||
credentials.json
|
||||
service-account*.json
|
||||
|
||||
docs/
|
||||
docs/*
|
||||
!docs/api/
|
||||
|
||||
CROSSPLATFORM_PLAN.md
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
@@ -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
|
||||
}
|
||||
+33
-91
@@ -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;
|
||||
}
|
||||
}
|
||||
+51
@@ -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());
|
||||
}
|
||||
}
|
||||
+3
@@ -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
|
||||
|
||||
+106
@@ -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)));
|
||||
}
|
||||
}
|
||||
+22
-9
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+316
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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();
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+71
@@ -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));
|
||||
}
|
||||
}
|
||||
+154
@@ -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;
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
+77
@@ -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));
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+64
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
+117
@@ -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");
|
||||
}
|
||||
}
|
||||
+249
@@ -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"));
|
||||
}
|
||||
}
|
||||
+132
@@ -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"));
|
||||
}
|
||||
}
|
||||
+316
@@ -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));
|
||||
}
|
||||
}
|
||||
+394
@@ -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));
|
||||
}
|
||||
}
|
||||
+104
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -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);
|
||||
}
|
||||
}
|
||||
+100
@@ -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));
|
||||
}
|
||||
}
|
||||
+119
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+87
@@ -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]);
|
||||
}
|
||||
}
|
||||
+58
@@ -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)));
|
||||
}
|
||||
}
|
||||
+61
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 -> "";
|
||||
};
|
||||
```
|
||||
@@ -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 `%`.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user