diff --git a/.gitignore b/.gitignore index d18318624..0fd13b24a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ credentials.json service-account*.json docs/ + +CROSSPLATFORM_PLAN.md diff --git a/Iris/golden/overworld-s1337-c0x0-r8.hashes b/Iris/golden/overworld-s1337-c0x0-r8.hashes new file mode 100644 index 000000000..bb3220c77 --- /dev/null +++ b/Iris/golden/overworld-s1337-c0x0-r8.hashes @@ -0,0 +1,298 @@ +#iris-goldenhash v1 +#world=goldentest +#dim=overworld +#seed=1337 +#mc=26.1.2.build.2591-stable +#minY=-256 maxY=512 +#center=0,0 +#radius=8 +-8 0 e8c6f44099c05ed375fc14866389bc8c7c14b51b69434c9c516caf53d031c415 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 1 c555764a99e9bb25c1ad95890f24b38447d4104637eb8d1c84629b929dbef7ff 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 2 0d7bfb60c8913b6ee0225faa06c5759c2c1c05646ed2bac3fc924b3e53c0a125 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 3 f682f9939349330bfe2160d9b8d9ce0d43b3c710860107abce8d98a38ee85b7e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 4 e96d9e6b17b59f251023cb70f78a2e4a155d613ddd9208c0bb66c01589f3649c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 5 f3dc77a9e64d2d9639d7f23222bc4c61272e8dab1c93e97420d9667640e6d212 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 6 ad62cd0be871a2bb2d547e64df2ea40b40e38e729b19cbb21d7343d8146dfc51 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 7 4f01547457c8cb8da9b4de50780c8ceffbe8da3781cb171c682e367a46a4006a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 8 0fc9c9d510b070d6b8bc7bd867134f1367a3f5ad0514ffd8925ce089de734cb3 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 -8 01347120983abadd10251a68a3ea131414986aadc5861b9ed2684930998b187f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 -7 65d0f851d4c2467f0f541967b692cd44d95ddb59cd2250af08bfa788af8a3803 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 -6 d1bc22d2912b8555226c19f38f5f8e574331be5901215122a5ffbeb73b419765 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 -5 ba6f58e5f76cbee8058c839ff7e42f4e7c4a07158cf97f959e4f676d4a477b72 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 -4 3e7d2602fff6f0ace932134462745586d7baaed6fbdc5d4285f71219c19d281d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 -3 88c0654c7380d9b616079d7991b41677e7f4c46acc6dcad93024e2c2b0f72288 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 -2 94d591ae106d14204ed910cc1de28de4040f853d6a9bd47fc7fdebec0324f6a9 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-8 -1 26218d2073cbe0cc87e91e789f973444510a49c0e3c3c5f94f32febb1496288a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 0 4549aad82cdfa6356d7de621924f62ca950640e9b8f5e771964fb7a66d648009 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 1 89e7a6a250b09565b2d00050fac709bc6612e16c4a7e0d189595f460ef28f1eb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 2 2da2f65fa337c51d140f339af28faa2987dc09f793b7dd3dcbdb2f801cec76d7 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 3 5d5ec222384ce07398e352f405f23dd3454277c8d5251c32d870659a8c52f6fc 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 4 1839f53ff781c3d133d741d297527a6c864f185be77c6a79ec294de34bb86e45 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 5 9f6f17c5c3e12f1d94e6a7b8a81ebe082e55b548213c605c904554baa864c83d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 6 85482bebafc31f647b56d400e41df1c78555446df9d24c7c9baeaa0760f0ea1e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 7 bb9382d28c0841a6aa6ebfc35ab8df808bea54cad20c858fb58a2652a8ca3e9e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 8 68bba9b6d00765c098cb3d29907977198303c69a39410e5d46ca109d791d22de 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 -8 2c622b6c55a20271a48874ae9e489ce3c938c634eb4cb2c317b66a21ecf36c76 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 -7 1f29f9d700900e966d96f6425c28e6ac5c30f77d6d5bc8efca22937678f6fbfa 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 -6 3b9e3e48cc43167ac8e659409c62ae9bd7cdddca74cf6efc23dfead2913dff2b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 -5 1ed142541d6bb7b11909a69257678277546b72cfdf8a64f44a471f827bbb671a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 -4 d7f808e9d725823ba2b68e54b1985f458ce180fc1c5dda3dac4add38d831a1ce 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 -3 0971e32206dcb07b64ef050c23a06a354f3d776095e10c7e65ca44db999b868e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 -2 25e93be610632dbb353b4673aaf02b17a8153332f5557617ddafbce5332b8bb2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-7 -1 92bb2c7632a54d837db26d79d114fa6626b16289ab9879eca89aa3030633813f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 0 7ecbcbb6be3e4e6d8449f90468d9969204b18fb9746ecad6319741f9f12c6fbc 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 1 3898687b4624ebeeb6e61fc40f39d6b5842f25495bfe63dcf9a4733596bc3c82 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 2 0e3017098a07c8b596f4d3b221b9ebd0d564bb0e2776391031a716c2bf1d01b7 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 3 126fba2b943704a1791648a76e100f5ea263920837e334ecb7d9188c3d76412d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 4 68d6139155e498e561efb77c9f57568684429ada7918c47f44f6a85041478220 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 5 b50c5089e48a84d456120884e90e6a17d8bb02478743a97a0beb1b4eb4fb9890 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 6 0317ed303e7ae37b236bfa0b86d6c00bc2c6b6423f2d1b0228a0f0e4b0f5b6d2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 7 330599a6b02758c52f13792e83173fb8beca89e94f26f7c12d82415d8a0c49e1 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 8 e74f8d776ca319f992298dfb1d0545f9e3796d8c74ff59d0a57e8155eb5d955b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 -8 f65ef781c13b78f1ec46d98b1396c8c716b3752da6315cc805359354917480ab 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 -7 efdef3ed4747552d65982c605365dc6e576ab9030643efe8b1bb2456be5a09a8 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 -6 06f8d72fc8281b0e0fbbbf8f8b91575a1db4af551dc87f459877e3eaeebf363c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 -5 54211524408e92d4518e7409ebaa45647ce7a6645a34de338167d2ff213839f0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 -4 5540bded756e63b331df11f89f57566ba86b711a4b88fb5480e5dda9f2540586 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 -3 601b734e87ee9a74173b780d566f85322c45bdaf03125bbb070e136c9dfe5007 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 -2 1ca8f873f3369f53537cb7b5506f19e1c7ea4fd06c817616508b0c96103b26c4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-6 -1 ea159282ee7c77e10e8506feccc6f8d6798ac414737521565b0f604911939c1f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 0 c1c95112697de045a0add257e6e793f03f1592d451e774eac0757ac7155b48ef 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 1 a5ea2ca87fa5e0dceb6df886d926cd4559c667a83abd86415f1c27dc9ed81ee5 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 2 dc7f16b61d9edc02d5249b8d2ee6354272b03db78af103b149d393517b84bda8 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 3 cee16aa555acbdf22437047b4256f6c768a9a030118f5638a642917657c1ef55 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 4 67681fafbaea4803cc440ec1e8f65d743c3b8a46697be8976d413d32f7a3c72f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 5 fba2f12c44e498b0507d319a43c0f2c967ce63bb77793027deb6d1ca3e6e234a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 6 b72a83a562f7d9e2f684f89de7f2893f8c213e74aadb58cd186ba61c5dc7e45d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 7 7089ebbd8ee10a7d7fc335055a350072414b0ffde6c0b3033df6d1adff1d2169 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 8 185ff9a6c30cfb4b0536b484bced135792f00509751cd006df2c714f53fec7b2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 -8 d1d70baf419895f95f0b4eeb26a7b7510015153c72d47a9a48105ac170688ce6 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 -7 e328bdfec9d335c3dc8079a54b28c776752870004058db0844b2b4ebfc025071 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 -6 70d531e3e5930e2fe203a446f97720f94ac7ce8a7aa5b1f9be733d081b503593 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 -5 bd2b0e65f1c27f14e168f12add29691b2375583e9967f6b85b7f0a4bf5dad848 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 -4 327d84fbaf300d46540d858bf08df637404d80d4de3be3dd0baac2b4939df441 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 -3 c50372e6900776f53862e713ba873fcaec8102d77120460599a75f5aec4ef4ea 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 -2 81ce5424ec2ad445bd0158cbe57137d5a566726a705fe35c7538088bb986526a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-5 -1 ad141fd05004823307416d1d3b4261d39387f2dc6e52b38475e1d9fd526dfe81 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 0 86e9e5ce6e00100fbfb8ea5112a0d86a450fdc297c9e710dc5a26f727c865ecd 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 1 27dc14d872694dc1502d2f9b84a5d1a5dafca624a3972ddad3f25941feaaaa73 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 2 bed812029e6fed53cb5abb9ee76f970c322a9028c6421c6334ade091f2d467f0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 3 b8a6b54438b8980d0ecbe3c2d7d0aebeb2bca228c530651c76213b2f6be90af4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 4 dda62b17f47229a7e1d88f29aaea0c06a98456b3d11a6fcab848f61be6011bc9 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 5 b4b40ef42ec4dc99fbc16149387750bf43a4c0111909693de2ea14b98eb631a0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 6 e946df48cd22fdef70b500519b5f83a7369a014e6e92521df3ef94305ba12541 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 7 9c19d9ed486cf5fd9a8786a0ae4bea850dec20e987b3f34683e09d829b6bff14 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 8 e1851ad3d1e45e2447e581670864f5b31b804727d36ee6ceed956701379b5931 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 -8 5f604232c902d63a68d07da0d381d6c211a0fc5cc5132b57c63eee182b9bb570 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 -7 039905dec3c6e4f37ee946e4b593411f8ccc0b359d2fb0a26ee111b6c79cf242 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 -6 bbbc3fa281dabfc4301c21237b1a3b7fae9bec0ce10545fd566e0e00dcc80043 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 -5 ba1603746f8a4016fe9d1e6f9e58fd49b57c08445e49434624dd3f005b120f18 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 -4 4bcf6ab1960c59eac15286e5adf8efb5140626bd22225bb78bddfe43adb7c1e0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 -3 1ca1fae7c121e55be9ca1e369fe58f09a61f13b981f189598eb46aac03ebadc5 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 -2 a3154b14c6c2472b9e3596bcee51372ddaaf2b2b4225090c77797612b73571cd 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-4 -1 ede17ad65f03e78875b5a96aa33004e0900c0304d56c228cea550f414203c469 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 0 bd87aa58f3fb8d3b974fc46449c776e610f2aff7667419089563dac7c938ca0f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 1 4dc30c498d41c84f8f6deed9accb857c29d759b533bce9b48768092690ac0424 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 2 d46285266abb3d5ca853d70c5549c4fd0fc8f32905feb66a55aec573d045afab 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 3 01d5a13a92f8221047bf8755efbb4a42a6a68582f0bae4e3b081b7c306632bbd 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 4 597746d886a37cdc7d875c6a0df97c3e7d8980d0ea33915fe307828f23686f1a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 5 3510c1c85c72591b00b7f08efa70ec438142f9fd22a89daa1f8981ed2dc2b10e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 6 9d531df8effbb51c64f08e99f1d5e2841eb4ef6c49d1397381a20a6576e16850 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 7 755e79e527c74cdce0d0b1b4dbea74d639ce939c306129e0ebea84cf7d72bd13 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 8 0d1b0b8b1729aa56df6c1bdcc08c9057c4fd2741b9a3d369242774f53944e02f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 -8 4367ce7e43ce3dce053a5aa52b7e7b700a7e266239e91a70a90fc06b63d18ece 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 -7 c59e78e77238284e754406e9e5edc9ab95836de31ea02b3f9d892a97b8cd78a3 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 -6 c63548a3b36db9293dd8bca785b008a3e555cfc28ebd29c3f3f2db6b48ad7d90 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 -5 4680afd2a8a2070863108cf1b6bf2d20526870039194856dbc01a247decf4433 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 -4 697e060186a06cae5ceea0c2022ef734e8515834f154c339c4f858933af34bbb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 -3 2819fced8d0234d52889df26a34816f77e5dcdcbd0ad6a84f14eb76805460ffb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 -2 e07b5b1c0c9fa889833d12b7d4e0d77a6e13562115f7df0821d33d944ffb951c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-3 -1 fe3f09f13704393d2b7b1de01ad1e6019c2026732c7f37fb606e98b8caa8fc57 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 0 51f8a1a38a41616de5065389e8c2d8ab2ef140b84cbfdba5450455b5595b9d3d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 1 e581ea05cc318caeda834761bfd658a9a5c6a7538319a8054828ee9e130bf263 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 2 cd300f15b2ac4e29c99f9259f789f70693e187b47fb4327366d0a2f7e1b43767 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 3 9ca913943094b455fd9ddb2baf4a2d5d9804dd9d6306ed8fe9343f08c28f3699 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 4 22090840e16f339a5f2785d6499a431f00c39fce5d54f87b4cd338b03993729a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 5 62278637c9203e9dc118d6b572fa305988d3b8835e3c6f51317418e02807c569 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 6 fc79a0b5b1c3c5807de4a44f5859d4a9b0a71850ff423f2007f2690e15635eab 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 7 a8369fe6fd76191aac527edcf568b15f6f222a96ded1fea44bf855f0ae9b48ae 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 8 55d37c3ee6dffcf24c0b831d0bbd8bf48391002bae36cb179d89d95ae7a21605 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 -8 0a70e75164d5358712a0e87b6bf1a66c9bf42a43332eb2d1de9bd6661f0501e6 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 -7 60e5e216ff3a4147a4a206861051a773924bf7d59910dc690bf78486ccf0a826 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 -6 00fd0a1144b5a098953d57a1f2b43258c7796b5f023f072c15267e993258de85 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 -5 cafd29e010f6af2ebe580b9b0bb710a54fd23592dade31b7e669b35c3f76f3fb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 -4 622462ae5157344ddfd31919d4f829bd7de499d859e8ea9e7a78aece2948185c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 -3 0298439e5b148523b1a4a39ef0ceb4d46cf05afa0409b0531b44e096ef3cc371 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 -2 ba01546214f5c228ac0d49fd29af7e537e690e6c23ada25ff5ebb8b7cf8900a1 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-2 -1 9ea6b0d37b3b9e81fe0d0384de53793b23075433b7c77bb7dd3b10dcea2ba0c1 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 0 5b945f1828d88ac141f97bbe29587a2841d85a631c19e9575ee33f92d3340813 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 1 df696e4e814a2ede0ef1377cf1a1ab783577d4f328397ddebd8ad87cee12e535 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 2 dc6b7b03f4731400d9e04a20e0a3f6175ea7b5cf48a1dc9ec17d93b3b55d79f0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 3 d9929169dba8ea107712fe27d739d662155b37ee61a54363f029365a89b110ae 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 4 8c6a4a674b635ba2cfd81b5eeb001c9feb27be24c4c63519f1b3bd5e5eedb27a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 5 bf7eda22dfb6b3b4200ffbdb769cd431556a60a6f475482775d2df4a36e6f348 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 6 0fb399a09635cb5b63def5091260c412e2471259f99b4cfa62fd9c636ecbf096 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 7 0b607c01a69c2265f51a6f8064fe74f1a79d92b8cfc4d1079f8d853de407b307 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 8 9cc17e392bb780aa2dbd777f2e639e23acaef1f27c5c60ad2d3d992b78d31d48 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 -8 802a05904797cf3791f398056dbdcf9fa04cf8f30412f330e74a4c4344075558 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 -7 85246d2e6d27b72a997a36b467b6938e0f8d0e9d1ba865bcc02f3391afb07e26 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 -6 3b7485773bdcc52ad4dc09e2aa66aff5d2d339d481c7e8f4041f175486cf112f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 -5 3de97a621cd4c0871e0bcad0bf260151ec13436496912e48d994b938a0b0e4d7 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 -4 6084ca4bd6718289312e4a510635608bdb9a53111f8f855f3f62436eb0eb5c19 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 -3 7d2c092c140c2105a6e1be99c2535a97c240bcdac31a31ae724c0fd1af3d4415 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 -2 040127151932013ebe1894f4204923cd18ad5f80b6b756d32e915d1261476e05 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +-1 -1 d534d548ba184029ad57ed1619cb66af64c8559d2e1916dbdc35352944a92002 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 0 9d634e5914634328e642e3ff4ee25b1d855f485f93af99e8ab3abfe396ad0711 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 1 642706de1286e15f8d63bd5b71c24dbe00906a4f0375e4c74fc65365aae72991 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 2 88a50a0c23489c5b7bc49181b2839ff5d9c0af0699101c778b2c6fcb4a0a8922 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 3 a6e306902412137b51c1f9e49b22874765a9fc474535e2b12fa6e98fcbfeb5df 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 4 b3200e2fcb52fd1e925d043eea822b06bf9628fadd1f58ad58c2656d33d1ae85 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 5 2b8861ca99726fa6cb1b85ccca44426c79b36fe61b9384e4bed568c5104feef2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 6 952eb848f973913ab7c037c4dead78872690720732d751fef6f4777315f6c44d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 7 b93001115c62d7ab2733a86b482b340fa5eb789ddb99039e7e299e14f1c106e4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 8 dad0064ff5ad856d2140e923b771ea92d0bdcf157e3bb813b79565ff9f473b36 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 -8 e5b7f9b4885bbb15d3da42b3343610521e1b03a73bbf208b66b53833b180166b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 -7 b6a2ff710e665cff92200cd77b21e575a3d7db02986aca3054a86d5f10b7f05c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 -6 3ba1c1c28f3b875a40eacc39a74ef819bb52df10b3313c535edf5b973551e8f6 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 -5 905d0c485c49a9f01aec61d859b5548c4ec72eade81ca85c3864294d3677b151 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 -4 33dc036e9420b269dcb5247f2df35e55a42310c6d8a6784ed121d56c7487ee00 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 -3 2c8d81467deb96167f74cd6e1fc51ce97adeaf56596f0bf948f37edb6707bf0b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 -2 64d93e292f86d8701d0ec2e7d099196b94d5fd179ceed471c28ae59e55ee28e2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +0 -1 708b7e4b5c912b10ed5147d2d87bd77b913a37fbde4155aac7ce6260b5471bcf 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 0 8f2cba2e6652796cbab43457666667d8ec5beef356095467fb75ba53e7de38ce 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 1 0b69855d8785d992be6743aba315c2b1014a58dab0b364af3394823652ca62ed 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 2 0db7977d44cbcbee49be1a963b42b60756f21077f04fabf78f3a71c617fc8db7 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 3 c0c5bb35ddcbf2b25c360f100e949629c2533a326feae8d114fe465740eb08db 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 4 54822a01ccf10a646ed9459b82a12d177813bf4a23c55ea4f7aa92bf1afb9974 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 5 f01e402910c0d3760368d2a6486a2e6ecc9f63ca8e58765074fc920d25b0faed 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 6 2cbb7b541d8e8012c64fdc872270fc1400cc510182f4333d6a0b5dd21215d08e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 7 b711c83e157c349c2d3cfac9b6add13e097d507284f6fa62f18c24ae78afa814 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 8 76e802d6e2425307ca4644014b7b03e53fab0f4adc03dfa4baa7623d7d8b7413 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 -8 56b4596f35a47200714e74589f9b89a8eb5a01df53f2e5035a294f4c80d88641 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 -7 ae559e04a1367fe775479c35168382ee74bd450774c6bb785335d2ea468a741e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 -6 e3e4f5ad931e99baecf24895bc7c3d7f8cfae5c3dc485c21ac47a3f5030e3617 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 -5 047a5d16348cbac2e1add22d67268d5f1e43b7d03de0fe85edb6abea470c6cc9 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 -4 53021a6c0ca99c136c1dc7c7365c6a3b7b84dcae506fbf1ea54eb1cf6456dcc4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 -3 83d897f90ec0fc2357c93bf07d43952df23c9d1d0d6d5b22ff73cac0ab338b7b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 -2 afbb2abecfa34904954f2e16ffb56b49dd292170f90dd9552a2f101d101c01ef 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +1 -1 a22df312d77de965860d2bd1ff489d1c38b343c2beb11707949b444725c25a4c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 0 9abdce5ab4c5df4479bf095f5d200174650f4beb3504a99c26f988b3b7675898 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 1 a2cd93c410be4911fe0304da5d2993058ad5957f670df5e5d781e3a7fde37fbb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 2 19606f317ca9aae79e2d7f66c9ab14d9138a7b7d9bcc5d61a2040297fb1580a3 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 3 ae9e0cef6124a2fb1283cb349d698030f90b6f1509b46a88b79d4ad81c5075c3 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 4 ceb1afc0561b22131484f37f704c66fb0be6e62262ddc778de791b3780b5b535 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 5 703003021b68774b61e040bb552f4883793828cdbe75dbcad424afe2220e18ab 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 6 b7e2a7f81ffce5851a996898cacc38b3597426dd473ea6310dca7ffe126f99e3 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 7 e13be39d90d9bd7e395e0763bebdf29e8c08d676ec78d5a16b7511710d302909 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 8 907fc6c166e0a65d37f5276072ccaa3fcab9064660836691e3f459c8a0cf42e6 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 -8 86c8905772609245555d5860066b167773b4cb919a46a7ea7ec2fb8075399753 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 -7 c132100840d3ac286482223957a18729768cff3f4cd0721c794771e4c50ec315 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 -6 9a6becb1d8aa3c329623f53f1a1944e1e2a0f0988acb1d2cdb6c231afa211e90 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 -5 cb8c69a2143e96e0c49c3131f209fee789513246f1afaf95cb01e3088ab299f4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 -4 d256f0a962427bc0624eaf826516d981596fc252ad0d10d7204b1fc06477b07a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 -3 11ddf89eb5ed8e7d5c6c0c98c209f1237260fdaa5f5ba1e9608f412a9b1d1a08 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 -2 9bcc19afed624473a53a7d6492714d7fd36203c731c37fb2a0c483afcac0dd8f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +2 -1 44daf4251ccad570516d5c2490cfa8095da33dbd0f66465db700791db9642919 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 0 31a12ef94005ed541ecc4e83a2fa493f0d4d28a3ba08e9a5d9bfafb30aefe28d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 1 a5cb0ee02b85b4235cc64e5ae388ed0c0749712efe1ee79479b72555f05e4f30 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 2 9047f6e870831d21b794349be2c312a0df67022627e6288dcee60aa4159b5d22 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 3 f8888a1491bf3e2df54d40c3814ef2ea912b88ad40cbecdeb44f1fa5663a5f71 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 4 287401b60ebd6b4e4f7a73dbcd35c919b182fea0e62c41ddb1c1288537b62ca9 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 5 dd885bb7003dd62933dab34145561b99f8035f8041c05ee6ef8346cd31b89933 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 6 8f8ede04cf803d45a24932177ba707688e09cc90d1b2cbabf6298a6b29d19eeb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 7 d825796e3a32cbbb3604aa65fdc417defb5effdc32e05696344d9a3c016ed474 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 8 260a4e2748902a3a2fb8d7af62929100ba6fe9975142286032f33fcfccf51f6c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 -8 d66d74a3e4b2f247cdefeb554299b45cbabc8d879e22b95d4b27cfc8e5141cbe 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 -7 0bfea12ea04c7720f5648c89a01fd175be32e8a77cc4a7a603ed2876c62161dc 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 -6 626380d70931e53f62e83ae8118840eda96107e5f0965daeb199a2740e1c2e5b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 -5 56b49e323ffc4ada99a906dfce0d1e2c11b31dc3e9052c0c468ed6420d31b858 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 -4 f2350cb0d8dde09f298c613f5ad1d97fc382b0c1d5b12826eddb2f2195bc7831 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 -3 8c57558a9a5644c05c5daa58d17e2be1b9de1a65d6f7deb4855297a97523f482 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 -2 336127103c8f6aae0fd993713bd880199eeefb589442fa5ebc2386f0c022dce0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +3 -1 523a07c0a804ede754f78283488eddef94bfe25b72cacaf534263282dfd46be5 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 0 cf509fb497c5d7bda76448d12a1a4fe8ce0fd38036a271fcd7b0c48449f82ab8 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 1 59ab0838a0833b55c37406f68c447c2b084e88f4e17d59cd6dac4bfa30263b8a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 2 3588d7ae2ee246864fad05fd0f03dad8741ffcc0cb7704c11c1bd431b049b235 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 3 f70ae200940e87bc0df540e7921ccf5600b1c323b9c07368e07587f34c55d1c1 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 4 6e98b393eaad8c62f1a2425559556100ca3dff5a13f82887e88093d9ef7d620a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 5 7d6899d1c1a8de24aae6e47ff09cf5f876e247b66f7925943b6bcc5c2fa18b2f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 6 4fd67412489af76fe1c5955685380e31a34475d401b3c7fcbb7c5d088faa42ad 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 7 774c770ffb3179e5b2c6d1fe97ff6ac8d3645aa725b52c4ccdd46260e8505ff5 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 8 3097971d8936ed6d53a74ffb45f4eb98118bb7d364a34494de72a86f0b3cf2d5 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 -8 20ab184d0549d5312eb3c4c4c93c27d3dfaf3cf5dc599f35c291b928218938be 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 -7 b22075a0dd7dd86db2e98414131576d3f28fec55d7e8ad623fdc92d2e5c77f17 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 -6 d4e34b1219c0d25371cedb943dd3ff72fedf96e2d86d4d62e36c29195efbf1d4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 -5 b13ceb0561b22bb5dc346608a63635892771509538f26a214d5cac8213207b99 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 -4 40c1c9e7104508edae785a3534a98e9e74dd2a4f66c5853138fa441fb64f003e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 -3 e4b49f322b501551da69e06e3ce41572ce6af154dffd1e4b27691c8153cd4fc4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 -2 0fdb0c8ac97ac20a56c15058ae46a6e4c734ff6974dd85068005779f5685ea9e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +4 -1 35edd44c1c2f3001316a226dafdcd90aa67de5ae613ae56cad6d3ee985eac4e4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 0 b08c5b7e8d207372eb4be8e01289449e574f1d9e0f882d1af190529d23f3e001 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 1 282aa33dfda7d77a51448969f29f5414d452ab88e4ee7c531465746db049f394 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 2 2a6739e7840576314fbcf347982829160ca4a5fb910746cc7a7f93c457de360a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 3 ad2a08c773242a0f61c0d90a00c166b3de65466d09f02ce5daba95fa8af6ecfb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 4 8ed02630b5641e2efcce55ef70d0387898996d9fdc4979c41ca894a39776b365 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 5 d66c6279267ce407bbe5c0007a50bb48359d08cdf95cce6659be45963148e402 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 6 50bc4674208dde2e0f0124d95fbdeda90cd9d9aebfb584a0ca24680e9275bf46 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 7 e1080da9f31c8cf90e12335cacd75a3439f693f963e4e7da28643a4aeea62a56 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 8 fe11aca2e168ecdca408d4dcbc45b0083f834fee5de4ddbb35227f475925978a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 -8 8f51ef501c2b4b16b808dc7cf3845ada51ea8edaa6f8681a234b592f27937983 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 -7 69937c9e1b63da726e051f0725db5355e2bcb18376df2f3b3028cf804bb24db6 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 -6 c20e2b424f96d572cace671c9063268b764a08b9927bf4ca785dfbf300d4ec63 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 -5 a14bedb28bcb68f69e39257ada66536a1eebd15af6aaf8dc9c868cc4808a079f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 -4 044bf8274f32bad7bdc4a720611faac7ecf961d201790af3b14646cc6ef65280 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 -3 f9e4bf03c9e9a0bb68e22aabffdcd10da64b91f736d1e09a7b43f4bc94a98077 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 -2 fd6394bbe1b01be2f5fb8847f543e69bbbf6ed272c29650d721eb7a4de32b4e2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +5 -1 620d5b20d3cc817044d8e1087cf18cc42fce70ef04a26ff7b3ea6b824b685122 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 0 9f1d84e4b370154ed36eb76bd8feb1a92d4e0d04aed7e1fe55f30ba19248ae8a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 1 65501bdc9af1fa61b6ac7f3016d7db4529b9bf20f39866a569b6e58c73865297 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 2 c4258153706bd0863490040c8af3e32c6d39ce0e664e99069cf7b10f1a1ebebc 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 3 43f539e2d1729422f8c7724f0bc2a0ad1bb750bbded45ee10dfb6f9cf8de9bfe 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 4 329e0c6370d49ef08a3515a6491389a1dd1501a31ab98f9a7b64e87e2e43ca30 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 5 a980add3cfa56e95901b5d693cd741c97cf35e3fcdbde7272c48a39d3785575e 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 6 3626dc0c6fb1e8f1c05573e6a309d97fa83cff6af9020948f5471fee24f11969 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 7 1ccdf04b99d41c2fdf5f62fa148f6c9a45285650a7fc0a8438d3bd80ec9a5753 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 8 12aaa847543f538ba7a85aa90edecaede9fa313e881b1dc560ee6e0e6640c8ff 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 -8 683ce017cbc497d1b823090a0ec984c0478ddc4e3ec2077096b6569a54d0186b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 -7 94ea7dea313d1ddb52b086acf681185b45de615ca19b07f9b31863ad50d63df5 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 -6 942e705c68ed7f1ef3c625843a06924dcb1299c10421bdaf3914c3cf091a2ddb 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 -5 7a8982f6e99e72e735d32f7685b486447642b80463b9ac09afa4c8d392d1cd8c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 -4 22b2dac5870b720f01d68d38475287a34f0ed21f39dd9717b7dda9f96818ef1c 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 -3 20fa5ff56a7e160284fee9efe2731d505b59feac67ccacaf7c33f8cc92d2bece 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 -2 7427be500743e898067c3879844931a0c1894cb62524717547267ed1e69eb4a0 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +6 -1 98446f1c88d2efea8314f61f35f8fb01f0478c77fc5204095ba83fb53ac8c7ef 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 0 1196a06655cf67cc0d56ac2281315211a13769b9198bed293aa3813d088e9d44 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 1 2b50aedb8471f269114bb7e8be545c0066869bb7812d96c5019006d8bbfbc250 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 2 d71eea940fa42cf164940ccf32137f76bfbf78087e3bf55827b32c0539e3651a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 3 3b10ae5f226f8bfef16e84cafc24c3d06fcf7e9fe894024ce1bffd9548c2200b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 4 c627e0fdede69245d1d864e93f7d1fdb09bc28a8d1573de2d9bfcc2b3ef865a3 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 5 88ba2b1f11c70e7c4e132ef95d20e5fcddaad3aab614814b303a9af7e29e453a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 6 6bbc34b26da81a0820eb03b848f372f32fd9c09c41c5f4c094f4598b9b495a7d 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 7 a7c48618de4cd7bf9cfbfc794514556f028755bc6dcce0a34cfae009d28ad4f1 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 8 32885533d96c8ed9d11a23080df65d08c8360852ee79ea3923767808b06c599b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 -8 3e7c6219d03bcb3d3f5e845735e648a9dc0ddeb4047f3004469e89058e954094 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 -7 7202ef092e139a4f3119e32d827ce1fa0640d02329230a92c7e43f6893e8cfb7 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 -6 f57cac950053f8f2aa978e01473a91655bc32427befbf2f2eaaca5642304abbd 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 -5 e07049ac9a576241138f0c931bce0f737328b9f5cf5e2f076a33d0c4dc96843a 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 -4 48e72991ba4d7f30546a4db4e7d6e0eb3908ac2f45150d57aca666ace4e0ecbe 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 -3 7b2c25f93d898720159a89cdcf6fd64d108d3325a4b4b7136ad4054232f7a416 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 -2 fda7e40529cf6ff9c3447d9fff9906c549f2998baba2c7edba22c0641156c3b7 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +7 -1 88ae6027aca5f412c97a28bbb7c7d31dda5616a3f2600764f4ddf608f27ad1f4 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 0 62b82563ed14f8182e3e4b83ff54cc41657cf55eec2a5112123cd7cf095f9995 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 1 02797a2b0765485eeea460b65f03a7a94937eb3300b5eb9d09fd1e09cee38894 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 2 c52de795f6ca08db1be71a2a4f518c8b6b70bdc53800d708503118ae95893f86 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 3 378b7f06bb7dace3bac1566f3148256a0d8365d2918926dc73186b40c25e1518 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 4 ddef83c202a59cb14b5b75bef83d678c11d1d0a33fa803f662c9f9cb435d3110 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 5 ab3866ec28e7eac2b18b12b539337cf3343816885e18a1498b7f7106288cc670 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 6 45e9ea668e339e9bccdb103eb6a055f10bb8dc49e7177a37b9a19a862f13e861 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 7 1871c550876139b2a533c43a3b6b6afbbdc4c3aa3aa903cca98e3b67f23cc4d2 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 8 5a9a452a44f385b53ac52f5fc6921f2f9e865bb63b1f0958b2314f5526156ef8 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 -8 819d7f6becf5f5acf6aef8b750353ad867289aa8a8a6b3ced77e999c7958a47b 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 -7 57ac4798c2301dc72028f4704fbe897a2e871cc2d88957840a7f9264261cb6f1 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 -6 6da3519f7893f32772681a6e12f06edabb774e9e8d5a9293002608a8cc40e9fe 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 -5 2e2e6e327e0e27d6d6de0ea6618f219fd080f6f9de05b6d6602ebb90bad6a077 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 -4 01989f7b6de2ec03426e2b68190d6fc75e2d57b24225305377338294ca2d14e6 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 -3 e57520d1b8c17e9de905510adda0d94fe4d8309245db26ecfc6dedbdd7702719 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 -2 35ba48632ddfcbe171d457105142dee5f1123f528f1a02c127377b80e83ca58f 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +8 -1 ab693d879e0a8e294d19057eb9540fcf4d2daae5c4bd1b5df649b74dd86697a9 27f1c6a7c3a67d38cd1c3cb8cded37b2d7866b89839b0e9f0624bee0644d5b23 +#combined=28b777380f2a5e467cb387f9c3fb08c8a3e6bdc6bc226c38ee6e14cc7e380da4 diff --git a/adapters/bukkit/nms/v26_2_R1/logs/latest.log b/adapters/bukkit/nms/v26_2_R1/logs/latest.log new file mode 100644 index 000000000..e69de29bb diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java index ca589913b..b117d354c 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java @@ -66,34 +66,33 @@ public class CustomBiomeSource extends BiomeSource { this.cacheDimension = engine.getDimension(); } - private static List> getAllBiomes(Registry customRegistry, Registry registry, Engine engine, Holder fallback) { + private static List> getAllBiomes(Registry customRegistry, Registry registry, Engine engine) { LinkedHashSet> biomes = new LinkedHashSet<>(); - boolean resolutionFailed = false; for (IrisBiome i : engine.getAllBiomes()) { Holder vanillaHolder = NMSBinding.biomeToBiomeBase(registry, i.getVanillaDerivative()); - if (vanillaHolder != null) { - biomes.add(vanillaHolder); - } else if (!i.isCustom()) { - resolutionFailed = true; + if (vanillaHolder == null) { + throw new IllegalStateException("Iris structure biome derivative '" + + i.getVanillaDerivativeKey() + "' is not registered for biome '" + i.getLoadKey() + "'"); } + biomes.add(vanillaHolder); if (i.isCustom()) { for (IrisBiomeCustom j : i.getCustomDerivitives()) { Holder customHolder = resolveCustomBiomeHolder(customRegistry, engine, j.getId()); - if (customHolder != null) { - biomes.add(customHolder); - } else { - resolutionFailed = true; + if (customHolder == null) { + throw new IllegalStateException("Iris custom structure biome '" + + engine.getDimension().getLoadKey() + ":" + j.getId() + "' is not registered"); } + biomes.add(customHolder); } } } - if ((resolutionFailed || biomes.isEmpty()) && fallback != null) { - biomes.add(fallback); + if (biomes.isEmpty()) { + throw new IllegalStateException("Iris pack '" + engine.getName() + + "' has no registered structure biomes"); } - return new ArrayList<>(biomes); } @@ -159,7 +158,7 @@ public class CustomBiomeSource extends BiomeSource { .lookup(Registries.BIOME).orElse(null); Registry worldRegistry = ((CraftWorld) world).getHandle().registryAccess() .lookup(Registries.BIOME).orElse(null); - return Set.copyOf(getAllBiomes(customRegistry, worldRegistry, engine, fallbackBiome)); + return Set.copyOf(getAllBiomes(customRegistry, worldRegistry, engine)); } private KMap> fillCustomBiomes(Registry customRegistry, Engine engine, Holder fallback) { @@ -310,10 +309,16 @@ public class CustomBiomeSource extends BiomeSource { int blockZ = z << 2; IrisBiome irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); if (irisBiome == null) { - return getFallbackBiome(); + throw new IllegalStateException("Iris returned no surface structure biome at block " + + blockX + "," + blockZ); } Holder holder = NMSBinding.biomeToBiomeBase(biomeRegistry, irisBiome.getVanillaDerivative()); - return holder == null ? getFallbackBiome() : holder; + if (holder == null) { + throw new IllegalStateException("Iris structure biome derivative '" + + irisBiome.getVanillaDerivativeKey() + "' is not registered at block " + + blockX + "," + blockZ); + } + return holder; } public Holder getVisibleNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { @@ -362,19 +367,17 @@ public class CustomBiomeSource extends BiomeSource { private Holder resolveStructureBiomeHolder(int x, int y, int z) { BiomeResolution resolution = resolveBiomeResolution(x, y, z); if (resolution == null) { - return getFallbackBiome(); + throw new IllegalStateException("Iris returned no structure biome at quart " + + x + "," + y + "," + z); } Holder holder = NMSBinding.biomeToBiomeBase(biomeRegistry, resolution.irisBiome.getVanillaDerivative()); - if (holder != null) { - return holder; + if (holder == null) { + throw new IllegalStateException("Iris structure biome derivative '" + + resolution.irisBiome.getVanillaDerivativeKey() + "' is not registered at block " + + resolution.blockX + "," + resolution.blockY + "," + resolution.blockZ); } - - if (resolution.irisBiome.isCustom()) { - return resolveCustomHolder(resolution); - } - - return getFallbackBiome(); + return holder; } private Holder resolveVisibleBiomeHolder(int x, int y, int z) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index ccd21deb7..7daddfd30 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -2,12 +2,15 @@ package art.arcane.iris.core.nms.v26_2_R1; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.core.structure.NativeStructureLocateCapability; import art.arcane.iris.engine.object.IrisDimension; -import art.arcane.iris.engine.object.IrisImportedStructureControl; import art.arcane.iris.engine.object.IrisNativeStructureDecision; -import art.arcane.iris.engine.object.IrisVanillaStructureStiltSettings; +import art.arcane.iris.engine.object.IrisStructureStiltSettings; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +import art.arcane.iris.nativegen.NativeStructureGenerationException; +import art.arcane.iris.nativegen.NativeStructureLocateResults; import art.arcane.iris.nativegen.NativeStructurePostProcessor; -import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.iris.util.common.reflect.WrappedField; @@ -81,7 +84,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; public class IrisChunkGenerator extends CustomChunkGenerator { - private static final String NATIVE_MONUMENT_KEY = "minecraft:monument"; private static final WrappedField BIOME_SOURCE; private static final WrappedReturningMethod SET_HEIGHT; private final ChunkGenerator delegate; @@ -109,74 +111,83 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public @Nullable Pair> findNearestMapStructure(ServerLevel level, HolderSet holders, BlockPos pos, int radius, boolean findUnexplored) { - try { - Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); - BlockPos best = null; - Holder bestHolder = null; - long bestDist = Long.MAX_VALUE; - for (Holder holder : holders) { - Object id = registry.getKey(holder.value()); - if (id == null) { - continue; - } - IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( - engine, id.toString(), pos.getX(), pos.getZ(), Math.max(1, radius)); - if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { - continue; - } - if (!result.found()) { - continue; - } - long dx = (long) result.originX() - pos.getX(); - long dz = (long) result.originZ() - pos.getZ(); - long d = dx * dx + dz * dz; - if (d < bestDist) { - bestDist = d; - best = new BlockPos(result.originX(), result.baseY(), result.originZ()); - bestHolder = holder; - } - } - if (best != null) { - return Pair.of(best, bestHolder); - } - } catch (Throwable e) { - IrisLogging.reportError("Iris-placed structure lookup failed near " - + pos.getX() + ", " + pos.getZ() + ".", e); - } - if (!importedControl().active()) { - return null; - } + Pair> irisPlaced = findNearestIrisStructure( + level, holders, pos, Math.max(1, radius), findUnexplored); HolderSet reachable = filterReachableStructures(level, holders); - if (reachable == null || reachable.size() == 0) { - return null; - } - try { - return delegate.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); - } catch (Throwable e) { - IrisLogging.reportError("Vanilla structure locate failed near " - + pos.getX() + ", " + pos.getZ() + ".", e); - return null; - } + Pair> nativeLocated = reachable == null || reachable.size() == 0 + ? null + : delegate.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); + return NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated); } - private HolderSet filterReachableStructures(ServerLevel level, HolderSet holders) { - Set reachable = reachableStructureKeys(level); - IrisImportedStructureControl control = importedControl(); + private Pair> findNearestIrisStructure(ServerLevel level, + HolderSet holders, + BlockPos pos, int radius, + boolean findUnexplored) { + if (findUnexplored) { + return null; + } Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); - List> kept = new ArrayList<>(holders.size()); + BlockPos best = null; + Holder bestHolder = null; + long bestDist = Long.MAX_VALUE; for (Holder holder : holders) { Object id = registry.getKey(holder.value()); if (id == null) { + throw new IllegalStateException("Native structure locate received an unregistered structure holder"); + } + String structureId = id.toString(); + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, + structureId, NativeStructurePostProcessor.isUndergroundStep(holder.value().step())); + if (decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS) { continue; } + IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( + engine, structureId, pos.getX(), pos.getZ(), radius); + if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { + throw new IllegalStateException("Iris structure locate reached its safety limit for " + + structureId + " within " + radius + " chunks"); + } + if (!result.found()) { + continue; + } + long dx = (long) result.originX() - pos.getX(); + long dz = (long) result.originZ() - pos.getZ(); + long d = dx * dx + dz * dz; + if (d < bestDist) { + bestDist = d; + best = new BlockPos(result.originX(), result.baseY(), result.originZ()); + bestHolder = holder; + } + } + return best == null ? null : Pair.of(best, bestHolder); + } + + private HolderSet filterReachableStructures(ServerLevel level, HolderSet holders) { + Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + List candidates = new ArrayList<>(holders.size()); + for (Holder holder : holders) { + Object id = registry.getKey(holder.value()); + if (id == null) { + throw new IllegalStateException("Native structure filtering received an unregistered structure holder"); + } String key = id.toString(); - IrisNativeStructureDecision decision = control.resolve( + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, key, NativeStructurePostProcessor.isUndergroundStep(holder.value().step())); - if (NATIVE_MONUMENT_KEY.equals(key) || !decision.generate() - || IrisStructureLocator.suppressesVanilla(engine, key) || !reachable.contains(key)) { + if (NativeStructureLocateCapability.isPaperUnavailable(key) || !decision.generate()) { continue; } - kept.add(holder); + candidates.add(new NativeLocateCandidate(holder, key)); + } + if (candidates.isEmpty()) { + return HolderSet.direct(List.of()); + } + Set reachable = reachableStructureKeys(level); + List> kept = new ArrayList<>(candidates.size()); + for (NativeLocateCandidate candidate : candidates) { + if (reachable.contains(candidate.key())) { + kept.add(candidate.holder()); + } } if (kept.size() == holders.size()) { return holders; @@ -195,14 +206,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator { if (cached != null && cached.dimension() == dimension) { return cached.keys(); } - Set reachable; - try { - reachable = Set.copyOf(VanillaStructureBiomes.reachableStructureKeys(level, customBiomeSource)); - } catch (Throwable error) { - IrisLogging.reportError("Iris could not resolve native structure biome reachability; " - + "native locate is disabled until the next hotload.", error); - reachable = Set.of(); - } + Set reachable = Set.copyOf( + VanillaStructureBiomes.reachableStructureKeys(level, customBiomeSource)); reachableStructureCache = new ReachableStructureCache(dimension, reachable); return reachable; } @@ -232,9 +237,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void createStructures(RegistryAccess registryAccess, ChunkGeneratorStructureState structureState, StructureManager structureManager, ChunkAccess access, StructureTemplateManager templateManager, ResourceKey levelKey) { - if (!importedControl().active()) { - return; - } Map previousStarts = new HashMap<>(access.getAllStarts()); super.createStructures(registryAccess, structureState, structureManager, access, templateManager, levelKey); adjustGeneratedStructures(registryAccess, access, previousStarts); @@ -242,7 +244,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private void adjustGeneratedStructures(RegistryAccess registryAccess, ChunkAccess access, Map previousStarts) { Registry registry = registryAccess.lookupOrThrow(Registries.STRUCTURE); - IrisImportedStructureControl control = importedControl(); + ChunkPos chunkPos = access.getPos(); for (Map.Entry entry : access.getAllStarts().entrySet()) { Structure structure = entry.getKey(); StructureStart start = entry.getValue(); @@ -251,32 +253,40 @@ public class IrisChunkGenerator extends CustomChunkGenerator { } Identifier id = registry.getKey(structure); String structureId = id == null ? null : id.toString(); - IrisNativeStructureDecision decision = control.resolve( - structureId, NativeStructurePostProcessor.isUndergroundStep(structure.step())); - if (!decision.generate() || IrisStructureLocator.suppressesVanilla(engine, structureId)) { + if (structureId == null) { + throw NativeStructureGenerationException.failure( + "resolution", null, chunkPos.x(), chunkPos.z()); + } + boolean undergroundStep = NativeStructurePostProcessor.isUndergroundStep(structure.step()); + IrisNativeStructureDecision decision; + try { + decision = NativeStructureGenerationPolicy.resolve(engine, + structureId, undergroundStep); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "policy resolution", structureId, chunkPos.x(), chunkPos.z(), error); + } + if (!decision.generate()) { access.setStartForStructure(structure, StructureStart.INVALID_START); continue; } try { - NativeStructurePostProcessor.applyVerticalShift( + NativeStructurePostProcessor.applyVerticalPlacement( start, + structureId, decision.yShift(), + getSeaLevel(), access.getMinY(), - access.getMinY() + access.getHeight()); - } catch (RuntimeException error) { - access.setStartForStructure(structure, StructureStart.INVALID_START); - IrisLogging.reportError("Iris rejected native structure " + structureId + " in chunk " - + access.getPos().x() + "," + access.getPos().z() - + " because its vertical bounds are invalid.", error); - continue; + access.getMinY() + access.getHeight(), + undergroundStep, + (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "vertical adjustment", structureId, chunkPos.x(), chunkPos.z(), error); } } } - private IrisImportedStructureControl importedControl() { - return engine.getDimension().getImportedStructures(); - } - @Override public ChunkGeneratorStructureState createState(HolderLookup holderlookup, RandomState randomstate, long i, SpigotWorldConfig conf) { return delegate.createState(holderlookup, randomstate, i, conf); @@ -347,15 +357,17 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla) { addVanillaDecorations(generatoraccessseed, ichunkaccess, structuremanager); - if (importedControl().active()) { - placeVanillaStructures(generatoraccessseed, ichunkaccess, structuremanager); - } + placeVanillaStructures(generatoraccessseed, ichunkaccess, structuremanager); delegate.applyBiomeDecoration(generatoraccessseed, ichunkaccess, structuremanager, false); } private void placeVanillaStructures(WorldGenLevel world, ChunkAccess chunk, StructureManager structureManager) { if (!structureManager.shouldGenerateStructures()) { - return; + ChunkPos disabledChunk = chunk.getPos(); + throw new IllegalStateException("Iris cannot generate native structures in chunk " + + disabledChunk.x() + "," + disabledChunk.z() + + " because structure generation is disabled outside the pack; enable native structure generation " + + "and deny individual structures through importedStructures.disabled"); } ChunkPos chunkPos = chunk.getPos(); SectionPos sectionPos = SectionPos.of(chunkPos, world.getMinSectionY()); @@ -366,42 +378,60 @@ public class IrisChunkGenerator extends CustomChunkGenerator { long decoSeed = random.setDecorationSeed(world.getSeed(), origin.getX(), origin.getZ()); BoundingBox area = writableArea(chunk); int steps = GenerationStep.Decoration.values().length; - IrisImportedStructureControl control = importedControl(); List placementGroups = new ArrayList<>(); + List nativeStarts = new ArrayList<>(); List vegetationTargets = new ArrayList<>(); for (int step = 0; step < steps; step++) { int index = 0; for (Structure structure : byStep.get(step)) { Object id = registry.getKey(structure); String structureId = id == null ? null : id.toString(); - IrisNativeStructureDecision decision = control.resolve( - structureId, NativeStructurePostProcessor.isUndergroundStep(structure.step())); - if (decision.generate() && !IrisStructureLocator.suppressesVanilla(engine, structureId)) { - try { + if (structureId == null) { + throw NativeStructureGenerationException.failure( + "resolution", null, chunkPos.x(), chunkPos.z()); + } + try { + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, + structureId, NativeStructurePostProcessor.isUndergroundStep(structure.step())); + if (decision.generate()) { List starts = structureManager.startsForStructure(sectionPos, structure); if (!starts.isEmpty()) { List resolvedStarts = List.copyOf(starts); placementGroups.add(new NativePlacementGroup( structureId, decision, index, step, resolvedStarts)); + nativeStarts.addAll(resolvedStarts); + boolean clearEntireFootprint = NativeStructurePostProcessor + .shouldClearEntireVegetationFootprint( + structure.step(), decision.clearVegetation()); for (StructureStart start : resolvedStarts) { vegetationTargets.add(new NativeStructurePostProcessor.VegetationTarget( - start, decision.clearVegetation())); + start, clearEntireFootprint)); } } - } catch (Throwable e) { - IrisLogging.reportError("Iris failed to resolve native structure " + structureId - + " in chunk " + chunkPos.x() + "," + chunkPos.z() + ".", e); } + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "resolution", structureId, chunkPos.x(), chunkPos.z(), error); } index++; } } + try { + NativeStructurePostProcessor.prepareSurfaceStructures( + world, area, nativeStarts, + (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "terrain integration", nativeStructureBatchContext(placementGroups), + chunkPos.x(), chunkPos.z(), error); + } try { NativeStructurePostProcessor.clearIntersectingVegetation( world, chunk, area, vegetationTargets); - } catch (Throwable e) { - IrisLogging.reportError("Iris failed to clear vegetation from native structures in chunk " - + chunkPos.x() + "," + chunkPos.z() + ".", e); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "vegetation cleanup", nativeStructureBatchContext(placementGroups), + chunkPos.x(), chunkPos.z(), error); } for (NativePlacementGroup group : placementGroups) { random.setFeatureSeed(decoSeed, group.featureIndex(), group.step()); @@ -410,13 +440,27 @@ public class IrisChunkGenerator extends CustomChunkGenerator { placeVanillaStructure(world, structureManager, random, area, chunkPos, group.structureId(), start, group.decision()); } - } catch (Throwable e) { - IrisLogging.reportError("Iris failed to place native structure " + group.structureId() - + " in chunk " + chunkPos.x() + "," + chunkPos.z() + ".", e); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "placement", group.structureId(), chunkPos.x(), chunkPos.z(), error); } } } + private static String nativeStructureBatchContext(List placementGroups) { + if (placementGroups.isEmpty()) { + return ""; + } + StringBuilder context = new StringBuilder("["); + for (int i = 0; i < placementGroups.size(); i++) { + if (i > 0) { + context.append(", "); + } + context.append(placementGroups.get(i).structureId()); + } + return context.append(']').toString(); + } + private void placeVanillaStructure(WorldGenLevel world, StructureManager structureManager, WorldgenRandom random, BoundingBox area, ChunkPos chunkPos, String structureId, StructureStart start, IrisNativeStructureDecision decision) { @@ -451,13 +495,14 @@ public class IrisChunkGenerator extends CustomChunkGenerator { } } - private BlockState resolveStiltBlock(IrisVanillaStructureStiltSettings settings, RNG rng, int x, int y, int z) { + private BlockState resolveStiltBlock(IrisStructureStiltSettings settings, RNG rng, int x, int y, int z) { if (settings.getPalette() == null) { return Blocks.COBBLESTONE.defaultBlockState(); } PlatformBlockState platformState = settings.getPalette().get(rng, x, y, z, engine.getData()); if (platformState == null || !(platformState.nativeHandle() instanceof BlockData blockData)) { - return Blocks.COBBLESTONE.defaultBlockState(); + throw new IllegalStateException("Configured native structure stilt palette did not resolve a Bukkit block at " + + x + "," + y + "," + z); } if (blockData instanceof IrisCustomData customData) { blockData = customData.getBase(); @@ -465,7 +510,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator { if (blockData instanceof CraftBlockData craftBlockData) { return craftBlockData.getState(); } - return Blocks.COBBLESTONE.defaultBlockState(); + throw new IllegalStateException("Configured native structure stilt palette resolved unsupported Bukkit block data " + + blockData.getClass().getName() + " at " + x + "," + y + "," + z); } private BoundingBox writableArea(ChunkAccess chunk) { @@ -616,4 +662,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private record NativePlacementGroup(String structureId, IrisNativeStructureDecision decision, int featureIndex, int step, List starts) { } + + private record NativeLocateCandidate(Holder holder, String key) { + } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index ec82f3c53..130e14c38 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -15,6 +15,7 @@ import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.nativegen.NativeStructureGenerationException; import art.arcane.iris.util.project.agent.Agent; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; @@ -396,6 +397,11 @@ public class NMSBinding implements INMSBinding { return true; } + @Override + public boolean supportsIrisWorldGeneration() { + return true; + } + @Override public int getTrueBiomeBaseId(Object biomeBase) { return getCustomBiomeRegistry().getId(((Holder) biomeBase).value()); @@ -470,8 +476,8 @@ public class NMSBinding implements INMSBinding { KList keys = new KList<>(); try { registry().lookupOrThrow(Registries.STRUCTURE).keySet().forEach(k -> keys.add(k.toString())); - } catch (Throwable e) { - IrisLogging.reportError(e); + } catch (RuntimeException e) { + throw new IllegalStateException("Iris failed to read registered structure keys from the Minecraft registry", e); } return keys; } @@ -481,8 +487,8 @@ public class NMSBinding implements INMSBinding { KList keys = new KList<>(); try { registry().lookupOrThrow(Registries.STRUCTURE_SET).keySet().forEach(k -> keys.add(k.toString())); - } catch (Throwable e) { - IrisLogging.reportError(e); + } catch (RuntimeException e) { + throw new IllegalStateException("Iris failed to read registered structure-set keys from the Minecraft registry", e); } return keys; } @@ -494,8 +500,9 @@ public class NMSBinding implements INMSBinding { ServerLevel level = ((CraftWorld) world).getHandle(); BiomeSource source = level.getChunkSource().getGenerator().getBiomeSource(); keys.addAll(VanillaStructureBiomes.reachableStructureKeys(level, source)); - } catch (Throwable e) { - IrisLogging.reportError(e); + } catch (RuntimeException e) { + throw new IllegalStateException("Iris failed to resolve reachable structures for Bukkit world '" + + (world == null ? "" : world.getName()) + "'", e); } return keys; } @@ -504,9 +511,14 @@ public class NMSBinding implements INMSBinding { public KList getStructureBiomeKeys(String structureKey) { KList keys = new KList<>(); try { - keys.addAll(VanillaStructureBiomes.structureBiomeKeys(registry(), structureKey)); - } catch (Throwable e) { - IrisLogging.reportError(e); + RegistryAccess access = registry(); + if (access == null) { + throw new IllegalStateException("Minecraft registry access is unavailable"); + } + keys.addAll(VanillaStructureBiomes.structureBiomeKeys(access, structureKey)); + } catch (RuntimeException e) { + throw new IllegalStateException("Iris failed to resolve biome keys for registered structure '" + + structureKey + "'", e); } return keys; } @@ -518,8 +530,9 @@ public class NMSBinding implements INMSBinding { ServerLevel level = ((CraftWorld) world).getHandle(); BiomeSource source = level.getChunkSource().getGenerator().getBiomeSource(); keys.addAll(VanillaStructureBiomes.possibleBiomeKeys(source)); - } catch (Throwable e) { - IrisLogging.reportError(e); + } catch (RuntimeException e) { + throw new IllegalStateException("Iris failed to resolve possible structure biome keys for Bukkit world '" + + (world == null ? "" : world.getName()) + "'", e); } return keys; } @@ -636,9 +649,9 @@ public class NMSBinding implements INMSBinding { } } return new int[]{box.minX(), box.minY(), box.minZ(), box.maxX(), box.maxY(), box.maxZ()}; - } catch (Throwable e) { - IrisLogging.reportError(e); - return null; + } catch (RuntimeException e) { + throw NativeStructureGenerationException.failure( + "capture placement", structureKey, chunkX, chunkZ, e); } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java index 2cc2a7452..d78966f38 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java @@ -21,10 +21,10 @@ final class VanillaStructureBiomes { } static Set possibleBiomeKeys(BiomeSource source) { - Set keys = new LinkedHashSet<>(); if (source == null) { - return keys; + throw new IllegalStateException("Minecraft chunk generator has no biome source"); } + Set keys = new LinkedHashSet<>(); Set> possibleBiomes = source instanceof CustomBiomeSource customBiomeSource ? customBiomeSource.possibleStructureBiomes() : source.possibleBiomes(); @@ -34,18 +34,24 @@ final class VanillaStructureBiomes { keys.add(key.get().identifier().toString()); } } + if (keys.isEmpty()) { + throw new IllegalStateException("Minecraft biome source exposes no registered possible biomes"); + } return keys; } static Set structureBiomeKeys(RegistryAccess access, String structureKey) { - Set keys = new LinkedHashSet<>(); - if (access == null || structureKey == null || structureKey.isEmpty()) { - return keys; + if (access == null) { + throw new IllegalStateException("Minecraft registry access is unavailable"); } + if (structureKey == null || structureKey.isBlank()) { + throw new IllegalArgumentException("Registered structure key must not be blank"); + } + Set keys = new LinkedHashSet<>(); Registry registry = access.lookupOrThrow(Registries.STRUCTURE); Structure structure = registry.getValue(Identifier.parse(structureKey)); if (structure == null) { - return keys; + throw new IllegalArgumentException("Registered structure does not exist: " + structureKey); } for (Holder holder : structure.biomes()) { Optional> key = holder.unwrapKey(); @@ -53,18 +59,19 @@ final class VanillaStructureBiomes { keys.add(key.get().identifier().toString()); } } + if (keys.isEmpty()) { + throw new IllegalStateException("Registered structure '" + structureKey + + "' exposes no registered biome keys"); + } return keys; } static Set reachableStructureKeys(ServerLevel level, BiomeSource source) { + if (level == null) { + throw new IllegalStateException("Minecraft server level is unavailable"); + } Set reachable = new LinkedHashSet<>(); - if (level == null || source == null) { - return reachable; - } Set possible = possibleBiomeKeys(source); - if (possible.isEmpty()) { - return reachable; - } Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); for (Map.Entry, Structure> entry : registry.entrySet()) { for (Holder holder : entry.getValue().biomes()) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java new file mode 100644 index 000000000..27d7d8b88 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java @@ -0,0 +1,57 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +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 IrisChunkGeneratorFailureContractTest { + @Test + public void structureLocateDoesNotCatchAndFallThroughToAnotherImplementation() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + int locateStart = source.indexOf("public @Nullable Pair> findNearestMapStructure"); + int locateEnd = source.indexOf("private HolderSet filterReachableStructures", locateStart); + String locate = source.substring(locateStart, locateEnd); + int reachabilityStart = source.indexOf("private Set reachableStructureKeys"); + int reachabilityEnd = source.indexOf("protected MapCodec", reachabilityStart); + String reachability = source.substring(reachabilityStart, reachabilityEnd); + + assertTrue(locate.contains("reached its safety limit")); + assertTrue(locate.contains("unregistered structure holder")); + assertFalse(locate.contains("catch (Throwable")); + assertFalse(locate.contains("IrisLogging.reportError")); + assertFalse(reachability.contains("catch (Throwable")); + assertFalse(reachability.contains("reachable = Set.of()")); + } + + @Test + public void structureGenerationAbortsInsteadOfLoggingAndContinuing() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + int adjustmentStart = source.indexOf("private void adjustGeneratedStructures"); + int adjustmentEnd = source.indexOf("public ChunkGeneratorStructureState createState", adjustmentStart); + String adjustment = source.substring(adjustmentStart, adjustmentEnd); + int placementStart = source.indexOf("private void placeVanillaStructures"); + int placementEnd = source.indexOf("private void placeVanillaStructure(", placementStart + 1); + String placement = source.substring(placementStart, placementEnd); + + assertTrue(adjustment.contains("NativeStructureGenerationException.failure(")); + assertTrue(adjustment.contains("\"vertical adjustment\"")); + assertFalse(adjustment.contains("IrisLogging.reportError")); + assertTrue(placement.contains("\"resolution\"")); + assertTrue(placement.contains("\"terrain integration\"")); + assertTrue(placement.contains("\"vegetation cleanup\"")); + assertTrue(placement.contains("\"placement\"")); + assertTrue(placement.contains("because structure generation is disabled outside the pack")); + assertTrue(placement.contains("prepareSurfaceStructures")); + assertTrue(placement.contains("clearIntersectingVegetation")); + assertTrue(placement.indexOf("prepareSurfaceStructures") + < placement.indexOf("clearIntersectingVegetation")); + assertTrue(placement.indexOf("clearIntersectingVegetation") + < placement.indexOf("for (NativePlacementGroup group")); + assertFalse(placement.contains("IrisLogging.reportError")); + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorMonumentLocateContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorMonumentLocateContractTest.java index 357026fe3..6ba42803d 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorMonumentLocateContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorMonumentLocateContractTest.java @@ -1,5 +1,10 @@ package art.arcane.iris.core.nms.v26_2_R1; +import art.arcane.iris.nativegen.NativeStructureLocateResults; +import com.mojang.datafixers.util.Pair; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.world.level.levelgen.structure.Structure; import org.junit.Test; import java.io.IOException; @@ -8,35 +13,70 @@ import java.nio.file.Path; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; public class IrisChunkGeneratorMonumentLocateContractTest { @Test - public void irisPlacementRunsBeforeExactNativeMonumentIsRemovedFromDelegateLookup() throws IOException { + public void nativeLocateAllowsExplicitReplacementBeforeNativeCapabilityGate() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); int findStart = source.indexOf("findNearestMapStructure(ServerLevel level"); assertTrue(findStart >= 0); - int filterStart = source.indexOf("private HolderSet filterReachableStructures", findStart); - assertTrue(filterStart > findStart); - String findMethod = source.substring(findStart, filterStart); - int irisLocate = findMethod.indexOf("IrisStructureLocator.locate("); - int searchLimit = findMethod.indexOf("LocateStatus.SEARCH_LIMIT_REACHED", irisLocate); - int limitSkip = findMethod.indexOf("continue;", searchLimit); - int nativeFilter = findMethod.indexOf("filterReachableStructures(level, holders)"); - int delegateLocate = findMethod.indexOf("delegate.findNearestMapStructure(level, reachable"); + int irisHelperStart = source.indexOf("private Pair> findNearestIrisStructure(", findStart); + int filterStart = source.indexOf("private HolderSet filterReachableStructures", irisHelperStart); + assertTrue(irisHelperStart > findStart); + assertTrue(filterStart > irisHelperStart); + String outerMethod = source.substring(findStart, irisHelperStart); + String irisHelper = source.substring(irisHelperStart, filterStart); + int policyResolution = irisHelper.indexOf("NativeStructureGenerationPolicy.resolve(engine,"); + int unexploredGuard = irisHelper.indexOf("if (findUnexplored)"); + int registryLookup = irisHelper.indexOf("level.registryAccess().lookupOrThrow(Registries.STRUCTURE)"); + int replacementCheck = irisHelper.indexOf( + "decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS"); + int irisLocate = irisHelper.indexOf("IrisStructureLocator.locate(", replacementCheck); + int searchLimit = irisHelper.indexOf("LocateStatus.SEARCH_LIMIT_REACHED", irisLocate); + int limitSkip = irisHelper.indexOf("continue;", searchLimit); + int nativeFilter = outerMethod.indexOf("filterReachableStructures(level, holders)"); + int delegateLocate = outerMethod.indexOf("delegate.findNearestMapStructure(level, reachable"); + int nearestSelection = outerMethod.indexOf( + "NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated)"); int reachabilityStart = source.indexOf("private Set reachableStructureKeys", filterStart); assertTrue(reachabilityStart > filterStart); String filterMethod = source.substring(filterStart, reachabilityStart); - int monumentReject = filterMethod.indexOf("if (NATIVE_MONUMENT_KEY.equals(key)"); + int monumentReject = filterMethod.indexOf("if (NativeStructureLocateCapability.isPaperUnavailable(key)"); int rejectContinue = filterMethod.indexOf("continue;", monumentReject); + int emptyNativePartition = filterMethod.indexOf("if (candidates.isEmpty())", rejectContinue); + int reachabilityLookup = filterMethod.indexOf("reachableStructureKeys(level)", emptyNativePartition); - assertTrue(irisLocate >= 0); + assertTrue(policyResolution >= 0); + assertTrue(unexploredGuard >= 0); + assertTrue(registryLookup > unexploredGuard); + assertTrue(policyResolution > registryLookup); + assertTrue(replacementCheck > policyResolution); + assertTrue(irisLocate > replacementCheck); assertTrue(searchLimit > irisLocate); assertTrue(limitSkip > searchLimit); - assertTrue(nativeFilter > limitSkip); + assertTrue(nativeFilter >= 0); assertTrue(delegateLocate > nativeFilter); + assertTrue(nearestSelection > delegateLocate); assertTrue(monumentReject >= 0); assertTrue(rejectContinue > monumentReject); - assertTrue(findMethod.contains("new BlockPos(result.originX(), result.baseY(), result.originZ())")); + assertTrue(emptyNativePartition > rejectContinue); + assertTrue(reachabilityLookup > emptyNativePartition); + assertFalse(irisHelper.contains("NativeStructureLocateCapability.isPaperUnavailable(structureId)")); + assertTrue(irisHelper.contains("new BlockPos(result.originX(), result.baseY(), result.originZ())")); + } + + @Test + public void mixedLocateSelectsNearestProviderAndPrefersNativeOnTie() { + BlockPos origin = BlockPos.ZERO; + Pair> irisNear = Pair.of(new BlockPos(4, 70, 0), null); + Pair> nativeFar = Pair.of(new BlockPos(8, 70, 0), null); + Pair> nativeNear = Pair.of(new BlockPos(2, 70, 0), null); + Pair> nativeTie = Pair.of(new BlockPos(0, 70, 4), null); + + assertSame(irisNear, NativeStructureLocateResults.nearest(origin, irisNear, nativeFar)); + assertSame(nativeNear, NativeStructureLocateResults.nearest(origin, irisNear, nativeNear)); + assertSame(nativeTie, NativeStructureLocateResults.nearest(origin, irisNear, nativeTie)); } @Test @@ -54,18 +94,34 @@ public class IrisChunkGeneratorMonumentLocateContractTest { } @Test - public void verticalShiftMovesPiecesJigsawJunctionsAndCachedBoundsTogether() throws IOException { + public void verticalPlacementMovesPiecesMonumentChildrenJigsawJunctionsAndCachedBoundsTogether() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.nativeStructurePostProcessorSource"))); - int method = source.indexOf("public static int applyVerticalShift"); - int clamp = source.indexOf("StructureVerticalBounds.clampOffset", method); - int pieceMove = source.indexOf("piece.move(0, offsetY, 0)", clamp); - int junctionMove = source.indexOf("junction.getSourceGroundY() + offsetY", pieceMove); - int boundsMove = source.indexOf("bounds.move(0, offsetY, 0)", junctionMove); + int placementStart = source.indexOf("public static int applyVerticalPlacement"); + int shiftStart = source.indexOf("public static int applyVerticalShift", placementStart); + int alignmentStart = source.indexOf("static int alignOceanMonumentToSeaLevel", shiftStart); + int moveStart = source.indexOf("private static void moveStructureStart", alignmentStart); + int movePieceStart = source.indexOf("private static void moveStructurePiece", moveStart); + int monumentChildrenStart = source.indexOf("static List monumentChildPieces", movePieceStart); - assertTrue(method >= 0); - assertTrue(clamp > method); - assertTrue(pieceMove > clamp); - assertTrue(junctionMove > pieceMove); - assertTrue(boundsMove > junctionMove); + assertTrue(placementStart >= 0); + assertTrue(shiftStart > placementStart); + assertTrue(alignmentStart > shiftStart); + assertTrue(moveStart > alignmentStart); + assertTrue(movePieceStart > moveStart); + assertTrue(monumentChildrenStart > movePieceStart); + String placementMethod = source.substring(placementStart, shiftStart); + String shiftMethod = source.substring(shiftStart, alignmentStart); + String moveMethod = source.substring(moveStart, movePieceStart); + String movePieceMethod = source.substring(movePieceStart, monumentChildrenStart); + + assertTrue(placementMethod.contains("return alignOceanMonumentToSeaLevel(")); + assertTrue(placementMethod.contains("return applyVerticalShift(")); + assertTrue(shiftMethod.contains("StructureVerticalBounds.clampOffset")); + assertTrue(shiftMethod.contains("moveStructureStart(start, bounds, offsetY)")); + assertTrue(moveMethod.contains("moveStructurePiece(piece, offsetY)")); + assertTrue(moveMethod.contains("cachedBounds.move(0, offsetY, 0)")); + assertTrue(movePieceMethod.contains("piece.move(0, offsetY, 0)")); + assertTrue(movePieceMethod.contains("child.move(0, offsetY, 0)")); + assertTrue(movePieceMethod.contains("junction.getSourceGroundY() + offsetY")); } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingPlacementFailureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingPlacementFailureContractTest.java new file mode 100644 index 000000000..bbe7e5202 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingPlacementFailureContractTest.java @@ -0,0 +1,29 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +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 NMSBindingPlacementFailureContractTest { + @Test + public void structurePlacementPropagatesRuntimeFailuresWithContext() throws IOException { + Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource")); + String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java")); + int methodStart = source.indexOf("public int[] placeStructure("); + int methodEnd = source.indexOf("\n @Override", methodStart + 1); + String method = source.substring(methodStart, methodEnd); + int catchStart = method.indexOf("catch (RuntimeException e)"); + + assertTrue(catchStart >= 0); + String failurePath = method.substring(catchStart); + assertTrue(failurePath.contains("throw NativeStructureGenerationException.failure(")); + assertTrue(failurePath.contains("\"capture placement\", structureKey, chunkX, chunkZ, e")); + assertFalse(failurePath.contains("return null;")); + assertFalse(failurePath.contains("catch (Throwable")); + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingStructureFailureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingStructureFailureContractTest.java new file mode 100644 index 000000000..50583cd95 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingStructureFailureContractTest.java @@ -0,0 +1,43 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class NMSBindingStructureFailureContractTest { + @Test + public void structureRegistryHooksThrowInsteadOfReturningEmptyOnFailure() throws IOException { + Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource")); + String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java")); + List methodNames = List.of( + "getStructureKeys", + "getStructureSetKeys", + "getReachableStructureKeys", + "getStructureBiomeKeys", + "getPossibleBiomeKeys"); + + for (String methodName : methodNames) { + String method = methodSource(source, methodName); + assertTrue(methodName + " must preserve failure context", method.contains("throw new IllegalStateException(")); + assertTrue(methodName + " must retain its root cause", method.contains(", e);")); + assertFalse(methodName + " must not log and return an empty registry result", + method.contains("IrisLogging.reportError")); + assertFalse(methodName + " must not catch fatal JVM errors", method.contains("catch (Throwable")); + } + } + + private static String methodSource(String source, String methodName) { + int start = source.indexOf("public KList " + methodName + "("); + int end = source.indexOf("\n @Override", start + 1); + if (start < 0 || end < 0) { + throw new IllegalArgumentException("Unable to find NMS binding method " + methodName); + } + return source.substring(start, end); + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorMonumentTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorMonumentTest.java new file mode 100644 index 000000000..89ff8e50c --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorMonumentTest.java @@ -0,0 +1,140 @@ +package art.arcane.iris.nativegen; + +import net.minecraft.SharedConstants; +import net.minecraft.core.Direction; +import net.minecraft.core.HolderSet; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces; +import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class NativeStructurePostProcessorMonumentTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void vanillaSeaLevelKeepsTheVanillaMonumentHeight() { + StructureStart start = monumentStart(1337L); + + int offset = NativeStructurePostProcessor.applyVerticalPlacement( + start, "minecraft:monument", 0, 63, -64, 320, false, (x, z) -> 0); + + assertEquals(0, offset); + assertEquals(39, start.getBoundingBox().minY()); + assertEquals(61, start.getBoundingBox().maxY()); + } + + @Test + public void shiftedSeaLevelMovesTheShellAndEveryRoomTogether() { + StructureStart start = monumentStart(1337L); + OceanMonumentPieces.MonumentBuilding building = monumentBuilding(start); + List children = NativeStructurePostProcessor.monumentChildPieces(building); + int[] childMinY = minimumYs(children); + BoundingBox cachedBounds = start.getBoundingBox(); + + int offset = NativeStructurePostProcessor.applyVerticalPlacement( + start, "minecraft:monument", 0, 50, -256, 512, false, (x, z) -> 0); + + assertEquals(-13, offset); + assertSame(cachedBounds, start.getBoundingBox()); + assertEquals(26, building.getBoundingBox().minY()); + assertEquals(48, building.getBoundingBox().maxY()); + assertEquals(26, start.getBoundingBox().minY()); + assertEquals(48, start.getBoundingBox().maxY()); + assertTrue(children.size() > 20); + for (int i = 0; i < children.size(); i++) { + assertEquals(childMinY[i] - 13, children.get(i).getBoundingBox().minY()); + } + + int repeatedOffset = NativeStructurePostProcessor.applyVerticalPlacement( + start, "minecraft:monument", 0, 50, -256, 512, false, (x, z) -> 0); + assertEquals(0, repeatedOffset); + } + + @Test + public void configuredOffsetIsRelativeToTheActualSeaLevel() { + StructureStart start = monumentStart(1337L); + + int offset = NativeStructurePostProcessor.applyVerticalPlacement( + start, "minecraft:monument", 3, 50, -256, 512, false, (x, z) -> 0); + + assertEquals(-10, offset); + assertEquals(29, start.getBoundingBox().minY()); + assertEquals(51, start.getBoundingBox().maxY()); + } + + @Test + public void vanillaReloadRegenerationIsRealignedBeforePlacement() { + long seed = 1337L; + ChunkPos chunkPos = new ChunkPos(0, 0); + StructureStart initial = monumentStart(seed); + NativeStructurePostProcessor.applyVerticalPlacement( + initial, "minecraft:monument", 0, 50, -256, 512, false, (x, z) -> 0); + + PiecesContainer regenerated = OceanMonumentStructure.regeneratePiecesAfterLoad( + chunkPos, seed, new PiecesContainer(initial.getPieces())); + StructureStart reloaded = new StructureStart( + monumentStructure(), chunkPos, 0, regenerated); + + assertEquals(39, reloaded.getBoundingBox().minY()); + int offset = NativeStructurePostProcessor.applyVerticalPlacement( + reloaded, "minecraft:monument", 0, 50, -256, 512, false, (x, z) -> 0); + assertEquals(-13, offset); + assertEquals(26, reloaded.getBoundingBox().minY()); + assertEquals(48, reloaded.getBoundingBox().maxY()); + } + + @Test + public void impossibleSeaLevelAlignmentFailsInsteadOfClippingTheMonument() { + StructureStart start = monumentStart(1337L); + try { + NativeStructurePostProcessor.applyVerticalPlacement( + start, "minecraft:monument", 0, -50, -64, 320, false, (x, z) -> 0); + } catch (IllegalStateException error) { + assertTrue(error.getMessage().contains("cannot align")); + return; + } + throw new AssertionError("Expected an out-of-bounds monument alignment to fail"); + } + + private static StructureStart monumentStart(long seed) { + ChunkPos chunkPos = new ChunkPos(0, 0); + OceanMonumentPieces.MonumentBuilding building = new OceanMonumentPieces.MonumentBuilding( + RandomSource.create(seed), -29, -29, Direction.NORTH); + return new StructureStart( + monumentStructure(), chunkPos, 0, new PiecesContainer(List.of(building))); + } + + private static OceanMonumentStructure monumentStructure() { + return new OceanMonumentStructure(new Structure.StructureSettings(HolderSet.empty())); + } + + private static OceanMonumentPieces.MonumentBuilding monumentBuilding(StructureStart start) { + return (OceanMonumentPieces.MonumentBuilding) start.getPieces().get(0); + } + + private static int[] minimumYs(List pieces) { + int[] ys = new int[pieces.size()]; + for (int i = 0; i < pieces.size(); i++) { + ys[i] = pieces.get(i).getBoundingBox().minY(); + } + return ys; + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java new file mode 100644 index 000000000..a9cac52d3 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java @@ -0,0 +1,327 @@ +package art.arcane.iris.nativegen; + +import com.mojang.datafixers.util.Either; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; +import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Modifier; +import java.lang.reflect.Proxy; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class NativeStructurePostProcessorSurfaceTerrainTest { + @BeforeClass + public static void bootstrapMinecraft() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void onlySurfaceBeardThinStructuresPrepareTerrain() { + assertTrue(NativeStructurePostProcessor.shouldPrepareSurfaceTerrain( + TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.SURFACE_STRUCTURES)); + assertFalse(NativeStructurePostProcessor.shouldPrepareSurfaceTerrain( + TerrainAdjustment.BURY, GenerationStep.Decoration.SURFACE_STRUCTURES)); + assertFalse(NativeStructurePostProcessor.shouldPrepareSurfaceTerrain( + TerrainAdjustment.BEARD_BOX, GenerationStep.Decoration.SURFACE_STRUCTURES)); + assertFalse(NativeStructurePostProcessor.shouldPrepareSurfaceTerrain( + TerrainAdjustment.ENCAPSULATE, GenerationStep.Decoration.SURFACE_STRUCTURES)); + } + + @Test + public void undergroundAdjustmentsNeverPrepareSurfaceTerrain() { + for (TerrainAdjustment adjustment : List.of( + TerrainAdjustment.BEARD_THIN, + TerrainAdjustment.BURY, + TerrainAdjustment.BEARD_BOX, + TerrainAdjustment.ENCAPSULATE)) { + assertFalse(NativeStructurePostProcessor.shouldPrepareSurfaceTerrain( + adjustment, GenerationStep.Decoration.UNDERGROUND_STRUCTURES)); + } + } + + @Test + public void surfaceAnchorIsFlushInsideAndUnchangedAtRadius() { + NativeStructurePostProcessor.SurfaceAnchor anchor = anchor(80, 2); + + assertEquals(80, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(anchor), 2, 2, 64)); + assertEquals(64, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(anchor), 16, 2, 64)); + assertEquals(64, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(anchor), 17, 2, 64)); + } + + @Test + public void surfaceAnchorRaisesAndLowersThroughTheTaper() { + NativeStructurePostProcessor.SurfaceAnchor raised = anchor(80, 2); + NativeStructurePostProcessor.SurfaceAnchor lowered = anchor(64, 2); + + assertEquals(68, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(raised), 10, 2, 64)); + assertEquals(76, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(lowered), 10, 2, 80)); + } + + @Test + public void containingRigidFloorsHaveDeterministicPriority() { + NativeStructurePostProcessor.SurfaceAnchor rigid = anchor(70, 2); + NativeStructurePostProcessor.SurfaceAnchor junction = anchor(90, 1); + + assertEquals(70, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(rigid, junction), 2, 2, 64)); + assertEquals(70, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(junction, rigid), 2, 2, 64)); + + NativeStructurePostProcessor.SurfaceAnchor weakTie = anchor(48, 1); + NativeStructurePostProcessor.SurfaceAnchor strongTie = anchor(80, 2); + assertEquals(80, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(weakTie, strongTie), 2, 2, 64)); + assertEquals(80, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(strongTie, weakTie), 2, 2, 64)); + } + + @Test + public void containingFootprintOverridesAnAdjacentPiecesFalloff() { + NativeStructurePostProcessor.SurfaceAnchor local = + new NativeStructurePostProcessor.SurfaceAnchor(0, 4, 0, 4, 65, 2); + NativeStructurePostProcessor.SurfaceAnchor adjacent = + new NativeStructurePostProcessor.SurfaceAnchor(5, 9, 0, 4, 80, 2); + + assertEquals(77, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(adjacent), 4, 2, 64)); + assertEquals(65, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(local, adjacent), 4, 2, 64)); + assertEquals(65, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(adjacent, local), 4, 2, 64)); + } + + @Test + public void opposingFalloffsBlendWithoutAnAbruptMidpointSeam() { + NativeStructurePostProcessor.SurfaceAnchor high = + new NativeStructurePostProcessor.SurfaceAnchor(0, 0, 0, 0, 80, 2); + NativeStructurePostProcessor.SurfaceAnchor low = + new NativeStructurePostProcessor.SurfaceAnchor(12, 12, 0, 0, 48, 2); + int previous = NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(high, low), 0, 0, 64); + + for (int x = 1; x <= 12; x++) { + int forward = NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(high, low), x, 0, 64); + int reversed = NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(low, high), x, 0, 64); + assertEquals(forward, reversed); + assertTrue(Math.abs(forward - previous) <= 4); + previous = forward; + } + assertEquals(64, NativeStructurePostProcessor.resolveSurfaceTarget( + List.of(high, low), 6, 0, 64)); + } + + @Test + public void surfaceColumnsMutateTerrainAndRemoveUnsupportedDecoration() { + Map lowered = new HashMap<>(); + put(lowered, 0, 61, 0, Blocks.STONE.defaultBlockState()); + put(lowered, 0, 62, 0, Blocks.DIRT.defaultBlockState()); + put(lowered, 0, 63, 0, Blocks.DIRT.defaultBlockState()); + put(lowered, 0, 64, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + put(lowered, 0, 65, 0, Blocks.DANDELION.defaultBlockState()); + + NativeStructurePostProcessor.applySurfaceColumn( + world(lowered), new BlockPos.MutableBlockPos(), + 0, 0, 64, 62, -64, 319); + + assertEquals(Blocks.STONE.defaultBlockState(), state(lowered, 0, 61, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(lowered, 0, 62, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(lowered, 0, 63, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(lowered, 0, 64, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(lowered, 0, 65, 0)); + + Map raised = new HashMap<>(); + put(raised, 0, 63, 0, Blocks.DIRT.defaultBlockState()); + put(raised, 0, 64, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + put(raised, 0, 65, 0, Blocks.DANDELION.defaultBlockState()); + + NativeStructurePostProcessor.applySurfaceColumn( + world(raised), new BlockPos.MutableBlockPos(), + 0, 0, 64, 68, -64, 319); + + assertEquals(Blocks.DIRT.defaultBlockState(), state(raised, 0, 65, 0)); + assertEquals(Blocks.DIRT.defaultBlockState(), state(raised, 0, 66, 0)); + assertEquals(Blocks.DIRT.defaultBlockState(), state(raised, 0, 67, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(raised, 0, 68, 0)); + } + + @Test + public void loweredFluidColumnsRemainFluidFilled() { + Map blocks = new HashMap<>(); + put(blocks, 0, 62, 0, Blocks.DIRT.defaultBlockState()); + put(blocks, 0, 63, 0, Blocks.DIRT.defaultBlockState()); + put(blocks, 0, 64, 0, Blocks.GRAVEL.defaultBlockState()); + put(blocks, 0, 65, 0, Blocks.WATER.defaultBlockState()); + + NativeStructurePostProcessor.applySurfaceColumn( + world(blocks), new BlockPos.MutableBlockPos(), + 0, 0, 64, 62, -64, 319); + + assertEquals(Blocks.WATER.defaultBlockState(), state(blocks, 0, 63, 0)); + assertEquals(Blocks.WATER.defaultBlockState(), state(blocks, 0, 64, 0)); + assertEquals(Blocks.WATER.defaultBlockState(), state(blocks, 0, 65, 0)); + assertEquals(Blocks.GRAVEL.defaultBlockState(), state(blocks, 0, 62, 0)); + } + + @Test + public void singlePoolTemplateFieldMatchesTheRuntimeContract() { + Field field = NativeStructurePostProcessor.resolveSinglePoolTemplateField(); + + assertEquals(SinglePoolElement.class, field.getDeclaringClass()); + assertEquals(Either.class, field.getType()); + assertTrue(Modifier.isProtected(field.getModifiers())); + assertTrue(Modifier.isFinal(field.getModifiers())); + assertFalse(Modifier.isStatic(field.getModifiers())); + assertTrue(field.trySetAccessible()); + } + + @Test + public void runtimeTemplatesAndLegacyAirUseTheExactContract() { + StructureTemplate runtimeTemplate = new StructureTemplate(); + + assertEquals(runtimeTemplate, NativeStructurePostProcessor.resolveTemplateReference( + Either.right(runtimeTemplate), null)); + assertFalse(NativeStructurePostProcessor.shouldClearLegacyAir(79, 80, false)); + assertTrue(NativeStructurePostProcessor.shouldClearLegacyAir(80, 80, false)); + assertTrue(NativeStructurePostProcessor.shouldClearLegacyAir(96, 80, false)); + assertFalse(NativeStructurePostProcessor.shouldClearLegacyAir(96, 80, true)); + } + + @Test + public void rotatedTemplateAirClearsOnlyInsideTheChunkAndAboveTheFloor() throws Exception { + StructureTemplate.StructureBlockInfo clear = new StructureTemplate.StructureBlockInfo( + new BlockPos(1, 0, 0), Blocks.AIR.defaultBlockState(), null); + StructureTemplate.StructureBlockInfo belowFloor = new StructureTemplate.StructureBlockInfo( + new BlockPos(0, -1, 0), Blocks.AIR.defaultBlockState(), null); + StructureTemplate.StructureBlockInfo outsideChunk = new StructureTemplate.StructureBlockInfo( + new BlockPos(3, 0, 0), Blocks.AIR.defaultBlockState(), null); + StructureTemplate template = template(List.of(clear, belowFloor, outsideChunk)); + BlockPos origin = new BlockPos(10, 80, 10); + StructurePlaceSettings settings = new StructurePlaceSettings().setRotation(Rotation.CLOCKWISE_90); + BlockPos clearPosition = origin.offset(StructureTemplate.calculateRelativePosition(settings, clear.pos())); + BlockPos belowFloorPosition = origin.offset( + StructureTemplate.calculateRelativePosition(settings, belowFloor.pos())); + BlockPos outsidePosition = origin.offset( + StructureTemplate.calculateRelativePosition(settings, outsideChunk.pos())); + BoundingBox area = new BoundingBox( + Math.min(clearPosition.getX(), belowFloorPosition.getX()), + Math.min(clearPosition.getY(), belowFloorPosition.getY()), + Math.min(clearPosition.getZ(), belowFloorPosition.getZ()), + Math.max(clearPosition.getX(), belowFloorPosition.getX()), + Math.max(clearPosition.getY(), belowFloorPosition.getY()), + Math.max(clearPosition.getZ(), belowFloorPosition.getZ())); + settings.setBoundingBox(area); + assertFalse(area.isInside(outsidePosition)); + Map blocks = new HashMap<>(); + blocks.put(clearPosition, Blocks.DIRT.defaultBlockState()); + blocks.put(belowFloorPosition, Blocks.DIRT.defaultBlockState()); + blocks.put(outsidePosition, Blocks.DIRT.defaultBlockState()); + + NativeStructurePostProcessor.clearTemplateAir( + world(blocks), template, origin, 80, settings); + + assertEquals(Blocks.AIR.defaultBlockState(), blocks.get(clearPosition)); + assertEquals(Blocks.DIRT.defaultBlockState(), blocks.get(belowFloorPosition)); + assertEquals(Blocks.DIRT.defaultBlockState(), blocks.get(outsidePosition)); + } + + @Test + public void unrelatedPieceBoundsAreRejectedBeforeTemplateScanning() { + BoundingBox area = new BoundingBox(0, -64, 0, 15, 319, 15); + + assertTrue(NativeStructurePostProcessor.intersects( + new BoundingBox(15, 60, 15, 30, 90, 30), area)); + assertFalse(NativeStructurePostProcessor.intersects( + new BoundingBox(16, 60, 16, 30, 90, 30), area)); + assertFalse(NativeStructurePostProcessor.intersects( + new BoundingBox(0, 320, 0, 15, 350, 15), area)); + } + + @Test + public void vegetationCleanupUsesTheSameCircularTaperAsTerrain() { + BoundingBox piece = new BoundingBox(0, 60, 0, 4, 80, 4); + + assertTrue(NativeStructurePostProcessor.withinSurfaceTerrainRadius(16, 2, piece, 12)); + assertFalse(NativeStructurePostProcessor.withinSurfaceTerrainRadius(16, 16, piece, 12)); + } + + private static NativeStructurePostProcessor.SurfaceAnchor anchor(int meetY, int strength) { + return new NativeStructurePostProcessor.SurfaceAnchor(0, 4, 0, 4, meetY, strength); + } + + private static StructureTemplate template( + List blocks) throws Exception { + Constructor constructor = + StructureTemplate.Palette.class.getDeclaredConstructor(List.class); + assertTrue(constructor.trySetAccessible()); + StructureTemplate.Palette palette = constructor.newInstance(blocks); + StructureTemplate template = new StructureTemplate(); + template.palettes.add(palette); + return template; + } + + private static WorldGenLevel world(Map blocks) { + InvocationHandler handler = (proxy, method, arguments) -> { + String methodName = method.getName(); + if (methodName.equals("getBlockState")) { + BlockPos position = (BlockPos) arguments[0]; + return state(blocks, position.getX(), position.getY(), position.getZ()); + } + if (methodName.equals("setBlock")) { + BlockPos position = (BlockPos) arguments[0]; + BlockState blockState = (BlockState) arguments[1]; + put(blocks, position.getX(), position.getY(), position.getZ(), blockState); + return true; + } + if (methodName.equals("hashCode")) { + return System.identityHashCode(proxy); + } + if (methodName.equals("equals")) { + return proxy == arguments[0]; + } + if (methodName.equals("toString")) { + return "surface-test-world"; + } + throw new UnsupportedOperationException(method.toString()); + }; + return (WorldGenLevel) Proxy.newProxyInstance( + WorldGenLevel.class.getClassLoader(), new Class[]{WorldGenLevel.class}, handler); + } + + private static void put(Map blocks, + int x, int y, int z, BlockState state) { + blocks.put(new BlockPos(x, y, z), state); + } + + private static BlockState state(Map blocks, int x, int y, int z) { + return blocks.getOrDefault(new BlockPos(x, y, z), Blocks.AIR.defaultBlockState()); + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java index ce6a24ef0..0f3bbe51b 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java @@ -1,8 +1,10 @@ package art.arcane.iris.nativegen; import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.structure.BoundingBox; import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -24,6 +26,22 @@ public class NativeStructurePostProcessorVegetationTest { assertTrue(NativeStructurePostProcessor.shouldClearVegetationColumn(20, 100, true)); } + @Test + public void surfaceStructuresClearTheirEntireFootprintByDefault() { + assertTrue(NativeStructurePostProcessor.shouldClearEntireVegetationFootprint( + GenerationStep.Decoration.SURFACE_STRUCTURES, false)); + assertTrue(NativeStructurePostProcessor.shouldClearEntireVegetationFootprint( + GenerationStep.Decoration.SURFACE_STRUCTURES, true)); + } + + @Test + public void undergroundStructuresPreserveSurfaceVegetationUnlessConfigured() { + assertFalse(NativeStructurePostProcessor.shouldClearEntireVegetationFootprint( + GenerationStep.Decoration.UNDERGROUND_STRUCTURES, false)); + assertTrue(NativeStructurePostProcessor.shouldClearEntireVegetationFootprint( + GenerationStep.Decoration.UNDERGROUND_STRUCTURES, true)); + } + @Test public void allUndergroundGenerationStepsShareOneClassification() { assertTrue(NativeStructurePostProcessor.isUndergroundStep( @@ -35,4 +53,25 @@ public class NativeStructurePostProcessorVegetationTest { assertFalse(NativeStructurePostProcessor.isUndergroundStep( GenerationStep.Decoration.SURFACE_STRUCTURES)); } + + @Test + public void undergroundStructuresUseTheLowestTerrainColumn() { + BoundingBox bounds = new BoundingBox(0, 60, 0, 1, 80, 0); + int offset = NativeStructurePostProcessor.resolveBuriedOffset( + bounds, 0, -64, 320, (x, z) -> x == 1 ? 76 : 100); + assertEquals(-5, offset); + } + + @Test + public void undergroundStructuresFailWhenBurialWouldCrossWorldFloor() { + BoundingBox bounds = new BoundingBox(0, 60, 0, 1, 80, 0); + try { + NativeStructurePostProcessor.resolveBuriedOffset( + bounds, 0, 58, 320, (x, z) -> x == 1 ? 76 : 100); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("world minimum")); + return; + } + throw new AssertionError("Expected underground burial to fail below the world floor"); + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java index 80a43e382..d71652ef2 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java @@ -21,12 +21,16 @@ package art.arcane.iris.core.commands; import art.arcane.iris.Iris; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.core.service.ObjectStudioSaveService; +import art.arcane.iris.core.structure.NativeStructureLocateCapability; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.platform.EngineBukkitOps; import art.arcane.iris.engine.framework.StructureReachability; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.util.common.director.DirectorExecutor; import art.arcane.iris.util.common.director.specialhandlers.ObjectHandler; import art.arcane.iris.util.common.director.specialhandlers.StructureHandler; @@ -117,13 +121,34 @@ public class CommandFind implements DirectorExecutor { return; } - if (IrisStructureLocator.isPlaced(e, structure)) { - locateIrisStructure(e, structure, commandSender); + String structureKey = structure == null ? "" : structure.trim(); + Structure nativeStructure = resolveNativeStructure(structureKey); + if (nativeStructure != null) { + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve( + e, structureKey, false); + if (!decision.generate() + && decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + commandSender.sendMessage(C.RED + NativeStructureGenerationPolicy.generationStatusMessage( + structureKey, decision.status())); + return; + } + if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + locateIrisStructure(e, structureKey, commandSender); + return; + } + if (NativeStructureLocateCapability.isPaperUnavailable(structureKey)) { + commandSender.sendMessage(C.RED + NativeStructureLocateCapability.unavailableMessage()); + return; + } + } + + if (nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)) { + locateIrisStructure(e, structureKey, commandSender); return; } - if (BukkitNativeStructureLocatePolicy.isUnavailable(structure)) { - commandSender.sendMessage(C.RED + BukkitNativeStructureLocatePolicy.unavailableMessage()); + if (nativeStructure == null) { + commandSender.sendMessage(C.RED + "Unknown structure: " + structureKey); return; } @@ -135,42 +160,44 @@ public class CommandFind implements DirectorExecutor { World targetWorld = target.getWorld(); Location origin = target.getLocation(); - commandSender.sendMessage(C.GRAY + "Locating " + structure + "..."); + commandSender.sendMessage(C.GRAY + "Locating " + structureKey + "..."); J.s(() -> { try { - Registry structureRegistry = RegistryAccess.registryAccess().getRegistry(RegistryKey.STRUCTURE); - Structure match = null; - for (Structure candidate : structureRegistry) { - NamespacedKey key = structureRegistry.getKey(candidate); - if (key != null && key.toString().equalsIgnoreCase(structure)) { - match = candidate; - break; - } - } - if (match == null) { - sendStructureMessage(target, commandSender, C.RED + "Unknown structure: " + structure); - return; - } - if (!StructureReachability.isReachable(e, structure)) { - KList miss = StructureReachability.missingBiomeKeys(e, structure); + if (!StructureReachability.isReachable(e, structureKey)) { + KList miss = StructureReachability.missingBiomeKeys(e, structureKey); sendStructureMessage(target, commandSender, - C.YELLOW + structure + " cannot generate in this world (its required biomes are not produced by this pack" + C.YELLOW + structureKey + " cannot generate in this world (its required biomes are not produced by this pack" + (miss.isEmpty() ? "" : ": needs " + String.join("/", miss)) + ")."); return; } - StructureSearchResult result = targetWorld.locateNearestStructure(origin, match, 100, false); + StructureSearchResult result = targetWorld.locateNearestStructure( + origin, nativeStructure, 100, false); if (result == null || result.getLocation() == null) { - sendStructureMessage(target, commandSender, C.YELLOW + "No " + structure + " found within range of you."); + sendStructureMessage(target, commandSender, + C.YELLOW + "No " + structureKey + " found within range of you."); return; } - prepareStructureTeleport(target, targetWorld, commandSender, structure, result.getLocation(), false); + prepareStructureTeleport( + target, targetWorld, commandSender, structureKey, result.getLocation(), false); } catch (Throwable t) { - sendStructureMessage(target, commandSender, C.RED + "Could not locate " + structure + ": " + t.getClass().getSimpleName()); - Iris.reportError("Could not locate structure '" + structure + "'.", t); + sendStructureMessage(target, commandSender, + C.RED + "Could not locate " + structureKey + ": " + t.getClass().getSimpleName()); + Iris.reportError("Could not locate structure '" + structureKey + "'.", t); } }); } + private static Structure resolveNativeStructure(String structureKey) { + Registry structureRegistry = RegistryAccess.registryAccess().getRegistry(RegistryKey.STRUCTURE); + for (Structure candidate : structureRegistry) { + NamespacedKey key = structureRegistry.getKey(candidate); + if (key != null && key.toString().equalsIgnoreCase(structureKey)) { + return candidate; + } + } + return null; + } + private void locateIrisStructure(Engine engine, String structure, VolmitSender commandSender) { Player target = player(); if (target == null) { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java index 7f2dc407d..8efc74ca6 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java @@ -22,15 +22,13 @@ import art.arcane.iris.Iris; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.core.link.WorldEditLink; import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.core.runtime.ObjectStudioActivation; import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.core.service.ObjectSVC; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.service.WandSVC; import art.arcane.iris.core.tools.IrisConverter; -import art.arcane.iris.core.tools.PlausibilizeMode; -import art.arcane.iris.core.tools.TreePlausibilizer; +import art.arcane.iris.core.tools.TreePlausibilizeBatch; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.IrisDimension; @@ -353,199 +351,63 @@ public class CommandObject implements DirectorExecutor { } } - @Director(description = "Make tree leaves vanilla-decay-plausible (every leaf within 6 blocks of a log)", + @Director(description = "Grow organic branches through the canopy so every leaf survives vanilla decay", origin = DirectorOrigin.BOTH) public void plausibilize( @Param(description = "Object key, prefix (trees/), or filesystem path", customHandler = ObjectTargetHandler.class) String target, - @Param(description = "DEFAULT: tentacle logs, delete orphans. NORMALIZE: + flip persistent=false. FOLIAGE_OVERATURE: add leaves to bridge orphans, no deletions. SMOKE: wipe & repaint canopy shell.", - defaultValue = "DEFAULT") - PlausibilizeMode mode, - @Param(description = "Analyze only, do not write", defaultValue = "false") + @Param(name = "dryrun", description = "dryrun=true analyzes only, writes nothing", defaultValue = "false") boolean dryRun, - @Param(description = "Canopy shell radius (SMOKE only), clamped [0,5]", defaultValue = "2") - int radius + @Param(name = "reach", description = "reach=N max branch length in blocks from existing wood; farther leaf clusters are pinned persistent instead. reach=0 grows unlimited", defaultValue = "12") + int reach ) { - List targets = resolveTargets(target); + IrisData nearest = data(); + List targets = TreePlausibilizeBatch.resolve(target, nearest); + if (targets.isEmpty() && nearest == null) { + targets = resolveFromPacks(target); + } if (targets.isEmpty()) { sender().sendMessage(C.RED + "No objects matched: " + target); return; } - sender().sendMessage(C.IRIS + "Plausibilize [" + mode.name() - + (dryRun ? " DRY" : "") - + (mode == PlausibilizeMode.SMOKE ? " r=" + radius : "") + sender().sendMessage(C.IRIS + "Plausibilize [reach=" + reach + (dryRun ? ", DRY" : "") + "] queued " + targets.size() + " object(s)"); org.bukkit.command.CommandSender s = sender(); - J.a(() -> runPlausibilize(targets, dryRun, mode, radius, s)); + List queued = targets; + J.a(() -> TreePlausibilizeBatch.run(queued, dryRun, reach, nearest, (String line) -> + s.sendMessage(line.startsWith("Done:") || line.startsWith("Totals:") || line.startsWith("[") + ? C.IRIS + line + : C.GRAY + " " + line))); } - private List resolveTargets(String target) { - List out = new ArrayList<>(); - if (target == null || target.isEmpty()) { - return out; - } - - File direct = new File(target); - if (direct.isFile() && target.toLowerCase().endsWith(".iob")) { - out.add(new Target(direct.getName().replaceAll("\\.iob$", ""), direct)); - return out; - } - if (direct.isDirectory()) { - walkIob(direct, direct, out); - return out; - } - - IrisData irisData = data(); - if (irisData != null) { - ResourceLoader loader = irisData.getObjectLoader(); - if (!target.endsWith("/") && loader.findFile(target) != null) { - out.add(new Target(target, null)); - return out; - } - String prefix = target.endsWith("/") ? target : target + "/"; - for (String k : loader.getPossibleKeys()) { - if (k.startsWith(prefix)) { - out.add(new Target(k, null)); - } - } - return out; - } - + private static List resolveFromPacks(String target) { + List out = new ArrayList<>(); File packsFolder = Iris.instance.getDataFolder("packs"); File[] packs = packsFolder.listFiles(File::isDirectory); - if (packs != null) { - for (File pack : packs) { - File objectsRoot = new File(pack, "objects"); - if (!objectsRoot.isDirectory()) continue; - File candidate = new File(objectsRoot, target + ".iob"); - if (candidate.isFile()) { - out.add(new Target(pack.getName() + "/" + target, candidate)); - continue; - } - File candidateDir = new File(objectsRoot, target); - if (candidateDir.isDirectory()) { - walkIob(candidateDir, objectsRoot, out); - } + if (packs == null) { + return out; + } + for (File pack : packs) { + File objectsRoot = new File(pack, "objects"); + if (!objectsRoot.isDirectory()) { + continue; + } + File candidate = new File(objectsRoot, target + ".iob"); + if (candidate.isFile()) { + out.add(new TreePlausibilizeBatch.Target(pack.getName() + "/" + target, candidate)); + continue; + } + File candidateDir = new File(objectsRoot, target); + if (candidateDir.isDirectory()) { + TreePlausibilizeBatch.walkIob(candidateDir, objectsRoot, out); } } return out; } - private static void walkIob(File root, File keyRoot, List out) { - File[] kids = root.listFiles(); - if (kids == null) return; - for (File f : kids) { - if (f.isDirectory()) { - walkIob(f, keyRoot, out); - } else if (f.getName().toLowerCase().endsWith(".iob")) { - String rel = keyRoot.toPath().relativize(f.toPath()).toString() - .replace(File.separatorChar, '/') - .replaceAll("\\.iob$", ""); - out.add(new Target(rel, f)); - } - } - } - - private record Target(String key, File file) { - } - - private static void runPlausibilize( - List targets, - boolean dryRun, - PlausibilizeMode mode, - int radius, - org.bukkit.command.CommandSender s - ) { - int processed = 0; - int skipped = 0; - int failed = 0; - int changed = 0; - long totalLogsAdded = 0L; - long totalLeavesAdded = 0L; - long totalLeavesRemoved = 0L; - long totalNormalized = 0L; - long totalUnreachableAfter = 0L; - - int progressStep = Math.max(1, targets.size() / 20); - int index = 0; - - for (Target t : targets) { - index++; - try { - IrisObject o = loadTarget(t); - if (o == null) { - s.sendMessage(C.YELLOW + " skip " + t.key() + ": failed to load"); - skipped++; - continue; - } - - TreePlausibilizer.Result r = dryRun - ? TreePlausibilizer.analyze(o, mode, radius) - : TreePlausibilizer.apply(o, mode, radius); - - if (r.skipReason() != null) { - s.sendMessage(C.YELLOW + " skip " + t.key() + ": " + r.skipReason()); - skipped++; - continue; - } - - boolean touched = r.logsAdded() > 0 || r.leavesAdded() > 0 - || r.leavesRemoved() > 0 || r.leavesNormalized() > 0; - if (!dryRun && touched) { - File dest = o.getLoadFile() != null ? o.getLoadFile() : t.file(); - if (dest != null) { - o.write(dest); - changed++; - } - } - - processed++; - totalLogsAdded += r.logsAdded(); - totalLeavesAdded += r.leavesAdded(); - totalLeavesRemoved += r.leavesRemoved(); - totalNormalized += r.leavesNormalized(); - totalUnreachableAfter += r.unreachableAfter(); - - if (touched || targets.size() == 1) { - s.sendMessage(C.GRAY + " " + t.key() - + C.WHITE + " +" + r.logsAdded() + " logs" - + C.WHITE + " +" + r.leavesAdded() + " leaves" - + C.WHITE + " -" + r.leavesRemoved() + " removed" - + (r.leavesNormalized() > 0 ? C.WHITE + " ~" + r.leavesNormalized() + " normalized" : "") - + (r.unreachableAfter() > 0 ? C.YELLOW + " " + r.unreachableAfter() + " unreachable" : "")); - } - - if (targets.size() > 1 && index % progressStep == 0) { - s.sendMessage(C.IRIS + " [" + index + "/" + targets.size() + "]"); - } - } catch (Throwable e) { - s.sendMessage(C.RED + " fail " + t.key() + ": " + e.getClass().getSimpleName() - + ": " + e.getMessage()); - e.printStackTrace(); - failed++; - } - } - - s.sendMessage(C.IRIS + "Done: " + processed + " processed, " + changed + " changed, " - + skipped + " skipped, " + failed + " failed"); - s.sendMessage(C.IRIS + "Totals: +" + totalLogsAdded + " logs, +" + totalLeavesAdded + " leaves, -" - + totalLeavesRemoved + " removed, ~" + totalNormalized + " normalized, " - + totalUnreachableAfter + " unreachable"); - } - - private static IrisObject loadTarget(Target t) throws IOException { - if (t.file() != null) { - IrisObject o = new IrisObject(); - o.read(t.file()); - o.setLoadFile(t.file()); - return o; - } - return IrisData.loadAnyObject(t.key(), null); - } - @Director(description = "Convert .schem files in the 'convert' folder to .iob files.") public void convert () { try { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java index aa8fc1a8f..a5642ebb4 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java @@ -21,18 +21,23 @@ package art.arcane.iris.core.commands; import art.arcane.iris.Iris; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.structure.BulkStructureImporter; +import art.arcane.iris.core.structure.NativeStructureLocateCapability; import art.arcane.iris.core.structure.StructureCaptureImporter; import art.arcane.iris.core.structure.StructureImporter; import art.arcane.iris.core.structure.StructureIndexService; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.PlacedStructurePiece; import art.arcane.iris.engine.framework.StructureAssembler; import art.arcane.iris.engine.framework.StructureReachability; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.engine.object.ObjectPlaceMode; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.common.director.DirectorExecutor; @@ -158,14 +163,23 @@ public class CommandStructure implements DirectorExecutor { private void runVerification(Engine engine, KList structureKeys, int centerX, int centerZ, int searchRadius, VolmitSender commandSender, Player target) { KList messages = new KList<>(); - Set reachable; - try { - reachable = StructureReachability.reachableKeys(engine); - } catch (Throwable error) { - messages.add(C.RED + "Structure verification could not resolve native biome reachability."); - sendVerificationMessages(commandSender, target, messages); - Iris.reportError("Could not resolve native structure biome reachability for verification.", error); - return; + Map decisions = new HashMap<>(structureKeys.size()); + boolean requiresNativeReachability = false; + for (String keyName : structureKeys) { + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, keyName, false); + decisions.put(keyName, decision); + requiresNativeReachability |= decision.generate(); + } + Set reachable = Set.of(); + if (requiresNativeReachability) { + try { + reachable = StructureReachability.reachableKeys(engine); + } catch (Throwable error) { + messages.add(C.RED + "Structure verification could not resolve native biome reachability."); + sendVerificationMessages(commandSender, target, messages); + Iris.reportError("Could not resolve native structure biome reachability for verification.", error); + return; + } } int located = 0; int nativeEligible = 0; @@ -175,8 +189,8 @@ public class CommandStructure implements DirectorExecutor { int searchLimited = 0; int errors = 0; for (String keyName : structureKeys) { - boolean irisPlaced = IrisStructureLocator.isPlaced(engine, keyName); - if (irisPlaced) { + IrisNativeStructureDecision decision = decisions.get(keyName); + if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { try { IrisStructureLocator.LocateResult result = IrisStructureLocator.locate(engine, keyName, centerX, centerZ, searchRadius); @@ -201,7 +215,7 @@ public class CommandStructure implements DirectorExecutor { } continue; } - if (!engine.getDimension().getImportedStructures().shouldGenerate(keyName)) { + if (!decision.generate()) { disabled++; messages.add(C.GRAY + "[disabled] " + C.WHITE + keyName); continue; @@ -213,20 +227,20 @@ public class CommandStructure implements DirectorExecutor { + (missing.isEmpty() ? "" : C.YELLOW + " needs " + String.join("/", missing))); continue; } - if (BukkitNativeStructureLocatePolicy.isUnavailable(keyName)) { + nativeEligible++; + if (NativeStructureLocateCapability.isPaperUnavailable(keyName)) { unavailable++; - messages.add(C.RED + "[unavailable] " + C.WHITE + keyName + C.RED + ": " - + BukkitNativeStructureLocatePolicy.unavailableMessage()); + messages.add(C.YELLOW + "[native-eligible/locate-unavailable] " + C.WHITE + keyName + C.YELLOW + ": " + + NativeStructureLocateCapability.unavailableMessage()); continue; } - nativeEligible++; messages.add(C.GREEN + "[native-eligible] " + C.WHITE + keyName); } messages.add(C.GREEN + "Structure verify: " + C.WHITE + located + C.GREEN + " Iris placements located, " + C.WHITE + nativeEligible + C.GREEN + " native structures eligible, " + C.WHITE + disabled + C.GREEN + " disabled by policy, " + C.WHITE + unreachable + C.GREEN + " biome-unreachable, " - + C.WHITE + unavailable + C.GREEN + " unavailable on this platform, " + + C.WHITE + unavailable + C.GREEN + " generation-eligible but unavailable to synchronous locate, " + C.WHITE + searchLimited + C.GREEN + " density searches safety-limited, " + C.WHITE + errors + C.GREEN + " errors. Native eligibility is checked without running blocking live locates."); sendVerificationMessages(commandSender, target, messages); @@ -285,12 +299,13 @@ public class CommandStructure implements DirectorExecutor { sender().sendMessage(C.RED + "Could not resolve the pack for dimension " + dimension.getLoadKey()); return; } - IrisStructure s = IrisData.loadAnyStructure(structure, data); + IrisStructure s = data.load(IrisStructure.class, structure, false); if (s == null) { sender().sendMessage(C.RED + "No iris structure '" + structure + "' in this pack"); return; } - StructureAssembler assembler = new StructureAssembler(data, s, 0, 64, 0); + StructureAssembler assembler = StructureAssembler.forData( + data, s, new IrisPosition(0, 64, 0)); KList pieces = assembler.assemble(new RNG(1234)); if (pieces == null || pieces.isEmpty()) { sender().sendMessage(C.RED + "Structure '" + structure + "' assembled 0 pieces (check startPool '" + s.getStartPool() + "')"); @@ -321,13 +336,14 @@ public class CommandStructure implements DirectorExecutor { sender().sendMessage(C.RED + "Could not resolve the pack for dimension " + dimension.getLoadKey()); return; } - IrisStructure s = IrisData.loadAnyStructure(structure, data); + IrisStructure s = data.load(IrisStructure.class, structure, false); if (s == null) { sender().sendMessage(C.RED + "No iris structure '" + structure + "' in this pack"); return; } Location loc = player().getLocation(); - StructureAssembler assembler = new StructureAssembler(data, s, loc.getBlockX(), loc.getBlockY(), loc.getBlockZ()); + StructureAssembler assembler = StructureAssembler.forData( + data, s, new IrisPosition(loc.getBlockX(), loc.getBlockY(), loc.getBlockZ())); RNG rng = new RNG((long) loc.getBlockX() * 341873128712L + loc.getBlockZ()); KList pieces = assembler.assemble(rng); if (pieces == null || pieces.isEmpty()) { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index a11192dc7..2403b3bfb 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -26,6 +26,7 @@ import art.arcane.iris.core.gui.NoiseExplorerGUI; import art.arcane.iris.core.gui.VisionGUI; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.project.IrisProject; +import art.arcane.iris.core.service.BoardSVC; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.structure.BulkStructureImporter; import art.arcane.iris.core.structure.FeatureImporter; @@ -193,6 +194,25 @@ public class CommandStudio implements DirectorExecutor { })); } + @Director(description = "Toggle your Studio debug scoreboard", aliases = {"board", "sidebar", "sb"}, origin = DirectorOrigin.PLAYER) + public void scoreboard() { + Player target = player(); + VolmitSender commandSender = sender(); + if (!J.runEntity(target, () -> { + PlatformChunkGenerator generator = IrisToolbelt.access(target.getWorld()); + if (generator == null || !generator.isStudio()) { + commandSender.sendMessage(C.RED + "You must be in a Studio world to toggle the debug scoreboard."); + return; + } + + boolean visible = Iris.service(BoardSVC.class).toggle(target); + commandSender.sendMessage((visible ? C.GREEN : C.YELLOW) + + "Studio debug scoreboard " + (visible ? "enabled." : "disabled.")); + })) { + commandSender.sendMessage(C.RED + "Could not update the Studio debug scoreboard right now."); + } + } + @Director(description = "Create a new studio project", aliases = "+", sync = true) public void create( @Param(description = "The name of this new Iris Project.", defaultValue = "studio") @@ -259,7 +279,7 @@ public class CommandStudio implements DirectorExecutor { Inventory inv = Bukkit.createInventory(null, 27 * 2); try { - EngineBukkitOps.addItems(engine(), true, inv, RNG.r, tables, InventorySlotType.STORAGE, player().getWorld(), player().getLocation().getBlockX(), player().getLocation().getBlockY(), player().getLocation().getBlockZ(), 1); + EngineBukkitOps.addItems(engine(), true, inv, tables, InventorySlotType.STORAGE, player().getWorld(), player().getLocation().getBlockX(), player().getLocation().getBlockY(), player().getLocation().getBlockZ()); } catch (Throwable e) { Iris.reportError(e); sender().sendMessage(C.RED + "Cannot add items to virtual inventory because of: " + e.getMessage()); @@ -286,7 +306,7 @@ public class CommandStudio implements DirectorExecutor { inv.clear(); } - EngineBukkitOps.addItems(engine, true, inv, new RNG(RNG.r.imax()), tables, InventorySlotType.STORAGE, player.getWorld(), player.getLocation().getBlockX(), player.getLocation().getBlockY(), player.getLocation().getBlockZ(), 1); + EngineBukkitOps.addItems(engine, true, inv, tables, InventorySlotType.STORAGE, player.getWorld(), player.getLocation().getBlockX(), player.getLocation().getBlockY(), player.getLocation().getBlockZ()); }, fast ? 5 : 35)); sender().sendMessage(C.GREEN + "Opening inventory now!"); @@ -829,7 +849,7 @@ public class CommandStudio implements DirectorExecutor { sender().sendMessage(C.RED + "No studio world is open!"); return true; } - if (!engine().isStudio()) { + if (!IrisToolbelt.isStudio(world())) { sender().sendMessage(C.RED + "You must be in a studio world!"); return true; } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/BukkitNativeStructureLocatePolicyTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/BukkitNativeStructureLocatePolicyTest.java deleted file mode 100644 index b02d831f6..000000000 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/BukkitNativeStructureLocatePolicyTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package art.arcane.iris.core.commands; - -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class BukkitNativeStructureLocatePolicyTest { - @Test - public void nativeMonumentLocateIsUnavailable() { - assertTrue(BukkitNativeStructureLocatePolicy.isUnavailable("minecraft:monument")); - assertTrue(BukkitNativeStructureLocatePolicy.isUnavailable(" MINECRAFT:MONUMENT ")); - } - - @Test - public void unrelatedAndNonCanonicalKeysRemainAvailable() { - assertFalse(BukkitNativeStructureLocatePolicy.isUnavailable(null)); - assertFalse(BukkitNativeStructureLocatePolicy.isUnavailable("")); - assertFalse(BukkitNativeStructureLocatePolicy.isUnavailable("minecraft:stronghold")); - assertFalse(BukkitNativeStructureLocatePolicy.isUnavailable("minecraft:ocean_monument")); - assertFalse(BukkitNativeStructureLocatePolicy.isUnavailable("minecraft:ocean_monuments")); - } - - @Test - public void rejectionExplainsSafetyAndGenerationBehavior() { - String message = BukkitNativeStructureLocatePolicy.unavailableMessage().toLowerCase(); - - assertTrue(message.contains("unavailable")); - assertTrue(message.contains("stall the server thread")); - assertTrue(message.contains("generation is unaffected")); - } -} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java index 81e61efc2..500dc2fd2 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java @@ -1,11 +1,14 @@ package art.arcane.iris.core.commands; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import org.junit.Test; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -21,6 +24,47 @@ public class IrisStructureLocateCommandContractTest { assertFalse(source.contains("at[2] + 8")); } + @Test + public void findRoutesOnlyExplicitNativeReplacementThroughIrisLocate() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.commandFindSource"))); + int methodStart = source.indexOf("public void structure("); + int methodEnd = source.indexOf("private static Structure resolveNativeStructure", methodStart); + String method = source.substring(methodStart, methodEnd); + int nativeResolution = method.indexOf("resolveNativeStructure(structureKey)"); + int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(", nativeResolution); + int replacementCheck = method.indexOf( + "decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution); + int replacementLocate = method.indexOf("locateIrisStructure(e, structureKey, commandSender)", replacementCheck); + int capabilityCheck = method.indexOf("NativeStructureLocateCapability.isPaperUnavailable(structureKey)", replacementLocate); + int genericIrisLookup = method.indexOf( + "nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)", capabilityCheck); + int nativeLocate = method.indexOf("targetWorld.locateNearestStructure(", genericIrisLookup); + assertTrue(nativeResolution >= 0); + assertTrue(policyResolution > nativeResolution); + assertTrue(replacementCheck > policyResolution); + assertTrue(replacementLocate > replacementCheck); + assertTrue(capabilityCheck > replacementLocate); + assertTrue(genericIrisLookup > capabilityCheck); + assertTrue(nativeLocate > policyResolution); + assertTrue(method.contains("decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS")); + } + + @Test + public void findNativePolicyMessagesMatchModdedDiagnostics() { + assertEquals( + "Native structure minecraft:village_plains is disabled by this dimension's importedStructures settings.", + NativeStructureGenerationPolicy.generationStatusMessage( + "minecraft:village_plains", NativeStructureGenerationStatus.DISABLED_BY_PACK)); + assertEquals( + "Native structure minecraft:ancient_city is replaced by an Iris placement in this pack and locates through that explicit replacement.", + NativeStructureGenerationPolicy.generationStatusMessage( + "minecraft:ancient_city", NativeStructureGenerationStatus.REPLACED_BY_IRIS)); + assertEquals( + "Native structure minecraft:stronghold generates natively.", + NativeStructureGenerationPolicy.generationStatusMessage( + "minecraft:stronghold", NativeStructureGenerationStatus.GENERATE_NATIVE)); + } + @Test public void structureVerifyReportsDensityLimitAndUsesExactLocatedOrigin() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.commandStructureSource"))); @@ -31,4 +75,21 @@ public class IrisStructureLocateCommandContractTest { assertFalse(source.contains("at[0] + 8")); assertFalse(source.contains("at[2] + 8")); } + + @Test + public void structureVerifyPartitionsPolicyBeforeNativeReachability() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.commandStructureSource"))); + int methodStart = source.indexOf("private void runVerification("); + int methodEnd = source.indexOf("private void sendVerificationMessages(", methodStart); + String method = source.substring(methodStart, methodEnd); + int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, keyName, false)"); + int nativeRequirement = method.indexOf("requiresNativeReachability |= decision.generate()", policyResolution); + int reachabilityGuard = method.indexOf("if (requiresNativeReachability)", nativeRequirement); + int reachabilityLookup = method.indexOf("StructureReachability.reachableKeys(engine)", reachabilityGuard); + + assertTrue(policyResolution >= 0); + assertTrue(nativeRequirement > policyResolution); + assertTrue(reachabilityGuard > nativeRequirement); + assertTrue(reachabilityLookup > reachabilityGuard); + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/StudioScoreboardCommandTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/StudioScoreboardCommandTest.java new file mode 100644 index 000000000..6d6d88e28 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/StudioScoreboardCommandTest.java @@ -0,0 +1,25 @@ +package art.arcane.iris.core.commands; + +import art.arcane.volmlib.util.director.DirectorOrigin; +import art.arcane.volmlib.util.director.annotations.Director; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class StudioScoreboardCommandTest { + @Test + public void scoreboardCommandIsPlayerScopedAndEntityScheduled() throws NoSuchMethodException { + Method command = CommandStudio.class.getDeclaredMethod("scoreboard"); + Director director = command.getAnnotation(Director.class); + + assertEquals(DirectorOrigin.PLAYER, director.origin()); + assertFalse(director.sync()); + assertTrue(List.of(director.aliases()).contains("board")); + assertTrue(List.of(director.aliases()).contains("sidebar")); + } +} diff --git a/adapters/fabric/logs/2026-07-13-1.log.gz b/adapters/fabric/logs/2026-07-13-1.log.gz index 4b7fcf581..5dce6915b 100644 Binary files a/adapters/fabric/logs/2026-07-13-1.log.gz and b/adapters/fabric/logs/2026-07-13-1.log.gz differ diff --git a/adapters/fabric/logs/2026-07-13-2.log.gz b/adapters/fabric/logs/2026-07-13-2.log.gz index 90473b0bf..e065b084a 100644 Binary files a/adapters/fabric/logs/2026-07-13-2.log.gz and b/adapters/fabric/logs/2026-07-13-2.log.gz differ diff --git a/adapters/fabric/logs/2026-07-13-3.log.gz b/adapters/fabric/logs/2026-07-13-3.log.gz index 5dce6915b..302c0a9ca 100644 Binary files a/adapters/fabric/logs/2026-07-13-3.log.gz and b/adapters/fabric/logs/2026-07-13-3.log.gz differ diff --git a/adapters/fabric/logs/2026-07-13-4.log.gz b/adapters/fabric/logs/2026-07-13-4.log.gz index e065b084a..af4a81f38 100644 Binary files a/adapters/fabric/logs/2026-07-13-4.log.gz and b/adapters/fabric/logs/2026-07-13-4.log.gz differ diff --git a/adapters/fabric/logs/2026-07-13-5.log.gz b/adapters/fabric/logs/2026-07-13-5.log.gz index 302c0a9ca..d10c5927e 100644 Binary files a/adapters/fabric/logs/2026-07-13-5.log.gz and b/adapters/fabric/logs/2026-07-13-5.log.gz differ diff --git a/adapters/fabric/logs/2026-07-13-6.log.gz b/adapters/fabric/logs/2026-07-13-6.log.gz index af4a81f38..111bd6e80 100644 Binary files a/adapters/fabric/logs/2026-07-13-6.log.gz and b/adapters/fabric/logs/2026-07-13-6.log.gz differ diff --git a/adapters/fabric/logs/2026-07-13-7.log.gz b/adapters/fabric/logs/2026-07-13-7.log.gz deleted file mode 100644 index d10c5927e..000000000 Binary files a/adapters/fabric/logs/2026-07-13-7.log.gz and /dev/null differ diff --git a/adapters/fabric/logs/2026-07-14-1.log.gz b/adapters/fabric/logs/2026-07-14-1.log.gz new file mode 100644 index 000000000..f2591bc41 Binary files /dev/null and b/adapters/fabric/logs/2026-07-14-1.log.gz differ diff --git a/adapters/fabric/logs/2026-07-14-2.log.gz b/adapters/fabric/logs/2026-07-14-2.log.gz new file mode 100644 index 000000000..3acd9a201 Binary files /dev/null and b/adapters/fabric/logs/2026-07-14-2.log.gz differ diff --git a/adapters/fabric/logs/2026-07-14-3.log.gz b/adapters/fabric/logs/2026-07-14-3.log.gz new file mode 100644 index 000000000..1956ec139 Binary files /dev/null and b/adapters/fabric/logs/2026-07-14-3.log.gz differ diff --git a/adapters/fabric/logs/2026-07-14-4.log.gz b/adapters/fabric/logs/2026-07-14-4.log.gz new file mode 100644 index 000000000..307a91a81 Binary files /dev/null and b/adapters/fabric/logs/2026-07-14-4.log.gz differ diff --git a/adapters/fabric/logs/2026-07-15-1.log.gz b/adapters/fabric/logs/2026-07-15-1.log.gz new file mode 100644 index 000000000..3573eb521 Binary files /dev/null and b/adapters/fabric/logs/2026-07-15-1.log.gz differ diff --git a/adapters/fabric/logs/2026-07-15-2.log.gz b/adapters/fabric/logs/2026-07-15-2.log.gz new file mode 100644 index 000000000..a6da56c75 Binary files /dev/null and b/adapters/fabric/logs/2026-07-15-2.log.gz differ diff --git a/adapters/fabric/logs/2026-07-15-3.log.gz b/adapters/fabric/logs/2026-07-15-3.log.gz new file mode 100644 index 000000000..1eb638688 Binary files /dev/null and b/adapters/fabric/logs/2026-07-15-3.log.gz differ diff --git a/adapters/fabric/logs/2026-07-15-4.log.gz b/adapters/fabric/logs/2026-07-15-4.log.gz new file mode 100644 index 000000000..19698e44d Binary files /dev/null and b/adapters/fabric/logs/2026-07-15-4.log.gz differ diff --git a/adapters/fabric/logs/2026-07-15-5.log.gz b/adapters/fabric/logs/2026-07-15-5.log.gz new file mode 100644 index 000000000..e4308645f Binary files /dev/null and b/adapters/fabric/logs/2026-07-15-5.log.gz differ diff --git a/adapters/fabric/logs/2026-07-15-6.log.gz b/adapters/fabric/logs/2026-07-15-6.log.gz new file mode 100644 index 000000000..2e1b01d0c Binary files /dev/null and b/adapters/fabric/logs/2026-07-15-6.log.gz differ diff --git a/adapters/fabric/logs/2026-07-16-1.log.gz b/adapters/fabric/logs/2026-07-16-1.log.gz new file mode 100644 index 000000000..05bd1395f Binary files /dev/null and b/adapters/fabric/logs/2026-07-16-1.log.gz differ diff --git a/adapters/fabric/logs/2026-07-16-2.log.gz b/adapters/fabric/logs/2026-07-16-2.log.gz new file mode 100644 index 000000000..375fe476b Binary files /dev/null and b/adapters/fabric/logs/2026-07-16-2.log.gz differ diff --git a/adapters/fabric/logs/2026-07-16-3.log.gz b/adapters/fabric/logs/2026-07-16-3.log.gz new file mode 100644 index 000000000..8453c9881 Binary files /dev/null and b/adapters/fabric/logs/2026-07-16-3.log.gz differ diff --git a/adapters/fabric/logs/latest.log b/adapters/fabric/logs/latest.log index d3476ba9b..ec0f86bf1 100644 --- a/adapters/fabric/logs/latest.log +++ b/adapters/fabric/logs/latest.log @@ -1,9 +1,102 @@ -[21:25:27] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test' -[21:25:27] [Test worker/ERROR]: [worldcheck] server stop request failed +[05:44:33] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test' +[05:44:33] [Test worker/ERROR]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +java.lang.RuntimeException: cleanup failed + at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[05:44:33] [Test worker/ERROR]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +java.lang.RuntimeException: enable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) + Suppressed: java.lang.RuntimeException: cleanup failed + at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) + ... 42 more +[05:44:33] [Test worker/ERROR]: [worldcheck] server stop request failed java.lang.IllegalStateException: stop request failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:218) - at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:171) - at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:215) + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:235) + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:176) + at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:232) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) at java.base/java.lang.reflect.Method.invoke(Method.java:565) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) @@ -46,11 +139,11 @@ java.lang.IllegalStateException: stop request failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[21:25:27] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed +[05:44:33] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed java.lang.IllegalStateException: shutdown wait failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:244) - at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:186) - at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:242) + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:261) + at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:191) + at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:259) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) at java.base/java.lang.reflect.Method.invoke(Method.java:565) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) @@ -93,11 +186,57 @@ java.lang.IllegalStateException: shutdown wait failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[21:25:27] [Test worker/ERROR]: [worldcheck] check failed +[05:44:33] [Test worker/ERROR]: [worldcheck] check failed java.lang.IllegalStateException: check failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:204) - at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:166) - at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:202) + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:221) + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:171) + at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:219) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[05:44:33] [Test worker/INFO]: Iris registered custom content provider 'iris_discovery_success' +[05:44:33] [Test worker/ERROR]: Iris custom content provider discovery failed +java.lang.RuntimeException: provider init failed + at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) at java.base/java.lang.reflect.Method.invoke(Method.java:565) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricForcedDatapackSources.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricForcedDatapackSources.java index 6b911cc0f..a67f60f07 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricForcedDatapackSources.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricForcedDatapackSources.java @@ -21,28 +21,20 @@ package art.arcane.iris.fabric; import art.arcane.iris.modded.ModdedForcedDatapack; import net.minecraft.server.packs.repository.PackRepository; import net.minecraft.server.packs.repository.RepositorySource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.LinkedHashSet; +import java.util.Objects; import java.util.Set; public final class FabricForcedDatapackSources { - private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); - private FabricForcedDatapackSources() { } public static void attach(PackRepository repository) { - if (repository == null) { - return; - } - try { - Set merged = new LinkedHashSet<>(repository.sources); - merged.add(ModdedForcedDatapack.repositorySource()); - repository.sources = merged; - } catch (Throwable e) { - LOGGER.error("Iris failed to attach the forced startup datapack source to the server data pack repository", e); - } + PackRepository activeRepository = Objects.requireNonNull(repository, + "Iris cannot attach the forced startup datapack source to a null repository"); + Set merged = new LinkedHashSet<>(activeRepository.sources); + merged.add(ModdedForcedDatapack.repositorySource()); + activeRepository.sources = merged; } } diff --git a/adapters/forge/logs/2026-07-13-1.log.gz b/adapters/forge/logs/2026-07-13-1.log.gz deleted file mode 100644 index fe00e5f13..000000000 Binary files a/adapters/forge/logs/2026-07-13-1.log.gz and /dev/null differ diff --git a/adapters/forge/logs/2026-07-13-2.log.gz b/adapters/forge/logs/2026-07-13-2.log.gz index 78a0d89ea..a68c49bc4 100644 Binary files a/adapters/forge/logs/2026-07-13-2.log.gz and b/adapters/forge/logs/2026-07-13-2.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-3.log.gz b/adapters/forge/logs/2026-07-13-3.log.gz index ab3a4c968..fe00e5f13 100644 Binary files a/adapters/forge/logs/2026-07-13-3.log.gz and b/adapters/forge/logs/2026-07-13-3.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-4.log.gz b/adapters/forge/logs/2026-07-13-4.log.gz index 06ab7744d..78a0d89ea 100644 Binary files a/adapters/forge/logs/2026-07-13-4.log.gz and b/adapters/forge/logs/2026-07-13-4.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-5.log.gz b/adapters/forge/logs/2026-07-13-5.log.gz index 75f340a49..ab3a4c968 100644 Binary files a/adapters/forge/logs/2026-07-13-5.log.gz and b/adapters/forge/logs/2026-07-13-5.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-6.log.gz b/adapters/forge/logs/2026-07-13-6.log.gz index 1adc77fdd..06ab7744d 100644 Binary files a/adapters/forge/logs/2026-07-13-6.log.gz and b/adapters/forge/logs/2026-07-13-6.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-7.log.gz b/adapters/forge/logs/2026-07-13-7.log.gz index 456446564..75f340a49 100644 Binary files a/adapters/forge/logs/2026-07-13-7.log.gz and b/adapters/forge/logs/2026-07-13-7.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-8.log.gz b/adapters/forge/logs/2026-07-13-8.log.gz new file mode 100644 index 000000000..1adc77fdd Binary files /dev/null and b/adapters/forge/logs/2026-07-13-8.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-9.log.gz b/adapters/forge/logs/2026-07-13-9.log.gz new file mode 100644 index 000000000..456446564 Binary files /dev/null and b/adapters/forge/logs/2026-07-13-9.log.gz differ diff --git a/adapters/forge/logs/2026-07-14-2.log.gz b/adapters/forge/logs/2026-07-14-2.log.gz new file mode 100644 index 000000000..e0be73a9f Binary files /dev/null and b/adapters/forge/logs/2026-07-14-2.log.gz differ diff --git a/adapters/forge/logs/2026-07-14-3.log.gz b/adapters/forge/logs/2026-07-14-3.log.gz new file mode 100644 index 000000000..3b39c09ce Binary files /dev/null and b/adapters/forge/logs/2026-07-14-3.log.gz differ diff --git a/adapters/forge/logs/2026-07-15-10.log.gz b/adapters/forge/logs/2026-07-15-10.log.gz new file mode 100644 index 000000000..d91df97ee Binary files /dev/null and b/adapters/forge/logs/2026-07-15-10.log.gz differ diff --git a/adapters/forge/logs/2026-07-15-11.log.gz b/adapters/forge/logs/2026-07-15-11.log.gz new file mode 100644 index 000000000..82438f88d Binary files /dev/null and b/adapters/forge/logs/2026-07-15-11.log.gz differ diff --git a/adapters/forge/logs/2026-07-15-12.log.gz b/adapters/forge/logs/2026-07-15-12.log.gz new file mode 100644 index 000000000..5b7d15e90 Binary files /dev/null and b/adapters/forge/logs/2026-07-15-12.log.gz differ diff --git a/adapters/forge/logs/2026-07-15-2.log.gz b/adapters/forge/logs/2026-07-15-2.log.gz new file mode 100644 index 000000000..6add339ae Binary files /dev/null and b/adapters/forge/logs/2026-07-15-2.log.gz differ diff --git a/adapters/forge/logs/2026-07-15-3.log.gz b/adapters/forge/logs/2026-07-15-3.log.gz new file mode 100644 index 000000000..0d7c3a0dd Binary files /dev/null and b/adapters/forge/logs/2026-07-15-3.log.gz differ diff --git a/adapters/forge/logs/2026-07-15-4.log.gz b/adapters/forge/logs/2026-07-15-4.log.gz new file mode 100644 index 000000000..20533f63d Binary files /dev/null and b/adapters/forge/logs/2026-07-15-4.log.gz differ diff --git a/adapters/forge/logs/2026-07-15-5.log.gz b/adapters/forge/logs/2026-07-15-5.log.gz new file mode 100644 index 000000000..229e3c92a Binary files /dev/null and b/adapters/forge/logs/2026-07-15-5.log.gz differ diff --git a/adapters/forge/logs/2026-07-15-6.log.gz b/adapters/forge/logs/2026-07-15-6.log.gz new file mode 100644 index 000000000..a50c6c7b2 Binary files /dev/null and b/adapters/forge/logs/2026-07-15-6.log.gz differ diff --git a/adapters/forge/logs/2026-07-15-7.log.gz b/adapters/forge/logs/2026-07-15-7.log.gz new file mode 100644 index 000000000..8f1dd1715 Binary files /dev/null and b/adapters/forge/logs/2026-07-15-7.log.gz differ diff --git a/adapters/forge/logs/2026-07-15-8.log.gz b/adapters/forge/logs/2026-07-15-8.log.gz new file mode 100644 index 000000000..b3e8766df Binary files /dev/null and b/adapters/forge/logs/2026-07-15-8.log.gz differ diff --git a/adapters/forge/logs/2026-07-15-9.log.gz b/adapters/forge/logs/2026-07-15-9.log.gz new file mode 100644 index 000000000..854f43e55 Binary files /dev/null and b/adapters/forge/logs/2026-07-15-9.log.gz differ diff --git a/adapters/forge/logs/2026-07-16-1.log.gz b/adapters/forge/logs/2026-07-16-1.log.gz new file mode 100644 index 000000000..0b5d20165 Binary files /dev/null and b/adapters/forge/logs/2026-07-16-1.log.gz differ diff --git a/adapters/forge/logs/2026-07-16-2.log.gz b/adapters/forge/logs/2026-07-16-2.log.gz new file mode 100644 index 000000000..6e318dc28 Binary files /dev/null and b/adapters/forge/logs/2026-07-16-2.log.gz differ diff --git a/adapters/forge/logs/2026-07-16-3.log.gz b/adapters/forge/logs/2026-07-16-3.log.gz new file mode 100644 index 000000000..b66383d9d Binary files /dev/null and b/adapters/forge/logs/2026-07-16-3.log.gz differ diff --git a/adapters/forge/logs/debug-1.log.gz b/adapters/forge/logs/debug-1.log.gz index c495e0d26..9bc216e76 100644 Binary files a/adapters/forge/logs/debug-1.log.gz and b/adapters/forge/logs/debug-1.log.gz differ diff --git a/adapters/forge/logs/debug-2.log.gz b/adapters/forge/logs/debug-2.log.gz index 6398dca63..b8467bbe0 100644 Binary files a/adapters/forge/logs/debug-2.log.gz and b/adapters/forge/logs/debug-2.log.gz differ diff --git a/adapters/forge/logs/debug-3.log.gz b/adapters/forge/logs/debug-3.log.gz index eb39f1229..d4536c195 100644 Binary files a/adapters/forge/logs/debug-3.log.gz and b/adapters/forge/logs/debug-3.log.gz differ diff --git a/adapters/forge/logs/debug-4.log.gz b/adapters/forge/logs/debug-4.log.gz index ecefbdaf3..1ca6f48af 100644 Binary files a/adapters/forge/logs/debug-4.log.gz and b/adapters/forge/logs/debug-4.log.gz differ diff --git a/adapters/forge/logs/debug-5.log.gz b/adapters/forge/logs/debug-5.log.gz index 2be6f7877..e6c45411a 100644 Binary files a/adapters/forge/logs/debug-5.log.gz and b/adapters/forge/logs/debug-5.log.gz differ diff --git a/adapters/forge/logs/debug.log b/adapters/forge/logs/debug.log index c3f67fff3..c6075567d 100644 --- a/adapters/forge/logs/debug.log +++ b/adapters/forge/logs/debug.log @@ -1,12 +1,105 @@ -[13Jul2026 21:25:26.811] [Test worker/DEBUG] [io.netty.util.internal.logging.InternalLoggerFactory/]: Using SLF4J as the default logging framework -[13Jul2026 21:25:26.813] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.level: simple -[13Jul2026 21:25:26.813] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.targetRecords: 4 -[13Jul2026 21:25:28.681] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' -[13Jul2026 21:25:28.758] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed +[16Jul2026 05:44:24.794] [Test worker/DEBUG] [io.netty.util.internal.logging.InternalLoggerFactory/]: Using SLF4J as the default logging framework +[16Jul2026 05:44:24.795] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.level: simple +[16Jul2026 05:44:24.795] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.targetRecords: 4 +[16Jul2026 05:44:26.716] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' +[16Jul2026 05:44:26.771] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +java.lang.RuntimeException: cleanup failed + at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] +[16Jul2026 05:44:26.779] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +java.lang.RuntimeException: enable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] + Suppressed: java.lang.RuntimeException: cleanup failed + at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] + ... 42 more +[16Jul2026 05:44:26.814] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed java.lang.IllegalStateException: stop request failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:218) ~[test/:?] - at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:171) ~[main/:?] - at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:215) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:235) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:176) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:232) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -49,11 +142,11 @@ java.lang.IllegalStateException: stop request failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[13Jul2026 21:25:28.765] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed +[16Jul2026 05:44:26.821] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed java.lang.IllegalStateException: shutdown wait failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:244) ~[test/:?] - at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:186) ~[main/:?] - at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:242) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:261) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:191) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:259) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -96,11 +189,57 @@ java.lang.IllegalStateException: shutdown wait failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[13Jul2026 21:25:28.769] [Test worker/ERROR] [Iris/]: [worldcheck] check failed +[16Jul2026 05:44:26.826] [Test worker/ERROR] [Iris/]: [worldcheck] check failed java.lang.IllegalStateException: check failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:204) ~[test/:?] - at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:166) ~[main/:?] - at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:202) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:221) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:171) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:219) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] +[16Jul2026 05:44:26.832] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' +[16Jul2026 05:44:26.833] [Test worker/ERROR] [Iris/]: Iris custom content provider discovery failed +java.lang.RuntimeException: provider init failed + at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] diff --git a/adapters/forge/logs/latest.log b/adapters/forge/logs/latest.log index bb52a3294..c04e3ae4a 100644 --- a/adapters/forge/logs/latest.log +++ b/adapters/forge/logs/latest.log @@ -1,9 +1,102 @@ -[13Jul2026 21:25:28.681] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' -[13Jul2026 21:25:28.758] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed +[16Jul2026 05:44:26.716] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' +[16Jul2026 05:44:26.771] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +java.lang.RuntimeException: cleanup failed + at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] +[16Jul2026 05:44:26.779] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +java.lang.RuntimeException: enable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] + Suppressed: java.lang.RuntimeException: cleanup failed + at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] + ... 42 more +[16Jul2026 05:44:26.814] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed java.lang.IllegalStateException: stop request failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:218) ~[test/:?] - at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:171) ~[main/:?] - at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:215) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:235) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:176) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:232) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -46,11 +139,11 @@ java.lang.IllegalStateException: stop request failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[13Jul2026 21:25:28.765] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed +[16Jul2026 05:44:26.821] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed java.lang.IllegalStateException: shutdown wait failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:244) ~[test/:?] - at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:186) ~[main/:?] - at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:242) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:261) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:191) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:259) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -93,11 +186,57 @@ java.lang.IllegalStateException: shutdown wait failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[13Jul2026 21:25:28.769] [Test worker/ERROR] [Iris/]: [worldcheck] check failed +[16Jul2026 05:44:26.826] [Test worker/ERROR] [Iris/]: [worldcheck] check failed java.lang.IllegalStateException: check failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:204) ~[test/:?] - at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:166) ~[main/:?] - at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:202) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:221) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:171) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:219) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] +[16Jul2026 05:44:26.832] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' +[16Jul2026 05:44:26.833] [Test worker/ERROR] [Iris/]: Iris custom content provider discovery failed +java.lang.RuntimeException: provider init failed + at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureGenerationException.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureGenerationException.java new file mode 100644 index 000000000..738613a3e --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureGenerationException.java @@ -0,0 +1,35 @@ +package art.arcane.iris.nativegen; + +public final class NativeStructureGenerationException extends IllegalStateException { + private NativeStructureGenerationException(FailureContext context, Throwable cause) { + super(message(context), cause); + } + + private NativeStructureGenerationException(FailureContext context) { + super(message(context)); + } + + public static NativeStructureGenerationException failure(String operation, String structureContext, + int chunkX, int chunkZ, Throwable cause) { + return new NativeStructureGenerationException( + new FailureContext(operation, structureContext, chunkX, chunkZ), cause); + } + + public static NativeStructureGenerationException failure(String operation, String structureContext, + int chunkX, int chunkZ) { + return new NativeStructureGenerationException( + new FailureContext(operation, structureContext, chunkX, chunkZ)); + } + + private static String message(FailureContext context) { + String resolvedContext = context.structureContext() == null || context.structureContext().isBlank() + ? "" + : context.structureContext(); + return "Iris native structure " + context.operation() + " failed for " + resolvedContext + + " in chunk " + context.chunkX() + "," + context.chunkZ() + + "; chunk generation was aborted to prevent partial structure state"; + } + + private record FailureContext(String operation, String structureContext, int chunkX, int chunkZ) { + } +} diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureLocateResults.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureLocateResults.java new file mode 100644 index 000000000..a4a64b29a --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureLocateResults.java @@ -0,0 +1,45 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.nativegen; + +import com.mojang.datafixers.util.Pair; +import net.minecraft.core.BlockPos; + +public final class NativeStructureLocateResults { + private NativeStructureLocateResults() { + } + + public static Pair nearest(BlockPos origin, Pair replacement, + Pair nativeResult) { + if (replacement == null) { + return nativeResult; + } + if (nativeResult == null) { + return replacement; + } + return horizontalDistanceSquared(origin, nativeResult.getFirst()) + <= horizontalDistanceSquared(origin, replacement.getFirst()) ? nativeResult : replacement; + } + + private static long horizontalDistanceSquared(BlockPos origin, BlockPos target) { + long dx = (long) target.getX() - origin.getX(); + long dz = (long) target.getZ() - origin.getZ(); + return dx * dx + dz * dz; + } +} diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java index 6cc2e6688..5a7b8f1f9 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java @@ -2,9 +2,12 @@ package art.arcane.iris.nativegen; import art.arcane.iris.engine.framework.StructureVerticalBounds; import art.arcane.iris.engine.object.IrisNativeStructureDecision; -import art.arcane.iris.engine.object.IrisVanillaStructureStiltSettings; +import art.arcane.iris.engine.object.IrisObjectVacuum; +import art.arcane.iris.engine.object.IrisStructureStiltSettings; import art.arcane.volmlib.util.math.RNG; +import com.mojang.datafixers.util.Either; import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; import net.minecraft.tags.BlockTags; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.StructureManager; @@ -20,15 +23,36 @@ import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; import net.minecraft.world.level.levelgen.structure.StructurePiece; import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; import net.minecraft.world.level.levelgen.structure.pools.JigsawJunction; +import net.minecraft.world.level.levelgen.structure.pools.LegacySinglePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement; +import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; +import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Arrays; import java.util.BitSet; import java.util.List; +import java.util.Objects; +import java.util.function.IntBinaryOperator; public final class NativeStructurePostProcessor { private static final int FOUNDATION_VERTICAL_TOLERANCE = 1; + private static final int MAX_BURIAL_COLUMNS = 2_000_000; + private static final int MONUMENT_BASE_BELOW_SEA_LEVEL = 24; + private static final String OCEAN_MONUMENT_ID = "minecraft:monument"; + private static final double SURFACE_TERRAIN_FALLOFF = 2.0; + private static final long SURFACE_TERRAIN_INFLUENCE_SCALE = 1_000_000L; + private static final int SURFACE_TERRAIN_RADIUS = 12; + private static final int UNDERGROUND_SURFACE_CLEARANCE = 1; private NativeStructurePostProcessor() { } @@ -37,38 +61,185 @@ public final class NativeStructurePostProcessor { WorldgenRandom random, BoundingBox area, ChunkPos chunkPos, String structureId, StructureStart start, IrisNativeStructureDecision decision, StiltBlockResolver stiltBlockResolver) { - IrisVanillaStructureStiltSettings stilt = decision.stilt(); + IrisStructureStiltSettings stilt = decision.stilt(); + ensureMonumentSeaLevelAlignment(start, structureId, decision.yShift(), generator.getSeaLevel(), + area.minY(), area.maxY() + 1); start.placeInChunk(world, structureManager, generator, random, area, chunkPos); if (stilt != null) { placeStilts(world, area, structureId, start, stilt, stiltBlockResolver); } } + public static int applyVerticalPlacement(StructureStart start, String structureId, int requestedOffset, + int seaLevel, int worldMinY, int worldMaxYExclusive, + boolean underground, IntBinaryOperator surfaceHeight) { + if (isOceanMonument(structureId)) { + return alignOceanMonumentToSeaLevel( + start, requestedOffset, seaLevel, worldMinY, worldMaxYExclusive); + } + return applyVerticalShift( + start, requestedOffset, worldMinY, worldMaxYExclusive, underground, surfaceHeight); + } + public static int applyVerticalShift(StructureStart start, int requestedOffset, int worldMinY, - int worldMaxYExclusive) { + int worldMaxYExclusive, boolean underground, + IntBinaryOperator surfaceHeight) { BoundingBox bounds = start.getBoundingBox(); + int resolvedOffset = underground + ? resolveBuriedOffset(bounds, requestedOffset, worldMinY, worldMaxYExclusive, surfaceHeight) + : requestedOffset; int offsetY = StructureVerticalBounds.clampOffset( - bounds.minY(), bounds.maxY(), requestedOffset, worldMinY, worldMaxYExclusive); + bounds.minY(), bounds.maxY(), resolvedOffset, worldMinY, worldMaxYExclusive); + if (underground && offsetY > resolvedOffset) { + throw new IllegalStateException("Underground native structure cannot fit below terrain and inside world bounds"); + } if (offsetY == 0) { return 0; } + moveStructureStart(start, bounds, offsetY); + return offsetY; + } + + static int alignOceanMonumentToSeaLevel(StructureStart start, int configuredOffset, int seaLevel, + int worldMinY, int worldMaxYExclusive) { + OceanMonumentPieces.MonumentBuilding building = requireOceanMonumentBuilding(start); + BoundingBox bounds = start.getBoundingBox(); + int targetMinY = Math.addExact( + Math.subtractExact(seaLevel, MONUMENT_BASE_BELOW_SEA_LEVEL), configuredOffset); + int requestedOffset = Math.subtractExact(targetMinY, building.getBoundingBox().minY()); + int offsetY = StructureVerticalBounds.clampOffset( + bounds.minY(), bounds.maxY(), requestedOffset, worldMinY, worldMaxYExclusive); + if (offsetY != requestedOffset) { + throw new IllegalStateException("Ocean monument cannot align to sea level " + seaLevel + + " with configured offset " + configuredOffset + " inside world bounds [" + + worldMinY + "," + worldMaxYExclusive + ")"); + } + if (offsetY == 0) { + return 0; + } + moveStructureStart(start, bounds, offsetY); + return offsetY; + } + + private static void ensureMonumentSeaLevelAlignment(StructureStart start, String structureId, + int configuredOffset, int seaLevel, + int worldMinY, int worldMaxYExclusive) { + if (isOceanMonument(structureId)) { + alignOceanMonumentToSeaLevel( + start, configuredOffset, seaLevel, worldMinY, worldMaxYExclusive); + } + } + + private static boolean isOceanMonument(String structureId) { + return OCEAN_MONUMENT_ID.equals(structureId); + } + + private static OceanMonumentPieces.MonumentBuilding requireOceanMonumentBuilding(StructureStart start) { + Objects.requireNonNull(start, "Ocean monument start must not be null"); + List pieces = start.getPieces(); + if (pieces.size() != 1 || !(pieces.get(0) instanceof OceanMonumentPieces.MonumentBuilding building)) { + throw new IllegalStateException("minecraft:monument must contain exactly one MonumentBuilding, found " + + pieces.size() + " top-level pieces"); + } + return building; + } + + private static void moveStructureStart(StructureStart start, BoundingBox cachedBounds, int offsetY) { for (StructurePiece piece : start.getPieces()) { - piece.move(0, offsetY, 0); - if (piece instanceof PoolElementStructurePiece poolPiece) { - List junctions = poolPiece.getJunctions(); - for (int i = 0; i < junctions.size(); i++) { - JigsawJunction junction = junctions.get(i); - junctions.set(i, new JigsawJunction( - junction.getSourceX(), - junction.getSourceGroundY() + offsetY, - junction.getSourceZ(), - junction.getDeltaY(), - junction.getDestProjection())); - } + moveStructurePiece(piece, offsetY); + } + cachedBounds.move(0, offsetY, 0); + } + + private static void moveStructurePiece(StructurePiece piece, int offsetY) { + piece.move(0, offsetY, 0); + if (piece instanceof OceanMonumentPieces.MonumentBuilding building) { + for (StructurePiece child : monumentChildPieces(building)) { + child.move(0, offsetY, 0); } } - bounds.move(0, offsetY, 0); - return offsetY; + if (piece instanceof PoolElementStructurePiece poolPiece) { + List junctions = poolPiece.getJunctions(); + for (int i = 0; i < junctions.size(); i++) { + JigsawJunction junction = junctions.get(i); + junctions.set(i, new JigsawJunction( + junction.getSourceX(), + junction.getSourceGroundY() + offsetY, + junction.getSourceZ(), + junction.getDeltaY(), + junction.getDestProjection())); + } + } + } + + static List monumentChildPieces(OceanMonumentPieces.MonumentBuilding building) { + Object value; + try { + value = MonumentChildPiecesAccess.FIELD.get(building); + } catch (IllegalAccessException error) { + throw new IllegalStateException("Cannot read Ocean Monument child pieces", error); + } + if (!(value instanceof List children)) { + throw new IllegalStateException("Ocean Monument child-pieces field is not a list"); + } + List pieces = new ArrayList<>(children.size()); + for (Object child : children) { + if (!(child instanceof StructurePiece monumentPiece) + || child.getClass().getEnclosingClass() != OceanMonumentPieces.class) { + throw new IllegalStateException("Ocean Monument child-pieces list contains " + + (child == null ? "null" : child.getClass().getName())); + } + pieces.add(monumentPiece); + } + return List.copyOf(pieces); + } + + private static Field resolveMonumentChildPiecesField() { + Field resolved = null; + for (Field field : OceanMonumentPieces.MonumentBuilding.class.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.getType() != List.class) { + continue; + } + if (resolved != null) { + throw new IllegalStateException("Ocean Monument has multiple instance List fields"); + } + resolved = field; + } + if (resolved == null) { + throw new IllegalStateException("Ocean Monument child-pieces List field is missing"); + } + if (!Modifier.isPrivate(resolved.getModifiers()) || !Modifier.isFinal(resolved.getModifiers())) { + throw new IllegalStateException("Ocean Monument child-pieces field has an unexpected access contract"); + } + if (!resolved.trySetAccessible()) { + throw new IllegalStateException("Ocean Monument child-pieces field is inaccessible"); + } + return resolved; + } + + static int resolveBuriedOffset(BoundingBox bounds, int requestedOffset, int worldMinY, + int worldMaxYExclusive, IntBinaryOperator surfaceHeight) { + Objects.requireNonNull(bounds, "Native structure bounds must not be null"); + Objects.requireNonNull(surfaceHeight, "Underground native structure requires a terrain height resolver"); + long width = (long) bounds.maxX() - bounds.minX() + 1L; + long depth = (long) bounds.maxZ() - bounds.minZ() + 1L; + if (width <= 0L || depth <= 0L || width > MAX_BURIAL_COLUMNS / depth) { + throw new IllegalStateException("Underground native structure burial footprint is invalid or exceeds " + + MAX_BURIAL_COLUMNS + " columns"); + } + int maximumOffset = requestedOffset; + for (int x = bounds.minX(); x <= bounds.maxX(); x++) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) { + int allowedTopY = surfaceHeight.applyAsInt(x, z) - UNDERGROUND_SURFACE_CLEARANCE; + maximumOffset = Math.min(maximumOffset, allowedTopY - bounds.maxY()); + } + } + int clampedOffset = StructureVerticalBounds.clampOffset( + bounds.minY(), bounds.maxY(), maximumOffset, worldMinY, worldMaxYExclusive); + if (clampedOffset > maximumOffset) { + throw new IllegalStateException("Underground native structure cannot be buried without crossing world minimum Y"); + } + return clampedOffset; } public static boolean isUndergroundStep(GenerationStep.Decoration step) { @@ -77,6 +248,365 @@ public final class NativeStructurePostProcessor { || step == GenerationStep.Decoration.STRONGHOLDS; } + public static boolean shouldClearEntireVegetationFootprint(GenerationStep.Decoration step, + boolean configured) { + return configured || !isUndergroundStep(step); + } + + public static void prepareSurfaceStructures(WorldGenLevel world, BoundingBox area, + List starts, + IntBinaryOperator surfaceHeight) { + if (starts == null || starts.isEmpty()) { + return; + } + Objects.requireNonNull(surfaceHeight, "Surface structure terrain fitting requires an Iris height resolver"); + List anchors = collectSurfaceAnchors(starts); + if (!anchors.isEmpty()) { + fitSurfaceTerrain(world, area, anchors, surfaceHeight); + } + StructureTemplateManager templateManager = world.getLevel().getStructureManager(); + for (StructureStart start : starts) { + if (requiresSurfaceTerrain(start)) { + clearLegacyTemplateAir(world, area, start, templateManager); + } + } + } + + static boolean shouldPrepareSurfaceTerrain(TerrainAdjustment adjustment, + GenerationStep.Decoration step) { + return adjustment == TerrainAdjustment.BEARD_THIN + && step == GenerationStep.Decoration.SURFACE_STRUCTURES; + } + + static int resolveSurfaceTarget(List anchors, int worldX, int worldZ, + int originalY) { + int localTargetY = originalY; + SurfaceAnchor selectedLocal = null; + long totalInfluence = 0L; + long weightedMeetY = 0L; + long maximumInfluence = 0L; + for (SurfaceAnchor anchor : anchors) { + int outX = IrisObjectVacuum.outset(worldX, anchor.minX(), anchor.maxX()); + int outZ = IrisObjectVacuum.outset(worldZ, anchor.minZ(), anchor.maxZ()); + long distanceSquared = (long) outX * outX + (long) outZ * outZ; + if (distanceSquared > (long) SURFACE_TERRAIN_RADIUS * SURFACE_TERRAIN_RADIUS) { + continue; + } + boolean containsColumn = outX == 0 && outZ == 0; + if (containsColumn) { + if (precedes(anchor, selectedLocal)) { + localTargetY = anchor.meetY(); + selectedLocal = anchor; + } + continue; + } + double factor = IrisObjectVacuum.columnInfluence( + worldX, worldZ, + anchor.minX(), anchor.maxX(), anchor.minZ(), anchor.maxZ(), + SURFACE_TERRAIN_RADIUS, SURFACE_TERRAIN_FALLOFF); + long influence = Math.round(factor * SURFACE_TERRAIN_INFLUENCE_SCALE); + if (influence <= 0L) { + continue; + } + long weightedInfluence = influence * Math.max(1, anchor.strength()); + totalInfluence += weightedInfluence; + weightedMeetY += weightedInfluence * anchor.meetY(); + maximumInfluence = Math.max(maximumInfluence, influence); + } + if (selectedLocal != null) { + return localTargetY; + } + if (totalInfluence == 0L) { + return originalY; + } + double blendedMeetY = weightedMeetY / (double) totalInfluence; + double factor = maximumInfluence / (double) SURFACE_TERRAIN_INFLUENCE_SCALE; + return (int) Math.round(originalY + ((blendedMeetY - originalY) * factor)); + } + + private static boolean precedes(SurfaceAnchor candidate, SurfaceAnchor selected) { + if (selected == null) { + return true; + } + if (candidate.strength() != selected.strength()) { + return candidate.strength() > selected.strength(); + } + if (candidate.meetY() != selected.meetY()) { + return candidate.meetY() < selected.meetY(); + } + if (candidate.minX() != selected.minX()) { + return candidate.minX() < selected.minX(); + } + if (candidate.minZ() != selected.minZ()) { + return candidate.minZ() < selected.minZ(); + } + if (candidate.maxX() != selected.maxX()) { + return candidate.maxX() < selected.maxX(); + } + if (candidate.maxZ() != selected.maxZ()) { + return candidate.maxZ() < selected.maxZ(); + } + return false; + } + + private static List collectSurfaceAnchors(List starts) { + List anchors = new ArrayList<>(); + for (StructureStart start : starts) { + if (!requiresSurfaceTerrain(start)) { + continue; + } + for (StructurePiece piece : start.getPieces()) { + if (piece instanceof PoolElementStructurePiece poolPiece) { + if (poolPiece.getElement().getProjection() == StructureTemplatePool.Projection.RIGID) { + BoundingBox bounds = poolPiece.getBoundingBox(); + anchors.add(new SurfaceAnchor( + bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(), + bounds.minY() + poolPiece.getGroundLevelDelta() - 1, 2)); + } + for (JigsawJunction junction : poolPiece.getJunctions()) { + anchors.add(new SurfaceAnchor( + junction.getSourceX(), junction.getSourceX(), + junction.getSourceZ(), junction.getSourceZ(), + junction.getSourceGroundY() - 1, 1)); + } + continue; + } + BoundingBox bounds = piece.getBoundingBox(); + anchors.add(new SurfaceAnchor( + bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(), + bounds.minY() - 1, 2)); + } + } + return List.copyOf(anchors); + } + + private static boolean requiresSurfaceTerrain(StructureStart start) { + return start != null + && start.isValid() + && shouldPrepareSurfaceTerrain( + start.getStructure().terrainAdaptation(), start.getStructure().step()); + } + + private static void fitSurfaceTerrain(WorldGenLevel world, BoundingBox area, + List anchors, + IntBinaryOperator surfaceHeight) { + int width = area.getXSpan(); + int depth = area.getZSpan(); + int[] originalHeights = new int[width * depth]; + int[] targetHeights = new int[width * depth]; + for (int z = area.minZ(); z <= area.maxZ(); z++) { + for (int x = area.minX(); x <= area.maxX(); x++) { + int column = (z - area.minZ()) * width + x - area.minX(); + int originalY = Math.max(area.minY(), Math.min( + area.maxY(), surfaceHeight.applyAsInt(x, z))); + originalHeights[column] = originalY; + targetHeights[column] = Math.max(area.minY(), Math.min( + area.maxY(), resolveSurfaceTarget(anchors, x, z, originalY))); + } + } + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (int z = area.minZ(); z <= area.maxZ(); z++) { + for (int x = area.minX(); x <= area.maxX(); x++) { + int column = (z - area.minZ()) * width + x - area.minX(); + applySurfaceColumn(world, position, x, z, + originalHeights[column], targetHeights[column], area.minY(), area.maxY()); + } + } + } + + static void applySurfaceColumn(WorldGenLevel world, BlockPos.MutableBlockPos position, + int x, int z, int originalY, int targetY, + int worldMinY, int worldMaxY) { + if (targetY == originalY) { + return; + } + SurfaceMaterials materials = resolveSurfaceMaterials(world, position, x, z, originalY, worldMinY); + if (targetY < originalY) { + BlockState clearedState = clearSurfaceDecorationAndResolveFill( + world, position, x, z, originalY, worldMaxY); + for (int y = originalY; y > targetY; y--) { + world.setBlock(position.set(x, y, z), clearedState, 2); + } + world.setBlock(position.set(x, targetY, z), materials.surface(), 2); + return; + } + for (int y = originalY + 1; y < targetY; y++) { + world.setBlock(position.set(x, y, z), materials.subsurface(), 2); + } + world.setBlock(position.set(x, targetY, z), materials.surface(), 2); + } + + private static BlockState clearSurfaceDecorationAndResolveFill( + WorldGenLevel world, BlockPos.MutableBlockPos position, + int x, int z, int originalY, int worldMaxY) { + BlockState air = Blocks.AIR.defaultBlockState(); + for (int y = originalY + 1; y <= worldMaxY; y++) { + BlockState state = world.getBlockState(position.set(x, y, z)); + if (state.isAir()) { + return air; + } + if (!state.getFluidState().isEmpty()) { + BlockState fluid = state.getFluidState().createLegacyBlock(); + if (state != fluid) { + world.setBlock(position, fluid, 2); + } + return fluid; + } + if (state.isSolid() || isTreeBlock(state)) { + return air; + } + world.setBlock(position, air, 2); + } + return air; + } + + private static SurfaceMaterials resolveSurfaceMaterials(WorldGenLevel world, + BlockPos.MutableBlockPos position, + int x, int z, int originalY, + int worldMinY) { + BlockState surface = world.getBlockState(position.set(x, originalY, z)); + BlockState subsurface = null; + for (int y = originalY - 1; y >= worldMinY; y--) { + BlockState candidate = world.getBlockState(position.set(x, y, z)); + if (isTerrainBlock(candidate)) { + subsurface = candidate; + break; + } + } + if (subsurface == null) { + subsurface = isTerrainBlock(surface) ? surface : Blocks.STONE.defaultBlockState(); + } + if (!isTerrainBlock(surface)) { + surface = subsurface; + } + return new SurfaceMaterials(surface, subsurface); + } + + private static boolean isTerrainBlock(BlockState state) { + return state.isSolid() && !isTreeBlock(state); + } + + private static void clearLegacyTemplateAir(WorldGenLevel world, BoundingBox area, + StructureStart start, + StructureTemplateManager templateManager) { + for (StructurePiece piece : start.getPieces()) { + if (!(piece instanceof PoolElementStructurePiece poolPiece) + || poolPiece.getElement().getProjection() != StructureTemplatePool.Projection.RIGID + || !intersects(poolPiece.getBoundingBox(), area)) { + continue; + } + int groundY = poolPiece.getBoundingBox().minY() + poolPiece.getGroundLevelDelta(); + StructurePlaceSettings settings = new StructurePlaceSettings() + .setRotation(poolPiece.getRotation()) + .setBoundingBox(area); + clearLegacyTemplateAir(world, poolPiece.getElement(), poolPiece.getPosition(), + groundY, settings, templateManager); + } + } + + private static void clearLegacyTemplateAir(WorldGenLevel world, StructurePoolElement element, + BlockPos position, int groundY, + StructurePlaceSettings settings, + StructureTemplateManager templateManager) { + if (element instanceof ListPoolElement listElement) { + for (StructurePoolElement child : listElement.getElements()) { + clearLegacyTemplateAir(world, child, position, groundY, settings, templateManager); + } + return; + } + if (!(element instanceof LegacySinglePoolElement legacyElement)) { + return; + } + StructureTemplate template = resolveTemplate(legacyElement, templateManager); + clearTemplateAir(world, template, position, groundY, settings); + } + + static void clearTemplateAir(WorldGenLevel world, StructureTemplate template, + BlockPos position, int groundY, + StructurePlaceSettings settings) { + List airBlocks = template.filterBlocks( + position, settings, Blocks.AIR); + BlockState air = Blocks.AIR.defaultBlockState(); + for (StructureTemplate.StructureBlockInfo airBlock : airBlocks) { + BlockPos airPosition = airBlock.pos(); + if (shouldClearLegacyAir( + airPosition.getY(), groundY, world.getBlockState(airPosition).isAir())) { + world.setBlock(airPosition, air, 2); + } + } + } + + static boolean shouldClearLegacyAir(int airY, int groundY, boolean existingAir) { + return airY >= groundY && !existingAir; + } + + static boolean intersects(BoundingBox first, BoundingBox second) { + return first.maxX() >= second.minX() && first.minX() <= second.maxX() + && first.maxY() >= second.minY() && first.minY() <= second.maxY() + && first.maxZ() >= second.minZ() && first.minZ() <= second.maxZ(); + } + + private static StructureTemplate resolveTemplate(SinglePoolElement element, + StructureTemplateManager templateManager) { + Object value; + try { + value = SinglePoolTemplateAccess.FIELD.get(element); + } catch (IllegalAccessException error) { + throw new IllegalStateException("Cannot read native structure pool template", error); + } + if (!(value instanceof Either reference)) { + throw new IllegalStateException("Native structure pool template field is not an Either"); + } + return resolveTemplateReference(reference, templateManager); + } + + static StructureTemplate resolveTemplateReference(Either reference, + StructureTemplateManager templateManager) { + return reference.map( + location -> resolveNamedTemplate(location, templateManager), + NativeStructurePostProcessor::requireRuntimeTemplate); + } + + private static StructureTemplate resolveNamedTemplate(Object value, + StructureTemplateManager templateManager) { + if (!(value instanceof Identifier identifier)) { + throw new IllegalStateException("Native structure pool template identifier is " + + (value == null ? "null" : value.getClass().getName())); + } + return templateManager.getOrCreate(identifier); + } + + private static StructureTemplate requireRuntimeTemplate(Object value) { + if (!(value instanceof StructureTemplate template)) { + throw new IllegalStateException("Native runtime structure pool template is " + + (value == null ? "null" : value.getClass().getName())); + } + return template; + } + + static Field resolveSinglePoolTemplateField() { + Field resolved = null; + for (Field field : SinglePoolElement.class.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.getType() != Either.class) { + continue; + } + if (resolved != null) { + throw new IllegalStateException("SinglePoolElement has multiple instance Either fields"); + } + resolved = field; + } + if (resolved == null) { + throw new IllegalStateException("SinglePoolElement template Either field is missing"); + } + if (!Modifier.isProtected(resolved.getModifiers()) || !Modifier.isFinal(resolved.getModifiers())) { + throw new IllegalStateException("SinglePoolElement template field has an unexpected access contract"); + } + if (!resolved.trySetAccessible()) { + throw new IllegalStateException("SinglePoolElement template field is inaccessible"); + } + return resolved; + } + public static void clearIntersectingVegetation(WorldGenLevel world, ChunkAccess chunk, BoundingBox area, List targets) { if (targets == null || targets.isEmpty()) { @@ -141,19 +671,25 @@ public final class NativeStructurePostProcessor { private static void markVegetationColumns(BoundingBox area, VegetationSnapshot snapshot, VegetationTarget target, boolean[] clearColumns) { int width = area.getXSpan(); + int padding = requiresSurfaceTerrain(target.start()) ? SURFACE_TERRAIN_RADIUS : 0; int[] pieceTops = new int[clearColumns.length]; Arrays.fill(pieceTops, Integer.MIN_VALUE); for (StructurePiece piece : target.start().getPieces()) { BoundingBox bounds = piece.getBoundingBox(); - int minX = Math.max(area.minX(), bounds.minX()); - int maxX = Math.min(area.maxX(), bounds.maxX()); - int minZ = Math.max(area.minZ(), bounds.minZ()); - int maxZ = Math.min(area.maxZ(), bounds.maxZ()); + int minX = Math.max(area.minX(), bounds.minX() - padding); + int maxX = Math.min(area.maxX(), bounds.maxX() + padding); + int minZ = Math.max(area.minZ(), bounds.minZ() - padding); + int maxZ = Math.min(area.maxZ(), bounds.maxZ() + padding); if (minX > maxX || minZ > maxZ) { continue; } for (int z = minZ; z <= maxZ; z++) { for (int x = minX; x <= maxX; x++) { + if (padding > 0) { + if (!withinSurfaceTerrainRadius(x, z, bounds, padding)) { + continue; + } + } int column = (z - area.minZ()) * width + x - area.minX(); pieceTops[column] = Math.max(pieceTops[column], bounds.maxY()); } @@ -169,6 +705,12 @@ public final class NativeStructurePostProcessor { } } + static boolean withinSurfaceTerrainRadius(int x, int z, BoundingBox bounds, int radius) { + int outX = IrisObjectVacuum.outset(x, bounds.minX(), bounds.maxX()); + int outZ = IrisObjectVacuum.outset(z, bounds.minZ(), bounds.maxZ()); + return (long) outX * outX + (long) outZ * outZ <= (long) radius * radius; + } + static boolean shouldClearVegetationColumn(int pieceTopY, int lowestTreeY, boolean force) { return force || pieceTopY >= lowestTreeY; } @@ -255,7 +797,7 @@ public final class NativeStructurePostProcessor { } private static void placeStilts(WorldGenLevel world, BoundingBox area, String structureId, - StructureStart start, IrisVanillaStructureStiltSettings settings, + StructureStart start, IrisStructureStiltSettings settings, StiltBlockResolver stiltBlockResolver) { BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); int structureHash = structureId == null ? 0 : structureId.hashCode(); @@ -273,12 +815,55 @@ public final class NativeStructurePostProcessor { if (existingState.isSolid() && !vegetation) { break; } - BlockState stilt = stiltBlockResolver.resolve(settings, rng, column.x(), y, column.z()); - world.setBlock(position, stilt == null ? Blocks.COBBLESTONE.defaultBlockState() : stilt, 2); + BlockState stilt = Objects.requireNonNull( + stiltBlockResolver.resolve(settings, rng, column.x(), y, column.z()), + "Stilt palette returned no block for " + structureId + " at " + + column.x() + "," + y + "," + column.z()); + world.setBlock(position, stilt, 2); } } } + public static StiltSupportAudit auditStiltSupport(WorldGenLevel world, BoundingBox area, + StructureStart start, BlockState expectedStilt) { + Objects.requireNonNull(expectedStilt, "Expected stilt state must not be null"); + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + int baseColumns = 0; + int stiltBlocks = 0; + int stiltColumns = 0; + int unsupportedColumns = 0; + for (FoundationColumn column : foundationEnvelope(area, start)) { + int foundationY = findFoundationY(world, column, position); + if (foundationY == Integer.MIN_VALUE) { + continue; + } + baseColumns++; + boolean grounded = foundationY == area.minY(); + boolean stiltColumn = false; + for (int y = foundationY - 1; y >= area.minY(); y--) { + BlockState state = world.getBlockState(position.set(column.x(), y, column.z())); + if (state.is(expectedStilt.getBlock())) { + stiltBlocks++; + stiltColumn = true; + if (y == area.minY()) { + grounded = true; + } + continue; + } + boolean vegetation = state.is(BlockTags.LOGS) || state.is(BlockTags.LEAVES); + grounded = state.isSolid() && !vegetation; + break; + } + if (stiltColumn) { + stiltColumns++; + } + if (!grounded) { + unsupportedColumns++; + } + } + return new StiltSupportAudit(baseColumns, stiltBlocks, stiltColumns, unsupportedColumns); + } + private static int findFoundationY(WorldGenLevel world, FoundationColumn column, BlockPos.MutableBlockPos position) { for (int cell = 0; cell < column.ys().length; cell++) { @@ -293,7 +878,7 @@ public final class NativeStructurePostProcessor { @FunctionalInterface public interface StiltBlockResolver { - BlockState resolve(IrisVanillaStructureStiltSettings settings, RNG rng, int x, int y, int z); + BlockState resolve(IrisStructureStiltSettings settings, RNG rng, int x, int y, int z); } private record FoundationColumn(int x, int z, int[] ys) { @@ -302,6 +887,30 @@ public final class NativeStructurePostProcessor { public record VegetationTarget(StructureStart start, boolean force) { } + public record StiltSupportAudit(int baseColumns, int stiltBlocks, int stiltColumns, + int unsupportedColumns) { + } + private record VegetationSnapshot(BitSet[] columns, int[] lowestY, int treeBlockCount) { } + + record SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength) { + } + + private record SurfaceMaterials(BlockState surface, BlockState subsurface) { + } + + private static final class MonumentChildPiecesAccess { + private static final Field FIELD = resolveMonumentChildPiecesField(); + + private MonumentChildPiecesAccess() { + } + } + + private static final class SinglePoolTemplateAccess { + private static final Field FIELD = resolveSinglePoolTemplateField(); + + private SinglePoolTemplateAccess() { + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java index 3c41ecfa7..ffd88328c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java @@ -75,7 +75,7 @@ final class IrisModdedBiomeSource extends BiomeSource { if (registry == null) { throw new IllegalStateException("Iris cannot create structure state without the biome registry"); } - Set generatedBiomeKeys = exactStructureBiomeKeys(); + Set generatedBiomeKeys = requireConfiguredStructureBiomeKeys(exactStructureBiomeKeys()); Set missingBiomeKeys = new LinkedHashSet<>(generatedBiomeKeys); missingBiomeKeys.removeAll(registeredBiomeKeys(registry)); if (!missingBiomeKeys.isEmpty()) { @@ -101,21 +101,32 @@ final class IrisModdedBiomeSource extends BiomeSource { @Override protected Stream> collectPossibleBiomes() { + Set generatedBiomeKeys = requireConfiguredStructureBiomeKeys(exactStructureBiomeKeys()); Registry registry = biomeRegistry(); - if (registry == null) { - return serializedSource.possibleBiomes().stream(); - } - Set generatedBiomeKeys = possibleStructureBiomeKeys(); LinkedHashSet> possible = new LinkedHashSet<>(); - Holder fallback = fallbackHolder(registry); - registry.listElements().forEach((Holder.Reference reference) -> { - String key = holderKey(reference); - if (isGeneratedBiomeKey(key, generatedBiomeKeys)) { - possible.add(reference); + if (registry == null) { + for (Holder biome : serializedSource.possibleBiomes()) { + if (isGeneratedBiomeKey(holderKey(biome), generatedBiomeKeys)) { + possible.add(biome); + } } - }); - if (possible.isEmpty() && fallback != null) { - possible.add(fallback); + } else { + Set missingBiomeKeys = new LinkedHashSet<>(generatedBiomeKeys); + missingBiomeKeys.removeAll(registeredBiomeKeys(registry)); + if (!missingBiomeKeys.isEmpty()) { + throw new IllegalStateException("Iris structure biomes are not registered: " + + missingBiomeKeys); + } + registry.listElements().forEach((Holder.Reference reference) -> { + if (isGeneratedBiomeKey(holderKey(reference), generatedBiomeKeys)) { + possible.add(reference); + } + }); + } + if (possible.isEmpty()) { + String phase = registry == null ? "serialized biome bootstrap" : "biome registry"; + throw new IllegalStateException("Iris configured structure biomes are absent from the " + + phase + ": " + generatedBiomeKeys); } return possible.stream(); } @@ -220,31 +231,44 @@ final class IrisModdedBiomeSource extends BiomeSource { private Holder resolveSurfaceStructureBiome(Engine engine, int quartX, int quartZ, Climate.Sampler sampler) { Registry registry = biomeRegistry(); - if (!isReady(engine) || registry == null) { - return serializedSource.getNoiseBiome(quartX, 0, quartZ, sampler); + if (!isReady(engine)) { + throw new IllegalStateException("Iris structure biome lookup ran before engine binding"); + } + if (registry == null) { + throw new IllegalStateException("Iris structure biome lookup has no biome registry"); } IrisBiome irisBiome = engine.getComplex().getTrueBiomeStream().get(quartX << 2, quartZ << 2); - Holder resolved = irisBiome == null ? null : resolveHolder(registry, irisBiome.getVanillaDerivativeKey()); - return resolved == null ? serializedSource.getNoiseBiome(quartX, 0, quartZ, sampler) : resolved; + if (irisBiome == null) { + throw new IllegalStateException("Iris returned no surface structure biome at quart " + + quartX + "," + quartZ); + } + Holder resolved = resolveHolder(registry, irisBiome.getVanillaDerivativeKey()); + if (resolved == null) { + throw new IllegalStateException("Iris structure biome derivative '" + + irisBiome.getVanillaDerivativeKey() + "' is not registered at quart " + + quartX + "," + quartZ); + } + return resolved; } private Holder resolveStructureBiome(Engine engine, int quartX, int quartY, int quartZ, Climate.Sampler sampler) { Registry registry = biomeRegistry(); BiomeResolution resolution = resolveBiomeResolution(engine, quartX, quartY, quartZ); - if (resolution == null || registry == null) { - return serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler); + if (resolution == null) { + throw new IllegalStateException("Iris returned no structure biome at quart " + + quartX + "," + quartY + "," + quartZ); + } + if (registry == null) { + throw new IllegalStateException("Iris structure biome lookup has no biome registry"); } Holder resolved = resolveHolder(registry, resolution.irisBiome().getVanillaDerivativeKey()); - if (resolved == null && resolution.irisBiome().isCustom()) { - IrisBiomeCustom customBiome = resolution.irisBiome().getCustomBiome( - resolution.rng(), engine, resolution.blockX(), resolution.blockY(), resolution.blockZ()); - if (customBiome != null) { - resolved = resolveHolder(registry, engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT) - + ":" + customBiome.getId().toLowerCase(Locale.ROOT)); - } + if (resolved == null) { + throw new IllegalStateException("Iris structure biome derivative '" + + resolution.irisBiome().getVanillaDerivativeKey() + "' is not registered at block " + + resolution.blockX() + "," + resolution.blockY() + "," + resolution.blockZ()); } - return resolved == null ? fallbackBiome(registry, quartX, quartY, quartZ, sampler) : resolved; + return resolved; } private Holder resolveVisibleBiome(Engine engine, int quartX, int quartY, int quartZ, @@ -349,6 +373,13 @@ final class IrisModdedBiomeSource extends BiomeSource { return key != null && generatedBiomeKeys.contains(key.toLowerCase(Locale.ROOT)); } + static Set requireConfiguredStructureBiomeKeys(Set configuredBiomeKeys) { + if (configuredBiomeKeys == null || configuredBiomeKeys.isEmpty()) { + throw new IllegalStateException("Iris has no configured structure biomes"); + } + return configuredBiomeKeys; + } + static boolean isGuaranteedSurfaceBiome(int quartY, int minHeight) { int internalY = (quartY << 2) - minHeight; int caveSwitchY = Math.max(-8 - minHeight, 40); @@ -377,15 +408,15 @@ final class IrisModdedBiomeSource extends BiomeSource { if (cached != null) { return cached; } - LinkedHashSet possible = new LinkedHashSet<>(exactStructureBiomeKeys()); + Set possible = requireConfiguredStructureBiomeKeys(exactStructureBiomeKeys()); Registry registry = biomeRegistry(); - if (registry != null) { - Set registered = registeredBiomeKeys(registry); - Holder fallback = fallbackHolder(registry); - String fallbackKey = fallback == null ? null : holderKey(fallback); - if (fallbackKey != null && requiresPossibleBiomeFallback(possible, registered)) { - possible.add(fallbackKey); - } + if (registry == null) { + throw new IllegalStateException("Iris cannot resolve structure biomes without the biome registry"); + } + Set missing = new LinkedHashSet<>(possible); + missing.removeAll(registeredBiomeKeys(registry)); + if (!missing.isEmpty()) { + throw new IllegalStateException("Iris structure biomes are not registered: " + missing); } Set resolved = Set.copyOf(possible); possibleStructureBiomeKeys = resolved; @@ -393,34 +424,31 @@ final class IrisModdedBiomeSource extends BiomeSource { } private Set exactStructureBiomeKeys() { + IrisModdedChunkGenerator current = generator; + if (current == null) { + return Set.of(); + } + Engine engine = current.structureEngineOrNull(); + if (!isReady(engine)) { + return current.configuredStructureBiomeKeys(); + } LinkedHashSet possible = new LinkedHashSet<>(); - Engine engine = engineOrNull(); - if (isReady(engine)) { - String namespace = engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT); - for (IrisBiome irisBiome : engine.getAllBiomes()) { - String derivative = normalizeKey(irisBiome.getVanillaDerivativeKey()); - if (derivative != null) { - possible.add(derivative); - } - if (!irisBiome.isCustom()) { - continue; - } - for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - possible.add(namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT)); - } + String namespace = engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT); + for (IrisBiome irisBiome : engine.getAllBiomes()) { + String derivative = normalizeKey(irisBiome.getVanillaDerivativeKey()); + if (derivative != null) { + possible.add(derivative); + } + if (!irisBiome.isCustom()) { + continue; + } + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + possible.add(namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT)); } } - IrisModdedChunkGenerator current = generator; - if (current != null) { - possible.addAll(current.configuredStructureBiomeKeys()); - } return Set.copyOf(possible); } - static boolean requiresPossibleBiomeFallback(Set configured, Set registered) { - return configured.isEmpty() || !registered.containsAll(configured); - } - private static Set registeredBiomeKeys(Registry registry) { Set registered = new HashSet<>(); registry.listElements().forEach((Holder.Reference reference) -> { @@ -459,18 +487,6 @@ final class IrisModdedBiomeSource extends BiomeSource { return identifier == null ? null : identifier.toString().toLowerCase(Locale.ROOT); } - private static Holder fallbackHolder(Registry registry) { - if (registry == null) { - return null; - } - Holder plains = resolveHolder(registry, "minecraft:plains"); - if (plains != null) { - return plains; - } - return registry.listElements().findFirst().>map( - (Holder.Reference reference) -> reference).orElse(null); - } - private Holder fallbackBiome(Registry registry, int quartX, int quartY, int quartZ, Climate.Sampler sampler) { Holder plains = resolveHolder(registry, "minecraft:plains"); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index 96054f6ad..504a0fcaa 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -19,15 +19,19 @@ package art.arcane.iris.modded; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; -import art.arcane.iris.engine.object.IrisImportedStructureControl; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisNativeStructureDecision; -import art.arcane.iris.engine.object.IrisVanillaStructureStiltSettings; +import art.arcane.iris.engine.object.IrisStructureStiltSettings; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +import art.arcane.iris.nativegen.NativeStructureGenerationException; +import art.arcane.iris.nativegen.NativeStructureLocateResults; import art.arcane.iris.nativegen.NativeStructurePostProcessor; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; @@ -99,6 +103,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; @@ -208,11 +213,62 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { public synchronized void repoint(String pack, String packDimensionKey, long seed) { ServerLevel level = boundLevel(); if (level != null) { - ModdedWorldEngines.evict(level); + repointAndBind(level, pack, packDimensionKey, seed); + return; + } + applyUnboundConfiguration(pack, packDimensionKey, seed); + } + + synchronized void repointAndBind(ServerLevel level, String pack, String packDimensionKey, long seed) { + if (level.getChunkSource().getGenerator() != this) { + throw new IllegalArgumentException("ServerLevel does not use Iris generator '" + dimensionKey + "'"); + } + requireGlobalStructureGeneration( + level.getServer().getWorldGenSettings().options().generateStructures(), dimensionKey); + Engine replacement = ModdedWorldEngines.prepareReplacement(level, pack, packDimensionKey, seed); + try { + ModdedWorldEngines.installReplacement(level, replacement); + } catch (Throwable error) { + try { + ModdedWorldEngines.closeUnregistered(replacement); + } catch (Throwable cleanupError) { + error.addSuppressed(cleanupError); + } + if (error instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (error instanceof Error fatalError) { + throw fatalError; + } + throw new IllegalStateException("Iris generator '" + dimensionKey + "' failed to replace its engine", error); } this.activePack = pack; this.activeDimensionKey = packDimensionKey; this.seedOverride = seed; + this.engine = replacement; + this.configuredStructureBiomeKeys = null; + this.configuredPack = null; + this.engineBinding.reset(); + this.engineBinding.complete(replacement); + this.announced.set(false); + this.structureBiomeSource.clearCaches(); + this.worldCheckStructureShifts.clear(); + resetVanillaSpawnBiomes(); + } + + public synchronized void unbindEngine() { + ServerLevel level = boundLevel(); + unbindEngine(level); + } + + synchronized void unbindEngine(ServerLevel level) { + if (level != null) { + ModdedWorldEngines.evictOrThrow(level); + } + clearEngineBinding(); + } + + private void clearEngineBinding() { this.engine = null; this.configuredStructureBiomeKeys = null; this.configuredPack = null; @@ -223,11 +279,10 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { resetVanillaSpawnBiomes(); } - public synchronized void unbindEngine() { - ServerLevel level = boundLevel(); - if (level != null) { - ModdedWorldEngines.evict(level); - } + private void applyUnboundConfiguration(String pack, String packDimensionKey, long seed) { + this.activePack = pack; + this.activeDimensionKey = packDimensionKey; + this.seedOverride = seed; this.engine = null; this.configuredStructureBiomeKeys = null; this.configuredPack = null; @@ -265,33 +320,14 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { public Pair> findNearestMapStructure(ServerLevel level, HolderSet holders, BlockPos pos, int radius, boolean findUnexplored) { - Engine current = engineOrNull(); - if (current == null) { - return super.findNearestMapStructure(level, holders, pos, radius, findUnexplored); - } - try { - Pair> irisPlaced = findNearestIrisStructure( - level, holders, pos, Math.max(1, radius), current); - if (irisPlaced != null) { - return irisPlaced; - } - } catch (Throwable e) { - LOGGER.error("Iris-placed structure locate failed near {},{}", pos.getX(), pos.getZ(), e); - } - IrisImportedStructureControl control = current.getDimension().getImportedStructures(); - if (control == null || !control.active()) { - return null; - } - HolderSet reachable = filterReachableNativeStructures(level, holders, current, control); - if (reachable.size() == 0) { - return null; - } - try { - return super.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); - } catch (Throwable e) { - LOGGER.error("Native structure locate failed near {},{}", pos.getX(), pos.getZ(), e); - return null; - } + Engine current = engine(); + Pair> irisPlaced = findNearestIrisStructure( + level, holders, pos, Math.max(1, radius), findUnexplored, current); + HolderSet reachable = filterReachableNativeStructures(level, holders, current); + Pair> nativeLocated = reachable.size() == 0 + ? null + : super.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); + return NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated); } public boolean isNativeStructureReachable(Holder structure) { @@ -300,8 +336,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private Pair> findNearestIrisStructure(ServerLevel level, HolderSet holders, - BlockPos pos, int radius, + BlockPos pos, int radius, boolean findUnexplored, Engine current) { + if (findUnexplored) { + return null; + } Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); BlockPos best = null; Holder bestHolder = null; @@ -309,12 +348,19 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { for (Holder holder : holders) { Identifier id = registry.getKey(holder.value()); if (id == null) { + throw new IllegalStateException("Native structure locate received an unregistered structure holder"); + } + String structureId = id.toString(); + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(current, + structureId, NativeStructurePostProcessor.isUndergroundStep(holder.value().step())); + if (decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS) { continue; } IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( - current, id.toString(), pos.getX(), pos.getZ(), radius); + current, structureId, pos.getX(), pos.getZ(), radius); if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { - continue; + throw new IllegalStateException("Iris structure locate reached its safety limit for " + + structureId + " within " + radius + " chunks"); } if (!result.found()) { continue; @@ -332,28 +378,23 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } private HolderSet filterReachableNativeStructures(ServerLevel level, HolderSet holders, - Engine current, - IrisImportedStructureControl control) { - try { - Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); - List> kept = new ArrayList<>(holders.size()); - for (Holder holder : holders) { - Identifier id = registry.getKey(holder.value()); - String key = id == null ? null : id.toString(); - IrisNativeStructureDecision decision = control.resolve( - key, NativeStructurePostProcessor.isUndergroundStep(holder.value().step())); - if (key == null || !decision.generate() - || IrisStructureLocator.suppressesVanilla(current, key) - || !structureBiomeSource.isStructureReachable(holder)) { - continue; - } - kept.add(holder); + Engine current) { + Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + List> kept = new ArrayList<>(holders.size()); + for (Holder holder : holders) { + Identifier id = registry.getKey(holder.value()); + if (id == null) { + throw new IllegalStateException("Native structure filtering received an unregistered structure holder"); } - return kept.size() == holders.size() ? holders : HolderSet.direct(kept); - } catch (Throwable error) { - LOGGER.error("Iris could not resolve native structure biome reachability; native locate failed closed", error); - return HolderSet.direct(List.of()); + String key = id.toString(); + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(current, + key, NativeStructurePostProcessor.isUndergroundStep(holder.value().step())); + if (!decision.generate() || !structureBiomeSource.isStructureReachable(holder)) { + continue; + } + kept.add(holder); } + return kept.size() == holders.size() ? holders : HolderSet.direct(kept); } private ServerLevel boundLevel() { @@ -389,6 +430,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } private Engine bindEngine(ServerLevel level) { + try { + requireGlobalStructureGeneration( + level.getServer().getWorldGenSettings().options().generateStructures(), dimensionKey); + } catch (RuntimeException error) { + engineBinding.fail(error); + throw error; + } Engine cached = engine; if (cached != null && !cached.isClosed() && cached.getComplex() != null) { engineBinding.complete(cached); @@ -424,6 +472,15 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } } + static void requireGlobalStructureGeneration(boolean enabled, String dimensionKey) { + if (enabled) { + return; + } + throw new IllegalStateException("Iris generator '" + dimensionKey + + "' cannot bind while generate-structures=false; set generate-structures=true, restart the server, " + + "and deny individual structures through importedStructures.disabled"); + } + private Engine engineOrNull() { Engine cached = engine; if (cached != null) { @@ -437,7 +494,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } Engine structureEngineOrNull() { - return engineOrNull(); + engineBinding.throwIfFailed(dimensionKey); + Engine cached = engine; + if (cached != null && !cached.isClosed() && cached.getComplex() != null) { + return cached; + } + ServerLevel level = boundLevel(); + return level == null ? null : bindEngine(level); } Engine awaitStructureEngine() { @@ -523,6 +586,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { if (configuredPack != null) { return configuredPack; } + PackValidationRegistry.requireLoadable(activePack); File packDirectory = ModdedWorldEngines.resolvePack(activePack, activeDimensionKey); IrisData data = IrisData.get(packDirectory); IrisDimension dimension = data.getDimensionLoader().load(activeDimensionKey); @@ -807,19 +871,15 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, StructureManager structureManager) { - if (importedControl().active()) { - placeVanillaStructures(level, chunk, structureManager); - } + placeVanillaStructures(level, chunk, structureManager); } @Override public void createStructures(RegistryAccess registryAccess, ChunkGeneratorStructureState structureState, StructureManager structureManager, ChunkAccess chunk, StructureTemplateManager templateManager, ResourceKey levelKey) { - if (!importedControl().active()) { - return; - } + Engine current = engine(); Map previousStarts = new HashMap<>(chunk.getAllStarts()); super.createStructures(registryAccess, structureState, structureManager, chunk, templateManager, levelKey); - adjustGeneratedStructures(registryAccess, chunk, previousStarts); + adjustGeneratedStructures(registryAccess, chunk, previousStarts, current); } @Override @@ -828,9 +888,9 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } private void adjustGeneratedStructures(RegistryAccess registryAccess, ChunkAccess chunk, - Map previousStarts) { + Map previousStarts, Engine current) { Registry registry = registryAccess.lookupOrThrow(Registries.STRUCTURE); - IrisImportedStructureControl control = importedControl(); + ChunkPos chunkPos = chunk.getPos(); for (Map.Entry entry : chunk.getAllStarts().entrySet()) { Structure structure = entry.getKey(); StructureStart start = entry.getValue(); @@ -839,24 +899,37 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } Identifier id = registry.getKey(structure); String structureId = id == null ? null : id.toString(); - IrisNativeStructureDecision decision = control.resolve( - structureId, NativeStructurePostProcessor.isUndergroundStep(structure.step())); - if (!decision.generate() || IrisStructureLocator.suppressesVanilla(engine(), structureId)) { + if (structureId == null) { + throw NativeStructureGenerationException.failure( + "resolution", null, chunkPos.x(), chunkPos.z()); + } + boolean undergroundStep = NativeStructurePostProcessor.isUndergroundStep(structure.step()); + IrisNativeStructureDecision decision; + try { + decision = NativeStructureGenerationPolicy.resolve(current, + structureId, undergroundStep); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "policy resolution", structureId, chunkPos.x(), chunkPos.z(), error); + } + if (!decision.generate()) { chunk.setStartForStructure(structure, StructureStart.INVALID_START); continue; } int offsetY; try { - offsetY = NativeStructurePostProcessor.applyVerticalShift( + offsetY = NativeStructurePostProcessor.applyVerticalPlacement( start, + structureId, decision.yShift(), + getSeaLevel(), chunk.getMinY(), - chunk.getMinY() + chunk.getHeight()); - } catch (RuntimeException error) { - chunk.setStartForStructure(structure, StructureStart.INVALID_START); - LOGGER.error("Iris rejected native structure {} in chunk {},{} because its vertical bounds are invalid", - structureId, chunk.getPos().x(), chunk.getPos().z(), error); - continue; + chunk.getMinY() + chunk.getHeight(), + undergroundStep, + (x, z) -> current.getHeight(x, z, true) + current.getMinHeight()); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "vertical adjustment", structureId, chunkPos.x(), chunkPos.z(), error); } recordWorldCheckStructureShift(structureId, start.getChunkPos(), offsetY); } @@ -864,7 +937,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private void placeVanillaStructures(WorldGenLevel world, ChunkAccess chunk, StructureManager structureManager) { if (!structureManager.shouldGenerateStructures()) { - return; + ChunkPos disabledChunk = chunk.getPos(); + throw new IllegalStateException("Iris cannot generate native structures in chunk " + + disabledChunk.x() + "," + disabledChunk.z() + + " because generate-structures=false disables them outside the pack; set generate-structures=true, " + + "restart the server, and deny individual structures through importedStructures.disabled"); } ChunkPos chunkPos = chunk.getPos(); SectionPos sectionPos = SectionPos.of(chunkPos, world.getMinSectionY()); @@ -875,42 +952,61 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { long decorationSeed = random.setDecorationSeed(world.getSeed(), origin.getX(), origin.getZ()); BoundingBox area = writableArea(chunk); int steps = GenerationStep.Decoration.values().length; - IrisImportedStructureControl control = importedControl(); + Engine current = engine(); List placementGroups = new ArrayList<>(); + List nativeStarts = new ArrayList<>(); List vegetationTargets = new ArrayList<>(); for (int step = 0; step < steps; step++) { int index = 0; for (Structure structure : byStep.get(step)) { Identifier id = registry.getKey(structure); String structureId = id == null ? null : id.toString(); - IrisNativeStructureDecision decision = control.resolve( - structureId, NativeStructurePostProcessor.isUndergroundStep(structure.step())); - if (decision.generate() && !IrisStructureLocator.suppressesVanilla(engine(), structureId)) { - try { + if (structureId == null) { + throw NativeStructureGenerationException.failure( + "resolution", null, chunkPos.x(), chunkPos.z()); + } + try { + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(current, + structureId, NativeStructurePostProcessor.isUndergroundStep(structure.step())); + if (decision.generate()) { List starts = structureManager.startsForStructure(sectionPos, structure); if (!starts.isEmpty()) { List resolvedStarts = List.copyOf(starts); placementGroups.add(new NativePlacementGroup( structureId, decision, index, step, resolvedStarts)); + nativeStarts.addAll(resolvedStarts); + boolean clearEntireFootprint = NativeStructurePostProcessor + .shouldClearEntireVegetationFootprint( + structure.step(), decision.clearVegetation()); for (StructureStart start : resolvedStarts) { vegetationTargets.add(new NativeStructurePostProcessor.VegetationTarget( - start, decision.clearVegetation())); + start, clearEntireFootprint)); } } - } catch (Throwable e) { - LOGGER.error("Iris failed to resolve native structure {} in chunk {},{}", - structureId, chunkPos.x(), chunkPos.z(), e); } + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "resolution", structureId, chunkPos.x(), chunkPos.z(), error); } index++; } } + try { + NativeStructurePostProcessor.prepareSurfaceStructures( + world, area, nativeStarts, + (x, z) -> current.getHeight(x, z, true) + current.getMinHeight()); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "terrain integration", nativeStructureBatchContext(placementGroups), + chunkPos.x(), chunkPos.z(), error); + } try { NativeStructurePostProcessor.clearIntersectingVegetation( world, chunk, area, vegetationTargets); - } catch (Throwable e) { - LOGGER.error("Iris failed to clear vegetation from native structures in chunk {},{}", - chunkPos.x(), chunkPos.z(), e); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "vegetation cleanup", nativeStructureBatchContext(placementGroups), + chunkPos.x(), chunkPos.z(), error); } for (NativePlacementGroup group : placementGroups) { random.setFeatureSeed(decorationSeed, group.featureIndex(), group.step()); @@ -919,13 +1015,27 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { placeVanillaStructure(world, structureManager, random, area, chunkPos, group.structureId(), start, group.decision()); } - } catch (Throwable e) { - LOGGER.error("Iris failed to place native structure {} in chunk {},{}", - group.structureId(), chunkPos.x(), chunkPos.z(), e); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "placement", group.structureId(), chunkPos.x(), chunkPos.z(), error); } } } + private static String nativeStructureBatchContext(List placementGroups) { + if (placementGroups.isEmpty()) { + return ""; + } + StringBuilder context = new StringBuilder("["); + for (int i = 0; i < placementGroups.size(); i++) { + if (i > 0) { + context.append(", "); + } + context.append(placementGroups.get(i).structureId()); + } + return context.append(']').toString(); + } + private void placeVanillaStructure(WorldGenLevel world, StructureManager structureManager, WorldgenRandom random, BoundingBox area, ChunkPos chunkPos, String structureId, StructureStart start, @@ -978,22 +1088,19 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { return worldCheckStructureShifts.get(new NativeStructureStartKey(structureId, startChunk.pack())); } - private BlockState resolveStiltBlock(IrisVanillaStructureStiltSettings settings, RNG rng, + private BlockState resolveStiltBlock(IrisStructureStiltSettings settings, RNG rng, int x, int y, int z) { if (settings.getPalette() == null) { return Blocks.COBBLESTONE.defaultBlockState(); } PlatformBlockState platformState = settings.getPalette().get(rng, x, y, z, engine().getData()); if (platformState == null || !(platformState.nativeHandle() instanceof BlockState blockState)) { - return Blocks.COBBLESTONE.defaultBlockState(); + throw new IllegalStateException("Configured native structure stilt palette did not resolve a Minecraft block at " + + x + "," + y + "," + z); } return blockState; } - private IrisImportedStructureControl importedControl() { - return engine().getDimension().getImportedStructures(); - } - private BoundingBox writableArea(ChunkAccess chunk) { ChunkPos chunkPos = chunk.getPos(); int minX = chunkPos.getMinBlockX(); @@ -1128,6 +1235,19 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { future.completeExceptionally(error); } + void throwIfFailed(String dimensionKey) { + CompletableFuture current = future; + if (!current.isCompletedExceptionally()) { + return; + } + try { + current.join(); + } catch (CompletionException error) { + throw new IllegalStateException("Iris generator '" + dimensionKey + "' failed to bind", + error.getCause()); + } + } + void reset() { future = new CompletableFuture<>(); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java index 0550b60ea..a56e794ed 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java @@ -20,6 +20,7 @@ package art.arcane.iris.modded; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBlockData; import art.arcane.iris.engine.object.IrisBlockDrops; @@ -145,7 +146,7 @@ public final class ModdedBlockBreakHandler { for (IrisBlockDrops provider : providers) { replaceVanillaDrops |= provider.isReplaceVanillaDrops(); for (IrisLoot loot : provider.getDrops()) { - if (RNG.r.i(1, loot.getRarity()) != loot.getRarity()) { + if (!LootResolver.oneIn(RNG.r, loot.getRarity())) { continue; } ItemStack stack = ModdedItemTranslator.stack(loot, RNG.r, level); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDeathLoot.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDeathLoot.java index b15762a1d..06ac3f694 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDeathLoot.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDeathLoot.java @@ -38,7 +38,6 @@ import java.util.concurrent.ConcurrentHashMap; public final class ModdedDeathLoot { private static final String TAG_PREFIX = "iris_loot|"; - private static final int LOOT_RNG_SALT = 345911; private static final Set WARNED_TABLES = ConcurrentHashMap.newKeySet(); private static final Set WARNED_ENTITY_TYPES = ConcurrentHashMap.newKeySet(); @@ -112,8 +111,7 @@ public final class ModdedDeathLoot { } continue; } - RNG lootRng = new RNG(binding.seed()).nextParallelRNG(LOOT_RNG_SALT); - drops.addAll(ModdedItemTranslator.loot(table, lootRng, InventorySlotType.STORAGE, level, + drops.addAll(ModdedItemTranslator.loot(table, engine.getSeedManager().getLoot(), InventorySlotType.STORAGE, level, binding.spawnX(), binding.spawnY(), binding.spawnZ())); } return drops; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java index f22d8d3f8..49b9d6c8f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java @@ -68,7 +68,13 @@ public final class ModdedDimensionManager { private ModdedDimensionManager() { } - public static void bindAccess(ModdedServerAccess serverAccess) { + public static synchronized ModdedServerAccess bindAccess(ModdedServerAccess serverAccess) { + ModdedServerAccess previous = access; + access = serverAccess; + return previous; + } + + public static synchronized void restoreAccess(ModdedServerAccess serverAccess) { access = serverAccess; } @@ -110,12 +116,13 @@ public final class ModdedDimensionManager { } public static Handle create(MinecraftServer server, String dimensionId, String pack, String packDimensionKey, long seed) { + ModdedStartup.requirePackForWorldCreation(pack); ModdedServerAccess serverAccess = requireAccess(); synchronized (LOCK) { ResourceKey key = levelKey(dimensionId); Handle existing = HANDLES.get(dimensionId); if (existing != null && serverAccess.hasLevel(server, key)) { - existing.generator().repoint(pack, packDimensionKey, seed); + existing.generator().repointAndBind(existing.level(), pack, packDimensionKey, seed); Handle refreshed = new Handle(dimensionId, pack, packDimensionKey, seed, existing.level(), existing.generator()); HANDLES.put(dimensionId, refreshed); return refreshed; @@ -126,7 +133,7 @@ public final class ModdedDimensionManager { throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId + "': a non-Iris level with that id is already loaded"); } LOGGER.warn("Iris dimension '{}' is already present in the running server; reusing it", dimensionId); - generator.repoint(pack, packDimensionKey, seed); + generator.repointAndBind(present, pack, packDimensionKey, seed); Handle handle = new Handle(dimensionId, pack, packDimensionKey, seed, present, generator); HANDLES.put(dimensionId, handle); return handle; @@ -231,10 +238,6 @@ public final class ModdedDimensionManager { private static Holder resolveDimensionType(RegistryAccess registryAccess, String pack, String packDimensionKey) { Registry registry = registryAccess.lookupOrThrow(Registries.DIMENSION_TYPE); IrisDimension dimension = loadPackDimension(pack, packDimensionKey); - if (dimension == null) { - throw new IllegalStateException("Iris cannot resolve dimension type for missing pack dimension '" - + packDimensionKey + "' in pack '" + pack + "'"); - } String typeRef = ModdedForcedDatapack.dimensionTypeRef(dimension); ResourceKey typeKey = ResourceKey.create(Registries.DIMENSION_TYPE, Identifier.parse(typeRef)); Holder.Reference packType = ModdedForcedDatapack.requireRegisteredDimensionType( @@ -246,12 +249,22 @@ public final class ModdedDimensionManager { try { File packFolder = ModdedWorldEngines.packFolder(pack); if (!packFolder.isDirectory()) { - return null; + throw new IllegalStateException("Iris pack directory is missing: " + packFolder.getAbsolutePath()); } - return IrisData.get(packFolder).getDimensionLoader().load(packDimensionKey); + IrisDimension dimension = IrisData.get(packFolder).getDimensionLoader().load(packDimensionKey); + if (dimension == null) { + throw new IllegalStateException("Iris pack '" + pack + + "' does not contain dimension '" + packDimensionKey + "'"); + } + return dimension; } catch (Throwable e) { - LOGGER.warn("Iris could not load pack '{}' dimension '{}' for dimension type resolution: {}", pack, packDimensionKey, e.toString()); - return null; + LOGGER.error("Iris could not load pack '{}' dimension '{}' for dimension type resolution", + pack, packDimensionKey, e); + if (e instanceof Error fatalError) { + throw fatalError; + } + throw new IllegalStateException("Iris could not load pack '" + pack + "' dimension '" + + packDimensionKey + "' for dimension type resolution", e); } } @@ -285,10 +298,56 @@ public final class ModdedDimensionManager { List.of(), false); - serverAccess.putLevel(server, key, level); - generator.bindLevel(level); - server.getPlayerList().addWorldborderListener(level); - return new Handle(dimensionId, pack, packDimensionKey, seed, level, generator); + try { + generator.bindLevel(level); + Handle handle = new Handle(dimensionId, pack, packDimensionKey, seed, level, generator); + ServerLevel previous = serverAccess.putLevelIfAbsent(server, key, level); + if (previous != null) { + throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId + + "': the level was registered concurrently"); + } + server.getPlayerList().addWorldborderListener(level); + return handle; + } catch (Throwable error) { + rollbackInjection(server, serverAccess, key, level, generator, error); + if (error instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (error instanceof Error fatalError) { + throw fatalError; + } + throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, error); + } + } + + private static void rollbackInjection(MinecraftServer server, ModdedServerAccess serverAccess, + ResourceKey key, ServerLevel level, + IrisModdedChunkGenerator generator, Throwable failure) { + try { + if (serverAccess.hasLevel(server, key)) { + ServerLevel removed = serverAccess.removeLevel(server, key); + if (removed != null && removed != level) { + serverAccess.putLevel(server, key, removed); + throw new IllegalStateException("Iris injection rollback encountered another registered level for " + + key.identifier()); + } + } + } catch (Throwable cleanupError) { + failure.addSuppressed(cleanupError); + LOGGER.error("Iris failed to remove a partially injected level for {}", key.identifier(), cleanupError); + } + try { + generator.unbindEngine(level); + } catch (Throwable cleanupError) { + failure.addSuppressed(cleanupError); + LOGGER.error("Iris failed to close a partially bound engine for {}", key.identifier(), cleanupError); + } + try { + level.close(); + } catch (Throwable cleanupError) { + failure.addSuppressed(cleanupError); + LOGGER.error("Iris failed to close a partially injected level for {}", key.identifier(), cleanupError); + } } public static int evacuate(MinecraftServer server, ServerLevel from) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index b1f49740e..810731d6d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -42,6 +42,7 @@ import art.arcane.iris.modded.service.ModdedLogFilterService; import art.arcane.iris.modded.service.ModdedPreservationService; import art.arcane.iris.modded.service.ModdedSettingsHotloadService; import art.arcane.iris.modded.service.ModdedStudioHotloadService; +import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; import net.minecraft.core.BlockPos; @@ -53,6 +54,8 @@ import net.minecraft.world.level.storage.LevelData; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayDeque; + public final class ModdedEngineBootstrap { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final String[] CORE_SELF_TEST_CLASSES = { @@ -61,9 +64,9 @@ public final class ModdedEngineBootstrap { "art.arcane.iris.core.loader.IrisData" }; private static final Object LOCK = new Object(); - private static final ModdedServiceManager SERVICE_MANAGER = new ModdedServiceManager(); + private static final ModdedServiceManager UNBOUND_SERVICE_MANAGER = new ModdedServiceManager(); private static volatile ModdedLoader loader; - private static volatile ModdedPlatform platform; + private static volatile BoundRuntime runtime; private static volatile MinecraftServer currentServer; private static volatile MinecraftServer spawnCaptureServer; private static volatile boolean initialSpawnWasDefault; @@ -72,19 +75,20 @@ public final class ModdedEngineBootstrap { } public static ModdedServiceManager services() { - return SERVICE_MANAGER; + BoundRuntime bound = runtime; + return bound == null ? UNBOUND_SERVICE_MANAGER : bound.services(); } public static ModdedScheduler schedulerOrNull() { - ModdedPlatform bound = platform; - return bound == null ? null : bound.moddedScheduler(); + BoundRuntime bound = runtime; + return bound == null ? null : bound.platform().moddedScheduler(); } public static void tick(MinecraftServer server) { ModdedScheduler.tick(server); ModdedStartup.runOnce(server); ModdedPrimaryWorldRouter.tick(server); - SERVICE_MANAGER.tick(server); + services().tick(server); ModdedPregenBossBar.tick(server); ModdedProtocolHandler.tickDimensionSync(server); } @@ -93,14 +97,15 @@ public final class ModdedEngineBootstrap { captureInitialSpawn(server); currentServer = server; bind(); - bindWorldGenerators(server); - ModdedStartup.reset(); ModdedScheduler scheduler = schedulerOrNull(); if (scheduler != null) { scheduler.reset(); } + ModdedStartup.prepareForStartup(); IrisModdedChunkGenerator.startGenPool(); - SERVICE_MANAGER.enableAll(); + bindWorldGenerators(server); + ModdedStartup.runOnce(server); + services().enableAll(); ModdedProtocolHandler.start(server); ModdedSentry.start(loader()); } @@ -115,6 +120,7 @@ public final class ModdedEngineBootstrap { public static void serverAboutToStart(MinecraftServer server) { captureInitialSpawn(server); + ModdedStartup.prepareForStartup(); } public static void serverStarted(MinecraftServer server) { @@ -140,7 +146,7 @@ public final class ModdedEngineBootstrap { ModdedStudioCommands.clear(); ModdedWorldEngines.shutdown(); ModdedPrimaryWorldRouter.clear(); - SERVICE_MANAGER.disableAll(); + services().disableAll(); ModdedDimensionManager.clear(); ModdedScheduler scheduler = schedulerOrNull(); if (scheduler != null) { @@ -256,45 +262,141 @@ public final class ModdedEngineBootstrap { } public static ModdedPlatform bind() { - ModdedPlatform bound = platform; + BoundRuntime bound = runtime; if (bound != null) { - return bound; + return bound.platform(); } synchronized (LOCK) { - if (platform != null) { - return platform; + BoundRuntime synchronizedBound = runtime; + if (synchronizedBound != null) { + return synchronizedBound.platform(); } ModdedLoader boundLoader = loader(); - GuiHost.suppressDesktop(boundLoader.clientEnvironment()); ModdedPlatform created = new ModdedPlatform(boundLoader); - IrisPlatforms.bind(created); - ModdedDimensionManager.bindAccess(new ModdedServerLevels()); - IrisObjectRotation.bindFallbackRotator(new ModdedStateRotator()); - BlockDataMergeSupport.bindFallbackMerger(new ModdedStateMerger()); - TileData.bindFallbackReader(new ModdedTileReader(boundLoader::currentServer)); - TileData.bindFallbackFactory(ModdedTileData::fromProperties); - ModdedGuiHost.install(); - ModdedDecoratorHooks decoratorHooks = new ModdedDecoratorHooks(); - DecoratorPlatformHooks.bind(decoratorHooks, decoratorHooks); - ModdedPreservationService preservation = SERVICE_MANAGER.register(ModdedPreservationService.class, new ModdedPreservationService()); - SERVICE_MANAGER.register(ModdedLogFilterService.class, new ModdedLogFilterService()); - SERVICE_MANAGER.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService()); - SERVICE_MANAGER.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService()); - ModdedStudioHotloadService studioHotloadService = SERVICE_MANAGER.register(ModdedStudioHotloadService.class, new ModdedStudioHotloadService()); - SERVICE_MANAGER.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService()); - SERVICE_MANAGER.register(ModdedEntitySpawnService.class, new ModdedEntitySpawnService()); - IrisServices.register(PreservationRegistry.class, preservation); - IrisServices.register(EngineEffectsProvider.class, (EngineEffectsProvider) ModdedEngineEffects::new); - IrisServices.register(EnginePlatformHooks.class, studioHotloadService); - IrisServices.register(EngineWorldManagerProvider.class, (EngineWorldManagerProvider) (Engine engine) -> new ModdedWorldManager(engine)); - ModdedCustomContentRegistry.discover(); - platform = created; - SERVICE_MANAGER.enableAll(); - if (boundLoader.clientEnvironment()) { - ModdedStartup.prefetchDefaultPack(); + ModdedServiceManager createdServices = new ModdedServiceManager(); + BindRollback rollback = new BindRollback(); + try { + boolean previousDesktopSuppression = GuiHost.isDesktopSuppressed(); + GuiHost.suppressDesktop(boundLoader.clientEnvironment()); + rollback.add(() -> GuiHost.suppressDesktop(previousDesktopSuppression)); + + IrisPlatform previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.bind(created); + rollback.add(() -> restorePlatform(previousPlatform)); + + ModdedServerAccess previousServerAccess = ModdedDimensionManager.bindAccess(new ModdedServerLevels()); + rollback.add(() -> ModdedDimensionManager.restoreAccess(previousServerAccess)); + + IrisObjectRotation.StateRotator previousRotator = IrisObjectRotation.bindPlatformRotator(new ModdedStateRotator()); + rollback.add(() -> IrisObjectRotation.restorePlatformRotator(previousRotator)); + + BlockDataMergeSupport.StateMerger previousMerger = BlockDataMergeSupport.bindPlatformMerger(new ModdedStateMerger()); + rollback.add(() -> BlockDataMergeSupport.restorePlatformMerger(previousMerger)); + + TileData.TileReader previousTileReader = TileData.bindPlatformReader(new ModdedTileReader(boundLoader::currentServer)); + rollback.add(() -> TileData.restorePlatformReader(previousTileReader)); + + TileData.TileFactory previousTileFactory = TileData.bindPlatformFactory(ModdedTileData::fromProperties); + rollback.add(() -> TileData.restorePlatformFactory(previousTileFactory)); + + GuiHost.Provider previousGuiHost = GuiHost.get(); + ModdedGuiHost.install(); + rollback.add(() -> GuiHost.set(previousGuiHost)); + + ModdedDecoratorHooks decoratorHooks = new ModdedDecoratorHooks(); + DecoratorPlatformHooks.Bindings previousDecoratorBindings = DecoratorPlatformHooks.bind(decoratorHooks, decoratorHooks); + rollback.add(() -> DecoratorPlatformHooks.restore(previousDecoratorBindings)); + + ModdedPreservationService preservation = createdServices.register( + ModdedPreservationService.class, new ModdedPreservationService()); + createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService()); + createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService()); + createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService()); + ModdedStudioHotloadService studioHotloadService = createdServices.register( + ModdedStudioHotloadService.class, new ModdedStudioHotloadService()); + createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService()); + createdServices.register(ModdedEntitySpawnService.class, new ModdedEntitySpawnService()); + + bindService(PreservationRegistry.class, preservation, rollback); + bindService(EngineEffectsProvider.class, + (EngineEffectsProvider) ModdedEngineEffects::new, rollback); + bindService(EnginePlatformHooks.class, studioHotloadService, rollback); + bindService(EngineWorldManagerProvider.class, + (EngineWorldManagerProvider) (Engine engine) -> new ModdedWorldManager(engine), rollback); + + ModdedCustomContentRegistry.Discovery customContentDiscovery = + ModdedCustomContentRegistry.discover(); + rollback.add(customContentDiscovery::rollback); + ModdedIrisSplash.print(boundLoader); + createdServices.enableAll(); + runtime = new BoundRuntime(created, createdServices); + rollback.clear(); + return created; + } catch (Throwable failure) { + createdServices.rollback(failure); + rollback.restore(failure); + LOGGER.error("Iris modded platform binding failed", failure); + if (failure instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (failure instanceof Error fatalError) { + throw fatalError; + } + throw new IllegalStateException("Iris modded platform binding failed", failure); } - ModdedIrisSplash.print(boundLoader); - return created; + } + } + + private static void bindService(Class type, T service, BindRollback rollback) { + T previous = IrisServices.getOrNull(type); + IrisServices.register(type, service); + rollback.add(() -> restoreService(type, previous)); + } + + private static void restoreService(Class type, T service) { + if (service == null) { + IrisServices.remove(type); + return; + } + IrisServices.register(type, service); + } + + private static void restorePlatform(IrisPlatform platform) { + IrisPlatforms.unbind(); + if (platform != null) { + IrisPlatforms.bind(platform); + } + } + + private record BoundRuntime(ModdedPlatform platform, ModdedServiceManager services) { + } + + @FunctionalInterface + private interface RollbackAction { + void restore() throws Throwable; + } + + private static final class BindRollback { + private final ArrayDeque actions = new ArrayDeque<>(); + + void add(RollbackAction action) { + actions.push(action); + } + + void restore(Throwable failure) { + while (!actions.isEmpty()) { + try { + actions.pop().restore(); + } catch (Throwable rollbackFailure) { + if (rollbackFailure != failure) { + failure.addSuppressed(rollbackFailure); + } + } + } + } + + void clear() { + actions.clear(); } } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java index 4d68be056..e0597d393 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java @@ -20,6 +20,7 @@ package art.arcane.iris.modded; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.IrisAttributeModifier; import art.arcane.iris.engine.object.IrisEntity; import art.arcane.iris.engine.object.IrisEffect; @@ -241,7 +242,7 @@ public final class ModdedEntitySpawner { } private static void setSlot(LivingEntity living, EquipmentSlot slot, IrisLoot loot, ServerLevel level, RNG rng) { - if (loot == null || rng.i(1, loot.getRarity()) != 1) { + if (loot == null || !LootResolver.oneIn(rng, loot.getRarity())) { return; } ItemStack stack = ModdedItemTranslator.stack(loot, rng, level); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java index e9945cfa3..6b686d77b 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java @@ -41,12 +41,15 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.function.Consumer; import java.util.stream.Stream; @@ -62,9 +65,7 @@ public final class ModdedForcedDatapack { public static RepositorySource repositorySource() { return (Consumer consumer) -> { Pack pack = buildPack(); - if (pack != null) { - consumer.accept(pack); - } + consumer.accept(pack); }; } @@ -78,9 +79,10 @@ public final class ModdedForcedDatapack { private static Pack buildPack() { Path directory = regenerate(); - if (directory == null) { - return null; - } + return requireReadablePack(directory); + } + + private static Pack requireReadablePack(Path directory) { PackLocationInfo location = new PackLocationInfo( PACK_ID, Component.literal("Iris World Generation"), @@ -90,7 +92,8 @@ public final class ModdedForcedDatapack { PathPackResources.PathResourcesSupplier supplier = new PathPackResources.PathResourcesSupplier(directory); Pack pack = Pack.readMetaAndCreate(location, supplier, PackType.SERVER_DATA, selection); if (pack == null) { - LOGGER.error("Iris forced datapack at {} produced no readable pack metadata", directory); + throw new IllegalStateException("Iris forced datapack at " + directory + + " produced no readable pack metadata"); } return pack; } @@ -99,24 +102,41 @@ public final class ModdedForcedDatapack { synchronized (LOCK) { try { return write(); - } catch (IOException e) { + } catch (Throwable e) { LOGGER.error("Iris failed to generate the forced startup datapack", e); - return null; + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (e instanceof Error fatalError) { + throw fatalError; + } + throw new IllegalStateException("Iris failed to generate the forced startup datapack", e); } } } private static Path write() throws IOException { + ModdedStartup.ensureDefaultPack(); + Path datapackRoot = datapackRoot(); + Files.createDirectories(datapackRoot); + Path stagingDirectory = Files.createTempDirectory(datapackRoot, PACK_FOLDER + ".staging-"); try { - ModdedStartup.ensureDefaultPack(); - } catch (Throwable e) { - LOGGER.warn("Iris could not ensure the default pack before building the forced datapack", e); + writeStagedPack(stagingDirectory); + requireReadablePack(stagingDirectory); + publishDirectory(stagingDirectory, packDirectory()); + return packDirectory(); + } catch (IOException | RuntimeException | Error failure) { + try { + clean(stagingDirectory); + } catch (Throwable cleanupError) { + failure.addSuppressed(cleanupError); + } + throw failure; } - Path packDirectory = packDirectory(); - clean(packDirectory); - Files.createDirectories(packDirectory); + } - File packFolder = packDirectory.toFile(); + private static void writeStagedPack(Path stagingDirectory) throws IOException { + File packFolder = stagingDirectory.toFile(); KList folders = new KList<>(); folders.add(packFolder); Map> seenBiomes = new LinkedHashMap<>(); @@ -124,8 +144,13 @@ public final class ModdedForcedDatapack { int packCount = 0; KList presetIds = new KList<>(); - File[] packs = packsRoot().toFile().listFiles(File::isDirectory); + File root = packsRoot().toFile(); + File[] packs = root.listFiles(File::isDirectory); + if (packs == null && root.exists()) { + throw new IOException("Iris could not read installed pack directory " + root.getAbsolutePath()); + } if (packs != null) { + Arrays.sort(packs, Comparator.comparing(File::getName)); for (File pack : packs) { if (installPack(pack, fixer, folders, seenBiomes, presetIds)) { packCount++; @@ -133,46 +158,52 @@ public final class ModdedForcedDatapack { } } - writePackMeta(packDirectory); + writePackMeta(stagingDirectory); if ("forge".equalsIgnoreCase(ModdedEngineBootstrap.loader().platformName())) { - writeForgeBlockLootModifier(packDirectory); + writeForgeBlockLootModifier(stagingDirectory); } if (!presetIds.isEmpty()) { - writeWorldPresetTag(packDirectory, presetIds); + writeWorldPresetTag(stagingDirectory, presetIds); } - LOGGER.info("Iris forced startup datapack regenerated: {} pack(s), {} world preset(s), {} custom biome(s) at {}", packCount, presetIds.size(), countBiomes(seenBiomes), packDirectory); + LOGGER.info("Iris forced startup datapack staged: {} pack(s), {} world preset(s), {} custom biome(s) at {}", packCount, presetIds.size(), countBiomes(seenBiomes), stagingDirectory); if (packCount == 0) { LOGGER.warn("Iris installed NO worldgen packs into the forced datapack - custom biomes and their colors will NOT generate. Install a pack (e.g. /iris download overworld) and restart the server before creating an Iris world."); } - return packDirectory; } - private static boolean installPack(File packFolder, IDataFixer fixer, KList folders, Map> seenBiomes, KList presetIds) { + private static boolean installPack(File packFolder, IDataFixer fixer, KList folders, + Map> seenBiomes, + KList presetIds) throws IOException { String packName = packFolder.getName(); - File[] dimensionFiles = new File(packFolder, "dimensions").listFiles((File file) -> file.isFile() && file.getName().endsWith(".json")); - if (dimensionFiles == null || dimensionFiles.length == 0) { + File dimensionsDirectory = new File(packFolder, "dimensions"); + if (!dimensionsDirectory.isDirectory()) { return false; } - boolean installed = false; + File[] dimensionFiles = dimensionsDirectory.listFiles( + (File file) -> file.isFile() && file.getName().endsWith(".json")); + if (dimensionFiles == null) { + throw new IOException("Iris could not read dimensions for pack '" + packName + "'"); + } + if (dimensionFiles.length == 0) { + return false; + } + Arrays.sort(dimensionFiles, Comparator.comparing(File::getName)); + IrisData data = IrisData.get(packFolder); for (File dimensionFile : dimensionFiles) { String dimensionKey = dimensionFile.getName().substring(0, dimensionFile.getName().length() - ".json".length()); - try { - IrisData data = IrisData.get(packFolder); - IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); - if (dimension == null) { - continue; - } - dimension.installBiomes(fixer, () -> data, folders, biomesForNamespace(seenBiomes, dimension.getLoadKey())); - writeDimensionType(folders, fixer, dimension); - String presetKey = dimensionKey.equals(packName) ? packName : packName + "_" + dimensionKey; - writeWorldPreset(folders, dimension, packName, dimensionKey, presetKey); - presetIds.add("irisworldgen:" + presetKey); - installed = true; - } catch (Throwable e) { - LOGGER.error("Iris failed to install forced datapack content for pack '{}' dimension '{}'", packName, dimensionKey, e); + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); + if (dimension == null) { + throw new IllegalStateException("Iris pack '" + packName + "' dimension '" + + dimensionKey + "' did not load while building the forced datapack"); } + dimension.installBiomes(fixer, () -> data, folders, + biomesForNamespace(seenBiomes, dimension.getLoadKey())); + writeDimensionType(folders, fixer, dimension); + String presetKey = dimensionKey.equals(packName) ? packName : packName + "_" + dimensionKey; + writeWorldPreset(folders, dimension, packName, dimensionKey, presetKey); + presetIds.add("irisworldgen:" + presetKey); } - return installed; + return true; } static KSet biomesForNamespace(Map> biomes, String namespace) { @@ -319,6 +350,35 @@ public final class ModdedForcedDatapack { } } + static void publishDirectory(Path stagingDirectory, Path publishedDirectory) throws IOException { + Path backupDirectory = publishedDirectory.resolveSibling( + publishedDirectory.getFileName() + ".backup-" + UUID.randomUUID()); + boolean hadPublishedDirectory = Files.exists(publishedDirectory); + if (hadPublishedDirectory) { + Files.move(publishedDirectory, backupDirectory, StandardCopyOption.ATOMIC_MOVE); + } + try { + Files.move(stagingDirectory, publishedDirectory, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException | RuntimeException | Error failure) { + if (hadPublishedDirectory) { + try { + Files.move(backupDirectory, publishedDirectory, StandardCopyOption.ATOMIC_MOVE); + } catch (Throwable rollbackError) { + failure.addSuppressed(rollbackError); + } + } + throw failure; + } + if (hadPublishedDirectory) { + try { + clean(backupDirectory); + } catch (Throwable cleanupError) { + LOGGER.warn("Iris published the forced datapack but could not remove backup {}", + backupDirectory, cleanupError); + } + } + } + private static Path packsRoot() { return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs"); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItemTranslator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItemTranslator.java index 10137165c..1ac1308ef 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItemTranslator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItemTranslator.java @@ -18,12 +18,12 @@ package art.arcane.iris.modded; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.InventorySlotType; import art.arcane.iris.engine.object.IrisAttributeModifier; import art.arcane.iris.engine.object.IrisEnchantment; import art.arcane.iris.engine.object.IrisLoot; import art.arcane.iris.engine.object.IrisLootTable; -import art.arcane.iris.engine.object.NoiseStyle; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; @@ -76,23 +76,25 @@ public final class ModdedItemTranslator { private ModdedItemTranslator() { } - public static KList loot(IrisLootTable table, RNG rng, InventorySlotType slot, ServerLevel level, int x, int y, int z) { + public static KList loot(IrisLootTable table, long lootSeed, InventorySlotType slot, ServerLevel level, int x, int y, int z) { KList out = new KList<>(); KList entries = table.getLoot(); - if (entries.isEmpty()) { + if (entries.isEmpty() || table.getMaxTries() <= 0) { return out; } + RNG rng = LootResolver.tableRng(lootSeed, table, x, y, z); int m = 0; int c = 0; - int mx = rng.i(table.getMinPicked(), table.getMaxPicked()); + int mx = Math.max(0, LootResolver.inclusive(rng, table.getMinPicked(), table.getMaxPicked())); while (m < mx && c++ < table.getMaxTries()) { - IrisLoot entry = entries.get(rng.i(entries.size())); - if (entry.getSlotTypes() != slot) { + int entryIndex = rng.nextInt(entries.size()); + IrisLoot entry = entries.get(entryIndex); + if (entry == null || entry.getSlotTypes() != slot) { continue; } - ItemStack item = item(entry, table, rng, level, x, y, z); + ItemStack item = item(entry, table, rng, lootSeed, entryIndex, level, x, y, z); if (item != null && !item.isEmpty()) { out.add(item); m++; @@ -117,8 +119,9 @@ public final class ModdedItemTranslator { } } - public static ItemStack item(IrisLoot loot, IrisLootTable table, RNG rng, ServerLevel level, int x, int y, int z) { - if (loot.getChance().aquire(() -> NoiseStyle.STATIC.create(rng)).fit(1, loot.getRarity() * table.getRarity(), x, y, z) != 1) { + public static ItemStack item(IrisLoot loot, IrisLootTable table, RNG rng, long lootSeed, int entryIndex, ServerLevel level, int x, int y, int z) { + long combinedRarity = LootResolver.combinedRarity(loot.getRarity(), table.getRarity()); + if (!LootResolver.spatialOneIn(lootSeed, table, entryIndex, x, y, z, combinedRarity)) { return null; } @@ -150,7 +153,7 @@ public final class ModdedItemTranslator { } Item item = BuiltInRegistries.ITEM.getValue(id); - return new ItemStack(item, Math.max(1, rng.i(loot.getMinAmount(), loot.getMaxAmount()))); + return new ItemStack(item, Math.max(1, LootResolver.inclusive(rng, loot.getMinAmount(), loot.getMaxAmount()))); } private static void applyComponents(IrisLoot loot, ItemStack stack, RNG rng, ServerLevel level) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLootApplier.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLootApplier.java index 607a5a461..434bd1729 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLootApplier.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLootApplier.java @@ -19,17 +19,14 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.framework.PlacedObject; import art.arcane.iris.engine.object.IObjectLoot; import art.arcane.iris.engine.object.InventorySlotType; -import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.object.IrisLootMode; -import art.arcane.iris.engine.object.IrisLootReference; import art.arcane.iris.engine.object.IrisLootTable; import art.arcane.iris.engine.object.IrisObjectLoot; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisObjectVanillaLoot; -import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.collection.KList; @@ -42,10 +39,12 @@ import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.Container; +import net.minecraft.world.RandomizableContainer; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.ChestBlock; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.ChestType; import net.minecraft.world.level.storage.loot.LootParams; import net.minecraft.world.level.storage.loot.LootTable; import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets; @@ -60,8 +59,13 @@ public final class ModdedLootApplier { private ModdedLootApplier() { } - public static void apply(Engine engine, ServerLevel level, BlockPos pos, BlockState state, MantleChunk mc, RNG rng, int localX, int localZ) { - List sources = resolveSources(engine, level, rng, pos, state, mc); + public static void apply(Engine engine, ServerLevel level, BlockPos pos, BlockState state, MantleChunk mc) { + if (!isCanonicalContainer(pos, state)) { + return; + } + RNG rng = LootResolver.containerRng(engine.getSeedManager().getLoot(), pos.getX(), pos.getY(), pos.getZ()); + boolean nativeLootDefined = hasNativeLootTable(level, pos, state); + List sources = resolveSources(engine, level, rng, pos, state, mc, nativeLootDefined); if (sources.isEmpty()) { return; } @@ -80,7 +84,7 @@ public final class ModdedLootApplier { if (table == null) { continue; } - items.addAll(ModdedItemTranslator.loot(table, rng, InventorySlotType.STORAGE, level, localX, pos.getY(), localZ)); + items.addAll(ModdedItemTranslator.loot(table, engine.getSeedManager().getLoot(), InventorySlotType.STORAGE, level, pos.getX(), pos.getY(), pos.getZ())); continue; } if (source instanceof NativeLootSource nativeSource) { @@ -96,15 +100,18 @@ public final class ModdedLootApplier { fillContainer(container, items, rng); } - private static List resolveSources(Engine engine, ServerLevel level, RNG rng, BlockPos pos, BlockState state, MantleChunk mc) { + private static List resolveSources(Engine engine, ServerLevel level, RNG rng, BlockPos pos, BlockState state, MantleChunk mc, boolean nativeLootDefined) { int rx = pos.getX(); int rz = pos.getZ(); int ry = pos.getY() - engine.getWorld().minHeight(); - double he = engine.getComplex().getHeightStream().get(rx, rz); List sources = new ArrayList<>(); + boolean objectLootDefined = nativeLootDefined; PlacedObject po = engine.getObjectPlacement(rx, ry, rz, mc); if (po != null && po.getPlacement() != null && ModdedBlockResolution.isStorageChest(state)) { + objectLootDefined = objectLootDefined + || po.getPlacement().getLoot().isNotEmpty() + || po.getPlacement().getVanillaLoot().isNotEmpty(); Candidate picked = pickPlacementTable(engine, level, po.getPlacement(), state, rng); if (picked != null) { sources.add(picked.source()); @@ -114,17 +121,16 @@ public final class ModdedLootApplier { } } - IrisRegion region = engine.getComplex().getRegionStream().get(rx, rz); - IrisBiome biomeSurface = engine.getComplex().getTrueBiomeStream().get(rx, rz); - IrisBiome biomeUnder = ry < he ? engine.getCaveBiome(rx, ry, rz) : biomeSurface; - - double multiplier = 1D * engine.getDimension().getLoot().getMultiplier() * region.getLoot().getMultiplier() * biomeSurface.getLoot().getMultiplier() * biomeUnder.getLoot().getMultiplier(); - boolean fallback = sources.isEmpty(); - injectReferenceSources(sources, engine.getDimension().getLoot(), engine, fallback); - injectReferenceSources(sources, region.getLoot(), engine, fallback); - injectReferenceSources(sources, biomeSurface.getLoot(), engine, fallback); - injectReferenceSources(sources, biomeUnder.getLoot(), engine, fallback); - scaleSources(sources, multiplier, rng); + LootResolver.resolveEnvironmentSources( + sources, + engine, + rng, + rx, + ry, + rz, + objectLootDefined, + IrisLootSource::new + ); return sources; } @@ -134,7 +140,7 @@ public final class ModdedLootApplier { List global = new ArrayList<>(); for (IrisObjectLoot loot : placement.getLoot()) { - if (loot == null) { + if (loot == null || loot.getWeight() <= 0) { continue; } IrisLootTable table = engine.getData().getLootLoader().load(loot.getName()); @@ -146,7 +152,7 @@ public final class ModdedLootApplier { } for (IrisObjectVanillaLoot loot : placement.getVanillaLoot()) { - if (loot == null) { + if (loot == null || loot.getWeight() <= 0) { continue; } ResourceKey key = resolveNativeKey(loot.getName(), candidate -> hasNativeTable(level, candidate)); @@ -162,30 +168,7 @@ public final class ModdedLootApplier { } List pool = !exact.isEmpty() ? exact : !basic.isEmpty() ? basic : global; - return pickWeighted(pool, rng); - } - - static void injectSources(List sources, List additions, IrisLootMode mode, boolean fallback) { - if (mode == IrisLootMode.FALLBACK && !fallback) { - return; - } - if (mode == IrisLootMode.CLEAR || mode == IrisLootMode.REPLACE) { - sources.clear(); - } - sources.addAll(additions); - } - - static void scaleSources(List sources, double multiplier, RNG rng) { - if (sources.isEmpty()) { - return; - } - int target = (int) Math.round(sources.size() * multiplier); - while (sources.size() < target && !sources.isEmpty()) { - sources.add(sources.get(rng.i(sources.size() - 1))); - } - while (sources.size() > target && !sources.isEmpty()) { - sources.remove(rng.i(sources.size() - 1)); - } + return LootResolver.pickWeighted(pool, Candidate::weight, rng); } static ResourceKey resolveNativeKey(String name, Predicate> registryContains) { @@ -205,23 +188,6 @@ public final class ModdedLootApplier { container.setChanged(); } - private static void injectReferenceSources(List sources, IrisLootReference reference, Engine engine, boolean fallback) { - IrisLootMode mode = reference.getMode(); - if (mode == IrisLootMode.FALLBACK && !fallback) { - return; - } - injectSources(sources, irisSources(reference, engine), mode, fallback); - } - - private static List irisSources(IrisLootReference reference, Engine engine) { - KList tables = reference.getLootTables(engine.getComplex()); - List sources = new ArrayList<>(tables.size()); - for (IrisLootTable table : tables) { - sources.add(new IrisLootSource(table)); - } - return sources; - } - private static boolean hasNativeTable(ServerLevel level, ResourceKey key) { return level.getServer().reloadableRegistries().lookup() .lookup(Registries.LOOT_TABLE) @@ -244,25 +210,43 @@ public final class ModdedLootApplier { } } - private static Candidate pickWeighted(List pool, RNG rng) { - if (pool.isEmpty()) { - return null; + static boolean isCanonicalContainer(BlockPos pos, BlockState state) { + if (!(state.getBlock() instanceof ChestBlock) || state.getValue(ChestBlock.TYPE) == ChestType.SINGLE) { + return true; } - int total = 0; - for (Candidate candidate : pool) { - total += Math.max(0, candidate.weight()); + BlockPos connected = ChestBlock.getConnectedBlockPos(pos, state); + if (connected.equals(pos)) { + return true; } - if (total <= 0) { - return pool.get(rng.nextInt(pool.size())); + return isCanonicalPair(pos, connected); + } + + static boolean isCanonicalPair(BlockPos pos, BlockPos connected) { + return pos.getX() < connected.getX() + || pos.getX() == connected.getX() && pos.getZ() <= connected.getZ(); + } + + static boolean hasNativeLootTable(ServerLevel level, BlockPos pos, BlockState state) { + if (hasNativeLootTableAt(level, pos)) { + return true; } - int pull = rng.nextInt(total); - for (Candidate candidate : pool) { - pull -= Math.max(0, candidate.weight()); - if (pull < 0) { - return candidate; - } + if (!(state.getBlock() instanceof ChestBlock) || state.getValue(ChestBlock.TYPE) == ChestType.SINGLE) { + return false; } - return pool.get(pool.size() - 1); + BlockPos connected = ChestBlock.getConnectedBlockPos(pos, state); + return level.hasChunkAt(connected) && hasNativeLootTableAt(level, connected); + } + + private static boolean hasNativeLootTableAt(ServerLevel level, BlockPos pos) { + return hasNativeLootTable(level.getBlockEntity(pos)); + } + + static boolean hasNativeLootTable(BlockEntity blockEntity) { + return blockEntity instanceof RandomizableContainer container && hasNativeLootTable(container); + } + + static boolean hasNativeLootTable(RandomizableContainer container) { + return container != null && container.getLootTable() != null; } private static Container resolveContainer(ServerLevel level, BlockPos pos, BlockState state) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerAccess.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerAccess.java index 1e4ad72ca..050a56369 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerAccess.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerAccess.java @@ -33,6 +33,8 @@ public interface ModdedServerAccess { ServerLevel putLevel(MinecraftServer server, ResourceKey key, ServerLevel level); + ServerLevel putLevelIfAbsent(MinecraftServer server, ResourceKey key, ServerLevel level); + ServerLevel removeLevel(MinecraftServer server, ResourceKey key); boolean hasLevel(MinecraftServer server, ResourceKey key); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerLevels.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerLevels.java index be70ebd74..b1b3fa137 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerLevels.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerLevels.java @@ -42,6 +42,11 @@ public final class ModdedServerLevels implements ModdedServerAccess { return server.levels.put(key, level); } + @Override + public ServerLevel putLevelIfAbsent(MinecraftServer server, ResourceKey key, ServerLevel level) { + return server.levels.putIfAbsent(key, level); + } + @Override public ServerLevel removeLevel(MinecraftServer server, ResourceKey key) { return server.levels.remove(key); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java index 5bcd419af..efd372504 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java @@ -40,7 +40,13 @@ public final class ModdedServiceManager { } services.put(type, service); if (enabled) { - enableService(service); + try { + service.onEnable(); + } catch (Throwable failure) { + services.remove(type); + disableAfterFailure(service, failure); + throw serviceFailure(service, failure); + } } return service; } @@ -55,8 +61,18 @@ public final class ModdedServiceManager { return; } enabled = true; - for (ModdedService service : services.values()) { - enableService(service); + ModdedService[] ordered = services.values().toArray(new ModdedService[0]); + for (int i = 0; i < ordered.length; i++) { + ModdedService service = ordered[i]; + try { + service.onEnable(); + } catch (Throwable failure) { + enabled = false; + for (int rollbackIndex = i; rollbackIndex >= 0; rollbackIndex--) { + disableAfterFailure(ordered[rollbackIndex], failure); + } + throw serviceFailure(service, failure); + } } } @@ -79,12 +95,15 @@ public final class ModdedServiceManager { forEachReversed(this::disableService); } - private void enableService(ModdedService service) { - try { - service.onEnable(); - } catch (Throwable error) { - LOGGER.error("Iris service onEnable failed for {}", service.getClass().getName(), error); + synchronized void rollback(Throwable failure) { + if (enabled) { + enabled = false; + ModdedService[] ordered = services.values().toArray(new ModdedService[0]); + for (int i = ordered.length - 1; i >= 0; i--) { + disableAfterFailure(ordered[i], failure); + } } + services.clear(); } private void disableService(ModdedService service) { @@ -103,6 +122,28 @@ public final class ModdedServiceManager { } } + private void disableAfterFailure(ModdedService service, Throwable failure) { + try { + service.onDisable(); + } catch (Throwable cleanupError) { + if (cleanupError != failure) { + failure.addSuppressed(cleanupError); + } + LOGGER.error("Iris service rollback failed for {}", service.getClass().getName(), cleanupError); + } + } + + private RuntimeException serviceFailure(ModdedService service, Throwable failure) { + LOGGER.error("Iris service onEnable failed for {}", service.getClass().getName(), failure); + if (failure instanceof RuntimeException runtimeException) { + return runtimeException; + } + if (failure instanceof Error fatalError) { + throw fatalError; + } + return new IllegalStateException("Iris service failed to enable: " + service.getClass().getName(), failure); + } + private void forEachReversed(Consumer action) { ModdedService[] ordered = services.values().toArray(new ModdedService[0]); for (int i = ordered.length - 1; i >= 0; i--) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index 3cc82d382..007dae24a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded; +import art.arcane.iris.core.pack.BrokenPackException; import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; @@ -34,6 +35,7 @@ import java.util.concurrent.atomic.AtomicBoolean; public final class ModdedStartup { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final AtomicBoolean PREPARED = new AtomicBoolean(false); private static final AtomicBoolean STARTED = new AtomicBoolean(false); private static final Object PACK_LOCK = new Object(); @@ -41,9 +43,17 @@ public final class ModdedStartup { } public static void reset() { + PREPARED.set(false); STARTED.set(false); } + public static void prepareForStartup() { + if (!PREPARED.compareAndSet(false, true)) { + return; + } + validateAllPacks(); + } + public static void prefetchDefaultPack() { ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); if (scheduler != null) { @@ -56,30 +66,30 @@ public final class ModdedStartup { } public static void runOnce(MinecraftServer server) { - if (server == null || !STARTED.compareAndSet(false, true)) { + if (server == null) { + return; + } + prepareForStartup(); + if (!STARTED.compareAndSet(false, true)) { return; } reinjectPersistentDimensions(server); ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); if (scheduler == null) { - validateAllPacks(); ensureDefaultPack(); return; } - scheduler.async(() -> { - validateAllPacks(); - ensureDefaultPack(); - }); + scheduler.async(ModdedStartup::ensureDefaultPack); } public static void validateAllPacks() { File packsRoot = ModdedPackCommands.packsRoot(); File[] packDirs = packsRoot.listFiles(File::isDirectory); + PackValidationRegistry.clear(); if (packDirs == null || packDirs.length == 0) { return; } - PackValidationRegistry.clear(); for (File packDir : packDirs) { try { PackValidationResult result = PackValidator.validate(packDir); @@ -99,10 +109,50 @@ public final class ModdedStartup { } } catch (Throwable e) { LOGGER.error("Iris pack validation failed for '{}'", packDir.getName(), e); + String detail = e.getMessage(); + if (detail == null || detail.isBlank()) { + detail = e.getClass().getSimpleName(); + } + PackValidationRegistry.publish(new PackValidationResult( + packDir.getName(), + List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail), + List.of(), + System.currentTimeMillis())); } } } + public static PackValidationResult requirePackForWorldCreation(String pack) { + if (pack == null || pack.isBlank()) { + throw new IllegalArgumentException("Pack name is required for world creation"); + } + File packDir = new File(ModdedPackCommands.packsRoot(), pack); + if (!packDir.isDirectory()) { + throw new BrokenPackException(pack, List.of( + "Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + ".")); + } + try { + PackValidationResult result = PackValidator.validate(packDir); + PackValidationRegistry.publish(result); + return PackValidationRegistry.requireLoadable(pack); + } catch (BrokenPackException e) { + throw e; + } catch (Throwable e) { + LOGGER.error("Iris required world-creation validation failed for '{}'", pack, e); + String detail = e.getMessage(); + if (detail == null || detail.isBlank()) { + detail = e.getClass().getSimpleName(); + } + PackValidationResult failure = new PackValidationResult( + pack, + List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail), + List.of(), + System.currentTimeMillis()); + PackValidationRegistry.publish(failure); + throw new BrokenPackException(pack, failure.getBlockingErrors()); + } + } + private static void reinjectPersistentDimensions(MinecraftServer server) { List dimensions = ModdedDimensionRegistryStore.load(server); if (dimensions.isEmpty()) { @@ -115,6 +165,11 @@ public final class ModdedStartup { injected++; } catch (Throwable e) { LOGGER.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), e); + if (e instanceof Error fatalError) { + throw fatalError; + } + throw new IllegalStateException("Iris failed to re-inject persistent dimension '" + + dimension.id() + "' from pack '" + dimension.pack() + "'", e); } } LOGGER.info("Iris re-injected {} persistent dimension(s) at startup", injected); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java index 185bb7c8b..9b79d3e7d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded; +import art.arcane.iris.nativegen.NativeStructureGenerationException; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.iris.spi.PlatformWorld; @@ -51,6 +52,7 @@ import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Supplier; @@ -59,7 +61,7 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { private final Supplier server; public ModdedStructureHooks(Supplier server) { - this.server = server; + this.server = Objects.requireNonNull(server); } @Override @@ -75,19 +77,16 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { @Override public List structureBiomeKeys(String structureKey) { List keys = new ArrayList<>(); + MinecraftServer instance = requireServer("resolve biome keys for registered structure '" + structureKey + "'"); try { - MinecraftServer instance = server.get(); - if (instance == null) { - return keys; - } Identifier identifier = Identifier.tryParse(structureKey); if (identifier == null) { - return keys; + throw new IllegalArgumentException("Registered structure key is invalid: " + structureKey); } Registry registry = instance.registryAccess().lookupOrThrow(Registries.STRUCTURE); Structure structure = registry.getValue(identifier); if (structure == null) { - return keys; + throw new IllegalArgumentException("Registered structure does not exist: " + structureKey); } for (Holder holder : structure.biomes()) { Optional> key = holder.unwrapKey(); @@ -95,8 +94,13 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { keys.add(key.get().identifier().toString()); } } - } catch (Throwable error) { - IrisLogging.reportError(error); + if (keys.isEmpty()) { + throw new IllegalStateException("Registered structure '" + structureKey + + "' exposes no registered biome keys"); + } + } catch (RuntimeException error) { + throw new IllegalStateException("Iris failed to resolve biome keys for registered structure '" + + structureKey + "' from the modded structure registry", error); } return keys; } @@ -129,24 +133,19 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { @Override public List reachableStructureKeys(PlatformWorld world) { List keys = new ArrayList<>(); + ServerLevel level = requireLevel(world, "resolve reachable structures"); try { - ServerLevel level = level(world); - if (level == null) { - return keys; - } BiomeSource source = level.getChunkSource().getGenerator().getBiomeSource(); Set possibleBiomes = possibleBiomeKeys(source); - if (possibleBiomes.isEmpty()) { - return keys; - } Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); for (Map.Entry, Structure> entry : registry.entrySet()) { if (hasPossibleBiome(entry.getValue(), possibleBiomes)) { keys.add(entry.getKey().identifier().toString()); } } - } catch (Throwable error) { - IrisLogging.reportError(error); + } catch (RuntimeException error) { + throw new IllegalStateException("Iris failed to resolve reachable structures for modded level '" + + level.dimension().identifier() + "'", error); } return keys; } @@ -154,14 +153,12 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { @Override public List possibleBiomeKeys(PlatformWorld world) { List keys = new ArrayList<>(); + ServerLevel level = requireLevel(world, "resolve possible structure biome keys"); try { - ServerLevel level = level(world); - if (level == null) { - return keys; - } keys.addAll(possibleBiomeKeys(level.getChunkSource().getGenerator().getBiomeSource())); - } catch (Throwable error) { - IrisLogging.reportError(error); + } catch (RuntimeException error) { + throw new IllegalStateException("Iris failed to resolve possible structure biome keys for modded level '" + + level.dimension().identifier() + "'", error); } return keys; } @@ -230,9 +227,9 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { } placeChunks(level, structureManager, generator, start, box, seed); return bounds(box); - } catch (Throwable error) { - IrisLogging.reportError(error); - return null; + } catch (RuntimeException error) { + throw NativeStructureGenerationException.failure( + "capture placement", structureKey, chunkX, chunkZ, error); } } @@ -264,16 +261,19 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { } private static Set possibleBiomeKeys(BiomeSource source) { - Set keys = new LinkedHashSet<>(); if (source == null) { - return keys; + throw new IllegalStateException("Minecraft chunk generator has no biome source"); } + Set keys = new LinkedHashSet<>(); for (Holder holder : source.possibleBiomes()) { Optional> key = holder.unwrapKey(); if (key.isPresent()) { keys.add(key.get().identifier().toString()); } } + if (keys.isEmpty()) { + throw new IllegalStateException("Minecraft biome source exposes no registered possible biomes"); + } return keys; } @@ -312,18 +312,38 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { private List registryKeys(ResourceKey> registryKey) { List keys = new ArrayList<>(); + MinecraftServer instance = requireServer("read registry '" + registryKey.identifier() + "'"); try { - MinecraftServer instance = server.get(); - if (instance == null) { - return keys; - } Registry registry = instance.registryAccess().lookupOrThrow(registryKey); for (Identifier identifier : registry.keySet()) { keys.add(identifier.toString()); } - } catch (Throwable error) { - IrisLogging.reportError(error); + } catch (RuntimeException error) { + throw new IllegalStateException("Iris failed to read modded registry '" + + registryKey.identifier() + "'", error); } return keys; } + + private MinecraftServer requireServer(String operation) { + MinecraftServer instance; + try { + instance = server.get(); + } catch (RuntimeException error) { + throw new IllegalStateException("Iris failed to access the Minecraft server to " + operation, error); + } + if (instance == null) { + throw new IllegalStateException("Iris cannot " + operation + " before the Minecraft server is available"); + } + return instance; + } + + private static ServerLevel requireLevel(PlatformWorld world, String operation) { + ServerLevel level = level(world); + if (level == null) { + throw new IllegalStateException("Iris cannot " + operation + + " without a bound modded ServerLevel"); + } + return level; + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java index 7717d6c76..07f5b6936 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java @@ -19,6 +19,7 @@ package art.arcane.iris.modded; import art.arcane.iris.core.nms.datapack.DataVersion; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.StructureVerticalBounds; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisNativeStructureDecision; @@ -27,7 +28,6 @@ import art.arcane.volmlib.util.json.JSONObject; import com.mojang.datafixers.util.Pair; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; -import net.minecraft.core.HolderSet; import net.minecraft.core.Registry; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; @@ -43,14 +43,17 @@ import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; import net.minecraft.world.level.chunk.LevelChunkSection; import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.structure.BoundingBox; -import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructurePiece; import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement; +import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement; +import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -74,6 +77,7 @@ public final class ModdedWorldCheck { private static final int EXIT_FAILURE = 1; private static final int MAX_FOOTPRINT_CHUNKS = 96; private static final int MAX_START_REFERENCE_CHUNKS = 16; + private static final int MAX_STRUCTURE_CANDIDATES = 1024; private static final long SERVER_WAIT_TIMEOUT_MILLIS = 600000L; private static final long SERVER_WAIT_INTERVAL_MILLIS = 250L; private static final List STRUCTURE_CHECKS = List.of( @@ -136,8 +140,9 @@ public final class ModdedWorldCheck { } MinecraftServer serverRef = server; + WorldCheckPreparation preparation = serverRef.submit(() -> run(serverRef)).join(); exitCode = serverRef.submit(() -> runAndRequestStop( - () -> run(serverRef), + () -> completeWorldCheck(preparation), () -> { stopRequested.set(true); serverRef.halt(false); @@ -195,11 +200,12 @@ public final class ModdedWorldCheck { processExit.exit(exitCode); } - private static boolean run(MinecraftServer server) { + private static WorldCheckPreparation run(MinecraftServer server) { ServerLevel level = targetLevel(server); if (level == null) { LOGGER.error("[worldcheck] no Iris dimension is loaded"); - return false; + return new WorldCheckPreparation(false, false, false, false, + new NativeStructureGate(false, 0, false, null)); } String levelId = level.dimension().identifier().toString(); @@ -268,16 +274,43 @@ public final class ModdedWorldCheck { } boolean entityMixinsOk = checkEntityMixins(level); - boolean structureOk = false; - if (generator != null) { - structureOk = checkNativeStructures(level, generator, spawn); - } + NativeStructureGate structureGate = generator == null + ? new NativeStructureGate(false, 0, false, null) + : checkNativeStructures(level, generator, spawn); + boolean terrainOk = sectionsOk && varietyOk; + boolean nonStructurePass = irisGenerator && dimensionTypeOk && terrainOk && entityMixinsOk; + return new WorldCheckPreparation(nonStructurePass, terrainOk, dimensionTypeOk, + entityMixinsOk, structureGate); + } - boolean pass = irisGenerator && dimensionTypeOk && sectionsOk && varietyOk && entityMixinsOk && structureOk; + private static boolean completeWorldCheck(WorldCheckPreparation preparation) { + NativeStructureGate structureGate = preparation.structureGate(); + boolean poiOk = false; + PendingVillagePoi pendingPoi = structureGate.pendingPoi(); + if (pendingPoi != null) { + PoiAudit poi = auditStructurePois(pendingPoi.level(), pendingPoi.start()); + poiOk = villagePoiPass(poi.inBounds(), poi.outOfBounds()); + qaEvent("village_poi_metric", "village", poiOk, + "inBounds=" + poi.inBounds() + ",outOfBounds=" + poi.outOfBounds()); + if (!poiOk) { + LOGGER.error("[worldcheck] village POI audit failed: inBounds={} outOfBounds={}", + poi.inBounds(), poi.outOfBounds()); + } + } else { + qaEvent("village_poi_metric", "village", false, "skipped=structure"); + } + int passed = structureGate.nonVillagePassed() + + (structureGate.villagePassBeforePoi() && poiOk ? 1 : 0); + boolean structurePass = structureGate.passBeforePoi() && poiOk; + LOGGER.info("[worldcheck] native structure gate: {}/{} passed", passed, STRUCTURE_CHECKS.size()); + qaEvent("structure_aggregate", "all", structurePass, + "passed=" + passed + ",total=" + STRUCTURE_CHECKS.size()); + boolean pass = preparation.nonStructurePass() && structurePass; LOGGER.info("[worldcheck] {}", pass ? "PASS" : "FAIL"); qaEvent("worldcheck_final", "all", pass, - "structures=" + STRUCTURE_CHECKS.size() + ",terrain=" + (sectionsOk && varietyOk) - + ",dimensionType=" + dimensionTypeOk + ",entityMixins=" + entityMixinsOk); + "structures=" + STRUCTURE_CHECKS.size() + ",terrain=" + preparation.terrainOk() + + ",dimensionType=" + preparation.dimensionTypeOk() + + ",entityMixins=" + preparation.entityMixinsOk()); return pass; } @@ -354,25 +387,32 @@ public final class ModdedWorldCheck { return pass; } - private static boolean checkNativeStructures(ServerLevel level, IrisModdedChunkGenerator generator, BlockPos origin) { + private static NativeStructureGate checkNativeStructures(ServerLevel level, + IrisModdedChunkGenerator generator, + BlockPos origin) { boolean pass = true; - int passed = 0; + int nonVillagePassed = 0; + boolean villagePass = false; + PendingVillagePoi pendingPoi = null; for (StructureCheck check : STRUCTURE_CHECKS) { - boolean structurePass = checkNativeStructure(level, generator, origin, check); - if (structurePass) { - passed++; - } else { + StructureCheckResult result = checkNativeStructure(level, generator, origin, check); + if (!result.pass()) { pass = false; } + if (check.label().equals("village")) { + villagePass = result.pass(); + pendingPoi = result.pendingPoi(); + } else if (result.pass()) { + nonVillagePassed++; + } } - LOGGER.info("[worldcheck] native structure gate: {}/{} passed", passed, STRUCTURE_CHECKS.size()); - qaEvent("structure_aggregate", "all", pass, - "passed=" + passed + ",total=" + STRUCTURE_CHECKS.size()); - return pass; + return new NativeStructureGate(pass, nonVillagePassed, villagePass, pendingPoi); } - private static boolean checkNativeStructure(ServerLevel level, IrisModdedChunkGenerator generator, - BlockPos origin, StructureCheck check) { + private static StructureCheckResult checkNativeStructure(ServerLevel level, + IrisModdedChunkGenerator generator, + BlockPos origin, + StructureCheck check) { Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); List> registered = new ArrayList<>(check.registryKeys().size()); LinkedHashSet registeredKeys = new LinkedHashSet<>(); @@ -398,7 +438,7 @@ public final class ModdedWorldCheck { emitSkipped(check, "registry", "structure_reachability", "structure_locate", "structure_start_reference", "structure_footprint", "structure_material", "structure_block_entity"); - return false; + return new StructureCheckResult(false, null); } List> reachable = new ArrayList<>(registered.size()); @@ -422,35 +462,35 @@ public final class ModdedWorldCheck { LOGGER.error("[worldcheck] {} cannot generate in any biome produced by this Iris pack", check.label()); emitSkipped(check, "reachability", "structure_locate", "structure_start_reference", "structure_footprint", "structure_material", "structure_block_entity"); - return false; + return new StructureCheckResult(false, null); } long locateStart = System.nanoTime(); - Pair> found = generator.findNearestMapStructure( - level, HolderSet.direct(reachable), origin, check.locateRadius(), false); + Pair> found = findGeneratedStructureCandidate( + level, reachable, origin, check.locateRadius()); long locateMillis = (System.nanoTime() - locateStart) / 1_000_000L; Identifier foundKey = found == null ? null : registry.getKey(found.getSecond().value()); boolean locateOk = found != null && foundKey != null && reachableKeys.contains(foundKey.toString()); qaEvent("structure_locate", check.label(), locateOk, - "millis=" + locateMillis + ",radius=" + check.locateRadius() + "method=placement_candidates,millis=" + locateMillis + ",radius=" + check.locateRadius() + ",result=" + (foundKey == null ? "none" : foundKey)); if (found == null) { - LOGGER.error("[worldcheck] {} locate found no result within {} chunks after {}ms", + LOGGER.error("[worldcheck] {} native placement candidates produced no valid start within {} rings after {}ms", check.label(), check.locateRadius(), locateMillis); emitSkipped(check, "locate", "structure_start_reference", "structure_footprint", "structure_material", "structure_block_entity"); - return false; + return new StructureCheckResult(false, null); } BlockPos position = found.getFirst(); - LOGGER.info("[worldcheck] {} locate: {} {} {} in {}ms (radius={}, unexplored=false, result={})", + LOGGER.info("[worldcheck] {} generated candidate: {} {} {} in {}ms (radius={}, result={})", check.label(), position.getX(), position.getY(), position.getZ(), locateMillis, check.locateRadius(), foundKey); if (!locateOk) { - LOGGER.error("[worldcheck] {} locate returned unexpected structure {}", check.label(), foundKey); + LOGGER.error("[worldcheck] {} candidate scan returned unexpected structure {}", check.label(), foundKey); emitSkipped(check, "locate", "structure_start_reference", "structure_footprint", "structure_material", "structure_block_entity"); - return false; + return new StructureCheckResult(false, null); } int chunkX = position.getX() >> 4; @@ -471,11 +511,12 @@ public final class ModdedWorldCheck { check.label(), chunkX, chunkZ); emitSkipped(check, "start_reference", "structure_footprint", "structure_material", "structure_block_entity"); - return false; + return new StructureCheckResult(false, null); } - IrisNativeStructureDecision decision = generator.commandEngine().getDimension().getImportedStructures() - .resolve(foundKey.toString(), NativeStructurePostProcessor.isUndergroundStep(structure.step())); + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve( + generator.commandEngine(), foundKey.toString(), + NativeStructurePostProcessor.isUndergroundStep(structure.step())); Integer appliedShift = generator.worldCheckStructureShift(foundKey.toString(), start.getChunkPos()); BoundingBox shiftedBounds = start.getBoundingBox(); boolean verticalShiftOk = verticalShiftMatches( @@ -522,22 +563,17 @@ public final class ModdedWorldCheck { } boolean foundationOk = true; - boolean poiOk = true; + PendingVillagePoi pendingPoi = null; if (check.label().equals("village")) { foundationOk = villageFoundationPass(footprint.foundationGapColumns()); - LOGGER.info("[worldcheck] village foundation metric: cobblestone below piece bases={} columns={} unsupported={}", - footprint.foundationBlocks(), footprint.foundationColumns(), footprint.foundationGapColumns()); + LOGGER.info("[worldcheck] village foundation metric: bases={} cobblestone={} columns={} unsupported={}", + footprint.foundationBaseColumns(), footprint.foundationBlocks(), + footprint.foundationColumns(), footprint.foundationGapColumns()); qaEvent("village_foundation_metric", check.label(), foundationOk, - "cobblestoneBelowBase=" + footprint.foundationBlocks() + ",columns=" + "bases=" + footprint.foundationBaseColumns() + ",cobblestoneBelowBase=" + + footprint.foundationBlocks() + ",columns=" + footprint.foundationColumns() + ",unsupported=" + footprint.foundationGapColumns()); - PoiAudit poi = auditStructurePois(level, start); - poiOk = villagePoiPass(poi.inBounds(), poi.outOfBounds()); - qaEvent("village_poi_metric", check.label(), poiOk, - "inBounds=" + poi.inBounds() + ",outOfBounds=" + poi.outOfBounds()); - if (!poiOk) { - LOGGER.error("[worldcheck] village POI audit failed: inBounds={} outOfBounds={}", - poi.inBounds(), poi.outOfBounds()); - } + pendingPoi = new PendingVillagePoi(level, start); } boolean blockEntityOk = footprint.blockEntityStates() == footprint.blockEntitiesPresent(); @@ -562,8 +598,9 @@ public final class ModdedWorldCheck { if (!foundationOk) { LOGGER.error("[worldcheck] village has unsupported foundation columns after stilt placement"); } - return verticalShiftOk && footprintOk && materialOk && blockEntityOk - && vegetationOk && foundationOk && poiOk; + boolean pass = verticalShiftOk && footprintOk && materialOk && blockEntityOk + && vegetationOk && foundationOk; + return new StructureCheckResult(pass, pass ? pendingPoi : null); } private static StructureStart resolveStructureStart(ServerLevel level, ChunkAccess targetChunk, @@ -586,6 +623,96 @@ public final class ModdedWorldCheck { return direct; } + private static Pair> findGeneratedStructureCandidate( + ServerLevel level, List> structures, BlockPos origin, int maxRadius) { + ChunkGeneratorStructureState state = level.getChunkSource().getGeneratorState(); + Set attempted = new LinkedHashSet<>(); + for (Holder structure : structures) { + for (StructurePlacement placement : state.getPlacementsForStructure(structure)) { + if (!(placement instanceof ConcentricRingsStructurePlacement rings)) { + continue; + } + List positions = state.getRingPositionsFor(rings); + if (positions == null) { + continue; + } + List sorted = new ArrayList<>(positions); + sorted.sort(Comparator.comparingLong(position -> distanceSquared(origin, position))); + for (ChunkPos position : sorted) { + Pair> found = inspectStructureCandidate( + level, structures, placement, position, attempted); + if (found != null) { + return found; + } + if (attempted.size() >= MAX_STRUCTURE_CANDIDATES) { + return null; + } + } + } + } + + int originChunkX = origin.getX() >> 4; + int originChunkZ = origin.getZ() >> 4; + for (int radius = 0; radius <= maxRadius; radius++) { + for (Holder structure : structures) { + for (StructurePlacement placement : state.getPlacementsForStructure(structure)) { + if (!(placement instanceof RandomSpreadStructurePlacement randomSpread)) { + continue; + } + for (int x = -radius; x <= radius; x++) { + boolean xEdge = x == -radius || x == radius; + for (int z = -radius; z <= radius; z++) { + if (!xEdge && z != -radius && z != radius) { + continue; + } + int sectorX = originChunkX + randomSpread.spacing() * x; + int sectorZ = originChunkZ + randomSpread.spacing() * z; + ChunkPos candidate = randomSpread.getPotentialStructureChunk( + state.getLevelSeed(), sectorX, sectorZ); + if (!placement.isStructureChunk(state, candidate.x(), candidate.z())) { + continue; + } + Pair> found = inspectStructureCandidate( + level, structures, placement, candidate, attempted); + if (found != null) { + return found; + } + if (attempted.size() >= MAX_STRUCTURE_CANDIDATES) { + return null; + } + } + } + } + } + } + return null; + } + + private static Pair> inspectStructureCandidate( + ServerLevel level, List> structures, StructurePlacement placement, + ChunkPos candidate, Set attempted) { + if (!attempted.add(candidate.pack())) { + return null; + } + ChunkAccess chunk = level.getChunk(candidate.x(), candidate.z()); + for (Holder structure : structures) { + StructureStart start = resolveStructureStart(level, chunk, structure.value()); + if (start == null || !start.isValid()) { + continue; + } + BlockPos locate = placement.getLocatePos(start.getChunkPos()); + BlockPos resolved = new BlockPos(locate.getX(), start.getBoundingBox().minY(), locate.getZ()); + return Pair.of(resolved, structure); + } + return null; + } + + private static long distanceSquared(BlockPos origin, ChunkPos position) { + long x = (long) position.getMinBlockX() - origin.getX(); + long z = (long) position.getMinBlockZ() - origin.getZ(); + return x * x + z * z; + } + private static FootprintAudit auditFootprint(ServerLevel level, Structure structure, StructureStart start, StructureCheck check, Identifier structureKey) { List pieces = start.getPieces(); @@ -600,11 +727,11 @@ public final class ModdedWorldCheck { int characteristicChunks = 0; int vegetationBlocks = 0; int vegetationColumns = 0; + int foundationBaseColumns = 0; int foundationBlocks = 0; int foundationColumns = 0; int foundationGapColumns = 0; BitSet visited = new BitSet(level.getHeight() << 8); - int[] minimumPieceY = new int[256]; int[] maximumPieceY = new int[256]; for (ChunkPos chunkPos : selected) { ChunkAccess chunk = level.getChunk(chunkPos.x(), chunkPos.z()); @@ -617,8 +744,8 @@ public final class ModdedWorldCheck { BlockEntityAudit blockEntities = auditBlockEntities(level, chunk); blockEntityStates += blockEntities.states(); blockEntitiesPresent += blockEntities.present(); - StructureMaterialAudit material = auditStructureMaterial(level, chunk, pieces, check, - structureKey, visited, minimumPieceY, maximumPieceY); + StructureMaterialAudit material = auditStructureMaterial(level, chunk, start, check, + structureKey, visited, maximumPieceY); if (material.scanned()) { materialScannedChunks++; } @@ -628,6 +755,7 @@ public final class ModdedWorldCheck { } vegetationBlocks += material.vegetationBlocks(); vegetationColumns += material.vegetationColumns(); + foundationBaseColumns += material.foundationBaseColumns(); foundationBlocks += material.foundationBlocks(); foundationColumns += material.foundationColumns(); foundationGapColumns += material.foundationGapColumns(); @@ -648,19 +776,18 @@ public final class ModdedWorldCheck { return new FootprintAudit(selected.size(), availableChunks, evidenceChunks, coveredPieces, pieces.size(), blockEntityStates, blockEntitiesPresent, materialScannedChunks, characteristicBlocks, characteristicChunks, - vegetationBlocks, vegetationColumns, foundationBlocks, foundationColumns, - foundationGapColumns); + vegetationBlocks, vegetationColumns, foundationBaseColumns, foundationBlocks, + foundationColumns, foundationGapColumns); } private static StructureMaterialAudit auditStructureMaterial(ServerLevel level, ChunkAccess chunk, - List pieces, + StructureStart start, StructureCheck check, Identifier structureKey, BitSet visited, - int[] minimumPieceY, int[] maximumPieceY) { + List pieces = start.getPieces(); visited.clear(); - Arrays.fill(minimumPieceY, Integer.MAX_VALUE); Arrays.fill(maximumPieceY, Integer.MIN_VALUE); int characteristicBlocks = 0; int minimumWorldY = level.getMinY(); @@ -683,20 +810,10 @@ public final class ModdedWorldCheck { continue; } scanned = true; - int metricMinimumY = minimumY; - boolean metricBaseValid = true; - if (check.label().equals("village") && piece instanceof PoolElementStructurePiece poolPiece) { - int groundY = bounds.minY() + poolPiece.getGroundLevelDelta(); - metricBaseValid = groundY >= bounds.minY(); - metricMinimumY = Math.max(minimumWorldY, Math.min(maximumWorldY, groundY)); - } for (int z = minimumZ; z <= maximumZ; z++) { int localZ = z - minimumChunkZ; for (int x = minimumX; x <= maximumX; x++) { int column = (localZ << 4) | (x - minimumChunkX); - if (metricBaseValid) { - minimumPieceY[column] = Math.min(minimumPieceY[column], metricMinimumY); - } maximumPieceY[column] = Math.max(maximumPieceY[column], maximumY); } } @@ -747,55 +864,26 @@ public final class ModdedWorldCheck { } } + int foundationBaseColumns = 0; int foundationBlocks = 0; int foundationColumns = 0; int foundationGapColumns = 0; if (check.label().equals("village")) { - for (int column = 0; column < minimumPieceY.length; column++) { - int minimumY = minimumPieceY[column]; - if (minimumY == Integer.MAX_VALUE) { - continue; - } - boolean foundationColumn = false; - int x = minimumChunkX + (column & 15); - int z = minimumChunkZ + (column >> 4); - int foundationY = Integer.MIN_VALUE; - int foundationMaximumY = Math.min(maximumWorldY, minimumY + 1); - for (int y = minimumY; y <= foundationMaximumY; y++) { - if (chunk.getBlockState(position.set(x, y, z)).isSolid()) { - foundationY = y; - break; - } - } - boolean basePresent = foundationY != Integer.MIN_VALUE; - boolean supportSolid = false; - boolean vegetationSupport = false; - if (basePresent && foundationY > minimumWorldY) { - BlockState support = chunk.getBlockState(position.set(x, foundationY - 1, z)); - supportSolid = support.isSolid(); - vegetationSupport = support.is(BlockTags.LOGS) || support.is(BlockTags.LEAVES); - } - boolean worldFloorBase = basePresent && foundationY == minimumWorldY; - if (!villageFoundationSupported(basePresent, worldFloorBase, supportSolid, vegetationSupport)) { - foundationGapColumns++; - continue; - } - for (int y = foundationY - 1; y >= minimumWorldY; y--) { - BlockState state = chunk.getBlockState(position.set(x, y, z)); - if (!state.is(Blocks.COBBLESTONE)) { - break; - } - foundationBlocks++; - foundationColumn = true; - } - if (foundationColumn) { - foundationColumns++; - } - } + BoundingBox area = new BoundingBox( + minimumChunkX, minimumWorldY, minimumChunkZ, + maximumChunkX, maximumWorldY, maximumChunkZ); + NativeStructurePostProcessor.StiltSupportAudit foundation = + NativeStructurePostProcessor.auditStiltSupport( + level, area, start, Blocks.COBBLESTONE.defaultBlockState()); + foundationBaseColumns = foundation.baseColumns(); + foundationBlocks = foundation.stiltBlocks(); + foundationColumns = foundation.stiltColumns(); + foundationGapColumns = foundation.unsupportedColumns(); } return new StructureMaterialAudit(scanned, characteristicBlocks, vegetationBlocks, - vegetationColumns, foundationBlocks, foundationColumns, foundationGapColumns); + vegetationColumns, foundationBaseColumns, foundationBlocks, foundationColumns, + foundationGapColumns); } private static List selectFootprintChunks(StructureStart start, int limit) { @@ -1036,11 +1124,6 @@ public final class ModdedWorldCheck { return vegetation && blockY > highestPieceY; } - static boolean villageFoundationSupported(boolean basePresent, boolean worldFloorBase, - boolean supportSolid, boolean vegetationSupport) { - return basePresent && (worldFloorBase || (supportSolid && !vegetationSupport)); - } - static boolean villageFoundationPass(int unsupportedColumns) { return unsupportedColumns == 0; } @@ -1194,12 +1277,27 @@ public final class ModdedWorldCheck { private record StructureCheck(String label, List registryKeys, int locateRadius) { } + private record WorldCheckPreparation(boolean nonStructurePass, boolean terrainOk, + boolean dimensionTypeOk, boolean entityMixinsOk, + NativeStructureGate structureGate) { + } + + private record NativeStructureGate(boolean passBeforePoi, int nonVillagePassed, + boolean villagePassBeforePoi, PendingVillagePoi pendingPoi) { + } + + private record StructureCheckResult(boolean pass, PendingVillagePoi pendingPoi) { + } + + private record PendingVillagePoi(ServerLevel level, StructureStart start) { + } + private record FootprintAudit(int inspectedChunks, int availableChunks, int evidenceChunks, int coveredPieces, int totalPieces, int blockEntityStates, int blockEntitiesPresent, int materialScannedChunks, int characteristicBlocks, int characteristicChunks, int vegetationBlocks, int vegetationColumns, - int foundationBlocks, int foundationColumns, + int foundationBaseColumns, int foundationBlocks, int foundationColumns, int foundationGapColumns) { } @@ -1208,7 +1306,8 @@ public final class ModdedWorldCheck { private record StructureMaterialAudit(boolean scanned, int characteristicBlocks, int vegetationBlocks, int vegetationColumns, - int foundationBlocks, int foundationColumns, + int foundationBaseColumns, int foundationBlocks, + int foundationColumns, int foundationGapColumns) { } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index ade411ac3..c6cda5784 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -19,6 +19,7 @@ package art.arcane.iris.modded; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineTarget; @@ -35,6 +36,7 @@ import java.io.File; import java.util.ArrayList; import java.util.Collection; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; public final class ModdedWorldEngines { @@ -57,22 +59,48 @@ public final class ModdedWorldEngines { } public static void evict(ServerLevel level) { - Engine removed = ENGINES.remove(level); - if (removed == null) { - return; - } try { - if (!removed.isClosed()) { - removed.close(); - } - LOGGER.info("Iris engine evicted for {}", level.dimension().identifier()); + evictOrThrow(level); } catch (Throwable e) { LOGGER.error("Iris engine evict close failed for {}", level.dimension().identifier(), e); } } + static void evictOrThrow(ServerLevel level) { + Engine[] removed = new Engine[1]; + ENGINES.computeIfPresent(level, (ServerLevel ignored, Engine current) -> { + close(current); + removed[0] = current; + return null; + }); + if (removed[0] == null) { + return; + } + LOGGER.info("Iris engine evicted for {}", level.dimension().identifier()); + } + + static Engine prepareReplacement(ServerLevel level, String pack, String dimensionKey, long seedOverride) { + return create(level, pack, dimensionKey, seedOverride); + } + + static void installReplacement(ServerLevel level, Engine replacement) { + ServerLevel activeLevel = Objects.requireNonNull(level); + Engine activeReplacement = Objects.requireNonNull(replacement); + ENGINES.compute(activeLevel, (ServerLevel ignored, Engine current) -> { + if (current != null && current != activeReplacement) { + close(current); + } + return activeReplacement; + }); + } + + static void closeUnregistered(Engine engine) { + close(engine); + } + private static Engine create(ServerLevel level, String pack, String dimensionKey, long seedOverride) { ModdedEngineBootstrap.bind(); + PackValidationRegistry.requireLoadable(pack); File packDir = resolvePack(pack, dimensionKey); IrisData data = IrisData.get(packDir); IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); @@ -95,6 +123,16 @@ public final class ModdedWorldEngines { .platformWorld(new ModdedPlatformWorld(level)) .build(); Engine engine = new IrisEngine(new EngineTarget(world, dimension, data), false); + if (engine.isClosed() || engine.getComplex() == null) { + IllegalStateException failure = new IllegalStateException("Iris engine for " + + level.dimension().identifier() + " did not initialize a ready biome complex"); + try { + close(engine); + } catch (Throwable cleanupError) { + failure.addSuppressed(cleanupError); + } + throw failure; + } LOGGER.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{}", level.dimension().identifier(), packDir.getAbsolutePath(), dimension.getLoadKey(), seed, dimension.getMinHeight(), dimension.getMaxHeight()); @@ -131,24 +169,6 @@ public final class ModdedWorldEngines { return packDir; } - String parity = System.getProperty("iris.parity"); - if (parity != null) { - String parityPath = parity; - int lastColon = parity.lastIndexOf(':'); - if (lastColon > 0) { - try { - Integer.parseInt(parity.substring(lastColon + 1)); - parityPath = parity.substring(0, lastColon); - } catch (NumberFormatException ignored) { - } - } - File parityPack = new File(parityPath); - if (parityPack.isDirectory()) { - LOGGER.warn("Iris pack missing at {}; falling back to parity pack {}", packDir.getAbsolutePath(), parityPack.getAbsolutePath()); - return parityPack; - } - } - LOGGER.error("==============================================================="); LOGGER.error("Iris pack '{}' is not installed.", pack); LOGGER.error("Expected a pack folder at: {}", packDir.getAbsolutePath()); @@ -157,6 +177,12 @@ public final class ModdedWorldEngines { throw new IllegalStateException("Iris pack not installed: " + packDir.getAbsolutePath()); } + private static void close(Engine engine) { + if (engine != null && !engine.isClosed()) { + engine.close(); + } + } + public static void shutdown() { for (Map.Entry entry : ENGINES.entrySet()) { Engine engine = entry.getValue(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java index f2b57a548..f0ab19213 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java @@ -23,6 +23,7 @@ import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineWorldManager; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.IRare; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisEntity; @@ -492,7 +493,7 @@ public final class ModdedWorldManager implements EngineWorldManager { int min = entry.getMinSpawns(); int max = entry.getMaxSpawns(); - int count = min == max ? min : RNG.r.i(Math.min(min, max), Math.max(min, max)); + int count = LootResolver.inclusive(RNG.r, min, max); if (count <= 0) { return 0; } @@ -516,8 +517,8 @@ public final class ModdedWorldManager implements EngineWorldManager { worldY = caveFloor.getY() + 1; worldZ = caveFloor.getZ(); } else { - worldX = (chunkX << 4) + RNG.r.i(15); - worldZ = (chunkZ << 4) + RNG.r.i(15); + worldX = (chunkX << 4) + RNG.r.i(16); + worldZ = (chunkZ << 4) + RNG.r.i(16); int surfaceY = level.getMinY() + engine.getHeight(worldX, worldZ, false); int solidY = level.getMinY() + engine.getHeight(worldX, worldZ, true); worldY = group == IrisSpawnGroup.NORMAL @@ -527,6 +528,9 @@ public final class ModdedWorldManager implements EngineWorldManager { if (worldY <= level.getMinY() || worldY >= level.getMaxY()) { continue; } + if (!LootResolver.oneIn(entityRng, entry.getRarity())) { + continue; + } if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { continue; } @@ -551,7 +555,7 @@ public final class ModdedWorldManager implements EngineWorldManager { int min = entry.getMinSpawns(); int max = entry.getMaxSpawns(); - int count = min == max ? min : RNG.r.i(Math.min(min, max), Math.max(min, max)); + int count = LootResolver.inclusive(RNG.r, min, max); if (count <= 0) { return 0; } @@ -564,6 +568,9 @@ public final class ModdedWorldManager implements EngineWorldManager { int worldZ = position.getZ(); int spawned = 0; for (int i = 0; i < count; i++) { + if (!LootResolver.oneIn(entityRng, entry.getRarity())) { + continue; + } if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { continue; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java index 1eedfa27e..299673b1a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java @@ -28,9 +28,11 @@ import net.minecraft.world.level.block.state.BlockState; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.ServiceLoader; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; @@ -40,11 +42,12 @@ public final class ModdedCustomContentRegistry { private static final List PROVIDERS = new CopyOnWriteArrayList<>(); private static final Map CUSTOM_BLOCKS = new ConcurrentHashMap<>(); private static volatile boolean scanned = false; + private static DiscoveryBatch discoveryBatch; private ModdedCustomContentRegistry() { } - public static void registerCustomBlockData(String namespace, String key, String state) { + public static synchronized void registerCustomBlockData(String namespace, String key, String state) { if (namespace == null || key == null || state == null) { return; } @@ -60,14 +63,24 @@ public final class ModdedCustomContentRegistry { LOGGER.error("Iris custom block data '{}:{}' has unparseable state '{}'", namespace, key, state, error); return; } - CUSTOM_BLOCKS.put(identifier.toString(), parsed); + DiscoveryBatch activeBatch = discoveryBatch; + if (activeBatch == null) { + CUSTOM_BLOCKS.put(identifier.toString(), parsed); + } else { + activeBatch.customBlocks.put(identifier.toString(), parsed); + } LOGGER.info("Iris registered custom block data {}:{} -> {}", namespace, key, state); } - public static void register(ModdedDataProvider provider) { + public static synchronized void register(ModdedDataProvider provider) { if (provider == null) { return; } + DiscoveryBatch activeBatch = discoveryBatch; + if (activeBatch != null) { + activeBatch.add(provider); + return; + } for (ModdedDataProvider existing : PROVIDERS) { if (existing.modId().equals(provider.modId())) { LOGGER.warn("Iris custom content provider for '{}' already registered; ignoring duplicate", provider.modId()); @@ -83,17 +96,66 @@ public final class ModdedCustomContentRegistry { LOGGER.info("Iris registered custom content provider '{}'", provider.modId()); } - public static void discover() { + public static synchronized Discovery discover() { if (scanned) { - return; + return Discovery.unchanged(); } - scanned = true; - ServiceLoader loader = ServiceLoader.load(ModdedDataProvider.class, ModdedCustomContentRegistry.class.getClassLoader()); - for (ModdedDataProvider provider : loader) { - register(provider); + return discover(ServiceLoader.load( + ModdedDataProvider.class, ModdedCustomContentRegistry.class.getClassLoader())); + } + + static synchronized Discovery discover(Iterable discoveredProviders) { + List previousProviders = List.copyOf(PROVIDERS); + Map previousCustomBlocks = new LinkedHashMap<>(CUSTOM_BLOCKS); + boolean previousDiscoveryComplete = scanned; + DiscoveryBatch batch = new DiscoveryBatch(previousProviders, previousCustomBlocks); + discoveryBatch = batch; + try { + for (ModdedDataProvider provider : discoveredProviders) { + batch.add(provider); + } + PROVIDERS.addAll(batch.additions); + CUSTOM_BLOCKS.putAll(batch.customBlocks); + scanned = true; + for (ModdedDataProvider provider : batch.additions) { + LOGGER.info("Iris registered custom content provider '{}'", provider.modId()); + } + return new Discovery(previousProviders, previousCustomBlocks, + previousDiscoveryComplete, true); + } catch (Throwable failure) { + try { + restore(previousProviders, previousCustomBlocks, previousDiscoveryComplete); + } catch (Throwable rollbackFailure) { + if (rollbackFailure != failure) { + failure.addSuppressed(rollbackFailure); + } + } + LOGGER.error("Iris custom content provider discovery failed", failure); + if (failure instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (failure instanceof Error fatalError) { + throw fatalError; + } + throw new IllegalStateException("Iris custom content provider discovery failed", failure); + } finally { + discoveryBatch = null; } } + static synchronized boolean hasProvider(String modId) { + for (ModdedDataProvider provider : PROVIDERS) { + if (Objects.equals(provider.modId(), modId)) { + return true; + } + } + return false; + } + + static boolean discoveryComplete() { + return scanned; + } + public static boolean hasProviders() { return !PROVIDERS.isEmpty() || !CUSTOM_BLOCKS.isEmpty(); } @@ -203,4 +265,68 @@ public final class ModdedCustomContentRegistry { } return state; } + + private static synchronized void restore(List providers, + Map customBlocks, + boolean discoveryComplete) { + PROVIDERS.clear(); + PROVIDERS.addAll(providers); + CUSTOM_BLOCKS.clear(); + CUSTOM_BLOCKS.putAll(customBlocks); + scanned = discoveryComplete; + } + + public static final class Discovery { + private final List providers; + private final Map customBlocks; + private final boolean discoveryComplete; + private boolean active; + + private Discovery(List providers, Map customBlocks, + boolean discoveryComplete, boolean active) { + this.providers = providers; + this.customBlocks = customBlocks; + this.discoveryComplete = discoveryComplete; + this.active = active; + } + + private static Discovery unchanged() { + return new Discovery(List.of(), Map.of(), true, false); + } + + public synchronized void rollback() { + if (!active) { + return; + } + restore(providers, customBlocks, discoveryComplete); + active = false; + } + } + + private static final class DiscoveryBatch { + private final List providers; + private final List additions = new ArrayList<>(); + private final Map customBlocks; + + private DiscoveryBatch(List providers, Map customBlocks) { + this.providers = new ArrayList<>(providers); + this.customBlocks = new LinkedHashMap<>(customBlocks); + } + + private void add(ModdedDataProvider provider) { + ModdedDataProvider candidate = Objects.requireNonNull(provider, + "Iris discovered a null custom content provider"); + String modId = Objects.requireNonNull(candidate.modId(), + "Iris custom content provider returned a null mod id"); + for (ModdedDataProvider existing : providers) { + if (modId.equals(existing.modId())) { + LOGGER.warn("Iris custom content provider for '{}' already registered; ignoring duplicate", modId); + return; + } + } + providers.add(candidate); + additions.add(candidate); + candidate.init(); + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index d15d5974e..42ea5a42a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -25,9 +25,11 @@ import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.Locator; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.object.IrisImportedStructureControl; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.modded.IrisModdedChunkGenerator; @@ -889,16 +891,27 @@ public final class IrisModdedCommands { fail(source, "This command can only be used by players."); return 0; } - if (IrisStructureLocator.isPlaced(engine, key)) { - locateIrisStructure(source, level, engine, player, key); - return 1; - } Optional resolved = resolveNativeStructure(source, level, engine, key); if (resolved.isEmpty()) { + if (IrisStructureLocator.isPlaced(engine, key)) { + locateIrisStructure(source, level, engine, player, key); + return 1; + } fail(source, "Unknown structure '" + key + "'. Use tab completion to choose an Iris placement or a registered native/datapack structure."); return 0; } NativeStructureTarget target = resolved.get(); + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, target.key(), false); + if (!decision.generate() + && decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + fail(source, NativeStructureGenerationPolicy.generationStatusMessage( + target.key(), decision.status())); + return 0; + } + if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + locateIrisStructure(source, level, engine, player, target.key()); + return 1; + } if (target.availability() != NativeStructureAvailability.AVAILABLE) { fail(source, nativeUnavailableMessage(target.key(), target.availability())); return 0; @@ -1035,7 +1048,7 @@ public final class IrisModdedCommands { } } int irisPlaced = IrisStructureLocator.placedKeys(engine).size(); - ok(source, "Structure reachability: " + available + " native locatable, " + irisPlaced + ok(source, "Structure reachability: " + available + " native generation-eligible, " + irisPlaced + " Iris-placed, " + disabled + " native disabled, " + suppressed + " native replaced by Iris placements, " + unreachableBiomes + " native excluded by this pack's biomes, and " + unsupported @@ -1044,16 +1057,22 @@ public final class IrisModdedCommands { } private static int verifyStructure(CommandSourceStack source, ServerLevel level, Engine engine, String key) { - if (IrisStructureLocator.isPlaced(engine, key)) { - ok(source, "Structure " + key + " is Iris-placed and locatable with /iris goto structure " + key + "."); - return 1; - } Optional target = resolveNativeStructure(source, level, engine, key); if (target.isEmpty()) { + if (IrisStructureLocator.isPlaced(engine, key)) { + ok(source, "Structure " + key + " is Iris-placed and locatable with /iris goto structure " + key + "."); + return 1; + } fail(source, "Unknown structure '" + key + "'. It is neither Iris-placed nor registered by vanilla or a datapack."); return 0; } NativeStructureTarget resolved = target.get(); + if (resolved.availability() == NativeStructureAvailability.IRIS_SUPPRESSED) { + ok(source, "Structure " + resolved.key() + + " is explicitly replaced by an Iris placement and locatable with /iris goto structure " + + resolved.key() + "."); + return 1; + } if (resolved.availability() != NativeStructureAvailability.AVAILABLE) { fail(source, nativeUnavailableMessage(resolved.key(), resolved.availability())); return 0; @@ -1084,9 +1103,9 @@ public final class IrisModdedCommands { Engine engine, String key, Holder.Reference holder) { boolean worldEnabled = source.getServer().getWorldGenSettings().options().generateStructures(); - IrisImportedStructureControl control = engine.getDimension().getImportedStructures(); - boolean selected = control != null && control.active() && control.shouldGenerate(key); - boolean suppressed = IrisStructureLocator.suppressesVanilla(engine, key); + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, key, false); + boolean selected = decision.status() != NativeStructureGenerationStatus.DISABLED_BY_PACK; + boolean suppressed = decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS; ChunkGenerator chunkGenerator = level.getChunkSource().getGenerator(); boolean biomeReachable = chunkGenerator instanceof IrisModdedChunkGenerator irisGenerator && irisGenerator.isNativeStructureReachable(holder); @@ -1121,8 +1140,10 @@ public final class IrisModdedCommands { private static String nativeUnavailableMessage(String key, NativeStructureAvailability availability) { return switch (availability) { case WORLD_DISABLED -> "Native structure generation is disabled for this world, so " + key + " cannot generate or be located."; - case FILTERED -> "Native structure " + key + " is disabled by this dimension's importedStructures settings."; - case IRIS_SUPPRESSED -> "Native structure " + key + " is replaced by an Iris placement in this pack; locate the Iris key instead."; + case FILTERED -> NativeStructureGenerationPolicy.generationStatusMessage( + key, NativeStructureGenerationStatus.DISABLED_BY_PACK); + case IRIS_SUPPRESSED -> NativeStructureGenerationPolicy.generationStatusMessage( + key, NativeStructureGenerationStatus.REPLACED_BY_IRIS); case BIOME_UNREACHABLE -> "Native structure " + key + " cannot generate because none of its required biomes are produced by this Iris pack."; case NO_PLACEMENT -> "Native structure " + key + " is registered, but its structure set has no placement supported by this dimension's generator state."; case AVAILABLE -> "Native structure " + key + " is available."; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java index 9ae99c05f..2d34f6c52 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java @@ -109,6 +109,7 @@ final class ModdedCommandHelp { Entry.command("x&y", "", "Autoselect up, down and out", "xay"), Entry.command("analyze", "", "Show object composition"), Entry.command("shrink", "", "Shrink an object to its minimum size"), + Entry.command("plausibilize", " [dryrun=true] [reach=N]", "Grow branches so tree leaves survive vanilla decay"), Entry.command("undo", "[amount]", "Undo pasted objects", "u") )); SECTIONS.put("o", SECTIONS.get("object")); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java index 3bde42b9b..831bd1651 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java @@ -19,6 +19,8 @@ package art.arcane.iris.modded.command; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.tools.TreePlausibilizeBatch; +import art.arcane.iris.core.tools.TreePlausibilizer; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectPlacement; @@ -27,6 +29,7 @@ import art.arcane.iris.engine.object.TileData; import art.arcane.iris.modded.ModdedBlockState; import art.arcane.iris.modded.ModdedTileData; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.math.RNG; import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; @@ -43,6 +46,7 @@ import net.minecraft.core.HolderLookup; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.NbtUtils; +import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.level.block.Blocks; @@ -157,7 +161,9 @@ public final class ModdedObjectCommands { root.then(bukkitOnly("we", "WorldEdit selection import requires the Bukkit plugin with WorldEdit installed.")); root.then(bukkitOnly("studio", "The object studio world requires the Bukkit studio toolchain; it is not available on modded servers.")); root.then(bukkitOnly("convert", "Schematic conversion (.schem -> .iob) requires the Bukkit plugin.")); - root.then(bukkitOnly("plausibilize", "Tree plausibilization requires the Bukkit plugin (its block-data tooling is not ported to modded yet).")); + root.then(Commands.literal("plausibilize") + .then(Commands.argument("args", StringArgumentType.greedyString()).suggests(OBJECT_KEYS) + .executes((CommandContext context) -> plausibilize(context.getSource(), StringArgumentType.getString(context, "args"))))); return root; } @@ -645,6 +651,48 @@ public final class ModdedObjectCommands { return 1; } + private static int plausibilize(CommandSourceStack source, String raw) { + Engine engine = IrisModdedCommands.engineFor(source.getLevel()); + IrisData data = engine == null ? null : engine.getData(); + boolean dryRun = false; + int reach = TreePlausibilizer.DEFAULT_REACH; + StringBuilder targetBuilder = new StringBuilder(); + for (String token : raw.trim().split("\\s+")) { + String lower = token.toLowerCase(Locale.ROOT); + if (lower.startsWith("dryrun=")) { + dryRun = Boolean.parseBoolean(lower.substring("dryrun=".length())); + continue; + } + if (lower.startsWith("reach=")) { + try { + reach = Integer.parseInt(lower.substring("reach=".length())); + } catch (NumberFormatException e) { + IrisModdedCommands.fail(source, "Invalid reach: " + token); + return 0; + } + continue; + } + if (!targetBuilder.isEmpty()) { + targetBuilder.append(' '); + } + targetBuilder.append(token); + } + String target = targetBuilder.toString(); + List targets = TreePlausibilizeBatch.resolve(target, data); + if (targets.isEmpty()) { + IrisModdedCommands.fail(source, "No objects matched: " + target); + return 0; + } + IrisModdedCommands.ok(source, "Plausibilize [reach=" + reach + (dryRun ? ", DRY" : "") + + "] queued " + targets.size() + " object(s)"); + boolean dry = dryRun; + int reachFinal = reach; + MinecraftServer server = source.getServer(); + J.a(() -> TreePlausibilizeBatch.run(targets, dry, reachFinal, data, (String line) -> + server.execute(() -> IrisModdedCommands.ok(source, line)))); + return 1; + } + private static int undo(CommandSourceStack source, int amount) { ServerPlayer player = source.getPlayer(); UUID owner = player == null ? ModdedObjectUndo.CONSOLE : player.getUUID(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java index aee8d3f77..21bf75fb7 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java @@ -24,6 +24,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.PlacedStructurePiece; import art.arcane.iris.engine.framework.StructureAssembler; import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.ObjectPlaceMode; import art.arcane.volmlib.util.collection.KList; @@ -137,12 +138,13 @@ public final class ModdedStructureCommands { return 0; } String key = keyRaw.trim(); - IrisStructure structure = IrisData.loadAnyStructure(key, data); + IrisStructure structure = data.load(IrisStructure.class, key, false); if (structure == null) { IrisModdedCommands.fail(source, "No iris structure '" + key + "' in this pack"); return 0; } - StructureAssembler assembler = new StructureAssembler(data, structure, 0, 64, 0); + StructureAssembler assembler = StructureAssembler.forData( + data, structure, new IrisPosition(0, 64, 0)); KList pieces = assembler.assemble(new RNG(1234)); if (pieces == null || pieces.isEmpty()) { IrisModdedCommands.fail(source, "Structure '" + key + "' assembled 0 pieces (check startPool '" + structure.getStartPool() + "')"); @@ -176,7 +178,7 @@ public final class ModdedStructureCommands { return 0; } String key = keyRaw.trim(); - IrisStructure structure = IrisData.loadAnyStructure(key, data); + IrisStructure structure = data.load(IrisStructure.class, key, false); if (structure == null) { IrisModdedCommands.fail(source, "No iris structure '" + key + "' in this pack"); return 0; @@ -184,7 +186,8 @@ public final class ModdedStructureCommands { int originX = player.blockPosition().getX(); int originY = player.blockPosition().getY(); int originZ = player.blockPosition().getZ(); - StructureAssembler assembler = new StructureAssembler(data, structure, originX, originY, originZ); + StructureAssembler assembler = StructureAssembler.forData( + data, structure, new IrisPosition(originX, originY, originZ)); RNG rng = new RNG((long) originX * 341873128712L + originZ); KList pieces = assembler.assemble(rng); if (pieces == null || pieces.isEmpty()) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java index 45c38fa57..856976795 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java @@ -23,6 +23,7 @@ import art.arcane.iris.core.gui.GuiHost; import art.arcane.iris.core.gui.NoiseExplorerGUI; import art.arcane.iris.core.gui.VisionGUI; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.StructurePackageClosure; import art.arcane.iris.core.project.IrisProjectCopier; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisBiome; @@ -33,6 +34,7 @@ import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisSpawner; +import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedPackInstaller; @@ -635,10 +637,12 @@ public final class ModdedStudioCommands { LinkedHashSet generatorKeys = new LinkedHashSet<>(); LinkedHashSet lootKeys = new LinkedHashSet<>(); LinkedHashSet objectKeys = new LinkedHashSet<>(); + LinkedHashSet structureKeys = new LinkedHashSet<>(); regionKeys.addAll(dimension.getRegions()); lootKeys.addAll(dimension.getLoot().getTables()); spawnerKeys.addAll(dimension.getEntitySpawners()); + collectStructureKeys(structureKeys, dimension.getStructures()); for (String regionKey : regionKeys) { IrisRegion region = dm.getRegionLoader().load(regionKey); @@ -652,6 +656,7 @@ public final class ModdedStudioCommands { }); lootKeys.addAll(region.getLoot().getTables()); spawnerKeys.addAll(region.getEntitySpawners()); + collectStructureKeys(structureKeys, region.getStructures()); } for (String biomeKey : biomeKeys) { IrisBiome biome = dm.getBiomeLoader().load(biomeKey); @@ -661,6 +666,7 @@ public final class ModdedStudioCommands { biome.getGenerators().forEach((IrisBiomeGeneratorLink link) -> generatorKeys.add(link.getGenerator())); lootKeys.addAll(biome.getLoot().getTables()); spawnerKeys.addAll(biome.getEntitySpawners()); + collectStructureKeys(structureKeys, biome.getStructures()); for (IrisObjectPlacement placement : biome.getObjects()) { objectKeys.addAll(placement.getPlace()); } @@ -674,6 +680,11 @@ public final class ModdedStudioCommands { } StringBuilder hashes = new StringBuilder(); + StructurePackageClosure structureClosure = StructurePackageClosure.collect(packFolder, structureKeys); + if (!structureClosure.isValid()) { + throw new IOException("Structure package closure is invalid: " + String.join("; ", structureClosure.errors())); + } + hashes.append(structureClosure.writeTo(folder, true)); for (String objectKey : objectKeys) { try { File objectFile = dm.getObjectLoader().findFile(objectKey); @@ -716,6 +727,20 @@ public final class ModdedStudioCommands { return output; } + private static void collectStructureKeys(LinkedHashSet keys, List placements) { + if (placements == null) { + return; + } + for (IrisStructurePlacement placement : placements) { + if (placement == null || placement.getStructures() == null) { + continue; + } + for (String structureKey : placement.getStructures()) { + keys.add(structureKey); + } + } + } + private static String copyJson(File folder, String category, String key, File file) { if (file == null || !file.isFile()) { return ""; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java index aba61006c..6e604e189 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java @@ -19,8 +19,7 @@ package art.arcane.iris.modded.command; import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.core.pack.PackValidationRegistry; -import art.arcane.iris.core.pack.PackValidationResult; +import art.arcane.iris.core.pack.BrokenPackException; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.MainWorldService; @@ -29,6 +28,7 @@ import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedModConfig; import art.arcane.iris.modded.ModdedPackInstaller; import art.arcane.iris.modded.ModdedPrimaryWorldRouter; +import art.arcane.iris.modded.ModdedStartup; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; @@ -187,10 +187,10 @@ public final class ModdedWorldCommands { } private static int enableInstalled(CommandSourceStack source, MinecraftServer server, String dimensionId, String pack, String packDimension, long seed) { - if (!loadPackDimension(source, pack, packDimension)) { + if (blockIfPackBroken(source, dimensionId, pack)) { return 0; } - if (blockIfPackBroken(source, dimensionId, pack)) { + if (!loadPackDimension(source, pack, packDimension)) { return 0; } try { @@ -218,10 +218,10 @@ public final class ModdedWorldCommands { return 0; } String dimensionId = DEFAULT_NAMESPACE + ":primary"; - if (!loadPackDimension(source, pack, packDimension)) { + if (blockIfPackBroken(source, dimensionId, pack)) { return 0; } - if (blockIfPackBroken(source, dimensionId, pack)) { + if (!loadPackDimension(source, pack, packDimension)) { return 0; } try { @@ -290,6 +290,9 @@ public final class ModdedWorldCommands { } private static int applyMainWorld(CommandSourceStack source, String pack, String packDimension, String packRef, long seed) { + if (blockIfPackBroken(source, "the main world", pack)) { + return 0; + } try { if (!loadPackDimension(source, pack, packDimension)) { return 0; @@ -299,9 +302,6 @@ public final class ModdedWorldCommands { IrisModdedCommands.fail(source, "Pack '" + pack + "' is not ready yet (still loading or validating). Try the command again in a moment."); return 0; } - if (blockIfPackBroken(source, "the main world", pack)) { - return 0; - } ModdedModConfig.setMainWorld(packRef, seed); if (!MainWorldService.stage(packRef, seed)) { IrisModdedCommands.fail(source, "Failed to write server.properties; check file permissions and set level-type manually."); @@ -321,16 +321,18 @@ public final class ModdedWorldCommands { } private static boolean blockIfPackBroken(CommandSourceStack source, String dimensionId, String pack) { - PackValidationResult validation = PackValidationRegistry.get(pack); - if (validation == null || validation.isLoadable()) { + try { + ModdedStartup.requirePackForWorldCreation(pack); return false; + } catch (BrokenPackException e) { + IrisModdedCommands.fail(source, "Refusing to create world '" + dimensionId + + "' using pack '" + pack + "' because required validation failed:"); + for (String reason : e.getReasons()) { + IrisModdedCommands.fail(source, " - " + reason); + } + IrisModdedCommands.fail(source, "Fix the pack and run /iris pack validate " + pack + " to revalidate."); + return true; } - IrisModdedCommands.fail(source, "Refusing to create world '" + dimensionId + "' using broken pack '" + pack + "':"); - for (String reason : validation.getBlockingErrors()) { - IrisModdedCommands.fail(source, " - " + reason); - } - IrisModdedCommands.fail(source, "Fix the pack and run /iris pack validate " + pack + " to revalidate."); - return true; } private static boolean loadPackDimension(CommandSourceStack source, String pack, String packDimension) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java index c4096b2a8..d448eedfa 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java @@ -34,8 +34,6 @@ import art.arcane.iris.util.project.matter.TileWrapper; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; -import art.arcane.volmlib.util.math.BlockPosition; -import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterUpdate; @@ -287,7 +285,6 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { } } - RNG rng = new RNG(Cache.key(chunkX, chunkZ)); chunk.iterate(MatterCavern.class, (Integer x, Integer yf, Integer z, MatterCavern v) -> { int y = yf + minHeight; if (y < level.getMinY() || y >= level.getMaxY()) { @@ -315,20 +312,20 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { if (grid[x][z] == Integer.MIN_VALUE) { continue; } - update(engine, level, x, grid[x][z], z, baseX, baseZ, chunk, rng); + update(engine, level, x, grid[x][z], z, baseX, baseZ, chunk); } } chunk.iterate(MatterUpdate.class, (Integer x, Integer yf, Integer z, MatterUpdate v) -> { if (v != null && v.isUpdate()) { - update(engine, level, x, yf + minHeight, z, baseX, baseZ, chunk, rng); + update(engine, level, x, yf + minHeight, z, baseX, baseZ, chunk); } }); chunk.deleteSlices(MatterUpdate.class); engine.getMetrics().getUpdates().put(stopwatch.getMilliseconds()); } - private void update(Engine engine, ServerLevel level, int x, int y, int z, int baseX, int baseZ, MantleChunk chunk, RNG rng) { + private void update(Engine engine, ServerLevel level, int x, int y, int z, int baseX, int baseZ, MantleChunk chunk) { if (y < level.getMinY() || y >= level.getMaxY()) { return; } @@ -339,9 +336,8 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { if (!ModdedBlockResolution.isStorageChest(state)) { return; } - RNG rx = rng.nextParallelRNG(BlockPosition.toLong(x, y, z)); try { - ModdedLootApplier.apply(engine, level, pos, state, chunk, rx, x & 15, z & 15); + ModdedLootApplier.apply(engine, level, pos, state, chunk); } catch (Throwable e) { IrisLogging.reportError(e); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/structure/ModdedJigsawStructureCapture.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/structure/ModdedJigsawStructureCapture.java new file mode 100644 index 000000000..238be29bf --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/structure/ModdedJigsawStructureCapture.java @@ -0,0 +1,620 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded.structure; + +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureLoss; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.util.common.math.IrisBlockVector; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.mojang.datafixers.util.Pair; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Registry; +import net.minecraft.core.Vec3i; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.JigsawBlock; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; +import net.minecraft.world.level.levelgen.structure.pools.EmptyPoolElement; +import net.minecraft.world.level.levelgen.structure.pools.FeaturePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.LegacySinglePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement; +import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; +import net.minecraft.world.level.levelgen.structure.pools.alias.PoolAliasBinding; +import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +final class ModdedJigsawStructureCapture { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private final MinecraftServer server; + private final StructureKey sourceKey; + private final StructureKey targetKey; + private final StructureSource.Kind sourceKind; + private final Registry structureRegistry; + private final Registry poolRegistry; + private final Registry blockRegistry; + private final StructureTemplateManager templateManager; + private final RegistryOps registryOps; + private final Map, ResourceKey> aliases; + private final Map objects; + private final Map> pieces; + private final Map> pools; + private final EnumSet capabilities; + private final List losses; + private final Set recordedLosses; + private final Set visitedPools; + private final Deque pendingPools; + private int blocks; + + private ModdedJigsawStructureCapture( + MinecraftServer server, + StructureKey sourceKey, + StructureKey targetKey, + StructureSource.Kind sourceKind + ) { + this.server = Objects.requireNonNull(server); + this.sourceKey = Objects.requireNonNull(sourceKey); + this.targetKey = Objects.requireNonNull(targetKey); + this.sourceKind = Objects.requireNonNull(sourceKind); + structureRegistry = server.registryAccess().lookupOrThrow(Registries.STRUCTURE); + poolRegistry = server.registryAccess().lookupOrThrow(Registries.TEMPLATE_POOL); + blockRegistry = server.registryAccess().lookupOrThrow(Registries.BLOCK); + templateManager = server.getStructureManager(); + registryOps = RegistryOps.create(NbtOps.INSTANCE, server.registryAccess()); + aliases = new HashMap<>(); + objects = new LinkedHashMap<>(); + pieces = new LinkedHashMap<>(); + pools = new LinkedHashMap<>(); + capabilities = EnumSet.of( + StructureCapability.BLOCKS, + StructureCapability.CONNECTORS, + StructureCapability.IRIS_PLACEMENT + ); + losses = new ArrayList<>(); + recordedLosses = new HashSet<>(); + visitedPools = new HashSet<>(); + pendingPools = new ArrayDeque<>(); + } + + static Capture capture( + MinecraftServer server, + StructureKey sourceKey, + StructureKey targetKey, + StructureSource.Kind sourceKind + ) throws IOException { + return new ModdedJigsawStructureCapture(server, sourceKey, targetKey, sourceKind).capture(); + } + + private Capture capture() throws IOException { + Identifier sourceIdentifier = identifier(sourceKey); + Structure structure = structureRegistry.getValue(sourceIdentifier); + if (structure == null) { + throw new IllegalArgumentException("No registered structure exists for " + sourceKey); + } + if (!(structure instanceof JigsawStructure jigsaw)) { + throw new UnsupportedStructureTypeException("Structure " + sourceKey + " uses " + + structure.getClass().getSimpleName() + "; only jigsaw structure graphs can be converted to Iris assembly resources"); + } + + CompoundTag encodedStructure = encodeStructure(structure); + configureAliases(jigsaw); + recordRootLosses(jigsaw); + String startPoolKey = poolKey(jigsaw.getStartPool().value()); + pendingPools.add(startPoolKey); + while (!pendingPools.isEmpty()) { + capturePool(pendingPools.removeFirst()); + } + if (pieces.isEmpty()) { + throw new IllegalStateException("Structure " + sourceKey + " produced no importable pieces"); + } + + int maxDepth = Math.max(1, encodedStructure.getIntOr("size", 1)); + int maxDistance = readHorizontalDistance(encodedStructure); + StructureSource source = StructureSource.identified( + sourceKind, + sourceKey, + SharedConstants.getCurrentVersion().name(), + NbtUtils.structureToSnbt(encodedStructure).getBytes(StandardCharsets.UTF_8) + ); + StructureResourceBundle bundle = buildBundle(source, startPoolKey, maxDepth, maxDistance); + return new Capture(bundle, blocks, pieces.size(), pools.size()); + } + + private CompoundTag encodeStructure(Structure structure) { + Tag encoded = Structure.DIRECT_CODEC.encodeStart(registryOps, structure).getOrThrow(); + if (!(encoded instanceof CompoundTag compound)) { + throw new IllegalStateException("Structure codec did not produce a compound for " + sourceKey); + } + return compound; + } + + private void configureAliases(JigsawStructure structure) { + List bindings = structure.getPoolAliases(); + if (bindings.isEmpty()) { + return; + } + RandomSource random = RandomSource.create(stableSeed(sourceKey.value())); + for (PoolAliasBinding binding : bindings) { + binding.forEachResolved(random, aliases::put); + } + addLossOnce( + "pool_aliases_resolved_once", + StructureLoss.warning( + StructureCapability.CONNECTORS, + "pool_aliases_resolved_once", + bindings.size() + " native pool alias binding(s) were resolved deterministically for the imported graph; per-placement alias variation is not represented." + ) + ); + } + + private void recordRootLosses(JigsawStructure structure) { + losses.add(StructureLoss.warning( + StructureCapability.NATIVE_PLACEMENT, + "native_placement_settings_not_imported", + "Native start height, heightmap projection, expansion, padding, and placement-set settings are not represented by Iris assembly placement." + )); + losses.add(StructureLoss.warning( + StructureCapability.LIQUID_SETTINGS, + "native_liquid_settings_not_imported", + "Native structure liquid placement behavior is not represented beyond the captured block and waterlogged states." + )); + if (structure.terrainAdaptation() != TerrainAdjustment.NONE) { + losses.add(StructureLoss.warning( + StructureCapability.TERRAIN_ADAPTATION, + "terrain_adaptation_not_imported", + "Native terrain adaptation '" + structure.terrainAdaptation().getSerializedName() + + "' is not represented by the Iris assembly." + )); + } + } + + private void capturePool(String sourcePoolKey) throws IOException { + if (!visitedPools.add(sourcePoolKey)) { + return; + } + Identifier sourcePoolIdentifier = Identifier.tryParse(sourcePoolKey); + StructureTemplatePool pool = sourcePoolIdentifier == null ? null : poolRegistry.getValue(sourcePoolIdentifier); + if (pool == null) { + throw new IllegalStateException("Jigsaw graph references missing template pool " + sourcePoolKey); + } + String irisPoolName = poolName(targetKey.path(), sourcePoolKey); + List entries = new ArrayList<>(); + List> templates = pool.getTemplates(); + for (int index = 0; index < templates.size(); index++) { + Pair weighted = templates.get(index); + StructurePoolElement element = weighted.getFirst(); + int weight = Math.max(1, weighted.getSecond()); + Map entry = new LinkedHashMap<>(); + if (element == EmptyPoolElement.INSTANCE) { + entry.put("empty", true); + } else { + entry.put("piece", captureElement(sourcePoolKey, index, element)); + } + entry.put("weight", weight); + entries.add(entry); + } + + Map poolJson = new LinkedHashMap<>(); + poolJson.put("pieces", entries); + String fallback = resolvedPoolKey(pool.getFallback().value()); + if (!fallback.equals(sourcePoolKey)) { + poolJson.put("fallback", poolName(targetKey.path(), fallback)); + pendingPools.addLast(fallback); + } + pools.put(irisPoolName, poolJson); + } + + private String captureElement(String sourcePoolKey, int index, StructurePoolElement element) throws IOException { + Objects.requireNonNull(element, "pool element"); + if (element instanceof SinglePoolElement single) { + return captureSingle(single); + } + String generatedName = generatedPieceName(targetKey.path(), sourcePoolKey, index, element); + if (pieces.containsKey(generatedName)) { + return generatedName; + } + if (element instanceof ListPoolElement list) { + capabilities.add(StructureCapability.LIST_ELEMENTS); + CompositeCapture composite = captureList(list, generatedName); + blocks += composite.object().getBlocks().size(); + emitPiece(generatedName, composite.object(), composite.losses(), element); + return generatedName; + } + IrisObject object = emptyObject(element); + StructureCapability unsupported = element instanceof FeaturePoolElement + ? StructureCapability.FEATURE_ELEMENTS : StructureCapability.BLOCKS; + StructureLoss loss = StructureLoss.warning( + unsupported, + "unsupported_pool_element", + "Pool element " + element.getClass().getSimpleName() + " was represented as an empty Iris piece." + ); + emitPiece(generatedName, object, List.of(loss), element); + return generatedName; + } + + private String captureSingle(SinglePoolElement element) throws IOException { + Identifier templateIdentifier = element.getTemplateLocation(); + StructureKey templateKey = StructureKey.parse(templateIdentifier.toString()); + boolean legacy = element instanceof LegacySinglePoolElement; + String pieceName = legacy + ? legacyPieceName(targetKey.path(), templateKey.value()) + : pieceName(targetKey.path(), templateKey.value()); + if (pieces.containsKey(pieceName)) { + return pieceName; + } + StructureTemplate template = templateManager.get(templateIdentifier) + .orElseThrow(() -> new IllegalStateException("Missing structure template " + templateIdentifier)); + ModdedStructureTemplateCapture.Capture capture = ModdedStructureTemplateCapture.capture( + templateKey, template, blockRegistry, true, !legacy); + capabilities.addAll(capture.capabilities()); + blocks += capture.blocks(); + List pieceLosses = new ArrayList<>(capture.losses()); + pieceLosses.addAll(elementLosses(element)); + emitPiece(pieceName, capture.object(), pieceLosses, element); + return pieceName; + } + + private CompositeCapture captureList(ListPoolElement list, String pieceName) throws IOException { + Vec3i size = list.getSize(templateManager, Rotation.NONE); + IrisObject composite = new IrisObject( + Math.max(1, size.getX()), + Math.max(1, size.getY()), + Math.max(1, size.getZ()) + ); + List compositeLosses = new ArrayList<>(); + for (StructurePoolElement child : list.getElements()) { + if (child == EmptyPoolElement.INSTANCE) { + continue; + } + if (child instanceof SinglePoolElement single) { + Identifier templateIdentifier = single.getTemplateLocation(); + StructureTemplate template = templateManager.get(templateIdentifier) + .orElseThrow(() -> new IllegalStateException("Missing structure template " + templateIdentifier)); + ModdedStructureTemplateCapture.Capture capture = ModdedStructureTemplateCapture.capture( + StructureKey.parse(templateIdentifier.toString()), + template, + blockRegistry, + true, + !(single instanceof LegacySinglePoolElement) + ); + merge(composite, capture.object()); + capabilities.addAll(capture.capabilities()); + compositeLosses.addAll(capture.losses()); + compositeLosses.addAll(elementLosses(single)); + continue; + } + if (child instanceof ListPoolElement nested) { + CompositeCapture nestedCapture = captureList(nested, pieceName); + merge(composite, nestedCapture.object()); + compositeLosses.addAll(nestedCapture.losses()); + continue; + } + StructureCapability unsupported = child instanceof FeaturePoolElement + ? StructureCapability.FEATURE_ELEMENTS : StructureCapability.LIST_ELEMENTS; + compositeLosses.add(StructureLoss.warning( + unsupported, + "list_child_not_imported", + "List element child " + child.getClass().getSimpleName() + " could not be flattened into the Iris object." + )); + } + compositeLosses.addAll(elementLosses(list)); + return new CompositeCapture(composite, compositeLosses); + } + + private List elementLosses(StructurePoolElement element) { + List elementLosses = new ArrayList<>(); + if (element.getProjection() != StructureTemplatePool.Projection.RIGID) { + elementLosses.add(StructureLoss.warning( + StructureCapability.PROJECTION, + "terrain_matching_projection_not_imported", + "Pool projection '" + element.getProjection().getSerializedName() + + "' was converted to rigid Iris piece placement." + )); + } + Tag encoded = StructurePoolElement.CODEC.encodeStart(registryOps, element).getOrThrow(); + if (encoded instanceof CompoundTag compound) { + String processors = compound.getStringOr("processors", ""); + if (!processors.isEmpty() && !processors.equals("minecraft:empty")) { + elementLosses.add(StructureLoss.warning( + StructureCapability.PROCESSORS, + "native_processors_not_imported", + "Native processor list '" + processors + "' was not applied to the captured template." + )); + } + if (compound.contains("override_liquid_settings")) { + elementLosses.add(StructureLoss.warning( + StructureCapability.LIQUID_SETTINGS, + "element_liquid_settings_not_imported", + "The pool element's liquid setting override is not represented by Iris placement." + )); + } + } + return elementLosses; + } + + private void emitPiece( + String pieceName, + IrisObject object, + List pieceLosses, + StructurePoolElement element + ) throws IOException { + String objectResource = "objects/" + pieceName + ".iob"; + objects.put(pieceName, serialize(object)); + for (StructureLoss loss : pieceLosses) { + losses.add(loss.affecting(objectResource)); + } + List> connectors = connectors(element, pieceName); + Map pieceJson = new LinkedHashMap<>(); + pieceJson.put("object", pieceName); + pieceJson.put("connectors", connectors); + pieceJson.put("rotatable", true); + pieces.put(pieceName, pieceJson); + } + + private List> connectors(StructurePoolElement element, String pieceName) { + List sourceConnectors = element.getShuffledJigsawBlocks( + templateManager, + BlockPos.ZERO, + Rotation.NONE, + RandomSource.create(stableSeed(sourceKey.value() + ":" + pieceName)) + ); + List> connectors = new ArrayList<>(sourceConnectors.size()); + for (StructureTemplate.JigsawBlockInfo source : sourceConnectors) { + recordConnectorLosses(source, pieceName); + ResourceKey resolvedPool = aliases.getOrDefault(source.pool(), source.pool()); + String sourcePoolKey = resolvedPool.identifier().toString(); + pendingPools.addLast(sourcePoolKey); + Map position = new LinkedHashMap<>(); + position.put("x", source.info().pos().getX()); + position.put("y", source.info().pos().getY()); + position.put("z", source.info().pos().getZ()); + Map connector = new LinkedHashMap<>(); + connector.put("position", position); + connector.put("direction", directionName(JigsawBlock.getFrontFacing(source.info().state()))); + connector.put("top", directionName(JigsawBlock.getTopFacing(source.info().state()))); + connector.put("pool", poolName(targetKey.path(), sourcePoolKey)); + connector.put("name", source.name().toString()); + connector.put("targetName", source.target().toString()); + connector.put("joint", source.jointType().getSerializedName().equals("aligned") ? "ALIGNED" : "ROLLABLE"); + connectors.add(connector); + } + return connectors; + } + + private void recordConnectorLosses(StructureTemplate.JigsawBlockInfo connector, String pieceName) { + String pieceResource = "jigsaw-pieces/" + pieceName + ".json"; + if (connector.placementPriority() != 0 || connector.selectionPriority() != 0) { + addLossOnce( + "connector-priority:" + pieceName, + StructureLoss.warning( + StructureCapability.CONNECTORS, + "connector_priorities_not_imported", + "Native connector placement and selection priorities are not represented by Iris assembly ordering." + ).affecting(pieceResource) + ); + } + } + + private StructureResourceBundle buildBundle( + StructureSource source, + String startPoolKey, + int maxDepth, + int maxDistance + ) { + StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(targetKey) + .source(source) + .backend(StructureBackend.IRIS_ASSEMBLY) + .capabilities(capabilities) + .losses(losses); + for (Map.Entry entry : objects.entrySet()) { + bundle.resource("objects/" + entry.getKey() + ".iob", entry.getValue()); + } + for (Map.Entry> entry : pieces.entrySet()) { + bundle.textResource("jigsaw-pieces/" + entry.getKey() + ".json", GSON.toJson(entry.getValue())); + } + for (Map.Entry> entry : pools.entrySet()) { + bundle.textResource("jigsaw-pools/" + entry.getKey() + ".json", GSON.toJson(entry.getValue())); + } + bundle.textResource( + "structures/" + targetKey.path() + ".json", + GSON.toJson(structureJson(sourceKey.value(), poolName(targetKey.path(), startPoolKey), maxDepth, maxDistance)) + ); + return bundle.build(); + } + + private String resolvedPoolKey(StructureTemplatePool pool) { + ResourceKey raw = poolResourceKey(pool); + return aliases.getOrDefault(raw, raw).identifier().toString(); + } + + private String poolKey(StructureTemplatePool pool) { + return poolResourceKey(pool).identifier().toString(); + } + + private ResourceKey poolResourceKey(StructureTemplatePool pool) { + Identifier identifier = poolRegistry.getKey(pool); + if (identifier == null) { + throw new IllegalStateException("Jigsaw structure references an unregistered template pool"); + } + return ResourceKey.create(Registries.TEMPLATE_POOL, identifier); + } + + private void addLossOnce(String key, StructureLoss loss) { + if (recordedLosses.add(key)) { + losses.add(loss); + } + } + + private static void merge(IrisObject target, IrisObject source) { + for (IrisBlockVector position : source.getBlocks().keys()) { + int x = position.getBlockX() + source.getCenter().getX(); + int y = position.getBlockY() + source.getCenter().getY(); + int z = position.getBlockZ() + source.getCenter().getZ(); + if (x < 0 || y < 0 || z < 0 || x >= target.getW() || y >= target.getH() || z >= target.getD()) { + continue; + } + target.setUnsignedTile(x, y, z, null); + target.setUnsigned(x, y, z, source.getBlocks().get(position)); + TileData tile = source.getStates().get(position); + if (tile != null) { + target.setUnsignedTile(x, y, z, tile); + } + } + } + + private static IrisObject emptyObject() { + return new IrisObject(1, 1, 1); + } + + private IrisObject emptyObject(StructurePoolElement element) { + Vec3i size = element.getSize(templateManager, Rotation.NONE); + return new IrisObject( + Math.max(1, size.getX()), + Math.max(1, size.getY()), + Math.max(1, size.getZ()) + ); + } + + private static byte[] serialize(IrisObject object) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + object.write(output); + return output.toByteArray(); + } + + private static int readHorizontalDistance(CompoundTag encodedStructure) { + int scalar = encodedStructure.getIntOr("max_distance_from_center", -1); + if (scalar > 0) { + return scalar; + } + CompoundTag compound = encodedStructure.getCompoundOrEmpty("max_distance_from_center"); + return Math.max(1, compound.getIntOr("horizontal", 80)); + } + + static Map structureJson(String source, String startPool, int maxDepth, int maxDistance) { + Map root = new LinkedHashMap<>(); + root.put("startPool", startPool); + root.put("maxDepth", Math.max(1, Math.min(30, maxDepth))); + root.put("maxSizeChunks", Math.max(1, Math.min(32, (Math.max(1, maxDistance) + 15) / 16))); + root.put("placeMode", "STRUCTURE_PIECE"); + root.put("vanillaSource", source); + return root; + } + + static String poolName(String base, String sourcePoolKey) { + StructureKey key = StructureKey.parse(sourcePoolKey); + return base + "/pool/" + key.namespace() + "/" + key.path(); + } + + static String pieceName(String base, String templateKey) { + StructureKey key = StructureKey.parse(templateKey); + return base + "/piece/" + key.namespace() + "/" + key.path(); + } + + static String legacyPieceName(String base, String templateKey) { + StructureKey key = StructureKey.parse(templateKey); + return base + "/piece/generated/legacy/" + key.namespace() + "/" + key.path(); + } + + static String directionName(Direction direction) { + return switch (direction) { + case UP -> "UP_POSITIVE_Y"; + case DOWN -> "DOWN_NEGATIVE_Y"; + case SOUTH -> "SOUTH_POSITIVE_Z"; + case EAST -> "EAST_POSITIVE_X"; + case WEST -> "WEST_NEGATIVE_X"; + case NORTH -> "NORTH_NEGATIVE_Z"; + }; + } + + private static String generatedPieceName(String base, String sourcePoolKey, int index, StructurePoolElement element) { + StructureKey poolKey = StructureKey.parse(sourcePoolKey); + String type = element instanceof ListPoolElement ? "list" : element instanceof FeaturePoolElement ? "feature" : "unsupported"; + return base + "/piece/generated/" + type + "/" + poolKey.namespace() + "/" + poolKey.path() + "/" + index; + } + + private static long stableSeed(String value) { + long hash = 0xcbf29ce484222325L; + for (int index = 0; index < value.length(); index++) { + hash ^= value.charAt(index); + hash *= 0x100000001b3L; + } + return hash; + } + + private static Identifier identifier(StructureKey key) { + return Identifier.fromNamespaceAndPath(key.namespace(), key.path()); + } + + record Capture(StructureResourceBundle bundle, int blocks, int pieces, int pools) { + Capture { + Objects.requireNonNull(bundle); + } + } + + static final class UnsupportedStructureTypeException extends IllegalArgumentException { + UnsupportedStructureTypeException(String message) { + super(message); + } + } + + private record CompositeCapture(IrisObject object, List losses) { + CompositeCapture { + Objects.requireNonNull(object); + losses = List.copyOf(losses); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/structure/ModdedStructureImportService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/structure/ModdedStructureImportService.java new file mode 100644 index 000000000..7e217c4e8 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/structure/ModdedStructureImportService.java @@ -0,0 +1,426 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded.structure; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.structure.authoring.IrisStructureBundleFactory; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureLoss; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteMode; +import art.arcane.iris.core.structure.authoring.StructureWriteOptions; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; +import net.minecraft.SharedConstants; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; + +public final class ModdedStructureImportService { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + + private final Supplier server; + + public ModdedStructureImportService(Supplier server) { + this.server = Objects.requireNonNull(server); + } + + public List templateKeys() throws StructureImportException { + try { + MinecraftServer activeServer = requireServerThread(); + return activeServer.getStructureManager().listTemplates() + .map((Identifier identifier) -> StructureKey.parse(identifier.toString())) + .sorted() + .toList(); + } catch (RuntimeException failure) { + throw report("Failed to list native structure templates", failure); + } + } + + public List jigsawStructureKeys() throws StructureImportException { + try { + MinecraftServer activeServer = requireServerThread(); + Registry registry = activeServer.registryAccess().lookupOrThrow(Registries.STRUCTURE); + List keys = new ArrayList<>(); + for (Identifier identifier : registry.keySet()) { + if (registry.getValue(identifier) instanceof JigsawStructure) { + keys.add(StructureKey.parse(identifier.toString())); + } + } + keys.sort(Comparator.naturalOrder()); + return List.copyOf(keys); + } catch (RuntimeException failure) { + throw report("Failed to list registered jigsaw structures", failure); + } + } + + public PreparedImport prepareTemplate(TemplateImportOptions options) throws StructureImportException { + Objects.requireNonNull(options); + try { + MinecraftServer activeServer = requireServerThread(); + Identifier sourceIdentifier = identifier(options.sourceKey()); + StructureTemplate template = activeServer.getStructureManager().get(sourceIdentifier) + .orElseThrow(() -> new IllegalArgumentException("No structure template exists for " + options.sourceKey())); + ModdedStructureTemplateCapture.Capture capture = ModdedStructureTemplateCapture.capture( + options.sourceKey(), + template, + activeServer.registryAccess().lookupOrThrow(Registries.BLOCK), + false + ); + StructureSource source = StructureSource.identified( + options.sourceKind(), + options.sourceKey(), + SharedConstants.getCurrentVersion().name(), + NbtUtils.structureToSnbt(capture.sourceTag()).getBytes(StandardCharsets.UTF_8) + ); + IrisStructureBundleFactory.SinglePieceOptions bundleOptions = new IrisStructureBundleFactory.SinglePieceOptions( + options.targetKey(), + source, + options.targetKey().path(), + capture.object(), + Math.max(capture.width(), capture.depth()), + options.placeMode(), + options.objectOnly(), + capture.capabilities(), + capture.losses() + ); + StructureResourceBundle bundle = IrisStructureBundleFactory.singlePiece(bundleOptions); + if (!options.objectOnly()) { + StructureResourceBundleGraphCompiler.requireViable(bundle); + } + return new PreparedImport( + options.packRoot(), + options.writeOptions(), + ImportKind.TEMPLATE, + bundle, + capture.blocks(), + 1, + options.objectOnly() ? 0 : 1 + ); + } catch (IOException | RuntimeException failure) { + throw report("Failed to prepare structure template import " + options.sourceKey(), failure); + } + } + + public PreparedImport prepareJigsawStructure(JigsawImportOptions options) throws StructureImportException { + Objects.requireNonNull(options); + try { + MinecraftServer activeServer = requireServerThread(); + ModdedJigsawStructureCapture.Capture capture = ModdedJigsawStructureCapture.capture( + activeServer, + options.sourceKey(), + options.targetKey(), + options.sourceKind() + ); + StructureResourceBundleGraphCompiler.requireViable(capture.bundle()); + return new PreparedImport( + options.packRoot(), + options.writeOptions(), + ImportKind.JIGSAW_STRUCTURE, + capture.bundle(), + capture.blocks(), + capture.pieces(), + capture.pools() + ); + } catch (ModdedJigsawStructureCapture.UnsupportedStructureTypeException failure) { + StructureLoss loss = StructureLoss.error( + StructureCapability.IRIS_PLACEMENT, + "unsupported_native_structure_type", + failure.getMessage() + ); + throw report("Cannot import native structure graph " + options.sourceKey(), failure, List.of(loss)); + } catch (IOException | RuntimeException failure) { + throw report("Failed to prepare jigsaw structure import " + options.sourceKey(), failure); + } + } + + public ImportResult write(PreparedImport prepared) { + Objects.requireNonNull(prepared); + StructureWriteResult writeResult = new StructureTransactionWriter(prepared.packRoot()) + .write(prepared.bundle(), prepared.writeOptions()); + writeResult.failure().ifPresent((Throwable failure) -> LOGGER.error( + "Failed to commit modded structure import {} to {}", + prepared.bundle().key(), + prepared.packRoot(), + failure + )); + if (writeResult.committed()) { + IrisData.getLoaded(prepared.packRoot().toFile()).ifPresent(IrisData::invalidateStructureResources); + } + String message = writeMessage(prepared, writeResult); + return new ImportResult( + writeResult.successful(), + message, + prepared.kind(), + prepared.bundle().key(), + prepared.blocks(), + prepared.pieces(), + prepared.pools(), + prepared.bundle().capabilities(), + prepared.bundle().losses(), + Optional.of(writeResult) + ); + } + + public ImportResult importTemplate(TemplateImportOptions options) { + try { + return write(prepareTemplate(options)); + } catch (StructureImportException failure) { + return failed(options.targetKey(), ImportKind.TEMPLATE, failure.getMessage(), failure.losses()); + } + } + + public ImportResult importJigsawStructure(JigsawImportOptions options) { + try { + return write(prepareJigsawStructure(options)); + } catch (StructureImportException failure) { + return failed(options.targetKey(), ImportKind.JIGSAW_STRUCTURE, failure.getMessage(), failure.losses()); + } + } + + private MinecraftServer requireServerThread() { + MinecraftServer activeServer = server.get(); + if (activeServer == null) { + throw new IllegalStateException("Minecraft server is not available"); + } + if (!activeServer.isSameThread()) { + throw new IllegalStateException("Structure registry capture must run on the logical server thread"); + } + return activeServer; + } + + private StructureImportException report(String context, Exception failure) { + return report(context, failure, List.of()); + } + + private StructureImportException report(String context, Exception failure, List losses) { + LOGGER.error(context, failure); + return new StructureImportException(context + ": " + failureDetail(failure), failure, losses); + } + + private static ImportResult failed( + StructureKey targetKey, + ImportKind kind, + String message, + List losses + ) { + return new ImportResult( + false, + message, + kind, + targetKey, + 0, + 0, + 0, + Set.of(), + losses, + Optional.empty() + ); + } + + private static String writeMessage(PreparedImport prepared, StructureWriteResult result) { + if (result.successful()) { + return switch (result.status()) { + case DRY_RUN -> "Validated import of '" + prepared.bundle().key() + "' without writing files"; + case ADDED -> "Imported '" + prepared.bundle().key() + "'"; + case OVERWRITTEN -> "Overwrote owned import '" + prepared.bundle().key() + "'"; + case UNCHANGED -> "Import '" + prepared.bundle().key() + "' is already current"; + case COMMITTED_CLEANUP_REQUIRED -> "Imported '" + prepared.bundle().key() + + "'; obsolete staging cleanup is still required"; + default -> "Imported '" + prepared.bundle().key() + "'"; + }; + } + if (!result.conflicts().isEmpty()) { + StructureWriteResult.Conflict conflict = result.conflicts().getFirst(); + return "Import conflict for '" + prepared.bundle().key() + "': " + conflict.relativePath() + + " is " + conflict.reason().name().toLowerCase() + "; existing files were preserved"; + } + return "Import failed for '" + prepared.bundle().key() + "': " + + result.failure().map(ModdedStructureImportService::failureDetail).orElse(result.status().name()); + } + + private static String failureDetail(Throwable failure) { + String message = failure.getMessage(); + return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; + } + + private static Identifier identifier(StructureKey key) { + return Identifier.fromNamespaceAndPath(key.namespace(), key.path()); + } + + public enum ImportKind { + TEMPLATE, + JIGSAW_STRUCTURE + } + + public record TemplateImportOptions( + Path packRoot, + StructureKey sourceKey, + StructureKey targetKey, + StructureSource.Kind sourceKind, + StructureWriteOptions writeOptions, + boolean objectOnly, + String placeMode + ) { + public TemplateImportOptions { + packRoot = normalizedRoot(packRoot); + Objects.requireNonNull(sourceKey); + Objects.requireNonNull(targetKey); + Objects.requireNonNull(sourceKind); + Objects.requireNonNull(writeOptions); + Objects.requireNonNull(placeMode); + if (placeMode.isBlank()) { + throw new IllegalArgumentException("Structure place mode cannot be blank"); + } + } + + public static TemplateImportOptions create( + Path packRoot, + StructureKey sourceKey, + StructureKey targetKey, + StructureWriteMode mode + ) { + return new TemplateImportOptions( + packRoot, + sourceKey, + targetKey, + inferredSourceKind(sourceKey), + new StructureWriteOptions(mode, false), + false, + "CENTER_HEIGHT" + ); + } + } + + public record JigsawImportOptions( + Path packRoot, + StructureKey sourceKey, + StructureKey targetKey, + StructureSource.Kind sourceKind, + StructureWriteOptions writeOptions + ) { + public JigsawImportOptions { + packRoot = normalizedRoot(packRoot); + Objects.requireNonNull(sourceKey); + Objects.requireNonNull(targetKey); + Objects.requireNonNull(sourceKind); + Objects.requireNonNull(writeOptions); + } + + public static JigsawImportOptions create( + Path packRoot, + StructureKey sourceKey, + StructureKey targetKey, + StructureWriteMode mode + ) { + return new JigsawImportOptions( + packRoot, + sourceKey, + targetKey, + inferredSourceKind(sourceKey), + new StructureWriteOptions(mode, false) + ); + } + } + + public record PreparedImport( + Path packRoot, + StructureWriteOptions writeOptions, + ImportKind kind, + StructureResourceBundle bundle, + int blocks, + int pieces, + int pools + ) { + public PreparedImport { + packRoot = normalizedRoot(packRoot); + Objects.requireNonNull(writeOptions); + Objects.requireNonNull(kind); + Objects.requireNonNull(bundle); + if (blocks < 0 || pieces < 0 || pools < 0) { + throw new IllegalArgumentException("Prepared import counts cannot be negative"); + } + } + } + + public record ImportResult( + boolean success, + String message, + ImportKind kind, + StructureKey targetKey, + int blocks, + int pieces, + int pools, + Set capabilities, + List losses, + Optional writeResult + ) { + public ImportResult { + Objects.requireNonNull(message); + Objects.requireNonNull(kind); + Objects.requireNonNull(targetKey); + capabilities = Set.copyOf(capabilities); + losses = List.copyOf(losses); + Objects.requireNonNull(writeResult); + } + } + + public static final class StructureImportException extends Exception { + private final List losses; + + public StructureImportException(String message, Throwable cause, List losses) { + super(message, cause); + this.losses = List.copyOf(losses); + } + + public List losses() { + return losses; + } + } + + private static Path normalizedRoot(Path path) { + return Objects.requireNonNull(path).toAbsolutePath().normalize(); + } + + private static StructureSource.Kind inferredSourceKind(StructureKey sourceKey) { + return sourceKey.namespace().equals("minecraft") + ? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/structure/ModdedStructureTemplateCapture.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/structure/ModdedStructureTemplateCapture.java new file mode 100644 index 000000000..aa85e8a36 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/structure/ModdedStructureTemplateCapture.java @@ -0,0 +1,339 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded.structure; + +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureLoss; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.modded.ModdedBlockResolution; +import art.arcane.iris.modded.ModdedBlockState; +import art.arcane.iris.modded.ModdedTileData; +import net.minecraft.core.HolderGetter; +import net.minecraft.core.Vec3i; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +final class ModdedStructureTemplateCapture { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + + private ModdedStructureTemplateCapture() { + } + + static Capture capture( + StructureKey sourceKey, + StructureTemplate template, + HolderGetter blockLookup, + boolean connectorsPreserved + ) { + return capture(sourceKey, template, blockLookup, connectorsPreserved, true); + } + + static Capture capture( + StructureKey sourceKey, + StructureTemplate template, + HolderGetter blockLookup, + boolean connectorsPreserved, + boolean includeAir + ) { + Objects.requireNonNull(template); + CompoundTag sourceTag = template.save(new CompoundTag()); + return captureTag(sourceKey, sourceTag, blockLookup, connectorsPreserved, includeAir); + } + + static Capture captureTag( + StructureKey sourceKey, + CompoundTag sourceTag, + HolderGetter blockLookup, + boolean connectorsPreserved + ) { + return captureTag(sourceKey, sourceTag, blockLookup, connectorsPreserved, true); + } + + static Capture captureTag( + StructureKey sourceKey, + CompoundTag sourceTag, + HolderGetter blockLookup, + boolean connectorsPreserved, + boolean includeAir + ) { + Objects.requireNonNull(sourceKey); + Objects.requireNonNull(sourceTag); + Objects.requireNonNull(blockLookup); + Vec3i size = readSize(sourceTag); + IrisObject object = new IrisObject(size.getX(), size.getY(), size.getZ()); + List losses = new ArrayList<>(); + ListTag palette = firstPalette(sourceTag, losses); + ListTag blocks = sourceTag.getListOrEmpty(StructureTemplate.BLOCKS_TAG); + CaptureCounts counts = captureBlocks(sourceKey, palette, blocks, blockLookup, object, losses, includeAir); + recordEntityLoss(sourceTag, losses); + recordMarkerLosses(counts, connectorsPreserved, losses); + + EnumSet capabilities = EnumSet.of(StructureCapability.BLOCKS); + if (counts.tiles() > 0) { + capabilities.add(StructureCapability.BLOCK_ENTITIES); + } + if (connectorsPreserved && counts.jigsaws() > 0) { + capabilities.add(StructureCapability.CONNECTORS); + } + return new Capture( + object, + counts.blocks(), + counts.tiles(), + counts.jigsaws(), + counts.dataMarkers(), + size.getX(), + size.getY(), + size.getZ(), + capabilities, + losses, + sourceTag.copy() + ); + } + + private static Vec3i readSize(CompoundTag sourceTag) { + ListTag size = sourceTag.getListOrEmpty(StructureTemplate.SIZE_TAG); + int width = size.getIntOr(0, 0); + int height = size.getIntOr(1, 0); + int depth = size.getIntOr(2, 0); + if (width < 1 || height < 1 || depth < 1) { + throw new IllegalArgumentException("Structure template has invalid dimensions " + + width + "x" + height + "x" + depth); + } + return new Vec3i(width, height, depth); + } + + private static ListTag firstPalette(CompoundTag sourceTag, List losses) { + ListTag palettes = sourceTag.getList(StructureTemplate.PALETTE_LIST_TAG).orElse(null); + if (palettes != null) { + if (palettes.isEmpty()) { + throw new IllegalArgumentException("Structure template has no palettes"); + } + if (palettes.size() > 1) { + losses.add(StructureLoss.warning( + StructureCapability.BLOCKS, + "palette_variants_not_imported", + "Only palette 0 was converted; " + (palettes.size() - 1) + + " additional palette(s) remain native-only." + )); + } + return palettes.getListOrEmpty(0); + } + ListTag palette = sourceTag.getListOrEmpty(StructureTemplate.PALETTE_TAG); + if (palette.isEmpty() && !sourceTag.getListOrEmpty(StructureTemplate.BLOCKS_TAG).isEmpty()) { + throw new IllegalArgumentException("Structure template has blocks but no palette"); + } + return palette; + } + + private static CaptureCounts captureBlocks( + StructureKey sourceKey, + ListTag palette, + ListTag blocks, + HolderGetter blockLookup, + IrisObject object, + List losses, + boolean includeAir + ) { + int blockCount = 0; + int tiles = 0; + int jigsaws = 0; + int dataMarkers = 0; + for (int index = 0; index < blocks.size(); index++) { + CompoundTag blockTag = blocks.getCompoundOrEmpty(index); + BlockPosition position = readPosition(blockTag); + if (!withinObject(position, object)) { + losses.add(StructureLoss.warning( + StructureCapability.BLOCKS, + "out_of_bounds_block_not_imported", + "Block " + index + " at " + position.x() + "," + position.y() + "," + position.z() + + " is outside the declared template bounds." + )); + continue; + } + int paletteIndex = blockTag.getIntOr(StructureTemplate.BLOCK_TAG_STATE, 0); + BlockState state = NbtUtils.readBlockState(blockLookup, palette.getCompoundOrEmpty(paletteIndex)); + CompoundTag blockEntityTag = blockTag.getCompound(StructureTemplate.BLOCK_TAG_NBT).orElse(null); + if (state.is(Blocks.STRUCTURE_VOID)) { + continue; + } + if (state.is(Blocks.STRUCTURE_BLOCK)) { + dataMarkers++; + continue; + } + if (state.is(Blocks.JIGSAW)) { + jigsaws++; + BlockState finalState = resolveJigsawFinalState(sourceKey, position, blockEntityTag, losses); + if (finalState == null || finalState.isAir()) { + continue; + } + state = finalState; + blockEntityTag = null; + } + if (!includeAir && state.isAir()) { + continue; + } + object.setUnsigned(position.x(), position.y(), position.z(), ModdedBlockState.of(state, null)); + blockCount++; + if (blockEntityTag != null && captureTile(sourceKey, position, state, blockEntityTag, object, losses)) { + tiles++; + } + } + return new CaptureCounts(blockCount, tiles, jigsaws, dataMarkers); + } + + private static BlockPosition readPosition(CompoundTag blockTag) { + ListTag position = blockTag.getListOrEmpty(StructureTemplate.BLOCK_TAG_POS); + return new BlockPosition( + position.getIntOr(0, Integer.MIN_VALUE), + position.getIntOr(1, Integer.MIN_VALUE), + position.getIntOr(2, Integer.MIN_VALUE) + ); + } + + private static boolean withinObject(BlockPosition position, IrisObject object) { + return position.x() >= 0 && position.x() < object.getW() + && position.y() >= 0 && position.y() < object.getH() + && position.z() >= 0 && position.z() < object.getD(); + } + + private static BlockState resolveJigsawFinalState( + StructureKey sourceKey, + BlockPosition position, + CompoundTag blockEntityTag, + List losses + ) { + String finalState = blockEntityTag == null + ? "minecraft:air" + : blockEntityTag.getStringOr("final_state", "minecraft:air"); + try { + return ModdedBlockResolution.strictParse(finalState).handle(); + } catch (IllegalArgumentException failure) { + LOGGER.error("Failed to parse jigsaw final state '{}' in {} at {},{},{}", + finalState, sourceKey, position.x(), position.y(), position.z(), failure); + losses.add(StructureLoss.warning( + StructureCapability.BLOCKS, + "jigsaw_final_state_not_imported", + "Jigsaw final state '" + finalState + "' at " + position.x() + "," + position.y() + "," + + position.z() + " could not be parsed." + )); + return null; + } + } + + private static boolean captureTile( + StructureKey sourceKey, + BlockPosition position, + BlockState state, + CompoundTag blockEntityTag, + IrisObject object, + List losses + ) { + try { + String blockKey = ModdedBlockState.serialize(state); + ModdedTileData tile = ModdedTileData.capture(blockKey, NbtUtils.structureToSnbt(blockEntityTag)); + object.setUnsignedTile(position.x(), position.y(), position.z(), tile); + return true; + } catch (IOException | RuntimeException failure) { + LOGGER.error("Failed to capture block entity in {} at {},{},{}", + sourceKey, position.x(), position.y(), position.z(), failure); + losses.add(StructureLoss.warning( + StructureCapability.BLOCK_ENTITIES, + "block_entity_not_imported", + "Block entity data at " + position.x() + "," + position.y() + "," + position.z() + + " could not be encoded." + )); + return false; + } + } + + private static void recordEntityLoss(CompoundTag sourceTag, List losses) { + int entityCount = sourceTag.getListOrEmpty(StructureTemplate.ENTITIES_TAG).size(); + if (entityCount > 0) { + losses.add(StructureLoss.warning( + StructureCapability.ENTITIES, + "entities_not_imported", + entityCount + " structure entit" + (entityCount == 1 ? "y was" : "ies were") + + " not converted into the Iris snapshot." + )); + } + } + + private static void recordMarkerLosses( + CaptureCounts counts, + boolean connectorsPreserved, + List losses + ) { + if (counts.dataMarkers() > 0) { + losses.add(StructureLoss.warning( + StructureCapability.PROCESSORS, + "data_markers_not_imported", + counts.dataMarkers() + " structure data marker(s) require native pool-element handlers and were omitted." + )); + } + if (!connectorsPreserved && counts.jigsaws() > 0) { + losses.add(StructureLoss.warning( + StructureCapability.CONNECTORS, + "connectors_not_imported", + counts.jigsaws() + " jigsaw connector(s) were resolved to final blocks without importing their pool graph." + )); + } + } + + record Capture( + IrisObject object, + int blocks, + int tiles, + int jigsaws, + int dataMarkers, + int width, + int height, + int depth, + Set capabilities, + List losses, + CompoundTag sourceTag + ) { + Capture { + Objects.requireNonNull(object); + capabilities = Set.copyOf(capabilities); + losses = List.copyOf(losses); + sourceTag = sourceTag.copy(); + } + } + + private record CaptureCounts(int blocks, int tiles, int jigsaws, int dataMarkers) { + } + + private record BlockPosition(int x, int y, int z) { + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedLocateRoutingTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedLocateRoutingTest.java new file mode 100644 index 000000000..144bd44be --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedLocateRoutingTest.java @@ -0,0 +1,25 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.nativegen.NativeStructureLocateResults; +import com.mojang.datafixers.util.Pair; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.world.level.levelgen.structure.Structure; +import org.junit.Test; + +import static org.junit.Assert.assertSame; + +public class IrisModdedLocateRoutingTest { + @Test + public void mixedLocateSelectsNearestProviderAndPrefersNativeOnTie() { + BlockPos origin = BlockPos.ZERO; + Pair> irisNear = Pair.of(new BlockPos(4, 70, 0), null); + Pair> nativeFar = Pair.of(new BlockPos(8, 70, 0), null); + Pair> nativeNear = Pair.of(new BlockPos(2, 70, 0), null); + Pair> nativeTie = Pair.of(new BlockPos(0, 70, 4), null); + + assertSame(irisNear, NativeStructureLocateResults.nearest(origin, irisNear, nativeFar)); + assertSame(nativeNear, NativeStructureLocateResults.nearest(origin, irisNear, nativeNear)); + assertSame(nativeTie, NativeStructureLocateResults.nearest(origin, irisNear, nativeTie)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java index 82ee526bf..2546a7290 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java @@ -23,6 +23,7 @@ import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class IrisModdedStructureParityTest { @@ -120,14 +121,18 @@ public class IrisModdedStructureParityTest { } @Test - public void possibleBiomeFallbackIsOnlyRequiredForMissingOrEmptyConfigurations() { - Set registered = Set.of("minecraft:deep_ocean", "minecraft:dark_forest", "minecraft:plains"); + public void structureBiomeContractRejectsAnEmptyConfiguredSet() { + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> IrisModdedBiomeSource.requireConfiguredStructureBiomeKeys(Set.of())); - assertFalse(IrisModdedBiomeSource.requiresPossibleBiomeFallback( - Set.of("minecraft:deep_ocean", "minecraft:dark_forest"), registered)); - assertTrue(IrisModdedBiomeSource.requiresPossibleBiomeFallback( - Set.of("minecraft:deep_ocean", "overworld:missing"), registered)); - assertTrue(IrisModdedBiomeSource.requiresPossibleBiomeFallback(Set.of(), registered)); + assertEquals("Iris has no configured structure biomes", error.getMessage()); + } + + @Test + public void structureBiomeContractPreservesConfiguredKeysDuringBootstrap() { + Set configured = Set.of("minecraft:deep_ocean", "minecraft:dark_forest"); + + assertSame(configured, IrisModdedBiomeSource.requireConfiguredStructureBiomeKeys(configured)); } @Test @@ -185,6 +190,27 @@ public class IrisModdedStructureParityTest { throw new AssertionError("Expected failed engine binding to propagate"); } + @Test + public void structureBiomeBootstrapAllowsOnlyPendingBindingsToUseMetadata() { + IrisModdedChunkGenerator.EngineBinding binding = + new IrisModdedChunkGenerator.EngineBinding<>(1L, TimeUnit.SECONDS); + + binding.throwIfFailed("overworld:overworld"); + } + + @Test + public void structureBiomeBootstrapPropagatesBindingFailure() { + IrisModdedChunkGenerator.EngineBinding binding = + new IrisModdedChunkGenerator.EngineBinding<>(1L, TimeUnit.SECONDS); + IllegalArgumentException failure = new IllegalArgumentException("broken pack"); + binding.fail(failure); + + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> binding.throwIfFailed("overworld:overworld")); + + assertSame(failure, error.getCause()); + } + @Test public void initialEntitySpawnsUseThePaperCompletionMarker() { assertSame(MantleFlag.INITIAL_SPAWNED_MARKER, ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java index cc1c1eef1..5938ea510 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java @@ -35,6 +35,7 @@ import java.util.stream.Stream; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class ModdedForcedDatapackTest { @@ -75,6 +76,71 @@ public class ModdedForcedDatapackTest { } } + @Test + public void publishesCompleteStagingDirectoryOverExistingPack() throws IOException { + Path root = Files.createTempDirectory("iris-forced-pack-publish"); + try { + Path published = Files.createDirectory(root.resolve("iris")); + Files.writeString(published.resolve("old.txt"), "old", StandardCharsets.UTF_8); + Path staging = Files.createDirectory(root.resolve("staging")); + Files.writeString(staging.resolve("new.txt"), "new", StandardCharsets.UTF_8); + + ModdedForcedDatapack.publishDirectory(staging, published); + + assertFalse(Files.exists(staging)); + assertFalse(Files.exists(published.resolve("old.txt"))); + assertEquals("new", Files.readString(published.resolve("new.txt"), StandardCharsets.UTF_8)); + assertEquals(List.of("iris"), directoryEntries(root)); + } finally { + deleteTree(root); + } + } + + @Test + public void publishesStagingDirectoryWhenNoPriorPackExists() throws IOException { + Path root = Files.createTempDirectory("iris-forced-pack-first-publish"); + try { + Path published = root.resolve("iris"); + Path staging = Files.createDirectory(root.resolve("staging")); + Files.writeString(staging.resolve("pack.mcmeta"), "first", StandardCharsets.UTF_8); + + ModdedForcedDatapack.publishDirectory(staging, published); + + assertEquals("first", + Files.readString(published.resolve("pack.mcmeta"), StandardCharsets.UTF_8)); + assertEquals(List.of("iris"), directoryEntries(root)); + } finally { + deleteTree(root); + } + } + + @Test + public void restoresPublishedPackWhenAtomicStagingMoveFails() throws IOException { + Path root = Files.createTempDirectory("iris-forced-pack-rollback"); + try { + Path published = Files.createDirectory(root.resolve("iris")); + Files.writeString(published.resolve("pack.mcmeta"), "known-good", StandardCharsets.UTF_8); + Path missingStaging = root.resolve("missing-staging"); + + assertThrows(IOException.class, + () -> ModdedForcedDatapack.publishDirectory(missingStaging, published)); + + assertEquals("known-good", + Files.readString(published.resolve("pack.mcmeta"), StandardCharsets.UTF_8)); + assertEquals(List.of("iris"), directoryEntries(root)); + } finally { + deleteTree(root); + } + } + + private List directoryEntries(Path root) throws IOException { + try (Stream entries = Files.list(root)) { + return entries.map((Path path) -> path.getFileName().toString()) + .sorted() + .toList(); + } + } + private void deleteTree(Path root) throws IOException { List paths = new ArrayList<>(); try (Stream walk = Files.walk(root)) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java new file mode 100644 index 000000000..3df2c1833 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java @@ -0,0 +1,249 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedLifecycleFailureContractTest { + private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources"; + + @Test + public void existingDimensionRepointBindsBeforeHandlePublication() throws IOException { + String managerSource = source("ModdedDimensionManager.java"); + String create = method(managerSource, "public static Handle create("); + int existingStart = requiredIndex(create, "if (existing != null"); + int presentStart = requiredIndex(create, "if (serverAccess.hasLevel(server, key))", existingStart + 1); + int injectionStart = requiredIndex(create, "try {", presentStart); + + assertRepointBeforePublication(create.substring(existingStart, presentStart)); + assertRepointBeforePublication(create.substring(presentStart, injectionStart)); + + String generatorSource = source("IrisModdedChunkGenerator.java"); + String repointAndBind = method(generatorSource, "void repointAndBind("); + assertBefore(repointAndBind, "ModdedWorldEngines.prepareReplacement(", + "ModdedWorldEngines.installReplacement("); + assertBefore(repointAndBind, "ModdedWorldEngines.installReplacement(", "this.activePack = pack;"); + String failure = catchBlock(repointAndBind); + assertTrue(failure.contains("ModdedWorldEngines.closeUnregistered(replacement);")); + assertTrue(failure.contains("addSuppressed(")); + + String worldEnginesSource = source("ModdedWorldEngines.java"); + String installReplacement = method(worldEnginesSource, "static void installReplacement("); + assertTrue(installReplacement.contains("ENGINES.compute(")); + assertBefore(installReplacement, "close(current);", "return activeReplacement;"); + assertFalse(installReplacement.contains("catch (")); + } + + @Test + public void injectionBindsBeforeRegistrationAndRollsBackEveryFailure() throws IOException { + String source = source("ModdedDimensionManager.java"); + String injection = method(source, "private static Handle inject("); + + assertBefore(injection, "generator.bindLevel(level);", "serverAccess.putLevelIfAbsent(server, key, level);"); + assertBefore(injection, "serverAccess.putLevelIfAbsent(server, key, level);", + "server.getPlayerList().addWorldborderListener(level);"); + assertFalse(injection.contains("serverAccess.putLevel(server, key, previous);")); + String failure = catchBlock(injection); + assertBefore(failure, "rollbackInjection(", "throw "); + + String rollback = method(source, "void rollbackInjection("); + assertBefore(rollback, "serverAccess.hasLevel(server, key)", "serverAccess.removeLevel(server, key);"); + assertTrue(rollback.contains("generator.unbindEngine(")); + assertTrue(rollback.contains("level.close();")); + assertTrue(rollback.contains("addSuppressed(")); + } + + @Test + public void persistentReinjectionRethrowsTheOriginalCause() throws IOException { + String source = source("ModdedStartup.java"); + String reinjection = method(source, "private static void reinjectPersistentDimensions("); + String failure = catchBlock(reinjection); + + assertTrue(failure.contains("LOGGER.error(")); + assertFalse(failure.contains("e.toString()")); + assertFalse(failure.contains("continue;")); + assertTrue(failure.contains("throw new IllegalStateException(")); + assertTrue(failure.contains(", e);")); + } + + @Test + public void packDimensionLoadingNeverSuppressesTheFailureAsNull() throws IOException { + String source = source("ModdedDimensionManager.java"); + String loading = method(source, "private static IrisDimension loadPackDimension("); + + assertFalse(loading.contains("return null;")); + assertFalse(loading.contains("e.toString()")); + int catchIndex = loading.indexOf("catch ("); + if (catchIndex >= 0) { + String failure = blockAt(loading, requiredIndex(loading, "{", catchIndex)); + assertTrue(failure.contains("throw ")); + assertTrue(failure.contains(", e);") || failure.contains("throw e;")); + } + } + + @Test + public void productionPackResolutionNeverUsesTheParityProbeAsData() throws IOException { + String source = source("ModdedWorldEngines.java"); + String resolvePack = method(source, "static File resolvePack("); + + assertFalse(source.contains("System.getProperty(\"iris.parity\")")); + assertFalse(source.contains("parityPack")); + assertFalse(source.contains("falling back to parity pack")); + assertTrue(resolvePack.contains("throw new IllegalStateException(")); + } + + @Test + public void engineBootstrapResetsStartupExactlyOnceDuringStop() throws IOException { + String source = source("ModdedEngineBootstrap.java"); + String stop = method(source, "public static void stop("); + + assertEquals(1, occurrences(stop, "ModdedStartup.reset();")); + } + + @Test + public void engineEvictionRetainsTheMappingUntilCloseSucceeds() throws IOException { + String source = source("ModdedWorldEngines.java"); + String eviction = method(source, "static void evictOrThrow("); + + assertTrue(eviction.contains("ENGINES.computeIfPresent(")); + assertFalse(eviction.contains("ENGINES.remove(")); + assertBefore(eviction, "close(current);", "return null;"); + } + + @Test + public void generatorUnbindRetainsOwnershipWhenEvictionFails() throws IOException { + String source = source("IrisModdedChunkGenerator.java"); + String unbind = method(source, "synchronized void unbindEngine(ServerLevel level)"); + + assertFalse(unbind.contains("finally")); + assertBefore(unbind, "ModdedWorldEngines.evictOrThrow(level);", "clearEngineBinding();"); + } + + @Test + public void engineBootstrapRollsBackEveryPublishedBindingBeforeRethrowing() throws IOException { + String source = source("ModdedEngineBootstrap.java"); + String bind = method(source, "public static ModdedPlatform bind("); + String failure = catchBlock(bind); + + assertBefore(bind, "createdServices.enableAll();", "runtime = new BoundRuntime("); + assertTrue(bind.contains("rollback.add(() -> GuiHost.suppressDesktop(")); + assertTrue(bind.contains("rollback.add(() -> restorePlatform(")); + assertTrue(bind.contains("rollback.add(() -> ModdedDimensionManager.restoreAccess(")); + assertTrue(bind.contains("rollback.add(() -> IrisObjectRotation.restorePlatformRotator(")); + assertTrue(bind.contains("rollback.add(() -> BlockDataMergeSupport.restorePlatformMerger(")); + assertTrue(bind.contains("rollback.add(() -> TileData.restorePlatformReader(")); + assertTrue(bind.contains("rollback.add(() -> TileData.restorePlatformFactory(")); + assertTrue(bind.contains("rollback.add(() -> GuiHost.set(")); + assertTrue(bind.contains("rollback.add(() -> DecoratorPlatformHooks.restore(")); + assertTrue(bind.contains("bindService(PreservationRegistry.class")); + assertTrue(bind.contains("bindService(EngineEffectsProvider.class")); + assertTrue(bind.contains("bindService(EnginePlatformHooks.class")); + assertTrue(bind.contains("bindService(EngineWorldManagerProvider.class")); + assertTrue(bind.contains("rollback.add(customContentDiscovery::rollback);")); + assertBefore(failure, "createdServices.rollback(failure);", "rollback.restore(failure);"); + assertBefore(failure, "rollback.restore(failure);", "throw "); + } + + private static void assertRepointBeforePublication(String branch) { + assertBefore(branch, "repointAndBind(", "new Handle("); + assertBefore(branch, "repointAndBind(", "HANDLES.put("); + assertBefore(branch, "repointAndBind(", "return "); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = requiredIndex(source, first); + int secondIndex = requiredIndex(source, second); + assertTrue(first + " must occur before " + second, firstIndex < secondIndex); + } + + private static int requiredIndex(String source, String needle) { + return requiredIndex(source, needle, 0); + } + + private static int requiredIndex(String source, String needle, int start) { + int index = source.indexOf(needle, start); + assertTrue("Missing source contract token: " + needle, index >= 0); + return index; + } + + private static String source(String fileName) throws IOException { + String sourceRoot = System.getProperty(SOURCE_ROOT_PROPERTY); + assertTrue("Missing system property " + SOURCE_ROOT_PROPERTY, + sourceRoot != null && !sourceRoot.isBlank()); + Path sourcePath = Path.of(sourceRoot, "art", "arcane", "iris", "modded", fileName); + return Files.readString(sourcePath); + } + + private static String method(String source, String signature) { + int start = requiredIndex(source, signature); + int openBrace = requiredIndex(source, "{", start); + return source.substring(start, matchingBrace(source, openBrace) + 1); + } + + private static String catchBlock(String source) { + int catchIndex = requiredIndex(source, "catch ("); + int openBrace = requiredIndex(source, "{", catchIndex); + return blockAt(source, openBrace); + } + + private static String blockAt(String source, int openBrace) { + return source.substring(openBrace, matchingBrace(source, openBrace) + 1); + } + + private static int matchingBrace(String source, int openBrace) { + int depth = 0; + boolean quoted = false; + boolean character = false; + boolean escaped = false; + for (int i = openBrace; i < source.length(); i++) { + char current = source.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if ((quoted || character) && current == '\\') { + escaped = true; + continue; + } + if (!character && current == '"') { + quoted = !quoted; + continue; + } + if (!quoted && current == '\'') { + character = !character; + continue; + } + if (quoted || character) { + continue; + } + if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + if (depth == 0) { + return i; + } + } + } + throw new IllegalArgumentException("Unclosed source block"); + } + + private static int occurrences(String source, String needle) { + int count = 0; + int offset = 0; + while (true) { + int index = source.indexOf(needle, offset); + if (index < 0) { + return count; + } + count++; + offset = index + needle.length(); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java index f3a6e251e..f2738b115 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java @@ -1,9 +1,13 @@ package art.arcane.iris.modded; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.IrisLootMode; import art.arcane.volmlib.util.math.RNG; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.RandomizableContainer; import net.minecraft.world.SimpleContainer; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.storage.loot.LootTable; @@ -12,6 +16,7 @@ import org.junit.Test; import java.util.ArrayList; import java.util.List; import java.util.Set; +import java.lang.reflect.Proxy; import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.Assert.assertEquals; @@ -25,7 +30,7 @@ public class ModdedLootApplierTest { public void addAppendsSourcesInOrder() { List sources = new ArrayList<>(List.of("placement-native")); - ModdedLootApplier.injectSources(sources, List.of("dimension-iris", "region-iris"), IrisLootMode.ADD, false); + LootResolver.injectSources(sources, List.of("dimension-iris", "region-iris"), IrisLootMode.ADD, false); assertEquals(List.of("placement-native", "dimension-iris", "region-iris"), sources); } @@ -34,7 +39,7 @@ public class ModdedLootApplierTest { public void clearRemovesNativeAndIrisSourcesBeforeAdding() { List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); - ModdedLootApplier.injectSources(sources, List.of("biome-iris"), IrisLootMode.CLEAR, false); + LootResolver.injectSources(sources, List.of("biome-iris"), IrisLootMode.CLEAR, false); assertEquals(List.of("biome-iris"), sources); } @@ -43,7 +48,7 @@ public class ModdedLootApplierTest { public void replaceRemovesNativeAndIrisSourcesBeforeAdding() { List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); - ModdedLootApplier.injectSources(sources, List.of("biome-iris"), IrisLootMode.REPLACE, false); + LootResolver.injectSources(sources, List.of("biome-iris"), IrisLootMode.REPLACE, false); assertEquals(List.of("biome-iris"), sources); } @@ -58,7 +63,7 @@ public class ModdedLootApplierTest { public void fallbackDoesNotOverrideExistingNativeOrIrisSources() { List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); - ModdedLootApplier.injectSources(sources, List.of("fallback-iris"), IrisLootMode.FALLBACK, false); + LootResolver.injectSources(sources, List.of("fallback-iris"), IrisLootMode.FALLBACK, false); assertEquals(List.of("placement-native", "dimension-iris"), sources); } @@ -67,7 +72,7 @@ public class ModdedLootApplierTest { public void fallbackAddsSourcesWhenPlacementIsEmpty() { List sources = new ArrayList<>(); - ModdedLootApplier.injectSources(sources, List.of("fallback-iris"), IrisLootMode.FALLBACK, true); + LootResolver.injectSources(sources, List.of("fallback-iris"), IrisLootMode.FALLBACK, true); assertEquals(List.of("fallback-iris"), sources); } @@ -76,7 +81,7 @@ public class ModdedLootApplierTest { public void zeroMultiplierRemovesNativeAndIrisSources() { List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); - ModdedLootApplier.scaleSources(sources, 0D, new RNG(17L)); + LootResolver.scaleSources(sources, 0D, new RNG(17L)); assertTrue(sources.isEmpty()); } @@ -85,7 +90,7 @@ public class ModdedLootApplierTest { public void unitMultiplierPreservesNativeAndIrisSources() { List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); - ModdedLootApplier.scaleSources(sources, 1D, new RNG(17L)); + LootResolver.scaleSources(sources, 1D, new RNG(17L)); assertEquals(List.of("placement-native", "dimension-iris"), sources); } @@ -94,7 +99,7 @@ public class ModdedLootApplierTest { public void doubleMultiplierScalesTheUnifiedSourceList() { List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); - ModdedLootApplier.scaleSources(sources, 2D, new RNG(17L)); + LootResolver.scaleSources(sources, 2D, new RNG(17L)); assertEquals(4, sources.size()); assertEquals("placement-native", sources.get(0)); @@ -142,6 +147,51 @@ public class ModdedLootApplierTest { assertTrue(container.changed); } + @Test + public void doubleChestHasExactlyOneCanonicalHalf() { + BlockPos leftPos = new BlockPos(0, 64, 0); + BlockPos rightPos = new BlockPos(1, 64, 0); + + assertTrue(ModdedLootApplier.isCanonicalPair(leftPos, rightPos)); + assertFalse(ModdedLootApplier.isCanonicalPair(rightPos, leftPos)); + } + + @Test + public void nativeLootTableIsDetectedBeforeContainerAccess() { + ResourceKey key = ResourceKey.create( + Registries.LOOT_TABLE, + Identifier.parse("minecraft:chests/simple_dungeon") + ); + RandomizableContainer empty = containerWithLootTable(null); + RandomizableContainer nativeLoot = containerWithLootTable(key); + + assertFalse(ModdedLootApplier.hasNativeLootTable(empty)); + assertTrue(ModdedLootApplier.hasNativeLootTable(nativeLoot)); + } + + private RandomizableContainer containerWithLootTable(ResourceKey key) { + return (RandomizableContainer) Proxy.newProxyInstance( + RandomizableContainer.class.getClassLoader(), + new Class[]{RandomizableContainer.class}, + (proxy, method, args) -> { + if (method.getName().equals("getLootTable")) { + return key; + } + Class returnType = method.getReturnType(); + if (returnType == boolean.class) { + return false; + } + if (returnType == int.class) { + return 0; + } + if (returnType == long.class) { + return 0L; + } + return null; + } + ); + } + private static final class TrackingContainer extends SimpleContainer { private boolean changed; diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedServiceManagerTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedServiceManagerTest.java new file mode 100644 index 000000000..401f7d3b6 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedServiceManagerTest.java @@ -0,0 +1,93 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.modded.service.ModdedService; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public class ModdedServiceManagerTest { + @Test + public void enablesAndDisablesEveryRegisteredService() { + ModdedServiceManager manager = new ModdedServiceManager(); + FirstService first = manager.register(FirstService.class, new FirstService()); + SecondService second = manager.register(SecondService.class, new SecondService(null, null)); + + manager.enableAll(); + manager.disableAll(); + + assertEquals(1, first.enableCount); + assertEquals(1, first.disableCount); + assertEquals(1, second.enableCount); + assertEquals(1, second.disableCount); + } + + @Test + public void rollsBackEnabledServicesAndPreservesCleanupFailures() { + ModdedServiceManager manager = new ModdedServiceManager(); + FirstService first = manager.register(FirstService.class, new FirstService()); + RuntimeException original = new RuntimeException("enable failed"); + RuntimeException cleanup = new RuntimeException("cleanup failed"); + SecondService second = manager.register( + SecondService.class, new SecondService(original, cleanup)); + + RuntimeException thrown = assertThrows(RuntimeException.class, manager::enableAll); + + assertSame(original, thrown); + assertEquals(1, first.enableCount); + assertEquals(1, first.disableCount); + assertEquals(1, second.enableCount); + assertEquals(1, second.disableCount); + assertEquals(1, thrown.getSuppressed().length); + assertSame(cleanup, thrown.getSuppressed()[0]); + + manager.rollback(thrown); + assertNull(manager.service(FirstService.class)); + assertNull(manager.service(SecondService.class)); + } + + private static final class FirstService implements ModdedService { + private int enableCount; + private int disableCount; + + @Override + public void onEnable() { + enableCount++; + } + + @Override + public void onDisable() { + disableCount++; + } + } + + private static final class SecondService implements ModdedService { + private final RuntimeException enableFailure; + private final RuntimeException disableFailure; + private int enableCount; + private int disableCount; + + private SecondService(RuntimeException enableFailure, RuntimeException disableFailure) { + this.enableFailure = enableFailure; + this.disableFailure = disableFailure; + } + + @Override + public void onEnable() { + enableCount++; + if (enableFailure != null) { + throw enableFailure; + } + } + + @Override + public void onDisable() { + disableCount++; + if (disableFailure != null) { + throw disableFailure; + } + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java index 13697d795..689238929 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java @@ -11,6 +11,7 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class ModdedStructureHooksTest { @@ -51,4 +52,47 @@ public class ModdedStructureHooksTest { assertEquals(320, ModdedPlatformWorld.exclusiveMaxHeight(-64, 384)); assertEquals(384, ModdedPlatformWorld.exclusiveMaxHeight(0, 384)); } + + @Test + public void structureRegistryHooksRejectUnavailableServer() { + ModdedStructureHooks hooks = new ModdedStructureHooks(() -> null); + + IllegalStateException structureKeys = assertThrows( + IllegalStateException.class, hooks::structureKeys); + IllegalStateException structureSetKeys = assertThrows( + IllegalStateException.class, hooks::structureSetKeys); + IllegalStateException structureBiomeKeys = assertThrows( + IllegalStateException.class, () -> hooks.structureBiomeKeys("minecraft:village")); + + assertTrue(structureKeys.getMessage().contains("before the Minecraft server is available")); + assertTrue(structureSetKeys.getMessage().contains("before the Minecraft server is available")); + assertTrue(structureBiomeKeys.getMessage().contains("before the Minecraft server is available")); + } + + @Test + public void structureReachabilityHooksRejectUnavailableLevel() { + ModdedStructureHooks hooks = new ModdedStructureHooks(() -> null); + + IllegalStateException reachable = assertThrows( + IllegalStateException.class, () -> hooks.reachableStructureKeys(null)); + IllegalStateException possibleBiomes = assertThrows( + IllegalStateException.class, () -> hooks.possibleBiomeKeys(null)); + + assertTrue(reachable.getMessage().contains("without a bound modded ServerLevel")); + assertTrue(possibleBiomes.getMessage().contains("without a bound modded ServerLevel")); + } + + @Test + public void structureRegistryHooksPreserveServerSupplierFailure() { + IllegalStateException cause = new IllegalStateException("server supplier failed"); + ModdedStructureHooks hooks = new ModdedStructureHooks(() -> { + throw cause; + }); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, hooks::structureKeys); + + assertTrue(failure.getMessage().contains("access the Minecraft server")); + assertEquals(cause, failure.getCause()); + } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructurePlacementFailureContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructurePlacementFailureContractTest.java new file mode 100644 index 000000000..0efa85911 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructurePlacementFailureContractTest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.modded; + +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 ModdedStructurePlacementFailureContractTest { + @Test + public void structurePlacementPropagatesRuntimeFailuresWithContext() throws IOException { + Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/ModdedStructureHooks.java"); + String source = Files.readString(sourcePath); + int methodStart = source.indexOf("public int[] placeStructure("); + int methodEnd = source.indexOf("\n @Override", methodStart + 1); + String method = source.substring(methodStart, methodEnd); + int catchStart = method.indexOf("catch (RuntimeException error)"); + + assertTrue(catchStart >= 0); + String failurePath = method.substring(catchStart); + assertTrue(failurePath.contains("throw NativeStructureGenerationException.failure(")); + assertTrue(failurePath.contains("\"capture placement\", structureKey, chunkX, chunkZ, error")); + assertFalse(failurePath.contains("return null;")); + assertFalse(failurePath.contains("catch (Throwable")); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileParityTest.java index 37c609e8f..d29756b04 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileParityTest.java @@ -39,8 +39,8 @@ public class ModdedTileParityTest { SharedConstants.tryDetectVersion(); Bootstrap.bootStrap(); registries = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); - TileData.bindFallbackReader(new ModdedTileReader(() -> null)); - TileData.bindFallbackFactory(ModdedTileData::fromProperties); + TileData.bindPlatformReader(new ModdedTileReader(() -> null)); + TileData.bindPlatformFactory(ModdedTileData::fromProperties); } @Test diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java index c04143942..175da412b 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java @@ -5,6 +5,9 @@ import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.levelgen.structure.BoundingBox; import org.junit.Test; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; @@ -23,6 +26,29 @@ public class ModdedWorldCheckTest { assertFalse(thread.isDaemon()); } + @Test + public void poiAuditRunsInASecondServerTaskAfterVillageGeneration() throws IOException { + Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/ModdedWorldCheck.java"); + String source = Files.readString(sourcePath); + int preparationSubmit = source.indexOf( + "WorldCheckPreparation preparation = serverRef.submit(() -> run(serverRef)).join();"); + int completionSubmit = source.indexOf( + "exitCode = serverRef.submit(() -> runAndRequestStop(", preparationSubmit); + int completionMethod = source.indexOf("private static boolean completeWorldCheck"); + int deferredAudit = source.indexOf("PoiAudit poi = auditStructurePois", completionMethod); + int structureMethod = source.indexOf("private static StructureCheckResult checkNativeStructure"); + int structureMethodEnd = source.indexOf("private static StructureStart resolveStructureStart", + structureMethod); + String structureSource = source.substring(structureMethod, structureMethodEnd); + + assertTrue(preparationSubmit >= 0); + assertTrue(completionSubmit > preparationSubmit); + assertTrue(deferredAudit > completionMethod); + assertFalse(structureSource.contains("auditStructurePois")); + assertFalse(source.contains("prepareDeferredAudits")); + } + @Test public void validStructureStartIsGenerationEvidence() { assertTrue(ModdedWorldCheck.hasNativeStructureEvidence(true, 0)); @@ -115,15 +141,6 @@ public class ModdedWorldCheckTest { assertFalse(ModdedWorldCheck.villageFoundationPass(1)); } - @Test - public void villageFoundationAuditRejectsMissingBaseAndInvalidSupport() { - assertTrue(ModdedWorldCheck.villageFoundationSupported(true, false, true, false)); - assertTrue(ModdedWorldCheck.villageFoundationSupported(true, true, false, false)); - assertFalse(ModdedWorldCheck.villageFoundationSupported(false, false, true, false)); - assertFalse(ModdedWorldCheck.villageFoundationSupported(true, false, false, false)); - assertFalse(ModdedWorldCheck.villageFoundationSupported(true, false, true, true)); - } - @Test public void villagePoiGateRequiresInBoundsPoiWithoutOutOfBoundsRecords() { assertTrue(ModdedWorldCheck.villagePoiPass(1, 0)); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java new file mode 100644 index 000000000..9c9a8a34d --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java @@ -0,0 +1,89 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.nativegen.NativeStructureGenerationException; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import org.junit.BeforeClass; +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.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class NativeStructureFailureContractTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void structureLocateDoesNotCatchAndFallThroughToAnotherImplementation() throws IOException { + Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/IrisModdedChunkGenerator.java"); + String source = Files.readString(sourcePath); + int locateStart = source.indexOf("public Pair> findNearestMapStructure"); + int locateEnd = source.indexOf("public boolean isNativeStructureReachable", locateStart); + String locate = source.substring(locateStart, locateEnd); + int filterStart = source.indexOf("private HolderSet filterReachableNativeStructures"); + int filterEnd = source.indexOf("private ServerLevel boundLevel", filterStart); + String filter = source.substring(filterStart, filterEnd); + + assertTrue(locate.contains("Engine current = engine();")); + assertFalse(locate.contains("catch (Throwable")); + assertFalse(locate.contains("return null;\n } catch")); + assertTrue(filter.contains("unregistered structure holder")); + assertFalse(filter.contains("catch (Throwable")); + assertFalse(filter.contains("failed closed")); + } + + @Test + public void globalStructureDisableRefusesGeneratorBinding() { + IrisModdedChunkGenerator.requireGlobalStructureGeneration(true, "overworld:overworld"); + + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> IrisModdedChunkGenerator.requireGlobalStructureGeneration( + false, "overworld:overworld")); + + assertTrue(error.getMessage().contains("overworld:overworld")); + assertTrue(error.getMessage().contains("generate-structures=false")); + assertTrue(error.getMessage().contains("importedStructures.disabled")); + } + + @Test + public void structureTerrainPreparationPrecedesVegetationAndPlacement() throws IOException { + Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/IrisModdedChunkGenerator.java"); + String source = Files.readString(sourcePath); + int placementStart = source.indexOf("private void placeVanillaStructures"); + int placementEnd = source.indexOf("private static String nativeStructureBatchContext", placementStart); + String placement = source.substring(placementStart, placementEnd); + + assertTrue(placement.contains("\"terrain integration\"")); + assertTrue(placement.contains("prepareSurfaceStructures")); + assertTrue(placement.contains("clearIntersectingVegetation")); + assertTrue(placement.indexOf("prepareSurfaceStructures") + < placement.indexOf("clearIntersectingVegetation")); + assertTrue(placement.indexOf("clearIntersectingVegetation") + < placement.indexOf("for (NativePlacementGroup group")); + } + + @Test + public void structureFailurePreservesPhaseIdentityChunkAndCause() { + IllegalArgumentException cause = new IllegalArgumentException("broken placement"); + NativeStructureGenerationException error = NativeStructureGenerationException.failure( + "placement", "minecraft:monument", 12, -8, cause); + + assertSame(cause, error.getCause()); + assertTrue(error.getMessage().contains("placement")); + assertTrue(error.getMessage().contains("minecraft:monument")); + assertTrue(error.getMessage().contains("12,-8")); + assertTrue(error.getMessage().contains("aborted")); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/api/ModdedCustomContentRegistryTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/api/ModdedCustomContentRegistryTest.java new file mode 100644 index 000000000..694276da5 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/api/ModdedCustomContentRegistryTest.java @@ -0,0 +1,84 @@ +package art.arcane.iris.modded.api; + +import net.minecraft.resources.Identifier; +import org.junit.Test; + +import java.util.Collection; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ModdedCustomContentRegistryTest { + @Test + public void publishesProvidersOnlyAfterCompleteDiscoveryAndCanRollBack() { + String modId = "iris_discovery_success"; + TestProvider provider = new TestProvider(modId, null); + boolean previousDiscoveryComplete = ModdedCustomContentRegistry.discoveryComplete(); + + ModdedCustomContentRegistry.Discovery discovery = + ModdedCustomContentRegistry.discover(List.of(provider)); + try { + assertTrue(ModdedCustomContentRegistry.discoveryComplete()); + assertTrue(ModdedCustomContentRegistry.hasProvider(modId)); + } finally { + discovery.rollback(); + } + + assertEquals(previousDiscoveryComplete, ModdedCustomContentRegistry.discoveryComplete()); + assertFalse(ModdedCustomContentRegistry.hasProvider(modId)); + } + + @Test + public void failedDiscoveryPublishesNothingAndPreservesTheCause() { + String firstModId = "iris_discovery_staged"; + String failingModId = "iris_discovery_failure"; + RuntimeException original = new RuntimeException("provider init failed"); + boolean previousDiscoveryComplete = ModdedCustomContentRegistry.discoveryComplete(); + + RuntimeException thrown = assertThrows(RuntimeException.class, + () -> ModdedCustomContentRegistry.discover(List.of( + new TestProvider(firstModId, null), + new TestProvider(failingModId, original)))); + + assertSame(original, thrown); + assertEquals(previousDiscoveryComplete, ModdedCustomContentRegistry.discoveryComplete()); + assertFalse(ModdedCustomContentRegistry.hasProvider(firstModId)); + assertFalse(ModdedCustomContentRegistry.hasProvider(failingModId)); + } + + private static final class TestProvider implements ModdedDataProvider { + private final String modId; + private final RuntimeException failure; + + private TestProvider(String modId, RuntimeException failure) { + this.modId = modId; + this.failure = failure; + } + + @Override + public String modId() { + return modId; + } + + @Override + public Collection getTypes(ModdedDataType type) { + return List.of(); + } + + @Override + public boolean isValidProvider(Identifier id, ModdedDataType type) { + return false; + } + + @Override + public void init() { + if (failure != null) { + throw failure; + } + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java index 9cf0e9cb1..2191da7d1 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java @@ -20,6 +20,7 @@ public class IrisModdedStructureCommandTest { assertTrue(source.contains("generator.findNearestMapStructure(")); assertTrue(source.contains("NATIVE_STRUCTURE_LOCATE_RADIUS = 100")); assertTrue(source.contains("HolderSet.direct(target.holder())")); + assertFalse(source.contains("NativeStructureLocateCapability")); assertTrue(source.contains("boolean teleported = player.teleportTo(")); assertTrue(source.contains("combineStructureKeys(irisKeys, nativeKeys)")); assertTrue(source.contains("irisGenerator.isNativeStructureReachable(holder)")); @@ -33,16 +34,64 @@ public class IrisModdedStructureCommandTest { } @Test - public void generatorLocatePrefersIrisPlacementsAndRejectsDormantNativeStarts() throws IOException { + public void generatorLocateUsesIrisOnlyForExplicitReplacement() throws IOException { String source = moddedSource("IrisModdedChunkGenerator.java"); + int methodStart = source.indexOf("private Pair> findNearestIrisStructure("); + int methodEnd = source.indexOf("private HolderSet filterReachableNativeStructures(", methodStart); + String method = source.substring(methodStart, methodEnd); + int unexploredGuard = method.indexOf("if (findUnexplored)"); + int registryLookup = method.indexOf("level.registryAccess().lookupOrThrow(Registries.STRUCTURE)"); + int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(current,"); + int replacementCheck = method.indexOf( + "decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution); + int irisLocate = method.indexOf("IrisStructureLocator.locate(", replacementCheck); - assertTrue(source.contains("public Pair> findNearestMapStructure(")); - assertTrue(source.contains("findNearestIrisStructure(")); - assertTrue(source.contains("filterReachableNativeStructures(")); - assertTrue(source.contains("IrisStructureLocator.suppressesVanilla(current, key)")); + assertTrue(unexploredGuard >= 0); + assertTrue(registryLookup > unexploredGuard); + assertTrue(policyResolution > registryLookup); + assertTrue(replacementCheck > policyResolution); + assertTrue(irisLocate > replacementCheck); + assertTrue(method.contains("LocateStatus.SEARCH_LIMIT_REACHED")); + assertTrue(method.contains("new BlockPos(result.originX(), result.baseY(), result.originZ())")); + assertFalse(method.contains("NativeStructureLocateCapability")); assertTrue(source.contains("structureBiomeSource.isStructureReachable(holder)")); - assertTrue(source.contains("LocateStatus.SEARCH_LIMIT_REACHED")); - assertTrue(source.contains("new BlockPos(result.originX(), result.baseY(), result.originZ())")); + assertFalse(source.contains("isPaperUnavailable")); + } + + @Test + public void commandResolvesNativePolicyBeforeAnyVanillaAliasLookup() throws IOException { + String source = source("IrisModdedCommands.java"); + int methodStart = source.indexOf("private static int gotoStructure("); + int methodEnd = source.indexOf("private static void locateIrisStructure(", methodStart); + String method = source.substring(methodStart, methodEnd); + int nativeResolution = method.indexOf("resolveNativeStructure(source, level, engine, key)"); + int genericIrisLookup = method.indexOf("IrisStructureLocator.isPlaced(engine, key)", nativeResolution); + int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, target.key(), false)", genericIrisLookup); + int replacementCheck = method.indexOf( + "decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution); + int replacementLocate = method.indexOf("locateIrisStructure(source, level, engine, player, target.key())", + replacementCheck); + + assertTrue(nativeResolution >= 0); + assertTrue(genericIrisLookup > nativeResolution); + assertTrue(policyResolution > genericIrisLookup); + assertTrue(replacementCheck > policyResolution); + assertTrue(replacementLocate > replacementCheck); + } + + @Test + public void verifyResolvesRegisteredNativeBeforeGenericIrisAliases() throws IOException { + String source = source("IrisModdedCommands.java"); + int methodStart = source.indexOf("private static int verifyStructure("); + int methodEnd = source.indexOf("private static Optional resolveNativeStructure(", + methodStart); + String method = source.substring(methodStart, methodEnd); + int nativeResolution = method.indexOf("resolveNativeStructure(source, level, engine, key)"); + int genericIrisLookup = method.indexOf("IrisStructureLocator.isPlaced(engine, key)", nativeResolution); + + assertTrue(nativeResolution >= 0); + assertTrue(genericIrisLookup > nativeResolution); + assertTrue(method.contains("NativeStructureAvailability.IRIS_SUPPRESSED")); } @Test diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/structure/ModdedJigsawStructureCaptureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/structure/ModdedJigsawStructureCaptureTest.java new file mode 100644 index 000000000..305a52385 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/structure/ModdedJigsawStructureCaptureTest.java @@ -0,0 +1,69 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded.structure; + +import net.minecraft.core.Direction; +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +public class ModdedJigsawStructureCaptureTest { + @Test + public void namespacedSourcePathsRemainDistinctAndPortable() { + String nested = ModdedJigsawStructureCapture.pieceName("village", "mod:a/b"); + String underscored = ModdedJigsawStructureCapture.pieceName("village", "mod_a:b"); + + assertEquals("village/piece/mod/a/b", nested); + assertEquals("village/piece/mod_a/b", underscored); + assertNotEquals(nested, underscored); + assertEquals("village/pool/mod/a/b", ModdedJigsawStructureCapture.poolName("village", "mod:a/b")); + assertEquals( + "village/piece/generated/legacy/mod/a/b", + ModdedJigsawStructureCapture.legacyPieceName("village", "mod:a/b") + ); + } + + @Test + public void rootJsonRetainsGraphLimitsAndSourceIdentity() { + Map root = ModdedJigsawStructureCapture.structureJson( + "minecraft:village_plains", + "village/pool/minecraft/village/plains/town_centers", + 6, + 81 + ); + + assertEquals("minecraft:village_plains", root.get("vanillaSource")); + assertEquals(6, root.get("maxDepth")); + assertEquals(6, root.get("maxSizeChunks")); + assertEquals("STRUCTURE_PIECE", root.get("placeMode")); + } + + @Test + public void connectorDirectionsUseIrisAxisNames() { + assertEquals("UP_POSITIVE_Y", ModdedJigsawStructureCapture.directionName(Direction.UP)); + assertEquals("DOWN_NEGATIVE_Y", ModdedJigsawStructureCapture.directionName(Direction.DOWN)); + assertEquals("NORTH_NEGATIVE_Z", ModdedJigsawStructureCapture.directionName(Direction.NORTH)); + assertEquals("SOUTH_POSITIVE_Z", ModdedJigsawStructureCapture.directionName(Direction.SOUTH)); + assertEquals("EAST_POSITIVE_X", ModdedJigsawStructureCapture.directionName(Direction.EAST)); + assertEquals("WEST_NEGATIVE_X", ModdedJigsawStructureCapture.directionName(Direction.WEST)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/structure/ModdedStructureImportServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/structure/ModdedStructureImportServiceTest.java new file mode 100644 index 000000000..5b50d7a89 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/structure/ModdedStructureImportServiceTest.java @@ -0,0 +1,86 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded.structure; + +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.core.structure.authoring.StructureWriteMode; +import art.arcane.iris.core.structure.authoring.StructureWriteOptions; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedStructureImportServiceTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void writesThroughOwnedAddOnlyAndOverwriteTransactions() throws Exception { + Path root = temporaryFolder.newFolder("modded-import").toPath(); + ModdedStructureImportService service = new ModdedStructureImportService(() -> null); + ModdedStructureImportService.PreparedImport first = prepared(root, "one", StructureWriteMode.ADD_ONLY); + + ModdedStructureImportService.ImportResult added = service.write(first); + ModdedStructureImportService.ImportResult conflict = service.write(first); + ModdedStructureImportService.ImportResult overwritten = service.write( + prepared(root, "two", StructureWriteMode.OVERWRITE) + ); + + assertTrue(added.success()); + assertEquals(StructureWriteResult.Status.ADDED, added.writeResult().orElseThrow().status()); + assertFalse(conflict.success()); + assertEquals(StructureWriteResult.Status.ADD_ONLY_CONFLICT, conflict.writeResult().orElseThrow().status()); + assertTrue(overwritten.success()); + assertEquals(StructureWriteResult.Status.OVERWRITTEN, overwritten.writeResult().orElseThrow().status()); + assertTrue(overwritten.capabilities().contains(StructureCapability.BLOCKS)); + } + + private static ModdedStructureImportService.PreparedImport prepared( + Path root, + String content, + StructureWriteMode mode + ) { + StructureKey key = StructureKey.parse("iris:test_structure"); + StructureResourceBundle bundle = StructureResourceBundle.builder(key) + .source(StructureSource.of(StructureSource.Kind.DATAPACK, StructureKey.parse("test:source"))) + .backend(StructureBackend.SNAPSHOT) + .capability(StructureCapability.BLOCKS) + .textResource("structures/test_structure.json", content) + .build(); + return new ModdedStructureImportService.PreparedImport( + root, + new StructureWriteOptions(mode, false), + ModdedStructureImportService.ImportKind.TEMPLATE, + bundle, + 1, + 1, + 1 + ); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/structure/ModdedStructureTemplateCaptureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/structure/ModdedStructureTemplateCaptureTest.java new file mode 100644 index 000000000..27be9eaa8 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/structure/ModdedStructureTemplateCaptureTest.java @@ -0,0 +1,184 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded.structure; + +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureKey; +import net.minecraft.SharedConstants; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.IntTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import net.minecraft.server.Bootstrap; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedStructureTemplateCaptureTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void capturesBlocksTilesAndExplicitStandaloneLosses() throws Exception { + CompoundTag template = templateTag(); + + ModdedStructureTemplateCapture.Capture capture = ModdedStructureTemplateCapture.captureTag( + StructureKey.parse("minecraft:test/template"), + template, + BuiltInRegistries.BLOCK, + false + ); + + assertEquals(4, capture.width()); + assertEquals(1, capture.height()); + assertEquals(1, capture.depth()); + assertEquals(3, capture.blocks()); + assertEquals(1, capture.tiles()); + assertEquals(1, capture.jigsaws()); + assertEquals(1, capture.dataMarkers()); + assertEquals(3, capture.object().getBlocks().size()); + assertEquals(1, capture.object().getStates().size()); + assertTrue(capture.capabilities().contains(StructureCapability.BLOCKS)); + assertTrue(capture.capabilities().contains(StructureCapability.BLOCK_ENTITIES)); + assertFalse(capture.capabilities().contains(StructureCapability.CONNECTORS)); + assertTrue(hasLoss(capture, "connectors_not_imported")); + assertTrue(hasLoss(capture, "data_markers_not_imported")); + assertTrue(hasLoss(capture, "entities_not_imported")); + ByteArrayOutputStream serialized = new ByteArrayOutputStream(); + capture.object().write(serialized); + assertTrue(serialized.size() > 0); + } + + @Test + public void graphCaptureReportsConnectorCapabilityWithoutStandaloneConnectorLoss() { + ModdedStructureTemplateCapture.Capture capture = ModdedStructureTemplateCapture.captureTag( + StructureKey.parse("minecraft:test/template"), + templateTag(), + BuiltInRegistries.BLOCK, + true + ); + + assertTrue(capture.capabilities().contains(StructureCapability.CONNECTORS)); + assertFalse(hasLoss(capture, "connectors_not_imported")); + } + + @Test + public void reportsAdditionalNativePalettes() { + CompoundTag template = templateTag(); + ListTag palettes = new ListTag(); + ListTag first = template.getListOrEmpty(StructureTemplate.PALETTE_TAG); + palettes.add(first.copy()); + palettes.add(first.copy()); + template.remove(StructureTemplate.PALETTE_TAG); + template.put(StructureTemplate.PALETTE_LIST_TAG, palettes); + + ModdedStructureTemplateCapture.Capture capture = ModdedStructureTemplateCapture.captureTag( + StructureKey.parse("minecraft:test/template"), + template, + BuiltInRegistries.BLOCK, + true + ); + + assertTrue(hasLoss(capture, "palette_variants_not_imported")); + } + + @Test + public void legacyCaptureOmitsAirBlocks() { + CompoundTag template = new CompoundTag(); + template.put(StructureTemplate.SIZE_TAG, intList(1, 1, 1)); + ListTag palette = new ListTag(); + palette.add(NbtUtils.writeBlockState(Blocks.AIR.defaultBlockState())); + template.put(StructureTemplate.PALETTE_TAG, palette); + ListTag blocks = new ListTag(); + blocks.add(block(0, 0, 0, 0, null)); + template.put(StructureTemplate.BLOCKS_TAG, blocks); + + ModdedStructureTemplateCapture.Capture capture = ModdedStructureTemplateCapture.captureTag( + StructureKey.parse("minecraft:test/legacy"), + template, + BuiltInRegistries.BLOCK, + true, + false + ); + + assertEquals(0, capture.blocks()); + assertTrue(capture.object().getBlocks().isEmpty()); + } + + private static CompoundTag templateTag() { + CompoundTag template = new CompoundTag(); + template.put(StructureTemplate.SIZE_TAG, intList(4, 1, 1)); + ListTag palette = new ListTag(); + palette.add(NbtUtils.writeBlockState(Blocks.STONE.defaultBlockState())); + palette.add(NbtUtils.writeBlockState(Blocks.CHEST.defaultBlockState())); + palette.add(NbtUtils.writeBlockState(Blocks.JIGSAW.defaultBlockState())); + palette.add(NbtUtils.writeBlockState(Blocks.STRUCTURE_BLOCK.defaultBlockState())); + template.put(StructureTemplate.PALETTE_TAG, palette); + + ListTag blocks = new ListTag(); + blocks.add(block(0, 0, 0, 0, null)); + CompoundTag chest = new CompoundTag(); + chest.putString("id", "minecraft:chest"); + chest.putString("CustomName", "test"); + blocks.add(block(1, 0, 0, 1, chest)); + CompoundTag jigsaw = new CompoundTag(); + jigsaw.putString("final_state", "minecraft:oak_planks"); + blocks.add(block(2, 0, 0, 2, jigsaw)); + blocks.add(block(3, 0, 0, 3, new CompoundTag())); + template.put(StructureTemplate.BLOCKS_TAG, blocks); + + ListTag entities = new ListTag(); + entities.add(new CompoundTag()); + template.put(StructureTemplate.ENTITIES_TAG, entities); + return template; + } + + private static CompoundTag block(int x, int y, int z, int state, CompoundTag nbt) { + CompoundTag block = new CompoundTag(); + block.put(StructureTemplate.BLOCK_TAG_POS, intList(x, y, z)); + block.putInt(StructureTemplate.BLOCK_TAG_STATE, state); + if (nbt != null) { + block.put(StructureTemplate.BLOCK_TAG_NBT, nbt); + } + return block; + } + + private static ListTag intList(int... values) { + ListTag list = new ListTag(); + for (int value : values) { + list.add(IntTag.valueOf(value)); + } + return list; + } + + private static boolean hasLoss(ModdedStructureTemplateCapture.Capture capture, String code) { + return capture.losses().stream().anyMatch(loss -> loss.code().equals(code)); + } +} diff --git a/build.gradle b/build.gradle index 366efb58e..50a05535c 100644 --- a/build.gradle +++ b/build.gradle @@ -105,6 +105,7 @@ nmsBindings.each { key, value -> compileOnly(rootProject.libs.annotations) compileOnly(rootProject.libs.byteBuddy.core) testImplementation('junit:junit:4.13.2') + testRuntimeOnly(project(':core')) } tasks.withType(org.gradle.api.tasks.testing.Test).configureEach { diff --git a/core/plugins/Iris/cache/instance b/core/plugins/Iris/cache/instance new file mode 100644 index 000000000..38fa149ac --- /dev/null +++ b/core/plugins/Iris/cache/instance @@ -0,0 +1 @@ +931318337 \ No newline at end of file diff --git a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java index a597718f6..7dbc52b48 100644 --- a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java +++ b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java @@ -142,7 +142,7 @@ public final class DatapackIngestService { if (report.changed()) { message(sender, C.YELLOW + "New datapack structures were installed. A server restart is required for them to register and generate."); message(sender, C.GRAY + "After the restart their jigsaw pools, pieces & objects are imported automatically (set general.autoImportDatapackStructures=false to disable), or run /iris structure import to import everything on demand. Reference an imported key from a 'structures' placement to position it manually."); - message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; control non-minecraft datapack and mod structures with importedStructures.enabled/disabled."); + message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; deny non-minecraft datapack and mod structures explicitly with importedStructures.disabled."); if (restart) { ServerConfigurator.restart(); } else { @@ -401,28 +401,46 @@ public final class DatapackIngestService { } IrisLogging.info("Importing datapack structures (jigsaw pools, pieces & objects) into packs that declare datapackImports..."); - AtomicInteger packs = new AtomicInteger(); + AtomicInteger attemptedPacks = new AtomicInteger(); + AtomicInteger completedPacks = new AtomicInteger(); try (Stream stream = ServerConfigurator.allPacks()) { stream.forEach(data -> { if (data == null || !hasImports(data)) { return; } + attemptedPacks.incrementAndGet(); try { - BulkStructureImporter.importDatapackStructures(data, StructureImporter.Mode.ADD_ONLY, BukkitPlatform.console()); - packs.incrementAndGet(); - } catch (Throwable e) { - IrisLogging.reportError(e); + BulkStructureImporter.Report report = BulkStructureImporter.importDatapackStructures( + data, StructureImporter.Mode.ADD_ONLY, BukkitPlatform.console()); + if (report.failed() > 0) { + IrisLogging.error("Datapack structure import for pack '%s' reported %d failure(s); the manifest remains pending for retry.", + data.getDataFolder().getPath(), report.failed()); + return; + } + completedPacks.incrementAndGet(); + } catch (RuntimeException e) { + IrisLogging.reportError("Datapack structure import failed for pack '" + + data.getDataFolder().getPath() + "'; the manifest remains pending for retry.", e); } }); } - for (Entry entry : manifest.entries) { - entry.structuresImported = true; + if (!markStructuresImportedIfComplete( + manifest.entries, attemptedPacks.get(), completedPacks.get())) { + return; } writeManifest(root, manifest); - if (packs.get() > 0) { - IrisLogging.info("Datapack structure import finished for " + packs.get() + " pack(s). Reference the imported keys from a 'structures' placement to position them manually."); + IrisLogging.info("Datapack structure import finished for " + completedPacks.get() + " pack(s). Reference the imported keys from a 'structures' placement to position them manually."); + } + + static boolean markStructuresImportedIfComplete(List entries, int attemptedPacks, int completedPacks) { + if (attemptedPacks < 1 || completedPacks != attemptedPacks) { + return false; } + for (Entry entry : entries) { + entry.structuresImported = true; + } + return true; } private static void flattenIfWrapped(File dir) throws IOException { diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index d50bce1c2..e0d4b029d 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -33,6 +33,10 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.framework.structure.StructureGraphCatalog; +import art.arcane.iris.core.structure.authoring.StructureRecoveryResult; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBlockData; import art.arcane.iris.engine.object.IrisDimension; @@ -209,10 +213,6 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { return loadAny(IrisGenerator.class, key, nearest); } - public static IrisStructure loadAnyStructure(String key, @Nullable IrisData nearest) { - return loadAny(IrisStructure.class, key, nearest); - } - public static IrisJigsawPool loadAnyJigsawPool(String key, @Nullable IrisData nearest) { return loadAny(IrisJigsawPool.class, key, nearest); } @@ -330,6 +330,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } public synchronized void hotloaded() { + StructureGraphCatalog.invalidate(this); closed = false; possibleSnippets = new KMap<>(); builder = new GsonBuilder() @@ -342,6 +343,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { loaders.clear(); File packs = dataFolder; packs.mkdirs(); + recoverStructureTransactions(); this.lootLoader = registerLoader(IrisLootTable.class); this.spawnerLoader = registerLoader(IrisSpawner.class); this.entityLoader = registerLoader(IrisEntity.class); @@ -380,6 +382,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { .setPrettyPrinting(); loaders.clear(); dataFolder.mkdirs(); + recoverStructureTransactions(); biomeLoader = registerLoader(IrisBiome.class); dimensionLoader = registerLoader(IrisDimension.class); builder.registerTypeAdapterFactory(KeyedType::createTypeAdapter); @@ -390,11 +393,46 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } public void dump() { + StructureGraphCatalog.invalidate(this); for (ResourceLoader i : loaders.values()) { i.clearCache(); } } + public synchronized void invalidateStructureResources() { + StructureGraphCatalog.invalidate(this); + invalidateLoader(objectLoader); + invalidateLoader(structureLoader); + invalidateLoader(jigsawPoolLoader); + invalidateLoader(jigsawPieceLoader); + if (engine != null) { + IrisStructureLocator.invalidate(engine); + } + } + + private void recoverStructureTransactions() { + StructureRecoveryResult recovery = new StructureTransactionWriter(dataFolder.toPath()) + .recoverIncompleteTransactions(); + if (recovery.successful()) { + if (recovery.recoveredTransactions() > 0) { + IrisLogging.warn("Recovered " + recovery.recoveredTransactions() + + " interrupted structure authoring transaction(s) in " + dataFolder); + } + return; + } + IllegalStateException failure = new IllegalStateException( + "Unable to recover interrupted structure authoring transactions in " + dataFolder); + for (StructureRecoveryResult.Failure recoveryFailure : recovery.failures()) { + failure.addSuppressed(new IOException( + "Recovery failed for " + recoveryFailure.transactionRoot(), + recoveryFailure.cause() + )); + } + IrisLogging.reportError(failure); + failure.printStackTrace(); + throw failure; + } + public void clearLists() { for (ResourceLoader i : loaders.values()) { i.clearList(); @@ -402,6 +440,14 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { possibleSnippets.clear(); } + private void invalidateLoader(ResourceLoader loader) { + if (loader == null) { + return; + } + loader.clearCache(); + loader.clearList(); + } + public Set> resolveSnippets() { var result = new HashSet>(); var processed = new HashSet>(); diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMS.java b/core/src/main/java/art/arcane/iris/core/nms/INMS.java index 762ee096e..9b5574081 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMS.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMS.java @@ -23,17 +23,7 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.nms.v1X.NMSBinding1X; import org.bukkit.Bukkit; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - public class INMS { - private static final Version CURRENT = new Version(26, 2, 0, "v26_2_R1"); - - private static final List REVISION = List.of( - CURRENT - ); - //@done private static final INMSBinding binding = bind(); @@ -46,81 +36,39 @@ public class INMS { return "BUKKIT"; } - try { - String name = Bukkit.getServer().getClass().getCanonicalName(); - if (name.equals("org.bukkit.craftbukkit.CraftServer")) { - return getTag(REVISION, "BUKKIT"); - } else { - return name.split("\\Q.\\E")[3]; - } - } catch (Throwable e) { - IrisLogging.reportError(e); - IrisLogging.error("Failed to determine server nms version!"); - e.printStackTrace(); - } - - return "BUKKIT"; + return NmsBindingSelector.select(requireMinecraftVersion()); } private static INMSBinding bind() { - String code = getNMSTag(); boolean disableNms = IrisSettings.get().getGeneral().isDisableNMS(); - List probeCodes = NmsBindingProbeSupport.getBindingProbeCodes(code, disableNms, getFallbackBindingCodes()); - if ("BUKKIT".equals(code) && !disableNms) { - IrisLogging.info("NMS tag resolution fell back to Bukkit; probing supported revision bindings."); - } - - for (int i = 0; i < probeCodes.size(); i++) { - INMSBinding resolvedBinding = tryBind(probeCodes.get(i), i == 0); - if (resolvedBinding != null) { - return resolvedBinding; - } - } - if (disableNms) { - IrisLogging.info("Craftbukkit " + code + " <-> " + NMSBinding1X.class.getSimpleName() + " Successfully Bound"); - IrisLogging.warn("Note: NMS support is disabled. Iris is running in limited Bukkit fallback mode."); + IrisLogging.info("Craftbukkit BUKKIT <-> " + NMSBinding1X.class.getSimpleName() + " Successfully Bound"); + IrisLogging.warn("NMS support is disabled. Iris world creation is unavailable until general.disableNMS=false."); return new NMSBinding1X(); } - - MinecraftVersion detectedVersion = getMinecraftVersion(); - String serverVersion = detectedVersion == null ? Bukkit.getServer().getVersion() : detectedVersion.value(); - throw new IllegalStateException("Iris requires Minecraft 26.2. Detected server version: " + serverVersion); + return bindExact(getNMSTag()); } - private static String getTag(List versions, String def) { - MinecraftVersion detectedVersion = getMinecraftVersion(); - if (detectedVersion == null) { - return def; - } - - for (Version p : versions) { - if (!detectedVersion.isSameRelease(p.major, p.minor, p.patch)) { - continue; - } - return p.tag; - } - return def; - } - - private static MinecraftVersion getMinecraftVersion() { + private static MinecraftVersion requireMinecraftVersion() { try { - return MinecraftVersion.detect(Bukkit.getServer()); + MinecraftVersion detected = MinecraftVersion.detect(Bukkit.getServer()); + if (detected == null) { + throw new IllegalStateException("Iris could not determine the exact Minecraft server version"); + } + return detected; } catch (Throwable e) { IrisLogging.reportError(e); IrisLogging.error("Failed to determine server minecraft version!"); e.printStackTrace(); - return null; + if (e instanceof IllegalStateException illegalStateException) { + throw illegalStateException; + } + throw new IllegalStateException("Iris could not determine the exact Minecraft server version", e); } } - private static INMSBinding tryBind(String code, boolean announce) { - if (announce) { - IrisLogging.info("Locating NMS Binding for " + code); - } else { - IrisLogging.info("Probing NMS Binding for " + code); - } - + private static INMSBinding bindExact(String code) { + IrisLogging.info("Locating exact NMS Binding for " + code); try { Class clazz = Class.forName("art.arcane.iris.core.nms." + code + ".NMSBinding"); Object candidate = clazz.getConstructor().newInstance(); @@ -128,25 +76,15 @@ public class INMS { IrisLogging.info("Craftbukkit " + code + " <-> " + candidate.getClass().getSimpleName() + " Successfully Bound"); return binding; } - } catch (ClassNotFoundException | NoClassDefFoundError classNotFoundException) { - IrisLogging.warn("Failed to load NMS binding class for " + code + ": " + classNotFoundException.getMessage()); + throw new IllegalStateException("Exact NMS binding class for " + code + + " does not implement " + INMSBinding.class.getName()); } catch (Throwable e) { IrisLogging.reportError(e); e.printStackTrace(); - } - - return null; - } - - private static Set getFallbackBindingCodes() { - Set codes = new LinkedHashSet<>(); - for (Version version : REVISION) { - if (version.tag != null && !version.tag.isBlank()) { - codes.add(version.tag); + if (e instanceof IllegalStateException illegalStateException) { + throw illegalStateException; } + throw new IllegalStateException("Failed to bind exact NMS revision " + code, e); } - return codes; } - - private record Version(int major, int minor, int patch, String tag) {} } diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java index 0bea1580a..011a6739f 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java @@ -74,6 +74,8 @@ public interface INMSBinding { boolean supportsCustomBiomes(); + boolean supportsIrisWorldGeneration(); + int getTrueBiomeBaseId(Object biomeBase); Object getTrueBiomeBase(Location location); @@ -94,25 +96,15 @@ public interface INMSBinding { KList getBiomes(); - default KList getStructureKeys() { - return new KList<>(); - } + KList getStructureKeys(); - default KList getStructureSetKeys() { - return new KList<>(); - } + KList getStructureSetKeys(); - default KList getReachableStructureKeys(World world) { - return new KList<>(); - } + KList getReachableStructureKeys(World world); - default KList getStructureBiomeKeys(String structureKey) { - return new KList<>(); - } + KList getStructureBiomeKeys(String structureKey); - default KList getPossibleBiomeKeys(World world) { - return new KList<>(); - } + KList getPossibleBiomeKeys(World world); default KList getObjectFeatureKeys() { return new KList<>(); @@ -229,8 +221,15 @@ public interface INMSBinding { KMap> getBlockProperties(); private void validateDimensionTypes(WorldCreator c) { - if (c.generator() instanceof PlatformChunkGenerator gen - && missingDimensionTypes(gen.getTarget().getDimension().getDimensionTypeKey())) { + if (!(c.generator() instanceof PlatformChunkGenerator generator)) { + return; + } + if (!supportsIrisWorldGeneration()) { + throw new IllegalStateException("Iris world '" + c.name() + "' cannot be created with limited NMS binding " + + getClass().getSimpleName() + + "; set general.disableNMS=false and use the supported Minecraft 26.2 server runtime"); + } + if (missingDimensionTypes(generator.getTarget().getDimension().getDimensionTypeKey())) { throw new IllegalStateException("Missing dimension types to create world"); } } diff --git a/core/src/main/java/art/arcane/iris/core/nms/NmsBindingProbeSupport.java b/core/src/main/java/art/arcane/iris/core/nms/NmsBindingProbeSupport.java deleted file mode 100644 index 397528d14..000000000 --- a/core/src/main/java/art/arcane/iris/core/nms/NmsBindingProbeSupport.java +++ /dev/null @@ -1,29 +0,0 @@ -package art.arcane.iris.core.nms; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -final class NmsBindingProbeSupport { - private NmsBindingProbeSupport() { - } - - static List getBindingProbeCodes(String code, boolean disableNms, Collection fallbackCodes) { - List probeCodes = new ArrayList<>(); - if (code == null || code.isBlank()) { - return probeCodes; - } - - if (!"BUKKIT".equals(code)) { - probeCodes.add(code); - return probeCodes; - } - - if (disableNms || fallbackCodes == null) { - return probeCodes; - } - - probeCodes.addAll(fallbackCodes); - return probeCodes; - } -} diff --git a/core/src/main/java/art/arcane/iris/core/nms/NmsBindingSelector.java b/core/src/main/java/art/arcane/iris/core/nms/NmsBindingSelector.java new file mode 100644 index 000000000..6f5f48717 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/nms/NmsBindingSelector.java @@ -0,0 +1,20 @@ +package art.arcane.iris.core.nms; + +final class NmsBindingSelector { + private static final String SUPPORTED_VERSION = "26.2"; + private static final String SUPPORTED_TAG = "v26_2_R1"; + + private NmsBindingSelector() { + } + + static String select(MinecraftVersion version) { + if (version == null) { + throw new IllegalStateException("Iris requires an exact Minecraft version before selecting NMS"); + } + if (!version.isSameRelease(26, 2, 0)) { + throw new IllegalStateException("Iris requires Minecraft " + SUPPORTED_VERSION + + ". Detected server version: " + version.value()); + } + return SUPPORTED_TAG; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java b/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java index aa7844394..5a1c73db7 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java +++ b/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java @@ -98,7 +98,8 @@ public class NMSBinding1X implements INMSBinding { @Override public void inject(long seed, Engine engine, World world) throws NoSuchFieldException, IllegalAccessException { - + throw new IllegalStateException("Iris world generation requires the supported NMS binding; " + + "general.disableNMS=true cannot create or initialize an Iris world"); } public Vector3d getBoundingbox() { @@ -159,6 +160,11 @@ public class NMSBinding1X implements INMSBinding { return false; } + @Override + public boolean supportsIrisWorldGeneration() { + return false; + } + @Override public int getTrueBiomeBaseId(Object biomeBase) { return 0; @@ -212,6 +218,31 @@ public class NMSBinding1X implements INMSBinding { return biomes; } + @Override + public KList getStructureKeys() { + throw unsupportedStructureHook("read registered structure keys"); + } + + @Override + public KList getStructureSetKeys() { + throw unsupportedStructureHook("read registered structure-set keys"); + } + + @Override + public KList getReachableStructureKeys(World world) { + throw unsupportedStructureHook("resolve reachable structures"); + } + + @Override + public KList getStructureBiomeKeys(String structureKey) { + throw unsupportedStructureHook("resolve structure biome keys"); + } + + @Override + public KList getPossibleBiomeKeys(World world) { + throw unsupportedStructureHook("resolve possible biome keys"); + } + @Override public DataVersion getDataVersion() { return DataVersion.UNSUPPORTED; @@ -258,4 +289,10 @@ public class NMSBinding1X implements INMSBinding { IrisLogging.error("Cannot use the global data palette! Iris is incapable of using MCA generation on this version of minecraft!"); return null; } + + private IllegalStateException unsupportedStructureHook(String operation) { + return new IllegalStateException("Iris cannot " + operation + " with limited NMS binding " + + getClass().getSimpleName() + + "; set general.disableNMS=false and use the supported Minecraft 26.2 server runtime"); + } } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidationRegistry.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidationRegistry.java index 9d4dcac10..d368a4315 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidationRegistry.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidationRegistry.java @@ -19,6 +19,7 @@ package art.arcane.iris.core.pack; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -42,6 +43,21 @@ public final class PackValidationRegistry { return RESULTS.get(packName); } + public static PackValidationResult requireLoadable(String packName) { + if (packName == null || packName.isBlank()) { + throw new IllegalArgumentException("Pack name is required for validation"); + } + PackValidationResult result = get(packName); + if (result == null) { + throw new BrokenPackException(packName, List.of( + "Required pack validation has not completed. World creation fails closed until validation succeeds.")); + } + if (!result.isLoadable()) { + throw new BrokenPackException(packName, result.getBlockingErrors()); + } + return result; + } + public static boolean isBroken(String packName) { PackValidationResult result = get(packName); return result != null && !result.isLoadable(); diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index 8c4569de2..73c5cded8 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -19,6 +19,10 @@ package art.arcane.iris.core.pack; import art.arcane.iris.engine.object.IrisBiomeCustomSpawnType; +import art.arcane.iris.engine.object.IrisLoot; +import art.arcane.iris.engine.object.IrisLootReference; +import art.arcane.iris.engine.object.IrisLootTable; +import art.arcane.iris.engine.object.ObjectPlaceMode; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformEntityType; @@ -35,6 +39,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -51,11 +56,13 @@ public final class PackValidator { private static final String DATAPACKS_FOLDER = "datapacks"; private static final String CACHE_FOLDER = "cache"; private static final String OBJECTS_FOLDER = "objects"; + private static final String LOOT_FOLDER = "loot"; private static final String DIMENSIONS_FOLDER = "dimensions"; private static final String STRUCTURES_FOLDER = "structures"; private static final String JIGSAW_POOLS_FOLDER = "jigsaw-pools"; private static final String JIGSAW_PIECES_FOLDER = "jigsaw-pieces"; private static final List STRUCTURE_HOST_FOLDERS = List.of(DIMENSIONS_FOLDER, "regions", "biomes"); + private static final List REMOVED_WORLDGEN_FIELDS = List.of("fluidBodies"); private static final List UNSUPPORTED_STRUCTURE_TRANSFORM_FIELDS = List.of("rotation", "translate", "scale"); private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); @@ -86,8 +93,19 @@ public final class PackValidator { } validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings); + blockingErrors.addAll(validateLootGraph(packFolder)); + blockingErrors.addAll(validateRemovedWorldgenFields(packFolder)); blockingErrors.addAll(validateUnsupportedStructureTransforms(packFolder)); blockingErrors.addAll(validateStructureGraph(packFolder)); + StructureGraphPackValidator.Validation compiledStructures = + StructureGraphPackValidator.validate( + packFolder.toPath(), collectPlacedStructureKeys(packFolder)); + addDistinct(blockingErrors, compiledStructures.errors()); + addDistinct(warnings, compiledStructures.warnings()); + blockingErrors.addAll(validateNativeStructureReplacements( + packFolder, + compiledStructures.replacementOutputStructures(), + compiledStructures.sampledVerticalEnvelopes())); blockingErrors.addAll(validateSpawnerEntityReferences( new File(packFolder, "spawners"), new File(packFolder, "entities"))); blockingErrors.addAll(validateCustomBiomeSpawns( @@ -98,6 +116,54 @@ public final class PackValidator { return new PackValidationResult(packName, blockingErrors, warnings, validatedAt); } + private static void addDistinct(List destination, List additions) { + for (String addition : additions) { + if (!destination.contains(addition)) { + destination.add(addition); + } + } + } + + static Set collectPlacedStructureKeys(File packFolder) { + Set structureKeys = new LinkedHashSet<>(); + if (packFolder == null || !packFolder.isDirectory()) { + return structureKeys; + } + for (String folderName : STRUCTURE_HOST_FOLDERS) { + File resourceFolder = new File(packFolder, folderName); + if (!resourceFolder.isDirectory()) { + continue; + } + for (File resourceFile : listJsonRecursive(resourceFolder)) { + JSONObject resource = readJson(resourceFile); + if (resource == null) { + continue; + } + JSONArray placements = resource.optJSONArray("structures"); + if (placements == null) { + continue; + } + for (int placementIndex = 0; placementIndex < placements.length(); placementIndex++) { + JSONObject placement = placements.optJSONObject(placementIndex); + if (placement == null) { + continue; + } + JSONArray references = placement.optJSONArray("structures"); + if (references == null) { + continue; + } + for (int referenceIndex = 0; referenceIndex < references.length(); referenceIndex++) { + Object rawReference = references.opt(referenceIndex); + if (rawReference instanceof String structureKey && !structureKey.isBlank()) { + structureKeys.add(structureKey); + } + } + } + } + } + return Set.copyOf(structureKeys); + } + static List validateUnsupportedStructureTransforms(File packFolder) { List blockingErrors = new ArrayList<>(); if (packFolder == null || !packFolder.isDirectory()) { @@ -165,6 +231,535 @@ public final class PackValidator { return blockingErrors; } + static List validateLootGraph(File packFolder) { + List blockingErrors = new ArrayList<>(); + if (packFolder == null || !packFolder.isDirectory()) { + return blockingErrors; + } + + File lootFolder = new File(packFolder, LOOT_FOLDER); + Set lootKeys = deriveRegistrantKeysExact(lootFolder); + if (lootFolder.isDirectory()) { + List lootFiles = listJsonRecursive(lootFolder); + lootFiles.sort(Comparator.comparing(File::getPath)); + for (File lootFile : lootFiles) { + String lootKey = deriveKey(lootFolder, lootFile); + JSONObject table = readGraphJson(lootFile, "Loot table", lootKey, blockingErrors); + if (table != null) { + validateLootTable(lootKey, table, blockingErrors); + } + } + } + + for (String folderName : STRUCTURE_HOST_FOLDERS) { + File resourceFolder = new File(packFolder, folderName); + if (!resourceFolder.isDirectory()) { + continue; + } + List resourceFiles = listJsonRecursive(resourceFolder); + resourceFiles.sort(Comparator.comparing(File::getPath)); + for (File resourceFile : resourceFiles) { + JSONObject resource = readJson(resourceFile); + if (resource == null || !resource.has("loot")) { + continue; + } + String resourceType = structureHostType(folderName); + String resourceKey = deriveKey(resourceFolder, resourceFile); + validateLootReference(resourceType, resourceKey, resource.opt("loot"), lootKeys, blockingErrors); + } + } + return blockingErrors; + } + + static List validateRemovedWorldgenFields(File packFolder) { + List blockingErrors = new ArrayList<>(); + if (packFolder == null || !packFolder.isDirectory()) { + return blockingErrors; + } + + for (String folderName : STRUCTURE_HOST_FOLDERS) { + File resourceFolder = new File(packFolder, folderName); + if (!resourceFolder.isDirectory()) { + continue; + } + List resourceFiles = listJsonRecursive(resourceFolder); + resourceFiles.sort(Comparator.comparing(File::getPath)); + String resourceType = structureHostType(folderName); + for (File resourceFile : resourceFiles) { + JSONObject resource = readJson(resourceFile); + if (resource == null) { + continue; + } + String resourceKey = deriveKey(resourceFolder, resourceFile); + for (String field : REMOVED_WORLDGEN_FIELDS) { + if (resource.has(field)) { + blockingErrors.add(resourceType + " '" + resourceKey + "' declares removed field '" + + field + "'. Remove it because fluid-body generation is not supported."); + } + } + } + } + return blockingErrors; + } + + private static void validateLootTable(String lootKey, JSONObject table, List blockingErrors) { + String path = "Loot table '" + lootKey + "'"; + Integer rarity = lootInteger(table, "rarity", 1, path, blockingErrors); + Integer minimumPicked = lootInteger(table, "minPicked", 1, path, blockingErrors); + Integer maximumPicked = lootInteger(table, "maxPicked", 5, path, blockingErrors); + Integer maximumTries = lootInteger(table, "maxTries", 10, path, blockingErrors); + requireMinimum(path + ".rarity", rarity, 1, blockingErrors); + requireMinimum(path + ".minPicked", minimumPicked, 0, blockingErrors); + requireMinimum(path + ".maxPicked", maximumPicked, 1, blockingErrors); + requireMinimum(path + ".maxTries", maximumTries, 1, blockingErrors); + requireMaximum(path + ".minPicked", minimumPicked, IrisLootTable.MAX_PICKED, blockingErrors); + requireMaximum(path + ".maxPicked", maximumPicked, IrisLootTable.MAX_PICKED, blockingErrors); + requireMaximum(path + ".maxTries", maximumTries, IrisLootTable.MAX_TRIES, blockingErrors); + requireOrdered(path + ".minPicked", minimumPicked, path + ".maxPicked", maximumPicked, blockingErrors); + + JSONArray entries = table.optJSONArray("loot"); + if (entries == null || entries.length() == 0) { + blockingErrors.add(path + ".loot must be a non-empty array."); + return; + } + for (int entryIndex = 0; entryIndex < entries.length(); entryIndex++) { + JSONObject entry = entries.optJSONObject(entryIndex); + String entryPath = path + ".loot[" + entryIndex + "]"; + if (entry == null) { + blockingErrors.add(entryPath + " must be an object."); + continue; + } + String type = entry.optString("type", "").trim(); + if (type.isEmpty()) { + blockingErrors.add(entryPath + ".type must not be blank."); + } + Integer entryRarity = lootInteger(entry, "rarity", 1, entryPath, blockingErrors); + Integer minimumAmount = lootInteger(entry, "minAmount", 1, entryPath, blockingErrors); + Integer maximumAmount = lootInteger(entry, "maxAmount", 1, entryPath, blockingErrors); + requireMinimum(entryPath + ".rarity", entryRarity, 1, blockingErrors); + requireMinimum(entryPath + ".minAmount", minimumAmount, 1, blockingErrors); + requireMinimum(entryPath + ".maxAmount", maximumAmount, 1, blockingErrors); + requireMaximum(entryPath + ".minAmount", minimumAmount, IrisLoot.MAX_AMOUNT, blockingErrors); + requireMaximum(entryPath + ".maxAmount", maximumAmount, IrisLoot.MAX_AMOUNT, blockingErrors); + requireOrdered(entryPath + ".minAmount", minimumAmount, + entryPath + ".maxAmount", maximumAmount, blockingErrors); + validateLootEnchantments(entryPath, entry.opt("enchantments"), blockingErrors); + } + } + + private static void validateLootEnchantments(String entryPath, Object rawEnchantments, + List blockingErrors) { + if (rawEnchantments == null || rawEnchantments == JSONObject.NULL) { + return; + } + if (!(rawEnchantments instanceof JSONArray enchantments)) { + blockingErrors.add(entryPath + ".enchantments must be an array."); + return; + } + for (int enchantmentIndex = 0; enchantmentIndex < enchantments.length(); enchantmentIndex++) { + JSONObject enchantment = enchantments.optJSONObject(enchantmentIndex); + String enchantmentPath = entryPath + ".enchantments[" + enchantmentIndex + "]"; + if (enchantment == null) { + blockingErrors.add(enchantmentPath + " must be an object."); + continue; + } + if (enchantment.optString("enchantment", "").isBlank()) { + blockingErrors.add(enchantmentPath + ".enchantment must not be blank."); + } + Integer minimumLevel = lootInteger(enchantment, "minLevel", 1, enchantmentPath, blockingErrors); + Integer maximumLevel = lootInteger(enchantment, "maxLevel", 1, enchantmentPath, blockingErrors); + requireMinimum(enchantmentPath + ".minLevel", minimumLevel, 1, blockingErrors); + requireMinimum(enchantmentPath + ".maxLevel", maximumLevel, 1, blockingErrors); + requireOrdered(enchantmentPath + ".minLevel", minimumLevel, + enchantmentPath + ".maxLevel", maximumLevel, blockingErrors); + if (enchantment.has("chance")) { + Object rawChance = enchantment.opt("chance"); + if (!(rawChance instanceof Number number) + || !Double.isFinite(number.doubleValue()) + || number.doubleValue() < 0D + || number.doubleValue() > 1D) { + blockingErrors.add(enchantmentPath + ".chance must be a finite number from 0 to 1."); + } + } + } + } + + private static void validateLootReference(String resourceType, String resourceKey, Object rawLoot, + Set lootKeys, List blockingErrors) { + String path = resourceType + " '" + resourceKey + "'.loot"; + if (!(rawLoot instanceof JSONObject reference)) { + blockingErrors.add(path + " must be an object."); + return; + } + if (reference.has("mode")) { + Object rawMode = reference.opt("mode"); + if (!(rawMode instanceof String mode) + || !Set.of("ADD", "CLEAR", "REPLACE", "FALLBACK").contains(mode)) { + blockingErrors.add(path + ".mode must be ADD, CLEAR, REPLACE, or FALLBACK."); + } + } + if (reference.has("multiplier")) { + Object rawMultiplier = reference.opt("multiplier"); + if (!(rawMultiplier instanceof Number multiplier) + || !Double.isFinite(multiplier.doubleValue()) + || multiplier.doubleValue() < 0D + || multiplier.doubleValue() > IrisLootReference.MAX_MULTIPLIER) { + blockingErrors.add(path + ".multiplier must be a finite number from 0 to " + + (int) IrisLootReference.MAX_MULTIPLIER + "."); + } + } + if (!reference.has("tables")) { + return; + } + JSONArray tables = reference.optJSONArray("tables"); + if (tables == null) { + blockingErrors.add(path + ".tables must be an array."); + return; + } + for (int tableIndex = 0; tableIndex < tables.length(); tableIndex++) { + Object rawTableKey = tables.opt(tableIndex); + if (!(rawTableKey instanceof String tableKey) || tableKey.isBlank()) { + blockingErrors.add(path + ".tables[" + tableIndex + "] must name a loot table."); + } else if (!lootKeys.contains(tableKey)) { + blockingErrors.add(path + ".tables[" + tableIndex + + "] references missing loot table '" + tableKey + "'."); + } + } + } + + private static Integer lootInteger(JSONObject object, String field, int defaultValue, + String path, List blockingErrors) { + if (!object.has(field)) { + return defaultValue; + } + Object rawValue = object.opt(field); + if (!(rawValue instanceof Number number) + || !Double.isFinite(number.doubleValue()) + || number.doubleValue() != Math.rint(number.doubleValue()) + || number.longValue() < Integer.MIN_VALUE + || number.longValue() > Integer.MAX_VALUE) { + blockingErrors.add(path + "." + field + " must be an integer."); + return null; + } + return number.intValue(); + } + + private static void requireMinimum(String fieldPath, Integer value, int minimum, + List blockingErrors) { + if (value != null && value < minimum) { + blockingErrors.add(fieldPath + " must be at least " + minimum + "."); + } + } + + private static void requireMaximum(String fieldPath, Integer value, int maximum, + List blockingErrors) { + if (value != null && value > maximum) { + blockingErrors.add(fieldPath + " must be at most " + maximum + "."); + } + } + + private static void requireOrdered(String minimumPath, Integer minimum, String maximumPath, + Integer maximum, List blockingErrors) { + if (minimum != null && maximum != null && minimum > maximum) { + blockingErrors.add(minimumPath + " must not exceed " + maximumPath + "."); + } + } + + static List validateNativeStructureReplacements( + File packFolder, + Set replacementOutputStructures, + Map> sampledVerticalEnvelopes + ) { + List blockingErrors = new ArrayList<>(); + if (packFolder == null || !packFolder.isDirectory()) { + return blockingErrors; + } + Set viableStructures = replacementOutputStructures == null + ? Set.of() : replacementOutputStructures; + Map> verticalEnvelopes = + sampledVerticalEnvelopes == null ? Map.of() : sampledVerticalEnvelopes; + File structuresFolder = new File(packFolder, STRUCTURES_FOLDER); + Map structures = new HashMap<>(); + for (File structureFile : listJsonRecursive(structuresFolder)) { + JSONObject structure = readJson(structureFile); + if (structure != null) { + structures.put(deriveKey(structuresFolder, structureFile), structure); + } + } + + for (String folderName : STRUCTURE_HOST_FOLDERS) { + File resourceFolder = new File(packFolder, folderName); + if (!resourceFolder.isDirectory()) { + continue; + } + List resourceFiles = listJsonRecursive(resourceFolder); + resourceFiles.sort(Comparator.comparing(File::getPath)); + String resourceType = structureHostType(folderName); + for (File resourceFile : resourceFiles) { + JSONObject resource = readJson(resourceFile); + if (resource == null) { + continue; + } + JSONArray placements = resource.optJSONArray("structures"); + if (placements == null) { + continue; + } + String resourceKey = deriveKey(resourceFolder, resourceFile); + for (int placementIndex = 0; placementIndex < placements.length(); placementIndex++) { + JSONObject placement = placements.optJSONObject(placementIndex); + if (placement == null || !placement.has("nativeSuppression")) { + continue; + } + Object rawSuppression = placement.opt("nativeSuppression"); + if (!(rawSuppression instanceof String suppression)) { + blockingErrors.add(resourceType + " '" + resourceKey + "' structures[" + + placementIndex + "].nativeSuppression must be NONE or REPLACE_SOURCE."); + continue; + } + if ("NONE".equals(suppression)) { + continue; + } + if (!"REPLACE_SOURCE".equals(suppression)) { + blockingErrors.add(resourceType + " '" + resourceKey + "' structures[" + + placementIndex + "].nativeSuppression has unsupported value '" + + suppression + "'. Use NONE or REPLACE_SOURCE."); + continue; + } + if (!DIMENSIONS_FOLDER.equals(folderName)) { + blockingErrors.add(resourceType + " '" + resourceKey + "' structures[" + + placementIndex + "] requests REPLACE_SOURCE, but native replacement is only" + + " valid on dimension-level placements."); + continue; + } + JSONArray references = placement.optJSONArray("structures"); + if (references == null || references.length() == 0) { + blockingErrors.add("Dimension '" + resourceKey + "' structures[" + placementIndex + + "] requests REPLACE_SOURCE without any Iris structure references."); + continue; + } + for (int referenceIndex = 0; referenceIndex < references.length(); referenceIndex++) { + Object rawReference = references.opt(referenceIndex); + if (!(rawReference instanceof String structureKey) || structureKey.isBlank()) { + blockingErrors.add("Dimension '" + resourceKey + "' structures[" + placementIndex + + "].structures[" + referenceIndex + + "] must name an Iris structure for REPLACE_SOURCE."); + continue; + } + JSONObject structure = structures.get(structureKey); + if (structure == null) { + blockingErrors.add("Dimension '" + resourceKey + "' structures[" + placementIndex + + "] cannot REPLACE_SOURCE with missing or invalid structure '" + + structureKey + "'."); + continue; + } + String vanillaSource = structure.optString("vanillaSource", "").trim(); + if (!RESOURCE_KEY_PATTERN.matcher(vanillaSource).matches()) { + blockingErrors.add("Dimension '" + resourceKey + "' structures[" + placementIndex + + "] requests REPLACE_SOURCE for structure '" + structureKey + + "', but its vanillaSource is not a valid namespaced registry key."); + } + if (!viableStructures.contains(structureKey)) { + blockingErrors.add("Dimension '" + resourceKey + "' structures[" + placementIndex + + "] requests REPLACE_SOURCE for structure '" + structureKey + + "', but that structure is not runtime-viable. Native generation will not" + + " be used as a fallback."); + continue; + } + validateReplacementVerticalEnvelope( + resourceKey, + resource, + placementIndex, + placement, + structureKey, + structure, + verticalEnvelopes.get(structureKey), + blockingErrors); + } + } + } + } + return blockingErrors; + } + + private static void validateReplacementVerticalEnvelope( + String dimensionKey, + JSONObject dimension, + int placementIndex, + JSONObject placement, + String structureKey, + JSONObject structure, + List sampledVerticalEnvelopes, + List blockingErrors + ) { + String context = "Dimension '" + dimensionKey + "' structures[" + placementIndex + + "] REPLACE_SOURCE structure '" + structureKey + "'"; + if (sampledVerticalEnvelopes == null || sampledVerticalEnvelopes.isEmpty()) { + blockingErrors.add(context + " has no sampled vertical envelope. Native generation will not" + + " be used as a fallback."); + return; + } + + DimensionVerticalBounds worldBounds = resolveDimensionVerticalBounds(dimension, context, blockingErrors); + PlacementVerticalBounds placementBounds = resolvePlacementVerticalBounds(placement, context, blockingErrors); + ObjectPlaceMode placeMode = resolvePlaceMode(structure, context, blockingErrors); + if (worldBounds == null || placementBounds == null || placeMode == null) { + return; + } + + for (StructureGraphPackValidator.SampledVerticalEnvelope sampled : sampledVerticalEnvelopes) { + boolean exactY = placementBounds.underground() + || sampled.pieceCount() > 1 + || placeMode == ObjectPlaceMode.STRUCTURE_PIECE + || placeMode == ObjectPlaceMode.FLOATING; + if (!exactY) { + continue; + } + + long minimumYOffset = sampled.minimumYOffset(); + long maximumYOffset = sampled.maximumYOffset(); + boolean surfaceAligned = !placementBounds.underground() + && sampled.pieceCount() > 1 + && placeMode != ObjectPlaceMode.STRUCTURE_PIECE + && placeMode != ObjectPlaceMode.FLOATING; + if (surfaceAligned) { + maximumYOffset -= minimumYOffset; + minimumYOffset = 0L; + } + + boolean fitsConfiguredRange; + if (placementBounds.underground()) { + long minimumAnchor = Math.max( + Math.max(placementBounds.minimumY(), worldBounds.minimumY()), + worldBounds.minimumY() - minimumYOffset); + long maximumAnchor = Math.min( + Math.min(placementBounds.maximumY(), worldBounds.maximumY()), + worldBounds.maximumY() - maximumYOffset); + fitsConfiguredRange = minimumAnchor <= maximumAnchor; + } else { + long minimumTerrainY = Math.max(placementBounds.minimumY(), worldBounds.minimumY()); + long maximumTerrainY = Math.min(placementBounds.maximumY(), worldBounds.maximumY()); + fitsConfiguredRange = minimumTerrainY <= maximumTerrainY + && minimumTerrainY + minimumYOffset >= worldBounds.minimumY() + && maximumTerrainY + maximumYOffset <= worldBounds.maximumY(); + } + if (fitsConfiguredRange) { + continue; + } + + String alignment = surfaceAligned ? "surface-aligned " : ""; + blockingErrors.add(context + " sampled seed " + sampled.seed() + " has an " + alignment + + "exact-Y piece envelope " + minimumYOffset + ".." + maximumYOffset + + " relative to its anchor, which cannot fit placement band " + + placementBounds.minimumY() + ".." + placementBounds.maximumY() + + " inside writable world " + worldBounds.minimumY() + ".." + worldBounds.maximumY() + + ". Native generation will not be used as a fallback."); + return; + } + } + + private static DimensionVerticalBounds resolveDimensionVerticalBounds( + JSONObject dimension, + String context, + List blockingErrors + ) { + long dimensionMinimum = -64L; + long dimensionMaximum = 320L; + if (dimension.has("dimensionHeight")) { + JSONObject dimensionHeight = dimension.optJSONObject("dimensionHeight"); + if (dimensionHeight == null) { + blockingErrors.add(context + " cannot validate its vertical envelope because dimensionHeight" + + " must be an object."); + return null; + } + Long configuredMinimum = integralJsonNumber(dimensionHeight, "min", 16L); + Long configuredMaximum = integralJsonNumber(dimensionHeight, "max", 32L); + if (configuredMinimum == null || configuredMaximum == null) { + blockingErrors.add(context + " cannot validate its vertical envelope because dimensionHeight" + + " min and max must be finite integer values."); + return null; + } + dimensionMinimum = configuredMinimum; + dimensionMaximum = configuredMaximum; + } + + long writableMinimum = dimensionMinimum + 1L; + long writableMaximum = dimensionMaximum - 1L; + if (writableMinimum > writableMaximum) { + blockingErrors.add(context + " cannot validate its vertical envelope because dimensionHeight " + + dimensionMinimum + ".." + dimensionMaximum + " has no writable structure range."); + return null; + } + return new DimensionVerticalBounds(writableMinimum, writableMaximum); + } + + private static PlacementVerticalBounds resolvePlacementVerticalBounds( + JSONObject placement, + String context, + List blockingErrors + ) { + boolean underground = false; + if (placement.has("underground")) { + Object rawUnderground = placement.opt("underground"); + if (!(rawUnderground instanceof Boolean configuredUnderground)) { + blockingErrors.add(context + " cannot validate its vertical envelope because underground" + + " must be true or false."); + return null; + } + underground = configuredUnderground; + } + Long configuredMinimum = integralJsonNumber(placement, "minHeight", -2032L); + Long configuredMaximum = integralJsonNumber(placement, "maxHeight", 2032L); + if (configuredMinimum == null || configuredMaximum == null) { + blockingErrors.add(context + " cannot validate its vertical envelope because minHeight and" + + " maxHeight must be finite integer values."); + return null; + } + long minimumY = underground + ? Math.min(configuredMinimum, configuredMaximum) : configuredMinimum; + long maximumY = underground + ? Math.max(configuredMinimum, configuredMaximum) : configuredMaximum; + return new PlacementVerticalBounds(minimumY, maximumY, underground); + } + + private static ObjectPlaceMode resolvePlaceMode( + JSONObject structure, + String context, + List blockingErrors + ) { + if (!structure.has("placeMode")) { + return ObjectPlaceMode.STRUCTURE_PIECE; + } + Object rawPlaceMode = structure.opt("placeMode"); + if (!(rawPlaceMode instanceof String placeModeName)) { + blockingErrors.add(context + " cannot validate its vertical envelope because placeMode must name" + + " an Iris object place mode."); + return null; + } + try { + return ObjectPlaceMode.valueOf(placeModeName); + } catch (IllegalArgumentException exception) { + blockingErrors.add(context + " cannot validate its vertical envelope because placeMode '" + + placeModeName + "' is not supported."); + return null; + } + } + + private static Long integralJsonNumber(JSONObject owner, String field, long defaultValue) { + if (!owner.has(field)) { + return defaultValue; + } + Object rawValue = owner.opt(field); + if (!(rawValue instanceof Number number)) { + return null; + } + double value = number.doubleValue(); + if (!Double.isFinite(value) || value != Math.rint(value) + || value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + return null; + } + return (long) value; + } + private static void validateStructurePlacements(File packFolder, Set structureKeys, List blockingErrors) { @@ -786,6 +1381,12 @@ public final class PackValidator { private record ReferencedContentKeys(Set blocks, Set items, Set entities) { } + private record DimensionVerticalBounds(long minimumY, long maximumY) { + } + + private record PlacementVerticalBounds(long minimumY, long maximumY, boolean underground) { + } + record SpawnCategoryResolution(boolean entityKnown, String category) { static SpawnCategoryResolution unknown() { return new SpawnCategoryResolution(false, null); @@ -810,6 +1411,8 @@ public final class PackValidator { continue; } + validateImportedStructurePolicy(dimensionKey, dimJson, blockingErrors); + JSONArray regionsArray = dimJson.optJSONArray("regions"); if (regionsArray == null || regionsArray.length() == 0) { blockingErrors.add("Dimension '" + dimensionKey + "' declares no regions."); @@ -853,6 +1456,68 @@ public final class PackValidator { } } + static void validateImportedStructurePolicy(String dimensionKey, JSONObject dimension, + List blockingErrors) { + if (!dimension.has("importedStructures")) { + return; + } + if (dimension.isNull("importedStructures")) { + blockingErrors.add("Dimension '" + dimensionKey + "' importedStructures must be an object."); + return; + } + JSONObject policy = dimension.optJSONObject("importedStructures"); + if (policy == null) { + blockingErrors.add("Dimension '" + dimensionKey + "' importedStructures must be an object."); + return; + } + if (policy.has("mode")) { + blockingErrors.add("Dimension '" + dimensionKey + + "' importedStructures.mode is not supported. Native structures are enabled by default; list explicit denials in importedStructures.disabled."); + } + if (policy.has("enabled")) { + blockingErrors.add("Dimension '" + dimensionKey + + "' importedStructures.enabled is not supported. Native structures are enabled by default; list explicit denials in importedStructures.disabled."); + } + validateStructureKeyList(dimensionKey, policy, "disabled", blockingErrors); + JSONArray adjustments = policy.optJSONArray("adjustments"); + if (adjustments == null) { + if (policy.has("adjustments")) { + blockingErrors.add("Dimension '" + dimensionKey + + "' importedStructures.adjustments must be an array."); + } + return; + } + for (int index = 0; index < adjustments.length(); index++) { + JSONObject adjustment = adjustments.optJSONObject(index); + if (adjustment == null) { + blockingErrors.add("Dimension '" + dimensionKey + + "' importedStructures.adjustments has a non-object entry at index " + index + "."); + continue; + } + validateStructureKeyList(dimensionKey, adjustment, "match", blockingErrors); + } + } + + private static void validateStructureKeyList(String dimensionKey, JSONObject owner, String field, + List blockingErrors) { + if (!owner.has(field)) { + return; + } + JSONArray keys = owner.optJSONArray(field); + if (keys == null) { + blockingErrors.add("Dimension '" + dimensionKey + "' structure policy field '" + + field + "' must be an array."); + return; + } + for (int index = 0; index < keys.length(); index++) { + Object value = keys.opt(index); + if (!(value instanceof String key) || key.isBlank()) { + blockingErrors.add("Dimension '" + dimensionKey + "' structure policy field '" + + field + "' has a blank or non-string entry at index " + index + "."); + } + } + } + private static int countBiomeRefs(JSONObject regionJson, String field, File biomesFolder, String regionKey, List warnings) { JSONArray arr = regionJson.optJSONArray(field); if (arr == null) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/StructureGraphPackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/StructureGraphPackValidator.java new file mode 100644 index 000000000..6af54dc66 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/StructureGraphPackValidator.java @@ -0,0 +1,351 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.pack; + +import art.arcane.iris.engine.framework.PlacedStructurePiece; +import art.arcane.iris.engine.framework.StructureAssembler; +import art.arcane.iris.engine.framework.structure.IrisObjectFrameReader; +import art.arcane.iris.engine.framework.structure.StructureGraphCompilation; +import art.arcane.iris.engine.framework.structure.StructureGraphCompiler; +import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic; +import art.arcane.iris.engine.framework.structure.StructureGraphAssemblySample; +import art.arcane.iris.engine.framework.structure.StructureGraphResolver; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +final class StructureGraphPackValidator { + private static final Gson GSON = new GsonBuilder().create(); + private static final List GEOMETRY_SAMPLE_SEEDS = List.of( + 0L, 1L, 2L, 3L, 5L, 8L, 13L, 21L, 34L, 55L, 89L, 144L, 233L, 377L, 610L, 987L); + private static final int GEOMETRY_SAMPLE_ORIGIN_Y = 64; + + private StructureGraphPackValidator() { + } + + static Validation validate(Path packRoot) { + return validate(packRoot, null); + } + + static Validation validate(Path packRoot, Set activeStructureKeys) { + Path normalizedRoot = packRoot.toAbsolutePath().normalize(); + Path structuresRoot = normalizedRoot.resolve("structures"); + Set errors = new LinkedHashSet<>(); + Set warnings = new LinkedHashSet<>(); + Set replacementOutputStructures = new LinkedHashSet<>(); + Map> sampledVerticalEnvelopes = new LinkedHashMap<>(); + FileResolver resolver = new FileResolver(normalizedRoot); + for (Path structureFile : listJsonFiles(structuresRoot)) { + String structureKey = resourceKey(structuresRoot, structureFile, ".json"); + if (structureKey.equals("structure-index")) { + continue; + } + IrisStructure structure = readJson(structureFile, IrisStructure.class); + if (structure == null) { + continue; + } + structure.setLoadKey(structureKey); + if (activeStructureKeys != null && !activeStructureKeys.contains(structureKey)) { + continue; + } + StructureGraphCompilation compilation; + try { + compilation = StructureGraphCompiler.compile(structure, resolver); + } catch (MalformedObjectResourceException e) { + errors.add(e.getMessage()); + continue; + } + for (StructureGraphDiagnostic diagnostic : compilation.getDiagnostics()) { + if (diagnostic.severity() == StructureGraphDiagnostic.Severity.ERROR) { + errors.add(diagnostic.message()); + } else { + warnings.add(diagnostic.message()); + } + } + if (!compilation.isAssemblyViable()) { + errors.add("Structure '" + structureKey + + "' does not produce a complete deterministic assembly and will not generate" + + failedSampleSummary(compilation) + "."); + continue; + } + RuntimeGeometryValidation geometry = validateRuntimeGeometry(compilation); + if (geometry.failures().isEmpty()) { + sampledVerticalEnvelopes.put(structureKey, geometry.sampledVerticalEnvelopes()); + if (compilation.guaranteesAssemblyOutput() && geometry.outputForEverySample()) { + replacementOutputStructures.add(structureKey); + } + } else { + errors.add("Structure '" + structureKey + + "' fails sampled collision- and radius-aware assembly: " + + String.join("; ", geometry.failures()) + "."); + } + } + return new Validation( + List.copyOf(errors), List.copyOf(warnings), Set.copyOf(replacementOutputStructures), + immutableEnvelopes(sampledVerticalEnvelopes)); + } + + private static RuntimeGeometryValidation validateRuntimeGeometry( + StructureGraphCompilation compilation + ) { + List failures = new ArrayList<>(); + List sampledVerticalEnvelopes = new ArrayList<>(); + boolean outputForEverySample = true; + for (long seed : GEOMETRY_SAMPLE_SEEDS) { + try { + StructureAssembler assembler = StructureAssembler.forCompilation( + compilation, new IrisPosition(0, GEOMETRY_SAMPLE_ORIGIN_Y, 0)); + KList pieces = assembler.assemble(new RNG(seed)); + if (pieces == null) { + failures.add("seed " + seed + " returned no complete assembly"); + } else if (pieces.isEmpty()) { + outputForEverySample = false; + } else { + sampledVerticalEnvelopes.add(sampleVerticalEnvelope(seed, pieces)); + } + } catch (RuntimeException e) { + failures.add("seed " + seed + " threw " + e.getClass().getSimpleName() + + ": " + failureMessage(e)); + } + } + return new RuntimeGeometryValidation( + List.copyOf(failures), outputForEverySample, List.copyOf(sampledVerticalEnvelopes)); + } + + private static SampledVerticalEnvelope sampleVerticalEnvelope( + long seed, + KList pieces + ) { + int minimumY = Integer.MAX_VALUE; + int maximumY = Integer.MIN_VALUE; + for (PlacedStructurePiece piece : pieces) { + minimumY = Math.min(minimumY, piece.getMinY()); + maximumY = Math.max(maximumY, piece.getMaxY()); + } + return new SampledVerticalEnvelope( + seed, + pieces.size(), + Math.subtractExact(minimumY, GEOMETRY_SAMPLE_ORIGIN_Y), + Math.subtractExact(maximumY, GEOMETRY_SAMPLE_ORIGIN_Y)); + } + + private static Map> immutableEnvelopes( + Map> sampledVerticalEnvelopes + ) { + Map> immutable = new LinkedHashMap<>(); + for (Map.Entry> entry : sampledVerticalEnvelopes.entrySet()) { + immutable.put(entry.getKey(), List.copyOf(entry.getValue())); + } + return Map.copyOf(immutable); + } + + private static String failureMessage(RuntimeException exception) { + return exception.getMessage() == null || exception.getMessage().isBlank() + ? "no failure detail" : exception.getMessage(); + } + + private static String failedSampleSummary(StructureGraphCompilation compilation) { + List failures = new ArrayList<>(); + for (StructureGraphAssemblySample sample : compilation.getAssemblySamples()) { + if (sample.outcome().isComplete()) { + continue; + } + failures.add("seed " + sample.seed() + " placed " + sample.outcome().pieceKeys().size() + + " piece(s), left " + sample.outcome().unresolvedConnectorCount() + + " unresolved, cap=" + sample.outcome().pieceCapReached()); + if (failures.size() == 3) { + break; + } + } + return failures.isEmpty() ? "" : " (" + String.join("; ", failures) + ")"; + } + + private static List listJsonFiles(Path root) { + if (!Files.isDirectory(root)) { + return List.of(); + } + try (Stream paths = Files.walk(root)) { + return paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted() + .toList(); + } catch (IOException e) { + return List.of(); + } + } + + private static String resourceKey(Path root, Path file, String extension) { + String relative = root.relativize(file).toString().replace(file.getFileSystem().getSeparator(), "/"); + return relative.substring(0, relative.length() - extension.length()); + } + + private static T readJson(Path file, Class type) { + try { + return GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), type); + } catch (IOException | RuntimeException e) { + return null; + } + } + + record Validation( + List errors, + List warnings, + Set replacementOutputStructures, + Map> sampledVerticalEnvelopes + ) { + Validation { + errors = List.copyOf(errors); + warnings = List.copyOf(warnings); + replacementOutputStructures = Set.copyOf(replacementOutputStructures); + sampledVerticalEnvelopes = immutableEnvelopes(sampledVerticalEnvelopes); + } + } + + record SampledVerticalEnvelope(long seed, int pieceCount, int minimumYOffset, int maximumYOffset) { + SampledVerticalEnvelope { + if (pieceCount <= 0) { + throw new IllegalArgumentException("Sampled vertical envelope must contain at least one piece"); + } + if (maximumYOffset < minimumYOffset) { + throw new IllegalArgumentException("Sampled vertical envelope maximum Y cannot be below minimum Y"); + } + } + } + + private record RuntimeGeometryValidation( + List failures, + boolean outputForEverySample, + List sampledVerticalEnvelopes + ) { + } + + private static final class FileResolver implements StructureGraphResolver { + private final Path packRoot; + private final Map pools = new HashMap<>(); + private final Map pieces = new HashMap<>(); + private final Map objects = new HashMap<>(); + private final Set missingPools = new LinkedHashSet<>(); + private final Set missingPieces = new LinkedHashSet<>(); + private final Set missingObjects = new LinkedHashSet<>(); + + private FileResolver(Path packRoot) { + this.packRoot = packRoot; + } + + @Override + public IrisJigsawPool loadPool(String key) { + if (pools.containsKey(key)) { + return pools.get(key); + } + if (missingPools.contains(key)) { + return null; + } + Path file = resolve("jigsaw-pools", key, ".json"); + IrisJigsawPool pool = file == null ? null : readJson(file, IrisJigsawPool.class); + if (pool == null) { + missingPools.add(key); + return null; + } + pools.put(key, pool); + return pool; + } + + @Override + public IrisJigsawPiece loadPiece(String key) { + if (pieces.containsKey(key)) { + return pieces.get(key); + } + if (missingPieces.contains(key)) { + return null; + } + Path file = resolve("jigsaw-pieces", key, ".json"); + IrisJigsawPiece piece = file == null ? null : readJson(file, IrisJigsawPiece.class); + if (piece == null) { + missingPieces.add(key); + return null; + } + pieces.put(key, piece); + return piece; + } + + @Override + public IrisObject loadObject(String key) { + if (objects.containsKey(key)) { + return objects.get(key); + } + if (missingObjects.contains(key)) { + return null; + } + Path file = resolve("objects", key, ".iob"); + IrisObject object = file == null ? null : readObjectBounds(file); + if (object == null) { + missingObjects.add(key); + return null; + } + objects.put(key, object); + return object; + } + + private Path resolve(String folder, String key, String extension) { + if (key == null || key.isBlank() || key.indexOf('\\') >= 0 || key.indexOf(':') >= 0) { + return null; + } + Path root = packRoot.resolve(folder).normalize(); + Path file = root.resolve(key + extension).normalize(); + if (!file.startsWith(root) || !Files.isRegularFile(file)) { + return null; + } + return file; + } + + private IrisObject readObjectBounds(Path file) { + try (BufferedInputStream input = new BufferedInputStream(Files.newInputStream(file))) { + return IrisObjectFrameReader.readBounds(input, file.toString()); + } catch (IOException e) { + throw new MalformedObjectResourceException(e.getMessage(), e); + } + } + } + + private static final class MalformedObjectResourceException extends RuntimeException { + private MalformedObjectResourceException(String message, IOException cause) { + super(message, cause); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/StructurePackageClosure.java b/core/src/main/java/art/arcane/iris/core/pack/StructurePackageClosure.java new file mode 100644 index 000000000..0da4de3ab --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/StructurePackageClosure.java @@ -0,0 +1,518 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.volmlib.util.io.IO; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class StructurePackageClosure { + private static final String STRUCTURES = "structures"; + private static final String POOLS = "jigsaw-pools"; + private static final String PIECES = "jigsaw-pieces"; + private static final String OBJECTS = "objects"; + private static final String LOOT = "loot"; + + private final Path sourceRoot; + private final Set structures; + private final Set pools; + private final Set pieces; + private final Set objects; + private final Set loot; + private final List errors; + + private StructurePackageClosure(Path sourceRoot, MutableClosure closure) { + this.sourceRoot = sourceRoot; + this.structures = Collections.unmodifiableSet(new LinkedHashSet<>(closure.structures)); + this.pools = Collections.unmodifiableSet(new LinkedHashSet<>(closure.pools)); + this.pieces = Collections.unmodifiableSet(new LinkedHashSet<>(closure.pieces)); + this.objects = Collections.unmodifiableSet(new LinkedHashSet<>(closure.objects)); + this.loot = Collections.unmodifiableSet(new LinkedHashSet<>(closure.loot)); + this.errors = Collections.unmodifiableList(new ArrayList<>(closure.errors)); + } + + public static StructurePackageClosure collect(File sourceRoot, Collection rootStructures) { + Path normalizedRoot = sourceRoot.toPath().toAbsolutePath().normalize(); + MutableClosure closure = new MutableClosure(); + if (!Files.isDirectory(normalizedRoot)) { + closure.errors.add("Structure package source is not a directory: " + normalizedRoot); + return new StructurePackageClosure(normalizedRoot, closure); + } + + if (rootStructures != null) { + for (String structureKey : rootStructures) { + enqueueKey(closure.structureQueue, structureKey, "structure", closure.errors); + } + } + + collectStructures(normalizedRoot, closure); + collectPools(normalizedRoot, closure); + collectPieces(normalizedRoot, closure); + validateBinaryResources(normalizedRoot, closure); + validatePortableCollisions(closure.structures, STRUCTURES, closure.errors); + validatePortableCollisions(closure.pools, POOLS, closure.errors); + validatePortableCollisions(closure.pieces, PIECES, closure.errors); + validatePortableCollisions(closure.objects, OBJECTS, closure.errors); + validatePortableCollisions(closure.loot, LOOT, closure.errors); + return new StructurePackageClosure(normalizedRoot, closure); + } + + public boolean isValid() { + return errors.isEmpty(); + } + + public Set structures() { + return structures; + } + + public Set pools() { + return pools; + } + + public Set pieces() { + return pieces; + } + + public Set objects() { + return objects; + } + + public Set loot() { + return loot; + } + + public List errors() { + return errors; + } + + public String writeTo(File targetRoot, boolean minify) throws IOException { + if (!isValid()) { + throw new IOException("Cannot package an invalid structure graph: " + String.join("; ", errors)); + } + + Path normalizedTarget = targetRoot.toPath().toAbsolutePath().normalize(); + prepareTargetRoot(normalizedTarget); + StringBuilder hashes = new StringBuilder(); + copyJsonResources(normalizedTarget, STRUCTURES, structures, minify, hashes); + copyJsonResources(normalizedTarget, POOLS, pools, minify, hashes); + copyJsonResources(normalizedTarget, PIECES, pieces, minify, hashes); + copyJsonResources(normalizedTarget, LOOT, loot, minify, hashes); + copyBinaryResources(normalizedTarget, OBJECTS, objects, hashes); + return IO.hash(hashes.toString()); + } + + private static void collectStructures(Path sourceRoot, MutableClosure closure) { + while (!closure.structureQueue.isEmpty()) { + String structureKey = closure.structureQueue.removeFirst(); + if (!closure.structures.add(structureKey)) { + continue; + } + + JSONObject structure = readJson(sourceRoot, STRUCTURES, structureKey, closure.errors); + if (structure == null) { + continue; + } + String startPool = requiredString(structure, "startPool", "Structure '" + structureKey + "'", closure.errors); + if (startPool != null) { + enqueueKey(closure.poolQueue, startPool, + "start pool for structure '" + structureKey + "'", closure.errors); + } + JSONArray loot = optionalArray(structure, "loot", "Structure '" + structureKey + "'", closure.errors); + addArrayKeys(loot, closure.loot, "loot table", closure.errors); + } + } + + private static void collectPools(Path sourceRoot, MutableClosure closure) { + while (!closure.poolQueue.isEmpty()) { + String poolKey = closure.poolQueue.removeFirst(); + if (!closure.pools.add(poolKey)) { + continue; + } + + JSONObject pool = readJson(sourceRoot, POOLS, poolKey, closure.errors); + if (pool == null) { + continue; + } + JSONArray entries = requiredArray(pool, "pieces", "Jigsaw pool '" + poolKey + "'", closure.errors); + if (entries != null) { + for (int index = 0; index < entries.length(); index++) { + JSONObject entry = entries.optJSONObject(index); + if (entry == null) { + closure.errors.add("Jigsaw pool '" + poolKey + "' has a non-object piece entry at index " + index + "."); + continue; + } + if (isEmptyEntry(entry, poolKey, index, closure.errors)) { + continue; + } + String pieceKey = requiredString(entry, "piece", + "Piece entry " + index + " in jigsaw pool '" + poolKey + "'", closure.errors); + if (pieceKey != null) { + enqueueKey(closure.pieceQueue, pieceKey, + "piece in pool '" + poolKey + "'", closure.errors); + } + } + } + String fallback = optionalString(pool, "fallback", "Jigsaw pool '" + poolKey + "'", closure.errors); + enqueueOptionalKey(closure.poolQueue, fallback, + "fallback pool for '" + poolKey + "'", closure.errors); + } + } + + private static void collectPieces(Path sourceRoot, MutableClosure closure) { + while (!closure.pieceQueue.isEmpty()) { + String pieceKey = closure.pieceQueue.removeFirst(); + if (!closure.pieces.add(pieceKey)) { + continue; + } + + JSONObject piece = readJson(sourceRoot, PIECES, pieceKey, closure.errors); + if (piece == null) { + continue; + } + String objectKey = requiredString(piece, "object", "Jigsaw piece '" + pieceKey + "'", closure.errors); + if (objectKey != null) { + addRequiredKey(closure.objects, objectKey, + "object for piece '" + pieceKey + "'", closure.errors); + } + JSONArray connectors = optionalArray(piece, "connectors", "Jigsaw piece '" + pieceKey + "'", closure.errors); + if (connectors == null) { + continue; + } + for (int index = 0; index < connectors.length(); index++) { + JSONObject connector = connectors.optJSONObject(index); + if (connector == null) { + closure.errors.add("Jigsaw piece '" + pieceKey + "' has a non-object connector at index " + index + "."); + continue; + } + String poolKey = requiredString(connector, "pool", + "Connector " + index + " in jigsaw piece '" + pieceKey + "'", closure.errors); + if (poolKey != null) { + enqueueKey(closure.poolQueue, poolKey, + "connector pool for piece '" + pieceKey + "'", closure.errors); + } + } + collectPools(sourceRoot, closure); + } + } + + private static void validateBinaryResources(Path sourceRoot, MutableClosure closure) { + for (String objectKey : closure.objects) { + resolveExisting(sourceRoot, OBJECTS, objectKey, ".iob", closure.errors); + } + for (String lootKey : closure.loot) { + resolveExisting(sourceRoot, LOOT, lootKey, ".json", closure.errors); + } + } + + private static JSONObject readJson(Path sourceRoot, String folder, String key, List errors) { + Path file = resolveExisting(sourceRoot, folder, key, ".json", errors); + if (file == null) { + return null; + } + try { + return new JSONObject(Files.readString(file, StandardCharsets.UTF_8)); + } catch (IOException | RuntimeException e) { + errors.add("Invalid " + folder + " resource '" + key + "': " + describe(e)); + return null; + } + } + + private static Path resolveExisting(Path sourceRoot, String folder, String key, String extension, + List errors) { + Path file = resolveResource(sourceRoot, folder, key, extension, errors); + if (file != null && !Files.isRegularFile(file)) { + errors.add("Missing " + folder + " resource '" + key + "'."); + return null; + } + if (file != null && !isContainedSourceResource(sourceRoot, folder, key, extension, file, errors)) { + return null; + } + return file; + } + + private static Path resolveResource(Path root, String folder, String key, String extension, List errors) { + if (!validKey(key)) { + errors.add("Invalid " + folder + " resource key '" + key + "'."); + return null; + } + Path folderRoot = root.resolve(folder).normalize(); + Path file = folderRoot.resolve(key + extension).normalize(); + if (!file.startsWith(folderRoot)) { + errors.add("Resource key escapes " + folder + ": '" + key + "'."); + return null; + } + return file; + } + + private void copyJsonResources(Path targetRoot, String folder, Set keys, boolean minify, + StringBuilder hashes) throws IOException { + for (String key : keys) { + Path source = resolveExistingOrThrow(sourceRoot, folder, key, ".json"); + Path target = prepareTargetResource(targetRoot, folder, key, ".json"); + String json = new JSONObject(Files.readString(source, StandardCharsets.UTF_8)).toString(minify ? 0 : 4); + Files.writeString(target, json, StandardCharsets.UTF_8); + hashes.append(IO.hash(json)); + } + } + + private void copyBinaryResources(Path targetRoot, String folder, Set keys, + StringBuilder hashes) throws IOException { + for (String key : keys) { + Path source = resolveExistingOrThrow(sourceRoot, folder, key, ".iob"); + Path target = prepareTargetResource(targetRoot, folder, key, ".iob"); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + hashes.append(IO.hash(source.toFile())); + } + } + + private static Path resolveExistingOrThrow(Path root, String folder, String key, String extension) throws IOException { + List errors = new ArrayList<>(); + Path file = resolveExisting(root, folder, key, extension, errors); + if (file == null) { + throw new IOException(String.join("; ", errors)); + } + return file; + } + + private static Path resolveResourceOrThrow(Path root, String folder, String key, String extension) throws IOException { + List errors = new ArrayList<>(); + Path file = resolveResource(root, folder, key, extension, errors); + if (file == null) { + throw new IOException(String.join("; ", errors)); + } + return file; + } + + private static void prepareTargetRoot(Path targetRoot) throws IOException { + if (Files.exists(targetRoot, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isDirectory(targetRoot)) { + throw new IOException("Structure package target is not a directory: " + targetRoot); + } + return; + } + Files.createDirectories(targetRoot); + } + + private static Path prepareTargetResource(Path targetRoot, String folder, String key, String extension) throws IOException { + Path target = resolveResourceOrThrow(targetRoot, folder, key, extension); + Path parent = target.getParent(); + Path current = targetRoot; + Path relativeParent = targetRoot.relativize(parent); + for (Path segment : relativeParent) { + Path next = current.resolve(segment); + if (Files.exists(next, LinkOption.NOFOLLOW_LINKS)) { + if (Files.isSymbolicLink(next) || !Files.isDirectory(next, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Structure package target contains a non-directory or symbolic link: " + next); + } + } else { + Files.createDirectory(next); + } + current = next; + } + if (Files.isSymbolicLink(target)) { + throw new IOException("Structure package target is a symbolic link: " + target); + } + return target; + } + + private static boolean isContainedSourceResource(Path sourceRoot, String folder, String key, String extension, + Path file, List errors) { + try { + Path realSourceRoot = sourceRoot.toRealPath(); + Path realFile = file.toRealPath(); + if (!realFile.startsWith(realSourceRoot)) { + errors.add("Resource escapes the structure package through a symbolic link: " + + folder + "/" + key + extension + "."); + return false; + } + if (!hasExactSourceCase(sourceRoot, file)) { + errors.add("Resource path casing does not match its reference: " + + folder + "/" + key + extension + "."); + return false; + } + return true; + } catch (IOException exception) { + errors.add("Cannot verify " + folder + " resource '" + key + "': " + describe(exception)); + return false; + } + } + + private static boolean hasExactSourceCase(Path sourceRoot, Path file) throws IOException { + Path current = sourceRoot; + for (Path segment : sourceRoot.relativize(file)) { + Path exactChild = findExactChild(current, segment.toString()); + if (exactChild == null) { + return false; + } + current = exactChild; + } + return true; + } + + private static Path findExactChild(Path folder, String name) throws IOException { + try (DirectoryStream children = Files.newDirectoryStream(folder)) { + for (Path child : children) { + if (child.getFileName().toString().equals(name)) { + return child; + } + } + } + return null; + } + + private static void addArrayKeys(JSONArray values, Set target, String kind, List errors) { + if (values == null) { + return; + } + for (int index = 0; index < values.length(); index++) { + Object raw = values.opt(index); + if (!(raw instanceof String value)) { + errors.add("Invalid " + kind + " reference at index " + index + "."); + continue; + } + addRequiredKey(target, value, kind, errors); + } + } + + private static void addRequiredKey(Set target, String key, String kind, List errors) { + String canonical = canonicalKey(key); + if (canonical == null) { + errors.add("Missing or invalid " + kind + "."); + return; + } + target.add(canonical); + } + + private static void enqueueKey(Deque queue, String key, String kind, List errors) { + String canonical = canonicalKey(key); + if (canonical == null) { + errors.add("Missing or invalid " + kind + "."); + return; + } + queue.addLast(canonical); + } + + private static void enqueueOptionalKey(Deque queue, String key, String kind, List errors) { + if (key == null || key.isBlank()) { + return; + } + enqueueKey(queue, key, kind, errors); + } + + private static String canonicalKey(String key) { + if (key == null || !key.equals(key.trim())) { + return null; + } + return validKey(key) ? key : null; + } + + private static boolean validKey(String key) { + if (key == null || key.isBlank() || !key.equals(key.trim())) { + return false; + } + try { + StructureResourceBundle.validateRelativePath(key); + return true; + } catch (IllegalArgumentException exception) { + return false; + } + } + + private static String requiredString(JSONObject object, String field, String resource, List errors) { + Object value = object.opt(field); + if (value instanceof String stringValue) { + return stringValue; + } + errors.add(resource + " requires string field '" + field + "'."); + return null; + } + + private static String optionalString(JSONObject object, String field, String resource, List errors) { + if (!object.has(field)) { + return null; + } + return requiredString(object, field, resource, errors); + } + + private static JSONArray requiredArray(JSONObject object, String field, String resource, List errors) { + Object value = object.opt(field); + if (value instanceof JSONArray arrayValue) { + return arrayValue; + } + errors.add(resource + " requires array field '" + field + "'."); + return null; + } + + private static JSONArray optionalArray(JSONObject object, String field, String resource, List errors) { + if (!object.has(field)) { + return null; + } + return requiredArray(object, field, resource, errors); + } + + private static boolean isEmptyEntry(JSONObject entry, String poolKey, int index, List errors) { + if (!entry.has("empty")) { + return false; + } + Object emptyValue = entry.opt("empty"); + if (!(emptyValue instanceof Boolean isEmpty)) { + errors.add("Piece entry " + index + " in jigsaw pool '" + poolKey + "' requires boolean field 'empty'."); + return false; + } + if (!isEmpty) { + return false; + } + if (entry.has("piece")) { + errors.add("Empty piece entry " + index + " in jigsaw pool '" + poolKey + "' cannot define field 'piece'."); + } + return true; + } + + private static void validatePortableCollisions(Set keys, String folder, List errors) { + Map portableKeys = new HashMap<>(); + for (String key : keys) { + String portableKey = key.toLowerCase(Locale.ROOT); + String previous = portableKeys.putIfAbsent(portableKey, key); + if (previous != null && !previous.equals(key)) { + errors.add("Case-insensitive " + folder + " resource collision between '" + + previous + "' and '" + key + "'."); + } + } + } + + private static String describe(Exception exception) { + String message = exception.getMessage(); + return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message; + } + + private static final class MutableClosure { + private final Set structures = new LinkedHashSet<>(); + private final Set pools = new LinkedHashSet<>(); + private final Set pieces = new LinkedHashSet<>(); + private final Set objects = new LinkedHashSet<>(); + private final Set loot = new LinkedHashSet<>(); + private final List errors = new ArrayList<>(); + private final Deque structureQueue = new ArrayDeque<>(); + private final Deque poolQueue = new ArrayDeque<>(); + private final Deque pieceQueue = new ArrayDeque<>(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisProject.java b/core/src/main/java/art/arcane/iris/core/project/IrisProject.java index 6453f2db0..8c9a65dc5 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisProject.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisProject.java @@ -25,6 +25,7 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.core.pack.StructurePackageClosure; import art.arcane.iris.core.runtime.StudioOpenCoordinator; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.object.IrisBiome; @@ -37,6 +38,7 @@ import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisSpawner; +import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.annotations.Snippet; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.volmlib.util.collection.KList; @@ -69,7 +71,9 @@ import java.awt.Desktop; import java.awt.GraphicsEnvironment; import java.io.File; import java.io.IOException; +import java.util.LinkedHashSet; import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; @@ -623,7 +627,13 @@ public class IrisProject { IrisData dm = IrisData.get(path); IrisDimension dimension = dm.getDimensionLoader().load(dimm); File folder = new File(IrisPlatforms.get().dataFolder(), "exports/" + dimension.getLoadKey()); - folder.mkdirs(); + IO.delete(folder); + if (folder.exists()) { + throw new IllegalStateException("Failed to clear structure package staging folder " + folder.getAbsolutePath()); + } + if (!folder.mkdirs() && !folder.isDirectory()) { + throw new IllegalStateException("Failed to create structure package staging folder " + folder.getAbsolutePath()); + } IrisLogging.info("Packaging Dimension " + dimension.getName() + " " + (obfuscate ? "(Obfuscated)" : "")); KSet regions = new KSet<>(); KSet biomes = new KSet<>(); @@ -647,6 +657,10 @@ public class IrisProject { biomes.forEach((r) -> r.getLoot().getTables().forEach((i) -> loot.add(dm.getLootLoader().load(i)))); biomes.forEach((r) -> r.getEntitySpawners().forEach((sp) -> spawners.add(dm.getSpawnerLoader().load(sp)))); collectSpawnerEntityKeys(spawners).forEach((i) -> entities.add(dm.getEntityLoader().load(i))); + Set structureKeys = new LinkedHashSet<>(); + collectStructureKeys(structureKeys, dimension.getStructures()); + regions.forEach((region) -> collectStructureKeys(structureKeys, region.getStructures())); + biomes.forEach((biome) -> collectStructureKeys(structureKeys, biome.getStructures())); KMap renameObjects = new KMap<>(); String a; StringBuilder b = new StringBuilder(); @@ -704,6 +718,11 @@ public class IrisProject { IrisLogging.info("Writing Dimensional Scaffold"); try { + StructurePackageClosure structureClosure = StructurePackageClosure.collect(path, structureKeys); + if (!structureClosure.isValid()) { + throw new IOException("Structure package closure is invalid: " + String.join("; ", structureClosure.errors())); + } + b.append(structureClosure.writeTo(folder, minify)); a = new JSONObject(new Gson().toJson(dimension)).toString(minify ? 0 : 4); IO.writeAll(new File(folder, "dimensions/" + dimension.getLoadKey() + ".json"), a); b.append(IO.hash(a)); @@ -778,6 +797,20 @@ public class IrisProject { return entityKeys; } + private static void collectStructureKeys(Set keys, KList placements) { + if (placements == null) { + return; + } + for (IrisStructurePlacement placement : placements) { + if (placement == null || placement.getStructures() == null) { + continue; + } + for (String structureKey : placement.getStructures()) { + keys.add(structureKey); + } + } + } + public void compile(VolmitSender sender) { IrisData data = IrisData.get(getPath()); KList jobs = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java index 4410f7a90..86a99dbe9 100644 --- a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java @@ -293,7 +293,6 @@ public class SchemaBuilder { if (!definitions.containsKey(key)) { JSONObject j = new JSONObject(); j.put("enum", new JSONArray(StructureSchemaKeys.collect( - IrisPlatforms.get().registries().structureKeys(), Arrays.asList(data.getStructureLoader().getPossibleKeys()), Arrays.asList(data.getJigsawPieceLoader().getPossibleKeys())).toArray(new String[0]))); definitions.put(key, j); @@ -585,7 +584,6 @@ public class SchemaBuilder { if (!definitions.containsKey(key)) { JSONObject j = new JSONObject(); j.put("enum", new JSONArray(StructureSchemaKeys.collect( - IrisPlatforms.get().registries().structureKeys(), Arrays.asList(data.getStructureLoader().getPossibleKeys()), Arrays.asList(data.getJigsawPieceLoader().getPossibleKeys())).toArray(new String[0]))); definitions.put(key, j); @@ -595,6 +593,42 @@ public class SchemaBuilder { items.put("$ref", "#/definitions/" + key); prop.put("items", items); description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)"); + } else if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) { + fancyType = "List"; + String key = "enum-vanilla-structure"; + + if (!definitions.containsKey(key)) { + JSONObject j = new JSONObject(); + JSONArray values = new JSONArray(); + for (String structureKey : IrisPlatforms.get().registries().structureKeys()) { + values.put(structureKey); + } + j.put("enum", values); + definitions.put(key, j); + } + + JSONObject items = new JSONObject(); + items.put("$ref", "#/definitions/" + key); + prop.put("items", items); + description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)"); + } else if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) { + fancyType = "List"; + String key = "enum-vanilla-structure-set"; + + if (!definitions.containsKey(key)) { + JSONObject j = new JSONObject(); + JSONArray values = new JSONArray(); + for (String structureSetKey : IrisPlatforms.get().structureHooks().structureSetKeys()) { + values.put(structureSetKey); + } + j.put("enum", values); + definitions.put(key, j); + } + + JSONObject items = new JSONObject(); + items.put("$ref", "#/definitions/" + key); + prop.put("items", items); + description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure set key (use ctrl+space for auto complete!)"); } else if (k.isAnnotationPresent(RegistryListBlockType.class)) { fancyType = "List of Block Types"; String key = "enum-block-type"; diff --git a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java index f20264d33..380bc3cc4 100644 --- a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java @@ -22,12 +22,12 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.volmlib.util.board.Board; import art.arcane.volmlib.util.board.BoardProvider; import art.arcane.volmlib.util.board.BoardSettings; import art.arcane.volmlib.util.board.ScoreDirection; -import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.util.common.format.C; import art.arcane.volmlib.util.format.Form; import art.arcane.iris.util.common.plugin.IrisService; @@ -35,6 +35,7 @@ import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.matter.MatterCavern; import lombok.Data; import org.bukkit.Bukkit; +import org.bukkit.ChatColor; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.entity.Player; @@ -42,18 +43,23 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; -import org.bukkit.scoreboard.DisplaySlot; import org.bukkit.scoreboard.Objective; import org.bukkit.scoreboard.Scoreboard; import org.bukkit.scoreboard.Team; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; public class BoardSVC implements IrisService, BoardProvider { - private final KMap boards = new KMap<>(); - private BoardSettings settings; - private boolean boardEnabled; + private final Map boards = new ConcurrentHashMap<>(); + private final Set hiddenPlayers = ConcurrentHashMap.newKeySet(); + private volatile BoardSettings settings; + private volatile boolean boardEnabled; @Override public void onEnable() { @@ -80,16 +86,12 @@ public class BoardSVC implements IrisService, BoardProvider { return; } - Objective named = main.getObjective("board"); - if (named != null) { - named.unregister(); - } - - Objective sidebar = main.getObjective(DisplaySlot.SIDEBAR); - if (sidebar != null && "board".equals(sidebar.getName())) { - sidebar.unregister(); + Objective objective = main.getObjective("board"); + if (objective == null || !"Iris".equalsIgnoreCase(ChatColor.stripColor(objective.getDisplayName()))) { + return; } + objective.unregister(); Team team = main.getTeam("board"); if (team != null) { team.unregister(); @@ -106,6 +108,7 @@ public class BoardSVC implements IrisService, BoardProvider { board.cancel(); } boards.clear(); + hiddenPlayers.clear(); settings = null; } @@ -122,6 +125,7 @@ public class BoardSVC implements IrisService, BoardProvider { @EventHandler public void on(PlayerQuitEvent e) { remove(e.getPlayer()); + clearPlayerPreference(e.getPlayer().getUniqueId()); } public void updatePlayer(Player p) { @@ -152,12 +156,19 @@ public class BoardSVC implements IrisService, BoardProvider { return; } - var board = boards.remove(player); + PlayerBoard board = boards.remove(player); if (board != null) { board.cancel(); } } + public boolean toggle(Player player) { + Objects.requireNonNull(player, "player"); + boolean visible = togglePlayerBoard(player.getUniqueId()); + updatePlayer(player); + return visible; + } + @Override public String getTitle(Player player) { return C.GREEN + "Iris"; @@ -177,31 +188,66 @@ public class BoardSVC implements IrisService, BoardProvider { return false; } - World world = player.getWorld(); - if (!IrisToolbelt.isIrisWorld(world)) { - return false; + return isPlayerBoardEnabled(player.getUniqueId()) + && isStudioGeneratorEligible(IrisToolbelt.access(player.getWorld())); + } + + static boolean isStudioGeneratorEligible(PlatformChunkGenerator generator) { + return generator != null + && generator.isStudio() + && !generator.isClosing() + && generator.getEngine() != null; + } + + boolean isPlayerBoardEnabled(UUID playerId) { + return playerId != null && !hiddenPlayers.contains(playerId); + } + + boolean togglePlayerBoard(UUID playerId) { + Objects.requireNonNull(playerId, "playerId"); + if (hiddenPlayers.remove(playerId)) { + return true; } - PlatformChunkGenerator access = IrisToolbelt.access(world); - return access != null && access.getEngine() != null; + hiddenPlayers.add(playerId); + return false; + } + + void clearPlayerPreference(UUID playerId) { + if (playerId != null) { + hiddenPlayers.remove(playerId); + } + } + + static Scoreboard selectScoreboardToRestore(Scoreboard active, Scoreboard iris, Scoreboard previous) { + return Objects.equals(active, iris) ? previous : active; } @Data public class PlayerBoard { private final Player player; private final Board board; + private final Scoreboard previousScoreboard; + private final Scoreboard irisScoreboard; private volatile List lines; private volatile boolean cancelled; public PlayerBoard(Player player) { this.player = player; + Scoreboard previous = null; + Scoreboard assigned = null; try { + previous = player.getScoreboard(); if (Bukkit.getScoreboardManager() != null - && player.getScoreboard().equals(Bukkit.getScoreboardManager().getMainScoreboard())) { + && Objects.equals(previous, Bukkit.getScoreboardManager().getMainScoreboard())) { player.setScoreboard(Bukkit.getScoreboardManager().getNewScoreboard()); } - } catch (Throwable ignored) { + assigned = player.getScoreboard(); + } catch (Throwable e) { + IrisLogging.reportError("Failed to prepare the Studio scoreboard for " + player.getName() + ".", e); } + this.previousScoreboard = previous; + this.irisScoreboard = assigned; this.board = new Board(player, settings); this.lines = new ArrayList<>(); this.cancelled = false; @@ -216,19 +262,13 @@ public class BoardSVC implements IrisService, BoardProvider { } private void tick() { - if (!boardEnabled || !player.isOnline()) { - return; - } - - if (cancelled) { - board.remove(); + if (cancelled || !boardEnabled || !player.isOnline()) { return; } if (!isEligibleWorld(player)) { - boards.remove(player); - cancelled = true; - board.remove(); + boards.remove(player, this); + cancel(); return; } @@ -243,21 +283,49 @@ public class BoardSVC implements IrisService, BoardProvider { } cancelled = true; if (J.isOwnedByCurrentRegion(player) && player.isOnline()) { - board.remove(); + removeNow(); } else { - J.runEntity(player, board::remove); + J.runEntity(player, this::removeNow); + } + } + + private void removeNow() { + Scoreboard activeScoreboard = null; + try { + activeScoreboard = player.getScoreboard(); + board.remove(); + if (!player.isOnline()) { + return; + } + + Scoreboard restore = selectScoreboardToRestore( + activeScoreboard, + irisScoreboard, + previousScoreboard); + if (restore != null && !Objects.equals(player.getScoreboard(), restore)) { + player.setScoreboard(restore); + } + } catch (Throwable e) { + IrisLogging.reportError("Failed to remove the Studio scoreboard for " + player.getName() + ".", e); + if (activeScoreboard != null && player.isOnline()) { + player.setScoreboard(activeScoreboard); + } } } public void update() { - final World world = player.getWorld(); - final Location loc = player.getLocation(); + World world = player.getWorld(); + Location loc = player.getLocation(); - final var access = IrisToolbelt.access(world); - if (access == null) return; + PlatformChunkGenerator access = IrisToolbelt.access(world); + if (access == null) { + return; + } - final var engine = access.getEngine(); - if (engine == null) return; + Engine engine = access.getEngine(); + if (engine == null) { + return; + } int x = loc.getBlockX(); int y = loc.getBlockY() - world.getMinHeight(); diff --git a/core/src/main/java/art/arcane/iris/core/structure/FeatureImporter.java b/core/src/main/java/art/arcane/iris/core/structure/FeatureImporter.java index dac34f575..4405c40a7 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/FeatureImporter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/FeatureImporter.java @@ -22,7 +22,6 @@ import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.nms.INMS; -import art.arcane.iris.core.tools.PlausibilizeMode; import art.arcane.iris.core.tools.TreePlausibilizer; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.util.common.format.C; @@ -106,7 +105,7 @@ public final class FeatureImporter { object.shrinkwrap(); if (row.group().equals("trees") || row.group().equals("fallen_trees")) { try { - TreePlausibilizer.apply(object, PlausibilizeMode.NORMALIZE, TreePlausibilizer.DEFAULT_SHELL_RADIUS); + TreePlausibilizer.apply(object, TreePlausibilizer.seedOf(row.key() + "#" + written), TreePlausibilizer.DEFAULT_REACH); } catch (Throwable e) { IrisLogging.reportError(e); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/BukkitNativeStructureLocatePolicy.java b/core/src/main/java/art/arcane/iris/core/structure/NativeStructureLocateCapability.java similarity index 67% rename from adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/BukkitNativeStructureLocatePolicy.java rename to core/src/main/java/art/arcane/iris/core/structure/NativeStructureLocateCapability.java index 9cd4b132e..dffc707d0 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/BukkitNativeStructureLocatePolicy.java +++ b/core/src/main/java/art/arcane/iris/core/structure/NativeStructureLocateCapability.java @@ -1,5 +1,5 @@ /* - * Iris is a World Generator for Minecraft Bukkit Servers + * Iris is a World Generator for Minecraft Servers * Copyright (c) 2026 Arcane Arts (Volmit Software) * * This program is free software: you can redistribute it and/or modify @@ -16,20 +16,20 @@ * along with this program. If not, see . */ -package art.arcane.iris.core.commands; +package art.arcane.iris.core.structure; -final class BukkitNativeStructureLocatePolicy { +public final class NativeStructureLocateCapability { private static final String MONUMENT_KEY = "minecraft:monument"; - private static final String UNAVAILABLE_MESSAGE = "Native monument locating is unavailable on Paper, Purpur, and Folia because a cold search can stall the server thread. The monument lookup was skipped; monument generation is unaffected."; + private static final String UNAVAILABLE_MESSAGE = "Native monument generation is enabled, but synchronous monument locating is unavailable because a cold search can stall the server thread. The locate request was not run."; - private BukkitNativeStructureLocatePolicy() { + private NativeStructureLocateCapability() { } - static boolean isUnavailable(String structureKey) { + public static boolean isPaperUnavailable(String structureKey) { return structureKey != null && MONUMENT_KEY.equalsIgnoreCase(structureKey.trim()); } - static String unavailableMessage() { + public static String unavailableMessage() { return UNAVAILABLE_MESSAGE; } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/StructureCaptureImporter.java b/core/src/main/java/art/arcane/iris/core/structure/StructureCaptureImporter.java index 756e22697..04f9f0185 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/StructureCaptureImporter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/StructureCaptureImporter.java @@ -48,6 +48,7 @@ public final class StructureCaptureImporter { } public static Report importAllStructures(IrisData data, StructureImporter.Mode mode, VolmitSender sender) { + StructureImporter.Mode activeMode = mode == null ? StructureImporter.Mode.ADD_ONLY : mode; if (!INMS.get().supportsStructureCapture()) { sender.sendMessage(C.YELLOW + "Structure capture is not supported by the active NMS binding; skipping the capture pass."); return new Report(0, 0, 0, 0); @@ -65,7 +66,7 @@ public final class StructureCaptureImporter { } String name = StructureImporter.deriveName(key); File structureFile = new File(data.getDataFolder(), "structures/" + name + ".json"); - if (structureFile.exists()) { + if (structureFile.exists() && activeMode != StructureImporter.Mode.OVERWRITE) { continue; } targets.add(key); @@ -100,17 +101,20 @@ public final class StructureCaptureImporter { continue; } object.shrinkwrap(); - int span = Math.max(object.getW(), object.getD()); - File objectFile = new File(data.getDataFolder(), "objects/" + name + ".iob"); - objectFile.getParentFile().mkdirs(); - object.write(objectFile); - StructureImporter.writeSinglePieceStructure(data, name, key, span, "CENTER_HEIGHT"); + StructureImporter.Result result = StructureImporter.writeSinglePieceStructure( + data, name, key, object, "CENTER_HEIGHT", activeMode); + if (!result.success()) { + failed++; + sender.sendMessage(C.RED + "[fail] " + key + ": " + result.message()); + continue; + } imported++; sender.sendMessage(C.GRAY + "[capture] " + key + " -> objects/" + name + ".iob (" + object.getW() + "x" + object.getH() + "x" + object.getD() + ")"); } catch (Throwable e) { failed++; sender.sendMessage(C.RED + "[fail] " + key + ": " + e.getMessage()); IrisLogging.reportError(e); + e.printStackTrace(); } int processed = imported + skipped + failed; @@ -187,22 +191,33 @@ public final class StructureCaptureImporter { }); if (!scheduled) { - return null; + throw captureFailure(key, anchorChunkX, anchorChunkZ, + "region task was not accepted", null); } try { if (!latch.await(REGION_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { - return null; + throw captureFailure(key, anchorChunkX, anchorChunkZ, + "region task did not complete within " + REGION_TIMEOUT_SECONDS + " seconds", null); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); - return null; + throw captureFailure(key, anchorChunkX, anchorChunkZ, + "interrupted while waiting for the region task", e); } - if (errorRef.get() != null) { - IrisLogging.reportError(errorRef.get()); - return null; + Throwable error = errorRef.get(); + if (error != null) { + throw captureFailure(key, anchorChunkX, anchorChunkZ, + "platform placement failed", error); } return objectRef.get(); } + + static IllegalStateException captureFailure(String key, int chunkX, int chunkZ, + String detail, Throwable cause) { + String message = "Structure capture failed for '" + key + "' at chunk " + + chunkX + "," + chunkZ + ": " + detail; + return cause == null ? new IllegalStateException(message) : new IllegalStateException(message, cause); + } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java b/core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java index 0a7bc7d56..9ad88b9c1 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java @@ -19,9 +19,21 @@ package art.arcane.iris.core.structure; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.structure.authoring.IrisStructureBundleFactory; +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureLoss; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteMode; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.LegacyTileData; -import art.arcane.volmlib.util.io.IO; +import art.arcane.iris.spi.IrisLogging; +import com.google.gson.Gson; import com.google.gson.GsonBuilder; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -33,40 +45,62 @@ import org.bukkit.structure.Palette; import org.bukkit.structure.Structure; import org.bukkit.util.BlockVector; -import java.util.Optional; - import java.io.BufferedInputStream; import java.io.DataInputStream; import java.io.File; import java.io.FileInputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; +import java.io.IOException; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Optional; public final class StructureImporter { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + public enum Mode { OVERWRITE, - ADD_ONLY, - MERGE + ADD_ONLY } - public record Result(boolean success, String message, int blocks) { + public record Result(boolean success, String message, int blocks, List losses) { + public Result { + losses = List.copyOf(losses); + } + } + + record CapturedStructure( + IrisObject object, + int blocks, + int tiles, + int width, + int height, + int depth, + int structureMarkers, + List capabilities, + List losses + ) { + CapturedStructure { + Objects.requireNonNull(object); + capabilities = List.copyOf(capabilities); + losses = List.copyOf(losses); + } } private StructureImporter() { } public static Mode parseMode(String s) { - if (s == null) { - return Mode.OVERWRITE; + if (s == null || s.isBlank()) { + return Mode.ADD_ONLY; } return switch (s.toLowerCase().replace('-', '_')) { case "add_only", "addonly", "add" -> Mode.ADD_ONLY; - case "merge" -> Mode.MERGE; - default -> Mode.OVERWRITE; + case "overwrite", "replace" -> Mode.OVERWRITE; + default -> throw new IllegalArgumentException("Unknown structure import mode: " + s); }; } @@ -83,35 +117,93 @@ public final class StructureImporter { } public static Result importStructure(IrisData data, NamespacedKey key, String name, Mode mode, boolean objectOnly) { + Mode activeMode = mode == null ? Mode.ADD_ONLY : mode; Structure structure; try { structure = Bukkit.getStructureManager().loadStructure(key); } catch (Throwable e) { - return new Result(false, "Failed to load structure " + key + ": " + e.getMessage(), 0); + IrisLogging.reportError(e); + e.printStackTrace(); + return new Result(false, "Failed to load structure " + key + ": " + e.getMessage(), 0, List.of()); } if (structure == null || structure.getPalettes().isEmpty()) { - return new Result(false, "No loadable structure NBT for key " + key + " (jigsaw structures must be imported by their piece keys)", 0); + return new Result(false, "No loadable structure NBT for key " + key + " (jigsaw structures must be imported by their piece keys)", 0, List.of()); } - BlockVector size = structure.getSize(); + CapturedStructure captured; + try { + captured = captureStructure(structure); + } catch (Throwable e) { + IrisLogging.reportError(e); + e.printStackTrace(); + return new Result(false, "Failed to capture structure " + key + ": " + e.getMessage(), 0, List.of()); + } + + IrisObject object = captured.object(); + int count = captured.blocks(); + int tiles = captured.tiles(); + int w = captured.width(); + int h = captured.height(); + int d = captured.depth(); + List losses = new ArrayList<>(captured.losses()); + if (captured.structureMarkers() > 0) { + losses.add(StructureLoss.warning( + StructureCapability.CONNECTORS, + "connectors_not_imported", + captured.structureMarkers() + " jigsaw or structure marker(s) were flattened without importing their structure graph.")); + } + String writeNote = ""; + try { + StructureKey sourceKey = StructureKey.parse(key.toString()); + StructureKey bundleKey = new StructureKey("iris", name); + StructureSource.Kind sourceKind = key.getNamespace().equals("minecraft") + ? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK; + StructureSource source = StructureSource.of(sourceKind, sourceKey); + IrisStructureBundleFactory.SinglePieceOptions options = new IrisStructureBundleFactory.SinglePieceOptions( + bundleKey, source, name, object, Math.max(w, d), "CENTER_HEIGHT", objectOnly, + captured.capabilities(), losses); + StructureResourceBundle bundle = IrisStructureBundleFactory.singlePiece(options); + if (!objectOnly) { + StructureResourceBundleGraphCompiler.requireViable(bundle); + } + StructureWriteMode writeMode = activeMode == Mode.ADD_ONLY + ? StructureWriteMode.ADD_ONLY : StructureWriteMode.OVERWRITE; + StructureWriteResult writeResult = new StructureTransactionWriter(data.getDataFolder().toPath()) + .write(bundle, writeMode); + reportWriteFailure(writeResult); + if (!writeResult.successful()) { + return new Result(false, writeFailureMessage(name, activeMode, writeResult), count, losses); + } + if (writeResult.committed()) { + data.invalidateStructureResources(); + } + writeNote = writeResultNote(writeResult); + } catch (Throwable e) { + IrisLogging.reportError(e); + e.printStackTrace(); + return new Result(false, "Failed writing import for '" + name + "': " + e.getMessage(), count, losses); + } + + String lossSummary = losses.isEmpty() ? "" : ", " + losses.size() + " fidelity warning(s) recorded"; + return new Result(true, "Imported " + key + " as '" + name + "' (" + count + " blocks, " + tiles + + " tiles, " + w + "x" + h + "x" + d + lossSummary + ")" + writeNote, count, losses); + } + + static CapturedStructure captureStructure(Structure structure) { + Structure activeStructure = Objects.requireNonNull(structure); + if (activeStructure.getPalettes().isEmpty()) { + throw new IllegalArgumentException("Structure has no palettes"); + } + + BlockVector size = activeStructure.getSize(); int w = Math.max(1, size.getBlockX()); int h = Math.max(1, size.getBlockY()); int d = Math.max(1, size.getBlockZ()); - - File objectFile = new File(data.getDataFolder(), "objects/" + name + ".iob"); - File pieceFile = new File(data.getDataFolder(), "jigsaw-pieces/" + name + ".json"); - File poolFile = new File(data.getDataFolder(), "jigsaw-pools/" + name + ".json"); - File structureFile = new File(data.getDataFolder(), "structures/" + name + ".json"); - - boolean exists = objectOnly ? objectFile.exists() : (objectFile.exists() || structureFile.exists()); - if (mode == Mode.ADD_ONLY && exists) { - return new Result(false, "Skipped (add-only): '" + name + "' already exists", 0); - } - IrisObject object = new IrisObject(w, h, d); int count = 0; int tiles = 0; - Palette palette = structure.getPalettes().get(0); + int structureMarkers = 0; + Palette palette = activeStructure.getPalettes().get(0); for (BlockState block : palette.getBlocks()) { Location loc = block.getLocation(); int x = loc.getBlockX(); @@ -126,6 +218,9 @@ public final class StructureImporter { continue; } boolean structural = mat == Material.JIGSAW || mat == Material.STRUCTURE_BLOCK; + if (structural) { + structureMarkers++; + } if (mat == Material.JIGSAW) { BlockData resolved = readJigsawFinalState(block); if (resolved == null || isAir(resolved)) { @@ -146,22 +241,22 @@ public final class StructureImporter { } } - try { - objectFile.getParentFile().mkdirs(); - object.write(objectFile); - writeJson(pieceFile, pieceJson(name)); - if (objectOnly) { - IO.deleteUp(poolFile); - IO.deleteUp(structureFile); - } else { - writeJson(poolFile, poolJson(name)); - writeJson(structureFile, structureJson(name, key.toString(), Math.max(w, d))); - } - } catch (Throwable e) { - return new Result(false, "Failed writing import for '" + name + "': " + e.getMessage(), count); + List capabilities = new ArrayList<>(); + capabilities.add(StructureCapability.BLOCKS); + if (tiles > 0) { + capabilities.add(StructureCapability.BLOCK_ENTITIES); } - - return new Result(true, "Imported " + key + " as '" + name + "' (" + count + " blocks, " + tiles + " tiles, " + w + "x" + h + "x" + d + ")", count); + return new CapturedStructure( + object, + count, + tiles, + w, + h, + d, + structureMarkers, + capabilities, + importLosses(activeStructure, structureMarkers) + ); } private static boolean isAir(BlockData data) { @@ -169,6 +264,55 @@ public final class StructureImporter { return m == Material.AIR || m == Material.CAVE_AIR || m == Material.VOID_AIR; } + private static List importLosses(Structure structure, int structureMarkers) { + List losses = new ArrayList<>(); + if (structure.getPaletteCount() > 1) { + losses.add(StructureLoss.warning( + StructureCapability.BLOCKS, + "palette_variants_not_imported", + "Only palette 0 was converted; " + (structure.getPaletteCount() - 1) + " additional palette(s) remain native-only.")); + } + if (structure.getEntityCount() > 0) { + losses.add(StructureLoss.warning( + StructureCapability.ENTITIES, + "entities_not_imported", + structure.getEntityCount() + " structure entit" + (structure.getEntityCount() == 1 ? "y was" : "ies were") + + " not converted into the Iris snapshot.")); + } + if (structureMarkers > 0) { + losses.add(StructureLoss.warning( + StructureCapability.BLOCKS, + "structure_markers_resolved", + structureMarkers + " jigsaw or structure marker(s) were resolved to final blocks or omitted from the Iris snapshot.")); + } + return losses; + } + + private static void reportWriteFailure(StructureWriteResult result) { + result.failure().ifPresent((Throwable failure) -> { + IrisLogging.reportError(failure); + failure.printStackTrace(); + }); + } + + private static String writeResultNote(StructureWriteResult result) { + return result.status() == StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED + ? " (committed; staging cleanup is required, see console)" : ""; + } + + private static String writeFailureMessage(String name, Mode mode, StructureWriteResult result) { + if (result.status() == StructureWriteResult.Status.ADD_ONLY_CONFLICT) { + return "Skipped (add-only): '" + name + "' already exists"; + } + if (!result.conflicts().isEmpty()) { + StructureWriteResult.Conflict conflict = result.conflicts().getFirst(); + return "Import conflict for '" + name + "': " + conflict.relativePath() + " is " + + conflict.reason().name().toLowerCase() + ". Existing authored files were preserved."; + } + String failure = result.failure().map(Throwable::getMessage).orElse(result.status().name()); + return "Failed writing import for '" + name + "' in " + mode.name().toLowerCase() + " mode: " + failure; + } + private static BlockData readJigsawFinalState(BlockState block) { try { Object nbt = block.getClass().getMethod("getSnapshotNBT").invoke(block); @@ -187,6 +331,8 @@ public final class StructureImporter { } return Bukkit.createBlockData(finalState); } catch (Throwable e) { + IrisLogging.reportError(e); + e.printStackTrace(); return null; } } @@ -195,27 +341,26 @@ public final class StructureImporter { try { return LegacyTileData.fromBukkit(block); } catch (Throwable e) { + IrisLogging.reportError(e); + e.printStackTrace(); return null; } } public static Result importTemplateGroup(IrisData data, String groupName, String vanillaSource, String prefix, Mode mode) { + Mode activeMode = mode == null ? Mode.ADD_ONLY : mode; File objectsRoot = new File(data.getDataFolder(), "objects"); File prefixDir = new File(objectsRoot, prefix); if (!prefixDir.isDirectory()) { - return new Result(false, "No imported templates under objects/" + prefix + " for " + groupName, 0); + return new Result(false, "No imported templates under objects/" + prefix + " for " + groupName, 0, List.of()); } List iobs = new ArrayList<>(); collectIob(prefixDir, iobs); if (iobs.isEmpty()) { - return new Result(false, "No .iob templates under objects/" + prefix + " for " + groupName, 0); - } - - File structureFile = new File(data.getDataFolder(), "structures/" + groupName + ".json"); - if (mode == Mode.ADD_ONLY && structureFile.exists()) { - return new Result(false, "Skipped (add-only): structure '" + groupName + "' already exists", 0); + return new Result(false, "No .iob templates under objects/" + prefix + " for " + groupName, 0, List.of()); } + iobs.sort(Comparator.comparing(File::getAbsolutePath)); String rootPath = objectsRoot.getAbsolutePath() + File.separator; List pieceNames = new ArrayList<>(); @@ -226,20 +371,55 @@ public final class StructureImporter { rel = rel.substring(0, rel.length() - ".iob".length()); } pieceNames.add(rel); - maxSpan = Math.max(maxSpan, readObjectSpan(iob)); + try { + maxSpan = Math.max(maxSpan, readObjectSpan(iob)); + } catch (IOException e) { + IrisLogging.reportError(e); + e.printStackTrace(); + return new Result(false, "Cannot read imported object '" + rel + "': " + e.getMessage(), 0, List.of()); + } } try { for (String piece : pieceNames) { - writeJson(new File(data.getDataFolder(), "jigsaw-pieces/" + piece + ".json"), pieceJson(piece)); + File pieceFile = new File(data.getDataFolder(), "jigsaw-pieces/" + piece + ".json"); + if (!pieceFile.isFile()) { + return new Result(false, "Missing imported jigsaw piece '" + piece + + "'; import the source templates before building group '" + groupName + "'", 0, List.of()); + } } - writeJson(new File(data.getDataFolder(), "jigsaw-pools/" + groupName + ".json"), poolJsonMulti(pieceNames)); - writeJson(structureFile, structureJson(groupName, vanillaSource, maxSpan)); + + StructureKey sourceKey = StructureKey.parse(vanillaSource, "minecraft"); + StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft") + ? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK; + StructureResourceBundle bundle = StructureResourceBundle.builder(new StructureKey("iris", groupName)) + .source(StructureSource.of(sourceKind, sourceKey)) + .backend(StructureBackend.IRIS_ASSEMBLY) + .capability(StructureCapability.BLOCKS) + .capability(StructureCapability.IRIS_PLACEMENT) + .textResource("jigsaw-pools/" + groupName + ".json", GSON.toJson(poolJsonMulti(pieceNames))) + .textResource("structures/" + groupName + ".json", + GSON.toJson(structureJson(groupName, vanillaSource, maxSpan))) + .build(); + StructureWriteMode writeMode = activeMode == Mode.ADD_ONLY + ? StructureWriteMode.ADD_ONLY : StructureWriteMode.OVERWRITE; + StructureWriteResult writeResult = new StructureTransactionWriter(data.getDataFolder().toPath()) + .write(bundle, writeMode); + reportWriteFailure(writeResult); + if (!writeResult.successful()) { + return new Result(false, writeFailureMessage(groupName, activeMode, writeResult), 0, List.of()); + } + if (writeResult.committed()) { + data.invalidateStructureResources(); + } + return new Result(true, "Built structure '" + groupName + "' from " + pieceNames.size() + + " template variants" + writeResultNote(writeResult), pieceNames.size(), List.of()); } catch (Throwable e) { - return new Result(false, "Failed writing group structure '" + groupName + "': " + e.getMessage(), 0); + IrisLogging.reportError(e); + e.printStackTrace(); + return new Result(false, "Failed writing group structure '" + groupName + "': " + e.getMessage(), 0, List.of()); } - return new Result(true, "Built structure '" + groupName + "' from " + pieceNames.size() + " template variants", pieceNames.size()); } private static void collectIob(File dir, List out) { @@ -256,14 +436,15 @@ public final class StructureImporter { } } - private static int readObjectSpan(File iob) { + private static int readObjectSpan(File iob) throws IOException { try (DataInputStream din = new DataInputStream(new BufferedInputStream(new FileInputStream(iob)))) { int w = din.readInt(); - din.readInt(); + int h = din.readInt(); int d = din.readInt(); - return Math.max(1, Math.max(w, d)); - } catch (Throwable e) { - return 1; + if (w < 1 || h < 1 || d < 1) { + throw new IOException("Invalid IOB dimensions " + w + "x" + h + "x" + d + " in " + iob); + } + return Math.max(w, d); } } @@ -280,33 +461,50 @@ public final class StructureImporter { return pool; } - private static Map pieceJson(String name) { - Map piece = new LinkedHashMap<>(); - piece.put("object", name); - piece.put("connectors", new ArrayList<>()); - piece.put("rotatable", true); - return piece; - } - - private static Map poolJson(String name) { - Map entry = new LinkedHashMap<>(); - entry.put("piece", name); - entry.put("weight", 1); - List pieces = new ArrayList<>(); - pieces.add(entry); - Map pool = new LinkedHashMap<>(); - pool.put("pieces", pieces); - return pool; - } - - public static void writeSinglePieceStructure(IrisData data, String name, String vanillaSource, int maxSpan, String placeMode) throws Exception { - writeJson(new File(data.getDataFolder(), "jigsaw-pieces/" + name + ".json"), pieceJson(name)); - writeJson(new File(data.getDataFolder(), "jigsaw-pools/" + name + ".json"), poolJson(name)); - Map structure = structureJson(name, vanillaSource, maxSpan); - if (placeMode != null && !placeMode.isEmpty()) { - structure.put("placeMode", placeMode); + public static Result writeSinglePieceStructure( + IrisData data, + String name, + String vanillaSource, + IrisObject object, + String placeMode, + Mode mode + ) { + Mode activeMode = mode == null ? Mode.ADD_ONLY : mode; + int span = Math.max(object.getW(), object.getD()); + try { + StructureKey sourceKey = StructureKey.parse(vanillaSource, "minecraft"); + StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft") + ? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK; + IrisStructureBundleFactory.SinglePieceOptions options = new IrisStructureBundleFactory.SinglePieceOptions( + new StructureKey("iris", name), + StructureSource.of(sourceKind, sourceKey), + name, + object, + span, + placeMode, + false, + List.of(StructureCapability.BLOCKS, StructureCapability.IRIS_PLACEMENT), + List.of()); + StructureResourceBundle bundle = IrisStructureBundleFactory.singlePiece(options); + StructureResourceBundleGraphCompiler.requireViable(bundle); + StructureWriteMode writeMode = activeMode == Mode.ADD_ONLY + ? StructureWriteMode.ADD_ONLY : StructureWriteMode.OVERWRITE; + StructureWriteResult writeResult = new StructureTransactionWriter(data.getDataFolder().toPath()) + .write(bundle, writeMode); + reportWriteFailure(writeResult); + if (!writeResult.successful()) { + return new Result(false, writeFailureMessage(name, activeMode, writeResult), 0, List.of()); + } + if (writeResult.committed()) { + data.invalidateStructureResources(); + } + return new Result(true, "Captured '" + vanillaSource + "' as '" + name + "'" + + writeResultNote(writeResult), object.getBlocks().size(), List.of()); + } catch (Throwable e) { + IrisLogging.reportError(e); + e.printStackTrace(); + return new Result(false, "Failed writing capture for '" + name + "': " + e.getMessage(), 0, List.of()); } - writeJson(new File(data.getDataFolder(), "structures/" + name + ".json"), structure); } private static Map structureJson(String name, String source, int maxSpan) { @@ -319,9 +517,4 @@ public final class StructureImporter { return root; } - private static void writeJson(File file, Map content) throws Exception { - file.getParentFile().mkdirs(); - String json = new GsonBuilder().setPrettyPrinting().create().toJson(content); - Files.writeString(file.toPath(), json, StandardCharsets.UTF_8); - } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/StructureSchemaKeys.java b/core/src/main/java/art/arcane/iris/core/structure/StructureSchemaKeys.java index 6df4f2e4e..ca036489d 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/StructureSchemaKeys.java +++ b/core/src/main/java/art/arcane/iris/core/structure/StructureSchemaKeys.java @@ -29,7 +29,7 @@ public final class StructureSchemaKeys { private StructureSchemaKeys() { } - public static KList collect(Collection vanillaStructureKeys, Collection importedStructureKeys, Collection jigsawPieceKeys) { + public static KList collect(Collection structureKeys, Collection jigsawPieceKeys) { Set pieces = new HashSet<>(); if (jigsawPieceKeys != null) { for (String piece : jigsawPieceKeys) { @@ -40,17 +40,8 @@ public final class StructureSchemaKeys { } TreeSet merged = new TreeSet<>(); - if (vanillaStructureKeys != null) { - for (String key : vanillaStructureKeys) { - if (key == null || key.isBlank()) { - continue; - } - merged.add(StructureImporter.deriveName(key)); - } - } - - if (importedStructureKeys != null) { - for (String key : importedStructureKeys) { + if (structureKeys != null) { + for (String key : structureKeys) { if (key == null || key.isBlank()) { continue; } diff --git a/core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java b/core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java index 646d1b278..b6bff2465 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java @@ -19,18 +19,28 @@ package art.arcane.iris.core.structure; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureLoss; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteMode; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; import art.arcane.iris.engine.object.IrisObject; -import art.arcane.volmlib.util.io.IO; +import art.arcane.iris.spi.IrisLogging; +import com.google.gson.Gson; import com.google.gson.GsonBuilder; import org.bukkit.Bukkit; -import org.bukkit.Material; import org.bukkit.NamespacedKey; +import org.bukkit.structure.Structure; -import java.io.File; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.Method; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; @@ -41,16 +51,24 @@ import java.util.Map; import java.util.Set; public final class VillageImporter { - public record Result(boolean success, String message, int pools, int pieces) { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + public record Result(boolean success, String message, int pools, int pieces, List losses) { + public Result { + losses = List.copyOf(losses); + } } private VillageImporter() { } public static Result importVillage(IrisData data, NamespacedKey structureKey, String name, StructureImporter.Mode mode) { + StructureImporter.Mode activeMode = mode == null ? StructureImporter.Mode.ADD_ONLY : mode; + List losses = new ArrayList<>(); Object server; Object registryAccess; Object structureManager; + String writeNote = ""; try { Object craftServer = Bukkit.getServer(); Object dedicated = invoke(craftServer, "getHandle"); @@ -58,28 +76,33 @@ public final class VillageImporter { registryAccess = resolveRegistryAccess(server); structureManager = invoke(server, "getStructureManager"); } catch (Throwable e) { - return new Result(false, "Failed to access server registries via reflection: " + e, 0, 0); + reportFailure(e); + return failed("Failed to access server registries via reflection: " + e, losses); } if (registryAccess == null) { - return new Result(false, "Could not resolve RegistryAccess from the server", 0, 0); + return failed("Could not resolve RegistryAccess from the server", losses); } Object startPool; int maxDepth; + int maxDistanceFromCenter; try { Object structureRegistry = lookupRegistry(registryAccess, "STRUCTURE"); Object structure = registryGet(structureRegistry, structureKey); if (structure == null) { - return new Result(false, "No structure registered for key " + structureKey, 0, 0); + return failed("No structure registered for key " + structureKey, losses); } if (!structure.getClass().getName().endsWith("JigsawStructure")) { - return new Result(false, "Structure " + structureKey + " is not a jigsaw structure (" + structure.getClass().getSimpleName() + "); use 'import' for single-template structures", 0, 0); + return failed("Structure " + structureKey + " is not a jigsaw structure (" + + structure.getClass().getSimpleName() + "); use 'import' for single-template structures", losses); } Object startPoolHolder = invoke(structure, "getStartPool"); startPool = unwrapHolder(startPoolHolder); - maxDepth = readIntField(structure, "maxDepth", 7); + maxDepth = readIntMember(structure, "maxDepth"); + maxDistanceFromCenter = readIntMember(structure, "maxDistanceFromCenter"); } catch (Throwable e) { - return new Result(false, "Failed to read jigsaw structure graph: " + e, 0, 0); + reportFailure(e); + return failed("Failed to read jigsaw structure graph: " + e, losses); } Object templatePoolRegistry; @@ -88,32 +111,37 @@ public final class VillageImporter { templatePoolRegistry = lookupRegistry(registryAccess, "TEMPLATE_POOL"); random = new java.util.Random(structureKey.hashCode()); } catch (Throwable e) { - return new Result(false, "Failed to access TEMPLATE_POOL registry: " + e, 0, 0); + reportFailure(e); + return failed("Failed to access TEMPLATE_POOL registry: " + e, losses); } String startPoolKey; try { startPoolKey = registryKeyOf(templatePoolRegistry, startPool); } catch (Throwable e) { - startPoolKey = null; + reportFailure(e); + return failed("Could not resolve the start pool key for " + structureKey + ": " + e, losses); } if (startPoolKey == null) { - return new Result(false, "Could not resolve the start pool key for " + structureKey, 0, 0); - } - - if (mode == StructureImporter.Mode.ADD_ONLY && new File(data.getDataFolder(), "structures/" + name + ".json").exists()) { - return new Result(false, "Skipped (add-only): '" + name + "' already exists", 0, 0); + return failed("Could not resolve the start pool key for " + structureKey, losses); } Set visitedPools = new HashSet<>(); - Set importedPieces = new HashSet<>(); Deque poolQueue = new ArrayDeque<>(); poolQueue.add(startPoolKey); Map> emittedPools = new LinkedHashMap<>(); Map> emittedPieces = new LinkedHashMap<>(); - List errors = new ArrayList<>(); - String emptyPieceName = null; + Map emittedObjects = new LinkedHashMap<>(); + Set capabilities = new HashSet<>(); + capabilities.add(StructureCapability.BLOCKS); + capabilities.add(StructureCapability.CONNECTORS); + capabilities.add(StructureCapability.IRIS_PLACEMENT); + List fatalErrors = new ArrayList<>(); + losses.add(StructureLoss.warning( + StructureCapability.NATIVE_PLACEMENT, + "native_placement_settings_not_imported", + "Native jigsaw placement settings other than the start pool, maximum depth, and maximum distance are not represented by the Iris assembly.")); int pieceBlocks = 0; while (!poolQueue.isEmpty()) { @@ -125,10 +153,12 @@ public final class VillageImporter { try { pool = registryGetByKey(templatePoolRegistry, poolKey); } catch (Throwable e) { - errors.add("pool " + poolKey + ": " + e.getMessage()); + reportFailure(e); + fatalErrors.add("pool " + poolKey + ": " + e.getMessage()); continue; } if (pool == null) { + fatalErrors.add("pool " + poolKey + " is not registered"); continue; } @@ -140,7 +170,13 @@ public final class VillageImporter { Object fallbackHolder = invoke(pool, "getFallback"); Object fallbackPool = unwrapHolder(fallbackHolder); fallbackKey = registryKeyOf(templatePoolRegistry, fallbackPool); - } catch (Throwable ignored) { + } catch (Throwable e) { + reportFailure(e); + losses.add(StructureLoss.warning( + StructureCapability.CONNECTORS, + "fallback_pool_not_imported", + "The fallback for source pool " + poolKey + " could not be resolved: " + failureDetail(e)) + .affecting("jigsaw-pools/" + irisPoolName + ".json")); } if (fallbackKey != null && !fallbackKey.equals(poolKey)) { poolQueue.add(fallbackKey); @@ -150,7 +186,8 @@ public final class VillageImporter { try { templates = (List) invoke(pool, "getTemplates"); } catch (Throwable e) { - errors.add("templates " + poolKey + ": " + e.getMessage()); + reportFailure(e); + fatalErrors.add("templates " + poolKey + ": " + e.getMessage()); templates = List.of(); } @@ -162,55 +199,94 @@ public final class VillageImporter { Object second = invoke(pair, "getSecond"); weight = second instanceof Number ? Math.max(1, ((Number) second).intValue()) : 1; } catch (Throwable e) { + reportFailure(e); + losses.add(StructureLoss.warning( + StructureCapability.LIST_ELEMENTS, + "pool_entry_not_imported", + "A source entry in pool " + poolKey + " could not be read: " + failureDetail(e)) + .affecting("jigsaw-pools/" + irisPoolName + ".json")); continue; } if (element == null) { continue; } - String templateLocation = templateLocationOf(element); + String templateLocation; + try { + templateLocation = templateLocationOf(element); + } catch (Throwable e) { + reportFailure(e); + losses.add(StructureLoss.warning( + StructureCapability.BLOCKS, + "template_location_not_imported", + "A source template location in pool " + poolKey + " could not be read: " + failureDetail(e)) + .affecting("jigsaw-pools/" + irisPoolName + ".json")); + continue; + } if (templateLocation == null) { String elementType = element.getClass().getSimpleName(); if (elementType.endsWith("EmptyPoolElement")) { - if (emptyPieceName == null) { - emptyPieceName = name + "/piece/empty"; - if (importedPieces.add(emptyPieceName) && writeEmptyPiece(data, emptyPieceName)) { - emittedPieces.put(emptyPieceName, pieceJson(emptyPieceName, new ArrayList>())); - } - } Map emptyEntry = new LinkedHashMap<>(); - emptyEntry.put("piece", emptyPieceName); + emptyEntry.put("empty", true); emptyEntry.put("weight", weight); pieceEntries.add(emptyEntry); } else { - errors.add("skipped unsupported element " + elementType + " in pool " + poolKey); + StructureCapability unsupportedCapability = unsupportedCapability(elementType); + losses.add(StructureLoss.warning( + unsupportedCapability, + "unsupported_pool_element", + "Skipped unsupported " + elementType + " in source pool " + poolKey + ".") + .affecting("jigsaw-pools/" + irisPoolName + ".json")); } continue; } NamespacedKey pieceNbtKey = NamespacedKey.fromString(templateLocation.toLowerCase()); if (pieceNbtKey == null) { - errors.add("bad piece key " + templateLocation); + fatalErrors.add("invalid piece key " + templateLocation + " in pool " + poolKey); continue; } String irisPieceName = pieceName(name, templateLocation); - if (importedPieces.add(irisPieceName)) { - StructureImporter.Result imported = StructureImporter.importStructure(data, pieceNbtKey, irisPieceName, mode); - if (!imported.success()) { - errors.add(templateLocation + ": " + imported.message()); + if (!emittedPieces.containsKey(irisPieceName)) { + Structure sourceTemplate; + try { + sourceTemplate = Bukkit.getStructureManager().loadStructure(pieceNbtKey); + } catch (Throwable e) { + reportFailure(e); + fatalErrors.add(templateLocation + ": failed to load structure template: " + failureDetail(e)); + continue; + } + if (sourceTemplate == null || sourceTemplate.getPalettes().isEmpty()) { + fatalErrors.add(templateLocation + ": no loadable structure template was registered"); continue; } - pieceBlocks += imported.blocks(); - removeStrayPieceArtifacts(data, irisPieceName); - Connectors result = readConnectors(element, structureManager, random, name); + StructureImporter.CapturedStructure captured; + try { + captured = StructureImporter.captureStructure(sourceTemplate); + } catch (Throwable e) { + reportFailure(e); + fatalErrors.add(templateLocation + ": failed to capture structure template: " + failureDetail(e)); + continue; + } + pieceBlocks += captured.blocks(); + capabilities.addAll(captured.capabilities()); + for (StructureLoss loss : captured.losses()) { + losses.add(loss.affecting("objects/" + irisPieceName + ".iob")); + } + emittedObjects.put(irisPieceName, captured.object()); + + Connectors result = readConnectors(element, structureManager, random, name, irisPieceName); emittedPieces.put(irisPieceName, pieceJson(irisPieceName, result.json())); poolQueue.addAll(result.targetPoolKeys()); + losses.addAll(result.losses()); } - Map entry = new LinkedHashMap<>(); - entry.put("piece", irisPieceName); - entry.put("weight", weight); - pieceEntries.add(entry); + if (emittedPieces.containsKey(irisPieceName)) { + Map entry = new LinkedHashMap<>(); + entry.put("piece", irisPieceName); + entry.put("weight", weight); + pieceEntries.add(entry); + } } Map poolJson = new LinkedHashMap<>(); @@ -221,112 +297,222 @@ public final class VillageImporter { emittedPools.put(irisPoolName, poolJson); } + if (!fatalErrors.isEmpty()) { + return failed("Failed to capture the complete graph for " + structureKey + ": " + fatalErrors.getFirst() + + (fatalErrors.size() == 1 ? "" : " (" + (fatalErrors.size() - 1) + " more)"), losses); + } if (emittedPieces.isEmpty()) { - return new Result(false, "Imported 0 pieces for " + structureKey + (errors.isEmpty() ? "" : " (" + errors.get(0) + ")"), 0, 0); + return failed("Imported 0 pieces for " + structureKey, losses); + } + for (StructureLoss loss : losses) { + if (loss.capability() == StructureCapability.CONNECTORS) { + capabilities.remove(StructureCapability.CONNECTORS); + break; + } } try { - for (Map.Entry> e : emittedPieces.entrySet()) { - writeJson(new File(data.getDataFolder(), "jigsaw-pieces/" + e.getKey() + ".json"), e.getValue()); + StructureKey sourceKey = StructureKey.parse(structureKey.toString()); + StructureSource.Kind sourceKind = structureKey.getNamespace().equals("minecraft") + ? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK; + StructureSource source = new StructureSource(sourceKind, sourceKey, Bukkit.getBukkitVersion(), ""); + Map objectResources = new LinkedHashMap<>(); + for (Map.Entry entry : emittedObjects.entrySet()) { + objectResources.put(entry.getKey(), serialize(entry.getValue())); } - for (Map.Entry> e : emittedPools.entrySet()) { - writeJson(new File(data.getDataFolder(), "jigsaw-pools/" + e.getKey() + ".json"), e.getValue()); + Map rootStructure = structureJson( + structureKey.toString(), + poolName(name, startPoolKey), + maxDepth, + maxDistanceFromCenter); + StructureResourceBundle bundle = buildBundle( + new StructureKey("iris", name), + source, + objectResources, + emittedPieces, + emittedPools, + rootStructure, + capabilities, + losses); + StructureResourceBundleGraphCompiler.requireViable(bundle); + StructureWriteMode writeMode = activeMode == StructureImporter.Mode.OVERWRITE + ? StructureWriteMode.OVERWRITE : StructureWriteMode.ADD_ONLY; + StructureWriteResult writeResult = new StructureTransactionWriter(data.getDataFolder().toPath()) + .write(bundle, writeMode); + reportWriteFailure(writeResult); + if (!writeResult.successful()) { + return new Result(false, writeFailureMessage(name, writeResult), emittedPools.size(), + emittedPieces.size(), losses); } - writeJson(new File(data.getDataFolder(), "structures/" + name + ".json"), structureJson(name, structureKey.toString(), poolName(name, startPoolKey), maxDepth)); + if (writeResult.committed()) { + data.invalidateStructureResources(); + } + writeNote = writeResultNote(writeResult); } catch (Throwable e) { - return new Result(false, "Failed writing jigsaw resources for '" + name + "': " + e, emittedPools.size(), emittedPieces.size()); + reportFailure(e); + return new Result(false, "Failed writing jigsaw resources for '" + name + "': " + e, + emittedPools.size(), emittedPieces.size(), losses); } String msg = "Imported village " + structureKey + " as '" + name + "': " + emittedPieces.size() + " pieces, " + emittedPools.size() + " pools, " + pieceBlocks + " blocks"; - if (!errors.isEmpty()) { - msg += " (" + errors.size() + " piece(s) skipped; first: " + errors.get(0) + ")"; + if (!losses.isEmpty()) { + msg += " (" + losses.size() + " fidelity warning(s) recorded)"; } - return new Result(true, msg, emittedPools.size(), emittedPieces.size()); + return new Result(true, msg + writeNote, emittedPools.size(), emittedPieces.size(), losses); } - private record Connectors(List> json, Set targetPoolKeys) { + static StructureResourceBundle buildBundle( + StructureKey bundleKey, + StructureSource source, + Map objects, + Map> pieces, + Map> pools, + Map structure, + Set capabilities, + List losses + ) { + StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(bundleKey) + .source(source) + .backend(StructureBackend.IRIS_ASSEMBLY) + .capabilities(capabilities) + .losses(losses); + for (Map.Entry entry : objects.entrySet()) { + bundle.resource("objects/" + entry.getKey() + ".iob", entry.getValue()); + } + for (Map.Entry> entry : pieces.entrySet()) { + bundle.textResource("jigsaw-pieces/" + entry.getKey() + ".json", GSON.toJson(entry.getValue())); + } + for (Map.Entry> entry : pools.entrySet()) { + bundle.textResource("jigsaw-pools/" + entry.getKey() + ".json", GSON.toJson(entry.getValue())); + } + bundle.textResource("structures/" + bundleKey.path() + ".json", GSON.toJson(structure)); + return bundle.build(); } - private static Connectors readConnectors(Object element, Object structureManager, java.util.Random random, String baseName) { + private record Connectors( + List> json, + Set targetPoolKeys, + List losses + ) { + } + + private static Connectors readConnectors( + Object element, + Object structureManager, + java.util.Random random, + String baseName, + String pieceName + ) { List> connectors = new ArrayList<>(); Set targets = new HashSet<>(); + List losses = new ArrayList<>(); + String affectedResource = "jigsaw-pieces/" + pieceName + ".json"; try { Object zero = staticField("net.minecraft.core.BlockPos", "ZERO"); Object rotationNone = staticField("net.minecraft.world.level.block.Rotation", "NONE"); Method m = findMethod4(element.getClass(), "getShuffledJigsawBlocks"); if (m == null) { - return new Connectors(connectors, targets); + losses.add(StructureLoss.warning( + StructureCapability.CONNECTORS, + "connector_extraction_unavailable", + "The source pool element does not expose jigsaw connector extraction on this server version.") + .affecting(affectedResource)); + return new Connectors(connectors, targets, losses); } m.setAccessible(true); Object random0 = freshRandomSource(random); List blocks = (List) m.invoke(element, structureManager, zero, rotationNone, random0); if (blocks == null) { - return new Connectors(connectors, targets); + losses.add(StructureLoss.warning( + StructureCapability.CONNECTORS, + "connector_extraction_returned_null", + "The source pool element returned no connector collection.") + .affecting(affectedResource)); + return new Connectors(connectors, targets, losses); } for (Object jigsaw : blocks) { String[] rawPoolKey = new String[1]; - Map connector = connectorFrom(jigsaw, baseName, rawPoolKey); - if (connector != null) { + try { + Map connector = connectorFrom(jigsaw, baseName, rawPoolKey); connectors.add(connector); if (rawPoolKey[0] != null && !rawPoolKey[0].isEmpty()) { targets.add(rawPoolKey[0]); } + } catch (Throwable e) { + reportFailure(e); + losses.add(StructureLoss.warning( + StructureCapability.CONNECTORS, + "connector_not_imported", + "A source jigsaw connector could not be converted: " + failureDetail(e)) + .affecting(affectedResource)); } } - } catch (Throwable ignored) { + } catch (Throwable e) { + reportFailure(e); + losses.add(StructureLoss.warning( + StructureCapability.CONNECTORS, + "connector_extraction_failed", + "Source jigsaw connectors could not be extracted: " + failureDetail(e)) + .affecting(affectedResource)); } - return new Connectors(connectors, targets); + return new Connectors(connectors, targets, losses); } - private static Map connectorFrom(Object jigsaw, String baseName, String[] rawPoolKeyOut) { - try { - Object info = invoke(jigsaw, "info"); - Object pos = invoke(info, "pos"); - Object blockState = invoke(info, "state"); - int x = readInt(pos, "getX"); - int y = readInt(pos, "getY"); - int z = readInt(pos, "getZ"); + private static Map connectorFrom(Object jigsaw, String baseName, String[] rawPoolKeyOut) throws Exception { + Object info = invoke(jigsaw, "info"); + Object pos = invoke(info, "pos"); + Object blockState = invoke(info, "state"); + int x = readInt(pos, "getX"); + int y = readInt(pos, "getY"); + int z = readInt(pos, "getZ"); - Object poolKey = invoke(jigsaw, "pool"); - String poolId = identifierString(invoke(poolKey, "identifier")); - Object nameId = invoke(jigsaw, "name"); - Object targetId = invoke(jigsaw, "target"); - Object jointType = invoke(jigsaw, "jointType"); + Object poolKey = invoke(jigsaw, "pool"); + String poolId = identifierString(invoke(poolKey, "identifier")); + Object nameId = invoke(jigsaw, "name"); + Object targetId = invoke(jigsaw, "target"); + Object jointType = invoke(jigsaw, "jointType"); - String front = frontFacing(blockState); - rawPoolKeyOut[0] = poolId; + String front = frontFacing(blockState); + String top = topFacing(blockState); + rawPoolKeyOut[0] = poolId; - Map connector = new LinkedHashMap<>(); - Map position = new LinkedHashMap<>(); - position.put("x", x); - position.put("y", y); - position.put("z", z); - connector.put("position", position); - connector.put("direction", irisDirection(front)); - connector.put("pool", poolId == null ? "" : poolName(baseName, poolId)); - connector.put("name", identifierString(nameId)); - connector.put("targetName", identifierString(targetId)); - connector.put("joint", jointType != null && jointType.toString().toUpperCase().contains("ALIGN") ? "ALIGNED" : "ROLLABLE"); - return connector; - } catch (Throwable e) { - return null; - } + Map connector = new LinkedHashMap<>(); + Map position = new LinkedHashMap<>(); + position.put("x", x); + position.put("y", y); + position.put("z", z); + connector.put("position", position); + connector.put("direction", irisDirection(front)); + connector.put("top", irisDirection(top)); + connector.put("pool", poolId == null ? "" : poolName(baseName, poolId)); + connector.put("name", identifierString(nameId)); + connector.put("targetName", identifierString(targetId)); + connector.put("joint", jointType != null && jointType.toString().toUpperCase().contains("ALIGN") ? "ALIGNED" : "ROLLABLE"); + return connector; } - private static String frontFacing(Object blockState) { - try { - Class jigsawBlock = Class.forName("net.minecraft.world.level.block.JigsawBlock"); - Method getFront = jigsawBlock.getMethod("getFrontFacing", Class.forName("net.minecraft.world.level.block.state.BlockState")); - Object direction = getFront.invoke(null, blockState); - if (direction == null) { - return "north"; - } - Method getName = direction.getClass().getMethod("getName"); - getName.setAccessible(true); - return String.valueOf(getName.invoke(direction)).toLowerCase(); - } catch (Throwable e) { + private static String frontFacing(Object blockState) throws Exception { + Class jigsawBlock = Class.forName("net.minecraft.world.level.block.JigsawBlock"); + Method getFront = jigsawBlock.getMethod("getFrontFacing", Class.forName("net.minecraft.world.level.block.state.BlockState")); + Object direction = getFront.invoke(null, blockState); + if (direction == null) { return "north"; } + Method getName = direction.getClass().getMethod("getName"); + getName.setAccessible(true); + return String.valueOf(getName.invoke(direction)).toLowerCase(); + } + + private static String topFacing(Object blockState) throws Exception { + Class jigsawBlock = Class.forName("net.minecraft.world.level.block.JigsawBlock"); + Method getTop = jigsawBlock.getMethod("getTopFacing", Class.forName("net.minecraft.world.level.block.state.BlockState")); + Object direction = getTop.invoke(null, blockState); + if (direction == null) { + return "up"; + } + Method getName = direction.getClass().getMethod("getName"); + getName.setAccessible(true); + return String.valueOf(getName.invoke(direction)).toLowerCase(); } private static String irisDirection(String front) { @@ -340,18 +526,14 @@ public final class VillageImporter { }; } - private static String templateLocationOf(Object element) { - try { - Method m = findMethod(element.getClass(), "getTemplateLocation"); - if (m == null) { - return null; - } - m.setAccessible(true); - Object id = m.invoke(element); - return identifierString(id); - } catch (Throwable e) { + private static String templateLocationOf(Object element) throws Exception { + Method m = findMethod(element.getClass(), "getTemplateLocation"); + if (m == null) { return null; } + m.setAccessible(true); + Object id = m.invoke(element); + return identifierString(id); } private static Object resolveRegistryAccess(Object server) { @@ -376,7 +558,8 @@ public final class VillageImporter { } } } - } catch (Throwable ignored) { + } catch (Throwable e) { + reportFailure(e); } return null; } @@ -503,14 +686,27 @@ public final class VillageImporter { return opt; } - private static int readIntField(Object o, String fieldName, int fallback) { - try { - Field f = o.getClass().getDeclaredField(fieldName); - f.setAccessible(true); - return f.getInt(o); - } catch (Throwable e) { - return fallback; + private static int readIntMember(Object value, String memberName) throws Exception { + Class type = value.getClass(); + while (type != null) { + try { + Field field = type.getDeclaredField(memberName); + field.setAccessible(true); + return field.getInt(value); + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } } + Method method = findMethod(value.getClass(), memberName); + if (method != null && Number.class.isAssignableFrom(boxedType(method.getReturnType()))) { + method.setAccessible(true); + return ((Number) method.invoke(value)).intValue(); + } + throw new NoSuchFieldException(memberName + " on " + value.getClass().getName()); + } + + private static Class boxedType(Class type) { + return type == int.class ? Integer.class : type; } private static int readInt(Object o, String method) throws Exception { @@ -576,16 +772,14 @@ public final class VillageImporter { return create.invoke(null, random.nextLong()); } - private static String poolName(String base, String poolKey) { - return base + "/pool/" + sanitize(poolKey); + static String poolName(String base, String poolKey) { + StructureKey key = StructureKey.parse(poolKey); + return base + "/pool/" + key.namespace() + "/" + key.path(); } - private static String pieceName(String base, String templateLocation) { - return base + "/piece/" + sanitize(templateLocation); - } - - private static String sanitize(String key) { - return key.replace(':', '_').replace('/', '_'); + static String pieceName(String base, String templateLocation) { + StructureKey key = StructureKey.parse(templateLocation); + return base + "/piece/" + key.namespace() + "/" + key.path(); } private static Map pieceJson(String pieceName, List> connectors) { @@ -596,37 +790,71 @@ public final class VillageImporter { return piece; } - private static Map structureJson(String name, String source, String startPool, int maxDepth) { + static Map structureJson( + String source, + String startPool, + int maxDepth, + int maxDistanceFromCenter + ) { Map root = new LinkedHashMap<>(); root.put("startPool", startPool); root.put("maxDepth", Math.max(1, Math.min(30, maxDepth))); - root.put("maxSizeChunks", 8); + int maxSizeChunks = Math.max(1, Math.min(32, (Math.max(1, maxDistanceFromCenter) + 15) / 16)); + root.put("maxSizeChunks", maxSizeChunks); root.put("placeMode", "STRUCTURE_PIECE"); root.put("vanillaSource", source); return root; } - private static void removeStrayPieceArtifacts(IrisData data, String pieceName) { - IO.deleteUp(new File(data.getDataFolder(), "jigsaw-pools/" + pieceName + ".json")); - IO.deleteUp(new File(data.getDataFolder(), "structures/" + pieceName + ".json")); - } - - private static boolean writeEmptyPiece(IrisData data, String pieceName) { - try { - File objectFile = new File(data.getDataFolder(), "objects/" + pieceName + ".iob"); - objectFile.getParentFile().mkdirs(); - IrisObject object = new IrisObject(1, 1, 1); - object.setUnsigned(0, 0, 0, art.arcane.iris.platform.bukkit.BukkitBlockState.of(Material.AIR.createBlockData())); - object.write(objectFile); - return true; - } catch (Throwable e) { - return false; + private static StructureCapability unsupportedCapability(String elementType) { + if (elementType.endsWith("ListPoolElement")) { + return StructureCapability.LIST_ELEMENTS; } + if (elementType.endsWith("FeaturePoolElement")) { + return StructureCapability.FEATURE_ELEMENTS; + } + return StructureCapability.BLOCKS; } - private static void writeJson(File file, Map content) throws Exception { - file.getParentFile().mkdirs(); - String json = new GsonBuilder().setPrettyPrinting().create().toJson(content); - Files.writeString(file.toPath(), json, StandardCharsets.UTF_8); + private static byte[] serialize(IrisObject object) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + object.write(output); + return output.toByteArray(); + } + + private static Result failed(String message, List losses) { + return new Result(false, message, 0, 0, losses); + } + + private static void reportFailure(Throwable failure) { + IrisLogging.reportError(failure); + failure.printStackTrace(); + } + + private static void reportWriteFailure(StructureWriteResult result) { + result.failure().ifPresent(VillageImporter::reportFailure); + } + + private static String writeResultNote(StructureWriteResult result) { + return result.status() == StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED + ? " (committed; staging cleanup is required, see console)" : ""; + } + + private static String writeFailureMessage(String name, StructureWriteResult result) { + if (result.status() == StructureWriteResult.Status.ADD_ONLY_CONFLICT) { + return "Skipped (add-only): '" + name + "' already exists"; + } + if (!result.conflicts().isEmpty()) { + StructureWriteResult.Conflict conflict = result.conflicts().getFirst(); + return "Import conflict for '" + name + "': " + conflict.relativePath() + " is " + + conflict.reason().name().toLowerCase() + ". Existing authored files were preserved."; + } + String failure = result.failure().map(VillageImporter::failureDetail).orElse(result.status().name()); + return "Failed writing jigsaw import for '" + name + "': " + failure; + } + + private static String failureDetail(Throwable failure) { + String message = failure.getMessage(); + return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/IrisStructureBundleFactory.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/IrisStructureBundleFactory.java new file mode 100644 index 000000000..f0c25d359 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/IrisStructureBundleFactory.java @@ -0,0 +1,107 @@ +package art.arcane.iris.core.structure.authoring; + +import art.arcane.iris.engine.object.IrisObject; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class IrisStructureBundleFactory { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private IrisStructureBundleFactory() { + } + + public static StructureResourceBundle singlePiece(SinglePieceOptions options) throws IOException { + SinglePieceOptions activeOptions = Objects.requireNonNull(options); + StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(activeOptions.bundleKey()) + .source(activeOptions.source()) + .backend(StructureBackend.SNAPSHOT) + .capabilities(activeOptions.capabilities()) + .losses(activeOptions.losses()) + .resource("objects/" + activeOptions.resourceKey() + ".iob", serialize(activeOptions.object())) + .textResource("jigsaw-pieces/" + activeOptions.resourceKey() + ".json", + GSON.toJson(pieceJson(activeOptions.resourceKey()))); + if (!activeOptions.objectOnly()) { + bundle.capability(StructureCapability.IRIS_PLACEMENT) + .textResource("jigsaw-pools/" + activeOptions.resourceKey() + ".json", + GSON.toJson(poolJson(activeOptions.resourceKey()))) + .textResource("structures/" + activeOptions.resourceKey() + ".json", + GSON.toJson(structureJson(activeOptions))); + } + return bundle.build(); + } + + private static byte[] serialize(IrisObject object) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + object.write(output); + return output.toByteArray(); + } + + private static Map pieceJson(String resourceKey) { + Map piece = new LinkedHashMap<>(); + piece.put("object", resourceKey); + piece.put("connectors", new ArrayList<>()); + piece.put("rotatable", true); + return piece; + } + + private static Map poolJson(String resourceKey) { + Map entry = new LinkedHashMap<>(); + entry.put("piece", resourceKey); + entry.put("weight", 1); + List pieces = new ArrayList<>(); + pieces.add(entry); + Map pool = new LinkedHashMap<>(); + pool.put("pieces", pieces); + return pool; + } + + private static Map structureJson(SinglePieceOptions options) { + Map structure = new LinkedHashMap<>(); + structure.put("startPool", options.resourceKey()); + structure.put("maxDepth", 1); + structure.put("maxSizeChunks", Math.max(1, (options.maxSpan() / 16) + 1)); + structure.put("placeMode", options.placeMode()); + structure.put("vanillaSource", options.source().key().value()); + return structure; + } + + public record SinglePieceOptions( + StructureKey bundleKey, + StructureSource source, + String resourceKey, + IrisObject object, + int maxSpan, + String placeMode, + boolean objectOnly, + Collection capabilities, + Collection losses + ) { + public SinglePieceOptions { + Objects.requireNonNull(bundleKey); + Objects.requireNonNull(source); + Objects.requireNonNull(resourceKey); + Objects.requireNonNull(object); + Objects.requireNonNull(placeMode); + Objects.requireNonNull(capabilities); + Objects.requireNonNull(losses); + StructureResourceBundle.validateRelativePath("structures/" + resourceKey + ".json"); + if (maxSpan < 1) { + throw new IllegalArgumentException("Structure span must be positive"); + } + if (placeMode.isBlank()) { + throw new IllegalArgumentException("Structure place mode cannot be blank"); + } + capabilities = List.copyOf(capabilities); + losses = List.copyOf(losses); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/tools/PlausibilizeMode.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureBackend.java similarity index 85% rename from core/src/main/java/art/arcane/iris/core/tools/PlausibilizeMode.java rename to core/src/main/java/art/arcane/iris/core/structure/authoring/StructureBackend.java index 939d147eb..015f2b869 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/PlausibilizeMode.java +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureBackend.java @@ -16,11 +16,10 @@ * along with this program. If not, see . */ -package art.arcane.iris.core.tools; +package art.arcane.iris.core.structure.authoring; -public enum PlausibilizeMode { - DEFAULT, - NORMALIZE, - FOLIAGE_OVERATURE, - SMOKE +public enum StructureBackend { + NATIVE, + IRIS_ASSEMBLY, + SNAPSHOT } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/LootProvider.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureCapability.java similarity index 70% rename from core/src/main/java/art/arcane/iris/engine/framework/LootProvider.java rename to core/src/main/java/art/arcane/iris/core/structure/authoring/StructureCapability.java index f15394d98..394e28720 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/LootProvider.java +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureCapability.java @@ -16,12 +16,20 @@ * along with this program. If not, see . */ -package art.arcane.iris.engine.framework; +package art.arcane.iris.core.structure.authoring; -import art.arcane.iris.engine.object.IrisLootReference; -import art.arcane.iris.engine.object.IrisLootTable; -import art.arcane.volmlib.util.collection.KList; - -public interface LootProvider { - void injectTables(KList list, IrisLootReference r, boolean fallback); +public enum StructureCapability { + BLOCKS, + BLOCK_ENTITIES, + ENTITIES, + CONNECTORS, + PROCESSORS, + PROJECTION, + LIST_ELEMENTS, + FEATURE_ELEMENTS, + LIQUID_SETTINGS, + TERRAIN_ADAPTATION, + MIRRORING, + NATIVE_PLACEMENT, + IRIS_PLACEMENT } diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureFileOperations.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureFileOperations.java new file mode 100644 index 000000000..97478f6d6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureFileOperations.java @@ -0,0 +1,148 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +interface StructureFileOperations { + boolean exists(Path path); + + boolean isRegularFile(Path path); + + default boolean isDirectory(Path path) { + return Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS); + } + + byte[] readAllBytes(Path path) throws IOException; + + String sha256(Path path) throws IOException; + + void createDirectories(Path path) throws IOException; + + void writeNew(Path path, byte[] content) throws IOException; + + void move(Path source, Path target) throws IOException; + + default void moveNew(Path source, Path target) throws IOException { + move(source, target); + } + + void deleteIfExists(Path path) throws IOException; + + void deleteTree(Path root) throws IOException; + + default List list(Path root) throws IOException { + try (Stream stream = Files.list(root)) { + return stream.sorted().toList(); + } + } + + default void forceFile(Path path) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.WRITE)) { + channel.force(true); + } + } + + default void forceDirectory(Path path) throws IOException { + if (!Files.getFileStore(path).supportsFileAttributeView("posix")) { + return; + } + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + channel.force(true); + } + } +} + +final class NioStructureFileOperations implements StructureFileOperations { + @Override + public boolean exists(Path path) { + return Files.exists(path, LinkOption.NOFOLLOW_LINKS); + } + + @Override + public boolean isRegularFile(Path path) { + return Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS); + } + + @Override + public byte[] readAllBytes(Path path) throws IOException { + return Files.readAllBytes(path); + } + + @Override + public String sha256(Path path) throws IOException { + try (InputStream input = Files.newInputStream(path)) { + return StructureHash.sha256(input); + } + } + + @Override + public void createDirectories(Path path) throws IOException { + Files.createDirectories(path); + } + + @Override + public void writeNew(Path path, byte[] content) throws IOException { + Files.write(path, content, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + } + + @Override + public void move(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + @Override + public void moveNew(Path source, Path target) throws IOException { + Files.move(source, target); + } + + @Override + public void deleteIfExists(Path path) throws IOException { + Files.deleteIfExists(path); + } + + @Override + public void deleteTree(Path root) throws IOException { + if (!exists(root)) { + return; + } + List paths; + try (Stream stream = Files.walk(root)) { + paths = stream.sorted(Comparator.reverseOrder()).toList(); + } + for (Path path : paths) { + Files.deleteIfExists(path); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureHash.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureHash.java new file mode 100644 index 000000000..db64f7f0d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureHash.java @@ -0,0 +1,65 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; +import java.util.regex.Pattern; + +public final class StructureHash { + private static final Pattern SHA_256_PATTERN = Pattern.compile("[a-f0-9]{64}"); + + private StructureHash() { + } + + public static String sha256(byte[] content) { + Objects.requireNonNull(content, "content"); + MessageDigest digest = createSha256Digest(); + return HexFormat.of().formatHex(digest.digest(content)); + } + + public static String sha256(InputStream input) throws IOException { + Objects.requireNonNull(input, "input"); + MessageDigest digest = createSha256Digest(); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read); + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + public static boolean isSha256(String value) { + return value != null && SHA_256_PATTERN.matcher(value).matches(); + } + + private static MessageDigest createSha256Digest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available", e); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureKey.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureKey.java new file mode 100644 index 000000000..e3789de6e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureKey.java @@ -0,0 +1,87 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import java.util.Objects; +import java.util.regex.Pattern; + +public record StructureKey(String namespace, String path) implements Comparable { + private static final Pattern NAMESPACE_PATTERN = Pattern.compile("[a-z0-9._-]+"); + private static final Pattern PATH_PATTERN = Pattern.compile("[a-z0-9._/-]+"); + + public StructureKey { + Objects.requireNonNull(namespace, "namespace"); + Objects.requireNonNull(path, "path"); + if (!NAMESPACE_PATTERN.matcher(namespace).matches()) { + throw new IllegalArgumentException("Invalid structure namespace: " + namespace); + } + if (!PATH_PATTERN.matcher(path).matches()) { + throw new IllegalArgumentException("Invalid structure path: " + path); + } + validatePathSegments(path); + } + + public static StructureKey parse(String value) { + Objects.requireNonNull(value, "value"); + int separator = value.indexOf(':'); + if (separator <= 0 || separator == value.length() - 1 || separator != value.lastIndexOf(':')) { + throw new IllegalArgumentException("Structure key must use namespace:path: " + value); + } + return new StructureKey(value.substring(0, separator), value.substring(separator + 1)); + } + + public static StructureKey parse(String value, String defaultNamespace) { + Objects.requireNonNull(value, "value"); + Objects.requireNonNull(defaultNamespace, "defaultNamespace"); + if (value.indexOf(':') < 0) { + return new StructureKey(defaultNamespace, value); + } + return parse(value); + } + + public String value() { + return namespace + ":" + path; + } + + @Override + public int compareTo(StructureKey other) { + int namespaceComparison = namespace.compareTo(other.namespace); + if (namespaceComparison != 0) { + return namespaceComparison; + } + return path.compareTo(other.path); + } + + @Override + public String toString() { + return value(); + } + + private static void validatePathSegments(String path) { + if (path.startsWith("/") || path.endsWith("/") || path.contains("//")) { + throw new IllegalArgumentException("Invalid structure path: " + path); + } + String[] segments = path.split("/"); + for (String segment : segments) { + if (segment.equals(".") || segment.equals("..")) { + throw new IllegalArgumentException("Invalid structure path: " + path); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureLoss.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureLoss.java new file mode 100644 index 000000000..b148ec8c2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureLoss.java @@ -0,0 +1,65 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import java.util.Objects; +import java.util.regex.Pattern; + +public record StructureLoss( + StructureCapability capability, + Severity severity, + String code, + String detail, + String affectedResource +) { + private static final Pattern CODE_PATTERN = Pattern.compile("[a-z0-9._-]+"); + + public StructureLoss { + Objects.requireNonNull(capability, "capability"); + Objects.requireNonNull(severity, "severity"); + Objects.requireNonNull(code, "code"); + Objects.requireNonNull(detail, "detail"); + Objects.requireNonNull(affectedResource, "affectedResource"); + if (!CODE_PATTERN.matcher(code).matches()) { + throw new IllegalArgumentException("Invalid structure loss code: " + code); + } + if (detail.isBlank()) { + throw new IllegalArgumentException("Structure loss detail cannot be blank"); + } + } + + public static StructureLoss warning(StructureCapability capability, String code, String detail) { + return new StructureLoss(capability, Severity.WARNING, code, detail, ""); + } + + public static StructureLoss error(StructureCapability capability, String code, String detail) { + return new StructureLoss(capability, Severity.ERROR, code, detail, ""); + } + + public StructureLoss affecting(String resource) { + Objects.requireNonNull(resource, "resource"); + return new StructureLoss(capability, severity, code, detail, resource); + } + + public enum Severity { + INFO, + WARNING, + ERROR + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureOwnershipManifest.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureOwnershipManifest.java new file mode 100644 index 000000000..aaa62462d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureOwnershipManifest.java @@ -0,0 +1,122 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +public record StructureOwnershipManifest( + int schemaVersion, + StructureKey structure, + StructureSource source, + StructureBackend backend, + List capabilities, + List losses, + Map resourceHashes +) { + public static final int CURRENT_SCHEMA_VERSION = 1; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + public StructureOwnershipManifest { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported structure ownership manifest schema: " + schemaVersion); + } + Objects.requireNonNull(structure, "structure"); + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(backend, "backend"); + Objects.requireNonNull(capabilities, "capabilities"); + Objects.requireNonNull(losses, "losses"); + Objects.requireNonNull(resourceHashes, "resourceHashes"); + ArrayList orderedCapabilities = new ArrayList<>(capabilities); + orderedCapabilities.sort(Comparator.naturalOrder()); + capabilities = List.copyOf(orderedCapabilities); + losses = List.copyOf(losses); + TreeMap orderedHashes = new TreeMap<>(); + TreeMap portablePaths = new TreeMap<>(); + for (Map.Entry entry : resourceHashes.entrySet()) { + String relativePath = StructureResourceBundle.validateRelativePath(entry.getKey()); + String portablePath = relativePath.toLowerCase(Locale.ROOT); + String previousPath = portablePaths.putIfAbsent(portablePath, relativePath); + if (previousPath != null) { + throw new IllegalArgumentException( + "Ownership manifest contains case-colliding resources: " + previousPath + " and " + relativePath + ); + } + String contentHash = Objects.requireNonNull(entry.getValue(), "contentHash"); + if (!StructureHash.isSha256(contentHash)) { + throw new IllegalArgumentException("Invalid SHA-256 hash for resource " + relativePath); + } + orderedHashes.put(relativePath, contentHash); + } + resourceHashes = Collections.unmodifiableMap(orderedHashes); + } + + public static StructureOwnershipManifest from(StructureResourceBundle bundle) { + Objects.requireNonNull(bundle, "bundle"); + TreeMap hashes = new TreeMap<>(); + for (StructureResourceBundle.Resource resource : bundle.resources().values()) { + hashes.put(resource.relativePath(), resource.contentHash()); + } + return new StructureOwnershipManifest( + CURRENT_SCHEMA_VERSION, + bundle.key(), + bundle.source(), + bundle.backend(), + new ArrayList<>(bundle.capabilities()), + bundle.losses(), + hashes + ); + } + + public static StructureOwnershipManifest fromJson(byte[] content) { + Objects.requireNonNull(content, "content"); + StructureOwnershipManifest manifest = GSON.fromJson( + new String(content, StandardCharsets.UTF_8), + StructureOwnershipManifest.class + ); + if (manifest == null) { + throw new IllegalArgumentException("Structure ownership manifest is empty"); + } + return manifest; + } + + public byte[] toJson() { + return GSON.toJson(this).getBytes(StandardCharsets.UTF_8); + } + + public String relativePath() { + return relativePath(structure); + } + + public static String relativePath(StructureKey structure) { + Objects.requireNonNull(structure, "structure"); + String identityHash = StructureHash.sha256(structure.value().getBytes(StandardCharsets.UTF_8)); + return ".iris/structure-manifests/key-" + identityHash + ".json"; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureRecoveryResult.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureRecoveryResult.java new file mode 100644 index 000000000..0dd5f0143 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureRecoveryResult.java @@ -0,0 +1,56 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +public record StructureRecoveryResult( + int restoredPreparedTransactions, + int cleanedCommittedTransactions, + int cleanedOrphanTransactions, + List failures +) { + public StructureRecoveryResult { + if (restoredPreparedTransactions < 0 + || cleanedCommittedTransactions < 0 + || cleanedOrphanTransactions < 0) { + throw new IllegalArgumentException("Structure recovery counts cannot be negative"); + } + Objects.requireNonNull(failures, "failures"); + failures = List.copyOf(failures); + } + + public boolean successful() { + return failures.isEmpty(); + } + + public int recoveredTransactions() { + return restoredPreparedTransactions + cleanedCommittedTransactions + cleanedOrphanTransactions; + } + + public record Failure(Path transactionRoot, Throwable cause) { + public Failure { + Objects.requireNonNull(transactionRoot, "transactionRoot"); + Objects.requireNonNull(cause, "cause"); + transactionRoot = transactionRoot.toAbsolutePath().normalize(); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureResourceBundle.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureResourceBundle.java new file mode 100644 index 000000000..bc72603e8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureResourceBundle.java @@ -0,0 +1,234 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Pattern; + +public final class StructureResourceBundle { + private static final Pattern WINDOWS_RESERVED_NAME = Pattern.compile( + "(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\\..*)?" + ); + + private final StructureKey key; + private final StructureSource source; + private final StructureBackend backend; + private final Set capabilities; + private final List losses; + private final Map resources; + + private StructureResourceBundle(Builder builder) { + key = builder.key; + source = Objects.requireNonNull(builder.source, "source"); + backend = Objects.requireNonNull(builder.backend, "backend"); + capabilities = immutableCapabilities(builder.capabilities); + losses = List.copyOf(builder.losses); + resources = Collections.unmodifiableMap(new TreeMap<>(builder.resources)); + if (resources.isEmpty()) { + throw new IllegalStateException("Structure resource bundle cannot be empty"); + } + } + + public static Builder builder(StructureKey key) { + return new Builder(key); + } + + public StructureKey key() { + return key; + } + + public StructureSource source() { + return source; + } + + public StructureBackend backend() { + return backend; + } + + public Set capabilities() { + return capabilities; + } + + public List losses() { + return losses; + } + + public Map resources() { + return resources; + } + + public static String validateRelativePath(String relativePath) { + Objects.requireNonNull(relativePath, "relativePath"); + if (relativePath.isBlank() || relativePath.startsWith("/") || relativePath.endsWith("/")) { + throw new IllegalArgumentException("Resource path must be a non-empty relative path: " + relativePath); + } + if (relativePath.indexOf('\\') >= 0 || relativePath.indexOf(':') >= 0 || relativePath.contains("//")) { + throw new IllegalArgumentException("Resource path is not portable: " + relativePath); + } + String[] segments = relativePath.split("/"); + if (segments[0].equalsIgnoreCase(".iris")) { + throw new IllegalArgumentException("Resource path uses the reserved .iris directory: " + relativePath); + } + for (String segment : segments) { + validatePathSegment(relativePath, segment); + } + return relativePath; + } + + private static Set immutableCapabilities(EnumSet capabilities) { + if (capabilities.isEmpty()) { + return Set.of(); + } + return Collections.unmodifiableSet(EnumSet.copyOf(capabilities)); + } + + private static void validatePathSegment(String relativePath, String segment) { + if (segment.isEmpty() || segment.equals(".") || segment.equals("..") || segment.endsWith(".") || segment.endsWith(" ")) { + throw new IllegalArgumentException("Resource path is not portable: " + relativePath); + } + if (WINDOWS_RESERVED_NAME.matcher(segment).matches()) { + throw new IllegalArgumentException("Resource path uses a reserved file name: " + relativePath); + } + for (int i = 0; i < segment.length(); i++) { + char character = segment.charAt(i); + if (Character.isISOControl(character) || character == '"' || character == '*' || character == '<' + || character == '>' || character == '?' || character == '|') { + throw new IllegalArgumentException("Resource path is not portable: " + relativePath); + } + } + } + + public static final class Builder { + private final StructureKey key; + private final EnumSet capabilities; + private final List losses; + private final Map resources; + private final Map portableResourcePaths; + private StructureSource source; + private StructureBackend backend; + + private Builder(StructureKey key) { + this.key = Objects.requireNonNull(key, "key"); + capabilities = EnumSet.noneOf(StructureCapability.class); + losses = new ArrayList<>(); + resources = new TreeMap<>(); + portableResourcePaths = new HashMap<>(); + } + + public Builder source(StructureSource source) { + this.source = Objects.requireNonNull(source, "source"); + return this; + } + + public Builder backend(StructureBackend backend) { + this.backend = Objects.requireNonNull(backend, "backend"); + return this; + } + + public Builder capability(StructureCapability capability) { + capabilities.add(Objects.requireNonNull(capability, "capability")); + return this; + } + + public Builder capabilities(Collection capabilities) { + Objects.requireNonNull(capabilities, "capabilities"); + for (StructureCapability capability : capabilities) { + capability(capability); + } + return this; + } + + public Builder loss(StructureLoss loss) { + losses.add(Objects.requireNonNull(loss, "loss")); + return this; + } + + public Builder losses(Collection losses) { + Objects.requireNonNull(losses, "losses"); + for (StructureLoss loss : losses) { + loss(loss); + } + return this; + } + + public Builder resource(String relativePath, byte[] content) { + Resource resource = new Resource(relativePath, content); + String portablePath = resource.relativePath().toLowerCase(Locale.ROOT); + String previousPath = portableResourcePaths.putIfAbsent(portablePath, resource.relativePath()); + if (previousPath != null) { + throw new IllegalArgumentException("Duplicate structure resource: " + relativePath); + } + resources.put(resource.relativePath(), resource); + return this; + } + + public Builder textResource(String relativePath, String content) { + Objects.requireNonNull(content, "content"); + return resource(relativePath, content.getBytes(StandardCharsets.UTF_8)); + } + + public StructureResourceBundle build() { + return new StructureResourceBundle(this); + } + } + + public static final class Resource { + private final String relativePath; + private final byte[] content; + private final String contentHash; + + private Resource(String relativePath, byte[] content) { + this.relativePath = validateRelativePath(relativePath); + Objects.requireNonNull(content, "content"); + this.content = content.clone(); + contentHash = StructureHash.sha256(this.content); + } + + public String relativePath() { + return relativePath; + } + + public byte[] content() { + return content.clone(); + } + + public int size() { + return content.length; + } + + public String contentHash() { + return contentHash; + } + + byte[] contentForWrite() { + return content; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureSource.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureSource.java new file mode 100644 index 000000000..1569f2631 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureSource.java @@ -0,0 +1,49 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import java.util.Objects; + +public record StructureSource(Kind kind, StructureKey key, String version, String contentHash) { + public StructureSource { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(version, "version"); + Objects.requireNonNull(contentHash, "contentHash"); + if (!contentHash.isEmpty() && !StructureHash.isSha256(contentHash)) { + throw new IllegalArgumentException("Source content hash must be SHA-256"); + } + } + + public static StructureSource of(Kind kind, StructureKey key) { + return new StructureSource(kind, key, "", ""); + } + + public static StructureSource identified(Kind kind, StructureKey key, String version, byte[] content) { + return new StructureSource(kind, key, version, StructureHash.sha256(content)); + } + + public enum Kind { + IRIS, + VANILLA, + DATAPACK, + MOD, + UNKNOWN + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionJournal.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionJournal.java new file mode 100644 index 000000000..6e93b94ce --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionJournal.java @@ -0,0 +1,139 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +record StructureTransactionJournal( + int schemaVersion, + UUID transactionId, + Phase phase, + List targets +) { + static final int CURRENT_SCHEMA_VERSION = 1; + static final String FILE_NAME = "transaction.json"; + static final String NEXT_FILE_NAME = "transaction.json.next"; + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Pattern MANIFEST_PATH = Pattern.compile( + "\\.iris/structure-manifests/key-[a-f0-9]{64}\\.json" + ); + + StructureTransactionJournal { + if (schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported structure transaction schema: " + schemaVersion); + } + Objects.requireNonNull(transactionId, "transactionId"); + Objects.requireNonNull(phase, "phase"); + Objects.requireNonNull(targets, "targets"); + if (targets.isEmpty()) { + throw new IllegalArgumentException("Structure transaction must contain at least one target"); + } + ArrayList orderedTargets = new ArrayList<>(targets); + orderedTargets.sort(Comparator.comparing(Target::relativePath)); + Set portablePaths = new HashSet<>(orderedTargets.size()); + for (Target target : orderedTargets) { + String portablePath = target.relativePath().toLowerCase(Locale.ROOT); + if (!portablePaths.add(portablePath)) { + throw new IllegalArgumentException("Structure transaction contains duplicate target " + + target.relativePath()); + } + } + targets = List.copyOf(orderedTargets); + } + + static StructureTransactionJournal prepared(UUID transactionId, List targets) { + return new StructureTransactionJournal(CURRENT_SCHEMA_VERSION, transactionId, Phase.PREPARED, targets); + } + + static StructureTransactionJournal fromJson(byte[] content) { + Objects.requireNonNull(content, "content"); + StructureTransactionJournal journal = GSON.fromJson( + new String(content, StandardCharsets.UTF_8), + StructureTransactionJournal.class + ); + if (journal == null) { + throw new IllegalArgumentException("Structure transaction journal is empty"); + } + return journal; + } + + StructureTransactionJournal committed() { + return new StructureTransactionJournal(schemaVersion, transactionId, Phase.COMMITTED, targets); + } + + byte[] toJson() { + return GSON.toJson(this).getBytes(StandardCharsets.UTF_8); + } + + enum Phase { + PREPARED, + COMMITTED + } + + record Target( + String relativePath, + boolean hadOriginal, + String originalHash, + String replacementHash + ) { + Target { + Objects.requireNonNull(relativePath, "relativePath"); + Objects.requireNonNull(originalHash, "originalHash"); + Objects.requireNonNull(replacementHash, "replacementHash"); + if (relativePath.startsWith(".iris/")) { + StructureResourceBundle.validateRelativePath("transaction/" + relativePath); + if (!MANIFEST_PATH.matcher(relativePath).matches()) { + throw new IllegalArgumentException("Invalid internal structure transaction target: " + + relativePath); + } + } else { + relativePath = StructureResourceBundle.validateRelativePath(relativePath); + } + if (hadOriginal && !StructureHash.isSha256(originalHash)) { + throw new IllegalArgumentException("Structure transaction original hash is invalid for " + + relativePath); + } + if (!hadOriginal && !originalHash.isEmpty()) { + throw new IllegalArgumentException("Structure transaction has a hash for a new target " + + relativePath); + } + if (!replacementHash.isEmpty() && !StructureHash.isSha256(replacementHash)) { + throw new IllegalArgumentException("Structure transaction replacement hash is invalid for " + + relativePath); + } + if (!hadOriginal && replacementHash.isEmpty()) { + throw new IllegalArgumentException("Structure transaction new target has no replacement hash for " + + relativePath); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java new file mode 100644 index 000000000..526fde354 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java @@ -0,0 +1,1005 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; + +public final class StructureTransactionWriter { + private static final String STAGING_RELATIVE_PATH = ".iris/structure-staging"; + private static final String PROCESS_LOCK_RELATIVE_PATH = ".iris/structure-authoring.lock"; + private static final ConcurrentMap ROOT_LOCKS = new ConcurrentHashMap<>(); + + private final Path packRoot; + private final StructureFileOperations files; + private final ReentrantLock rootLock; + + public StructureTransactionWriter(Path packRoot) { + this(packRoot, new NioStructureFileOperations()); + } + + StructureTransactionWriter(Path packRoot, StructureFileOperations files) { + this.packRoot = canonicalPackRoot(Objects.requireNonNull(packRoot, "packRoot")); + this.files = Objects.requireNonNull(files, "files"); + rootLock = ROOT_LOCKS.computeIfAbsent(this.packRoot, ignored -> new ReentrantLock()); + } + + public Path packRoot() { + return packRoot; + } + + public Path ownershipManifestPath(StructureKey key) { + Objects.requireNonNull(key, "key"); + return resolveTarget(StructureOwnershipManifest.relativePath(key)); + } + + public StructureRecoveryResult recoverIncompleteTransactions() { + rootLock.lock(); + try (ProcessLock ignored = acquireProcessLock()) { + return recoverIncompleteTransactionsLocked(); + } catch (IOException | RuntimeException e) { + return new StructureRecoveryResult( + 0, + 0, + 0, + List.of(new StructureRecoveryResult.Failure(packRoot, e)) + ); + } finally { + rootLock.unlock(); + } + } + + public StructureWriteResult write(StructureResourceBundle bundle, StructureWriteMode mode) { + return write(bundle, new StructureWriteOptions(mode, false)); + } + + public StructureWriteResult preview(StructureResourceBundle bundle, StructureWriteMode mode) { + return write(bundle, StructureWriteOptions.preview(mode)); + } + + public StructureWriteResult write(StructureResourceBundle bundle, StructureWriteOptions options) { + Objects.requireNonNull(bundle, "bundle"); + Objects.requireNonNull(options, "options"); + rootLock.lock(); + try { + if (options.dryRun()) { + return writeLocked(bundle, options); + } + try (ProcessLock ignored = acquireProcessLock()) { + return writeLocked(bundle, options); + } + } catch (IOException | RuntimeException e) { + return failedResult(bundle, e); + } finally { + rootLock.unlock(); + } + } + + private StructureWriteResult writeLocked(StructureResourceBundle bundle, StructureWriteOptions options) + throws IOException { + if (!options.dryRun()) { + StructureRecoveryResult recovery = recoverIncompleteTransactionsLocked(); + if (!recovery.successful()) { + return failedResult(bundle, recoveryFailure(recovery)); + } + } + WritePlan plan = buildPlan(bundle, options.mode()); + if (!plan.conflicts().isEmpty()) { + StructureWriteResult.Status status = options.mode() == StructureWriteMode.ADD_ONLY + ? StructureWriteResult.Status.ADD_ONLY_CONFLICT + : StructureWriteResult.Status.OWNERSHIP_CONFLICT; + return result(status, plan, Optional.empty()); + } + if (options.dryRun()) { + return result(StructureWriteResult.Status.DRY_RUN, plan, Optional.empty()); + } + if (plan.action() == StructureWriteResult.Action.NONE) { + return result(StructureWriteResult.Status.UNCHANGED, plan, Optional.empty()); + } + return commit(plan); + } + + private StructureRecoveryResult recoverIncompleteTransactionsLocked() { + Path stagingRoot = stagingRoot(); + ArrayList failures = new ArrayList<>(); + int restoredPrepared = 0; + int cleanedCommitted = 0; + int cleanedOrphans = 0; + if (!files.exists(stagingRoot)) { + return new StructureRecoveryResult(0, 0, 0, List.of()); + } + if (!files.isDirectory(stagingRoot)) { + IOException failure = new IOException("Structure staging root is not a directory: " + stagingRoot); + return new StructureRecoveryResult( + 0, + 0, + 0, + List.of(new StructureRecoveryResult.Failure(stagingRoot, failure)) + ); + } + + List transactionRoots; + try { + transactionRoots = files.list(stagingRoot); + } catch (IOException | RuntimeException e) { + return new StructureRecoveryResult( + 0, + 0, + 0, + List.of(new StructureRecoveryResult.Failure(stagingRoot, e)) + ); + } + + for (Path transactionRoot : transactionRoots) { + try { + RecoveryOutcome outcome = recoverTransaction(transactionRoot); + switch (outcome) { + case RESTORED_PREPARED -> restoredPrepared++; + case CLEANED_COMMITTED -> cleanedCommitted++; + case CLEANED_ORPHAN -> cleanedOrphans++; + } + } catch (IOException | RuntimeException e) { + failures.add(new StructureRecoveryResult.Failure(transactionRoot, e)); + } + } + return new StructureRecoveryResult( + restoredPrepared, + cleanedCommitted, + cleanedOrphans, + failures + ); + } + + private RecoveryOutcome recoverTransaction(Path transactionRoot) throws IOException { + Path normalizedRoot = transactionRoot.toAbsolutePath().normalize(); + Path stagingRoot = stagingRoot(); + if (!Objects.equals(normalizedRoot.getParent(), stagingRoot)) { + throw new IOException("Structure transaction is not a direct child of " + stagingRoot); + } + rejectSymbolicLinks(stagingRoot, normalizedRoot); + if (!files.isDirectory(normalizedRoot)) { + files.deleteTree(normalizedRoot); + files.forceDirectory(stagingRoot); + return RecoveryOutcome.CLEANED_ORPHAN; + } + + Path journalPath = recoveryJournalPath(normalizedRoot); + if (journalPath == null) { + if (hasRecoveryData(normalizedRoot.resolve("backup"))) { + throw new IOException("Structure transaction has backups but no recovery journal: " + + normalizedRoot); + } + cleanupTransaction(normalizedRoot); + return RecoveryOutcome.CLEANED_ORPHAN; + } + if (!files.isRegularFile(journalPath)) { + throw new IOException("Structure transaction journal is not a regular file: " + journalPath); + } + + StructureTransactionJournal journal; + try { + journal = StructureTransactionJournal.fromJson(files.readAllBytes(journalPath)); + } catch (RuntimeException e) { + throw new IOException("Invalid structure transaction journal at " + journalPath, e); + } + String directoryName = Objects.requireNonNull(normalizedRoot.getFileName(), "transaction directory name") + .toString(); + if (!journal.transactionId().toString().equals(directoryName)) { + throw new IOException("Structure transaction journal id " + journal.transactionId() + + " does not match directory " + directoryName); + } + + return switch (journal.phase()) { + case PREPARED -> { + restorePreparedTransaction(normalizedRoot, journal); + cleanupTransaction(normalizedRoot); + yield RecoveryOutcome.RESTORED_PREPARED; + } + case COMMITTED -> { + verifyCommittedTransaction(journal); + cleanupTransaction(normalizedRoot); + yield RecoveryOutcome.CLEANED_COMMITTED; + } + }; + } + + private Path recoveryJournalPath(Path transactionRoot) throws IOException { + Path journalPath = transactionRoot.resolve(StructureTransactionJournal.FILE_NAME); + if (files.exists(journalPath)) { + return journalPath; + } + Path nextJournalPath = transactionRoot.resolve(StructureTransactionJournal.NEXT_FILE_NAME); + if (!files.exists(nextJournalPath)) { + return null; + } + if (!files.isRegularFile(nextJournalPath)) { + throw new IOException("Structure transaction next journal is not a regular file: " + + nextJournalPath); + } + return nextJournalPath; + } + + private void verifyCommittedTransaction(StructureTransactionJournal journal) throws IOException { + for (StructureTransactionJournal.Target state : journal.targets()) { + Path target = resolveTarget(state.relativePath()); + if (state.replacementHash().isEmpty()) { + if (files.exists(target)) { + throw new IOException("Committed structure transaction retained a removed target: " + + state.relativePath()); + } + continue; + } + if (!files.isRegularFile(target)) { + throw new IOException("Committed structure transaction target is missing or is not a regular file: " + + state.relativePath()); + } + String actualHash = files.sha256(target); + if (!state.replacementHash().equals(actualHash)) { + throw new IOException("Committed structure transaction target hash mismatch for " + + state.relativePath()); + } + } + } + + private void restorePreparedTransaction( + Path transactionRoot, + StructureTransactionJournal journal + ) throws IOException { + Throwable recoveryFailure = null; + ArrayList targets = new ArrayList<>(journal.targets()); + Collections.reverse(targets); + for (StructureTransactionJournal.Target state : targets) { + try { + restorePreparedTarget(transactionRoot, state); + } catch (IOException | RuntimeException e) { + recoveryFailure = appendFailure(recoveryFailure, e); + } + } + if (recoveryFailure != null) { + throw new IOException("Unable to restore prepared structure transaction " + transactionRoot, + recoveryFailure); + } + } + + private void restorePreparedTarget( + Path transactionRoot, + StructureTransactionJournal.Target state + ) throws IOException { + Path target = resolveTarget(state.relativePath()); + Path backupRoot = transactionRoot.resolve("backup").normalize(); + Path backup = resolveTransactionPath(backupRoot, state.relativePath()); + Path targetParent = Objects.requireNonNull(target.getParent(), "recovery target parent"); + if (!state.hadOriginal()) { + removeReplacementIfPresent(target, state); + return; + } + if (!files.exists(backup)) { + verifyOriginalTarget(target, state); + files.forceFile(target); + files.forceDirectory(targetParent); + return; + } + if (!files.isRegularFile(backup)) { + throw new IOException("Structure transaction backup is not a regular file: " + backup); + } + verifyOriginalContent(backup, state); + if (files.isRegularFile(target) && state.originalHash().equals(files.sha256(target))) { + files.deleteIfExists(backup); + files.forceFile(target); + files.forceDirectory(targetParent); + files.forceDirectory(Objects.requireNonNull(backup.getParent(), "recovery backup parent")); + return; + } + removeReplacementIfPresent(target, state); + files.createDirectories(targetParent); + files.moveNew(backup, target); + files.forceFile(target); + files.forceDirectory(targetParent); + files.forceDirectory(Objects.requireNonNull(backup.getParent(), "recovery backup parent")); + } + + private boolean hasRecoveryData(Path backupRoot) throws IOException { + if (!files.exists(backupRoot)) { + return false; + } + if (!files.isDirectory(backupRoot)) { + return true; + } + for (Path child : files.list(backupRoot)) { + if (!files.isDirectory(child) || hasRecoveryData(child)) { + return true; + } + } + return false; + } + + private IOException recoveryFailure(StructureRecoveryResult recovery) { + IOException failure = new IOException("Unable to recover " + recovery.failures().size() + + " incomplete structure transaction(s) under " + stagingRoot()); + for (StructureRecoveryResult.Failure recoveryFailure : recovery.failures()) { + failure.addSuppressed(recoveryFailure.cause()); + } + return failure; + } + + private WritePlan buildPlan(StructureResourceBundle bundle, StructureWriteMode mode) throws IOException { + StructureOwnershipManifest nextManifest = StructureOwnershipManifest.from(bundle); + String manifestRelativePath = nextManifest.relativePath(); + Path manifestPath = resolveTarget(manifestRelativePath); + ArrayList conflicts = new ArrayList<>(); + TreeMap previousResourceHashes = new TreeMap<>(); + + if (mode == StructureWriteMode.ADD_ONLY) { + if (files.exists(manifestPath)) { + conflicts.add(StructureWriteResult.Conflict.at( + manifestRelativePath, + StructureWriteResult.ConflictReason.MANIFEST_EXISTS + )); + } + findAddOnlyConflicts(bundle, conflicts); + return createPlan( + bundle, + nextManifest, + previousResourceHashes, + StructureWriteResult.Action.ADD, + conflicts + ); + } + + if (!files.exists(manifestPath)) { + findUnownedResources(bundle, previousResourceHashes, conflicts); + return createPlan( + bundle, + nextManifest, + previousResourceHashes, + StructureWriteResult.Action.ADD, + conflicts + ); + } + + if (!files.isRegularFile(manifestPath)) { + conflicts.add(StructureWriteResult.Conflict.invalidManifest( + manifestRelativePath, + "Ownership manifest is not a regular file" + )); + return createPlan( + bundle, + nextManifest, + previousResourceHashes, + StructureWriteResult.Action.OVERWRITE, + conflicts + ); + } + + StructureOwnershipManifest previousManifest; + try { + previousManifest = StructureOwnershipManifest.fromJson(files.readAllBytes(manifestPath)); + } catch (RuntimeException e) { + conflicts.add(StructureWriteResult.Conflict.invalidManifest(manifestRelativePath, e.toString())); + return createPlan( + bundle, + nextManifest, + previousResourceHashes, + StructureWriteResult.Action.OVERWRITE, + conflicts + ); + } + + if (!previousManifest.structure().equals(bundle.key())) { + conflicts.add(StructureWriteResult.Conflict.invalidManifest( + manifestRelativePath, + "Ownership manifest belongs to " + previousManifest.structure() + )); + return createPlan( + bundle, + nextManifest, + previousResourceHashes, + StructureWriteResult.Action.OVERWRITE, + conflicts + ); + } + + previousResourceHashes.putAll(previousManifest.resourceHashes()); + verifyOwnedResources(previousResourceHashes, conflicts); + findUnownedResources(bundle, previousResourceHashes, conflicts); + StructureWriteResult.Action action = nextManifest.equals(previousManifest) && conflicts.isEmpty() + ? StructureWriteResult.Action.NONE + : StructureWriteResult.Action.OVERWRITE; + return createPlan(bundle, nextManifest, previousResourceHashes, action, conflicts); + } + + private void findAddOnlyConflicts( + StructureResourceBundle bundle, + List conflicts + ) { + for (String relativePath : bundle.resources().keySet()) { + if (files.exists(resolveTarget(relativePath))) { + conflicts.add(StructureWriteResult.Conflict.at( + relativePath, + StructureWriteResult.ConflictReason.RESOURCE_EXISTS + )); + } + } + } + + private void findUnownedResources( + StructureResourceBundle bundle, + Map previousResourceHashes, + List conflicts + ) { + for (String relativePath : bundle.resources().keySet()) { + if (!previousResourceHashes.containsKey(relativePath) && files.exists(resolveTarget(relativePath))) { + conflicts.add(StructureWriteResult.Conflict.at( + relativePath, + StructureWriteResult.ConflictReason.UNOWNED_RESOURCE + )); + } + } + } + + private void verifyOwnedResources( + Map previousResourceHashes, + List conflicts + ) throws IOException { + for (Map.Entry entry : previousResourceHashes.entrySet()) { + String relativePath = entry.getKey(); + Path target = resolveTarget(relativePath); + if (!files.exists(target)) { + conflicts.add(StructureWriteResult.Conflict.at( + relativePath, + StructureWriteResult.ConflictReason.MISSING_OWNED_RESOURCE + )); + continue; + } + if (!files.isRegularFile(target)) { + conflicts.add(StructureWriteResult.Conflict.at( + relativePath, + StructureWriteResult.ConflictReason.NON_FILE_RESOURCE + )); + continue; + } + String actualHash = files.sha256(target); + if (!entry.getValue().equals(actualHash)) { + conflicts.add(StructureWriteResult.Conflict.modified(relativePath, entry.getValue(), actualHash)); + } + } + } + + private WritePlan createPlan( + StructureResourceBundle bundle, + StructureOwnershipManifest nextManifest, + Map previousResourceHashes, + StructureWriteResult.Action action, + List conflicts + ) { + TreeSet affectedResources = new TreeSet<>(previousResourceHashes.keySet()); + affectedResources.addAll(bundle.resources().keySet()); + affectedResources.add(nextManifest.relativePath()); + ArrayList orderedConflicts = new ArrayList<>(conflicts); + orderedConflicts.sort((left, right) -> left.relativePath().compareTo(right.relativePath())); + return new WritePlan( + bundle, + nextManifest, + nextManifest.toJson(), + action, + List.copyOf(orderedConflicts), + List.copyOf(affectedResources) + ); + } + + private StructureWriteResult commit(WritePlan plan) { + UUID transactionId = UUID.randomUUID(); + Path transactionRoot = stagingRoot().resolve(transactionId.toString()).normalize(); + Path stagedRoot = transactionRoot.resolve("staged"); + Path backupRoot = transactionRoot.resolve("backup"); + Path stagedManifest = stagedRoot.resolve("ownership-manifest.json"); + LinkedHashMap backups = new LinkedHashMap<>(); + ArrayList installedTargets = new ArrayList<>(); + StructureTransactionJournal journal; + + try { + files.createDirectories(stagedRoot); + files.createDirectories(backupRoot); + stageResources(plan, stagedRoot, stagedManifest); + journal = createJournal(plan, transactionId); + writeJournal(transactionRoot, journal); + files.forceDirectory(transactionRoot); + files.forceDirectory(stagingRoot()); + verifyTargetSnapshot(journal); + backupTargets(journal, backupRoot, backups); + installResources(plan, stagedRoot, stagedManifest, installedTargets); + } catch (IOException | RuntimeException commitFailure) { + return rollbackResult(plan, transactionRoot, backups, installedTargets, commitFailure); + } + + boolean committedJournalWritten = false; + try { + writeJournal(transactionRoot, journal.committed()); + committedJournalWritten = true; + files.forceDirectory(transactionRoot); + } catch (IOException | RuntimeException commitPhaseFailure) { + if (committedJournalWritten || isCommittedJournal(transactionRoot, commitPhaseFailure)) { + return result( + StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED, + plan, + Optional.of(commitPhaseFailure) + ); + } + return rollbackResult(plan, transactionRoot, backups, installedTargets, commitPhaseFailure); + } + + try { + cleanupTransaction(transactionRoot); + } catch (IOException | RuntimeException cleanupFailure) { + return result( + StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED, + plan, + Optional.of(cleanupFailure) + ); + } + + StructureWriteResult.Status status = plan.action() == StructureWriteResult.Action.ADD + ? StructureWriteResult.Status.ADDED + : StructureWriteResult.Status.OVERWRITTEN; + return result(status, plan, Optional.empty()); + } + + private void stageResources(WritePlan plan, Path stagedRoot, Path stagedManifest) throws IOException { + for (StructureResourceBundle.Resource resource : plan.bundle().resources().values()) { + Path staged = stagedRoot.resolve(resource.relativePath()).normalize(); + files.createDirectories(Objects.requireNonNull(staged.getParent(), "staged parent")); + files.writeNew(staged, resource.contentForWrite()); + files.forceFile(staged); + } + files.createDirectories(Objects.requireNonNull(stagedManifest.getParent(), "manifest parent")); + files.writeNew(stagedManifest, plan.nextManifestContent()); + files.forceFile(stagedManifest); + } + + private StructureTransactionJournal createJournal(WritePlan plan, UUID transactionId) throws IOException { + ArrayList targets = new ArrayList<>(plan.affectedResources().size()); + for (String relativePath : plan.affectedResources()) { + Path target = resolveTarget(relativePath); + boolean hadOriginal = files.exists(target); + targets.add(new StructureTransactionJournal.Target( + relativePath, + hadOriginal, + hadOriginal ? files.sha256(target) : "", + replacementHash(plan, relativePath) + )); + } + return StructureTransactionJournal.prepared(transactionId, targets); + } + + private void writeJournal( + Path transactionRoot, + StructureTransactionJournal journal + ) throws IOException { + Path journalPath = transactionRoot.resolve(StructureTransactionJournal.FILE_NAME); + Path nextJournalPath = transactionRoot.resolve(StructureTransactionJournal.NEXT_FILE_NAME); + files.deleteIfExists(nextJournalPath); + files.writeNew(nextJournalPath, journal.toJson()); + files.forceFile(nextJournalPath); + files.move(nextJournalPath, journalPath); + } + + private boolean isCommittedJournal(Path transactionRoot, Throwable commitPhaseFailure) { + Path journalPath = transactionRoot.resolve(StructureTransactionJournal.FILE_NAME); + if (!files.isRegularFile(journalPath)) { + return false; + } + try { + return StructureTransactionJournal.fromJson(files.readAllBytes(journalPath)).phase() + == StructureTransactionJournal.Phase.COMMITTED; + } catch (IOException | RuntimeException e) { + commitPhaseFailure.addSuppressed(e); + return false; + } + } + + private void verifyTargetSnapshot(StructureTransactionJournal journal) throws IOException { + for (StructureTransactionJournal.Target state : journal.targets()) { + Path target = resolveTarget(state.relativePath()); + boolean exists = files.exists(target); + if (exists != state.hadOriginal()) { + throw new IOException("Structure transaction target changed during preparation: " + + state.relativePath()); + } + if (state.hadOriginal()) { + verifyOriginalTarget(target, state); + } + } + } + + private void backupTargets( + StructureTransactionJournal journal, + Path backupRoot, + Map backups + ) throws IOException { + for (StructureTransactionJournal.Target state : journal.targets()) { + if (!state.hadOriginal()) { + continue; + } + Path target = resolveTarget(state.relativePath()); + Path backup = resolveTransactionPath(backupRoot, state.relativePath()); + files.createDirectories(Objects.requireNonNull(backup.getParent(), "backup parent")); + backups.put(target, backup); + files.moveNew(target, backup); + verifyOriginalContent(backup, state); + files.forceDirectory(Objects.requireNonNull(target.getParent(), "backup target parent")); + files.forceDirectory(Objects.requireNonNull(backup.getParent(), "backup parent")); + } + } + + private void installResources( + WritePlan plan, + Path stagedRoot, + Path stagedManifest, + List installedTargets + ) throws IOException { + for (StructureResourceBundle.Resource resource : plan.bundle().resources().values()) { + Path source = stagedRoot.resolve(resource.relativePath()).normalize(); + Path target = resolveTarget(resource.relativePath()); + files.createDirectories(Objects.requireNonNull(target.getParent(), "target parent")); + files.moveNew(source, target); + installedTargets.add(new InstalledTarget(target, resource.contentHash())); + files.forceFile(target); + files.forceDirectory(Objects.requireNonNull(target.getParent(), "target parent")); + } + Path manifestTarget = resolveTarget(plan.nextManifest().relativePath()); + files.createDirectories(Objects.requireNonNull(manifestTarget.getParent(), "manifest target parent")); + files.moveNew(stagedManifest, manifestTarget); + installedTargets.add(new InstalledTarget( + manifestTarget, + StructureHash.sha256(plan.nextManifestContent()) + )); + files.forceFile(manifestTarget); + files.forceDirectory(Objects.requireNonNull(manifestTarget.getParent(), "manifest target parent")); + } + + private StructureWriteResult rollbackResult( + WritePlan plan, + Path transactionRoot, + Map backups, + List installedTargets, + Throwable commitFailure + ) { + Optional rollbackFailure = rollback(backups, installedTargets); + if (rollbackFailure.isPresent()) { + IOException retainedRecovery = new IOException( + "Transaction recovery data retained at " + transactionRoot, + rollbackFailure.get() + ); + commitFailure.addSuppressed(retainedRecovery); + return result(StructureWriteResult.Status.FAILED, plan, Optional.of(commitFailure)); + } + cleanupAfterFailure(transactionRoot, commitFailure); + return result(StructureWriteResult.Status.ROLLED_BACK, plan, Optional.of(commitFailure)); + } + + private Optional rollback(Map backups, List installedTargets) { + Throwable rollbackFailure = null; + ArrayList reversedTargets = new ArrayList<>(installedTargets); + Collections.reverse(reversedTargets); + for (InstalledTarget installedTarget : reversedTargets) { + try { + removeInstalledTarget(installedTarget); + } catch (IOException | RuntimeException e) { + rollbackFailure = appendFailure(rollbackFailure, e); + } + } + + ArrayList> reversedBackups = new ArrayList<>(backups.entrySet()); + Collections.reverse(reversedBackups); + for (Map.Entry entry : reversedBackups) { + Path target = entry.getKey(); + Path backup = entry.getValue(); + if (!files.exists(backup)) { + continue; + } + try { + files.createDirectories(Objects.requireNonNull(target.getParent(), "rollback target parent")); + files.moveNew(backup, target); + files.forceFile(target); + files.forceDirectory(Objects.requireNonNull(target.getParent(), "rollback target parent")); + files.forceDirectory(Objects.requireNonNull(backup.getParent(), "rollback backup parent")); + } catch (IOException | RuntimeException e) { + rollbackFailure = appendFailure(rollbackFailure, e); + } + } + return Optional.ofNullable(rollbackFailure); + } + + private void cleanupAfterFailure(Path transactionRoot, Throwable failure) { + try { + cleanupTransaction(transactionRoot); + } catch (IOException | RuntimeException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + + private void cleanupTransaction(Path transactionRoot) throws IOException { + files.deleteTree(transactionRoot.resolve("staged")); + files.deleteTree(transactionRoot.resolve("backup")); + files.deleteIfExists(transactionRoot.resolve(StructureTransactionJournal.NEXT_FILE_NAME)); + forceExistingDirectory(transactionRoot); + files.deleteIfExists(transactionRoot.resolve(StructureTransactionJournal.FILE_NAME)); + files.deleteTree(transactionRoot); + Path stagingRoot = stagingRoot(); + if (files.exists(stagingRoot)) { + files.forceDirectory(stagingRoot); + } + } + + private Throwable appendFailure(Throwable existing, Throwable next) { + if (existing == null) { + return next; + } + existing.addSuppressed(next); + return existing; + } + + private void verifyOriginalTarget( + Path target, + StructureTransactionJournal.Target state + ) throws IOException { + if (!files.isRegularFile(target)) { + throw new IOException("Structure transaction original target is missing or is not a regular file for " + + state.relativePath()); + } + verifyOriginalContent(target, state); + } + + private void verifyOriginalContent( + Path path, + StructureTransactionJournal.Target state + ) throws IOException { + String actualHash = files.sha256(path); + if (!state.originalHash().equals(actualHash)) { + throw new IOException("Structure transaction original content hash mismatch for " + + state.relativePath()); + } + } + + private void removeReplacementIfPresent( + Path target, + StructureTransactionJournal.Target state + ) throws IOException { + if (!files.exists(target)) { + return; + } + if (!files.isRegularFile(target)) { + throw new IOException("Structure transaction replacement is not a regular file for " + + state.relativePath()); + } + if (state.replacementHash().isEmpty()) { + throw new IOException("Unexpected replacement appeared while recovering " + state.relativePath()); + } + String actualHash = files.sha256(target); + if (!state.replacementHash().equals(actualHash)) { + throw new IOException("Structure transaction replacement content changed after the interrupted write for " + + state.relativePath()); + } + files.deleteIfExists(target); + forceExistingDirectory(Objects.requireNonNull(target.getParent(), "replacement target parent")); + } + + private String replacementHash(WritePlan plan, String relativePath) { + StructureResourceBundle.Resource resource = plan.bundle().resources().get(relativePath); + if (resource != null) { + return resource.contentHash(); + } + if (plan.nextManifest().relativePath().equals(relativePath)) { + return StructureHash.sha256(plan.nextManifestContent()); + } + return ""; + } + + private void forceExistingDirectory(Path path) throws IOException { + if (files.isDirectory(path)) { + files.forceDirectory(path); + } + } + + private void removeInstalledTarget(InstalledTarget installedTarget) throws IOException { + Path target = installedTarget.path(); + if (!files.exists(target)) { + return; + } + if (!files.isRegularFile(target) || !installedTarget.contentHash().equals(files.sha256(target))) { + throw new IOException("Installed structure target changed before rollback: " + target); + } + files.deleteIfExists(target); + forceExistingDirectory(Objects.requireNonNull(target.getParent(), "rollback target parent")); + } + + private StructureWriteResult result( + StructureWriteResult.Status status, + WritePlan plan, + Optional failure + ) { + return new StructureWriteResult( + status, + plan.action(), + plan.conflicts(), + plan.affectedResources(), + plan.nextManifest().relativePath(), + failure + ); + } + + private StructureWriteResult failedResult(StructureResourceBundle bundle, Throwable failure) { + StructureOwnershipManifest manifest = StructureOwnershipManifest.from(bundle); + TreeSet affectedResources = new TreeSet<>(bundle.resources().keySet()); + affectedResources.add(manifest.relativePath()); + return new StructureWriteResult( + StructureWriteResult.Status.FAILED, + StructureWriteResult.Action.NONE, + List.of(), + List.copyOf(affectedResources), + manifest.relativePath(), + Optional.of(failure) + ); + } + + private Path resolveTarget(String relativePath) { + return resolveWithin(packRoot, relativePath, "Resource path escapes the pack root: "); + } + + private Path resolveTransactionPath(Path root, String relativePath) { + return resolveWithin(root, relativePath, "Transaction path escapes its root: "); + } + + private Path stagingRoot() { + return resolveWithin(packRoot, STAGING_RELATIVE_PATH, "Structure staging path escapes the pack root: "); + } + + private ProcessLock acquireProcessLock() throws IOException { + Path lockPath = resolveWithin( + packRoot, + PROCESS_LOCK_RELATIVE_PATH, + "Structure authoring lock path escapes the pack root: " + ); + Files.createDirectories(Objects.requireNonNull(lockPath.getParent(), "structure authoring lock parent")); + FileChannel channel = FileChannel.open( + lockPath, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE + ); + try { + return new ProcessLock(channel, channel.lock()); + } catch (IOException | OverlappingFileLockException e) { + channel.close(); + throw new IOException("Unable to acquire the structure authoring lock for " + packRoot, e); + } + } + + private Path resolveWithin(Path root, String relativePath, String errorPrefix) { + Path target = root.resolve(relativePath).normalize(); + if (!target.startsWith(root) || target.equals(root)) { + throw new IllegalArgumentException(errorPrefix + relativePath); + } + rejectSymbolicLinks(root, target); + return target; + } + + private void rejectSymbolicLinks(Path root, Path target) { + Path current = root; + Path relative = root.relativize(target); + for (Path segment : relative) { + current = current.resolve(segment); + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(current)) { + throw new IllegalArgumentException("Structure authoring path contains a symbolic link: " + current); + } + } + } + + private static Path canonicalPackRoot(Path root) { + Path normalized = root.toAbsolutePath().normalize(); + if (!Files.exists(normalized)) { + return normalized; + } + try { + return normalized.toRealPath(); + } catch (IOException e) { + throw new IllegalArgumentException("Unable to resolve the structure pack root " + normalized, e); + } + } + + private enum RecoveryOutcome { + RESTORED_PREPARED, + CLEANED_COMMITTED, + CLEANED_ORPHAN + } + + private record InstalledTarget(Path path, String contentHash) { + private InstalledTarget { + Objects.requireNonNull(path, "path"); + if (!StructureHash.isSha256(contentHash)) { + throw new IllegalArgumentException("Installed target content hash is invalid"); + } + } + } + + private record ProcessLock(FileChannel channel, FileLock lock) implements AutoCloseable { + private ProcessLock { + Objects.requireNonNull(channel, "channel"); + Objects.requireNonNull(lock, "lock"); + } + + @Override + public void close() throws IOException { + IOException failure = null; + try { + lock.release(); + } catch (IOException e) { + failure = e; + } + try { + channel.close(); + } catch (IOException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + if (failure != null) { + throw failure; + } + } + } + + private record WritePlan( + StructureResourceBundle bundle, + StructureOwnershipManifest nextManifest, + byte[] nextManifestContent, + StructureWriteResult.Action action, + List conflicts, + List affectedResources + ) { + private WritePlan { + nextManifestContent = nextManifestContent.clone(); + } + + @Override + public byte[] nextManifestContent() { + return nextManifestContent.clone(); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/VanillaStructureMode.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteMode.java similarity index 57% rename from core/src/main/java/art/arcane/iris/engine/object/VanillaStructureMode.java rename to core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteMode.java index 0652dd72b..d9824887c 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/VanillaStructureMode.java +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteMode.java @@ -16,15 +16,9 @@ * along with this program. If not, see . */ -package art.arcane.iris.engine.object; +package art.arcane.iris.core.structure.authoring; -import art.arcane.iris.engine.object.annotations.Desc; - -@Desc("Master toggle for native vanilla, mod, and datapack structure generation in a dimension.") -public enum VanillaStructureMode { - @Desc("All native vanilla, mod, and datapack structures generate, except any keys listed in 'disabled'. This is the default. Use this to blacklist a few structures.") - ALL_ON, - - @Desc("No native vanilla, mod, or datapack structures generate, except any keys listed in 'enabled'. Use this to whitelist a few structures.") - ALL_OFF +public enum StructureWriteMode { + ADD_ONLY, + OVERWRITE } diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteOptions.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteOptions.java new file mode 100644 index 000000000..d850a82fa --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteOptions.java @@ -0,0 +1,39 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import java.util.Objects; + +public record StructureWriteOptions(StructureWriteMode mode, boolean dryRun) { + public StructureWriteOptions { + Objects.requireNonNull(mode, "mode"); + } + + public static StructureWriteOptions addOnly() { + return new StructureWriteOptions(StructureWriteMode.ADD_ONLY, false); + } + + public static StructureWriteOptions overwrite() { + return new StructureWriteOptions(StructureWriteMode.OVERWRITE, false); + } + + public static StructureWriteOptions preview(StructureWriteMode mode) { + return new StructureWriteOptions(mode, true); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteResult.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteResult.java new file mode 100644 index 000000000..ea89238cb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteResult.java @@ -0,0 +1,119 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public record StructureWriteResult( + Status status, + Action action, + List conflicts, + List affectedResources, + String manifestPath, + Optional failure +) { + public StructureWriteResult { + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(action, "action"); + Objects.requireNonNull(conflicts, "conflicts"); + Objects.requireNonNull(affectedResources, "affectedResources"); + Objects.requireNonNull(manifestPath, "manifestPath"); + Objects.requireNonNull(failure, "failure"); + conflicts = List.copyOf(conflicts); + affectedResources = List.copyOf(affectedResources); + } + + public boolean successful() { + return switch (status) { + case DRY_RUN, ADDED, OVERWRITTEN, UNCHANGED, COMMITTED_CLEANUP_REQUIRED -> true; + case ADD_ONLY_CONFLICT, OWNERSHIP_CONFLICT, ROLLED_BACK, FAILED -> false; + }; + } + + public boolean committed() { + return switch (status) { + case ADDED, OVERWRITTEN, UNCHANGED, COMMITTED_CLEANUP_REQUIRED -> true; + case DRY_RUN, ADD_ONLY_CONFLICT, OWNERSHIP_CONFLICT, ROLLED_BACK, FAILED -> false; + }; + } + + public enum Status { + DRY_RUN, + ADDED, + OVERWRITTEN, + UNCHANGED, + ADD_ONLY_CONFLICT, + OWNERSHIP_CONFLICT, + ROLLED_BACK, + FAILED, + COMMITTED_CLEANUP_REQUIRED + } + + public enum Action { + ADD, + OVERWRITE, + NONE + } + + public enum ConflictReason { + RESOURCE_EXISTS, + MANIFEST_EXISTS, + UNOWNED_RESOURCE, + MODIFIED_RESOURCE, + MISSING_OWNED_RESOURCE, + NON_FILE_RESOURCE, + INVALID_MANIFEST + } + + public record Conflict( + String relativePath, + ConflictReason reason, + String expectedHash, + String actualHash, + String detail + ) { + public Conflict { + Objects.requireNonNull(relativePath, "relativePath"); + Objects.requireNonNull(reason, "reason"); + Objects.requireNonNull(expectedHash, "expectedHash"); + Objects.requireNonNull(actualHash, "actualHash"); + Objects.requireNonNull(detail, "detail"); + } + + public static Conflict at(String relativePath, ConflictReason reason) { + return new Conflict(relativePath, reason, "", "", ""); + } + + public static Conflict modified(String relativePath, String expectedHash, String actualHash) { + return new Conflict( + relativePath, + ConflictReason.MODIFIED_RESOURCE, + expectedHash, + actualHash, + "Resource content differs from its ownership manifest" + ); + } + + public static Conflict invalidManifest(String relativePath, String detail) { + return new Conflict(relativePath, ConflictReason.INVALID_MANIFEST, "", "", detail); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioCell.java b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioCell.java new file mode 100644 index 000000000..ac61cb3dd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioCell.java @@ -0,0 +1,315 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +public record SimpleStructureStudioCell( + int x, + int z, + SimpleStructureStudioTopology topology, + int quarterTurns, + SimpleStructureStudioRotationPolicy rotationPolicy, + String connectorChannel, + int connectorHeight, + List variants, + int activeVariantIndex +) { + public static final String DEFAULT_CONNECTOR_CHANNEL = "path"; + + private static final Pattern CONNECTOR_CHANNEL_PATTERN = Pattern.compile( + "(?:[a-z0-9._-]+:)?[a-z0-9._-]+(?:/[a-z0-9._-]+)*" + ); + + public SimpleStructureStudioCell { + if (x < 0 || z < 0) { + throw new IllegalArgumentException("Cell coordinates cannot be negative: " + x + ", " + z); + } + Objects.requireNonNull(topology, "topology"); + Objects.requireNonNull(rotationPolicy, "rotationPolicy"); + quarterTurns = Math.floorMod(quarterTurns, 4); + if (!rotationPolicy.allows(quarterTurns)) { + throw new IllegalArgumentException( + "Rotation policy " + rotationPolicy + " does not allow quarter turn " + quarterTurns + ); + } + Objects.requireNonNull(connectorChannel, "connectorChannel"); + if (!CONNECTOR_CHANNEL_PATTERN.matcher(connectorChannel).matches()) { + throw new IllegalArgumentException("Invalid connector channel: " + connectorChannel); + } + if (connectorHeight < 0) { + throw new IllegalArgumentException("Connector height cannot be negative: " + connectorHeight); + } + Objects.requireNonNull(variants, "variants"); + variants = List.copyOf(variants); + validateVariants(variants); + if (topology == SimpleStructureStudioTopology.EMPTY && !variants.isEmpty()) { + throw new IllegalArgumentException("Empty cells cannot contain variants"); + } + if (variants.isEmpty() && activeVariantIndex != -1) { + throw new IllegalArgumentException("A cell without variants must use activeVariantIndex -1"); + } + if (!variants.isEmpty() && (activeVariantIndex < 0 || activeVariantIndex >= variants.size())) { + throw new IllegalArgumentException("Active variant index is outside the variant list: " + activeVariantIndex); + } + } + + public static SimpleStructureStudioCell empty(int x, int z) { + return new SimpleStructureStudioCell( + x, + z, + SimpleStructureStudioTopology.EMPTY, + 0, + SimpleStructureStudioRotationPolicy.FIXED, + DEFAULT_CONNECTOR_CHANNEL, + 0, + List.of(), + -1 + ); + } + + public static SimpleStructureStudioCell create(int x, int z, SimpleStructureStudioTopology topology) { + Objects.requireNonNull(topology, "topology"); + if (topology == SimpleStructureStudioTopology.EMPTY) { + return empty(x, z); + } + return new SimpleStructureStudioCell( + x, + z, + topology, + 0, + SimpleStructureStudioRotationPolicy.QUARTER_TURNS, + DEFAULT_CONNECTOR_CHANNEL, + 0, + List.of(), + -1 + ); + } + + public boolean isEmpty() { + return topology == SimpleStructureStudioTopology.EMPTY; + } + + public int connectorMask() { + return topology.connectorMask(quarterTurns); + } + + public boolean connects(SimpleStructureStudioDirection direction) { + return topology.connects(direction, quarterTurns); + } + + public Optional activeVariant() { + if (activeVariantIndex < 0) { + return Optional.empty(); + } + return Optional.of(variants.get(activeVariantIndex)); + } + + public SimpleStructureStudioCell withTopology(SimpleStructureStudioTopology newTopology) { + Objects.requireNonNull(newTopology, "newTopology"); + if (newTopology == SimpleStructureStudioTopology.EMPTY) { + return empty(x, z); + } + if (isEmpty()) { + return create(x, z, newTopology); + } + return new SimpleStructureStudioCell( + x, + z, + newTopology, + quarterTurns, + rotationPolicy, + connectorChannel, + connectorHeight, + variants, + activeVariantIndex + ); + } + + public SimpleStructureStudioCell withQuarterTurns(int newQuarterTurns) { + return new SimpleStructureStudioCell( + x, + z, + topology, + newQuarterTurns, + rotationPolicy, + connectorChannel, + connectorHeight, + variants, + activeVariantIndex + ); + } + + public SimpleStructureStudioCell rotateClockwise() { + return withQuarterTurns(rotationPolicy.next(quarterTurns)); + } + + public SimpleStructureStudioCell rotateCounterClockwise() { + return withQuarterTurns(rotationPolicy.previous(quarterTurns)); + } + + public SimpleStructureStudioCell withRotationPolicy(SimpleStructureStudioRotationPolicy newPolicy) { + Objects.requireNonNull(newPolicy, "newPolicy"); + int newQuarterTurns = newPolicy.allows(quarterTurns) ? quarterTurns : 0; + return new SimpleStructureStudioCell( + x, + z, + topology, + newQuarterTurns, + newPolicy, + connectorChannel, + connectorHeight, + variants, + activeVariantIndex + ); + } + + public SimpleStructureStudioCell withConnector(String newChannel, int newHeight) { + return new SimpleStructureStudioCell( + x, + z, + topology, + quarterTurns, + rotationPolicy, + newChannel, + newHeight, + variants, + activeVariantIndex + ); + } + + public SimpleStructureStudioCell addVariant(SimpleStructureStudioVariant variant) { + Objects.requireNonNull(variant, "variant"); + List updatedVariants = new ArrayList<>(variants); + updatedVariants.add(variant); + int newActiveIndex = activeVariantIndex < 0 ? 0 : activeVariantIndex; + return new SimpleStructureStudioCell( + x, + z, + topology, + quarterTurns, + rotationPolicy, + connectorChannel, + connectorHeight, + updatedVariants, + newActiveIndex + ); + } + + public SimpleStructureStudioCell setVariantWeight(String variantId, int weight) { + int variantIndex = requireVariantIndex(variantId); + List updatedVariants = new ArrayList<>(variants); + updatedVariants.set(variantIndex, updatedVariants.get(variantIndex).withWeight(weight)); + return new SimpleStructureStudioCell( + x, + z, + topology, + quarterTurns, + rotationPolicy, + connectorChannel, + connectorHeight, + updatedVariants, + activeVariantIndex + ); + } + + public SimpleStructureStudioCell removeVariant(String variantId) { + int variantIndex = requireVariantIndex(variantId); + List updatedVariants = new ArrayList<>(variants); + updatedVariants.remove(variantIndex); + int newActiveIndex = activeVariantIndex; + if (updatedVariants.isEmpty()) { + newActiveIndex = -1; + } else if (variantIndex < activeVariantIndex) { + newActiveIndex--; + } else if (newActiveIndex >= updatedVariants.size()) { + newActiveIndex = updatedVariants.size() - 1; + } + return new SimpleStructureStudioCell( + x, + z, + topology, + quarterTurns, + rotationPolicy, + connectorChannel, + connectorHeight, + updatedVariants, + newActiveIndex + ); + } + + public SimpleStructureStudioCell selectVariant(String variantId) { + int variantIndex = requireVariantIndex(variantId); + return new SimpleStructureStudioCell( + x, + z, + topology, + quarterTurns, + rotationPolicy, + connectorChannel, + connectorHeight, + variants, + variantIndex + ); + } + + public SimpleStructureStudioCell cycleVariant(int offset) { + if (variants.isEmpty()) { + return this; + } + int newActiveIndex = Math.floorMod((long) activeVariantIndex + offset, variants.size()); + return new SimpleStructureStudioCell( + x, + z, + topology, + quarterTurns, + rotationPolicy, + connectorChannel, + connectorHeight, + variants, + newActiveIndex + ); + } + + private static void validateVariants(List variants) { + Set variantIds = new HashSet<>(); + for (SimpleStructureStudioVariant variant : variants) { + Objects.requireNonNull(variant, "variant"); + if (!variantIds.add(variant.id())) { + throw new IllegalArgumentException("Duplicate variant id: " + variant.id()); + } + } + } + + private int requireVariantIndex(String variantId) { + Objects.requireNonNull(variantId, "variantId"); + for (int i = 0; i < variants.size(); i++) { + if (variants.get(i).id().equals(variantId)) { + return i; + } + } + throw new IllegalArgumentException("Unknown variant id: " + variantId); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioCompiler.java b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioCompiler.java new file mode 100644 index 000000000..116a819b8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioCompiler.java @@ -0,0 +1,401 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.engine.framework.structure.StructureGraphCompilation; +import art.arcane.iris.engine.framework.structure.StructureGraphCompiler; +import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic; +import art.arcane.iris.engine.framework.structure.StructureGraphResolver; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.JigsawJoint; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +public final class SimpleStructureStudioCompiler { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private SimpleStructureStudioCompiler() { + } + + public static StructureResourceBundle compile( + SimpleStructureStudioDraft draft, + SimpleStructureStudioPublishConfig config, + Map resolvedVariants + ) throws IOException { + CompilationInput input = new CompilationInput(draft, config, resolvedVariants); + return new CompilationState(input).compile(); + } + + private static final class CompilationState { + private final SimpleStructureStudioDraft draft; + private final SimpleStructureStudioPublishConfig config; + private final Map resolvedVariants; + private final Map pools; + private final Map pieces; + private final Map objects; + private final List startEntries; + private final List mainEntries; + private final List terminalEntries; + private final String startPoolKey; + private final String mainPoolKey; + private final String terminalPoolKey; + + private CompilationState(CompilationInput input) { + draft = input.draft(); + config = input.config(); + resolvedVariants = input.resolvedVariants(); + pools = new LinkedHashMap<>(); + pieces = new LinkedHashMap<>(); + objects = new LinkedHashMap<>(); + startEntries = new ArrayList<>(); + mainEntries = new ArrayList<>(); + terminalEntries = new ArrayList<>(); + startPoolKey = config.resourceKey() + "/start"; + mainPoolKey = config.resourceKey() + "/main"; + terminalPoolKey = config.resourceKey() + "/terminal"; + } + + private StructureResourceBundle compile() throws IOException { + validateDraft(); + validateResolvedVariants(); + compilePieces(); + compilePools(); + IrisStructure structure = compileStructure(); + validateGraph(structure); + return bundle(structure); + } + + private void validateDraft() { + if (!draft.hasContent()) { + throw new IllegalStateException("A Studio structure must contain authored tiles"); + } + + TreeSet startChannels = new TreeSet<>(); + TreeSet mainChannels = new TreeSet<>(); + TreeSet terminalChannels = new TreeSet<>(); + for (SimpleStructureStudioCell cell : draft.cells()) { + if (cell.rotationPolicy() == SimpleStructureStudioRotationPolicy.HALF_TURNS) { + throw new IllegalStateException( + "HALF_TURNS cannot be represented by the Iris jigsaw rotatable contract at cell " + + cell.x() + ", " + cell.z() + ); + } + if (cell.variants().isEmpty()) { + throw new IllegalStateException( + "Studio cell " + cell.x() + ", " + cell.z() + " has no captured variants" + ); + } + channelsFor(cell.topology(), startChannels, mainChannels, terminalChannels).add( + cell.connectorChannel() + ); + } + + requireChannels("START", startChannels); + requireChannels("main", mainChannels); + requireChannels("TERMINAL", terminalChannels); + if (!startChannels.equals(mainChannels) || !startChannels.equals(terminalChannels)) { + throw new IllegalStateException( + "START, main, and TERMINAL tiles must cover the same connector channels: start=" + + startChannels + ", main=" + mainChannels + ", terminal=" + terminalChannels + ); + } + } + + private Set channelsFor( + SimpleStructureStudioTopology topology, + Set startChannels, + Set mainChannels, + Set terminalChannels + ) { + return switch (topology) { + case START -> startChannels; + case TERMINAL -> terminalChannels; + case EMPTY -> throw new IllegalStateException("Drafts cannot publish empty cells"); + default -> mainChannels; + }; + } + + private void requireChannels(String category, Set channels) { + if (channels.isEmpty()) { + throw new IllegalStateException("A Studio structure must contain at least one " + category + " tile"); + } + } + + private void validateResolvedVariants() { + LinkedHashSet expected = new LinkedHashSet<>(); + for (SimpleStructureStudioCell cell : draft.cells()) { + for (SimpleStructureStudioVariant variant : cell.variants()) { + expected.add(SimpleStructureStudioVariantKey.of(cell, variant)); + } + } + + for (Map.Entry entry : resolvedVariants.entrySet()) { + if (entry.getKey() == null || entry.getValue() == null) { + throw new IllegalArgumentException("Resolved Studio variants cannot contain null keys or objects"); + } + } + LinkedHashSet actual = new LinkedHashSet<>(resolvedVariants.keySet()); + if (!actual.equals(expected)) { + LinkedHashSet missing = new LinkedHashSet<>(expected); + missing.removeAll(actual); + LinkedHashSet unexpected = new LinkedHashSet<>(actual); + unexpected.removeAll(expected); + throw new IllegalStateException( + "Resolved Studio variants do not match the draft: missing=" + describe(missing) + + ", unexpected=" + describe(unexpected) + ); + } + } + + private List describe(Set keys) { + TreeSet descriptions = new TreeSet<>(); + for (SimpleStructureStudioVariantKey key : keys) { + descriptions.add(key.cellX() + "," + key.cellZ() + ":" + key.variantId()); + } + return List.copyOf(descriptions); + } + + private void compilePieces() { + for (SimpleStructureStudioCell cell : draft.cells()) { + for (SimpleStructureStudioVariant variant : cell.variants()) { + SimpleStructureStudioVariantKey variantKey = SimpleStructureStudioVariantKey.of(cell, variant); + IrisObject object = resolvedVariants.get(variantKey); + validateObject(variantKey, object); + String resourceKey = variantResourceKey(cell, variant); + IrisJigsawPiece piece = new IrisJigsawPiece() + .setObject(resourceKey) + .setConnectors(connectors(cell, object)) + .setRotatable(cell.rotationPolicy() == SimpleStructureStudioRotationPolicy.QUARTER_TURNS); + objects.put(resourceKey, object); + pieces.put(resourceKey, piece); + entriesFor(cell.topology()).add(new PieceEntry(resourceKey, variant.weight())); + } + } + } + + private void validateObject(SimpleStructureStudioVariantKey key, IrisObject object) { + SimpleStructureStudioLayout layout = draft.layout(); + if (object.getW() != layout.cellWidth() + || object.getH() != layout.captureHeight() + || object.getD() != layout.cellDepth()) { + throw new IllegalStateException( + "Resolved object " + key.cellX() + "," + key.cellZ() + ":" + key.variantId() + + " has dimensions " + object.getW() + "x" + object.getH() + "x" + object.getD() + + "; expected " + layout.cellWidth() + "x" + layout.captureHeight() + "x" + + layout.cellDepth() + ); + } + } + + private String variantResourceKey( + SimpleStructureStudioCell cell, + SimpleStructureStudioVariant variant + ) { + return config.resourceKey() + "/cells/" + cell.x() + "-" + cell.z() + "/" + variant.id(); + } + + private KList connectors(SimpleStructureStudioCell cell, IrisObject object) { + KList connectors = new KList<>(); + for (SimpleStructureStudioDirection direction : SimpleStructureStudioDirection.values()) { + if (!cell.connects(direction)) { + continue; + } + connectors.add(new IrisJigsawConnector() + .setPosition(connectorPosition(direction, cell.connectorHeight(), object)) + .setDirection(irisDirection(direction)) + .setPool(mainPoolKey) + .setName(cell.connectorChannel()) + .setTargetName(cell.connectorChannel()) + .setJoint(JigsawJoint.ALIGNED)); + } + return connectors; + } + + private IrisPosition connectorPosition( + SimpleStructureStudioDirection direction, + int height, + IrisObject object + ) { + return switch (direction) { + case NORTH -> new IrisPosition(object.getW() / 2, height, 0); + case EAST -> new IrisPosition(object.getW() - 1, height, object.getD() / 2); + case SOUTH -> new IrisPosition(object.getW() / 2, height, object.getD() - 1); + case WEST -> new IrisPosition(0, height, object.getD() / 2); + }; + } + + private IrisDirection irisDirection(SimpleStructureStudioDirection direction) { + return switch (direction) { + case NORTH -> IrisDirection.NORTH_NEGATIVE_Z; + case EAST -> IrisDirection.EAST_POSITIVE_X; + case SOUTH -> IrisDirection.SOUTH_POSITIVE_Z; + case WEST -> IrisDirection.WEST_NEGATIVE_X; + }; + } + + private List entriesFor(SimpleStructureStudioTopology topology) { + return switch (topology) { + case START -> startEntries; + case TERMINAL -> terminalEntries; + case EMPTY -> throw new IllegalStateException("Drafts cannot publish empty cells"); + default -> mainEntries; + }; + } + + private void compilePools() { + pools.put(startPoolKey, pool(startEntries, "")); + pools.put(mainPoolKey, pool(mainEntries, terminalPoolKey)); + pools.put(terminalPoolKey, pool(terminalEntries, "")); + } + + private IrisJigsawPool pool(List entries, String fallback) { + KList weightedPieces = new KList<>(); + for (PieceEntry entry : entries) { + weightedPieces.add(new IrisJigsawPieceEntry(entry.pieceKey(), entry.weight())); + } + return new IrisJigsawPool().setPieces(weightedPieces).setFallback(fallback); + } + + private IrisStructure compileStructure() { + IrisStructure structure = new IrisStructure() + .setStartPool(startPoolKey) + .setMaxDepth(config.maxDepth()) + .setMaxSizeChunks(config.maxSizeChunks()) + .setPlaceMode(config.placeMode()); + structure.setLoadKey(config.resourceKey()); + return structure; + } + + private void validateGraph(IrisStructure structure) { + StructureGraphCompilation compilation = StructureGraphCompiler.compile( + structure, + new BundleGraphResolver(pools, pieces, objects) + ); + if (compilation.isAssemblyViable() && compilation.getDiagnostics().isEmpty()) { + return; + } + + StringBuilder failure = new StringBuilder("Studio structure graph is not safely assemblable"); + for (StructureGraphDiagnostic diagnostic : compilation.getDiagnostics()) { + failure.append("; ").append(diagnostic.code()).append(": ").append(diagnostic.message()); + } + if (!compilation.isAssemblyViable() && compilation.getDiagnostics().isEmpty()) { + failure.append("; deterministic assembly samples did not complete"); + } + throw new IllegalStateException(failure.toString()); + } + + private StructureResourceBundle bundle(IrisStructure structure) throws IOException { + StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(config.structureKey()) + .source(StructureSource.of(StructureSource.Kind.IRIS, config.structureKey())) + .backend(StructureBackend.IRIS_ASSEMBLY) + .capability(StructureCapability.BLOCKS) + .capability(StructureCapability.BLOCK_ENTITIES) + .capability(StructureCapability.CONNECTORS) + .capability(StructureCapability.IRIS_PLACEMENT) + .textResource("structures/" + config.resourceKey() + ".json", GSON.toJson(structure)); + + for (Map.Entry entry : objects.entrySet()) { + bundle.resource("objects/" + entry.getKey() + ".iob", serialize(entry.getValue())); + } + for (Map.Entry entry : pieces.entrySet()) { + bundle.textResource("jigsaw-pieces/" + entry.getKey() + ".json", GSON.toJson(entry.getValue())); + } + for (Map.Entry entry : pools.entrySet()) { + bundle.textResource("jigsaw-pools/" + entry.getKey() + ".json", GSON.toJson(entry.getValue())); + } + return bundle.build(); + } + + private byte[] serialize(IrisObject object) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + object.write(output); + return output.toByteArray(); + } + } + + private record CompilationInput( + SimpleStructureStudioDraft draft, + SimpleStructureStudioPublishConfig config, + Map resolvedVariants + ) { + private CompilationInput { + Objects.requireNonNull(draft, "draft"); + Objects.requireNonNull(config, "config"); + Objects.requireNonNull(resolvedVariants, "resolvedVariants"); + } + } + + private record PieceEntry(String pieceKey, int weight) { + } + + private static final class BundleGraphResolver implements StructureGraphResolver { + private final Map pools; + private final Map pieces; + private final Map objects; + + private BundleGraphResolver( + Map pools, + Map pieces, + Map objects + ) { + this.pools = pools; + this.pieces = pieces; + this.objects = objects; + } + + @Override + public IrisJigsawPool loadPool(String key) { + return pools.get(key); + } + + @Override + public IrisJigsawPiece loadPiece(String key) { + return pieces.get(key); + } + + @Override + public IrisObject loadObject(String key) { + return objects.get(key); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioDirection.java b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioDirection.java new file mode 100644 index 000000000..642b736d4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioDirection.java @@ -0,0 +1,74 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +public enum SimpleStructureStudioDirection { + NORTH(1, 0, -1), + EAST(2, 1, 0), + SOUTH(4, 0, 1), + WEST(8, -1, 0); + + public static final int ALL_MASK = 15; + + private static final SimpleStructureStudioDirection[] ORDERED = values(); + + private final int mask; + private final int offsetX; + private final int offsetZ; + + SimpleStructureStudioDirection(int mask, int offsetX, int offsetZ) { + this.mask = mask; + this.offsetX = offsetX; + this.offsetZ = offsetZ; + } + + public int mask() { + return mask; + } + + public int offsetX() { + return offsetX; + } + + public int offsetZ() { + return offsetZ; + } + + public SimpleStructureStudioDirection rotateClockwise(int quarterTurns) { + int rotatedIndex = Math.floorMod((long) ordinal() + quarterTurns, ORDERED.length); + return ORDERED[rotatedIndex]; + } + + public static int rotateMask(int connectorMask, int quarterTurns) { + if ((connectorMask & ~ALL_MASK) != 0) { + throw new IllegalArgumentException("Connector mask uses unsupported direction bits: " + connectorMask); + } + int normalizedTurns = Math.floorMod(quarterTurns, ORDERED.length); + if (normalizedTurns == 0 || connectorMask == 0) { + return connectorMask; + } + int rotatedMask = 0; + for (SimpleStructureStudioDirection direction : ORDERED) { + if ((connectorMask & direction.mask) != 0) { + rotatedMask |= direction.rotateClockwise(normalizedTurns).mask; + } + } + return rotatedMask; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioDraft.java b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioDraft.java new file mode 100644 index 000000000..17ecf6206 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioDraft.java @@ -0,0 +1,140 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public record SimpleStructureStudioDraft( + SimpleStructureStudioLayout layout, + long previewSeed, + List cells +) { + private static final Comparator CELL_ORDER = Comparator + .comparingInt(SimpleStructureStudioCell::z) + .thenComparingInt(SimpleStructureStudioCell::x); + + public SimpleStructureStudioDraft { + Objects.requireNonNull(layout, "layout"); + Objects.requireNonNull(cells, "cells"); + List orderedCells = new ArrayList<>(cells); + orderedCells.sort(CELL_ORDER); + validateCells(layout, orderedCells); + cells = List.copyOf(orderedCells); + } + + public static SimpleStructureStudioDraft empty(SimpleStructureStudioLayout layout, long previewSeed) { + return new SimpleStructureStudioDraft(layout, previewSeed, List.of()); + } + + public boolean hasContent() { + return !cells.isEmpty(); + } + + public Optional cellAt(int x, int z) { + requirePosition(x, z); + for (SimpleStructureStudioCell cell : cells) { + if (cell.x() == x && cell.z() == z) { + return Optional.of(cell); + } + } + return Optional.empty(); + } + + public SimpleStructureStudioCell cellOrEmpty(int x, int z) { + return cellAt(x, z).orElseGet(() -> SimpleStructureStudioCell.empty(x, z)); + } + + public SimpleStructureStudioDraft withLayout(SimpleStructureStudioLayout newLayout) { + Objects.requireNonNull(newLayout, "newLayout"); + if (hasContent() && !layout.equals(newLayout)) { + throw new IllegalStateException("The studio layout cannot be resized after content has been added"); + } + return new SimpleStructureStudioDraft(newLayout, previewSeed, cells); + } + + public SimpleStructureStudioDraft withPreviewSeed(long newPreviewSeed) { + return new SimpleStructureStudioDraft(layout, newPreviewSeed, cells); + } + + public SimpleStructureStudioDraft withCell(SimpleStructureStudioCell updatedCell) { + Objects.requireNonNull(updatedCell, "updatedCell"); + requirePosition(updatedCell.x(), updatedCell.z()); + List updatedCells = new ArrayList<>(cells.size() + 1); + for (SimpleStructureStudioCell cell : cells) { + if (cell.x() != updatedCell.x() || cell.z() != updatedCell.z()) { + updatedCells.add(cell); + } + } + if (!updatedCell.isEmpty()) { + updatedCells.add(updatedCell); + } + return new SimpleStructureStudioDraft(layout, previewSeed, updatedCells); + } + + public SimpleStructureStudioDraft withoutCell(int x, int z) { + requirePosition(x, z); + List updatedCells = new ArrayList<>(cells.size()); + for (SimpleStructureStudioCell cell : cells) { + if (cell.x() != x || cell.z() != z) { + updatedCells.add(cell); + } + } + return new SimpleStructureStudioDraft(layout, previewSeed, updatedCells); + } + + private static void validateCells( + SimpleStructureStudioLayout layout, + List cells + ) { + Set positions = new HashSet<>(); + for (SimpleStructureStudioCell cell : cells) { + Objects.requireNonNull(cell, "cell"); + if (cell.isEmpty()) { + throw new IllegalArgumentException("Drafts store only populated cells"); + } + if (!layout.contains(cell.x(), cell.z())) { + throw new IllegalArgumentException( + "Cell is outside the studio grid: " + cell.x() + ", " + cell.z() + ); + } + if (cell.connectorHeight() >= layout.captureHeight()) { + throw new IllegalArgumentException( + "Connector height " + cell.connectorHeight() + + " is outside capture height " + layout.captureHeight() + ); + } + long position = ((long) cell.x() << 32) ^ (cell.z() & 0xffffffffL); + if (!positions.add(position)) { + throw new IllegalArgumentException("Duplicate studio cell: " + cell.x() + ", " + cell.z()); + } + } + } + + private void requirePosition(int x, int z) { + if (!layout.contains(x, z)) { + throw new IndexOutOfBoundsException("Cell is outside the studio grid: " + x + ", " + z); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioLayout.java b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioLayout.java new file mode 100644 index 000000000..99e0debdf --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioLayout.java @@ -0,0 +1,68 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +public record SimpleStructureStudioLayout( + int gridWidth, + int gridDepth, + int cellWidth, + int cellDepth, + int captureHeight +) { + public SimpleStructureStudioLayout { + requirePositive("gridWidth", gridWidth); + requirePositive("gridDepth", gridDepth); + requirePositive("cellWidth", cellWidth); + requirePositive("cellDepth", cellDepth); + requirePositive("captureHeight", captureHeight); + multiply("grid cell count", gridWidth, gridDepth); + multiply("studio width", gridWidth, cellWidth); + multiply("studio depth", gridDepth, cellDepth); + } + + public int cellCount() { + return gridWidth * gridDepth; + } + + public int studioWidth() { + return gridWidth * cellWidth; + } + + public int studioDepth() { + return gridDepth * cellDepth; + } + + public boolean contains(int x, int z) { + return x >= 0 && x < gridWidth && z >= 0 && z < gridDepth; + } + + private static void requirePositive(String name, int value) { + if (value <= 0) { + throw new IllegalArgumentException(name + " must be greater than zero: " + value); + } + } + + private static void multiply(String name, int first, int second) { + try { + Math.multiplyExact(first, second); + } catch (ArithmeticException e) { + throw new IllegalArgumentException(name + " exceeds the supported integer range", e); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioPublishConfig.java b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioPublishConfig.java new file mode 100644 index 000000000..a90681cfa --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioPublishConfig.java @@ -0,0 +1,66 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.engine.object.ObjectPlaceMode; + +import java.util.Objects; + +public record SimpleStructureStudioPublishConfig( + StructureKey structureKey, + String resourceKey, + int maxDepth, + int maxSizeChunks, + ObjectPlaceMode placeMode +) { + public SimpleStructureStudioPublishConfig { + Objects.requireNonNull(structureKey, "structureKey"); + Objects.requireNonNull(resourceKey, "resourceKey"); + Objects.requireNonNull(placeMode, "placeMode"); + if (resourceKey.isBlank()) { + throw new IllegalArgumentException("resourceKey cannot be blank"); + } + StructureResourceBundle.validateRelativePath("structures/" + resourceKey + ".json"); + if (!structureKey.path().equals(resourceKey)) { + throw new IllegalArgumentException("structureKey path must match resourceKey: " + + structureKey.path() + " != " + resourceKey); + } + if (maxDepth < 1 || maxDepth > 30) { + throw new IllegalArgumentException("maxDepth must be between 1 and 30: " + maxDepth); + } + if (maxSizeChunks < 1 || maxSizeChunks > 32) { + throw new IllegalArgumentException("maxSizeChunks must be between 1 and 32: " + maxSizeChunks); + } + } + + public static SimpleStructureStudioPublishConfig defaults( + StructureKey structureKey, + String resourceKey + ) { + return new SimpleStructureStudioPublishConfig( + structureKey, + resourceKey, + 7, + 8, + ObjectPlaceMode.STRUCTURE_PIECE + ); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioRepository.java b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioRepository.java new file mode 100644 index 000000000..570aa8b38 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioRepository.java @@ -0,0 +1,113 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +import art.arcane.iris.core.structure.authoring.StructureKey; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; + +public final class SimpleStructureStudioRepository { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final ConcurrentMap ROOT_LOCKS = new ConcurrentHashMap<>(); + + private final Path packRoot; + private final Path draftsRoot; + private final ReentrantLock rootLock; + + public SimpleStructureStudioRepository(Path packRoot) { + this.packRoot = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize(); + draftsRoot = this.packRoot.resolve(".iris/structure-studio").normalize(); + rootLock = ROOT_LOCKS.computeIfAbsent(this.packRoot, ignored -> new ReentrantLock()); + } + + public Path packRoot() { + return packRoot; + } + + public Path draftPath(StructureKey key) { + StructureKey activeKey = Objects.requireNonNull(key, "key"); + Path path = draftsRoot.resolve(activeKey.namespace()).resolve(activeKey.path() + ".json").normalize(); + if (!path.startsWith(draftsRoot)) { + throw new IllegalArgumentException("Studio draft key escapes the pack: " + key); + } + return path; + } + + public Optional load(StructureKey key) throws IOException { + Path target = draftPath(key); + rootLock.lock(); + try { + if (!Files.isRegularFile(target)) { + return Optional.empty(); + } + try { + SimpleStructureStudioDraft draft = GSON.fromJson( + Files.readString(target, StandardCharsets.UTF_8), SimpleStructureStudioDraft.class); + if (draft == null) { + throw new IOException("Studio draft is empty: " + target); + } + return Optional.of(draft); + } catch (RuntimeException e) { + throw new IOException("Invalid Studio draft " + target + ": " + e.getMessage(), e); + } + } finally { + rootLock.unlock(); + } + } + + public void save(StructureKey key, SimpleStructureStudioDraft draft) throws IOException { + Path target = draftPath(key); + byte[] content = GSON.toJson(Objects.requireNonNull(draft, "draft")).getBytes(StandardCharsets.UTF_8); + rootLock.lock(); + Path staged = target.resolveSibling(target.getFileName() + "." + UUID.randomUUID() + ".tmp"); + try { + Files.createDirectories(target.getParent()); + Files.write(staged, content, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + moveReplace(staged, target); + } finally { + try { + Files.deleteIfExists(staged); + } finally { + rootLock.unlock(); + } + } + } + + private void moveReplace(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioRotationPolicy.java b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioRotationPolicy.java new file mode 100644 index 000000000..1c26fac18 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioRotationPolicy.java @@ -0,0 +1,52 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +public enum SimpleStructureStudioRotationPolicy { + FIXED, + HALF_TURNS, + QUARTER_TURNS; + + public boolean allows(int quarterTurns) { + int normalizedTurns = Math.floorMod(quarterTurns, 4); + return switch (this) { + case FIXED -> normalizedTurns == 0; + case HALF_TURNS -> normalizedTurns == 0 || normalizedTurns == 2; + case QUARTER_TURNS -> true; + }; + } + + public int next(int quarterTurns) { + int normalizedTurns = Math.floorMod(quarterTurns, 4); + return switch (this) { + case FIXED -> 0; + case HALF_TURNS -> Math.floorMod(normalizedTurns + 2, 4); + case QUARTER_TURNS -> Math.floorMod(normalizedTurns + 1, 4); + }; + } + + public int previous(int quarterTurns) { + int normalizedTurns = Math.floorMod(quarterTurns, 4); + return switch (this) { + case FIXED -> 0; + case HALF_TURNS -> Math.floorMod(normalizedTurns - 2, 4); + case QUARTER_TURNS -> Math.floorMod(normalizedTurns - 1, 4); + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioSession.java b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioSession.java new file mode 100644 index 000000000..b6be238ef --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioSession.java @@ -0,0 +1,233 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Objects; +import java.util.function.UnaryOperator; + +public final class SimpleStructureStudioSession { + public static final int DEFAULT_HISTORY_LIMIT = 64; + public static final int MAX_HISTORY_LIMIT = 256; + + private static final long PREVIEW_SEED_STEP = 0x9E3779B97F4A7C15L; + + private final int historyLimit; + private final Deque undoHistory; + private final Deque redoHistory; + private SimpleStructureStudioDraft draft; + private SimpleStructureStudioDraft savedDraft; + + private SimpleStructureStudioSession(SimpleStructureStudioDraft draft, int historyLimit) { + this.draft = Objects.requireNonNull(draft, "draft"); + if (historyLimit <= 0 || historyLimit > MAX_HISTORY_LIMIT) { + throw new IllegalArgumentException( + "History limit must be between 1 and " + MAX_HISTORY_LIMIT + ": " + historyLimit + ); + } + this.historyLimit = historyLimit; + undoHistory = new ArrayDeque<>(historyLimit); + redoHistory = new ArrayDeque<>(historyLimit); + savedDraft = draft; + } + + public static SimpleStructureStudioSession open(SimpleStructureStudioDraft draft, int historyLimit) { + return new SimpleStructureStudioSession(draft, historyLimit); + } + + public static SimpleStructureStudioSession createNew(SimpleStructureStudioDraft draft, int historyLimit) { + SimpleStructureStudioSession session = new SimpleStructureStudioSession(draft, historyLimit); + session.savedDraft = null; + return session; + } + + public synchronized SimpleStructureStudioDraft draft() { + return draft; + } + + public int historyLimit() { + return historyLimit; + } + + public synchronized boolean isDirty() { + return savedDraft == null || !draft.equals(savedDraft); + } + + public synchronized boolean canUndo() { + return !undoHistory.isEmpty(); + } + + public synchronized boolean canRedo() { + return !redoHistory.isEmpty(); + } + + public synchronized int undoDepth() { + return undoHistory.size(); + } + + public synchronized int redoDepth() { + return redoHistory.size(); + } + + public synchronized void markSaved() { + savedDraft = draft; + } + + public synchronized boolean resize(SimpleStructureStudioLayout newLayout) { + Objects.requireNonNull(newLayout, "newLayout"); + if (draft.layout().equals(newLayout)) { + return false; + } + if (draft.hasContent()) { + throw new IllegalStateException("The studio layout cannot be resized after content has been added"); + } + return applyDraft(draft.withLayout(newLayout)); + } + + public synchronized boolean replaceCell(SimpleStructureStudioCell cell) { + return applyDraft(draft.withCell(Objects.requireNonNull(cell, "cell"))); + } + + public synchronized boolean clearCell(int x, int z) { + return applyDraft(draft.withoutCell(x, z)); + } + + public synchronized boolean setTopology(int x, int z, SimpleStructureStudioTopology topology) { + Objects.requireNonNull(topology, "topology"); + if (topology == SimpleStructureStudioTopology.EMPTY) { + return clearCell(x, z); + } + SimpleStructureStudioCell cell = draft.cellOrEmpty(x, z).withTopology(topology); + return applyDraft(draft.withCell(cell)); + } + + public synchronized boolean setQuarterTurns(int x, int z, int quarterTurns) { + return updatePopulatedCell(x, z, cell -> cell.withQuarterTurns(quarterTurns)); + } + + public synchronized boolean rotateClockwise(int x, int z) { + return updatePopulatedCell(x, z, SimpleStructureStudioCell::rotateClockwise); + } + + public synchronized boolean rotateCounterClockwise(int x, int z) { + return updatePopulatedCell(x, z, SimpleStructureStudioCell::rotateCounterClockwise); + } + + public synchronized boolean setRotationPolicy( + int x, + int z, + SimpleStructureStudioRotationPolicy rotationPolicy + ) { + Objects.requireNonNull(rotationPolicy, "rotationPolicy"); + return updatePopulatedCell(x, z, cell -> cell.withRotationPolicy(rotationPolicy)); + } + + public synchronized boolean setConnector(int x, int z, String channel, int height) { + return updatePopulatedCell(x, z, cell -> cell.withConnector(channel, height)); + } + + public synchronized boolean addVariant(int x, int z, SimpleStructureStudioVariant variant) { + Objects.requireNonNull(variant, "variant"); + return updatePopulatedCell(x, z, cell -> cell.addVariant(variant)); + } + + public synchronized boolean setVariantWeight(int x, int z, String variantId, int weight) { + return updatePopulatedCell(x, z, cell -> cell.setVariantWeight(variantId, weight)); + } + + public synchronized boolean removeVariant(int x, int z, String variantId) { + return updatePopulatedCell(x, z, cell -> cell.removeVariant(variantId)); + } + + public synchronized boolean selectVariant(int x, int z, String variantId) { + return updatePopulatedCell(x, z, cell -> cell.selectVariant(variantId)); + } + + public synchronized boolean cycleVariant(int x, int z, int offset) { + return updatePopulatedCell(x, z, cell -> cell.cycleVariant(offset)); + } + + public synchronized boolean setPreviewSeed(long previewSeed) { + return applyDraft(draft.withPreviewSeed(previewSeed)); + } + + public synchronized long advancePreviewSeed() { + long nextSeed = nextPreviewSeed(draft.previewSeed()); + applyDraft(draft.withPreviewSeed(nextSeed)); + return nextSeed; + } + + public synchronized boolean undo() { + if (undoHistory.isEmpty()) { + return false; + } + redoHistory.addLast(draft); + trimHistory(redoHistory); + draft = undoHistory.removeLast(); + return true; + } + + public synchronized boolean redo() { + if (redoHistory.isEmpty()) { + return false; + } + undoHistory.addLast(draft); + trimHistory(undoHistory); + draft = redoHistory.removeLast(); + return true; + } + + public static long nextPreviewSeed(long previewSeed) { + return previewSeed + PREVIEW_SEED_STEP; + } + + private boolean updatePopulatedCell( + int x, + int z, + UnaryOperator update + ) { + SimpleStructureStudioCell cell = draft.cellAt(x, z).orElseThrow( + () -> new IllegalStateException("Studio cell is empty: " + x + ", " + z) + ); + SimpleStructureStudioCell updatedCell = Objects.requireNonNull(update.apply(cell), "updatedCell"); + if (updatedCell.x() != x || updatedCell.z() != z) { + throw new IllegalArgumentException("Cell updates cannot change the cell position"); + } + return applyDraft(draft.withCell(updatedCell)); + } + + private boolean applyDraft(SimpleStructureStudioDraft updatedDraft) { + Objects.requireNonNull(updatedDraft, "updatedDraft"); + if (draft.equals(updatedDraft)) { + return false; + } + undoHistory.addLast(draft); + trimHistory(undoHistory); + draft = updatedDraft; + redoHistory.clear(); + return true; + } + + private void trimHistory(Deque history) { + while (history.size() > historyLimit) { + history.removeFirst(); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioTopology.java b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioTopology.java new file mode 100644 index 000000000..b62de694a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioTopology.java @@ -0,0 +1,54 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +public enum SimpleStructureStudioTopology { + EMPTY(0), + END(SimpleStructureStudioDirection.NORTH.mask()), + STRAIGHT(SimpleStructureStudioDirection.NORTH.mask() | SimpleStructureStudioDirection.SOUTH.mask()), + CORNER(SimpleStructureStudioDirection.NORTH.mask() | SimpleStructureStudioDirection.EAST.mask()), + T(SimpleStructureStudioDirection.NORTH.mask() + | SimpleStructureStudioDirection.EAST.mask() + | SimpleStructureStudioDirection.WEST.mask()), + CROSS(SimpleStructureStudioDirection.ALL_MASK), + START(SimpleStructureStudioDirection.NORTH.mask()), + TERMINAL(SimpleStructureStudioDirection.NORTH.mask()); + + private final int baseConnectorMask; + + SimpleStructureStudioTopology(int baseConnectorMask) { + this.baseConnectorMask = baseConnectorMask; + } + + public int baseConnectorMask() { + return baseConnectorMask; + } + + public int connectorMask(int quarterTurns) { + return SimpleStructureStudioDirection.rotateMask(baseConnectorMask, quarterTurns); + } + + public int connectorCount() { + return Integer.bitCount(baseConnectorMask); + } + + public boolean connects(SimpleStructureStudioDirection direction, int quarterTurns) { + return (connectorMask(quarterTurns) & direction.mask()) != 0; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioVariant.java b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioVariant.java new file mode 100644 index 000000000..765d125ee --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioVariant.java @@ -0,0 +1,43 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; + +import java.util.Objects; +import java.util.regex.Pattern; + +public record SimpleStructureStudioVariant(String id, int weight) { + private static final Pattern ID_PATTERN = Pattern.compile("[a-z0-9._-]+(?:/[a-z0-9._-]+)*"); + + public SimpleStructureStudioVariant { + Objects.requireNonNull(id, "id"); + if (!ID_PATTERN.matcher(id).matches()) { + throw new IllegalArgumentException("Variant id must be a portable lowercase resource path: " + id); + } + StructureResourceBundle.validateRelativePath(id); + if (weight <= 0) { + throw new IllegalArgumentException("Variant weight must be greater than zero: " + weight); + } + } + + public SimpleStructureStudioVariant withWeight(int newWeight) { + return new SimpleStructureStudioVariant(id, newWeight); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioVariantKey.java b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioVariantKey.java new file mode 100644 index 000000000..841f7dde3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioVariantKey.java @@ -0,0 +1,42 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +import java.util.Objects; + +public record SimpleStructureStudioVariantKey(int cellX, int cellZ, String variantId) { + public SimpleStructureStudioVariantKey { + if (cellX < 0 || cellZ < 0) { + throw new IllegalArgumentException("Variant cell coordinates cannot be negative: " + cellX + ", " + cellZ); + } + Objects.requireNonNull(variantId, "variantId"); + if (variantId.isBlank()) { + throw new IllegalArgumentException("Variant id cannot be blank"); + } + } + + public static SimpleStructureStudioVariantKey of( + SimpleStructureStudioCell cell, + SimpleStructureStudioVariant variant + ) { + Objects.requireNonNull(cell, "cell"); + Objects.requireNonNull(variant, "variant"); + return new SimpleStructureStudioVariantKey(cell.x(), cell.z(), variant.id()); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/tools/TreePlausibilizeBatch.java b/core/src/main/java/art/arcane/iris/core/tools/TreePlausibilizeBatch.java new file mode 100644 index 000000000..ba2b34b3a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/tools/TreePlausibilizeBatch.java @@ -0,0 +1,170 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.tools; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.spi.IrisLogging; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; + +public final class TreePlausibilizeBatch { + private TreePlausibilizeBatch() { + } + + public record Target(String key, File file) { + } + + public static List resolve(String target, IrisData data) { + List out = new ArrayList<>(); + if (target == null || target.isEmpty()) { + return out; + } + + File direct = new File(target); + if (direct.isFile() && target.toLowerCase(Locale.ROOT).endsWith(".iob")) { + out.add(new Target(direct.getName().replaceAll("\\.iob$", ""), direct)); + return out; + } + if (direct.isDirectory()) { + walkIob(direct, direct, out); + return out; + } + + if (data != null) { + ResourceLoader loader = data.getObjectLoader(); + if (!target.endsWith("/") && loader.findFile(target) != null) { + out.add(new Target(target, null)); + return out; + } + String prefix = target.endsWith("/") ? target : target + "/"; + for (String k : loader.getPossibleKeys()) { + if (k.startsWith(prefix)) { + out.add(new Target(k, null)); + } + } + } + return out; + } + + public static void walkIob(File root, File keyRoot, List out) { + File[] kids = root.listFiles(); + if (kids == null) { + return; + } + for (File f : kids) { + if (f.isDirectory()) { + walkIob(f, keyRoot, out); + } else if (f.getName().toLowerCase(Locale.ROOT).endsWith(".iob")) { + String rel = keyRoot.toPath().relativize(f.toPath()).toString() + .replace(File.separatorChar, '/') + .replaceAll("\\.iob$", ""); + out.add(new Target(rel, f)); + } + } + } + + public static void run(List targets, boolean dryRun, int reach, IrisData nearest, Consumer out) { + int processed = 0; + int changed = 0; + int skipped = 0; + int failed = 0; + long totalWood = 0L; + long totalBranches = 0L; + long totalConverted = 0L; + long totalRewritten = 0L; + long totalPinned = 0L; + long totalUnreachableBefore = 0L; + long totalUnreachableAfter = 0L; + + int progressStep = Math.max(1, targets.size() / 20); + int index = 0; + + for (Target t : targets) { + index++; + try { + IrisObject o = load(t, nearest); + if (o == null) { + out.accept("skip " + t.key() + ": failed to load"); + skipped++; + continue; + } + + long seed = TreePlausibilizer.seedOf(t.key()); + TreePlausibilizer.Result r = dryRun + ? TreePlausibilizer.analyze(o, seed, reach) + : TreePlausibilizer.apply(o, seed, reach); + + if (!dryRun && r.mutated()) { + File dest = o.getLoadFile() != null ? o.getLoadFile() : t.file(); + if (dest != null) { + o.write(dest); + changed++; + } + } + + processed++; + totalWood += r.woodPlaced(); + totalBranches += r.branchesGrown(); + totalConverted += r.leavesConvertedToWood(); + totalRewritten += r.distancesRewritten(); + totalPinned += r.leavesPinnedPersistent(); + totalUnreachableBefore += r.unreachableBefore(); + totalUnreachableAfter += r.unreachableAfter(); + + if (r.mutated() || targets.size() == 1) { + out.accept(t.key() + ": +" + r.woodPlaced() + " wood (" + r.branchesGrown() + " branches), " + + r.leavesConvertedToWood() + " leaves->wood, ~" + r.distancesRewritten() + " distances" + + (r.leavesPinnedPersistent() > 0 ? ", !" + r.leavesPinnedPersistent() + " pinned" : "")); + } + + if (targets.size() > 1 && index % progressStep == 0) { + out.accept("[" + index + "/" + targets.size() + "]"); + } + } catch (Throwable e) { + out.accept("fail " + t.key() + ": " + e.getClass().getSimpleName() + ": " + e.getMessage()); + IrisLogging.reportError(e); + failed++; + } + } + + out.accept("Done: " + processed + " processed, " + changed + " changed, " + + skipped + " skipped, " + failed + " failed" + + (dryRun ? " (dry run, nothing written)" : "")); + out.accept("Totals: +" + totalWood + " wood (" + totalBranches + " branches), " + + totalConverted + " leaves->wood, ~" + totalRewritten + " distances, !" + + totalPinned + " pinned, unreachable " + totalUnreachableBefore + " -> " + totalUnreachableAfter); + } + + private static IrisObject load(Target t, IrisData nearest) throws IOException { + if (t.file() != null) { + IrisObject o = new IrisObject(); + o.read(t.file()); + o.setLoadFile(t.file()); + return o; + } + return IrisData.loadAnyObject(t.key(), nearest); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/tools/TreePlausibilizer.java b/core/src/main/java/art/arcane/iris/core/tools/TreePlausibilizer.java index c8ff25d77..4debad381 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/TreePlausibilizer.java +++ b/core/src/main/java/art/arcane/iris/core/tools/TreePlausibilizer.java @@ -18,21 +18,16 @@ package art.arcane.iris.core.tools; -import art.arcane.iris.platform.bukkit.BukkitBlockResolution; - import art.arcane.iris.engine.object.IrisObject; -import art.arcane.iris.util.common.data.VectorMap; -import org.bukkit.Axis; -import org.bukkit.Tag; -import art.arcane.iris.platform.bukkit.BukkitBlockState; +import art.arcane.iris.engine.object.IrisProceduralBlocks; import art.arcane.iris.spi.PlatformBlockState; -import org.bukkit.block.data.BlockData; -import org.bukkit.block.data.Orientable; -import org.bukkit.block.data.type.Leaves; +import art.arcane.iris.util.common.data.VectorMap; import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.volmlib.util.math.RNG; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Collections; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; @@ -42,532 +37,307 @@ import java.util.Set; public final class TreePlausibilizer { public static final int MAX_DISTANCE = 6; - public static final int DEFAULT_SHELL_RADIUS = 2; + public static final int DEFAULT_REACH = 12; + public static final int STOP_SHORT = 3; + private static final int MAX_TENDRILS_PER_CLUSTER = 24; + private static final int MAX_STALLS_PER_CLUSTER = 2; + private static final int SCATTER_CLUSTER_LIMIT = 256; + private static final double MAX_HELIX_AMPLITUDE = 1.6D; + private static final long GOLDEN_GAMMA = 0x9E3779B97F4A7C15L; private static final int[][] NEIGHBORS = { {1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1} }; - private static final BlockData FALLBACK_LOG = BukkitBlockResolution.get("minecraft:oak_log[axis=y]"); - private static final BlockData FALLBACK_LEAF = BukkitBlockResolution.get("minecraft:oak_leaves[distance=1,persistent=false,waterlogged=false]"); + private static final Set STEM_WOOD = Set.of( + "minecraft:bamboo_block", + "minecraft:stripped_bamboo_block", + "minecraft:crimson_stem", + "minecraft:stripped_crimson_stem", + "minecraft:warped_stem", + "minecraft:stripped_warped_stem" + ); private TreePlausibilizer() { } public record Result( int totalLeaves, - int persistentLeavesInput, - int reachableBefore, - int logsAdded, - int leavesAdded, - int leavesRemoved, - int leavesNormalized, - int unreachableAfter, - String skipReason + int unreachableBefore, + int clusters, + int branchesGrown, + int woodPlaced, + int leavesConvertedToWood, + int leavesPinnedPersistent, + int distancesRewritten, + int unreachableAfter ) { - public static Result skipped(String reason) { - return new Result(0, 0, 0, 0, 0, 0, 0, 0, reason); + public boolean mutated() { + return woodPlaced + leavesConvertedToWood + distancesRewritten > 0; } } - public static Result analyze(IrisObject obj, PlausibilizeMode mode, int shellRadius) { - return run(obj, false, mode, shellRadius); + public static long seedOf(String key) { + long hash = 0xCBF29CE484222325L; + for (int i = 0; i < key.length(); i++) { + hash ^= key.charAt(i); + hash *= 0x100000001B3L; + } + return hash; } - public static Result apply(IrisObject obj, PlausibilizeMode mode, int shellRadius) { - return run(obj, true, mode, shellRadius); + public static Result analyze(IrisObject obj, long seed, int reach) { + return run(obj, false, seed, reach); } - private static Result run(IrisObject obj, boolean mutate, PlausibilizeMode mode, int shellRadius) { - boolean normalize = mode == PlausibilizeMode.NORMALIZE; - boolean smoke = mode == PlausibilizeMode.SMOKE; - boolean foliageOverature = mode == PlausibilizeMode.FOLIAGE_OVERATURE; + public static Result apply(IrisObject obj, long seed, int reach) { + return run(obj, true, seed, reach); + } + + private static Result run(IrisObject obj, boolean mutate, long seed, int reach) { VectorMap blocks = obj.getBlocks(); - Map positions = new HashMap<>(blocks.size() * 2); - Set logPositions = new HashSet<>(); - Set originalLeafPositions = new HashSet<>(); - Set persistentLeafPositions = new HashSet<>(); - Map leafTypeCounts = new HashMap<>(); + Workspace ws = new Workspace(blocks); - int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE; - int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE; + int totalLeaves = ws.leaves.size(); + if (totalLeaves == 0) { + return new Result(0, 0, 0, 0, 0, 0, 0, 0, 0); + } - for (Map.Entry entry : blocks) { - IrisBlockVector pos = entry.getKey(); - BlockData data = (BlockData) entry.getValue().nativeHandle(); - long key = packKey(pos); - positions.put(key, data); - int x = pos.getBlockX(); - int y = pos.getBlockY(); - int z = pos.getBlockZ(); - if (x < minX) minX = x; - if (y < minY) minY = y; - if (z < minZ) minZ = z; - if (x > maxX) maxX = x; - if (y > maxY) maxY = y; - if (z > maxZ) maxZ = z; - if (isLog(data)) { - logPositions.add(key); - continue; - } - if (isLeaf(data)) { - boolean persistent = data instanceof Leaves leaves && leaves.isPersistent(); - if (persistent) { - persistentLeafPositions.add(key); + ws.template = pickBranchTemplate(ws.positions, ws.wood); + ws.distances = seedDistances(ws.wood, ws.leaves); + ws.unreached = new HashSet<>(ws.leaves); + ws.unreached.removeAll(ws.distances.keySet()); + int unreachableBefore = ws.unreached.size(); + + List> clusters = clusterize(ws.unreached); + int clusterCount = clusters.size(); + + if (ws.template != null && clusters.size() <= SCATTER_CLUSTER_LIMIT) { + int ordinal = 0; + for (List cluster : clusters) { + long clusterSeed = seed ^ (ordinal * GOLDEN_GAMMA); + ordinal++; + GapTracker tracker = new GapTracker(cluster, ws.wood); + if (reach > 0 && tracker.minGap() > reach) { + continue; } - originalLeafPositions.add(key); - leafTypeCounts.merge(data, 1, Integer::sum); + ws.growCluster(cluster, clusterSeed, tracker); } } - int persistentInput = persistentLeafPositions.size(); - int totalLeavesInitial = originalLeafPositions.size(); - - if (logPositions.isEmpty() && !originalLeafPositions.isEmpty()) { - return Result.skipped("leaves present but no logs to bridge from"); - } - if (logPositions.isEmpty() && !smoke) { - return new Result(0, 0, 0, 0, 0, 0, 0, 0, null); - } - - Set leafPositions; - int leavesRemoved = 0; - List removedLeafKeys = new ArrayList<>(); - - if (smoke) { - leafPositions = new HashSet<>(); - for (long key : originalLeafPositions) { - removedLeafKeys.add(key); - positions.remove(key); - } - leavesRemoved = removedLeafKeys.size(); - int r = Math.max(0, Math.min(shellRadius, 5)); - BlockData leafTemplate = pickDominantLeaf(leafTypeCounts); - paintShell( - logPositions, positions, leafPositions, - leafTemplate, r, - minX, minY, minZ, maxX, maxY, maxZ - ); - } else if (normalize) { - leafPositions = new HashSet<>(originalLeafPositions); - } else { - leafPositions = new HashSet<>(originalLeafPositions); - leafPositions.removeAll(persistentLeafPositions); - } - - int reachableBefore; - int logsAdded = 0; - Set unreached; - Map distances; - List inserts = new ArrayList<>(); - Set orphanRemovals = new HashSet<>(); - List leafAdds = new ArrayList<>(); - - if (!leafPositions.isEmpty() && !logPositions.isEmpty()) { - Set connectivityLeaves; - if (!normalize && !smoke) { - connectivityLeaves = new HashSet<>(leafPositions); - connectivityLeaves.addAll(persistentLeafPositions); + Map finalDistances = seedDistances(ws.wood, ws.leaves); + List rewrites = new ArrayList<>(); + int pinned = 0; + int unreachableAfter = 0; + List sortedLeaves = new ArrayList<>(ws.leaves); + Collections.sort(sortedLeaves); + for (long leaf : sortedLeaves) { + PlatformBlockState state = ws.positions.get(leaf); + Integer d = finalDistances.get(leaf); + PlatformBlockState next; + if (d != null) { + next = state.withProperty("persistent", "false") + .withProperty("distance", String.valueOf(Math.max(1, Math.min(MAX_DISTANCE, d)))); } else { - connectivityLeaves = leafPositions; + next = state.withProperty("persistent", "true").withProperty("distance", "7"); + pinned++; + unreachableAfter++; } - - distances = seedDistances(logPositions, connectivityLeaves); - reachableBefore = countReachable(leafPositions, distances); - unreached = new HashSet<>(leafPositions); - unreached.removeAll(distances.keySet()); - - if (foliageOverature && !smoke && !unreached.isEmpty()) { - BlockData bridgeLeaf = pickDominantLeaf(leafTypeCounts); - foliageBridge( - unreached, logPositions, distances, - leafPositions, connectivityLeaves, positions, - bridgeLeaf, leafAdds, - minX, minY, minZ, maxX, maxY, maxZ - ); - distances = seedDistances(logPositions, connectivityLeaves); - unreached = new HashSet<>(leafPositions); - unreached.removeAll(distances.keySet()); - } - - logsAdded = tentacleGrow( - unreached, distances, - logPositions, leafPositions, connectivityLeaves, positions, - inserts, orphanRemovals, !foliageOverature - ); - } else { - distances = new HashMap<>(); - unreached = new HashSet<>(); - reachableBefore = 0; - } - - leavesRemoved += orphanRemovals.size(); - - int leavesAdded = leafAdds.size(); - if (smoke) { - BlockData leafTemplate = pickDominantLeaf(leafTypeCounts); - for (long key : leafPositions) { - leafAdds.add(new LeafAddition(key, leafTemplate)); - } - leavesAdded = leafAdds.size(); - } - - int leavesNormalized = 0; - List normalizeRewrites = new ArrayList<>(); - if (normalize && !smoke) { - for (long pos : leafPositions) { - BlockData data = positions.get(pos); - if (data instanceof Leaves leaves && leaves.isPersistent()) { - BlockData cloned = data.clone(); - ((Leaves) cloned).setPersistent(false); - normalizeRewrites.add(new LeafRewrite(pos, cloned)); - leavesNormalized++; - } + if (!next.key().equals(state.key())) { + rewrites.add(new LeafRewrite(leaf, next)); } } if (mutate) { - if (smoke) { - for (long key : removedLeafKeys) { - if (!positions.containsKey(key)) { - blocks.remove(unpackKey(key)); - } - } + for (WoodPlacement placement : ws.placements) { + blocks.put(unpackKey(placement.key()), placement.state()); } - for (long key : orphanRemovals) { - blocks.remove(unpackKey(key)); + for (LeafRewrite rewrite : rewrites) { + blocks.put(unpackKey(rewrite.key()), rewrite.state()); } - for (LeafAddition addition : leafAdds) { - blocks.put(unpackKey(addition.key()), BukkitBlockState.of(addition.data())); - } - for (LogInsertion insertion : inserts) { - blocks.put(unpackKey(insertion.key()), BukkitBlockState.of(insertion.data())); - } - for (LeafRewrite rewrite : normalizeRewrites) { - blocks.put(unpackKey(rewrite.key()), BukkitBlockState.of(rewrite.data())); - } - } - - int finalLeafCount = leafPositions.size(); - if (!normalize && !smoke) { - finalLeafCount += persistentInput; } return new Result( - smoke ? leavesAdded : totalLeavesInitial, - persistentInput, - reachableBefore, - logsAdded, - leavesAdded, - leavesRemoved, - leavesNormalized, - unreached.size(), - null + totalLeaves, + unreachableBefore, + clusterCount, + ws.branches, + ws.woodPlaced, + ws.converted, + pinned, + rewrites.size(), + unreachableAfter ); } - private static void paintShell( - Set logPositions, - Map positions, - Set leafPositions, - BlockData leafTemplate, - int radius, - int minX, int minY, int minZ, int maxX, int maxY, int maxZ - ) { - if (radius <= 0) { - return; + private static PlatformBlockState pickBranchTemplate(Map positions, Set wood) { + if (wood.isEmpty()) { + return null; } - int r2 = radius * radius; - int bxMin = minX; - int byMin = minY; - int bzMin = minZ; - int bxMax = maxX; - int byMax = maxY; - int bzMax = maxZ; - - for (long log : logPositions) { - int[] lx = unpack(log); - for (int dx = -radius; dx <= radius; dx++) { - int ax = lx[0] + dx; - if (ax < bxMin || ax > bxMax) continue; - int dx2 = dx * dx; - for (int dy = -radius; dy <= radius; dy++) { - int ay = lx[1] + dy; - if (ay < byMin || ay > byMax) continue; - int dy2 = dy * dy; - int partial = dx2 + dy2; - if (partial > r2) continue; - for (int dz = -radius; dz <= radius; dz++) { - if (partial + dz * dz > r2) continue; - int az = lx[2] + dz; - if (az < bzMin || az > bzMax) continue; - long nk = packXYZ(ax, ay, az); - if (logPositions.contains(nk)) continue; - if (positions.containsKey(nk)) continue; - positions.put(nk, leafTemplate); - leafPositions.add(nk); - } - } + Map materialCounts = new HashMap<>(); + for (long key : wood) { + materialCounts.merge(IrisProceduralBlocks.materialKey(positions.get(key)), 1, Integer::sum); + } + String dominant = null; + int dominantCount = -1; + for (Map.Entry e : materialCounts.entrySet()) { + if (e.getValue() > dominantCount + || (e.getValue() == dominantCount && e.getKey().compareTo(dominant) < 0)) { + dominantCount = e.getValue(); + dominant = e.getKey(); } } - } - - private static int tentacleGrow( - Set unreached, - Map distances, - Set logPositions, - Set leafPositions, - Set connectivityLeaves, - Map positions, - List inserts, - Set orphanRemovals, - boolean deleteOrphans - ) { - int logsAdded = 0; - int safetyLimit = unreached.size() * 2 + 32; - long currentTarget = -1L; - - while (!unreached.isEmpty() && logsAdded < safetyLimit) { - if (currentTarget == -1L || !unreached.contains(currentTarget)) { - currentTarget = unreached.iterator().next(); - } - - long extensionLeaf = findWoodAdjacentLeafFrom(currentTarget, connectivityLeaves, logPositions); - - if (extensionLeaf == -1L) { - if (deleteOrphans) { - removeOrphanCluster(currentTarget, connectivityLeaves, leafPositions, unreached, distances, positions, orphanRemovals); - } else { - skipOrphanCluster(currentTarget, unreached, connectivityLeaves); - } - currentTarget = -1L; + Map stateCounts = new HashMap<>(); + Map statesByKey = new HashMap<>(); + for (long key : wood) { + PlatformBlockState state = positions.get(key); + if (!IrisProceduralBlocks.materialKey(state).equals(dominant)) { continue; } - - BlockData logData = pickLogVariant(extensionLeaf, positions, logPositions); - inserts.add(new LogInsertion(extensionLeaf, logData)); - - logPositions.add(extensionLeaf); - leafPositions.remove(extensionLeaf); - connectivityLeaves.remove(extensionLeaf); - distances.remove(extensionLeaf); - unreached.remove(extensionLeaf); - positions.put(extensionLeaf, logData); - logsAdded++; - - propagateFromLog(extensionLeaf, distances, connectivityLeaves, unreached); + stateCounts.merge(state.key(), 1, Integer::sum); + statesByKey.putIfAbsent(state.key(), state); } - return logsAdded; - } - - private static long findWoodAdjacentLeafFrom(long start, Set leafPositions, Set logPositions) { - if (!leafPositions.contains(start)) return -1L; - if (hasLogNeighbor(start, logPositions)) return start; - - Set visited = new HashSet<>(); - Deque queue = new ArrayDeque<>(); - queue.add(start); - visited.add(start); - - while (!queue.isEmpty()) { - long pos = queue.poll(); - int[] xyz = unpack(pos); - for (int[] n : NEIGHBORS) { - long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]); - if (!leafPositions.contains(nk) || !visited.add(nk)) continue; - if (hasLogNeighbor(nk, logPositions)) return nk; - queue.add(nk); + String best = null; + int bestCount = -1; + for (Map.Entry e : stateCounts.entrySet()) { + if (e.getValue() > bestCount + || (e.getValue() == bestCount && e.getKey().compareTo(best) < 0)) { + bestCount = e.getValue(); + best = e.getKey(); } } - return -1L; + return statesByKey.get(best); } - private static boolean hasLogNeighbor(long key, Set logPositions) { - int[] xyz = unpack(key); - for (int[] n : NEIGHBORS) { - long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]); - if (logPositions.contains(nk)) return true; - } - return false; - } - - private static void propagateFromLog( - long logKey, Map distances, - Set connectivityLeaves, Set unreached - ) { - int[] cx = unpack(logKey); - Deque q = new ArrayDeque<>(); - for (int[] n : NEIGHBORS) { - long nk = packXYZ(cx[0] + n[0], cx[1] + n[1], cx[2] + n[2]); - if (connectivityLeaves.contains(nk)) { - Integer cur = distances.get(nk); - if (cur == null || cur > 1) { - if (cur == null) unreached.remove(nk); - distances.put(nk, 1); - q.add(nk); - } + private static List> clusterize(Set unreached) { + List> clusters = new ArrayList<>(); + List sorted = new ArrayList<>(unreached); + Collections.sort(sorted); + Set assigned = new HashSet<>(); + for (long seedKey : sorted) { + if (assigned.contains(seedKey)) { + continue; } - } - while (!q.isEmpty()) { - long pos = q.poll(); - int d = distances.get(pos); - if (d >= MAX_DISTANCE) continue; - int[] px = unpack(pos); - for (int[] n : NEIGHBORS) { - long nk = packXYZ(px[0] + n[0], px[1] + n[1], px[2] + n[2]); - if (connectivityLeaves.contains(nk)) { - Integer cur = distances.get(nk); - if (cur == null || cur > d + 1) { - if (cur == null) unreached.remove(nk); - distances.put(nk, d + 1); - q.add(nk); - } - } - } - } - } - - private static void foliageBridge( - Set unreached, - Set logPositions, - Map distances, - Set leafPositions, - Set connectivityLeaves, - Map positions, - BlockData leafTemplate, - List leafAdds, - int minX, int minY, int minZ, int maxX, int maxY, int maxZ - ) { - Set pending = new HashSet<>(unreached); - while (!pending.isEmpty()) { - long seed = pending.iterator().next(); - Set cluster = new HashSet<>(); - Deque cq = new ArrayDeque<>(); - cq.add(seed); - cluster.add(seed); - while (!cq.isEmpty()) { - long p = cq.poll(); - int[] xyz = unpack(p); + List cluster = new ArrayList<>(); + Deque queue = new ArrayDeque<>(); + queue.add(seedKey); + assigned.add(seedKey); + while (!queue.isEmpty()) { + long pos = queue.poll(); + cluster.add(pos); + int[] xyz = unpack(pos); for (int[] n : NEIGHBORS) { long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]); - if (pending.contains(nk) && cluster.add(nk)) { - cq.add(nk); + if (unreached.contains(nk) && assigned.add(nk)) { + queue.add(nk); } } } - - Map parent = new HashMap<>(); - Deque q = new ArrayDeque<>(); - for (long c : cluster) { - parent.put(c, -1L); - q.add(c); - } - - long pathEnd = -1L; - while (!q.isEmpty() && pathEnd == -1L) { - long p = q.poll(); - int[] xyz = unpack(p); - for (int[] n : NEIGHBORS) { - int nx = xyz[0] + n[0]; - int ny = xyz[1] + n[1]; - int nz = xyz[2] + n[2]; - if (nx < minX || nx > maxX) continue; - if (ny < minY || ny > maxY) continue; - if (nz < minZ || nz > maxZ) continue; - long nk = packXYZ(nx, ny, nz); - if (parent.containsKey(nk)) continue; - if (logPositions.contains(nk) || distances.containsKey(nk)) { - pathEnd = p; - break; - } - if (positions.containsKey(nk)) continue; - parent.put(nk, p); - q.add(nk); - } - } - - pending.removeAll(cluster); - - if (pathEnd == -1L) { - continue; - } - - long cur = pathEnd; - while (cur != -1L && !cluster.contains(cur)) { - if (!positions.containsKey(cur)) { - BlockData clone = leafTemplate.clone(); - positions.put(cur, clone); - leafPositions.add(cur); - connectivityLeaves.add(cur); - leafAdds.add(new LeafAddition(cur, clone)); - } - cur = parent.get(cur); - } + Collections.sort(cluster); + clusters.add(cluster); } + return clusters; } - private static void skipOrphanCluster(long seed, Set unreached, Set connectivityLeaves) { - Deque queue = new ArrayDeque<>(); - Set visited = new HashSet<>(); - queue.add(seed); - visited.add(seed); - while (!queue.isEmpty()) { - long pos = queue.poll(); - unreached.remove(pos); - int[] xyz = unpack(pos); - for (int[] n : NEIGHBORS) { - long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]); - if (visited.add(nk) && unreached.contains(nk) && connectivityLeaves.contains(nk)) { - queue.add(nk); + private static final class GapTracker { + private final Map leafCells = new HashMap<>(); + private final Map gaps = new HashMap<>(); + private final Map anchors = new HashMap<>(); + private final Map anchorDistances = new HashMap<>(); + + private GapTracker(List cluster, Set wood) { + for (long leaf : cluster) { + leafCells.put(leaf, unpack(leaf)); + gaps.put(leaf, Integer.MAX_VALUE); + anchors.put(leaf, -1L); + anchorDistances.put(leaf, Long.MAX_VALUE); + } + for (long w : wood) { + onWoodPlaced(w); + } + } + + private void onWoodPlaced(long woodKey) { + int[] wx = unpack(woodKey); + for (Map.Entry entry : leafCells.entrySet()) { + long leaf = entry.getKey(); + int[] lx = entry.getValue(); + int cheb = chebyshev(lx, wx); + if (cheb < gaps.get(leaf)) { + gaps.put(leaf, cheb); + } + long dx = lx[0] - wx[0]; + long dy = lx[1] - wx[1]; + long dz = lx[2] - wx[2]; + long d2 = dx * dx + dy * dy + dz * dz; + long bestD2 = anchorDistances.get(leaf); + long bestAnchor = anchors.get(leaf); + if (d2 < bestD2 || (d2 == bestD2 && woodKey < bestAnchor)) { + anchorDistances.put(leaf, d2); + anchors.put(leaf, woodKey); } } } - } - private static void removeOrphanCluster( - long seed, - Set connectivityLeaves, Set leafPositions, Set unreached, - Map distances, Map positions, Set orphanRemovals - ) { - Deque queue = new ArrayDeque<>(); - Set visited = new HashSet<>(); - queue.add(seed); - visited.add(seed); + private void drop(long leaf) { + leafCells.remove(leaf); + gaps.remove(leaf); + anchors.remove(leaf); + anchorDistances.remove(leaf); + } - while (!queue.isEmpty()) { - long pos = queue.poll(); - int[] xyz = unpack(pos); - for (int[] n : NEIGHBORS) { - long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]); - if (visited.add(nk) && connectivityLeaves.contains(nk)) { - queue.add(nk); + private int minGap() { + int best = Integer.MAX_VALUE; + for (int gap : gaps.values()) { + if (gap < best) { + best = gap; } } - orphanRemovals.add(pos); - connectivityLeaves.remove(pos); - leafPositions.remove(pos); - unreached.remove(pos); - distances.remove(pos); - positions.remove(pos); + return best; + } + + private long worst(Set unreached) { + long worst = -1L; + int worstGap = -1; + for (Map.Entry entry : gaps.entrySet()) { + long leaf = entry.getKey(); + if (!unreached.contains(leaf)) { + continue; + } + int gap = entry.getValue(); + if (gap > worstGap || (gap == worstGap && leaf < worst)) { + worstGap = gap; + worst = leaf; + } + } + return worst; + } + + private long anchor(long leaf) { + Long anchor = anchors.get(leaf); + return anchor == null ? -1L : anchor; + } + + private int gapOf(long leaf) { + Integer gap = gaps.get(leaf); + return gap == null ? Integer.MAX_VALUE : gap; } } - private static Map seedDistances(Set logPositions, Set leafPositions) { + private static Map seedDistances(Set wood, Set leaves) { Map dist = new HashMap<>(); Deque queue = new ArrayDeque<>(); - - for (long leaf : leafPositions) { + for (long leaf : leaves) { int[] xyz = unpack(leaf); for (int[] n : NEIGHBORS) { long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]); - if (logPositions.contains(nk)) { + if (wood.contains(nk)) { dist.put(leaf, 1); queue.add(leaf); break; } } } - while (!queue.isEmpty()) { long pos = queue.poll(); int d = dist.get(pos); @@ -577,80 +347,63 @@ public final class TreePlausibilizer { int[] xyz = unpack(pos); for (int[] n : NEIGHBORS) { long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]); - if (leafPositions.contains(nk) && !dist.containsKey(nk)) { + if (leaves.contains(nk) && !dist.containsKey(nk)) { dist.put(nk, d + 1); queue.add(nk); } } } - return dist; } - private static int countReachable(Set leafPositions, Map distances) { - int count = 0; - for (long leaf : leafPositions) { - if (distances.containsKey(leaf)) { - count++; - } - } - return count; + private static boolean isWood(PlatformBlockState state) { + String material = IrisProceduralBlocks.materialKey(state); + return material.endsWith("_log") + || material.endsWith("_wood") + || material.endsWith("_hyphae") + || STEM_WOOD.contains(material); } - private static BlockData pickDominantLeaf(Map leafTypeCounts) { - BlockData best = null; - int bestCount = -1; - for (Map.Entry e : leafTypeCounts.entrySet()) { - if (e.getValue() > bestCount) { - bestCount = e.getValue(); - best = e.getKey(); - } - } - if (best == null) { - return FALLBACK_LEAF.clone(); - } - BlockData clone = best.clone(); - if (clone instanceof Leaves leaves) { - leaves.setPersistent(false); - } - return clone; + private static boolean isDecayableLeaf(PlatformBlockState state) { + return IrisProceduralBlocks.hasProperty(state, "distance") + && IrisProceduralBlocks.hasProperty(state, "persistent"); } - private static BlockData pickLogVariant(long target, Map positions, Set logPositions) { - if (logPositions.isEmpty()) { - return FALLBACK_LOG.clone(); + private static PlatformBlockState axisVariant(PlatformBlockState template, int dx, int dy, int dz) { + if (!IrisProceduralBlocks.hasProperty(template, "axis")) { + return template; } - int[] tx = unpack(target); - long nearest = -1L; - long nearestDistSq = Long.MAX_VALUE; - for (long lp : logPositions) { - int[] lx = unpack(lp); - long dx = tx[0] - lx[0]; - long dy = tx[1] - lx[1]; - long dz = tx[2] - lx[2]; - long d2 = dx * dx + dy * dy + dz * dz; - if (d2 < nearestDistSq) { - nearestDistSq = d2; - nearest = lp; - } + double weightedY = Math.abs(dy) * 1.8D; + String axis; + if (weightedY >= Math.abs(dx) && weightedY >= Math.abs(dz)) { + axis = "y"; + } else if (Math.abs(dx) >= Math.abs(dz)) { + axis = "x"; + } else { + axis = "z"; } - BlockData source = positions.get(nearest); - if (source == null) { - return FALLBACK_LOG.clone(); - } - BlockData clone = source.clone(); - if (clone instanceof Orientable orientable) { - orientable.setAxis(Axis.Y); - } - return clone; + return template.withProperty("axis", axis); } - private static boolean isLog(BlockData data) { - return Tag.LOGS.isTagged(data.getMaterial()); + private static int chebyshev(int[] a, int[] b) { + return Math.max(Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1])), Math.abs(a[2] - b[2])); } - private static boolean isLeaf(BlockData data) { - return Tag.LEAVES.isTagged(data.getMaterial()) || data instanceof Leaves; + private static double[] cross(double[] a, double[] b) { + return new double[]{ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0] + }; + } + + private static double lengthOf(double[] a) { + return Math.sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]); + } + + private static double[] normalize(double[] a) { + double len = lengthOf(a); + return new double[]{a[0] / len, a[1] / len, a[2] / len}; } private static long packKey(IrisBlockVector pos) { @@ -676,12 +429,294 @@ public final class TreePlausibilizer { return new IrisBlockVector(xyz[0], xyz[1], xyz[2]); } - private record LogInsertion(long key, BlockData data) { + private record WoodPlacement(long key, PlatformBlockState state) { } - private record LeafAddition(long key, BlockData data) { + private record LeafRewrite(long key, PlatformBlockState state) { } - private record LeafRewrite(long key, BlockData data) { + private static final class Workspace { + private final Map positions; + private final Set wood = new HashSet<>(); + private final Set leaves = new HashSet<>(); + private final List placements = new ArrayList<>(); + private Map distances = new HashMap<>(); + private Set unreached = new HashSet<>(); + private PlatformBlockState template; + private GapTracker tracker; + private int minX = Integer.MAX_VALUE; + private int minY = Integer.MAX_VALUE; + private int minZ = Integer.MAX_VALUE; + private int maxX = Integer.MIN_VALUE; + private int maxY = Integer.MIN_VALUE; + private int maxZ = Integer.MIN_VALUE; + private int woodPlaced; + private int converted; + private int branches; + + private Workspace(VectorMap blocks) { + positions = new HashMap<>(blocks.size() * 2); + for (Map.Entry entry : blocks) { + IrisBlockVector pos = entry.getKey(); + PlatformBlockState state = entry.getValue(); + long key = packKey(pos); + positions.put(key, state); + int x = pos.getBlockX(); + int y = pos.getBlockY(); + int z = pos.getBlockZ(); + if (x < minX) minX = x; + if (y < minY) minY = y; + if (z < minZ) minZ = z; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + if (z > maxZ) maxZ = z; + if (isWood(state)) { + wood.add(key); + } else if (isDecayableLeaf(state)) { + leaves.add(key); + } + } + } + + private void growCluster(List cluster, long clusterSeed, GapTracker gapTracker) { + tracker = gapTracker; + try { + int stalls = 0; + int budget = Math.max(MAX_TENDRILS_PER_CLUSTER, cluster.size() / 8); + for (int tendril = 0; tendril < budget; tendril++) { + long target = gapTracker.worst(unreached); + if (target == -1L) { + return; + } + long anchor = gapTracker.anchor(target); + if (anchor == -1L) { + return; + } + int placedBefore = placements.size(); + int unreachedBefore = unreachedInCluster(cluster); + RNG rng = new RNG(clusterSeed).nextParallelRNG(tendril); + growTendril(anchor, target, rng); + boolean progressed = placements.size() > placedBefore || unreachedInCluster(cluster) < unreachedBefore; + if (placements.size() > placedBefore) { + branches++; + } + if (progressed) { + stalls = 0; + continue; + } + stalls++; + if (stalls >= MAX_STALLS_PER_CLUSTER) { + break; + } + } + directConnect(cluster); + } finally { + tracker = null; + } + } + + private void growTendril(long anchorKey, long targetKey, RNG rng) { + int[] anchor = unpack(anchorKey); + int[] target = unpack(targetKey); + double[] delta = new double[]{target[0] - anchor[0], target[1] - anchor[1], target[2] - anchor[2]}; + double length = lengthOf(delta); + if (length < 1.0D) { + return; + } + double[] direction = normalize(delta); + double[] side = cross(direction, new double[]{0.0D, 1.0D, 0.0D}); + if (lengthOf(side) < 0.05D) { + side = cross(direction, new double[]{1.0D, 0.0D, 0.0D}); + } + side = normalize(side); + double[] up = cross(direction, side); + + double amplitudeMax = Math.min(MAX_HELIX_AMPLITUDE, length / 5.0D); + double totalTwist = (1.25D + rng.d()) * Math.PI; + double phase = rng.d() * 2.0D * Math.PI; + double handedness = rng.b() ? 1.0D : -1.0D; + double drawLength = Math.max(1.0D, length - STOP_SHORT); + int steps = (int) Math.ceil(drawLength * 2.0D); + + int[] previous = anchor; + for (int i = 1; i <= steps; i++) { + double arc = drawLength * i / steps; + double fraction = arc / drawLength; + double amplitude = amplitudeMax * Math.sin(Math.PI * fraction); + double theta = phase + handedness * totalTwist * (arc / length); + double offsetSide = amplitude * Math.cos(theta); + double offsetUp = amplitude * Math.sin(theta); + int cx = clamp((int) Math.round(anchor[0] + direction[0] * arc + side[0] * offsetSide + up[0] * offsetUp), minX, maxX); + int cy = clamp((int) Math.round(anchor[1] + direction[1] * arc + side[1] * offsetSide + up[1] * offsetUp), minY, maxY); + int cz = clamp((int) Math.round(anchor[2] + direction[2] * arc + side[2] * offsetSide + up[2] * offsetUp), minZ, maxZ); + int[] cell = new int[]{cx, cy, cz}; + bridge(previous, cell); + previous = cell; + } + } + + private void directConnect(List cluster) { + List ordered = new ArrayList<>(cluster); + if (tracker != null) { + GapTracker gapTracker = tracker; + ordered.sort((Long a, Long b) -> { + int ga = gapTracker.gapOf(a); + int gb = gapTracker.gapOf(b); + if (ga != gb) { + return Integer.compare(ga, gb); + } + return Long.compare(a, b); + }); + } + for (long target : ordered) { + if (!unreached.contains(target)) { + continue; + } + long anchor = tracker == null ? -1L : tracker.anchor(target); + if (anchor == -1L) { + return; + } + int placedBefore = placements.size(); + int[] pos = unpack(anchor); + int[] t = unpack(target); + int guard = chebyshev(pos, t) * 4 + 8; + while (guard-- > 0 && unreached.contains(target) && !distances.containsKey(target)) { + int dx = Integer.signum(t[0] - pos[0]); + int dy = Integer.signum(t[1] - pos[1]); + int dz = Integer.signum(t[2] - pos[2]); + if (dx == 0 && dy == 0 && dz == 0) { + break; + } + boolean landsOnTarget = pos[0] + dx == t[0] && pos[1] + dy == t[1] && pos[2] + dz == t[2]; + boolean diagonal = Math.abs(dx) + Math.abs(dy) + Math.abs(dz) > 1; + if (landsOnTarget && diagonal) { + if (dx != 0) { + dy = 0; + dz = 0; + } else if (dy != 0) { + dz = 0; + } + } + int[] step = new int[]{dx, dy, dz}; + int[][] candidates = {{dx, dy, dz}, {dx, 0, 0}, {0, dy, 0}, {0, 0, dz}}; + for (int[] candidate : candidates) { + if (candidate[0] == 0 && candidate[1] == 0 && candidate[2] == 0) { + continue; + } + int nx = pos[0] + candidate[0]; + int ny = pos[1] + candidate[1]; + int nz = pos[2] + candidate[2]; + if (nx == t[0] && ny == t[1] && nz == t[2]) { + continue; + } + if (leaves.contains(packXYZ(nx, ny, nz))) { + step = candidate; + break; + } + } + pos = new int[]{pos[0] + step[0], pos[1] + step[1], pos[2] + step[2]}; + placeCell(pos[0], pos[1], pos[2], step[0], step[1], step[2]); + } + if (placements.size() > placedBefore) { + branches++; + } + } + } + + private void bridge(int[] from, int[] to) { + int[] pos = from; + int guard = chebyshev(from, to) * 3 + 4; + while (guard-- > 0 && (pos[0] != to[0] || pos[1] != to[1] || pos[2] != to[2])) { + int dx = Integer.signum(to[0] - pos[0]); + int dy = Integer.signum(to[1] - pos[1]); + int dz = Integer.signum(to[2] - pos[2]); + pos = new int[]{pos[0] + dx, pos[1] + dy, pos[2] + dz}; + placeCell(pos[0], pos[1], pos[2], dx, dy, dz); + } + } + + private void placeCell(int x, int y, int z, int dx, int dy, int dz) { + long key = packXYZ(x, y, z); + if (wood.contains(key)) { + return; + } + PlatformBlockState existing = positions.get(key); + boolean convertLeaf = existing != null && leaves.contains(key); + if (existing != null && !convertLeaf) { + return; + } + PlatformBlockState state = axisVariant(template, dx, dy, dz); + positions.put(key, state); + wood.add(key); + if (convertLeaf) { + leaves.remove(key); + unreached.remove(key); + distances.remove(key); + converted++; + if (tracker != null) { + tracker.drop(key); + } + } else { + woodPlaced++; + } + placements.add(new WoodPlacement(key, state)); + if (tracker != null) { + tracker.onWoodPlaced(key); + } + propagate(key); + } + + private void propagate(long woodKey) { + int[] center = unpack(woodKey); + Deque queue = new ArrayDeque<>(); + for (int[] n : NEIGHBORS) { + long nk = packXYZ(center[0] + n[0], center[1] + n[1], center[2] + n[2]); + if (leaves.contains(nk)) { + Integer current = distances.get(nk); + if (current == null || current > 1) { + if (current == null) { + unreached.remove(nk); + } + distances.put(nk, 1); + queue.add(nk); + } + } + } + while (!queue.isEmpty()) { + long pos = queue.poll(); + int d = distances.get(pos); + if (d >= MAX_DISTANCE) { + continue; + } + int[] xyz = unpack(pos); + for (int[] n : NEIGHBORS) { + long nk = packXYZ(xyz[0] + n[0], xyz[1] + n[1], xyz[2] + n[2]); + if (leaves.contains(nk)) { + Integer current = distances.get(nk); + if (current == null || current > d + 1) { + if (current == null) { + unreached.remove(nk); + } + distances.put(nk, d + 1); + queue.add(nk); + } + } + } + } + } + + private int unreachedInCluster(List cluster) { + int count = 0; + for (long leaf : cluster) { + if (unreached.contains(leaf)) { + count++; + } + } + return count; + } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } } } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java index 0bb461bca..72d48f5ff 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java @@ -30,7 +30,6 @@ import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.mantle.MantleComponent; import art.arcane.iris.engine.mantle.components.MantleCarvingComponent; import art.arcane.iris.engine.mantle.components.MantleFloatingObjectComponent; -import art.arcane.iris.engine.mantle.components.MantleFluidBodyComponent; import art.arcane.iris.engine.mantle.components.MantleObjectComponent; import art.arcane.iris.engine.mantle.components.IrisStructureComponent; import art.arcane.iris.spi.IrisLogging; @@ -88,7 +87,6 @@ public class IrisEngineMantle implements EngineMantle { this.mantle = createMantle(engine); components = new KMap<>(); registerComponent(new MantleCarvingComponent(this)); - registerComponent(new MantleFluidBodyComponent(this)); object = new MantleObjectComponent(this); registerComponent(object); registerComponent(new MantleFloatingObjectComponent(this)); diff --git a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java index 0c022b74b..cbff7eebb 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java @@ -965,16 +965,15 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } IrisMarker mark = getData().getMarkerLoader().load(t.getTag()); + if (mark == null) { + return; + } + IrisPosition pos = new IrisPosition((c.getX() << 4) + x, y, (c.getZ() << 4) + z); - if (mark.isEmptyAbove()) { - boolean remove = c.getBlock(x, y + 1, z).getBlockData().getMaterial().isSolid() - || c.getBlock(x, y + 2, z).getBlockData().getMaterial().isSolid(); - - if (remove) { - b.add(pos); - return; - } + if (isMarkerObstructed(c, pos, mark.isEmptyAbove())) { + b.add(pos); + return; } for (String i : mark.getSpawners()) { @@ -1096,7 +1095,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } int minY = getEngine().getWorld().minHeight(); - int markerY = relative.getY() + minY; + int markerY = toWorldY(relative.getY(), minY); if (markerY + 2 >= chunk.getWorld().getMaxHeight()) { return true; } @@ -1145,7 +1144,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { }); KList d = new KList<>(); - IrisBiome b = EngineBukkitOps.getBiome(getEngine(), e.getBlock().getLocation().clone().subtract(0, getEngine().getWorld().minHeight(), 0)); + IrisBiome b = EngineBukkitOps.getBiome(getEngine(), e.getBlock().getLocation()); List dropProviders = filterDrops(b.getBlockDrops(), e, getData()); if (dropProviders.stream().noneMatch(IrisBlockDrops::isSkipParents)) { @@ -1177,6 +1176,10 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return worldY - minHeight; } + static int toWorldY(int mantleY, int minHeight) { + return mantleY + minHeight; + } + private List filterDrops(KList drops, BlockBreakEvent e, IrisData data) { return new KList<>(drops.stream().filter(d -> d.shouldDropFor(e.getBlock().getBlockData(), data)).toList()); } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java index b04f449bf..650e565d7 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java @@ -108,18 +108,28 @@ final class DecoratorCore { String half = IrisProceduralBlocks.propertyValue(bd, "half"); if (half != null) { + int lowerY = height + 1; + int upperY = height + 2; + if (!canPlaceTwoBlockPlant(data, x, z, lowerY, upperY, caveSkipFluid)) { + return; + } + try { - if (height + 2 < data.getHeight() && (!caveSkipFluid || !B.isFluid(data.get(x, height + 2, z)))) { - data.set(x, height + 2, z, bd.withProperty("half", topHalfValue(half))); - } + PlatformBlockState upper = bd.withProperty("half", topHalfValue(half)); + PlatformBlockState lower = fixFacesForHunk( + bd.withProperty("half", bottomHalfValue(half)), + data, x, z, realX, lowerY, realZ, mantle); + data.set(x, lowerY, z, lower); + data.set(x, upperY, z, upper); } catch (Throwable e) { IrisLogging.reportError(e); } - bd = bd.withProperty("half", bottomHalfValue(half)); + return; } - if (height + 1 < data.getHeight() && B.isAir(data.get(x, height + 1, z))) { - data.set(x, height + 1, z, fixFacesForHunk(bd, data, x, z, realX, height + 1, realZ, mantle)); + int targetY = height + 1; + if (targetY < data.getHeight() && B.isAir(data.get(x, targetY, z))) { + data.set(x, targetY, z, fixFacesForHunk(bd, data, x, z, realX, targetY, realZ, mantle)); } } @@ -169,18 +179,28 @@ final class DecoratorCore { String half = bd == null ? null : IrisProceduralBlocks.propertyValue(bd, "half"); if (half != null) { + int lowerY = height + 1; + int upperY = height + 2; + if (!canPlaceTwoBlockPlant(data, x, z, lowerY, upperY, caveSkipFluid)) { + return; + } + try { - if (height + 2 < data.getHeight() && (!caveSkipFluid || !B.isFluid(data.get(x, height + 2, z)))) { - data.set(x, height + 2, z, bd.withProperty("half", topHalfValue(half))); - } + PlatformBlockState upper = bd.withProperty("half", topHalfValue(half)); + PlatformBlockState lower = fixFacesForHunk( + bd.withProperty("half", bottomHalfValue(half)), + data, x, z, realX, lowerY, realZ, mantle); + data.set(x, lowerY, z, lower); + data.set(x, upperY, z, upper); } catch (Throwable e) { IrisLogging.reportError(e); } - bd = bd.withProperty("half", bottomHalfValue(half)); + return; } - if (height + 1 < data.getHeight() && B.isAir(data.get(x, height + 1, z))) { - data.set(x, height + 1, z, fixFacesForHunk(bd, data, x, z, realX, height + 1, realZ, mantle)); + int targetY = height + 1; + if (targetY < data.getHeight() && B.isAir(data.get(x, targetY, z))) { + data.set(x, targetY, z, fixFacesForHunk(bd, data, x, z, realX, targetY, realZ, mantle)); } } @@ -212,10 +232,23 @@ final class DecoratorCore { int stack = computeStack(decorator, rng, realX, realZ, irisData, effectiveMax); if (stack == 1) { - if (opts.caveSkipFluid && B.isFluid(data.get(x, height, z))) { + int targetY = height + 1; + if (targetY >= data.getHeight()) { return; } - data.set(x, height, z, decorator.pickBlockDataTop(rng, irisData, realX, realZ)); + + PlatformBlockState existing = data.get(x, targetY, z); + if (!canReplaceStackTarget(existing, opts.underwater) + || (opts.caveSkipFluid && B.isFluid(existing))) { + return; + } + + PlatformBlockState block = decorator.pickBlockDataTop(rng, irisData, realX, realZ); + if (block == null || (!opts.underwater && !canGoOn(block, data.get(x, height, z)))) { + return; + } + + data.set(x, targetY, z, block); return; } @@ -244,7 +277,9 @@ final class DecoratorCore { break; } - if (opts.caveSkipFluid && B.isFluid(data.get(x, height + 1 + i, z))) { + PlatformBlockState existing = data.get(x, height + 1 + i, z); + if (!canReplaceStackTarget(existing, opts.underwater) + || (opts.caveSkipFluid && B.isFluid(existing))) { break; } @@ -259,22 +294,28 @@ final class DecoratorCore { static void placeStackDown(IrisDecorator decorator, int x, int z, int realX, int realZ, int height, int minHeight, Hunk data, RNG rng, IrisData irisData, int max, PlaceOpts opts, EngineMantle mantle) { + if (height < 0 || height >= data.getHeight()) { + return; + } + int stack = computeStack(decorator, rng, realX, realZ, irisData, max); if (stack == 1) { if (opts.caveSkipFluid && B.isFluid(data.get(x, height, z))) { return; } - data.set(x, height, z, fixFacesForHunk( - decorator.pickBlockDataTop(rng, irisData, realX, realZ), - data, x, z, realX, height, realZ, mantle)); + PlatformBlockState block = decorator.pickBlockDataTop(rng, irisData, realX, realZ); + if (block == null) { + return; + } + data.set(x, height, z, fixFacesForHunk(block, data, x, z, realX, height, realZ, mantle)); return; } for (int i = 0; i < stack; i++) { int h = height - i; - if (h < minHeight) { - continue; + if (h < 0 || h < minHeight) { + break; } double threshold = ((double) i) / (double) (stack - 1); @@ -282,7 +323,11 @@ final class DecoratorCore { ? decorator.pickBlockDataTop(rng, irisData, realX, realZ) : decorator.pickBlockData(rng, irisData, realX, realZ); - if (bd != null && IrisProceduralBlocks.materialKey(bd).equals("minecraft:pointed_dripstone")) { + if (bd == null) { + break; + } + + if (IrisProceduralBlocks.materialKey(bd).equals("minecraft:pointed_dripstone")) { bd = dripstoneBlock(stack, i, "down"); } @@ -304,17 +349,24 @@ final class DecoratorCore { String half = IrisProceduralBlocks.propertyValue(bd, "half"); if (half != null) { + int lowerY = height + 1; + int upperY = height + 2; + if (max <= 2 || !canPlaceTwoBlockPlant(data, xf, zf, lowerY, upperY, false)) { + return; + } + try { - if (max > 2) { - data.set(xf, height + 2, zf, bd.withProperty("half", topHalfValue(half))); - } + PlatformBlockState upper = bd.withProperty("half", topHalfValue(half)); + PlatformBlockState lower = bd.withProperty("half", bottomHalfValue(half)); + data.set(xf, lowerY, zf, lower); + data.set(xf, upperY, zf, upper); } catch (Throwable e) { IrisLogging.reportError(e); } - bd = bd.withProperty("half", bottomHalfValue(half)); + return; } - if (max > 1) { + if (max > 1 && height + 1 < data.getHeight()) { data.set(xf, height + 1, zf, bd); } } @@ -333,7 +385,7 @@ final class DecoratorCore { int placed = 0; for (int i = 0; i < stack; i++) { int h = height + 1 + i; - if (h >= height + max) { + if (h >= height + max || h >= data.getHeight()) { break; } double threshold = stack == 1 ? 0.0 : ((double) i) / (stack - 1); @@ -385,7 +437,7 @@ final class DecoratorCore { int xx = rX + f.getModX(); int zz = rZ + f.getModZ(); - if (xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy > hunk.getHeight()) { + if (xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy >= hunk.getHeight()) { continue; } @@ -414,6 +466,22 @@ final class DecoratorCore { return ((BlockData) surface.nativeHandle()).isFaceSturdy(BlockFace.UP, BlockSupport.FULL); } + static boolean canReplaceStackTarget(PlatformBlockState state, boolean allowFluid) { + return B.isAir(state) || allowFluid && B.isFluid(state); + } + + private static boolean canPlaceTwoBlockPlant(Hunk data, int x, int z, + int lowerY, int upperY, boolean caveSkipFluid) { + if (lowerY < 0 || upperY >= data.getHeight()) { + return false; + } + + PlatformBlockState lower = data.get(x, lowerY, z); + PlatformBlockState upper = data.get(x, upperY, z); + return B.isAir(lower) && B.isAir(upper) + && (!caveSkipFluid || !B.isFluid(lower) && !B.isFluid(upper)); + } + private static int computeStack(IrisDecorator decorator, RNG rng, double realX, double realZ, IrisData irisData, int max) { int stack = decorator.getHeight(rng, realX, realZ, irisData); diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorPlatformHooks.java b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorPlatformHooks.java index 537dd614f..b14f63ab0 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorPlatformHooks.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorPlatformHooks.java @@ -37,9 +37,16 @@ public final class DecoratorPlatformHooks { boolean canGoOn(PlatformBlockState surface); } - public static void bind(FaceFixer faceFixer, SurfaceSturdiness surfaceSturdiness) { + public static synchronized Bindings bind(FaceFixer faceFixer, SurfaceSturdiness surfaceSturdiness) { + Bindings previous = new Bindings(FACE_FIXER, SURFACE_STURDINESS); FACE_FIXER = faceFixer; SURFACE_STURDINESS = surfaceSturdiness; + return previous; + } + + public static synchronized void restore(Bindings bindings) { + FACE_FIXER = bindings.faceFixer(); + SURFACE_STURDINESS = bindings.surfaceSturdiness(); } static FaceFixer faceFixer() { @@ -49,4 +56,7 @@ public final class DecoratorPlatformHooks { static SurfaceSturdiness surfaceSturdiness() { return SURFACE_STURDINESS; } + + public record Bindings(FaceFixer faceFixer, SurfaceSturdiness surfaceSturdiness) { + } } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisCeilingDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisCeilingDecorator.java index 520b44525..305eb0be7 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisCeilingDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisCeilingDecorator.java @@ -69,6 +69,6 @@ public class IrisCeilingDecorator extends IrisEngineDecorator { DecoratorCore.PlaceOpts opts = DecoratorCore.SCRATCH_OPTS.get(); opts.reset(); opts.caveSkipFluid = caveSkipFluid; - DecoratorCore.placeStackDown(decorator, x, z, realX, realZ, height, getEngine().getMinHeight(), data, rng, getData(), max, opts, getEngine().getMantle()); + DecoratorCore.placeStackDown(decorator, x, z, realX, realZ, height, 0, data, rng, getData(), max, opts, getEngine().getMantle()); } } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaFloorDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaFloorDecorator.java index 5a9c63894..9de54ad7b 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaFloorDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaFloorDecorator.java @@ -51,7 +51,7 @@ public class IrisSeaFloorDecorator extends IrisEngineDecorator { && !decorator.getSlopeCondition().isValid(getComplex().getSlopeStream().get(realX, realZ))) { return; } - if (height >= 0 || height < getEngine().getHeight()) { + if (height >= 0 && height < getEngine().getHeight()) { data.set(x, height, z, decorator.getBlockData100(biome, rng, realX, height, realZ, getData())); } return; diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaSurfaceDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaSurfaceDecorator.java index 5968cb894..e310f29ea 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaSurfaceDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSeaSurfaceDecorator.java @@ -47,8 +47,13 @@ public class IrisSeaSurfaceDecorator extends IrisEngineDecorator { } if (!decorator.isStacking()) { - if (height >= 0 || height < getEngine().getHeight()) { - data.set(x, height + 1, z, decorator.getBlockData100(biome, rng, realX, height, realZ, getData())); + int targetY = height + 1; + if (height >= 0 && targetY < getEngine().getHeight() + && DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { + PlatformBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData()); + if (block != null) { + data.set(x, targetY, z, block); + } } return; } @@ -59,20 +64,34 @@ public class IrisSeaSurfaceDecorator extends IrisEngineDecorator { } if (stack == 1) { - data.set(x, height, z, decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData())); + int targetY = height + 1; + if (targetY >= data.getHeight() || !DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { + return; + } + + PlatformBlockState block = decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData()); + if (block != null) { + data.set(x, targetY, z, block); + } return; } int engineHeight = getEngine().getHeight(); for (int i = 0; i < stack; i++) { int h = height + i; - if (h >= max || h >= engineHeight) { - continue; + int targetY = h + 1; + if (h >= max || targetY >= engineHeight + || !DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { + break; } double threshold = ((double) i) / (stack - 1); - data.set(x, h + 1, z, threshold >= decorator.getTopThreshold() + PlatformBlockState block = threshold >= decorator.getTopThreshold() ? decorator.getBlockDataForTop(biome, rng, realX, h, realZ, getData()) - : decorator.getBlockData100(biome, rng, realX, h, realZ, getData())); + : decorator.getBlockData100(biome, rng, realX, h, realZ, getData()); + if (block == null) { + break; + } + data.set(x, targetY, z, block); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java index d045b12b0..513852038 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java @@ -66,7 +66,15 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { } if (!decorator.isStacking()) { - data.set(x, height + 1, z, decorator.getBlockData100(biome, rng, realX, height, realZ, getData())); + int targetY = height + 1; + if (targetY >= data.getHeight() + || !DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { + return; + } + PlatformBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData()); + if (block != null) { + data.set(x, targetY, z, block); + } return; } @@ -78,16 +86,33 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { } if (stack == 1) { - data.set(x, height, z, decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData())); + int targetY = height + 1; + if (targetY >= data.getHeight() || !DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { + return; + } + + PlatformBlockState block = decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData()); + if (block != null) { + data.set(x, targetY, z, block); + } return; } for (int i = 0; i < stack; i++) { int h = height + i; + int targetY = h + 1; + if (targetY >= data.getHeight() + || !DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { + break; + } double threshold = ((double) i) / (stack - 1); - data.set(x, h + 1, z, threshold >= decorator.getTopThreshold() + PlatformBlockState block = threshold >= decorator.getTopThreshold() ? decorator.getBlockDataForTop(biome, rng, realX, h, realZ, getData()) - : decorator.getBlockData100(biome, rng, realX, h, realZ, getData())); + : decorator.getBlockData100(biome, rng, realX, h, realZ, getData()); + if (block == null) { + break; + } + data.set(x, targetY, z, block); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index a5a5c3daa..44f277747 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -36,9 +36,6 @@ import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionCarvingEntry; import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; import art.arcane.iris.engine.object.IrisEngineData; -import art.arcane.iris.engine.object.IrisLootMode; -import art.arcane.iris.engine.object.IrisLootReference; -import art.arcane.iris.engine.object.IrisLootTable; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisPosition; @@ -83,7 +80,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.stream.Collectors; -public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdater, Renderer, Hotloadable { +public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, Hotloadable { IrisComplex getComplex(); default @Nullable UpperDimensionContext getUpperContext() { @@ -319,18 +316,6 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat void blockUpdatedMetric(); - @Override - default void injectTables(KList list, IrisLootReference r, boolean fallback) { - if (r.getMode().equals(IrisLootMode.FALLBACK) && !fallback) - return; - - if (r.getMode().equals(IrisLootMode.CLEAR) || r.getMode().equals(IrisLootMode.REPLACE)) { - list.clear(); - } - - list.addAll(r.getLootTables(getComplex())); - } - EngineEffects getEffects(); default MultiBurst burst() { @@ -591,7 +576,7 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat if (marker.structureAware()) { IrisObject placedObject = getData().getObjectLoader().load(object); - IrisStructure structure = IrisData.loadAnyStructure(marker.structureKey(), getData()); + IrisStructure structure = getData().load(IrisStructure.class, marker.structureKey(), false); IrisObjectPlacement placement = placedObject == null || structure == null ? null : structure.createLootPlacement(object); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java new file mode 100644 index 000000000..44d7ac51b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java @@ -0,0 +1,618 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.framework; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.util.common.parallel.BurstExecutor; +import art.arcane.iris.util.common.parallel.MultiBurst; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.collection.KSet; +import art.arcane.volmlib.util.math.Position2; +import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; + +import java.util.ArrayDeque; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; + +public final class HintedLocator implements Locator { + private static final int BIOME_STRIDE_CHUNKS = 4; + private static final int REGION_STRIDE_CHUNKS = 16; + private static final int MAX_SAMPLE_RADIUS_CHUNKS = 100000; + + private final Locator exact; + private final Function planner; + + public HintedLocator(Locator exact, Function planner) { + this.exact = exact; + this.planner = planner; + } + + @FunctionalInterface + public interface CoarseSample { + boolean test(int blockX, int blockZ); + } + + public static final class SearchPlan { + private final boolean possible; + private final CoarseSample coarse; + private final int strideChunks; + private final Locator exactOverride; + + private SearchPlan(boolean possible, CoarseSample coarse, int strideChunks, Locator exactOverride) { + this.possible = possible; + this.coarse = coarse; + this.strideChunks = strideChunks; + this.exactOverride = exactOverride; + } + + public static SearchPlan impossible() { + return new SearchPlan(false, null, 1, null); + } + + public static SearchPlan unpruned() { + return new SearchPlan(true, null, 1, null); + } + + public static SearchPlan of(CoarseSample coarse, int strideChunks, Locator exactOverride) { + return new SearchPlan(true, coarse, strideChunks, exactOverride); + } + + public boolean isPossible() { + return possible; + } + + public CoarseSample getCoarse() { + return coarse; + } + + public int getStrideChunks() { + return strideChunks; + } + + public Locator getExactOverride() { + return exactOverride; + } + } + + @Override + public boolean matches(Engine engine, Position2 chunk) { + return exact.matches(engine, chunk); + } + + @Override + public Future find(Engine engine, Position2 pos, long timeout, Consumer checks) throws WrongEngineBroException { + if (engine.isClosed()) { + throw new WrongEngineBroException(); + } + + Locator.cancelSearch(); + + return MultiBurst.burst.completeValue(() -> { + AtomicBoolean stop = new AtomicBoolean(false); + LocatorCanceller.cancel = () -> stop.set(true); + + try { + SearchPlan plan = planner.apply(engine); + + if (!plan.isPossible()) { + return null; + } + + return search(engine, plan, pos, timeout, checks, stop); + } finally { + LocatorCanceller.cancel = null; + } + }); + } + + private Position2 search(Engine engine, SearchPlan plan, Position2 pos, long timeout, Consumer checks, AtomicBoolean stop) { + Locator verifier = plan.getExactOverride() != null ? plan.getExactOverride() : exact; + int stride = Math.max(1, plan.getStrideChunks()); + int batchTarget = IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()) * 32; + int maxRing = Math.max(1, MAX_SAMPLE_RADIUS_CHUNKS / stride); + PrecisionStopwatch stopwatch = PrecisionStopwatch.start(); + AtomicInteger covered = new AtomicInteger(); + KList batch = new KList<>(); + + for (int ring = 0; ring <= maxRing; ring++) { + if (stop.get() || stopwatch.getMilliseconds() >= timeout) { + return null; + } + + appendRing(batch, pos, ring, stride); + + if (batch.size() < batchTarget && ring < maxRing) { + continue; + } + + Position2 result = processBatch(engine, plan, verifier, batch, stride, timeout, stopwatch, covered, checks, stop); + batch.clear(); + + if (result != null) { + return result; + } + } + + return null; + } + + private Position2 processBatch(Engine engine, SearchPlan plan, Locator verifier, KList batch, int stride, long timeout, PrecisionStopwatch stopwatch, AtomicInteger covered, Consumer checks, AtomicBoolean stop) { + int size = batch.size(); + boolean[] hits = new boolean[size]; + boolean fine = stride <= 1; + CoarseSample coarse = plan.getCoarse(); + AtomicBoolean matched = new AtomicBoolean(false); + BurstExecutor executor = MultiBurst.burst.burst(size); + + for (int i = 0; i < size; i++) { + int index = i; + Position2 sample = batch.get(i); + executor.queue(() -> { + if (stop.get() || (fine && matched.get())) { + return; + } + + int blockX = (sample.getX() << 4) + 8; + int blockZ = (sample.getZ() << 4) + 8; + + if (coarse != null && !coarse.test(blockX, blockZ)) { + return; + } + + if (fine) { + if (verifier.matches(engine, sample)) { + hits[index] = true; + matched.set(true); + } + + return; + } + + hits[index] = true; + }); + } + + executor.complete(); + covered.addAndGet(size * stride * stride); + checks.accept(covered.get()); + + for (int i = 0; i < size; i++) { + if (!hits[i]) { + continue; + } + + if (fine) { + return batch.get(i); + } + + if (stop.get() || stopwatch.getMilliseconds() >= timeout) { + return null; + } + + Position2 refined = refine(engine, verifier, batch.get(i), stride, timeout, stopwatch, stop); + + if (refined != null) { + return refined; + } + } + + return null; + } + + private Position2 refine(Engine engine, Locator verifier, Position2 candidate, int stride, long timeout, PrecisionStopwatch stopwatch, AtomicBoolean stop) { + KList cells = new KList<>(); + + for (int ring = 0; ring <= stride; ring++) { + if (stop.get() || stopwatch.getMilliseconds() >= timeout) { + return null; + } + + cells.clear(); + appendRing(cells, candidate, ring, 1); + AtomicReference found = new AtomicReference<>(); + BurstExecutor executor = MultiBurst.burst.burst(cells.size()); + + for (Position2 cell : cells) { + executor.queue(() -> { + if (stop.get() || found.get() != null) { + return; + } + + if (verifier.matches(engine, cell)) { + found.compareAndSet(null, cell); + } + }); + } + + executor.complete(); + + if (found.get() != null) { + return found.get(); + } + } + + return null; + } + + static void appendRing(KList batch, Position2 origin, int ring, int stride) { + if (ring == 0) { + batch.add(origin); + return; + } + + int offset = ring * stride; + + for (int dx = -ring; dx <= ring; dx++) { + int x = origin.getX() + (dx * stride); + batch.add(new Position2(x, origin.getZ() - offset)); + batch.add(new Position2(x, origin.getZ() + offset)); + } + + for (int dz = -ring + 1; dz <= ring - 1; dz++) { + int z = origin.getZ() + (dz * stride); + batch.add(new Position2(origin.getX() - offset, z)); + batch.add(new Position2(origin.getX() + offset, z)); + } + } + + public static SearchPlan biomePlan(Engine engine, String biomeKey) { + if (engine.getFocus() != null || engine.getFocusRegion() != null) { + return SearchPlan.unpruned(); + } + + IrisComplex complex = engine.getComplex(); + BiomeSource source = new BiomeSource(engine); + KSet landHosts = new KSet<>(); + KSet seaHosts = new KSet<>(); + KSet shoreHosts = new KSet<>(); + KSet surfaceRegions = new KSet<>(); + KSet caveRegions = new KSet<>(); + + for (IrisRegion region : engine.getDimension().getAllRegions(engine)) { + if (region == null) { + continue; + } + + boolean hosted = false; + + for (String root : region.getLandBiomes()) { + if (source.closureContains(root, biomeKey)) { + landHosts.add(root); + hosted = true; + } + } + + for (String root : region.getSeaBiomes()) { + if (source.closureContains(root, biomeKey)) { + seaHosts.add(root); + hosted = true; + } + } + + for (String root : region.getShoreBiomes()) { + if (source.closureContains(root, biomeKey)) { + shoreHosts.add(root); + hosted = true; + } + } + + if (hosted) { + surfaceRegions.add(region.getLoadKey()); + } + + if (region.getCaveBiomes().contains(biomeKey)) { + caveRegions.add(region.getLoadKey()); + } + } + + if (!surfaceRegions.isEmpty()) { + return SearchPlan.of(surfaceCoarse(complex, surfaceRegions, landHosts, seaHosts, shoreHosts), BIOME_STRIDE_CHUNKS, null); + } + + if (!caveRegions.isEmpty()) { + CoarseSample coarse = caveCoarse(complex, caveRegions, biomeKey); + Locator caveExact = (e, c) -> { + IrisBiome biome = e.getCaveBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8); + return biome != null && biomeKey.equals(biome.getLoadKey()); + }; + + return SearchPlan.of(coarse, BIOME_STRIDE_CHUNKS, caveExact); + } + + return SearchPlan.impossible(); + } + + public static SearchPlan caveBiomePlan(Engine engine, String biomeKey) { + if (engine.getFocus() != null || engine.getFocusRegion() != null) { + return SearchPlan.unpruned(); + } + + IrisComplex complex = engine.getComplex(); + KSet caveRegions = new KSet<>(); + + for (IrisRegion region : engine.getDimension().getAllRegions(engine)) { + if (region != null && region.getCaveBiomes().contains(biomeKey)) { + caveRegions.add(region.getLoadKey()); + } + } + + if (caveRegions.isEmpty()) { + return SearchPlan.impossible(); + } + + return SearchPlan.of(caveCoarse(complex, caveRegions, biomeKey), BIOME_STRIDE_CHUNKS, null); + } + + public static SearchPlan regionPlan(Engine engine, String regionKey) { + if (engine.getFocus() != null || engine.getFocusRegion() != null) { + return SearchPlan.unpruned(); + } + + if (!engine.getDimension().getRegions().contains(regionKey)) { + return SearchPlan.impossible(); + } + + IrisComplex complex = engine.getComplex(); + CoarseSample coarse = (x, z) -> { + IrisRegion region = complex.getRegionStream().get(x, z); + return region != null && regionKey.equals(region.getLoadKey()); + }; + + return SearchPlan.of(coarse, REGION_STRIDE_CHUNKS, null); + } + + public static SearchPlan objectPlan(Engine engine, String objectKey) { + if (engine.getFocus() != null || engine.getFocusRegion() != null) { + return SearchPlan.unpruned(); + } + + IrisComplex complex = engine.getComplex(); + BiomeSource source = new BiomeSource(engine); + KSet landHosts = new KSet<>(); + KSet seaHosts = new KSet<>(); + KSet shoreHosts = new KSet<>(); + KSet directRegions = new KSet<>(); + KSet hostedRegions = new KSet<>(); + + for (IrisRegion region : engine.getDimension().getAllRegions(engine)) { + if (region == null) { + continue; + } + + if (placesObject(region.getSurfaceObjects(), objectKey)) { + directRegions.add(region.getLoadKey()); + } + + boolean hosted = false; + + for (String root : region.getLandBiomes()) { + if (source.closurePlacesObject(root, objectKey)) { + landHosts.add(root); + hosted = true; + } + } + + for (String root : region.getSeaBiomes()) { + if (source.closurePlacesObject(root, objectKey)) { + seaHosts.add(root); + hosted = true; + } + } + + for (String root : region.getShoreBiomes()) { + if (source.closurePlacesObject(root, objectKey)) { + shoreHosts.add(root); + hosted = true; + } + } + + if (hosted) { + hostedRegions.add(region.getLoadKey()); + } + } + + if (directRegions.isEmpty() && hostedRegions.isEmpty()) { + return SearchPlan.impossible(); + } + + CoarseSample surface = hostedRegions.isEmpty() ? null : surfaceCoarse(complex, hostedRegions, landHosts, seaHosts, shoreHosts); + CoarseSample coarse = (x, z) -> { + if (!directRegions.isEmpty()) { + IrisRegion region = complex.getRegionStream().get(x, z); + + if (region != null && directRegions.contains(region.getLoadKey())) { + return true; + } + } + + return surface != null && surface.test(x, z); + }; + + return SearchPlan.of(coarse, 1, null); + } + + private static CoarseSample surfaceCoarse(IrisComplex complex, KSet regions, KSet landHosts, KSet seaHosts, KSet shoreHosts) { + return (x, z) -> { + IrisRegion region = complex.getRegionStream().get(x, z); + + if (region == null || !regions.contains(region.getLoadKey())) { + return false; + } + + if (!landHosts.isEmpty()) { + IrisBiome biome = complex.getLandBiomeStream().get(x, z); + + if (biome != null && landHosts.contains(biome.getLoadKey())) { + return true; + } + } + + if (!seaHosts.isEmpty()) { + IrisBiome biome = complex.getSeaBiomeStream().get(x, z); + + if (biome != null && seaHosts.contains(biome.getLoadKey())) { + return true; + } + } + + if (!shoreHosts.isEmpty()) { + IrisBiome biome = complex.getShoreBiomeStream().get(x, z); + + if (biome != null && shoreHosts.contains(biome.getLoadKey())) { + return true; + } + } + + return false; + }; + } + + private static CoarseSample caveCoarse(IrisComplex complex, KSet caveRegions, String biomeKey) { + return (x, z) -> { + IrisRegion region = complex.getRegionStream().get(x, z); + + if (region == null || !caveRegions.contains(region.getLoadKey())) { + return false; + } + + IrisBiome cave = complex.getCaveBiomeStream().get(x, z); + return cave != null && biomeKey.equals(cave.getLoadKey()); + }; + } + + private static boolean placesObject(KList placements, String objectKey) { + for (IrisObjectPlacement placement : placements) { + if (placement != null && placement.getPlace().contains(objectKey)) { + return true; + } + } + + return false; + } + + private static final class BiomeSource { + private final Engine engine; + private final KMap cache = new KMap<>(); + + private BiomeSource(Engine engine) { + this.engine = engine; + } + + private IrisBiome load(String key) { + if (key == null) { + return null; + } + + IrisBiome cached = cache.get(key); + + if (cached != null) { + return cached; + } + + IrisBiome loaded = engine.getData().getBiomeLoader().load(key); + + if (loaded != null) { + cache.put(key, loaded); + } + + return loaded; + } + + private boolean closureContains(String rootKey, String targetKey) { + if (rootKey == null || targetKey == null) { + return false; + } + + KSet visited = new KSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + queue.add(rootKey); + + while (!queue.isEmpty()) { + String key = queue.poll(); + + if (!visited.add(key)) { + continue; + } + + if (key.equals(targetKey)) { + return true; + } + + IrisBiome biome = load(key); + + if (biome == null) { + continue; + } + + for (String child : biome.getChildren()) { + if (child != null && !visited.contains(child)) { + queue.add(child); + } + } + } + + return false; + } + + private boolean closurePlacesObject(String rootKey, String objectKey) { + if (rootKey == null || objectKey == null) { + return false; + } + + KSet visited = new KSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + queue.add(rootKey); + + while (!queue.isEmpty()) { + String key = queue.poll(); + + if (!visited.add(key)) { + continue; + } + + IrisBiome biome = load(key); + + if (biome == null) { + continue; + } + + if (placesObject(biome.getSurfaceObjects(), objectKey)) { + return true; + } + + for (String child : biome.getChildren()) { + if (child != null && !visited.contains(child)) { + queue.add(child); + } + } + } + + return false; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java index bbecdfdb5..b7eee71b9 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java @@ -19,16 +19,21 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.structure.StructureGraphCatalog; +import art.arcane.iris.engine.framework.structure.StructureGraphCompilation; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.NativeStructureSuppression; import art.arcane.iris.engine.object.ObjectPlaceMode; import art.arcane.iris.engine.object.StructureDistribution; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import java.util.ArrayList; import java.util.Collections; @@ -36,6 +41,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.regex.Pattern; /** * Finds where IRIS_PLACED structures generate. A structure key matches either the iris @@ -53,10 +59,14 @@ import java.util.Set; */ public final class IrisStructureLocator { private static final int DENSITY_CANDIDATE_BUDGET = 4_096; + private static final int MAX_BURIAL_COLUMNS = 2_000_000; + private static final int UNDERGROUND_SURFACE_CLEARANCE = 1; + private static final Pattern NAMESPACED_RESOURCE_KEY = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); private static final Cache INDEX_CACHE = Caffeine.newBuilder().weakKeys().build(); private static final PlacementIndex EMPTY_INDEX = new PlacementIndex( - Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptyList()); + Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), + Collections.emptyList()); private static final LocateResult NOT_FOUND_RESULT = new LocateResult(LocateStatus.NOT_FOUND, 0, 0, 0); private static final LocateResult SEARCH_LIMIT_RESULT = new LocateResult(LocateStatus.SEARCH_LIMIT_REACHED, 0, 0, 0); @@ -79,14 +89,14 @@ public final class IrisStructureLocator { PlacementIndex placementIndex = index(engine); String normalizedKey = normalize(key); return placementIndex.normalizedLoadKeys.contains(normalizedKey) - || placementIndex.vanillaSources.contains(normalizedKey); + || placementIndex.vanillaAliases.contains(normalizedKey); } public static boolean suppressesVanilla(Engine engine, String vanillaKey) { if (engine == null || vanillaKey == null || vanillaKey.isEmpty()) { return false; } - return index(engine).vanillaSources.contains(normalize(vanillaKey)); + return index(engine).suppressedVanillaSources.contains(normalize(vanillaKey)); } public static void invalidate(Engine engine) { @@ -117,7 +127,7 @@ public final class IrisStructureLocator { } SeedManager seedManager = engine.getSeedManager(); if (seedManager == null) { - return NOT_FOUND_RESULT; + throw new IllegalStateException("Iris structure locate requires a bound seed manager for '" + key + "'"); } long seed = seedManager.getMantle(); @@ -210,18 +220,20 @@ public final class IrisStructureLocator { return new LocateResult(LocateStatus.FOUND, resolved.originX(), resolved.baseY(), resolved.originZ()); } - public static ResolvedPlacement resolvePlacement(Engine engine, IrisStructurePlacement placement, int cx, int cz, int placementOrdinal) { - if (engine == null || placement == null || placement.getDistribution() == null - || placement.getStructures() == null || placement.getStructures().isEmpty() - || engine.getData() == null || engine.getSeedManager() == null) { - return null; + public static ResolvedPlacement resolvePlacement(Engine engine, IrisStructurePlacement placement, int cx, int cz) { + if (engine == null || engine.getData() == null || engine.getSeedManager() == null) { + throw new IllegalStateException("Iris structure placement requires a fully bound engine"); + } + if (placement == null || placement.getDistribution() == null + || placement.getStructures() == null || placement.getStructures().isEmpty()) { + throw new IllegalStateException("Iris structure placement is missing its distribution or structure list"); } long seed = engine.getSeedManager().getMantle(); - if (!StructurePlacementGrid.startsInChunk(placement, cx, cz, seed, placementOrdinal)) { + if (!StructurePlacementGrid.startsInChunk(placement, cx, cz, seed)) { return null; } - RNG rng = StructurePlacementGrid.placementRng(placement, cx, cz, seed, placementOrdinal); + RNG rng = StructurePlacementGrid.placementRng(placement, cx, cz, seed); int originX = (cx << 4) + rng.nextInt(16); int originZ = (cz << 4) + rng.nextInt(16); Integer baseY = resolveBaseY(engine, placement, originX, originZ, rng); @@ -231,26 +243,39 @@ public final class IrisStructureLocator { String selectedKey = selectStructureKey(placement, rng); if (selectedKey == null || selectedKey.isBlank()) { - return null; + throw new IllegalStateException("Iris structure placement selected a blank structure key"); } - IrisStructure structure = IrisData.loadAnyStructure(selectedKey, engine.getData()); + IrisStructure structure = engine.getData().load(IrisStructure.class, selectedKey, false); if (structure == null) { - return null; + throw new IllegalStateException("Iris structure placement references missing structure '" + + selectedKey + "'"); + } + StructureGraphCompilation compilation = StructureGraphCatalog.compile(engine.getData(), structure); + if (!compilation.isAssemblyViable()) { + throw new IllegalStateException("Iris structure '" + selectedKey + + "' has no runtime-viable assembly graph"); } - StructureAssembler assembler = new StructureAssembler(engine.getData(), structure, originX, baseY, originZ); + StructureAssembler assembler = StructureAssembler.forData( + engine.getData(), structure, new IrisPosition(originX, baseY, originZ)); KList pieces = assembler.assemble(rng); - if (pieces == null || pieces.isEmpty()) { + if (!requirePlacementOutput(placement, selectedKey, cx, cz, + pieces != null && !pieces.isEmpty(), "runtime assembly produced no pieces")) { return null; } pieces = alignSurfacePieces(pieces, placement, structure, baseY); + if (!requirePlacementOutput(placement, selectedKey, cx, cz, + pieces != null && !pieces.isEmpty(), "surface alignment produced no pieces")) { + return null; + } boolean exactY = hasExactY(placement, structure, pieces); int worldMin = engine.getMinHeight() + 1; int worldMax = engine.getMinHeight() + engine.getHeight() - 1; if (exactY) { Integer verticalShift = resolveVerticalShift(pieces, placement, baseY, worldMin, worldMax); - if (verticalShift == null) { + if (!requirePlacementOutput(placement, selectedKey, cx, cz, verticalShift != null, + "assembled pieces cannot fit the configured vertical and world bounds")) { return null; } if (verticalShift != 0) { @@ -258,16 +283,49 @@ public final class IrisStructureLocator { baseY += verticalShift; } } + if (placement.isUnderground()) { + Integer burialShift = resolveUndergroundBurialShift( + engine, pieces, placement, baseY, worldMin, worldMax); + if (!requirePlacementOutput(placement, selectedKey, cx, cz, burialShift != null, + "assembled pieces and carving envelope cannot remain fully buried beneath terrain")) { + return null; + } + if (burialShift != 0) { + pieces = shiftPieces(pieces, burialShift); + baseY += burialShift; + } + } long configuredRadius = (long) Math.max(1, structure.getMaxSizeChunks()) * 16L; int structureRadius = (int) Math.min(Integer.MAX_VALUE, configuredRadius); - if (!fitsHorizontalBounds(pieces, originX, originZ, structureRadius) - || (exactY && !fitsVerticalBounds(pieces, worldMin, worldMax))) { + boolean withinBounds = fitsHorizontalBounds(pieces, originX, originZ, structureRadius) + && (!exactY || fitsVerticalBounds(pieces, worldMin, worldMax)); + if (!requirePlacementOutput(placement, selectedKey, cx, cz, withinBounds, + "assembled pieces exceed the configured structure or world bounds")) { return null; } return new ResolvedPlacement(placement, selectedKey, structure, pieces, rng, originX, baseY, originZ, exactY); } + public static boolean requirePlacementOutput(IrisStructurePlacement placement, String structureKey, + int chunkX, int chunkZ, boolean outputPresent, + String failureReason) { + if (outputPresent) { + return true; + } + if (placement == null + || NativeStructureSuppression.REPLACE_SOURCE != placement.getNativeSuppression()) { + return false; + } + String key = structureKey == null || structureKey.isBlank() + ? String.valueOf(placement.getStructures()) : structureKey; + String reason = failureReason == null || failureReason.isBlank() + ? "placement produced no output" : failureReason; + throw new IllegalStateException("REPLACE_SOURCE placement for Iris structure '" + key + + "' failed in chunk " + chunkX + "," + chunkZ + ": " + reason + + ". Native generation is suppressed and will not be used as a fallback"); + } + static boolean fitsWorldBounds(KList pieces, int worldMin, int worldMax, int originX, int originZ, int structureRadius) { int[] bounds = computeBounds(pieces); @@ -281,12 +339,11 @@ public final class IrisStructureLocator { private static ResolvedPlacement resolveInChunk(Engine engine, String key, int cx, int cz) { IrisData data = engine.getData(); KList placements = placementsAt(engine, cx, cz); - for (int placementOrdinal = 0; placementOrdinal < placements.size(); placementOrdinal++) { - IrisStructurePlacement placement = placements.get(placementOrdinal); + for (IrisStructurePlacement placement : placements) { if (!matches(placement, key, data)) { continue; } - ResolvedPlacement resolved = resolvePlacement(engine, placement, cx, cz, placementOrdinal); + ResolvedPlacement resolved = resolvePlacement(engine, placement, cx, cz); if (resolved != null && matchesResolved(resolved, key)) { return resolved; } @@ -337,6 +394,86 @@ public final class IrisStructureLocator { return 0; } + static Integer resolveUndergroundBurialShift(Engine engine, KList pieces, + IrisStructurePlacement placement, int baseY, + int worldMin, int worldMax) { + int[] bounds = computeBounds(pieces); + if (engine == null || bounds == null || placement == null || !placement.isUnderground()) { + return null; + } + + int sideExtension = placement.isOverbore() + ? Math.max(1, placement.getOverboreRadius()) + : placement.isBore() ? Math.max(0, placement.getBorePadding()) : 0; + int topExtension = placement.isOverbore() + ? (int) Math.ceil(Math.max(1D, placement.getOverboreHeight()) * 1.8D) + : placement.isBore() ? Math.max(0, placement.getBorePadding()) : 0; + int bottomExtension = placement.isOverbore() ? Math.max(0, placement.getOverboreFloor()) : 0; + int bandMin = Math.max(worldMin, Math.min(placement.getMinHeight(), placement.getMaxHeight())); + int bandMax = Math.min(worldMax, Math.max(placement.getMinHeight(), placement.getMaxHeight())); + int minimumShift = Math.max(worldMin - (bounds[1] - bottomExtension), bandMin - baseY); + int maximumShift = Math.min(0, Math.min(worldMax - (bounds[4] + topExtension), bandMax - baseY)); + if (minimumShift > maximumShift) { + return null; + } + + Long2IntOpenHashMap surfaceHeights = new Long2IntOpenHashMap(); + surfaceHeights.defaultReturnValue(Integer.MIN_VALUE); + if (placement.isBore() && !placement.isOverbore()) { + maximumShift = resolveBurialEnvelopeShift( + engine, + bounds[0] - sideExtension, + bounds[3] + sideExtension, + bounds[2] - sideExtension, + bounds[5] + sideExtension, + bounds[4] + topExtension, + maximumShift, + surfaceHeights + ); + if (maximumShift == Integer.MIN_VALUE) { + return null; + } + } else { + for (PlacedStructurePiece piece : pieces) { + maximumShift = resolveBurialEnvelopeShift( + engine, + piece.getMinX() - sideExtension, + piece.getMaxX() + sideExtension, + piece.getMinZ() - sideExtension, + piece.getMaxZ() + sideExtension, + piece.getMaxY() + topExtension, + maximumShift, + surfaceHeights + ); + if (maximumShift == Integer.MIN_VALUE) { + return null; + } + } + } + return minimumShift > maximumShift ? null : maximumShift; + } + + private static int resolveBurialEnvelopeShift(Engine engine, int minX, int maxX, int minZ, int maxZ, + int envelopeTopY, int maximumShift, + Long2IntOpenHashMap surfaceHeights) { + for (int x = minX; x <= maxX; x++) { + for (int z = minZ; z <= maxZ; z++) { + long columnKey = ((long) x << 32) ^ (z & 0xffffffffL); + int surfaceY = surfaceHeights.get(columnKey); + if (surfaceY == Integer.MIN_VALUE) { + if (surfaceHeights.size() >= MAX_BURIAL_COLUMNS) { + return Integer.MIN_VALUE; + } + surfaceY = engine.getHeight(x, z, true) + engine.getMinHeight(); + surfaceHeights.put(columnKey, surfaceY); + } + int allowedTopY = surfaceY - UNDERGROUND_SURFACE_CLEARANCE; + maximumShift = Math.min(maximumShift, allowedTopY - envelopeTopY); + } + } + return maximumShift; + } + static String selectStructureKey(IrisStructurePlacement placement, RNG rng) { if (placement == null || placement.getStructures() == null || placement.getStructures().isEmpty()) { return null; @@ -432,7 +569,8 @@ public final class IrisStructureLocator { } if (placement.getDistribution() == StructureDistribution.RANDOM_SPREAD) { randomSpread.add(new RandomSpreadParameters( - Math.max(1, placement.getSpacing()), placement.getSeparation(), placement.getSalt())); + Math.max(1, placement.getSpacing()), placement.getSeparation(), + StructurePlacementGrid.placementSalt(placement))); } else if (placement.getDistribution() == StructureDistribution.CONCENTRIC_RINGS) { concentricRings.add(placement); } else if (isSearchableDensityPlacement(engine, placement)) { @@ -511,7 +649,7 @@ public final class IrisStructureLocator { if (structureKey == null || structureKey.isBlank()) { continue; } - IrisStructure structure = IrisData.loadAnyStructure(structureKey, data); + IrisStructure structure = data.load(IrisStructure.class, structureKey, false); if (structure == null) { continue; } @@ -536,9 +674,12 @@ public final class IrisStructureLocator { } private static PlacementIndex index(Engine engine) { - if (engine == null || engine.getData() == null || engine.getDimension() == null) { + if (engine == null) { return EMPTY_INDEX; } + if (engine.getData() == null || engine.getDimension() == null) { + throw new IllegalStateException("Iris structure index requires a fully bound engine and dimension"); + } return INDEX_CACHE.get(engine, ignored -> build(engine)); } @@ -546,42 +687,63 @@ public final class IrisStructureLocator { IrisData data = engine.getData(); Set loadKeys = new LinkedHashSet<>(); Set normalizedLoadKeys = new LinkedHashSet<>(); - Set vanillaSources = new LinkedHashSet<>(); + Set vanillaAliases = new LinkedHashSet<>(); + Set suppressedVanillaSources = new LinkedHashSet<>(); List placements = new ArrayList<>(); - collect(engine.getDimension().getStructures(), data, loadKeys, normalizedLoadKeys, vanillaSources, placements); + collect(engine.getDimension().getStructures(), data, loadKeys, normalizedLoadKeys, vanillaAliases, + suppressedVanillaSources, placements, true); for (IrisRegion region : engine.getDimension().getAllRegions(engine)) { - collect(region.getStructures(), data, loadKeys, normalizedLoadKeys, vanillaSources, placements); + collect(region.getStructures(), data, loadKeys, normalizedLoadKeys, vanillaAliases, + suppressedVanillaSources, placements, false); } for (IrisBiome biome : engine.getDimension().getReachableBiomes(engine)) { - collect(biome.getStructures(), data, loadKeys, normalizedLoadKeys, vanillaSources, placements); + collect(biome.getStructures(), data, loadKeys, normalizedLoadKeys, vanillaAliases, + suppressedVanillaSources, placements, false); } return new PlacementIndex( Collections.unmodifiableSet(loadKeys), Collections.unmodifiableSet(normalizedLoadKeys), - Collections.unmodifiableSet(vanillaSources), + Collections.unmodifiableSet(vanillaAliases), + Collections.unmodifiableSet(suppressedVanillaSources), List.copyOf(placements)); } private static void collect(KList source, IrisData data, Set loadKeys, - Set normalizedLoadKeys, Set vanillaSources, - List placements) { + Set normalizedLoadKeys, Set vanillaAliases, + Set suppressedVanillaSources, + List placements, boolean allowNativeSuppression) { if (source == null) { return; } for (IrisStructurePlacement placement : source) { - if (placement == null || placement.getStructures() == null) { - continue; + if (placement == null) { + throw new IllegalStateException("Iris structure placement list contains a null placement"); + } + if (placement.getDistribution() == null) { + throw new IllegalStateException("Iris structure placement is missing its distribution"); + } + boolean replacesNative = NativeStructureSuppression.REPLACE_SOURCE == placement.getNativeSuppression(); + if (replacesNative && !allowNativeSuppression) { + throw new IllegalStateException("REPLACE_SOURCE is only valid on dimension-level structure placements"); + } + if (placement.getStructures() == null || placement.getStructures().isEmpty()) { + throw new IllegalStateException(replacesNative + ? "REPLACE_SOURCE requires at least one Iris structure reference" + : "Iris structure placement requires at least one structure reference"); } - boolean validPlacement = false; for (String structureKey : placement.getStructures()) { if (structureKey == null || structureKey.isBlank()) { - continue; + throw new IllegalStateException(replacesNative + ? "REPLACE_SOURCE contains a blank Iris structure reference" + : "Iris structure placement contains a blank structure reference"); } - IrisStructure structure = IrisData.loadAnyStructure(structureKey, data); + IrisStructure structure = data.load(IrisStructure.class, structureKey, false); if (structure == null) { - continue; + throw new IllegalStateException((replacesNative + ? "REPLACE_SOURCE references missing Iris structure '" + : "Iris structure placement references missing structure '") + + structureKey + "'"); } - validPlacement = true; loadKeys.add(structureKey); normalizedLoadKeys.add(normalize(structureKey)); if (structure.getLoadKey() != null && !structure.getLoadKey().isBlank()) { @@ -589,12 +751,22 @@ public final class IrisStructureLocator { normalizedLoadKeys.add(normalize(structure.getLoadKey())); } if (structure.getVanillaSource() != null && !structure.getVanillaSource().isBlank()) { - vanillaSources.add(normalize(structure.getVanillaSource())); + vanillaAliases.add(normalize(structure.getVanillaSource())); + } + if (replacesNative) { + String vanillaSource = structure.getVanillaSource(); + if (vanillaSource == null || !NAMESPACED_RESOURCE_KEY.matcher(vanillaSource).matches()) { + throw new IllegalStateException("REPLACE_SOURCE structure '" + structureKey + + "' must declare a valid namespaced vanillaSource"); + } + if (!StructureGraphCatalog.guaranteesRuntimeOutput(data, structure)) { + throw new IllegalStateException("REPLACE_SOURCE structure '" + structureKey + + "' is not runtime-viable; native generation will not be used as a fallback"); + } + suppressedVanillaSources.add(normalize(vanillaSource)); } } - if (validPlacement) { - placements.add(placement); - } + placements.add(placement); } } @@ -628,14 +800,17 @@ public final class IrisStructureLocator { private static final class PlacementIndex { private final Set loadKeys; private final Set normalizedLoadKeys; - private final Set vanillaSources; + private final Set vanillaAliases; + private final Set suppressedVanillaSources; private final List placements; private PlacementIndex(Set loadKeys, Set normalizedLoadKeys, - Set vanillaSources, List placements) { + Set vanillaAliases, Set suppressedVanillaSources, + List placements) { this.loadKeys = loadKeys; this.normalizedLoadKeys = normalizedLoadKeys; - this.vanillaSources = vanillaSources; + this.vanillaAliases = vanillaAliases; + this.suppressedVanillaSources = suppressedVanillaSources; this.placements = placements; } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Locator.java b/core/src/main/java/art/arcane/iris/engine/framework/Locator.java index e49cfaeea..c6b77cf1e 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Locator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Locator.java @@ -50,15 +50,18 @@ public interface Locator { } static Locator region(String loadKey) { - return (e, c) -> e.getRegion((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey); + Locator exact = (e, c) -> e.getRegion((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey); + return new HintedLocator<>(exact, (engine) -> HintedLocator.regionPlan(engine, loadKey)); } static Locator object(String loadKey) { - return (e, c) -> e.getObjectsAt(c.getX(), c.getZ()).contains(loadKey); + Locator exact = (e, c) -> e.getObjectsAt(c.getX(), c.getZ()).contains(loadKey); + return new HintedLocator<>(exact, (engine) -> HintedLocator.objectPlan(engine, loadKey)); } static Locator surfaceBiome(String loadKey) { - return (e, c) -> e.getSurfaceBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey); + Locator exact = (e, c) -> e.getSurfaceBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey); + return new HintedLocator<>(exact, (engine) -> HintedLocator.biomePlan(engine, loadKey)); } static Locator structure(String key) { @@ -67,13 +70,14 @@ public interface Locator { static Locator poi(String type) { return (e, c) -> { - Set> pos = e.getPOIsAt((c.getX() << 4) + 8, (c.getZ() << 4) + 8); + Set> pos = e.getPOIsAt(c.getX(), c.getZ()); return pos.stream().anyMatch(p -> p.getA().equals(type)); }; } static Locator caveBiome(String loadKey) { - return (e, c) -> e.getCaveBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey); + Locator exact = (e, c) -> e.getCaveBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey); + return new HintedLocator<>(exact, (engine) -> HintedLocator.caveBiomePlan(engine, loadKey)); } static Locator caveOrMantleBiome(String loadKey) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/LootResolver.java b/core/src/main/java/art/arcane/iris/engine/framework/LootResolver.java new file mode 100644 index 000000000..c5eeb8bc5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/LootResolver.java @@ -0,0 +1,299 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisLootMode; +import art.arcane.iris.engine.object.IrisLootReference; +import art.arcane.iris.engine.object.IrisLootTable; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.ToIntFunction; + +public final class LootResolver { + private static final int MAX_SCALED_SOURCE_COUNT = 256; + private static final long CONTAINER_SALT = 0x632BE59BD9B4E019L; + private static final long TABLE_SALT = 0x94D049BB133111EBL; + private static final long ENTRY_SALT = 0x9E3779B97F4A7C15L; + + private LootResolver() { + } + + public static RNG containerRng(long lootSeed, int x, int y, int z) { + return new RNG(containerSeed(lootSeed, x, y, z)); + } + + public static long containerSeed(long lootSeed, int x, int y, int z) { + long seed = mix(lootSeed ^ CONTAINER_SALT); + seed = mix(seed ^ ((long) x * 0xD6E8FEB86659FD93L)); + seed = mix(seed ^ ((long) y * 0xA5A3564E27F886A7L)); + return mix(seed ^ ((long) z * 0x8CB92BA72F3D8DD7L)); + } + + public static RNG tableRng(long lootSeed, IrisLootTable table, int x, int y, int z) { + return new RNG(tableSeed(lootSeed, table, x, y, z)); + } + + public static long tableSeed(long lootSeed, IrisLootTable table, int x, int y, int z) { + return mix(containerSeed(lootSeed, x, y, z) ^ stableHash(tableIdentity(table)) ^ TABLE_SALT); + } + + public static boolean spatialOneIn( + long lootSeed, + IrisLootTable table, + int entryIndex, + int x, + int y, + int z, + long rarity + ) { + if (rarity <= 1L) { + return true; + } + long roll = mix(tableSeed(lootSeed, table, x, y, z) ^ ((long) entryIndex * ENTRY_SALT)); + return new RNG(roll).nextLong(rarity) == 0L; + } + + public static long combinedRarity(int tableRarity, int entryRarity) { + long table = Math.max(1, tableRarity); + long entry = Math.max(1, entryRarity); + if (table > Long.MAX_VALUE / entry) { + return Long.MAX_VALUE; + } + return table * entry; + } + + public static boolean oneIn(RNG rng, int rarity) { + int bound = Math.max(1, rarity); + return bound == 1 || rng.nextInt(bound) == 0; + } + + public static int inclusive(RNG rng, int first, int second) { + int lower = Math.min(first, second); + int upper = Math.max(first, second); + if (lower == upper) { + return lower; + } + long span = (long) upper - lower + 1L; + return (int) (lower + rng.nextLong(span)); + } + + public static T pickWeighted(List choices, ToIntFunction weight, RNG rng) { + long total = 0L; + for (T choice : choices) { + int value = Math.max(0, weight.applyAsInt(choice)); + total = Long.MAX_VALUE - total < value ? Long.MAX_VALUE : total + value; + } + if (total <= 0L) { + return null; + } + long pull = rng.nextLong(total); + for (T choice : choices) { + pull -= Math.max(0, weight.applyAsInt(choice)); + if (pull < 0L) { + return choice; + } + } + return null; + } + + public static void resolveEnvironmentSources( + List sources, + Engine engine, + RNG rng, + int x, + int relativeY, + int z, + boolean objectLootDefined, + Function mapper + ) { + IrisRegion region = engine.getComplex().getRegionStream().get(x, z); + IrisBiome surfaceBiome = engine.getComplex().getTrueBiomeStream().get(x, z); + double terrainHeight = engine.getComplex().getHeightStream().get(x, z); + IrisBiome contextualBiome = relativeY < terrainHeight + ? engine.getCaveBiome(x, relativeY, z) + : surfaceBiome; + if (contextualBiome == null) { + contextualBiome = surfaceBiome; + } + + boolean distinctContextualBiome = !sameBiome(surfaceBiome, contextualBiome); + double multiplier = engine.getDimension().getLoot().getMultiplier() + * region.getLoot().getMultiplier() + * surfaceBiome.getLoot().getMultiplier(); + if (distinctContextualBiome) { + multiplier *= contextualBiome.getLoot().getMultiplier(); + } + + boolean fallback = !objectLootDefined; + injectReference(sources, engine.getDimension().getLoot(), engine, mapper, fallback); + injectReference(sources, region.getLoot(), engine, mapper, fallback); + injectReference(sources, surfaceBiome.getLoot(), engine, mapper, fallback); + if (distinctContextualBiome) { + injectReference(sources, contextualBiome.getLoot(), engine, mapper, fallback); + } + scaleSources(sources, multiplier, rng); + } + + public static void injectSources( + List sources, + List additions, + IrisLootMode mode, + boolean fallback + ) { + if (mode == IrisLootMode.FALLBACK && !fallback) { + return; + } + if (mode == IrisLootMode.CLEAR || mode == IrisLootMode.REPLACE) { + sources.clear(); + } + sources.addAll(additions); + } + + public static void scaleSources(List sources, double multiplier, RNG rng) { + if (!Double.isFinite(multiplier) || multiplier < 0D) { + throw new IllegalArgumentException("Effective loot source multiplier must be finite and non-negative, got " + + multiplier + "."); + } + if (sources.isEmpty()) { + return; + } + if (sources.size() > MAX_SCALED_SOURCE_COUNT) { + throw new IllegalArgumentException("Loot source scaling starts above the maximum of " + + MAX_SCALED_SOURCE_COUNT + " sources: " + sources.size() + "."); + } + double scaledSize = sources.size() * multiplier; + if (!Double.isFinite(scaledSize) || scaledSize > MAX_SCALED_SOURCE_COUNT) { + throw new IllegalArgumentException("Loot source scaling exceeds the maximum of " + + MAX_SCALED_SOURCE_COUNT + " sources: " + sources.size() + " * " + multiplier + "."); + } + int target = (int) Math.round(scaledSize); + if (target == sources.size()) { + return; + } + List original = new ArrayList<>(sources); + int[] order = new int[original.size()]; + if (target < original.size()) { + shrinkSources(sources, original, order, target, rng); + return; + } + expandSources(sources, original, order, target, rng); + } + + static boolean sameBiome(IrisBiome first, IrisBiome second) { + if (first == second) { + return true; + } + if (first == null || second == null) { + return false; + } + return Objects.equals(first.getLoadKey(), second.getLoadKey()); + } + + static String tableIdentity(IrisLootTable table) { + if (table == null) { + return ""; + } + String loadKey = table.getLoadKey(); + if (loadKey != null && !loadKey.isBlank()) { + return loadKey; + } + String name = table.getName(); + return name == null ? "" : name; + } + + private static void injectReference( + List sources, + IrisLootReference reference, + Engine engine, + Function mapper, + boolean fallback + ) { + KList tables = reference.getLootTables(engine.getComplex()); + KList additions = new KList<>(); + for (IrisLootTable table : tables) { + if (table == null) { + continue; + } + T source = mapper.apply(table); + if (source != null) { + additions.add(source); + } + } + injectSources(sources, additions, reference.getMode(), fallback); + } + + private static void shrinkSources(List sources, List original, int[] order, int target, RNG rng) { + shuffleOrder(order, rng); + boolean[] selected = new boolean[original.size()]; + for (int index = 0; index < target; index++) { + selected[order[index]] = true; + } + sources.clear(); + for (int index = 0; index < original.size(); index++) { + if (selected[index]) { + sources.add(original.get(index)); + } + } + } + + private static void expandSources(List sources, List original, int[] order, int target, RNG rng) { + while (sources.size() < target) { + shuffleOrder(order, rng); + int additions = Math.min(target - sources.size(), original.size()); + for (int index = 0; index < additions; index++) { + sources.add(original.get(order[index])); + } + } + } + + private static void shuffleOrder(int[] order, RNG rng) { + for (int index = 0; index < order.length; index++) { + order[index] = index; + } + for (int index = 0; index < order.length - 1; index++) { + int selected = index + rng.nextInt(order.length - index); + int previous = order[index]; + order[index] = order[selected]; + order[selected] = previous; + } + } + + private static long stableHash(String value) { + long hash = 0xCBF29CE484222325L; + for (int i = 0; i < value.length(); i++) { + hash ^= value.charAt(i); + hash *= 0x100000001B3L; + } + return mix(hash); + } + + private static long mix(long value) { + long mixed = value; + mixed = (mixed ^ (mixed >>> 30)) * 0xBF58476D1CE4E5B9L; + mixed = (mixed ^ (mixed >>> 27)) * 0x94D049BB133111EBL; + return mixed ^ (mixed >>> 31); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureGenerationPolicy.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureGenerationPolicy.java new file mode 100644 index 000000000..4889b5d9a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureGenerationPolicy.java @@ -0,0 +1,62 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.object.IrisImportedStructureControl; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; + +import java.util.Objects; + +public final class NativeStructureGenerationPolicy { + private NativeStructureGenerationPolicy() { + } + + public static IrisNativeStructureDecision resolve(Engine engine, String structureKey, + boolean undergroundStep) { + Engine activeEngine = Objects.requireNonNull(engine, "Native structure policy requires an engine"); + IrisDimension dimension = Objects.requireNonNull(activeEngine.getDimension(), + "Native structure policy requires a bound dimension"); + IrisImportedStructureControl control = Objects.requireNonNull( + dimension.getImportedStructures(), + "Dimension importedStructures must not be null"); + IrisNativeStructureDecision decision = control.resolve(structureKey, undergroundStep); + if (!decision.generate()) { + return decision; + } + if (IrisStructureLocator.suppressesVanilla(activeEngine, structureKey)) { + return decision.withStatus(NativeStructureGenerationStatus.REPLACED_BY_IRIS); + } + return decision; + } + + public static String generationStatusMessage(String structureKey, + NativeStructureGenerationStatus status) { + String key = structureKey == null ? "" : structureKey.trim(); + return switch (Objects.requireNonNull(status, "Native structure status must not be null")) { + case GENERATE_NATIVE -> "Native structure " + key + " generates natively."; + case DISABLED_BY_PACK -> "Native structure " + key + + " is disabled by this dimension's importedStructures settings."; + case REPLACED_BY_IRIS -> "Native structure " + key + + " is replaced by an Iris placement in this pack and locates through that explicit replacement."; + case INVALID_REGISTRY_KEY -> "Native structure registry key is invalid: " + key; + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/PlacedStructurePiece.java b/core/src/main/java/art/arcane/iris/engine/framework/PlacedStructurePiece.java index 1955df480..7756b271b 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/PlacedStructurePiece.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/PlacedStructurePiece.java @@ -41,8 +41,14 @@ public class PlacedStructurePiece { private final int maxZ; public boolean intersects(PlacedStructurePiece o) { - return minX < o.maxX && maxX > o.minX - && minY < o.maxY && maxY > o.minY - && minZ < o.maxZ && maxZ > o.minZ; + long maxExclusiveX = (long) maxX + 1L; + long maxExclusiveY = (long) maxY + 1L; + long maxExclusiveZ = (long) maxZ + 1L; + long otherMaxExclusiveX = (long) o.maxX + 1L; + long otherMaxExclusiveY = (long) o.maxY + 1L; + long otherMaxExclusiveZ = (long) o.maxZ + 1L; + return minX < otherMaxExclusiveX && maxExclusiveX > o.minX + && minY < otherMaxExclusiveY && maxExclusiveY > o.minY + && minZ < otherMaxExclusiveZ && maxExclusiveZ > o.minZ; } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/SeedManager.java b/core/src/main/java/art/arcane/iris/engine/framework/SeedManager.java index 76ea3daab..def691b12 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/SeedManager.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/SeedManager.java @@ -51,6 +51,7 @@ public class SeedManager { private final long post; private final long bodies; private final long mode; + private final long loot; @Setter(AccessLevel.NONE) private long fullMixedSeed; @@ -75,6 +76,7 @@ public class SeedManager { post = of("post"); bodies = of("bodies"); mode = of("mode"); + loot = of("loot"); } private long of(String name) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java b/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java index f2d99bfb6..fe6866dcf 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java @@ -19,6 +19,12 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.structure.JigsawPoolSelection; +import art.arcane.iris.engine.framework.structure.StructureGraphCatalog; +import art.arcane.iris.engine.framework.structure.StructureGraphCompilation; +import art.arcane.iris.engine.framework.structure.StructureGraphCompiler; +import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic; +import art.arcane.iris.engine.framework.structure.StructureGraphResolver; import art.arcane.iris.engine.object.IrisDirection; import art.arcane.iris.engine.object.IrisJigsawConnector; import art.arcane.iris.engine.object.IrisJigsawPiece; @@ -26,110 +32,219 @@ import art.arcane.iris.engine.object.IrisJigsawPieceEntry; import art.arcane.iris.engine.object.IrisJigsawPool; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.JigsawJoint; -import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; -import art.arcane.iris.engine.object.IrisPosition; import art.arcane.volmlib.util.math.RNG; import java.util.ArrayDeque; import java.util.Deque; +import java.util.Objects; public final class StructureAssembler { private static final int HARD_PIECE_CAP = 512; + private static final int MAX_DEPTH = 30; + private static final int MAX_SIZE_CHUNKS = 32; + private static final int[] NO_ROTATION = {0}; private static final int[] Y_DEGREES = {0, 90, 180, 270}; - private final IrisData data; + private final StructureGraphResolver resolver; private final IrisStructure structure; private final int originX; private final int originY; private final int originZ; private final int radius; - public StructureAssembler(IrisData data, IrisStructure structure, int originX, int originY, int originZ) { - this.data = data; - this.structure = structure; - this.originX = originX; - this.originY = originY; - this.originZ = originZ; - this.radius = Math.max(1, structure.getMaxSizeChunks()) * 16; + private StructureAssembler(AssemblyOptions options) { + this.resolver = options.resolver(); + this.structure = options.structure(); + this.originX = options.origin().getX(); + this.originY = options.origin().getY(); + this.originZ = options.origin().getZ(); + this.radius = this.structure.getMaxSizeChunks() >= 1 + && this.structure.getMaxSizeChunks() <= MAX_SIZE_CHUNKS + ? this.structure.getMaxSizeChunks() * 16 : 0; + } + + public static StructureAssembler forData(IrisData data, IrisStructure structure, IrisPosition origin) { + IrisData activeData = Objects.requireNonNull(data, "Structure assembly data must not be null"); + IrisStructure activeStructure = Objects.requireNonNull( + structure, "Structure assembly structure must not be null"); + return forCompilation(StructureGraphCatalog.compile(activeData, activeStructure), origin); + } + + public static StructureAssembler forResolver(StructureGraphResolver resolver, IrisStructure structure, + IrisPosition origin) { + StructureGraphResolver activeResolver = Objects.requireNonNull( + resolver, "Structure assembly resolver must not be null"); + IrisStructure activeStructure = Objects.requireNonNull( + structure, "Structure assembly structure must not be null"); + return forCompilation(StructureGraphCompiler.compile(activeStructure, activeResolver), origin); + } + + public static StructureAssembler forCompilation(StructureGraphCompilation compilation, IrisPosition origin) { + StructureGraphCompilation activeCompilation = Objects.requireNonNull( + compilation, "Structure graph compilation must not be null"); + if (activeCompilation.hasErrors()) { + throw invalidGraph(activeCompilation); + } + return new StructureAssembler(new AssemblyOptions( + StructureGraphResolver.forCompiledGraph(activeCompilation.getGraph()), + activeCompilation.getGraph().getStructure(), origin)); } public KList assemble(RNG rng) { - IrisJigsawPool startPool = IrisData.loadAnyJigsawPool(structure.getStartPool(), data); - if (startPool == null || startPool.getPieces().isEmpty()) { - IrisLogging.warn("Structure " + structure.getLoadKey() + " has no resolvable start pool '" + structure.getStartPool() + "'"); - return null; + Objects.requireNonNull(rng, "Structure assembly RNG must not be null"); + if (radius == 0) { + throw new IllegalStateException("Structure '" + structure.getLoadKey() + + "' has maxSizeChunks outside 1.." + MAX_SIZE_CHUNKS); + } + if (structure.getMaxDepth() < 1 || structure.getMaxDepth() > MAX_DEPTH) { + throw new IllegalStateException("Structure '" + structure.getLoadKey() + + "' has maxDepth outside 1.." + MAX_DEPTH); + } + IrisJigsawPool startPool = resolver.loadPool(structure.getStartPool()); + if (startPool == null || startPool.getPieces() == null || startPool.getPieces().isEmpty()) { + throw new IllegalStateException("Structure '" + structure.getLoadKey() + + "' has no resolvable start pool '" + structure.getStartPool() + "'"); } KList placed = new KList<>(); Deque open = new ArrayDeque<>(); - IrisJigsawPiece startPiece = pickPiece(startPool, rng); - if (startPiece == null) { - return null; + IrisJigsawPieceEntry startEntry = weightedPick(startPool, rng); + if (startEntry == null) { + throw new IllegalStateException("Structure '" + structure.getLoadKey() + + "' start pool has no positively weighted entry"); } - IrisObject startObject = IrisData.loadAnyObject(startPiece.getObject(), data); + if (startEntry.isEmpty()) { + return placed; + } + IrisJigsawPiece startPiece = resolver.loadPiece(startEntry.getPiece()); + if (startPiece == null) { + throw new IllegalStateException("Structure '" + structure.getLoadKey() + + "' start pool references missing piece '" + startEntry.getPiece() + "'"); + } + IrisObject startObject = resolver.loadObject(startPiece.getObject()); if (startObject == null) { - IrisLogging.warn("Jigsaw piece references missing object '" + startPiece.getObject() + "'"); - return null; + throw new IllegalStateException("Structure '" + structure.getLoadKey() + + "' start piece references missing object '" + startPiece.getObject() + "'"); + } + if (startPiece.getConnectors() == null) { + throw new IllegalStateException("Structure '" + structure.getLoadKey() + + "' start piece has no connector list"); } - int startRotY = startPiece.isRotatable() ? Y_DEGREES[rng.i(0, 3)] : 0; + int startRotY = startPiece.isRotatable() ? Y_DEGREES[rng.i(Y_DEGREES.length)] : 0; IrisObjectRotation startRot = IrisObjectRotation.of(0, startRotY, 0); PlacedStructurePiece start = build(startPiece, startObject, originX, originY, originZ, startRot); placed.add(start); - enqueueConnectors(open, start, startObject, 1, null); + enqueueConnectors(open, start, startObject, 0, null); while (!open.isEmpty() && placed.size() < HARD_PIECE_CAP) { OpenConnector c = open.poll(); - String poolName = c.pool; int depth = c.depth; - - IrisJigsawPool pool = IrisData.loadAnyJigsawPool(poolName, data); - if (pool == null) { + if (depth > structure.getMaxDepth()) { continue; } - if (depth > structure.getMaxDepth()) { - if (pool.getFallback() == null || pool.getFallback().isEmpty()) { - continue; - } - pool = IrisData.loadAnyJigsawPool(pool.getFallback(), data); - if (pool == null || pool.getPieces().isEmpty()) { - continue; - } + + IrisJigsawPool pool = resolver.loadPool(c.pool); + if (pool == null) { + throw new IllegalStateException("Structure '" + structure.getLoadKey() + + "' references missing connector pool '" + c.pool + "'"); } - attachOne(open, placed, pool, c, depth, rng); + if (!attachOne(open, placed, pool, c, depth, rng)) { + return null; + } } + if (!open.isEmpty()) { + throw assemblyFailure("exceeded the hard piece cap of " + HARD_PIECE_CAP + + " with " + open.size() + " connector(s) unresolved"); + } return placed; } - private void attachOne(Deque open, KList placed, IrisJigsawPool pool, OpenConnector c, int depth, RNG rng) { - for (String pieceName : weightedOrder(pool, rng)) { - IrisJigsawPiece piece = IrisData.loadAnyJigsawPiece(pieceName, data); - if (piece == null) { - continue; + private boolean attachOne(Deque open, KList placed, + IrisJigsawPool pool, OpenConnector c, int depth, RNG rng) { + if (pool.getPieces() == null) { + throw assemblyFailure("connector pool '" + c.pool + "' has no piece list"); + } + if (pool.getPieces().isEmpty()) { + return true; + } + if (depth < structure.getMaxDepth() && attachFromPool(open, placed, pool, c, depth, rng)) { + return true; + } + + String fallbackKey = JigsawPoolSelection.directFallbackKey(pool); + if (fallbackKey.isEmpty()) { + return depth >= structure.getMaxDepth(); + } + IrisJigsawPool fallback = resolver.loadPool(fallbackKey); + if (fallback == null) { + throw assemblyFailure("references missing authored fallback pool '" + fallbackKey + "'"); + } + if (fallback.getPieces() == null) { + throw assemblyFailure("authored fallback pool '" + fallbackKey + "' has no piece list"); + } + if (fallback.getPieces().isEmpty()) { + return true; + } + if (attachFromPool(open, placed, fallback, c, depth, rng)) { + return true; + } + return depth >= structure.getMaxDepth(); + } + + private boolean attachFromPool(Deque open, KList placed, IrisJigsawPool pool, + OpenConnector c, int depth, RNG rng) { + for (IrisJigsawPieceEntry entry : weightedOrder(pool, rng)) { + if (entry.isEmpty()) { + return true; } - IrisObject object = IrisData.loadAnyObject(piece.getObject(), data); + String pieceName = entry.getPiece(); + if (pieceName == null || pieceName.isBlank()) { + throw assemblyFailure("pool contains a weighted entry without a piece key"); + } + IrisJigsawPiece piece = resolver.loadPiece(pieceName); + if (piece == null) { + throw assemblyFailure("pool references missing piece '" + pieceName + "'"); + } + if (piece.getObject() == null || piece.getObject().isBlank()) { + throw assemblyFailure("piece '" + pieceName + "' has no object key"); + } + IrisObject object = resolver.loadObject(piece.getObject()); if (object == null) { - continue; + throw assemblyFailure("piece '" + pieceName + "' references missing object '" + + piece.getObject() + "'"); + } + if (piece.getConnectors() == null) { + throw assemblyFailure("piece '" + pieceName + "' has no connector list"); } for (IrisJigsawConnector cb : piece.getConnectors()) { + requireConnector(cb, pieceName); + if (c.targetName == null) { + throw assemblyFailure("open connector from pool '" + c.pool + "' has no target name"); + } if (!cb.getName().equals(c.targetName)) { continue; } IrisDirection needed = c.facing.reverse(); - for (int yDeg : rotationCandidates(cb.getJoint(), rng)) { + int[] rotations = piece.isRotatable() ? rotationCandidates(c.joint, rng) : NO_ROTATION; + for (int yDeg : rotations) { IrisObjectRotation rot = IrisObjectRotation.of(0, yDeg, 0); IrisDirection rotatedFace = rot.rotate(cb.getDirection()); if (rotatedFace != needed) { continue; } + if (c.joint == JigsawJoint.ALIGNED && rot.rotate(cb.getTop()) != c.top) { + continue; + } IrisPosition center = centerOf(object); IrisPosition cr = new IrisPosition( @@ -150,43 +265,64 @@ public final class StructureAssembler { } placed.add(candidate); - enqueueConnectors(open, candidate, object, depth + 1, cb); - return; + if (depth < structure.getMaxDepth()) { + enqueueConnectors(open, candidate, object, depth + 1, cb); + } + return true; } } } + return false; } private void enqueueConnectors(Deque open, PlacedStructurePiece p, IrisObject object, int depth, IrisJigsawConnector skip) { + if (p.getPiece().getConnectors() == null) { + throw assemblyFailure("placed piece for object '" + object.getLoadKey() + "' has no connector list"); + } IrisPosition center = centerOf(object); for (IrisJigsawConnector con : p.getPiece().getConnectors()) { if (con == skip) { continue; } + requireConnector(con, object.getLoadKey()); + if (con.getPool() == null || con.getPool().isBlank()) { + continue; + } IrisPosition cr = new IrisPosition( con.getPosition().getX() - center.getX(), con.getPosition().getY() - center.getY(), con.getPosition().getZ() - center.getZ()); IrisPosition rcr = p.getRotation().rotate(cr, 0, 0, 0); IrisDirection facing = p.getRotation().rotate(con.getDirection()); + IrisDirection top = p.getRotation().rotate(con.getTop()); open.add(new OpenConnector( p.getX() + rcr.getX(), p.getY() + rcr.getY(), p.getZ() + rcr.getZ(), - facing, con.getPool(), con.getName(), con.getTargetName(), con.getJoint(), depth)); + facing, top, con.getPool(), con.getName(), con.getTargetName(), con.getJoint(), depth)); } } private PlacedStructurePiece build(IrisJigsawPiece piece, IrisObject object, int x, int y, int z, IrisObjectRotation rot) { - IrisPosition rotated = rot.rotate(new IrisPosition(object.getW(), object.getH(), object.getD()), 0, 0, 0); - int rw = Math.abs(rotated.getX()); - int rh = Math.abs(rotated.getY()); - int rd = Math.abs(rotated.getZ()); - int hx = rw / 2; - int hy = rh / 2; - int hz = rd / 2; + int width = Math.max(1, object.getW()); + int height = Math.max(1, object.getH()); + int depth = Math.max(1, object.getD()); + int localMinX = -(width / 2); + int localMinY = -(height / 2); + int localMinZ = -(depth / 2); + int localMaxX = localMinX + width - 1; + int localMaxY = localMinY + height - 1; + int localMaxZ = localMinZ + depth - 1; + IrisPosition rotatedMin = rot.rotate(new IrisPosition(localMinX, localMinY, localMinZ), 0, 0, 0); + IrisPosition rotatedMax = rot.rotate(new IrisPosition(localMaxX, localMaxY, localMaxZ), 0, 0, 0); + int minX = Math.min(rotatedMin.getX(), rotatedMax.getX()); + int minY = Math.min(rotatedMin.getY(), rotatedMax.getY()); + int minZ = Math.min(rotatedMin.getZ(), rotatedMax.getZ()); + int maxX = Math.max(rotatedMin.getX(), rotatedMax.getX()); + int maxY = Math.max(rotatedMin.getY(), rotatedMax.getY()); + int maxZ = Math.max(rotatedMin.getZ(), rotatedMax.getZ()); return new PlacedStructurePiece(piece, object, x, y, z, rot, - x - hx, y - hy, z - hz, x + hx, y + hy, z + hz); + x + minX, y + minY, z + minZ, x + maxX, y + maxY, z + maxZ); } private boolean withinRadius(PlacedStructurePiece p) { @@ -209,7 +345,7 @@ public final class StructureAssembler { } int[] shuffled = {0, 90, 180, 270}; for (int i = shuffled.length - 1; i > 0; i--) { - int j = rng.i(0, i); + int j = rng.i(i + 1); int t = shuffled[i]; shuffled[i] = shuffled[j]; shuffled[j] = t; @@ -221,52 +357,99 @@ public final class StructureAssembler { return new IrisPosition(object.getW() / 2, object.getH() / 2, object.getD() / 2); } - private IrisJigsawPiece pickPiece(IrisJigsawPool pool, RNG rng) { - String name = weightedPick(pool, rng); - return name == null ? null : IrisData.loadAnyJigsawPiece(name, data); - } - - private String weightedPick(IrisJigsawPool pool, RNG rng) { - int total = 0; + private IrisJigsawPieceEntry weightedPick(IrisJigsawPool pool, RNG rng) { + if (pool.getPieces() == null) { + throw assemblyFailure("pool has no piece list"); + } + long total = 0L; for (IrisJigsawPieceEntry e : pool.getPieces()) { - total += Math.max(1, e.getWeight()); + if (e == null || e.getWeight() <= 0) { + throw assemblyFailure("pool contains a null or non-positive weighted entry"); + } + total += e.getWeight(); } if (total <= 0) { return null; } - int t = rng.i(0, total - 1); + long t = rng.nextLong(total); for (IrisJigsawPieceEntry e : pool.getPieces()) { - t -= Math.max(1, e.getWeight()); + t -= e.getWeight(); if (t < 0) { - return e.getPiece(); + return e; } } - return pool.getPieces().getFirst().getPiece(); + return null; } - private KList weightedOrder(IrisJigsawPool pool, RNG rng) { - KList remaining = new KList<>(pool.getPieces()); - KList order = new KList<>(); - while (!remaining.isEmpty()) { - int total = 0; - for (IrisJigsawPieceEntry e : remaining) { - total += Math.max(1, e.getWeight()); + private KList weightedOrder(IrisJigsawPool pool, RNG rng) { + KList remaining = new KList<>(); + if (pool.getPieces() != null) { + for (IrisJigsawPieceEntry entry : pool.getPieces()) { + if (entry == null || entry.getWeight() <= 0) { + throw assemblyFailure("pool contains a null or non-positive weighted entry"); + } + remaining.add(entry); } - int t = rng.i(0, total - 1); + } + KList order = new KList<>(); + while (!remaining.isEmpty()) { + long total = 0L; + for (IrisJigsawPieceEntry e : remaining) { + total += e.getWeight(); + } + long t = rng.nextLong(total); int idx = 0; for (int i = 0; i < remaining.size(); i++) { - t -= Math.max(1, remaining.get(i).getWeight()); + t -= remaining.get(i).getWeight(); if (t < 0) { idx = i; break; } } - order.add(remaining.remove(idx).getPiece()); + order.add(remaining.remove(idx)); } return order; } - private record OpenConnector(int wx, int wy, int wz, IrisDirection facing, String pool, String name, + private void requireConnector(IrisJigsawConnector connector, String pieceKey) { + if (connector == null || connector.getPosition() == null || connector.getDirection() == null + || connector.getTop() == null || connector.getName() == null + || connector.getTargetName() == null || connector.getJoint() == null) { + throw assemblyFailure("piece '" + pieceKey + "' contains a malformed connector"); + } + } + + private IllegalStateException assemblyFailure(String detail) { + return new IllegalStateException("Structure '" + structure.getLoadKey() + "' assembly failed: " + detail); + } + + private static IllegalStateException invalidGraph(StructureGraphCompilation compilation) { + String structureKey = compilation.getGraph().getStructureKey(); + StringBuilder detail = new StringBuilder(); + for (StructureGraphDiagnostic diagnostic : compilation.getDiagnostics()) { + if (diagnostic.severity() != StructureGraphDiagnostic.Severity.ERROR) { + continue; + } + if (!detail.isEmpty()) { + detail.append("; "); + } + detail.append(diagnostic.message()); + } + return new IllegalStateException("Structure '" + structureKey + + "' assembly rejected its invalid graph: " + detail); + } + + private record OpenConnector(int wx, int wy, int wz, IrisDirection facing, IrisDirection top, + String pool, String name, String targetName, JigsawJoint joint, int depth) { } + + private record AssemblyOptions(StructureGraphResolver resolver, IrisStructure structure, + IrisPosition origin) { + private AssemblyOptions { + Objects.requireNonNull(resolver, "Structure assembly resolver must not be null"); + Objects.requireNonNull(structure, "Structure assembly structure must not be null"); + Objects.requireNonNull(origin, "Structure assembly origin must not be null"); + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java index 7eed38c96..dd7deffc5 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java @@ -27,16 +27,17 @@ public final class StructurePlacementGrid { private StructurePlacementGrid() { } - public static boolean startsInChunk(IrisStructurePlacement placement, int cx, int cz, long seed, int placementOrdinal) { + public static boolean startsInChunk(IrisStructurePlacement placement, int cx, int cz, long seed) { return switch (placement.getDistribution()) { - case RANDOM_SPREAD -> randomSpreadStart(cx, cz, placement.getSpacing(), placement.getSeparation(), placement.getSalt(), seed); - case DENSITY -> densityStart(placement, cx, cz, seed, placementOrdinal); + case RANDOM_SPREAD -> randomSpreadStart(cx, cz, placement.getSpacing(), placement.getSeparation(), + placementSalt(placement), seed); + case DENSITY -> densityStart(placement, cx, cz, seed); case CONCENTRIC_RINGS -> concentricRingsStart(cx, cz, placement, seed); }; } - public static RNG placementRng(IrisStructurePlacement placement, int cx, int cz, long seed, int placementOrdinal) { - return new RNG(placementSeed(placement, cx, cz, seed, placementOrdinal)); + public static RNG placementRng(IrisStructurePlacement placement, int cx, int cz, long seed) { + return new RNG(placementSeed(placement, cx, cz, seed)); } public static boolean randomSpreadStart(int cx, int cz, int spacing, int separation, int salt, long seed) { @@ -74,7 +75,7 @@ public final class StructurePlacementGrid { int slots = Math.min(spread, count - firstIndex); int slot = placementIndex - firstIndex; double slotAngle = (2.0 * Math.PI) / slots; - double offset = unitDouble(mix(seed, ring, count, placement.getSalt())) * slotAngle; + double offset = unitDouble(mix(seed, ring, count, placementSalt(placement))) * slotAngle; double angle = offset + (slot * slotAngle); long configuredRadius = (long) ring * distance; if (configuredRadius > Integer.MAX_VALUE) { @@ -106,7 +107,7 @@ public final class StructurePlacementGrid { continue; } double slotAngle = (2.0 * Math.PI) / slots; - double offset = unitDouble(mix(seed, ring, count, placement.getSalt())) * slotAngle; + double offset = unitDouble(mix(seed, ring, count, placementSalt(placement))) * slotAngle; double slotPosition = (Math.atan2(cz, cx) - offset) / slotAngle; int nearestSlot = Math.floorMod((int) Math.round(slotPosition), slots); for (int delta = -1; delta <= 1; delta++) { @@ -120,7 +121,7 @@ public final class StructurePlacementGrid { return false; } - private static boolean densityStart(IrisStructurePlacement placement, int cx, int cz, long seed, int placementOrdinal) { + private static boolean densityStart(IrisStructurePlacement placement, int cx, int cz, long seed) { double density = placement.getDensity(); if (density <= 0.0) { return false; @@ -128,24 +129,81 @@ public final class StructurePlacementGrid { if (density >= 1.0) { return true; } - RNG rng = new RNG(placementSeed(placement, cx, cz, seed, placementOrdinal) ^ DENSITY_SIGNATURE); + RNG rng = new RNG(placementSeed(placement, cx, cz, seed) ^ DENSITY_SIGNATURE); return rng.chance(density); } - private static long placementSeed(IrisStructurePlacement placement, int cx, int cz, long seed, int placementOrdinal) { + private static long placementSeed(IrisStructurePlacement placement, int cx, int cz, long seed) { long signature = 1469598103934665603L; - signature = (signature ^ placement.getDistribution().ordinal()) * 1099511628211L; - signature = (signature ^ placement.getSalt()) * 1099511628211L; - signature = (signature ^ placementOrdinal) * 1099511628211L; - for (String key : placement.getStructures()) { - if (key == null) { - continue; - } - for (int i = 0; i < key.length(); i++) { - signature = (signature ^ key.charAt(i)) * 1099511628211L; + signature = appendLong(signature, placement.getDistribution().ordinal()); + signature = appendLong(signature, placement.getSalt()); + String placementId = placement.getPlacementId(); + if (placementId != null && !placementId.isBlank()) { + signature = appendLong(signature, 1L); + signature = appendSignature(signature, placementId.trim()); + } else { + signature = appendLong(signature, 0L); + signature = appendLong(signature, placement.getSpacing()); + signature = appendLong(signature, placement.getSeparation()); + signature = appendLong(signature, Double.doubleToLongBits(placement.getDensity())); + signature = appendLong(signature, placement.getRingCount()); + signature = appendLong(signature, placement.getRingDistance()); + signature = appendLong(signature, placement.getRingSpread()); + signature = appendLong(signature, placement.getMinHeight()); + signature = appendLong(signature, placement.getMaxHeight()); + signature = appendLong(signature, placement.isUnderground() ? 1L : 0L); + signature = appendLong(signature, placement.isUnderwater() ? 1L : 0L); + signature = appendLong(signature, placement.getStructures().size()); + for (String key : placement.getStructures()) { + signature = appendSignature(signature, key == null ? "" : key); } } - return mix(seed ^ signature, cx, cz, placement.getSalt() ^ placementOrdinal); + int identitySalt = (int) (signature ^ (signature >>> 32)); + return mix(seed ^ signature, cx, cz, placementSalt(placement) ^ identitySalt); + } + + private static long appendSignature(long signature, String value) { + long result = appendLong(signature, value.length()); + for (int index = 0; index < value.length(); index++) { + result = (result ^ value.charAt(index)) * 1099511628211L; + } + return result; + } + + private static long appendLong(long signature, long value) { + long result = signature; + long remaining = value; + for (int index = 0; index < Long.BYTES; index++) { + result = (result ^ (remaining & 0xffL)) * 1099511628211L; + remaining >>>= Byte.SIZE; + } + return result; + } + + static int placementSalt(IrisStructurePlacement placement) { + String placementId = placement.getPlacementId(); + long identity; + if (placementId != null && !placementId.isBlank()) { + identity = appendSignature(1469598103934665603L, placementId.trim()); + } else { + identity = 1469598103934665603L; + identity = appendLong(identity, placement.getDistribution().ordinal()); + identity = appendLong(identity, placement.getSpacing()); + identity = appendLong(identity, placement.getSeparation()); + identity = appendLong(identity, Double.doubleToLongBits(placement.getDensity())); + identity = appendLong(identity, placement.getRingCount()); + identity = appendLong(identity, placement.getRingDistance()); + identity = appendLong(identity, placement.getRingSpread()); + identity = appendLong(identity, placement.getMinHeight()); + identity = appendLong(identity, placement.getMaxHeight()); + identity = appendLong(identity, placement.isUnderground() ? 1L : 0L); + identity = appendLong(identity, placement.isUnderwater() ? 1L : 0L); + identity = appendLong(identity, placement.getStructures().size()); + for (String key : placement.getStructures()) { + identity = appendSignature(identity, key == null ? "" : key); + } + } + return placement.getSalt() ^ (int) (identity ^ (identity >>> 32)); } private static double unitDouble(long value) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructureReachability.java b/core/src/main/java/art/arcane/iris/engine/framework/StructureReachability.java index 94f35f27a..aa780a7eb 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructureReachability.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructureReachability.java @@ -26,6 +26,8 @@ import com.github.benmanes.caffeine.cache.Caffeine; import java.util.Collections; import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Objects; import java.util.Set; /** @@ -44,20 +46,16 @@ public final class StructureReachability { } public static Set reachableKeys(Engine engine) { - if (engine == null) { - return Collections.emptySet(); + Engine activeEngine = Objects.requireNonNull(engine, "Structure reachability requires an engine"); + if (activeEngine.getData() == null) { + throw new IllegalStateException("Structure reachability requires a bound pack data loader"); } - if (engine.getData() == null) { - return Collections.emptySet(); - } - return REACHABLE_CACHE.get(engine, ignored -> build(engine)); + return REACHABLE_CACHE.get(activeEngine, ignored -> build(activeEngine)); } public static boolean isReachable(Engine engine, String structureKey) { - if (structureKey == null || structureKey.isEmpty()) { - return false; - } - return reachableKeys(engine).contains(structureKey.toLowerCase()); + String normalizedStructureKey = normalizeStructureKey(structureKey); + return reachableKeys(engine).contains(normalizedStructureKey); } public static void invalidate(Engine engine) { @@ -69,14 +67,18 @@ public final class StructureReachability { private static Set build(Engine engine) { IrisWorld world = engine.getWorld(); - if (world == null || world.platformWorld() == null) { - return Collections.emptySet(); + if (world == null) { + throw new IllegalStateException("Structure reachability requires a bound Iris world"); + } + if (world.platformWorld() == null) { + throw new IllegalStateException("Structure reachability requires a bound platform world"); } Set reachable = new LinkedHashSet<>(); for (String key : IrisPlatforms.get().structureHooks().reachableStructureKeys(world.platformWorld())) { - if (key != null && !key.isEmpty()) { - reachable.add(key.toLowerCase()); + if (key == null || key.isBlank()) { + throw new IllegalStateException("Platform structure reachability returned a blank registry key"); } + reachable.add(key.toLowerCase(Locale.ROOT)); } return Collections.unmodifiableSet(reachable); } @@ -88,24 +90,38 @@ public final class StructureReachability { */ public static KList missingBiomeKeys(Engine engine, String structureKey) { KList missing = new KList<>(); - if (engine == null || structureKey == null || structureKey.isEmpty()) { - return missing; + Engine activeEngine = Objects.requireNonNull(engine, "Structure biome diagnostics require an engine"); + String normalizedStructureKey = normalizeStructureKey(structureKey); + IrisWorld world = activeEngine.getWorld(); + if (world == null) { + throw new IllegalStateException("Structure biome diagnostics require a bound Iris world"); } - IrisWorld world = engine.getWorld(); - if (world == null || world.platformWorld() == null) { - return missing; + if (world.platformWorld() == null) { + throw new IllegalStateException("Structure biome diagnostics require a bound platform world"); } Set possible = new LinkedHashSet<>(); for (String key : IrisPlatforms.get().structureHooks().possibleBiomeKeys(world.platformWorld())) { - if (key != null) { - possible.add(key.toLowerCase()); + if (key == null || key.isBlank()) { + throw new IllegalStateException("Platform structure biome query returned a blank possible-biome key"); } + possible.add(key.toLowerCase(Locale.ROOT)); } - for (String biomeKey : IrisPlatforms.get().structureHooks().structureBiomeKeys(structureKey)) { - if (biomeKey != null && !possible.contains(biomeKey.toLowerCase())) { + for (String biomeKey : IrisPlatforms.get().structureHooks().structureBiomeKeys(normalizedStructureKey)) { + if (biomeKey == null || biomeKey.isBlank()) { + throw new IllegalStateException("Platform structure biome query returned a blank required-biome key for " + + normalizedStructureKey); + } + if (!possible.contains(biomeKey.toLowerCase(Locale.ROOT))) { missing.add(biomeKey); } } return missing; } + + private static String normalizeStructureKey(String structureKey) { + if (structureKey == null || structureKey.isBlank()) { + throw new IllegalArgumentException("Registered structure key must not be blank"); + } + return structureKey.trim().toLowerCase(Locale.ROOT); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java b/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java index cf7940ab7..a023e9671 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java @@ -4,11 +4,12 @@ import art.arcane.iris.platform.bukkit.BukkitBlockResolution; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.tools.IrisToolbelt; -import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.core.events.IrisLootEvent; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.platform.EngineBukkitOps; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.InventorySlotType; import art.arcane.iris.engine.object.IrisLootTable; @@ -16,6 +17,7 @@ import art.arcane.iris.engine.object.TileData; import art.arcane.iris.platform.bukkit.BukkitBlockState; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.volmlib.util.math.RNG; @@ -37,7 +39,7 @@ public class WorldObjectPlacer implements IObjectPlacer { private final EngineMantle mantle; public WorldObjectPlacer(World world) { - var a = IrisToolbelt.access(world); + PlatformChunkGenerator a = IrisToolbelt.access(world); if (a == null || a.getEngine() == null) throw new IllegalStateException(world.getName() + " is not an Iris World!"); this.world = world; this.engine = a.getEngine(); @@ -62,34 +64,37 @@ public class WorldObjectPlacer implements IObjectPlacer { Block block = world.getBlockAt(x, worldY, z); if (block.getType() == Material.BEDROCK) return; - InventorySlotType slot = null; - if (BukkitBlockResolution.isStorageChest(d)) { - slot = InventorySlotType.STORAGE; - } + boolean storageChest = BukkitBlockResolution.isStorageChest(d); if (d instanceof IrisCustomData data) { block.setBlockData(data.getBase(), false); IrisLogging.warn("Tried to place custom block at " + x + ", " + y + ", " + z + " which is not supported!"); } else block.setBlockData(d, false); - if (slot != null) { - RNG rx = new RNG(Cache.key(x, z)); - KList tables = EngineBukkitOps.getLootTables(engine, rx, block); + if (storageChest && !J.runRegion(world, x >> 4, z >> 4, () -> fillLoot(block), 1)) { + IrisLogging.warn("Failed to schedule loot resolution at " + x + ", " + worldY + ", " + z); + } + } - try { - Bukkit.getPluginManager().callEvent(new IrisLootEvent(engine, block, slot, tables)); + private void fillLoot(Block block) { + if (!BukkitBlockResolution.isStorageChest(block.getBlockData()) || !EngineBukkitOps.isCanonicalContainer(block)) { + return; + } + int x = block.getX(); + int y = block.getY(); + int z = block.getZ(); + RNG rng = LootResolver.containerRng(engine.getSeedManager().getLoot(), x, y, z); + KList tables = EngineBukkitOps.getLootTables(engine, rng, block); - if (!tables.isEmpty()){ - IrisLogging.debug("IrisLootEvent has been accessed"); - } - - if (tables.isEmpty()) - return; - InventoryHolder m = (InventoryHolder) block.getState(); - EngineBukkitOps.addItems(engine, false, m.getInventory(), rx, tables, slot, world, x, y, z, 15); - } catch (Throwable e) { - IrisLogging.reportError(e); + try { + Bukkit.getPluginManager().callEvent(new IrisLootEvent(engine, block, InventorySlotType.STORAGE, tables)); + if (tables.isEmpty()) { + return; } + InventoryHolder holder = (InventoryHolder) block.getState(); + EngineBukkitOps.addItems(engine, false, holder.getInventory(), tables, InventorySlotType.STORAGE, world, x, y, z); + } catch (Throwable e) { + IrisLogging.reportError(e); } } @@ -137,10 +142,17 @@ public class WorldObjectPlacer implements IObjectPlacer { @Override public void setData(int xx, int yy, int zz, T data) { + if (data == null || yy < 0 || yy >= engine.getHeight()) { + return; + } + mantle.getMantle().set(xx, yy, zz, data); } @Override public T getData(int xx, int yy, int zz, Class t) { - return null; + if (yy < 0 || yy >= engine.getHeight()) { + return null; + } + return mantle.getMantle().get(xx, yy, zz, t); } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/CompiledStructureGraph.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/CompiledStructureGraph.java new file mode 100644 index 000000000..3a5e70c2c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/CompiledStructureGraph.java @@ -0,0 +1,101 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisStructure; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class CompiledStructureGraph { + private final IrisStructure structure; + private final Map pools; + private final Map pieces; + private final Map objects; + private final Set reachablePools; + private final Set reachablePieces; + + private CompiledStructureGraph(Builder builder) { + structure = builder.structure; + pools = Collections.unmodifiableMap(new LinkedHashMap<>(builder.pools)); + pieces = Collections.unmodifiableMap(new LinkedHashMap<>(builder.pieces)); + objects = Collections.unmodifiableMap(new LinkedHashMap<>(builder.objects)); + reachablePools = Collections.unmodifiableSet(new LinkedHashSet<>(builder.reachablePools)); + reachablePieces = Collections.unmodifiableSet(new LinkedHashSet<>(builder.reachablePieces)); + } + + static Builder builder(IrisStructure structure) { + return new Builder(structure); + } + + public IrisStructure getStructure() { + return structure; + } + + public String getStructureKey() { + String key = structure.getLoadKey(); + return key == null || key.isBlank() ? "" : key; + } + + public Map getPools() { + return pools; + } + + public Map getPieces() { + return pieces; + } + + public Map getObjects() { + return objects; + } + + public Set getReachablePools() { + return reachablePools; + } + + public Set getReachablePieces() { + return reachablePieces; + } + + static final class Builder { + private final IrisStructure structure; + private final Map pools = new LinkedHashMap<>(); + private final Map pieces = new LinkedHashMap<>(); + private final Map objects = new LinkedHashMap<>(); + private final Set reachablePools = new LinkedHashSet<>(); + private final Set reachablePieces = new LinkedHashSet<>(); + + private Builder(IrisStructure structure) { + this.structure = Objects.requireNonNull(structure); + } + + Map pools() { + return pools; + } + + Map pieces() { + return pieces; + } + + Map objects() { + return objects; + } + + Set reachablePools() { + return reachablePools; + } + + Set reachablePieces() { + return reachablePieces; + } + + CompiledStructureGraph build() { + return new CompiledStructureGraph(this); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/IrisObjectFrameReader.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/IrisObjectFrameReader.java new file mode 100644 index 000000000..0a4341e2a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/IrisObjectFrameReader.java @@ -0,0 +1,386 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.engine.object.IrisObject; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +public final class IrisObjectFrameReader { + private static final String HEADER = "Iris V2 IOB;"; + private static final int MAX_FILE_BYTES = 64 * 1024 * 1024; + private static final int MAX_TILE_COUNT = 65_536; + private static final int MAX_CANDIDATE_OFFSETS = 1_024; + private static final long MAX_TILE_PARSE_STATES = 262_144L; + private static final int MAX_LEGACY_SIGN_COLOR_INDEX = 15; + + private IrisObjectFrameReader() { + } + + public static IrisObject readBounds(InputStream stream, String resourceName) throws IOException { + String activeResourceName = resourceName == null || resourceName.isBlank() + ? "" : resourceName; + try { + byte[] content = readLimitedContent(Objects.requireNonNull(stream), activeResourceName); + return readBounds(content, activeResourceName); + } catch (IOException e) { + if (e.getMessage() != null && e.getMessage().startsWith("Malformed Iris object resource ")) { + throw e; + } + throw malformed(activeResourceName, e.getMessage() == null + ? e.getClass().getSimpleName() : e.getMessage()); + } catch (RuntimeException e) { + throw malformed(activeResourceName, e.getMessage() == null + ? e.getClass().getSimpleName() : e.getMessage()); + } + } + + private static IrisObject readBounds(byte[] content, String resourceName) throws IOException { + ByteArrayInputStream bytes = new ByteArrayInputStream(content); + DataInputStream input = new DataInputStream(bytes); + int width = input.readInt(); + int height = input.readInt(); + int depth = input.readInt(); + long volume = requireDimensions(resourceName, width, height, depth); + if (!HEADER.equals(input.readUTF())) { + throw malformed(resourceName, "invalid header"); + } + + int paletteSize = input.readShort(); + if (paletteSize < 0) { + throw malformed(resourceName, "palette exceeds the signed IOB limit"); + } + for (int index = 0; index < paletteSize; index++) { + if (input.readUTF().isBlank()) { + throw malformed(resourceName, "palette entry " + index + " is blank"); + } + } + + int blockCount = input.readInt(); + if (blockCount < 0 || blockCount > volume || blockCount > 0 && paletteSize == 0) { + throw malformed(resourceName, "invalid block count " + blockCount); + } + for (int index = 0; index < blockCount; index++) { + int x = input.readShort(); + int y = input.readShort(); + int z = input.readShort(); + int paletteIndex = input.readShort(); + requirePosition(resourceName, "block", index, x, y, z, width, height, depth); + if (paletteIndex < 0 || paletteIndex >= paletteSize) { + throw malformed(resourceName, "block " + index + + " references invalid palette index " + paletteIndex); + } + } + + int tileCount = input.readInt(); + if (tileCount < 0 || tileCount > volume) { + throw malformed(resourceName, "invalid tile count " + tileCount); + } + if (tileCount > MAX_TILE_COUNT) { + throw malformed(resourceName, "tile count " + tileCount + + " exceeds limit " + MAX_TILE_COUNT); + } + int tileStart = content.length - bytes.available(); + requireCompleteTileFraming(content, tileStart, tileCount, width, height, depth, resourceName); + return new IrisObject(width, height, depth); + } + + private static void requireCompleteTileFraming( + byte[] content, + int tileStart, + int tileCount, + int width, + int height, + int depth, + String resourceName + ) throws IOException { + Set offsets = Set.of(tileStart); + long parseStates = 0L; + for (int tileIndex = 0; tileIndex < tileCount; tileIndex++) { + Set nextOffsets = new LinkedHashSet<>(); + TileFailureTracker failures = new TileFailureTracker(); + for (int offset : offsets) { + parseStates++; + requireParseStateBudget(resourceName, tileIndex, parseStates); + TilePrefix prefix = readTilePrefix( + content, offset, width, height, depth, tileIndex, failures); + if (prefix == null) { + continue; + } + int modernEnd = readModernTileEnd( + content, prefix.payloadOffset(), tileIndex, failures); + if (modernEnd >= 0) { + addCandidateOffset(resourceName, tileIndex, nextOffsets, modernEnd); + } + for (int legacyEnd : readLegacyTileEnds( + content, prefix.payloadOffset(), tileIndex, failures)) { + addCandidateOffset(resourceName, tileIndex, nextOffsets, legacyEnd); + } + } + if (nextOffsets.isEmpty()) { + throw malformed(resourceName, failures.detailOr( + "tile " + tileIndex + " is truncated or malformed")); + } + offsets = nextOffsets; + } + if (!offsets.contains(content.length)) { + throw malformed(resourceName, "tile records leave trailing or incomplete data"); + } + } + + private static TilePrefix readTilePrefix( + byte[] content, + int offset, + int width, + int height, + int depth, + int tileIndex, + TileFailureTracker failures + ) { + try { + ByteArrayInputStream bytes = slice(content, offset); + DataInputStream input = new DataInputStream(bytes); + int x = input.readShort(); + int y = input.readShort(); + int z = input.readShort(); + if (!withinSignedObjectAxis(x, width) || !withinSignedObjectAxis(y, height) + || !withinSignedObjectAxis(z, depth)) { + failures.record("tile " + tileIndex + " position " + x + "," + y + "," + z + + " is outside object bounds", 20); + return null; + } + return new TilePrefix(content.length - bytes.available()); + } catch (IOException | RuntimeException e) { + return null; + } + } + + private static int readModernTileEnd( + byte[] content, + int offset, + int tileIndex, + TileFailureTracker failures + ) { + try { + ByteArrayInputStream bytes = slice(content, offset); + DataInputStream input = new DataInputStream(bytes); + String material = input.readUTF(); + if (material.isBlank()) { + failures.record("tile " + tileIndex + " modern material is blank", 40); + return -1; + } + JsonElement properties = JsonParser.parseString(input.readUTF()); + if (!properties.isJsonObject()) { + failures.record("tile " + tileIndex + + " modern properties are not a JSON object", 30); + return -1; + } + return content.length - bytes.available(); + } catch (IOException | RuntimeException e) { + return -1; + } + } + + private static Set readLegacyTileEnds( + byte[] content, + int offset, + int tileIndex, + TileFailureTracker failures + ) { + try { + ByteArrayInputStream bytes = slice(content, offset); + DataInputStream input = new DataInputStream(bytes); + int id = input.readShort(); + return switch (id) { + case 0 -> singletonEnd(content, readLegacySign(input, bytes, tileIndex, failures)); + case 1 -> readLegacySpawnerEnds(content, content.length - bytes.available()); + case 2 -> readLegacyBannerEnds(content, input, bytes); + case 3 -> singletonEnd(content, readLegacyLootable(input, bytes)); + default -> Set.of(); + }; + } catch (IOException | RuntimeException e) { + return Set.of(); + } + } + + private static int readLegacySign( + DataInputStream input, + ByteArrayInputStream bytes, + int tileIndex, + TileFailureTracker failures + ) throws IOException { + input.readUTF(); + input.readUTF(); + input.readUTF(); + input.readUTF(); + int colorIndex = input.readByte(); + if (colorIndex < 0 || colorIndex > MAX_LEGACY_SIGN_COLOR_INDEX) { + failures.record("tile " + tileIndex + " legacy sign color index " + colorIndex + + " is outside 0.." + MAX_LEGACY_SIGN_COLOR_INDEX, 50); + throw new IOException("Invalid legacy sign color index"); + } + return bytes.available(); + } + + private static Set readLegacySpawnerEnds(byte[] content, int payloadOffset) { + Set ends = new LinkedHashSet<>(); + try { + ByteArrayInputStream keyedBytes = slice(content, payloadOffset); + new DataInputStream(keyedBytes).readUTF(); + ends.add(content.length - keyedBytes.available()); + } catch (IOException | RuntimeException ignored) { + } + if (payloadOffset <= content.length - Short.BYTES) { + ends.add(payloadOffset + Short.BYTES); + } + return Set.copyOf(ends); + } + + private static Set readLegacyBannerEnds( + byte[] content, + DataInputStream input, + ByteArrayInputStream bytes + ) throws IOException { + input.readUnsignedByte(); + int patternCount = input.readUnsignedByte(); + int patternsOffset = content.length - bytes.available(); + Set ends = new LinkedHashSet<>(); + try { + ByteArrayInputStream keyedBytes = slice(content, patternsOffset); + DataInputStream keyedInput = new DataInputStream(keyedBytes); + for (int index = 0; index < patternCount; index++) { + keyedInput.readUnsignedByte(); + keyedInput.readUTF(); + } + ends.add(content.length - keyedBytes.available()); + } catch (IOException | RuntimeException ignored) { + } + long legacyEnd = (long) patternsOffset + (long) patternCount * 2L; + if (legacyEnd <= content.length) { + ends.add((int) legacyEnd); + } + return Set.copyOf(ends); + } + + private static int readLegacyLootable(DataInputStream input, ByteArrayInputStream bytes) throws IOException { + input.readUTF(); + input.readUTF(); + input.readLong(); + return bytes.available(); + } + + private static Set singletonEnd(byte[] content, int remaining) { + return Set.of(content.length - remaining); + } + + private static byte[] readLimitedContent(InputStream stream, String resourceName) throws IOException { + return readLimitedContent(stream, resourceName, MAX_FILE_BYTES); + } + + static byte[] readLimitedContent( + InputStream stream, + String resourceName, + int maximumBytes + ) throws IOException { + byte[] content = stream.readNBytes(maximumBytes + 1); + if (content.length > maximumBytes) { + throw malformed(resourceName, "file exceeds " + maximumBytes + "-byte limit"); + } + return content; + } + + private static void addCandidateOffset( + String resourceName, + int tileIndex, + Set offsets, + int offset + ) throws IOException { + offsets.add(offset); + if (offsets.size() > MAX_CANDIDATE_OFFSETS) { + throw malformed(resourceName, "tile " + tileIndex + " exceeds candidate-offset limit " + + MAX_CANDIDATE_OFFSETS); + } + } + + private static void requireParseStateBudget( + String resourceName, + int tileIndex, + long parseStates + ) throws IOException { + if (parseStates > MAX_TILE_PARSE_STATES) { + throw malformed(resourceName, "tile " + tileIndex + " exceeds parse-state limit " + + MAX_TILE_PARSE_STATES); + } + } + + private static ByteArrayInputStream slice(byte[] content, int offset) { + if (offset < 0 || offset > content.length) { + throw new IllegalArgumentException("Invalid IOB frame offset " + offset); + } + return new ByteArrayInputStream(content, offset, content.length - offset); + } + + private static long requireDimensions(String resourceName, int width, int height, int depth) throws IOException { + if (width < 1 || height < 1 || depth < 1) { + throw malformed(resourceName, "invalid dimensions " + width + "x" + height + "x" + depth); + } + try { + return Math.multiplyExact(Math.multiplyExact((long) width, height), depth); + } catch (ArithmeticException e) { + throw malformed(resourceName, "dimensions overflow the IOB volume limit"); + } + } + + private static void requirePosition( + String resourceName, + String type, + int index, + int x, + int y, + int z, + int width, + int height, + int depth + ) throws IOException { + if (!withinSignedObjectAxis(x, width) || !withinSignedObjectAxis(y, height) + || !withinSignedObjectAxis(z, depth)) { + throw malformed(resourceName, type + " " + index + " position " + + x + "," + y + "," + z + " is outside object bounds"); + } + } + + private static boolean withinSignedObjectAxis(int coordinate, int size) { + int center = size / 2; + return coordinate >= -center && coordinate < size - center; + } + + private static IOException malformed(String resourceName, String detail) { + return new IOException("Malformed Iris object resource " + resourceName + ": " + detail); + } + + private record TilePrefix(int payloadOffset) { + } + + private static final class TileFailureTracker { + private String detail; + private int priority = Integer.MIN_VALUE; + + private void record(String candidateDetail, int candidatePriority) { + if (candidatePriority <= priority) { + return; + } + detail = candidateDetail; + priority = candidatePriority; + } + + private String detailOr(String fallback) { + return detail == null ? fallback : detail; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/JigsawPoolSelection.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/JigsawPoolSelection.java new file mode 100644 index 000000000..5dc3b3c25 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/JigsawPoolSelection.java @@ -0,0 +1,39 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.engine.object.IrisJigsawPool; + +import java.util.List; +import java.util.Objects; + +public final class JigsawPoolSelection { + private JigsawPoolSelection() { + } + + public static String directFallbackKey(IrisJigsawPool pool) { + Objects.requireNonNull(pool); + return normalize(pool.getFallback()); + } + + public static List candidatePoolKeys( + String primaryKey, + IrisJigsawPool primaryPool, + boolean includePrimary + ) { + Objects.requireNonNull(primaryPool); + String normalizedPrimary = normalize(primaryKey); + String directFallback = directFallbackKey(primaryPool); + if (!includePrimary) { + return directFallback.isEmpty() ? List.of() : List.of(directFallback); + } + if (normalizedPrimary.isEmpty()) { + return List.of(); + } + return directFallback.isEmpty() + ? List.of(normalizedPrimary) + : List.of(normalizedPrimary, directFallback); + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphAssemblySample.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphAssemblySample.java new file mode 100644 index 000000000..526392fad --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphAssemblySample.java @@ -0,0 +1,24 @@ +package art.arcane.iris.engine.framework.structure; + +import java.util.List; + +public record StructureGraphAssemblySample(long seed, Outcome outcome) { + public record Outcome(List pieceKeys, int unresolvedConnectorCount, boolean pieceCapReached, + boolean intentionalEmpty) { + public Outcome { + pieceKeys = List.copyOf(pieceKeys); + } + + public Outcome(List pieceKeys, int unresolvedConnectorCount, boolean pieceCapReached) { + this(pieceKeys, unresolvedConnectorCount, pieceCapReached, false); + } + + public boolean isViable() { + return (intentionalEmpty || !pieceKeys.isEmpty()) && !pieceCapReached; + } + + public boolean isComplete() { + return isViable() && unresolvedConnectorCount == 0; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCatalog.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCatalog.java new file mode 100644 index 000000000..7ad69f4c2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCatalog.java @@ -0,0 +1,102 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.PlacedStructurePiece; +import art.arcane.iris.engine.framework.StructureAssembler; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public final class StructureGraphCatalog { + private static final Cache> CACHE = + Caffeine.newBuilder().weakKeys().build(); + private static final Cache> RUNTIME_OUTPUT = + Caffeine.newBuilder().weakKeys().build(); + private static final List RUNTIME_SAMPLE_SEEDS = List.of( + 0L, 1L, 2L, 3L, 5L, 8L, 13L, 21L, 34L, 55L, 89L, 144L, 233L, 377L, 610L, 987L); + + private StructureGraphCatalog() { + } + + public static StructureGraphCompilation compile(IrisData data, IrisStructure structure) { + IrisData activeData = Objects.requireNonNull(data); + IrisStructure activeStructure = Objects.requireNonNull(structure); + String structureKey = activeStructure.getLoadKey(); + if (structureKey == null || structureKey.isBlank()) { + return compileAndReport(activeData, activeStructure); + } + ConcurrentMap catalog = CACHE.get( + activeData, ignored -> new ConcurrentHashMap<>()); + return catalog.computeIfAbsent(structureKey, ignored -> compileAndReport(activeData, activeStructure)); + } + + public static void invalidate(IrisData data) { + if (data != null) { + CACHE.invalidate(data); + RUNTIME_OUTPUT.invalidate(data); + } + } + + public static boolean guaranteesRuntimeOutput(IrisData data, IrisStructure structure) { + IrisData activeData = Objects.requireNonNull(data); + IrisStructure activeStructure = Objects.requireNonNull(structure); + if (!compile(activeData, activeStructure).guaranteesAssemblyOutput()) { + return false; + } + String structureKey = activeStructure.getLoadKey(); + if (structureKey == null || structureKey.isBlank()) { + return sampleRuntimeAssembly(activeData, activeStructure); + } + ConcurrentMap catalog = RUNTIME_OUTPUT.get( + activeData, ignored -> new ConcurrentHashMap<>()); + return catalog.computeIfAbsent( + structureKey, ignored -> sampleRuntimeAssembly(activeData, activeStructure)); + } + + private static StructureGraphCompilation compileAndReport(IrisData data, IrisStructure structure) { + StructureGraphCompilation compilation = StructureGraphCompiler.compile( + structure, StructureGraphResolver.forData(data)); + String structureKey = structure.getLoadKey() == null || structure.getLoadKey().isBlank() + ? "" : structure.getLoadKey(); + for (StructureGraphDiagnostic diagnostic : compilation.getDiagnostics()) { + String message = "[StructureGraph:" + structureKey + "] " + diagnostic.message(); + if (diagnostic.severity() == StructureGraphDiagnostic.Severity.ERROR) { + IrisLogging.error(message); + } else { + IrisLogging.warn(message); + } + } + if (!compilation.isAssemblyViable()) { + IrisLogging.error("[StructureGraph:" + structureKey + + "] Graph rejected because deterministic assembly did not complete."); + } + return compilation; + } + + private static boolean sampleRuntimeAssembly(IrisData data, IrisStructure structure) { + for (long seed : RUNTIME_SAMPLE_SEEDS) { + try { + StructureAssembler assembler = StructureAssembler.forData( + data, structure, new IrisPosition(0, 64, 0)); + KList pieces = assembler.assemble(new RNG(seed)); + if (pieces == null || pieces.isEmpty()) { + return false; + } + } catch (Throwable e) { + IrisLogging.reportError(e); + e.printStackTrace(); + return false; + } + } + return true; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilation.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilation.java new file mode 100644 index 000000000..9cfd5aa63 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilation.java @@ -0,0 +1,183 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public final class StructureGraphCompilation { + private final CompiledStructureGraph graph; + private final List diagnostics; + private final List assemblySamples; + + StructureGraphCompilation(Builder builder) { + graph = Objects.requireNonNull(builder.graph); + diagnostics = Collections.unmodifiableList(new ArrayList<>(builder.diagnostics)); + assemblySamples = Collections.unmodifiableList(new ArrayList<>(builder.assemblySamples)); + } + + static Builder builder(CompiledStructureGraph graph) { + return new Builder(graph); + } + + public CompiledStructureGraph getGraph() { + return graph; + } + + public List getDiagnostics() { + return diagnostics; + } + + public List getAssemblySamples() { + return assemblySamples; + } + + public boolean hasErrors() { + for (StructureGraphDiagnostic diagnostic : diagnostics) { + if (diagnostic.severity() == StructureGraphDiagnostic.Severity.ERROR) { + return true; + } + } + return false; + } + + public boolean isAssemblyViable() { + if (hasErrors() || assemblySamples.isEmpty()) { + return false; + } + for (StructureGraphAssemblySample sample : assemblySamples) { + StructureGraphAssemblySample.Outcome outcome = sample.outcome(); + if (!outcome.isComplete() && !isGeometryBoundedCandidate(outcome)) { + return false; + } + } + return true; + } + + public boolean guaranteesAssemblyOutput() { + if (!isAssemblyViable()) { + return false; + } + String startPoolKey = graph.getStructure().getStartPool(); + if (startPoolKey == null || startPoolKey.isBlank()) { + return false; + } + IrisJigsawPool startPool = graph.getPools().get(startPoolKey.trim()); + if (startPool == null || startPool.getPieces() == null || startPool.getPieces().isEmpty()) { + return false; + } + for (IrisJigsawPieceEntry entry : startPool.getPieces()) { + if (entry == null || entry.getWeight() <= 0 || entry.isEmpty()) { + return false; + } + IrisJigsawPiece piece = graph.getPieces().get(normalize(entry.getPiece())); + if (piece == null) { + return false; + } + IrisObject object = graph.getObjects().get(normalize(piece.getObject())); + if (object == null || !fitsStartRadius(object)) { + return false; + } + } + return reachableBranchesCanTerminate(); + } + + private boolean reachableBranchesCanTerminate() { + for (String pieceKey : graph.getReachablePieces()) { + IrisJigsawPiece piece = graph.getPieces().get(pieceKey); + if (piece == null || piece.getConnectors() == null) { + return false; + } + for (IrisJigsawConnector connector : piece.getConnectors()) { + if (connector == null) { + return false; + } + IrisJigsawPool pool = graph.getPools().get(normalize(connector.getPool())); + if (!canTerminateWithoutPlacement(pool)) { + return false; + } + } + } + return true; + } + + private boolean canTerminateWithoutPlacement(IrisJigsawPool pool) { + if (hasEmptyChoice(pool)) { + return true; + } + if (pool == null) { + return false; + } + String fallbackKey = JigsawPoolSelection.directFallbackKey(pool); + return !fallbackKey.isEmpty() && hasEmptyChoice(graph.getPools().get(fallbackKey)); + } + + private boolean hasEmptyChoice(IrisJigsawPool pool) { + if (pool == null || pool.getPieces() == null) { + return false; + } + if (pool.getPieces().isEmpty()) { + return true; + } + for (IrisJigsawPieceEntry entry : pool.getPieces()) { + if (entry != null && entry.getWeight() > 0 && entry.isEmpty()) { + return true; + } + } + return false; + } + + private boolean fitsStartRadius(IrisObject object) { + long radius = (long) graph.getStructure().getMaxSizeChunks() * 16L; + return axisFitsRadius(object.getW(), radius) && axisFitsRadius(object.getD(), radius); + } + + private boolean axisFitsRadius(int size, long radius) { + if (size < 1 || radius < 1L) { + return false; + } + long minimum = -(size / 2L); + long maximum = minimum + size - 1L; + return minimum >= -radius && maximum <= radius; + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private boolean isGeometryBoundedCandidate(StructureGraphAssemblySample.Outcome outcome) { + return outcome.pieceCapReached() + && !outcome.pieceKeys().isEmpty() + && outcome.unresolvedConnectorCount() == 0; + } + + static final class Builder { + private final CompiledStructureGraph graph; + private final List diagnostics = new ArrayList<>(); + private final List assemblySamples = new ArrayList<>(); + + private Builder(CompiledStructureGraph graph) { + this.graph = Objects.requireNonNull(graph); + } + + Builder diagnostics(List values) { + diagnostics.addAll(values); + return this; + } + + Builder assemblySamples(List values) { + assemblySamples.addAll(values); + return this; + } + + StructureGraphCompilation build() { + return new StructureGraphCompilation(this); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompiler.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompiler.java new file mode 100644 index 000000000..6089ba206 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompiler.java @@ -0,0 +1,871 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.JigsawJoint; +import art.arcane.volmlib.util.collection.KList; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SplittableRandom; + +public final class StructureGraphCompiler { + private static final int ASSEMBLY_PIECE_CAP = 512; + private static final int MAX_DEPTH = 30; + private static final int MAX_SIZE_CHUNKS = 32; + private static final int[] NO_ROTATION = {0}; + private static final int[] Y_ROTATIONS = {0, 90, 180, 270}; + private static final List ASSEMBLY_SEEDS = List.of(0L, 1L, 2L, 3L, 5L, 8L, 13L, 21L); + + private StructureGraphCompiler() { + } + + public static StructureGraphCompilation compile(IrisStructure structure, StructureGraphResolver resolver) { + CompilationState state = new CompilationState( + Objects.requireNonNull(structure), Objects.requireNonNull(resolver)); + state.loadClosure(); + state.detectFallbackCycles(); + state.analyzeReachability(); + List samples = state.sampleAssemblies(); + state.reportPieceCapSamples(samples); + CompiledStructureGraph graph = state.buildGraph(); + return StructureGraphCompilation.builder(graph) + .diagnostics(state.diagnostics()) + .assemblySamples(samples) + .build(); + } + + private static final class CompilationState { + private final IrisStructure structure; + private final StructureGraphResolver resolver; + private final CompiledStructureGraph.Builder graph; + private final Deque pendingPools = new ArrayDeque<>(); + private final Map> poolReferences = new LinkedHashMap<>(); + private final Map> poolEntries = new LinkedHashMap<>(); + private final Map> pieceConnectors = new LinkedHashMap<>(); + private final Map pieceObjects = new LinkedHashMap<>(); + private final Set processedPools = new HashSet<>(); + private final Set missingPools = new HashSet<>(); + private final Set attemptedPieces = new HashSet<>(); + private final Set missingPieces = new HashSet<>(); + private final Set attemptedObjects = new HashSet<>(); + private final Set missingObjects = new HashSet<>(); + private final Set reachableEntries = new LinkedHashSet<>(); + private final Set reachablePieceStates = new LinkedHashSet<>(); + private final Map> activeConnectors = new LinkedHashMap<>(); + private final List diagnostics = new ArrayList<>(); + private final Set diagnosticKeys = new HashSet<>(); + + private CompilationState(IrisStructure structure, StructureGraphResolver resolver) { + this.structure = structure; + this.resolver = resolver; + this.graph = CompiledStructureGraph.builder(structure); + } + + private void loadClosure() { + validateStructureLimits(); + String startPool = normalize(structure.getStartPool()); + if (startPool.isEmpty()) { + addDiagnostic(StructureGraphDiagnostic.Code.MISSING_START_POOL, + "Structure '" + structureKey() + "' does not declare a start pool.", + "structure:start-pool:empty"); + return; + } + + requestPool(startPool, new PoolReference( + "Structure '" + structureKey() + "' references missing start pool '" + startPool + "'.", + StructureGraphDiagnostic.Code.MISSING_START_POOL)); + while (!pendingPools.isEmpty()) { + processPool(pendingPools.removeFirst()); + } + } + + private void validateStructureLimits() { + if (structure.getMaxDepth() < 1 || structure.getMaxDepth() > MAX_DEPTH) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_MAX_DEPTH, + "Structure '" + structureKey() + "' has maxDepth " + structure.getMaxDepth() + + "; it must be between 1 and " + MAX_DEPTH + ".", + "structure:max-depth"); + } + if (structure.getMaxSizeChunks() < 1 || structure.getMaxSizeChunks() > MAX_SIZE_CHUNKS) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_MAX_SIZE, + "Structure '" + structureKey() + "' has maxSizeChunks " + structure.getMaxSizeChunks() + + "; it must be between 1 and " + MAX_SIZE_CHUNKS + ".", + "structure:max-size"); + } + } + + private void requestPool(String rawKey, PoolReference reference) { + String key = normalize(rawKey); + if (key.isEmpty()) { + return; + } + poolReferences.computeIfAbsent(key, ignored -> new ArrayList<>()).add(reference); + if (missingPools.contains(key)) { + addDiagnostic(reference.code(), reference.message(), "missing-pool:" + key + ":" + reference.message()); + return; + } + if (!processedPools.contains(key) && !pendingPools.contains(key)) { + pendingPools.addLast(key); + } + } + + private void processPool(String key) { + if (!processedPools.add(key)) { + return; + } + IrisJigsawPool pool = resolver.loadPool(key); + if (pool == null) { + missingPools.add(key); + for (PoolReference reference : poolReferences.getOrDefault(key, List.of())) { + addDiagnostic(reference.code(), reference.message(), + "missing-pool:" + key + ":" + reference.message()); + } + return; + } + + graph.pools().put(key, pool); + List entries = new ArrayList<>(); + poolEntries.put(key, entries); + KList configuredEntries = pool.getPieces(); + if (configuredEntries == null || configuredEntries.isEmpty()) { + if (key.equals(normalize(structure.getStartPool()))) { + addDiagnostic(StructureGraphDiagnostic.Code.EMPTY_START_POOL, + "Jigsaw pool '" + key + "' is empty.", + "empty-pool:" + key); + } + } else { + for (int index = 0; index < configuredEntries.size(); index++) { + scanPoolEntry(key, index, configuredEntries.get(index), entries); + } + } + + String fallback = JigsawPoolSelection.directFallbackKey(pool); + if (!fallback.isEmpty()) { + requestPool(fallback, new PoolReference( + "Jigsaw pool '" + key + "' references missing fallback pool '" + fallback + "'.", + StructureGraphDiagnostic.Code.MISSING_POOL)); + } + } + + private void scanPoolEntry(String poolKey, int index, IrisJigsawPieceEntry entry, + List entries) { + if (entry == null) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_POOL_ENTRY, + "Jigsaw pool '" + poolKey + "' pieces[" + index + "] is null.", + "pool-entry:null:" + poolKey + ":" + index); + return; + } + + String pieceKey = normalize(entry.getPiece()); + PoolEntryReference entryReference = new PoolEntryReference( + poolKey, index, pieceKey, entry.getWeight(), entry.isEmpty()); + entries.add(entryReference); + if (entry.getWeight() <= 0) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_WEIGHT, + "Jigsaw pool '" + poolKey + "' pieces[" + index + "] has non-positive weight " + + entry.getWeight() + ".", + "pool-entry:weight:" + poolKey + ":" + index); + } + if (entry.isEmpty()) { + if (!pieceKey.isEmpty()) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_POOL_ENTRY, + "Jigsaw pool '" + poolKey + "' pieces[" + index + + "] declares both empty=true and a piece.", + "pool-entry:ambiguous-empty:" + poolKey + ":" + index); + } + return; + } + if (pieceKey.isEmpty()) { + addDiagnostic(StructureGraphDiagnostic.Code.MISSING_PIECE, + "Jigsaw pool '" + poolKey + "' pieces[" + index + "] does not declare a piece.", + "pool-entry:piece-empty:" + poolKey + ":" + index); + return; + } + loadPiece(pieceKey, "Jigsaw pool '" + poolKey + "' pieces[" + index + + "] references missing piece '" + pieceKey + "'."); + } + + private void loadPiece(String key, String missingMessage) { + if (missingPieces.contains(key)) { + addDiagnostic(StructureGraphDiagnostic.Code.MISSING_PIECE, missingMessage, + "missing-piece:" + key + ":" + missingMessage); + return; + } + if (!attemptedPieces.add(key)) { + return; + } + + IrisJigsawPiece piece = resolver.loadPiece(key); + if (piece == null) { + missingPieces.add(key); + addDiagnostic(StructureGraphDiagnostic.Code.MISSING_PIECE, missingMessage, + "missing-piece:" + key + ":" + missingMessage); + return; + } + + graph.pieces().put(key, piece); + IrisObject object = loadPieceObject(key, piece); + scanConnectors(key, piece, object); + } + + private IrisObject loadPieceObject(String pieceKey, IrisJigsawPiece piece) { + String objectKey = normalize(piece.getObject()); + pieceObjects.put(pieceKey, objectKey); + if (objectKey.isEmpty()) { + addDiagnostic(StructureGraphDiagnostic.Code.MISSING_OBJECT, + "Jigsaw piece '" + pieceKey + "' does not declare an object.", + "piece-object:empty:" + pieceKey); + return null; + } + if (missingObjects.contains(objectKey)) { + addDiagnostic(StructureGraphDiagnostic.Code.MISSING_OBJECT, + "Jigsaw piece '" + pieceKey + "' references missing object '" + objectKey + "'.", + "missing-object:" + objectKey + ":" + pieceKey); + return null; + } + if (!attemptedObjects.add(objectKey)) { + return graph.objects().get(objectKey); + } + + IrisObject object = resolver.loadObject(objectKey); + if (object == null) { + missingObjects.add(objectKey); + addDiagnostic(StructureGraphDiagnostic.Code.MISSING_OBJECT, + "Jigsaw piece '" + pieceKey + "' references missing object '" + objectKey + "'.", + "missing-object:" + objectKey + ":" + pieceKey); + return null; + } + graph.objects().put(objectKey, object); + if (object.getW() < 1 || object.getH() < 1 || object.getD() < 1) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_OBJECT_BOUNDS, + "Object '" + objectKey + "' used by jigsaw piece '" + pieceKey + + "' has invalid dimensions " + object.getW() + "x" + object.getH() + "x" + + object.getD() + ".", + "object-bounds:" + objectKey); + } + return object; + } + + private void scanConnectors(String pieceKey, IrisJigsawPiece piece, IrisObject object) { + List references = new ArrayList<>(); + pieceConnectors.put(pieceKey, references); + KList connectors = piece.getConnectors(); + if (connectors == null) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_CONNECTOR, + "Jigsaw piece '" + pieceKey + "' declares a null connector list.", + "connector-list:null:" + pieceKey); + return; + } + for (int index = 0; index < connectors.size(); index++) { + IrisJigsawConnector connector = connectors.get(index); + if (connector == null) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_CONNECTOR, + "Jigsaw piece '" + pieceKey + "' connectors[" + index + "] is null.", + "connector:null:" + pieceKey + ":" + index); + continue; + } + + boolean validDirection = connector.getDirection() != null; + if (!validDirection) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_CONNECTOR_DIRECTION, + "Jigsaw piece '" + pieceKey + "' connectors[" + index + + "] does not declare a direction.", + "connector:direction:" + pieceKey + ":" + index); + } + boolean validTop = connector.getTop() != null; + if (!validTop) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_CONNECTOR_DIRECTION, + "Jigsaw piece '" + pieceKey + "' connectors[" + index + + "] does not declare a top direction.", + "connector:top:" + pieceKey + ":" + index); + } + boolean validJoint = connector.getJoint() != null; + if (!validJoint) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_CONNECTOR, + "Jigsaw piece '" + pieceKey + "' connectors[" + index + + "] does not declare a joint type.", + "connector:joint:" + pieceKey + ":" + index); + } + boolean validPosition = isValidPosition(connector.getPosition(), object); + if (connector.getPosition() == null || object != null && !validPosition) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_CONNECTOR_POSITION, + invalidPositionMessage(pieceKey, index, connector.getPosition(), object), + "connector:position:" + pieceKey + ":" + index); + } + + boolean validNames = connector.getName() != null && connector.getTargetName() != null; + if (!validNames) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_CONNECTOR, + "Jigsaw piece '" + pieceKey + "' connectors[" + index + + "] must declare non-null name and targetName values.", + "connector:names:" + pieceKey + ":" + index); + } + + String poolKey = normalize(connector.getPool()); + if (poolKey.isEmpty()) { + addDiagnostic(StructureGraphDiagnostic.Code.MISSING_CONNECTOR_POOL, + "Jigsaw piece '" + pieceKey + "' connectors[" + index + + "] does not declare a target pool.", + "connector:pool-empty:" + pieceKey + ":" + index); + } else { + requestPool(poolKey, new PoolReference( + "Jigsaw piece '" + pieceKey + "' connectors[" + index + + "] references missing pool '" + poolKey + "'.", + StructureGraphDiagnostic.Code.MISSING_POOL)); + } + references.add(new ConnectorReference( + pieceKey, index, connector, validPosition, validDirection, validTop, validJoint, validNames)); + } + } + + private String invalidPositionMessage(String pieceKey, int index, IrisPosition position, IrisObject object) { + if (position == null) { + return "Jigsaw piece '" + pieceKey + "' connectors[" + index + + "] does not declare a position."; + } + if (object == null) { + return "Jigsaw piece '" + pieceKey + "' connectors[" + index + + "] position " + position + " cannot be checked because its object is missing."; + } + return "Jigsaw piece '" + pieceKey + "' connectors[" + index + "] position " + position + + " is outside object bounds 0.." + (object.getW() - 1) + ", 0.." + (object.getH() - 1) + + ", 0.." + (object.getD() - 1) + "."; + } + + private boolean isValidPosition(IrisPosition position, IrisObject object) { + if (position == null || object == null) { + return false; + } + return position.getX() >= 0 && position.getX() < object.getW() + && position.getY() >= 0 && position.getY() < object.getH() + && position.getZ() >= 0 && position.getZ() < object.getD(); + } + + private void detectFallbackCycles() { + Map states = new HashMap<>(); + for (String poolKey : graph.pools().keySet()) { + detectFallbackCycle(poolKey, states); + } + } + + private void detectFallbackCycle(String startPool, Map states) { + if (states.getOrDefault(startPool, 0) == 2) { + return; + } + List path = new ArrayList<>(); + Map pathIndexes = new HashMap<>(); + String poolKey = startPool; + while (!poolKey.isEmpty() && graph.pools().containsKey(poolKey) + && states.getOrDefault(poolKey, 0) != 2) { + Integer cycleStart = pathIndexes.get(poolKey); + if (cycleStart != null) { + List cycle = new ArrayList<>(path.subList(cycleStart, path.size())); + cycle.add(poolKey); + String cyclePath = String.join(" -> ", cycle); + addDiagnostic(StructureGraphDiagnostic.Code.FALLBACK_CYCLE, + "Jigsaw pool fallback cycle detected: " + cyclePath + ".", + "fallback-cycle:" + cyclePath); + break; + } + pathIndexes.put(poolKey, path.size()); + path.add(poolKey); + states.put(poolKey, 1); + IrisJigsawPool pool = graph.pools().get(poolKey); + poolKey = pool == null ? "" : JigsawPoolSelection.directFallbackKey(pool); + } + for (String visitedPool : path) { + states.put(visitedPool, 2); + } + } + + private void analyzeReachability() { + String startPool = normalize(structure.getStartPool()); + if (!graph.pools().containsKey(startPool)) { + return; + } + + graph.reachablePools().add(startPool); + Deque pendingPieces = new ArrayDeque<>(); + for (PoolEntryReference entry : poolEntries.getOrDefault(startPool, List.of())) { + markReachableEntry(entry); + if (entry.empty()) { + continue; + } + IrisJigsawPiece piece = graph.pieces().get(entry.pieceKey()); + if (piece == null) { + continue; + } + for (int rotation : rotationsFor(piece)) { + markReachablePieceState(new PieceReachState(entry.pieceKey(), -1, rotation), pendingPieces); + } + } + if (pendingPieces.isEmpty()) { + addDiagnostic(StructureGraphDiagnostic.Code.NO_VIABLE_START_PIECE, + "Structure '" + structureKey() + "' has no start-pool entry with a positive weight," + + " resolvable piece, and resolvable object.", + "structure:no-viable-start"); + } + + while (!pendingPieces.isEmpty()) { + PieceReachState pieceState = pendingPieces.removeFirst(); + for (ConnectorReference source : pieceConnectors.getOrDefault(pieceState.pieceKey(), List.of())) { + if (source.index() == pieceState.skippedConnectorIndex() || !source.canSource()) { + continue; + } + OrientedConnector orientedSource = new OrientedConnector(source, pieceState.rotation()); + activeConnectors.computeIfAbsent(source.id(), ignored -> new ArrayList<>()) + .add(orientedSource); + List candidatePools = candidatePools(normalize(source.connector().getPool()), true); + for (String candidatePool : candidatePools) { + graph.reachablePools().add(candidatePool); + for (PoolEntryReference entry : poolEntries.getOrDefault(candidatePool, List.of())) { + if (!entryIsViable(entry)) { + continue; + } + List compatible = findCompatibleConnectors( + orientedSource, entry.pieceKey()); + if (!compatible.isEmpty()) { + markReachableEntry(entry); + for (ConnectorMatch target : compatible) { + markReachablePieceState( + new PieceReachState(entry.pieceKey(), target.connector().index(), + target.rotation()), + pendingPieces); + } + } + } + } + } + } + + reportUnmatchedReachableConnectors(); + reportUnreachableResources(); + } + + private void markReachableEntry(PoolEntryReference entry) { + if (!entryIsViable(entry)) { + return; + } + reachableEntries.add(entry.id()); + if (!entry.empty()) { + graph.reachablePieces().add(entry.pieceKey()); + } + } + + private void markReachablePieceState(PieceReachState state, Deque pendingPieces) { + if (!graph.reachablePieces().contains(state.pieceKey())) { + return; + } + if (reachablePieceStates.add(state)) { + pendingPieces.addLast(state); + } + } + + private boolean entryIsViable(PoolEntryReference entry) { + if (entry.weight() <= 0) { + return false; + } + if (entry.empty()) { + return entry.pieceKey().isEmpty(); + } + if (entry.pieceKey().isEmpty() || !graph.pieces().containsKey(entry.pieceKey())) { + return false; + } + String objectKey = pieceObjects.getOrDefault(entry.pieceKey(), ""); + return !objectKey.isEmpty() && graph.objects().containsKey(objectKey); + } + + private void reportUnmatchedReachableConnectors() { + for (List orientations : activeConnectors.values()) { + OrientedConnector representative = orientations.getFirst(); + List candidates = candidatePools( + normalize(representative.definition().getPool()), true); + boolean hasViableEntry = false; + boolean hasCompatibleEntry = false; + for (String candidatePool : candidates) { + for (PoolEntryReference entry : poolEntries.getOrDefault(candidatePool, List.of())) { + if (!entryIsViable(entry)) { + continue; + } + hasViableEntry = true; + if (entry.empty()) { + hasCompatibleEntry = true; + break; + } + for (OrientedConnector source : orientations) { + if (!findCompatibleConnectors(source, entry.pieceKey()).isEmpty()) { + hasCompatibleEntry = true; + break; + } + } + if (hasCompatibleEntry) { + break; + } + } + if (hasCompatibleEntry) { + break; + } + } + if (hasViableEntry && !hasCompatibleEntry) { + addDiagnostic(StructureGraphDiagnostic.Code.NO_COMPATIBLE_CONNECTOR, + "Jigsaw piece '" + representative.connector().pieceKey() + "' connectors[" + + representative.connector().index() + "] targets pool '" + + normalize(representative.definition().getPool()) + + "', but no reachable candidate exposes the requested target name with a" + + " compatible direction.", + "connector:no-match:" + representative.connector().pieceKey() + ":" + + representative.connector().index()); + } + } + } + + private void reportUnreachableResources() { + for (String poolKey : graph.pools().keySet()) { + if (!graph.reachablePools().contains(poolKey)) { + addDiagnostic(StructureGraphDiagnostic.Code.UNREACHABLE_POOL, + "Jigsaw pool '" + poolKey + "' is in the structure closure but cannot be reached" + + " from the start pool.", + "unreachable-pool:" + poolKey); + continue; + } + for (PoolEntryReference entry : poolEntries.getOrDefault(poolKey, List.of())) { + if (!entry.empty() && entryIsViable(entry) && !reachableEntries.contains(entry.id())) { + addDiagnostic(StructureGraphDiagnostic.Code.UNREACHABLE_PIECE, + "Jigsaw pool '" + poolKey + "' pieces[" + entry.index() + "] references piece '" + + entry.pieceKey() + "', but no reachable connector can attach it.", + "unreachable-piece:" + entry.id()); + } + } + } + } + + private List findCompatibleConnectors(OrientedConnector source, + String candidatePieceKey) { + List compatible = new ArrayList<>(); + IrisJigsawPiece candidatePiece = graph.pieces().get(candidatePieceKey); + if (candidatePiece == null) { + return compatible; + } + for (ConnectorReference candidate : pieceConnectors.getOrDefault(candidatePieceKey, List.of())) { + for (int rotation : rotationsFor(candidatePiece)) { + if (connectorsCompatible(source, candidate, rotation)) { + compatible.add(new ConnectorMatch(candidate, rotation)); + } + } + } + return compatible; + } + + private boolean connectorsCompatible(OrientedConnector source, ConnectorReference candidate, + int candidateRotation) { + if (!source.connector().canSource() || !candidate.canTarget()) { + return false; + } + IrisJigsawConnector sourceConnector = source.definition(); + IrisJigsawConnector candidateConnector = candidate.connector(); + if (!normalize(sourceConnector.getTargetName()).equals(normalize(candidateConnector.getName()))) { + return false; + } + + IrisDirection sourceDirection = rotateDirection( + sourceConnector.getDirection(), source.rotation()); + IrisDirection candidateDirection = rotateDirection( + candidateConnector.getDirection(), candidateRotation); + if (candidateDirection != sourceDirection.reverse()) { + return false; + } + if (sourceConnector.getJoint() != JigsawJoint.ALIGNED) { + return true; + } + IrisDirection sourceTop = rotateDirection(sourceConnector.getTop(), source.rotation()); + IrisDirection candidateTop = rotateDirection(candidateConnector.getTop(), candidateRotation); + return candidateTop == sourceTop; + } + + private List candidatePools(String firstPool, boolean includeFirst) { + List result = new ArrayList<>(); + IrisJigsawPool pool = graph.pools().get(firstPool); + if (pool == null) { + return result; + } + for (String candidate : JigsawPoolSelection.candidatePoolKeys(firstPool, pool, includeFirst)) { + if (graph.pools().containsKey(candidate)) { + result.add(candidate); + } + } + return result; + } + + private List sampleAssemblies() { + List samples = new ArrayList<>(ASSEMBLY_SEEDS.size()); + for (long seed : ASSEMBLY_SEEDS) { + samples.add(sampleAssembly(seed)); + } + return samples; + } + + private StructureGraphAssemblySample sampleAssembly(long seed) { + SplittableRandom random = new SplittableRandom(seed); + List pieces = new ArrayList<>(); + Deque open = new ArrayDeque<>(); + String startPool = normalize(structure.getStartPool()); + PoolEntryReference start = weightedPick(viableEntries(startPool), random); + if (start == null) { + return new StructureGraphAssemblySample(seed, + new StructureGraphAssemblySample.Outcome(pieces, 1, false)); + } + if (start.empty()) { + return new StructureGraphAssemblySample(seed, + new StructureGraphAssemblySample.Outcome(pieces, 0, false, true)); + } + + IrisJigsawPiece startPiece = graph.pieces().get(start.pieceKey()); + int startRotation = startPiece != null && startPiece.isRotatable() + ? Y_ROTATIONS[random.nextInt(Y_ROTATIONS.length)] + : 0; + addSamplePiece(start.pieceKey(), -1, 0, startRotation, pieces, open); + int unresolvedConnectors = 0; + while (!open.isEmpty() && pieces.size() < ASSEMBLY_PIECE_CAP) { + SampleConnector source = open.removeFirst(); + AttachmentCandidate candidate = sampleCandidate(source, random); + if (candidate == null) { + if (!isIntentionalTermination(source)) { + unresolvedConnectors++; + } + continue; + } + if (candidate.entry().empty()) { + continue; + } + if (source.depth() < Math.max(1, structure.getMaxDepth())) { + addSamplePiece(candidate.entry().pieceKey(), candidate.match().connector().index(), + source.depth() + 1, candidate.match().rotation(), pieces, open); + } else { + pieces.add(candidate.entry().pieceKey()); + } + } + boolean capped = !open.isEmpty(); + return new StructureGraphAssemblySample(seed, + new StructureGraphAssemblySample.Outcome(pieces, unresolvedConnectors, capped)); + } + + private AttachmentCandidate sampleCandidate(SampleConnector source, SplittableRandom random) { + String primaryPool = normalize(source.connector().definition().getPool()); + boolean withinDepth = source.depth() < Math.max(1, structure.getMaxDepth()); + List candidates = candidatePools(primaryPool, withinDepth); + for (String candidatePool : candidates) { + List attachments = new ArrayList<>(); + for (PoolEntryReference entry : viableEntries(candidatePool)) { + if (entry.empty()) { + attachments.add(new AttachmentCandidate(entry, null)); + continue; + } + List matching = findCompatibleConnectors( + source.connector(), entry.pieceKey()); + if (!matching.isEmpty()) { + attachments.add(new AttachmentCandidate(entry, matching.getFirst())); + } + } + AttachmentCandidate selected = weightedPickAttachments(attachments, random); + if (selected != null) { + return selected; + } + } + return null; + } + + private boolean isIntentionalTermination(SampleConnector source) { + String poolKey = normalize(source.connector().definition().getPool()); + IrisJigsawPool pool = graph.pools().get(poolKey); + if (pool == null) { + return false; + } + boolean includePrimary = source.depth() < Math.max(1, structure.getMaxDepth()); + for (String candidate : candidatePools(poolKey, includePrimary)) { + IrisJigsawPool candidatePool = graph.pools().get(candidate); + if (candidatePool != null && candidatePool.getPieces() != null + && candidatePool.getPieces().isEmpty()) { + return true; + } + } + return !includePrimary; + } + + private void addSamplePiece(String pieceKey, int skippedConnector, int connectorDepth, int rotation, + List pieces, Deque open) { + pieces.add(pieceKey); + for (ConnectorReference connector : pieceConnectors.getOrDefault(pieceKey, List.of())) { + if (connector.index() != skippedConnector && connector.canSource()) { + open.addLast(new SampleConnector( + new OrientedConnector(connector, rotation), connectorDepth)); + } + } + } + + private List viableEntries(String poolKey) { + List result = new ArrayList<>(); + for (PoolEntryReference entry : poolEntries.getOrDefault(poolKey, List.of())) { + if (entryIsViable(entry)) { + result.add(entry); + } + } + return result; + } + + private PoolEntryReference weightedPick(List entries, SplittableRandom random) { + long totalWeight = 0L; + for (PoolEntryReference entry : entries) { + totalWeight += entry.weight(); + } + if (totalWeight <= 0L) { + return null; + } + long target = random.nextLong(totalWeight); + for (PoolEntryReference entry : entries) { + target -= entry.weight(); + if (target < 0L) { + return entry; + } + } + return entries.getLast(); + } + + private AttachmentCandidate weightedPickAttachments(List attachments, + SplittableRandom random) { + long totalWeight = 0L; + for (AttachmentCandidate attachment : attachments) { + totalWeight += attachment.entry().weight(); + } + if (totalWeight <= 0L) { + return null; + } + long target = random.nextLong(totalWeight); + for (AttachmentCandidate attachment : attachments) { + target -= attachment.entry().weight(); + if (target < 0L) { + return attachment; + } + } + return attachments.getLast(); + } + + private void reportPieceCapSamples(List samples) { + List cappedSeeds = new ArrayList<>(); + for (StructureGraphAssemblySample sample : samples) { + if (sample.outcome().pieceCapReached()) { + cappedSeeds.add(Long.toString(sample.seed())); + } + } + if (!cappedSeeds.isEmpty()) { + addDiagnostic(StructureGraphDiagnostic.Code.ASSEMBLY_PIECE_CAP_REACHED, + "Deterministic structural assembly reached the " + ASSEMBLY_PIECE_CAP + + "-piece safety cap for seeds " + String.join(", ", cappedSeeds) + ".", + "assembly:piece-cap"); + } + } + + private CompiledStructureGraph buildGraph() { + return graph.build(); + } + + private List diagnostics() { + return diagnostics; + } + + private void addDiagnostic(StructureGraphDiagnostic.Code code, String message, String deduplicationKey) { + if (diagnosticKeys.add(code.name() + ":" + deduplicationKey)) { + diagnostics.add(new StructureGraphDiagnostic(code, message)); + } + } + + private String structureKey() { + String key = structure.getLoadKey(); + return key == null || key.isBlank() ? "" : key; + } + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private static int[] rotationsFor(IrisJigsawPiece piece) { + return piece.isRotatable() ? Y_ROTATIONS : NO_ROTATION; + } + + private static IrisDirection rotateDirection(IrisDirection direction, int degrees) { + if (direction.isVertical()) { + return direction; + } + int turns = Math.floorMod(degrees, 360) / 90; + IrisDirection rotated = direction; + for (int turn = 0; turn < turns; turn++) { + rotated = switch (rotated) { + case NORTH_NEGATIVE_Z -> IrisDirection.WEST_NEGATIVE_X; + case WEST_NEGATIVE_X -> IrisDirection.SOUTH_POSITIVE_Z; + case SOUTH_POSITIVE_Z -> IrisDirection.EAST_POSITIVE_X; + case EAST_POSITIVE_X -> IrisDirection.NORTH_NEGATIVE_Z; + case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> rotated; + }; + } + return rotated; + } + + private record PoolReference(String message, StructureGraphDiagnostic.Code code) { + } + + private record PoolEntryReference(String poolKey, int index, String pieceKey, int weight, boolean empty) { + private String id() { + return poolKey + "#" + index; + } + } + + private record ConnectorReference(String pieceKey, int index, IrisJigsawConnector connector, + boolean validPosition, boolean validDirection, boolean validTop, + boolean validJoint, boolean validNames) { + private String id() { + return pieceKey + "#" + index; + } + + private boolean canSource() { + return canTarget() && !normalize(connector.getPool()).isEmpty(); + } + + private boolean canTarget() { + return connector != null && validPosition && validDirection && validTop && validJoint && validNames; + } + } + + private record PieceReachState(String pieceKey, int skippedConnectorIndex, int rotation) { + } + + private record OrientedConnector(ConnectorReference connector, int rotation) { + private IrisJigsawConnector definition() { + return connector.connector(); + } + } + + private record ConnectorMatch(ConnectorReference connector, int rotation) { + } + + private record SampleConnector(OrientedConnector connector, int depth) { + } + + private record AttachmentCandidate(PoolEntryReference entry, ConnectorMatch match) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphDiagnostic.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphDiagnostic.java new file mode 100644 index 000000000..104f94e01 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphDiagnostic.java @@ -0,0 +1,52 @@ +package art.arcane.iris.engine.framework.structure; + +import java.util.Objects; + +public record StructureGraphDiagnostic(Code code, String message) { + public StructureGraphDiagnostic { + Objects.requireNonNull(code); + Objects.requireNonNull(message); + } + + public Severity severity() { + return code.severity(); + } + + public enum Severity { + ERROR, + WARNING + } + + public enum Code { + MISSING_START_POOL(Severity.ERROR), + EMPTY_START_POOL(Severity.ERROR), + MISSING_POOL(Severity.ERROR), + INVALID_MAX_DEPTH(Severity.ERROR), + INVALID_MAX_SIZE(Severity.ERROR), + INVALID_POOL_ENTRY(Severity.ERROR), + INVALID_WEIGHT(Severity.ERROR), + MISSING_PIECE(Severity.ERROR), + MISSING_OBJECT(Severity.ERROR), + INVALID_OBJECT_BOUNDS(Severity.ERROR), + INVALID_CONNECTOR(Severity.ERROR), + INVALID_CONNECTOR_POSITION(Severity.ERROR), + INVALID_CONNECTOR_DIRECTION(Severity.ERROR), + MISSING_CONNECTOR_POOL(Severity.ERROR), + FALLBACK_CYCLE(Severity.ERROR), + NO_VIABLE_START_PIECE(Severity.WARNING), + NO_COMPATIBLE_CONNECTOR(Severity.WARNING), + UNREACHABLE_POOL(Severity.WARNING), + UNREACHABLE_PIECE(Severity.WARNING), + ASSEMBLY_PIECE_CAP_REACHED(Severity.WARNING); + + private final Severity severity; + + Code(Severity severity) { + this.severity = severity; + } + + public Severity severity() { + return severity; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphResolver.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphResolver.java new file mode 100644 index 000000000..d252b0a5a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphResolver.java @@ -0,0 +1,74 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; + +import java.util.Objects; + +public interface StructureGraphResolver { + IrisJigsawPool loadPool(String key); + + IrisJigsawPiece loadPiece(String key); + + IrisObject loadObject(String key); + + static StructureGraphResolver forData(IrisData data) { + return new IrisDataStructureGraphResolver(Objects.requireNonNull(data)); + } + + static StructureGraphResolver forCompiledGraph(CompiledStructureGraph graph) { + return new CompiledStructureGraphResolver(Objects.requireNonNull(graph)); + } +} + +final class IrisDataStructureGraphResolver implements StructureGraphResolver { + private final IrisData data; + + IrisDataStructureGraphResolver(IrisData data) { + this.data = data; + } + + @Override + public IrisJigsawPool loadPool(String key) { + return data.load(IrisJigsawPool.class, key, false); + } + + @Override + public IrisJigsawPiece loadPiece(String key) { + return data.load(IrisJigsawPiece.class, key, false); + } + + @Override + public IrisObject loadObject(String key) { + return data.load(IrisObject.class, key, false); + } +} + +final class CompiledStructureGraphResolver implements StructureGraphResolver { + private final CompiledStructureGraph graph; + + CompiledStructureGraphResolver(CompiledStructureGraph graph) { + this.graph = graph; + } + + @Override + public IrisJigsawPool loadPool(String key) { + return graph.getPools().get(normalize(key)); + } + + @Override + public IrisJigsawPiece loadPiece(String key) { + return graph.getPieces().get(normalize(key)); + } + + @Override + public IrisObject loadObject(String key) { + return graph.getObjects().get(normalize(key)); + } + + private static String normalize(String key) { + return key == null ? "" : key.trim(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompiler.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompiler.java new file mode 100644 index 000000000..8c2d3ae9b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompiler.java @@ -0,0 +1,191 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.engine.framework.PlacedStructurePiece; +import art.arcane.iris.engine.framework.StructureAssembler; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class StructureResourceBundleGraphCompiler { + private static final Gson GSON = new GsonBuilder().create(); + private static final List ASSEMBLY_SEEDS = List.of( + 0L, 1L, 2L, 3L, 5L, 8L, 13L, 21L, 34L, 55L, 89L, 144L, 233L, 377L, 610L, 987L); + + private StructureResourceBundleGraphCompiler() { + } + + public static List compile(StructureResourceBundle bundle) { + BundleGraph graph = parse(Objects.requireNonNull(bundle)); + List compilations = new ArrayList<>(graph.structures().size()); + for (IrisStructure structure : graph.structures().values()) { + compilations.add(StructureGraphCompiler.compile(structure, graph.resolver())); + } + return List.copyOf(compilations); + } + + public static void requireViable(StructureResourceBundle bundle) { + BundleGraph graph = parse(Objects.requireNonNull(bundle)); + if (graph.structures().isEmpty()) { + throw new IllegalArgumentException("Structure bundle does not contain a root structure resource"); + } + for (IrisStructure structure : graph.structures().values()) { + StructureGraphCompilation compilation = StructureGraphCompiler.compile(structure, graph.resolver()); + if (!compilation.isAssemblyViable()) { + String diagnostic = compilation.getDiagnostics().isEmpty() + ? "deterministic assembly did not complete" + : compilation.getDiagnostics().getFirst().message(); + throw new IllegalArgumentException("Structure bundle graph is not viable: " + diagnostic); + } + requireGeometryViable(compilation); + } + } + + private static BundleGraph parse(StructureResourceBundle activeBundle) { + Map structures = new LinkedHashMap<>(); + Map pools = new LinkedHashMap<>(); + Map pieces = new LinkedHashMap<>(); + Map objects = new LinkedHashMap<>(); + for (StructureResourceBundle.Resource resource : activeBundle.resources().values()) { + String path = resource.relativePath(); + if (matches(path, "structures/", ".json")) { + IrisStructure structure = readJson(resource, IrisStructure.class); + String key = key(path, "structures/", ".json"); + structure.setLoadKey(key); + structures.put(key, structure); + } else if (matches(path, "jigsaw-pools/", ".json")) { + pools.put(key(path, "jigsaw-pools/", ".json"), readJson(resource, IrisJigsawPool.class)); + } else if (matches(path, "jigsaw-pieces/", ".json")) { + pieces.put(key(path, "jigsaw-pieces/", ".json"), readJson(resource, IrisJigsawPiece.class)); + } else if (matches(path, "objects/", ".iob")) { + objects.put(key(path, "objects/", ".iob"), readObjectBounds(resource)); + } + } + return new BundleGraph(structures, new BundleResolver(pools, pieces, objects)); + } + + private static void requireGeometryViable(StructureGraphCompilation compilation) { + String structureKey = compilation.getGraph().getStructureKey(); + for (long seed : ASSEMBLY_SEEDS) { + try { + StructureAssembler assembler = StructureAssembler.forCompilation( + compilation, new IrisPosition(0, 64, 0)); + KList pieces = assembler.assemble(new RNG(seed)); + if (pieces == null) { + throw new IllegalArgumentException("Structure bundle graph '" + structureKey + + "' fails sampled runtime geometry assembly at seed " + seed); + } + } catch (RuntimeException e) { + if (e instanceof IllegalArgumentException + && e.getMessage() != null + && e.getMessage().startsWith("Structure bundle graph '")) { + throw e; + } + throw new IllegalArgumentException("Structure bundle graph '" + structureKey + + "' fails sampled runtime geometry assembly at seed " + seed + ": " + + e.getClass().getSimpleName() + ": " + failureMessage(e), e); + } + } + } + + private static String failureMessage(RuntimeException exception) { + return exception.getMessage() == null || exception.getMessage().isBlank() + ? "no failure detail" : exception.getMessage(); + } + + private static boolean matches(String path, String prefix, String suffix) { + return path.startsWith(prefix) && path.endsWith(suffix) && path.length() > prefix.length() + suffix.length(); + } + + private static String key(String path, String prefix, String suffix) { + return path.substring(prefix.length(), path.length() - suffix.length()); + } + + private static T readJson(StructureResourceBundle.Resource resource, Class type) { + T value; + try { + value = GSON.fromJson(new String(resource.content(), StandardCharsets.UTF_8), type); + } catch (RuntimeException e) { + throw new IllegalArgumentException("Malformed structure resource " + resource.relativePath(), e); + } + if (value == null) { + throw new IllegalArgumentException("Empty structure resource " + resource.relativePath()); + } + return value; + } + + private static IrisObject readObjectBounds(StructureResourceBundle.Resource resource) { + try (ByteArrayInputStream input = new ByteArrayInputStream(resource.content())) { + return IrisObjectFrameReader.readBounds(input, resource.relativePath()); + } catch (IOException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } + + private record BundleResolver( + Map pools, + Map pieces, + Map objects + ) implements StructureGraphResolver { + private BundleResolver { + pools = Map.copyOf(pools); + pieces = Map.copyOf(pieces); + objects = Map.copyOf(objects); + } + + @Override + public IrisJigsawPool loadPool(String key) { + return pools.get(key); + } + + @Override + public IrisJigsawPiece loadPiece(String key) { + return pieces.get(key); + } + + @Override + public IrisObject loadObject(String key) { + return objects.get(key); + } + } + + private record BundleGraph(Map structures, BundleResolver resolver) { + private BundleGraph { + structures = Map.copyOf(structures); + Objects.requireNonNull(resolver); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java new file mode 100644 index 000000000..465822d67 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java @@ -0,0 +1,281 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.data.cache.Cache; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IObjectPlacer; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.volmlib.util.collection.KList; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +final class CaveObjectPlacementTransaction implements IObjectPlacer { + private static final int CACHE_MISS = Integer.MIN_VALUE; + + private final IObjectPlacer delegate; + private final Engine engine; + private final int anchorY; + private final int minDepthBelowSurface; + private final int worldHeight; + private final KList mutations; + private final Map bufferedBlocks; + private final Long2IntOpenHashMap surfaceHeights; + private final Long2IntOpenHashMap caveCeilings; + private int blockWrites; + + CaveObjectPlacementTransaction(IObjectPlacer delegate, int anchorY, int minDepthBelowSurface) { + this.delegate = delegate; + this.engine = delegate.getEngine(); + this.anchorY = anchorY; + this.minDepthBelowSurface = Math.max(0, minDepthBelowSurface); + this.worldHeight = engine == null ? 0 : engine.getHeight(); + this.mutations = new KList<>(); + this.bufferedBlocks = new HashMap<>(); + this.surfaceHeights = new Long2IntOpenHashMap(); + this.caveCeilings = new Long2IntOpenHashMap(); + this.surfaceHeights.defaultReturnValue(CACHE_MISS); + this.caveCeilings.defaultReturnValue(CACHE_MISS); + } + + CommitResult commit() { + if (blockWrites == 0) { + discard(); + return CommitResult.EMPTY; + } + + for (BufferedMutation mutation : mutations) { + if (!isWithinBounds(mutation.x(), mutation.y(), mutation.z())) { + discard(); + return CommitResult.REJECTED_BOUNDS; + } + } + + for (BufferedMutation mutation : mutations) { + mutation.apply(delegate); + } + discard(); + return CommitResult.COMMITTED; + } + + void discard() { + mutations.clear(); + bufferedBlocks.clear(); + blockWrites = 0; + } + + int getCaveCeiling(int x, int z) { + long key = Cache.key(x, z); + int cached = caveCeilings.get(key); + if (cached != CACHE_MISS) { + return cached; + } + + int ceiling = findCaveCeiling(x, z); + caveCeilings.put(key, ceiling); + return ceiling; + } + + static int maxBuriedY(int worldHeight, int surfaceY, int minDepthBelowSurface) { + return Math.min(worldHeight - 1, surfaceY - Math.max(0, minDepthBelowSurface)); + } + + @Override + public int getHighest(int x, int z, IrisData data) { + return delegate.getHighest(x, z, data); + } + + @Override + public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + return delegate.getHighest(x, z, data, ignoreFluid); + } + + @Override + public void set(int x, int y, int z, PlatformBlockState state) { + if (state == null) { + return; + } + mutations.add(new BlockMutation(x, y, z, state)); + bufferedBlocks.put(new PositionKey(x, y, z), state); + blockWrites++; + } + + @Override + public PlatformBlockState get(int x, int y, int z) { + PlatformBlockState buffered = bufferedBlocks.get(new PositionKey(x, y, z)); + return buffered == null ? delegate.get(x, y, z) : buffered; + } + + @Override + public boolean isPreventingDecay() { + return delegate.isPreventingDecay(); + } + + @Override + public boolean isCarved(int x, int y, int z) { + return delegate.isCarved(x, y, z); + } + + @Override + public boolean isSolid(int x, int y, int z) { + PlatformBlockState buffered = bufferedBlocks.get(new PositionKey(x, y, z)); + return buffered == null ? delegate.isSolid(x, y, z) : B.isSolid(buffered); + } + + @Override + public boolean isUnderwater(int x, int z) { + return delegate.isUnderwater(x, z); + } + + @Override + public int getFluidHeight() { + return delegate.getFluidHeight(); + } + + @Override + public boolean isDebugSmartBore() { + return delegate.isDebugSmartBore(); + } + + @Override + public void setTile(int x, int y, int z, TileData tile) { + if (tile != null) { + mutations.add(new TileMutation(x, y, z, tile)); + } + } + + @Override + public void setData(int x, int y, int z, T data) { + if (data == null) { + return; + } + mutations.add(new DataMutation(x, y, z, data)); + if (data instanceof PlatformBlockState state) { + bufferedBlocks.put(new PositionKey(x, y, z), state); + blockWrites++; + } + } + + @Override + public @Nullable T getData(int x, int y, int z, Class type) { + for (int i = mutations.size() - 1; i >= 0; i--) { + BufferedMutation mutation = mutations.get(i); + if (mutation.x() != x || mutation.y() != y || mutation.z() != z) { + continue; + } + Object value = mutation.value(); + if (type.isInstance(value)) { + return type.cast(value); + } + } + return delegate.getData(x, y, z, type); + } + + @Override + public Engine getEngine() { + return engine; + } + + private boolean isWithinBounds(int x, int y, int z) { + if (engine == null || y < 0 || y >= worldHeight) { + return false; + } + int surfaceY = getSurfaceHeight(x, z); + if (y > maxBuriedY(worldHeight, surfaceY, minDepthBelowSurface)) { + return false; + } + return y < getCaveCeiling(x, z); + } + + private int getSurfaceHeight(int x, int z) { + long key = Cache.key(x, z); + int cached = surfaceHeights.get(key); + if (cached != CACHE_MISS) { + return cached; + } + int surfaceY = engine.getHeight(x, z, true); + surfaceHeights.put(key, surfaceY); + return surfaceY; + } + + private int findCaveCeiling(int x, int z) { + if (worldHeight <= 0 || anchorY < 0 || anchorY >= worldHeight) { + return 0; + } + if (!delegate.isCarved(x, anchorY, z)) { + return Math.min(worldHeight, anchorY + 1); + } + + int scanLimit = Math.min(worldHeight - 1, Math.max(anchorY, getSurfaceHeight(x, z))); + for (int y = anchorY + 1; y <= scanLimit; y++) { + if (!delegate.isCarved(x, y, z)) { + return y; + } + } + return Math.min(worldHeight, scanLimit + 1); + } + + enum CommitResult { + COMMITTED, + EMPTY, + REJECTED_BOUNDS + } + + private interface BufferedMutation { + int x(); + + int y(); + + int z(); + + Object value(); + + void apply(IObjectPlacer placer); + } + + private record BlockMutation(int x, int y, int z, PlatformBlockState value) implements BufferedMutation { + @Override + public void apply(IObjectPlacer placer) { + placer.set(x, y, z, value); + } + } + + private record TileMutation(int x, int y, int z, TileData value) implements BufferedMutation { + @Override + public void apply(IObjectPlacer placer) { + placer.setTile(x, y, z, value); + } + } + + private record DataMutation(int x, int y, int z, Object value) implements BufferedMutation { + @Override + public void apply(IObjectPlacer placer) { + placer.setData(x, y, z, value); + } + } + + private record PositionKey(int x, int y, int z) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingIslandSampleResolver.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingIslandSampleResolver.java new file mode 100644 index 000000000..db4cbdbc6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingIslandSampleResolver.java @@ -0,0 +1,74 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.data.cache.Cache; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.modifier.IrisFloatingChildBiomeModifier; +import art.arcane.iris.engine.object.FloatingIslandBoundarySampler; +import art.arcane.iris.engine.object.FloatingIslandSample; +import art.arcane.iris.engine.object.IrisBiome; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import org.jetbrains.annotations.Nullable; + +final class FloatingIslandSampleResolver implements IslandObjectPlacer.SampleProvider { + private final IrisData data; + private final Engine engine; + private final int chunkHeight; + private final long baseSeed; + private final FloatingIslandBoundarySampler boundarySampler; + private final Long2ObjectOpenHashMap sampleCache; + private final LongOpenHashSet resolvedSamples; + + FloatingIslandSampleResolver(EngineMantle engineMantle, FloatingIslandBoundarySampler boundarySampler) { + this.data = engineMantle.getData(); + this.engine = engineMantle.getEngine(); + this.chunkHeight = engine.getHeight(); + this.baseSeed = engine.getSeedManager().getTerrain() ^ IrisFloatingChildBiomeModifier.FLOATING_BASE_SEED_SALT; + this.boundarySampler = boundarySampler; + this.sampleCache = new Long2ObjectOpenHashMap<>(); + this.resolvedSamples = new LongOpenHashSet(); + } + + @Override + public @Nullable FloatingIslandSample sample(int x, int z) { + long key = Cache.key(x, z); + if (resolvedSamples.contains(key)) { + return sampleCache.get(key); + } + IrisBiome parent = parent(x, z); + if (parent == null || parent.getFloatingChildBiomes() == null || parent.getFloatingChildBiomes().isEmpty()) { + resolvedSamples.add(key); + return null; + } + FloatingIslandSample sample = FloatingIslandSample.sample(parent, x, z, chunkHeight, baseSeed, data, engine, boundarySampler); + resolvedSamples.add(key); + if (sample != null) { + sampleCache.put(key, sample); + } + return sample; + } + + @Nullable IrisBiome parent(int x, int z) { + return boundarySampler.parent(x, z); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingObjectPlacementTransaction.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingObjectPlacementTransaction.java new file mode 100644 index 000000000..41b5e172b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/FloatingObjectPlacementTransaction.java @@ -0,0 +1,206 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IObjectPlacer; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.volmlib.util.collection.KList; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +final class FloatingObjectPlacementTransaction implements IObjectPlacer { + private final IslandObjectPlacer delegate; + private final KList mutations; + private final Map bufferedBlocks; + private int blockWrites; + + FloatingObjectPlacementTransaction(IslandObjectPlacer delegate) { + this.delegate = delegate; + this.mutations = new KList<>(); + this.bufferedBlocks = new HashMap<>(); + } + + CommitResult commit() { + if (blockWrites == 0) { + discard(); + return CommitResult.EMPTY; + } + for (BufferedMutation mutation : mutations) { + if (!delegate.canWriteObjectBlock(mutation.x(), mutation.y(), mutation.z())) { + discard(); + return CommitResult.REJECTED_SUPPORT; + } + } + for (BufferedMutation mutation : mutations) { + mutation.apply(delegate); + } + discard(); + return CommitResult.COMMITTED; + } + + void discard() { + mutations.clear(); + bufferedBlocks.clear(); + blockWrites = 0; + } + + @Override + public int getHighest(int x, int z, IrisData data) { + return delegate.getHighest(x, z, data); + } + + @Override + public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + return delegate.getHighest(x, z, data, ignoreFluid); + } + + @Override + public void set(int x, int y, int z, PlatformBlockState state) { + if (state == null) { + return; + } + mutations.add(new BlockMutation(x, y, z, state)); + bufferedBlocks.put(new PositionKey(x, y, z), state); + blockWrites++; + } + + @Override + public PlatformBlockState get(int x, int y, int z) { + PlatformBlockState state = bufferedBlocks.get(new PositionKey(x, y, z)); + return state == null ? delegate.get(x, y, z) : state; + } + + @Override + public boolean isPreventingDecay() { + return delegate.isPreventingDecay(); + } + + @Override + public boolean isCarved(int x, int y, int z) { + return delegate.isCarved(x, y, z); + } + + @Override + public boolean isSolid(int x, int y, int z) { + PlatformBlockState state = bufferedBlocks.get(new PositionKey(x, y, z)); + return state == null ? delegate.isSolid(x, y, z) : B.isSolid(state); + } + + @Override + public boolean isUnderwater(int x, int z) { + return delegate.isUnderwater(x, z); + } + + @Override + public int getFluidHeight() { + return delegate.getFluidHeight(); + } + + @Override + public boolean isDebugSmartBore() { + return delegate.isDebugSmartBore(); + } + + @Override + public void setTile(int x, int y, int z, TileData tile) { + if (tile != null) { + mutations.add(new TileMutation(x, y, z, tile)); + } + } + + @Override + public void setData(int x, int y, int z, T data) { + if (data == null) { + return; + } + mutations.add(new DataMutation(x, y, z, data)); + if (data instanceof PlatformBlockState state) { + bufferedBlocks.put(new PositionKey(x, y, z), state); + blockWrites++; + } + } + + @Override + public @Nullable T getData(int x, int y, int z, Class type) { + for (int i = mutations.size() - 1; i >= 0; i--) { + BufferedMutation mutation = mutations.get(i); + if (mutation.x() != x || mutation.y() != y || mutation.z() != z) { + continue; + } + Object value = mutation.value(); + if (type.isInstance(value)) { + return type.cast(value); + } + } + return delegate.getData(x, y, z, type); + } + + @Override + public Engine getEngine() { + return delegate.getEngine(); + } + + enum CommitResult { + COMMITTED, + EMPTY, + REJECTED_SUPPORT + } + + private interface BufferedMutation { + int x(); + + int y(); + + int z(); + + Object value(); + + void apply(IObjectPlacer placer); + } + + private record BlockMutation(int x, int y, int z, PlatformBlockState value) implements BufferedMutation { + @Override + public void apply(IObjectPlacer placer) { + placer.set(x, y, z, value); + } + } + + private record TileMutation(int x, int y, int z, TileData value) implements BufferedMutation { + @Override + public void apply(IObjectPlacer placer) { + placer.setTile(x, y, z, value); + } + } + + private record DataMutation(int x, int y, int z, Object value) implements BufferedMutation { + @Override + public void apply(IObjectPlacer placer) { + placer.setData(x, y, z, value); + } + } + + private record PositionKey(int x, int y, int z) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java index 8e2a63289..20d8c2e18 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java @@ -35,10 +35,14 @@ import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import java.util.ArrayList; import java.util.Arrays; +import java.util.BitSet; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; public class IrisCaveCarver3D { private static final byte LIQUID_AIR = 0; + private static final byte LIQUID_WATER = 1; private static final byte LIQUID_LAVA = 2; private static final byte LIQUID_FORCED_AIR = 3; private static final int ADAPTIVE_MIN_PLANE_COLUMNS = 16; @@ -58,6 +62,7 @@ public class IrisCaveCarver3D { private final ModuleState[] modules; private final double inverseNormalization; private final MatterCavern carveAir; + private final MatterCavern carveWater; private final MatterCavern carveLava; private final MatterCavern carveForcedAir; private final double normalizationFactor; @@ -76,6 +81,7 @@ public class IrisCaveCarver3D { this.data = engine.getData(); this.profile = profile; this.carveAir = new MatterCavern(true, "", LIQUID_AIR); + this.carveWater = new MatterCavern(true, "", LIQUID_WATER); this.carveLava = new MatterCavern(true, "", LIQUID_LAVA); this.carveForcedAir = new MatterCavern(true, "", LIQUID_FORCED_AIR); List moduleStates = new ArrayList<>(); @@ -166,6 +172,25 @@ public class IrisCaveCarver3D { IrisRange worldYRange, int[] precomputedSurfaceHeights, IrisRange overrideVerticalRange + ) { + WaterSupportPlan waterSupportPlan = new WaterSupportPlan(); + int carved = carve(writer, chunkX, chunkZ, columnWeights, minWeight, thresholdPenalty, + worldYRange, precomputedSurfaceHeights, overrideVerticalRange, waterSupportPlan); + waterSupportPlan.resolve(writer.acquireChunk(chunkX, chunkZ)); + return carved; + } + + int carve( + MantleWriter writer, + int chunkX, + int chunkZ, + double[] columnWeights, + double minWeight, + double thresholdPenalty, + IrisRange worldYRange, + int[] precomputedSurfaceHeights, + IrisRange overrideVerticalRange, + WaterSupportPlan waterSupportPlan ) { PrecisionStopwatch applyStopwatch = PrecisionStopwatch.start(); try { @@ -213,6 +238,7 @@ public class IrisCaveCarver3D { int x0 = PowerOfTwoCoordinates.chunkToBlock(chunkX); int z0 = PowerOfTwoCoordinates.chunkToBlock(chunkZ); int[] columnMaxY = scratch.columnMaxY; + int[] waterMaxY = scratch.waterMaxY; int[] surfaceBreakFloorY = scratch.surfaceBreakFloorY; boolean[] surfaceBreakColumn = scratch.surfaceBreakColumn; double[] columnThreshold = scratch.columnThreshold; @@ -240,6 +266,7 @@ public class IrisCaveCarver3D { : clearanceTopY; columnMaxY[index] = columnTopY; + waterMaxY[index] = resolveWaterMaxY(columnSurfaceY); surfaceBreakFloorY[index] = Math.max(minY, columnSurfaceY - surfaceBreakDepth); surfaceBreakColumn[index] = breakColumn; columnThreshold[index] = profile.getDensityThreshold().get(thresholdRng, x, z, data) - profile.getThresholdBias(); @@ -260,12 +287,14 @@ public class IrisCaveCarver3D { adaptiveThresholdMargin, surfaceBreakThresholdBoost, columnMaxY, + waterMaxY, surfaceBreakFloorY, surfaceBreakColumn, columnThreshold, clampedWeights, verticalEdgeFade, matterByY, + profile.isWaterRequiresFloor() ? waterSupportPlan : null, resolvedMinWeight, resolvedThresholdPenalty, 0D, @@ -280,12 +309,14 @@ public class IrisCaveCarver3D { maxY, surfaceBreakThresholdBoost, columnMaxY, + waterMaxY, surfaceBreakFloorY, surfaceBreakColumn, columnThreshold, clampedWeights, verticalEdgeFade, matterByY, + profile.isWaterRequiresFloor() ? waterSupportPlan : null, resolvedMinWeight, resolvedThresholdPenalty, 0D, @@ -303,12 +334,14 @@ public class IrisCaveCarver3D { latticeStep, surfaceBreakThresholdBoost, columnMaxY, + waterMaxY, surfaceBreakFloorY, surfaceBreakColumn, columnThreshold, clampedWeights, verticalEdgeFade, matterByY, + profile.isWaterRequiresFloor() ? waterSupportPlan : null, resolvedMinWeight, resolvedThresholdPenalty, 0D, @@ -324,12 +357,14 @@ public class IrisCaveCarver3D { sampleStep, surfaceBreakThresholdBoost, columnMaxY, + waterMaxY, surfaceBreakFloorY, surfaceBreakColumn, columnThreshold, clampedWeights, verticalEdgeFade, matterByY, + profile.isWaterRequiresFloor() ? waterSupportPlan : null, resolvedMinWeight, resolvedThresholdPenalty, 0D, @@ -352,12 +387,14 @@ public class IrisCaveCarver3D { int maxY, double surfaceBreakThresholdBoost, int[] columnMaxY, + int[] waterMaxY, int[] surfaceBreakFloorY, boolean[] surfaceBreakColumn, double[] columnThreshold, double[] clampedWeights, double[] verticalEdgeFade, MatterCavern[] matterByY, + WaterSupportPlan waterSupportPlan, double minWeight, double thresholdPenalty, double thresholdBoost, @@ -423,7 +460,7 @@ public class IrisCaveCarver3D { classifyDensityPlane(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve); int fadeIndex = y - minY; int localY = y & 15; - MatterCavern matter = matterByY[fadeIndex]; + MatterCavern verticalMatter = matterByY[fadeIndex]; if (skipExistingCarved) { for (int planeIndex = 0; planeIndex < planeCount; planeIndex++) { @@ -438,7 +475,10 @@ public class IrisCaveCarver3D { continue; } - cavernSlice.set(localX, localY, localZ, matter); + double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization; + MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ, + columnIndex, waterMaxY, localThreshold); + writeCavern(cavernSlice, localX, y, localZ, matter, waterSupportPlan); carved++; } continue; @@ -452,7 +492,10 @@ public class IrisCaveCarver3D { int columnIndex = planeColumnIndices[planeIndex]; int localX = PowerOfTwoCoordinates.unpackLocal16X(columnIndex); int localZ = columnIndex & 15; - cavernSlice.set(localX, localY, localZ, matter); + double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization; + MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ, + columnIndex, waterMaxY, localThreshold); + writeCavern(cavernSlice, localX, y, localZ, matter, waterSupportPlan); carved++; } } @@ -471,12 +514,14 @@ public class IrisCaveCarver3D { double adaptiveThresholdMargin, double surfaceBreakThresholdBoost, int[] columnMaxY, + int[] waterMaxY, int[] surfaceBreakFloorY, boolean[] surfaceBreakColumn, double[] columnThreshold, double[] clampedWeights, double[] verticalEdgeFade, MatterCavern[] matterByY, + WaterSupportPlan waterSupportPlan, double minWeight, double thresholdPenalty, double thresholdBoost, @@ -558,7 +603,7 @@ public class IrisCaveCarver3D { ); int fadeIndex = y - minY; int localY = y & 15; - MatterCavern matter = matterByY[fadeIndex]; + MatterCavern verticalMatter = matterByY[fadeIndex]; if (skipExistingCarved) { for (int planeIndex = 0; planeIndex < planeCount; planeIndex++) { @@ -573,7 +618,10 @@ public class IrisCaveCarver3D { continue; } - cavernSlice.set(localX, localY, localZ, matter); + double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization; + MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ, + columnIndex, waterMaxY, localThreshold); + writeCavern(cavernSlice, localX, y, localZ, matter, waterSupportPlan); carved++; } continue; @@ -587,7 +635,10 @@ public class IrisCaveCarver3D { int columnIndex = planeColumnIndices[planeIndex]; int localX = PowerOfTwoCoordinates.unpackLocal16X(columnIndex); int localZ = columnIndex & 15; - cavernSlice.set(localX, localY, localZ, matter); + double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization; + MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ, + columnIndex, waterMaxY, localThreshold); + writeCavern(cavernSlice, localX, y, localZ, matter, waterSupportPlan); carved++; } } @@ -627,12 +678,14 @@ public class IrisCaveCarver3D { int latticeStep, double surfaceBreakThresholdBoost, int[] columnMaxY, + int[] waterMaxY, int[] surfaceBreakFloorY, boolean[] surfaceBreakColumn, double[] columnThreshold, double[] clampedWeights, double[] verticalEdgeFade, MatterCavern[] matterByY, + WaterSupportPlan waterSupportPlan, double minWeight, double thresholdPenalty, double thresholdBoost, @@ -717,7 +770,7 @@ public class IrisCaveCarver3D { double density = sampleDensityOptimized(x, y, z); int stampMaxY = Math.min(maxY, y + 1); for (int yy = y; yy <= stampMaxY; yy++) { - MatterCavern matter = matterByY[yy - minY]; + MatterCavern verticalMatter = matterByY[yy - minY]; MatterSlice cavernSlice = resolveCavernSlice(scratch, chunk, PowerOfTwoCoordinates.floorDivPow2(yy, 4)); int localY = yy & 15; int fadeIndex = yy - minY; @@ -738,15 +791,19 @@ public class IrisCaveCarver3D { int localX = tileLocalX[columnIndex]; int localZ = tileLocalZ[columnIndex]; + int worldX = x0 + localX; + int worldZ = z0 + localZ; + MatterCavern matter = resolveMatter(verticalMatter, worldX, yy, worldZ, + index, waterMaxY, localThreshold); if (skipExistingCarved) { if (cavernSlice.get(localX, localY, localZ) == null) { - cavernSlice.set(localX, localY, localZ, matter); + writeCavern(cavernSlice, localX, yy, localZ, matter, waterSupportPlan); carved++; } continue; } - cavernSlice.set(localX, localY, localZ, matter); + writeCavern(cavernSlice, localX, yy, localZ, matter, waterSupportPlan); carved++; } } @@ -766,12 +823,14 @@ public class IrisCaveCarver3D { int sampleStep, double surfaceBreakThresholdBoost, int[] columnMaxY, + int[] waterMaxY, int[] surfaceBreakFloorY, boolean[] surfaceBreakColumn, double[] columnThreshold, double[] clampedWeights, double[] verticalEdgeFade, MatterCavern[] matterByY, + WaterSupportPlan waterSupportPlan, double minWeight, double thresholdPenalty, double thresholdBoost, @@ -812,18 +871,20 @@ public class IrisCaveCarver3D { int carveMaxY = Math.min(columnTopY, y + sampleStep - 1); for (int yy = y; yy <= carveMaxY; yy++) { - MatterCavern matter = matterByY[yy - minY]; + MatterCavern verticalMatter = matterByY[yy - minY]; + MatterCavern matter = resolveMatter(verticalMatter, x, yy, z, + index, waterMaxY, localThreshold); MatterSlice cavernSlice = resolveCavernSlice(scratch, chunk, PowerOfTwoCoordinates.floorDivPow2(yy, 4)); int localY = yy & 15; if (skipExistingCarved) { if (cavernSlice.get(lx, localY, lz) == null) { - cavernSlice.set(lx, localY, lz, matter); + writeCavern(cavernSlice, lx, yy, lz, matter, waterSupportPlan); carved++; } continue; } - cavernSlice.set(lx, localY, lz, matter); + writeCavern(cavernSlice, lx, yy, lz, matter, waterSupportPlan); carved++; } } @@ -2046,9 +2107,7 @@ public class IrisCaveCarver3D { MatterCavern[] matterByY = scratch.matterByY; boolean allowLava = profile.isAllowLava(); - boolean allowWater = profile.isAllowWater(); int lavaHeight = engine.getDimension().getCaveLavaHeight(); - int fluidHeight = engine.getDimension().getFluidHeight(); for (int y = minY; y <= maxY; y++) { int offset = y - minY; @@ -2056,10 +2115,6 @@ public class IrisCaveCarver3D { matterByY[offset] = carveLava; continue; } - if (allowWater && y <= fluidHeight) { - matterByY[offset] = carveAir; - continue; - } if (!allowLava && y <= lavaHeight) { matterByY[offset] = carveForcedAir; continue; @@ -2071,6 +2126,77 @@ public class IrisCaveCarver3D { return matterByY; } + private int resolveWaterMaxY(int columnSurfaceY) { + if (!profile.isAllowWater()) { + return Integer.MIN_VALUE; + } + + int minDepth = Math.max(0, profile.getWaterMinDepthBelowSurface()); + return Math.min(engine.getDimension().getFluidHeight(), columnSurfaceY - minDepth); + } + + private MatterCavern resolveMatter(MatterCavern verticalMatter, int x, int y, int z, + int columnIndex, int[] waterMaxY, double localThreshold) { + if (verticalMatter != carveLava + && y <= waterMaxY[columnIndex] + && isAquiferCandidate(x, y, z, localThreshold)) { + return carveWater; + } + return verticalMatter; + } + + private boolean isAquiferCandidate(int x, int y, int z, double localThreshold) { + int fluidHeight = engine.getDimension().getFluidHeight(); + double depthFactor = Math.max(0D, Math.min(1.5D, (fluidHeight - y) / 48D)); + double cutoff = 0.35D + (depthFactor * 0.2D); + if (detailDensity.noiseFastSigned3D(x, y * 0.5D, z) <= cutoff) { + return false; + } + return !profile.isWaterRequiresFloor() || hasAquiferCupSupport(x, y, z, localThreshold); + } + + private boolean hasAquiferCupSupport(int x, int y, int z, double threshold) { + int floorY = Math.max(0, y - 1); + int deepFloorY = Math.max(0, y - 2); + int aboveY = Math.min(engine.getHeight() - 1, y + 1); + if (!isDensitySolid(x, floorY, z, threshold)) { + return false; + } + if (!isDensitySolid(x, deepFloorY, z, threshold - 0.05D)) { + return false; + } + + int support = 0; + if (isDensitySolid(x + 1, y, z, threshold)) { + support++; + } + if (isDensitySolid(x - 1, y, z, threshold)) { + support++; + } + if (isDensitySolid(x, y, z + 1, threshold)) { + support++; + } + if (isDensitySolid(x, y, z - 1, threshold)) { + support++; + } + if (isDensitySolid(x, aboveY, z, threshold)) { + support++; + } + return support >= 4; + } + + private boolean isDensitySolid(int x, int y, int z, double threshold) { + return sampleDensityOptimized(x, y, z) > threshold; + } + + private void writeCavern(MatterSlice cavernSlice, int localX, int y, int localZ, + MatterCavern matter, WaterSupportPlan waterSupportPlan) { + cavernSlice.set(localX, y & 15, localZ, matter); + if (waterSupportPlan != null && matter == carveWater) { + waterSupportPlan.add(localX, y, localZ, carveWater, carveAir); + } + } + private void prepareSectionCaches(Scratch scratch, int minY, int maxY) { int minSection = Math.max(0, PowerOfTwoCoordinates.floorDivPow2(minY, 4)); int maxSection = Math.max(minSection, PowerOfTwoCoordinates.floorDivPow2(maxY, 4)); @@ -2139,6 +2265,97 @@ public class IrisCaveCarver3D { return verticalEdgeFade; } + static final class WaterSupportPlan { + private final IdentityHashMap groups = new IdentityHashMap<>(); + + void add(int localX, int y, int localZ, MatterCavern water, MatterCavern air) { + WaterCandidateGroup group = groups.computeIfAbsent(water, key -> new WaterCandidateGroup(water, air)); + int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); + group.positions.set((y << 8) | columnIndex); + } + + void resolve(MantleChunk chunk) { + if (chunk == null) { + groups.clear(); + return; + } + + for (Map.Entry entry : groups.entrySet()) { + WaterCandidateGroup group = entry.getValue(); + for (int position = group.positions.nextSetBit(0); position >= 0; position = group.positions.nextSetBit(position + 1)) { + int y = position >>> 8; + int columnIndex = position & 255; + int localX = PowerOfTwoCoordinates.unpackLocal16X(columnIndex); + int localZ = columnIndex & 15; + MatterCavern current = getCavern(chunk, localX, y, localZ); + if (current != group.water || hasCupSupport(chunk, localX, y, localZ)) { + continue; + } + + Matter section = chunk.get(y >> 4); + MatterSlice cavernSlice = section.getSlice(MatterCavern.class); + cavernSlice.set(localX, y & 15, localZ, group.air); + } + } + groups.clear(); + } + + private static boolean hasCupSupport(MantleChunk chunk, int localX, int y, int localZ) { + if (localX <= 0 || localX >= 15 || localZ <= 0 || localZ >= 15 + || y <= 1 || !isSolid(chunk, localX, y - 1, localZ) + || !isSolid(chunk, localX, y - 2, localZ)) { + return false; + } + + int support = 0; + if (isSolid(chunk, localX + 1, y, localZ)) { + support++; + } + if (isSolid(chunk, localX - 1, y, localZ)) { + support++; + } + if (isSolid(chunk, localX, y, localZ + 1)) { + support++; + } + if (isSolid(chunk, localX, y, localZ - 1)) { + support++; + } + if (isSolid(chunk, localX, y + 1, localZ)) { + support++; + } + return support >= 4; + } + + private static boolean isSolid(MantleChunk chunk, int localX, int y, int localZ) { + if (localX < 0 || localX >= 16 || localZ < 0 || localZ >= 16) { + return false; + } + MatterCavern cavern = getCavern(chunk, localX, y, localZ); + return cavern == null || !cavern.isCavern(); + } + + private static MatterCavern getCavern(MantleChunk chunk, int localX, int y, int localZ) { + Matter section = chunk.get(y >> 4); + if (section == null) { + return null; + } + + MatterSlice cavernSlice = section.getSlice(MatterCavern.class); + return cavernSlice == null ? null : cavernSlice.get(localX, y & 15, localZ); + } + } + + private static final class WaterCandidateGroup { + private final MatterCavern water; + private final MatterCavern air; + private final BitSet positions = new BitSet(); + + private WaterCandidateGroup(MatterCavern water, MatterCavern air) { + this.water = water; + this.air = air; + } + } + private static final class ModuleState { private final CNG density; private final int minY; @@ -2176,6 +2393,7 @@ public class IrisCaveCarver3D { private static final class Scratch { private final int[] columnMaxY = new int[256]; + private final int[] waterMaxY = new int[256]; private final int[] surfaceBreakFloorY = new int[256]; private final boolean[] surfaceBreakColumn = new boolean[256]; private final double[] columnThreshold = new double[256]; diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java index 41ad84b35..cb31734d5 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java @@ -19,6 +19,7 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.IrisStructureLocator; @@ -30,12 +31,14 @@ import art.arcane.iris.engine.mantle.IrisMantleComponent; import art.arcane.iris.engine.mantle.MantleWriter; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.ObjectPlaceMode; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.IrisStructureStiltSettings; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.noise.CNG; @@ -45,8 +48,11 @@ import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.math.RNG; +import it.unimi.dsi.fastutil.longs.Long2IntMap; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import java.util.HashSet; +import java.util.Objects; import java.util.Set; @ComponentFlag(ReservedFlag.JIGSAW) @@ -81,15 +87,15 @@ public class IrisStructureComponent extends IrisMantleComponent { } placements.addAll(getDimension().getStructures()); - for (int placementOrdinal = 0; placementOrdinal < placements.size(); placementOrdinal++) { - placeFromPlacement(writer, placements.get(placementOrdinal), x, z, placementOrdinal); + for (IrisStructurePlacement placement : placements) { + placeFromPlacement(writer, placement, x, z); } } @ChunkCoordinates - private void placeFromPlacement(MantleWriter writer, IrisStructurePlacement placement, int cx, int cz, int placementOrdinal) { + private void placeFromPlacement(MantleWriter writer, IrisStructurePlacement placement, int cx, int cz) { IrisStructureLocator.ResolvedPlacement resolved = IrisStructureLocator.resolvePlacement( - getEngineMantle().getEngine(), placement, cx, cz, placementOrdinal); + getEngineMantle().getEngine(), placement, cx, cz); if (resolved == null) { return; } @@ -102,7 +108,10 @@ public class IrisStructureComponent extends IrisMantleComponent { String key = resolved.structureKey(); IrisStructure structure = resolved.structure(); - KList pieces = resolved.pieces(); + KList pieces = resolvedPiecesOrNull(resolved, cx, cz); + if (pieces == null) { + return; + } RNG rng = resolved.rng(); int baseY = resolved.baseY(); if (trace) { @@ -120,23 +129,108 @@ public class IrisStructureComponent extends IrisMantleComponent { } ObjectPlaceMode mode = structure.getPlaceMode(); + int failedPieces = 0; + Long2IntOpenHashMap foundationColumns = placement.getStilt() == null + ? null : new Long2IntOpenHashMap(); if (placement.isUnderground()) { ObjectPlaceMode undergroundMode = (mode == ObjectPlaceMode.ORGANIC_STILT || mode == ObjectPlaceMode.CEILING_HANG) ? mode : ObjectPlaceMode.STRUCTURE_PIECE; for (PlacedStructurePiece p : pieces) { - placeObject(writer, structure, p, undergroundMode, p.getY(), rng); + if (placeObject(writer, structure, p, undergroundMode, p.getY(), rng, foundationColumns) == -1) { + failedPieces++; + } } } else if (mode == ObjectPlaceMode.STRUCTURE_PIECE || mode == ObjectPlaceMode.FLOATING) { for (PlacedStructurePiece p : pieces) { - placeObject(writer, structure, p, ObjectPlaceMode.STRUCTURE_PIECE, p.getY(), rng); + if (placeObject(writer, structure, p, ObjectPlaceMode.STRUCTURE_PIECE, p.getY(), rng, foundationColumns) == -1) { + failedPieces++; + } } } else if (pieces.size() == 1) { - placeObject(writer, structure, pieces.getFirst(), mode, -1, rng); + if (placeObject(writer, structure, pieces.getFirst(), mode, -1, rng, foundationColumns) == -1) { + failedPieces++; + } } else { for (PlacedStructurePiece p : pieces) { - placeObject(writer, structure, p, ObjectPlaceMode.STRUCTURE_PIECE, p.getY(), rng); + if (placeObject(writer, structure, p, ObjectPlaceMode.STRUCTURE_PIECE, p.getY(), rng, foundationColumns) == -1) { + failedPieces++; + } } } + requireAppliedPieces(resolved, cx, cz, failedPieces); + if (failedPieces == 0 && placement.getStilt() != null) { + placeFoundation(writer, foundationColumns, placement.getStilt(), rng); + } + } + + private void placeFoundation(MantleWriter writer, Long2IntOpenHashMap columns, + IrisStructureStiltSettings settings, RNG rng) { + IrisMaterialPalette palette = Objects.requireNonNull( + settings.getPalette(), "Structure stilt palette must not be null"); + int mantleOffset = getEngineMantle().getEngine().getMinHeight(); + int maxDepth = Math.max(1, settings.getMaxDepth()); + for (Long2IntMap.Entry column : columns.long2IntEntrySet()) { + int worldX = StructureFoundationPlanner.unpackX(column.getLongKey()); + int worldZ = StructureFoundationPlanner.unpackZ(column.getLongKey()); + int foundationY = column.getIntValue(); + PlatformBlockState foundationState = writer.getDataIfPresent( + worldX, foundationY, worldZ, PlatformBlockState.class); + if (foundationState == null || !foundationState.isSolid()) { + continue; + } + int terrainHeight = getEngineMantle().trueHeight(worldX, worldZ); + int groundY = StructureFoundationPlanner.findGroundY( + foundationY, maxDepth, 0, + y -> StructureFoundationPlanner.isGroundSolid( + writer.getDataIfPresent(worldX, y, worldZ, PlatformBlockState.class), + writer.getDataIfPresent(worldX, y, worldZ, MatterCavern.class) != null, + y, terrainHeight)); + if (groundY == StructureFoundationPlanner.NO_GROUND) { + continue; + } + StructureFoundationPlanner.fillSupportColumn(foundationY, groundY, y -> { + writeFoundationSupport( + writer, palette, rng, getData(), worldX, y, worldZ, mantleOffset); + }); + } + } + + static void writeFoundationSupport(MantleWriter writer, IrisMaterialPalette palette, RNG rng, + IrisData data, int worldX, int mantleY, int worldZ, + int mantleOffset) { + int worldY = mantleY + mantleOffset; + PlatformBlockState support = palette.get(rng, worldX, worldY, worldZ, data); + if (support == null) { + throw new IllegalStateException("Structure stilt palette resolved no block at " + + worldX + "," + worldY + "," + worldZ); + } + writer.clearData(worldX, mantleY, worldZ, MatterCavern.class); + writer.set(worldX, mantleY, worldZ, support); + } + + static KList resolvedPiecesOrNull( + IrisStructureLocator.ResolvedPlacement resolved, + int chunkX, + int chunkZ + ) { + if (resolved == null) { + return null; + } + KList pieces = resolved.pieces(); + if (!IrisStructureLocator.requirePlacementOutput( + resolved.placement(), resolved.structureKey(), chunkX, chunkZ, + pieces != null && !pieces.isEmpty(), "placement application received no assembled pieces")) { + return null; + } + return pieces; + } + + static void requireAppliedPieces(IrisStructureLocator.ResolvedPlacement resolved, + int chunkX, int chunkZ, int failedPieces) { + IrisStructureLocator.requirePlacementOutput( + resolved.placement(), resolved.structureKey(), chunkX, chunkZ, failedPieces == 0, + "object placement rejected " + failedPieces + " of " + resolved.pieces().size() + + " assembled piece(s)"); } private void boreStructure(MantleWriter writer, KList pieces, int padding) { @@ -377,7 +471,8 @@ public class IrisStructureComponent extends IrisMantleComponent { return new int[]{minX, minY, minZ, maxX, maxY, maxZ}; } - private void placeObject(MantleWriter writer, IrisStructure structure, PlacedStructurePiece p, ObjectPlaceMode mode, int y, RNG rng) { + private int placeObject(MantleWriter writer, IrisStructure structure, PlacedStructurePiece p, + ObjectPlaceMode mode, int y, RNG rng, Long2IntOpenHashMap foundationColumns) { IrisObject object = p.getObject(); String objectKey = object.getLoadKey(); IrisObjectPlacement config = structure.createLootPlacement(objectKey); @@ -391,7 +486,9 @@ public class IrisStructureComponent extends IrisMantleComponent { } int placeY = (y == -1) ? -1 : y - getEngineMantle().getEngine().getMinHeight(); String marker = structurePlacementMarker(structure, p, objectKey); - object.place(p.getX(), placeY, p.getZ(), writer, config, rng, (position, state) -> { + return object.place(p.getX(), placeY, p.getZ(), writer, config, rng, (position, state) -> { + StructureFoundationPlanner.recordBaseCell( + foundationColumns, position.getX(), position.getY(), position.getZ(), state); if (marker != null && shouldWriteStructureMarker(state)) { writer.setData(position.getX(), position.getY(), position.getZ(), marker); } @@ -443,7 +540,7 @@ public class IrisStructureComponent extends IrisMantleComponent { int carvePadding = placement.isOverbore() ? Math.max(0, placement.getOverboreRadius()) : placement.isBore() ? Math.max(0, placement.getBorePadding()) : 0; for (String key : placement.getStructures()) { - IrisStructure structure = art.arcane.iris.core.loader.IrisData.loadAnyStructure(key, getData()); + IrisStructure structure = getData().load(IrisStructure.class, key, false); if (structure != null) { max = Math.max(max, Math.max(1, structure.getMaxSizeChunks()) * 16 + carvePadding); } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java index 6d25bb80c..c6fd88aa4 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IslandObjectPlacer.java @@ -23,156 +23,54 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.mantle.MantleWriter; import art.arcane.iris.engine.object.FloatingIslandSample; import art.arcane.iris.engine.object.IObjectPlacer; +import art.arcane.iris.engine.object.IrisFloatingChildBiomes; import art.arcane.iris.engine.object.TileData; import art.arcane.iris.spi.PlatformBlockState; import org.jetbrains.annotations.Nullable; -public class IslandObjectPlacer implements IObjectPlacer { +public final class IslandObjectPlacer implements IObjectPlacer { private static final int OVERHANG_RADIUS = 2; - - public enum AnchorFace { TOP, BOTTOM } + private static final int OVERHANG_HEIGHT_TOLERANCE = 4; private final MantleWriter wrapped; - private final FloatingIslandSample[] samples; - private final boolean[] overhangAllowed; - private final int minX; - private final int minZ; - private final int chunkMaxIslandTopY; - private final int chunkMinIslandBottomY; + private final SampleProvider samples; + private final IrisFloatingChildBiomes entry; private final int anchorY; private final AnchorFace face; - public IslandObjectPlacer(MantleWriter wrapped, FloatingIslandSample[] samples, int minX, int minZ, int anchorTopY) { - this(wrapped, samples, minX, minZ, anchorTopY, AnchorFace.TOP); - } - - public IslandObjectPlacer(MantleWriter wrapped, FloatingIslandSample[] samples, int minX, int minZ, int anchorY, AnchorFace face) { + private IslandObjectPlacer(MantleWriter wrapped, AnchorSettings settings) { this.wrapped = wrapped; - this.samples = samples; - this.minX = minX; - this.minZ = minZ; - this.anchorY = anchorY; - this.face = face; - int maxTopY = -1; - int minBottomY = Integer.MAX_VALUE; - for (FloatingIslandSample s : samples) { - if (s != null) { - int ty = s.topY(); - if (ty > maxTopY) { - maxTopY = ty; - } - int by = s.bottomY(); - if (by >= 0 && by < minBottomY) { - minBottomY = by; - } - } - } - this.chunkMaxIslandTopY = maxTopY; - this.chunkMinIslandBottomY = (minBottomY == Integer.MAX_VALUE) ? -1 : minBottomY; - this.overhangAllowed = buildOverhangMask(samples); + this.samples = settings.samples(); + this.entry = settings.entry(); + this.anchorY = settings.anchorY(); + this.face = settings.face(); } - private static boolean[] buildOverhangMask(FloatingIslandSample[] samples) { - boolean[] mask = new boolean[256]; - for (int zf = 0; zf < 16; zf++) { - for (int xf = 0; xf < 16; xf++) { - int idx = (zf << 4) | xf; - if (samples[idx] != null) { - mask[idx] = true; - continue; - } - boolean touchedEdge = false; - boolean found = false; - for (int dz = -OVERHANG_RADIUS; dz <= OVERHANG_RADIUS && !found; dz++) { - int nzf = zf + dz; - for (int dx = -OVERHANG_RADIUS; dx <= OVERHANG_RADIUS; dx++) { - int nxf = xf + dx; - if (nxf < 0 || nxf >= 16 || nzf < 0 || nzf >= 16) { - touchedEdge = true; - continue; - } - if (samples[(nzf << 4) | nxf] != null) { - found = true; - break; - } - } - } - mask[idx] = found || touchedEdge; - } - } - return mask; + public static IslandObjectPlacer top(MantleWriter wrapped, SampleProvider samples, + IrisFloatingChildBiomes entry, int anchorY) { + return new IslandObjectPlacer(wrapped, new AnchorSettings(samples, entry, anchorY, AnchorFace.TOP)); } - private boolean shouldSkipAirColumn(int x, int y, int z) { - int xf = x - minX; - int zf = z - minZ; - if (xf >= 0 && xf < 16 && zf >= 0 && zf < 16) { - int idx = (zf << 4) | xf; - if (samples[idx] != null) { - if (face == AnchorFace.TOP) { - return false; - } - if (y >= anchorY) { - return true; - } - return false; - } - if (face == AnchorFace.TOP) { - if (y <= anchorY) { - return true; - } - if (!overhangAllowed[idx]) { - return true; - } - } else { - if (y >= anchorY) { - return true; - } - if (!overhangAllowed[idx]) { - return true; - } - } - return false; - } - if (face == AnchorFace.TOP) { - if (y <= anchorY) { - return true; - } - } else { - if (y >= anchorY) { - return true; - } - } - return true; + public static IslandObjectPlacer bottom(MantleWriter wrapped, SampleProvider samples, + IrisFloatingChildBiomes entry, int anchorY) { + return new IslandObjectPlacer(wrapped, new AnchorSettings(samples, entry, anchorY, AnchorFace.BOTTOM)); } public boolean canWriteObjectBlock(int x, int y, int z) { return !shouldSkipAirColumn(x, y, z); } - private @Nullable FloatingIslandSample sampleAt(int x, int z) { - int xf = x - minX; - int zf = z - minZ; - if (xf < 0 || xf >= 16 || zf < 0 || zf >= 16) { - return null; - } - return samples[(zf << 4) | xf]; - } - @Override public int getHighest(int x, int z, IrisData data) { - FloatingIslandSample s = sampleAt(x, z); + FloatingIslandSample sample = samples.sample(x, z); if (face == AnchorFace.TOP) { - if (s != null) { - return s.topY(); - } - return chunkMaxIslandTopY; + return sample == null ? anchorY : sample.topY(); } - if (s != null) { - int by = s.bottomY(); - return (by >= 0) ? by : chunkMinIslandBottomY; + if (sample == null) { + return anchorY; } - return chunkMinIslandBottomY; + int bottomY = sample.bottomY(); + return bottomY < 0 ? anchorY : bottomY; } @Override @@ -187,11 +85,11 @@ public class IslandObjectPlacer implements IObjectPlacer { @Override public boolean isSolid(int x, int y, int z) { - FloatingIslandSample s = sampleAt(x, z); - if (s != null) { - int idx = y - s.islandBaseY; - if (idx >= 0 && idx < s.solidMask.length) { - return s.solidMask[idx]; + FloatingIslandSample sample = samples.sample(x, z); + if (sample != null) { + int index = y - sample.islandBaseY; + if (index >= 0 && index < sample.solidMask.length) { + return sample.solidMask[index]; } return false; } @@ -204,11 +102,10 @@ public class IslandObjectPlacer implements IObjectPlacer { } @Override - public void set(int x, int y, int z, PlatformBlockState d) { - if (shouldSkipAirColumn(x, y, z)) { - return; + public void set(int x, int y, int z, PlatformBlockState state) { + if (!shouldSkipAirColumn(x, y, z)) { + wrapped.set(x, y, z, state); } - wrapped.set(x, y, z, d); } @Override @@ -232,28 +129,87 @@ public class IslandObjectPlacer implements IObjectPlacer { } @Override - public void setTile(int xx, int yy, int zz, TileData tile) { - if (shouldSkipAirColumn(xx, yy, zz)) { - return; + public void setTile(int x, int y, int z, TileData tile) { + if (!shouldSkipAirColumn(x, y, z)) { + wrapped.setTile(x, y, z, tile); } - wrapped.setTile(xx, yy, zz, tile); } @Override - public void setData(int xx, int yy, int zz, T data) { - if (shouldSkipAirColumn(xx, yy, zz)) { - return; + public void setData(int x, int y, int z, T data) { + if (!shouldSkipAirColumn(x, y, z)) { + wrapped.setData(x, y, z, data); } - wrapped.setData(xx, yy, zz, data); } @Override - public @Nullable T getData(int xx, int yy, int zz, Class t) { - return wrapped.getData(xx, yy, zz, t); + public @Nullable T getData(int x, int y, int z, Class type) { + return wrapped.getData(x, y, z, type); } @Override public Engine getEngine() { - return wrapped.getEngine(); + return wrapped == null ? null : wrapped.getEngine(); + } + + static boolean matchesAnchor(FloatingIslandSample sample, IrisFloatingChildBiomes entry, AnchorFace face) { + if (sample == null) { + return false; + } + return face == AnchorFace.TOP ? sample.entry == entry : sample.bottomEntry() == entry; + } + + private boolean shouldSkipAirColumn(int x, int y, int z) { + Engine engine = getEngine(); + if (engine != null && (y < 0 || y >= engine.getHeight())) { + return true; + } + + FloatingIslandSample sample = samples.sample(x, z); + if (matchesAnchor(sample, entry, face) && isNearAnchorHeight(sample)) { + return face == AnchorFace.BOTTOM && y >= anchorY; + } + if (face == AnchorFace.TOP && y <= anchorY) { + return true; + } + if (face == AnchorFace.BOTTOM && y >= anchorY) { + return true; + } + return !hasNearbySupport(x, z); + } + + private boolean hasNearbySupport(int x, int z) { + for (int dz = -OVERHANG_RADIUS; dz <= OVERHANG_RADIUS; dz++) { + for (int dx = -OVERHANG_RADIUS; dx <= OVERHANG_RADIUS; dx++) { + FloatingIslandSample sample = samples.sample(x + dx, z + dz); + if (matchesAnchor(sample, entry, face) && isNearAnchorHeight(sample)) { + return true; + } + } + } + return false; + } + + private boolean isNearAnchorHeight(FloatingIslandSample sample) { + int faceY = face == AnchorFace.TOP ? sample.topY() : sample.bottomY(); + return faceY >= 0 && Math.abs(faceY - anchorY) <= OVERHANG_HEIGHT_TOLERANCE; + } + + public enum AnchorFace { + TOP, + BOTTOM + } + + @FunctionalInterface + public interface SampleProvider { + @Nullable FloatingIslandSample sample(int x, int z); + } + + private record AnchorSettings( + SampleProvider samples, + IrisFloatingChildBiomes entry, + int anchorY, + AnchorFace face + ) { } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java index 80d0019d5..6c3f459dc 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java @@ -90,23 +90,29 @@ public class MantleCarvingComponent extends IrisMantleComponent { PrecisionStopwatch resolveStopwatch = PrecisionStopwatch.start(); List weightedProfiles = resolveWeightedProfiles(x, z, complex, resolverState); getEngineMantle().getEngine().getMetrics().getCarveResolve().put(resolveStopwatch.getMilliseconds()); + IrisCaveCarver3D.WaterSupportPlan waterSupportPlan = new IrisCaveCarver3D.WaterSupportPlan(); for (WeightedProfile weightedProfile : weightedProfiles) { - carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights); + carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights, waterSupportPlan); } UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext(); if (upperCtx != null && getDimension().isUpperDimensionCarving()) { - carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, chunkSurfaceHeights); + carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, chunkSurfaceHeights, waterSupportPlan); } + waterSupportPlan.resolve(writer.acquireChunk(x, z)); } @ChunkCoordinates - private void carveProfile(WeightedProfile weightedProfile, MantleWriter writer, int cx, int cz, int[] chunkSurfaceHeights) { + private void carveProfile(WeightedProfile weightedProfile, MantleWriter writer, int cx, int cz, + int[] chunkSurfaceHeights, IrisCaveCarver3D.WaterSupportPlan waterSupportPlan) { IrisCaveCarver3D carver = getCarver(weightedProfile.profile); - carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY, weightedProfile.worldYRange, chunkSurfaceHeights); + carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY, + weightedProfile.worldYRange, chunkSurfaceHeights, null, waterSupportPlan); } - private void carveUpperTerrain(UpperDimensionContext upperCtx, List normalProfiles, MantleWriter writer, int cx, int cz, int[] lowerSurfaceHeights) { + private void carveUpperTerrain(UpperDimensionContext upperCtx, List normalProfiles, + MantleWriter writer, int cx, int cz, int[] lowerSurfaceHeights, + IrisCaveCarver3D.WaterSupportPlan waterSupportPlan) { int chunkHeight = getEngineMantle().getEngine().getHeight(); int worldMinHeight = getEngineMantle().getEngine().getWorld().minHeight(); int gap = getDimension().getUpperDimensionGap(); @@ -154,7 +160,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { } IrisCaveCarver3D carver = getCarver(weightedProfile.profile); carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY, - constrainedRange, ceilingSurfaceHeights, fullVerticalRange); + constrainedRange, ceilingSurfaceHeights, fullVerticalRange, waterSupportPlan); } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java index 355f23096..e966ce265 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java @@ -18,8 +18,6 @@ package art.arcane.iris.engine.mantle.components; - -import art.arcane.iris.util.common.data.B; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.data.cache.Cache; @@ -27,7 +25,6 @@ import art.arcane.iris.engine.mantle.ComponentFlag; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.mantle.IrisMantleComponent; import art.arcane.iris.engine.mantle.MantleWriter; -import art.arcane.iris.engine.modifier.IrisFloatingChildBiomeModifier; import art.arcane.iris.engine.object.FloatingIslandSample; import art.arcane.iris.engine.object.FloatingObjectFootprint; import art.arcane.iris.engine.object.IObjectPlacer; @@ -40,16 +37,17 @@ import art.arcane.iris.engine.object.IrisObjectTranslate; import art.arcane.iris.engine.object.IrisProceduralBlocks; import art.arcane.iris.engine.object.ObjectPlaceMode; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; import art.arcane.iris.util.common.math.IrisBlockVector; import art.arcane.iris.util.project.context.ChunkContext; -import art.arcane.iris.util.project.context.ChunkedDataCache; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.spi.PlatformBlockState; import java.io.File; +import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Map; @@ -57,7 +55,6 @@ import java.util.Set; @ComponentFlag(ReservedFlag.FLOATING_OBJECT) public class MantleFloatingObjectComponent extends IrisMantleComponent { - private static final int MIN_FOOTPRINT_CELLS_CHECKED = 3; private static final int INVERTED_PICK_ATTEMPTS = 8; private static final IrisObjectRotation ROTATION_NONE = IrisObjectRotation.of(0, 0, 0); @@ -69,12 +66,10 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { IrisComplex complex = context.getComplex(); IrisData data = getData(); - int chunkHeight = getEngineMantle().getEngine().getHeight(); int minX = x << 4; int minZ = z << 4; - long baseSeed = getEngineMantle().getEngine().getSeedManager().getTerrain() ^ IrisFloatingChildBiomeModifier.FLOATING_BASE_SEED_SALT; RNG chunkRng = new RNG(Cache.key(x, z) + seed() + 0x0FA710BEL); - ChunkedDataCache biomeCache = context.getBiome(); + FloatingIslandSampleResolver sampleResolver = new FloatingIslandSampleResolver(getEngineMantle(), context.getFloatingIslandBoundarySampler()); FloatingIslandSample.clearChunkMemo(); @@ -83,11 +78,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { for (int zf = 0; zf < 16; zf++) { int wx = minX + xf; int wz = minZ + zf; - IrisBiome parent = biomeCache.get(xf, zf); - if (parent == null || parent.getFloatingChildBiomes() == null || parent.getFloatingChildBiomes().isEmpty()) { - continue; - } - FloatingIslandSample sample = FloatingIslandSample.sampleMemoized(parent, wx, wz, chunkHeight, baseSeed, data, getEngineMantle().getEngine()); + FloatingIslandSample sample = sampleResolver.sample(wx, wz); if (sample != null) { samples[(zf << 4) | xf] = sample; } @@ -115,13 +106,14 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { continue; } - IrisBiome parent = biomeCache.get(columns.get(0) & 15, columns.get(0) >> 4); + int firstKey = columns.get(0); + IrisBiome parent = sampleResolver.parent(minX + (firstKey & 15), minZ + (firstKey >> 4)); IrisBiome target = entry.getRealBiome(parent, data); KList floating = entry.getFloatingObjects(); if (floating != null && !floating.isEmpty()) { for (IrisObjectPlacement placement : floating) { - tryPlaceFloatingChunk(writer, complex, chunkRng, data, placement, samples, columns, minX, minZ, entry); + tryPlaceFloatingChunk(writer, complex, chunkRng, data, placement, columns, minX, minZ, entry); } } @@ -131,15 +123,15 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { boolean hasExtras = extras != null && !extras.isEmpty(); KList interior = null; if (hasSurface || hasExtras) { - interior = interiorColumns(samples, columns); + interior = interiorColumns(sampleResolver, columns, minX, minZ, entry, IslandObjectPlacer.AnchorFace.TOP); if (hasSurface) { for (IrisObjectPlacement placement : surface) { - tryPlaceAnchoredChunk(writer, complex, chunkRng, data, placement, samples, columns, interior, minX, minZ, entry); + tryPlaceAnchoredChunk(writer, complex, chunkRng, data, placement, samples, sampleResolver, columns, interior, minX, minZ, entry); } } if (hasExtras) { for (IrisObjectPlacement placement : extras) { - tryPlaceAnchoredChunk(writer, complex, chunkRng, data, placement, samples, columns, interior, minX, minZ, entry); + tryPlaceAnchoredChunk(writer, complex, chunkRng, data, placement, samples, sampleResolver, columns, interior, minX, minZ, entry); } } } @@ -152,20 +144,21 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { continue; } - IrisBiome parent = biomeCache.get(columns.get(0) & 15, columns.get(0) >> 4); + int firstKey = columns.get(0); + IrisBiome parent = sampleResolver.parent(minX + (firstKey & 15), minZ + (firstKey >> 4)); IrisBiome target = entry.getRealBiome(parent, data); KList bottom = target != null ? entry.resolveBottomObjects(target) : null; if (bottom != null && !bottom.isEmpty()) { - KList interior = interiorColumns(samples, columns); + KList interior = interiorColumns(sampleResolver, columns, minX, minZ, entry, IslandObjectPlacer.AnchorFace.BOTTOM); for (IrisObjectPlacement placement : bottom) { - tryPlaceInvertedChunk(writer, complex, chunkRng, data, placement, samples, columns, interior, minX, minZ, entry); + tryPlaceInvertedChunk(writer, complex, chunkRng, data, placement, samples, sampleResolver, columns, interior, minX, minZ, entry); } } } } @ChunkCoordinates - private void tryPlaceFloatingChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, FloatingIslandSample[] samples, KList columns, int minX, int minZ, IrisFloatingChildBiomes entry) { + private void tryPlaceFloatingChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, KList columns, int minX, int minZ, IrisFloatingChildBiomes entry) { if (placement == null || columns == null || columns.isEmpty()) { return; } @@ -191,7 +184,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } final IrisObject obj = obj0; - int key = columns.get(rng.i(0, columns.size() - 1)); + int key = columns.get(rng.i(columns.size())); int xx = minX + (key & 15); int zz = minZ + (key >> 4); IrisObjectPlacement floatingPlacement = placement.toPlacement(obj.getLoadKey()); @@ -211,7 +204,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } @ChunkCoordinates - private void tryPlaceAnchoredChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, FloatingIslandSample[] samples, KList columns, KList interior, int minX, int minZ, IrisFloatingChildBiomes entry) { + private void tryPlaceAnchoredChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, FloatingIslandSample[] samples, IslandObjectPlacer.SampleProvider sampleProvider, KList columns, KList interior, int minX, int minZ, IrisFloatingChildBiomes entry) { if (placement == null || columns.isEmpty()) { return; } @@ -243,7 +236,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { KList pool = interior.isEmpty() ? columns : interior; - int pickedKey = pool.get(rng.i(0, pool.size() - 1)); + int pickedKey = pool.get(rng.i(pool.size())); int pickedXf = pickedKey & 15; int pickedZf = pickedKey >> 4; FloatingIslandSample pickedSample = samples[(pickedZf << 4) | pickedXf]; @@ -252,8 +245,10 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } int pickTopY = pickedSample.topY(); - if (!isFootprintFlat(fp, pickedXf, pickedZf, pickTopY, samples, 2)) { - if (!isFootprintFlat(fp, pickedXf, pickedZf, pickTopY, samples, 4)) { + int pickedX = minX + pickedXf; + int pickedZ = minZ + pickedZf; + if (!isFootprintFlat(fp, pickedX, pickedZ, pickTopY, sampleProvider, entry, 2)) { + if (!isFootprintFlat(fp, pickedX, pickedZ, pickTopY, sampleProvider, entry, 4)) { continue; } } @@ -270,26 +265,31 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { int yv = pickTopY + 1 - fp.getLowestSolidKeyY(); - IslandObjectPlacer islandPlacer = new IslandObjectPlacer(writer, samples, minX, minZ, pickTopY); + IslandObjectPlacer islandPlacer = IslandObjectPlacer.top(writer, sampleProvider, entry, pickTopY); + FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(islandPlacer); int id = rng.i(0, Integer.MAX_VALUE); try { - obj.place(wx, yv, wz, islandPlacer, anchored, rng, (b, bd) -> { + int resultY = obj.place(wx, yv, wz, transaction, anchored, rng, (b, bd) -> { String marker = placementMarker(obj, id); - if (marker != null - && islandPlacer.canWriteObjectBlock(b.getX(), b.getY(), b.getZ()) - && shouldWritePlacementMarker(islandPlacer, bd, b.getX(), b.getY(), b.getZ())) { - writer.setData(b.getX(), b.getY(), b.getZ(), marker); + if (marker != null && shouldWritePlacementMarker(transaction, bd, b.getX(), b.getY(), b.getZ())) { + transaction.setData(b.getX(), b.getY(), b.getZ(), marker); } }, null, data); + if (resultY < 0) { + transaction.discard(); + } else { + transaction.commit(); + } } catch (Throwable e) { + transaction.discard(); IrisLogging.reportError(e); } } } @ChunkCoordinates - private void tryPlaceInvertedChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, FloatingIslandSample[] samples, KList columns, KList interior, int minX, int minZ, IrisFloatingChildBiomes entry) { + private void tryPlaceInvertedChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, FloatingIslandSample[] samples, IslandObjectPlacer.SampleProvider sampleProvider, KList columns, KList interior, int minX, int minZ, IrisFloatingChildBiomes entry) { if (placement == null || columns.isEmpty()) { return; } @@ -318,7 +318,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { final IrisObject obj = obj0; FloatingObjectFootprint fp = FloatingObjectFootprint.compute(obj); - int invertedYRotation = rng.i(0, 3) * 90; + int invertedYRotation = rng.i(4) * 90; IrisObjectRotation invertedRotation = IrisObjectRotation.xFlip180WithY(invertedYRotation); KList pool = interior.isEmpty() ? columns : interior; @@ -328,7 +328,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { int pickBottomY = -1; boolean foundBottomAnchor = false; for (int attempt = 0; attempt < INVERTED_PICK_ATTEMPTS; attempt++) { - int pickedKey = pool.get(rng.i(0, pool.size() - 1)); + int pickedKey = pool.get(rng.i(pool.size())); int candidateXf = pickedKey & 15; int candidateZf = pickedKey >> 4; FloatingIslandSample candidateSample = samples[(candidateZf << 4) | candidateXf]; @@ -339,8 +339,10 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { if (candidateBottomY < 0) { continue; } - if (!isFootprintFlatBottom(fp, invertedRotation, candidateXf, candidateZf, candidateBottomY, samples, 2) - && !isFootprintFlatBottom(fp, invertedRotation, candidateXf, candidateZf, candidateBottomY, samples, 4)) { + int candidateX = minX + candidateXf; + int candidateZ = minZ + candidateZf; + if (!isFootprintFlatBottom(fp, invertedRotation, candidateX, candidateZ, candidateBottomY, sampleProvider, entry, 2) + && !isFootprintFlatBottom(fp, invertedRotation, candidateX, candidateZ, candidateBottomY, sampleProvider, entry, 4)) { continue; } pickedXf = candidateXf; @@ -365,54 +367,55 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { int yv = invertedBaseY(pickBottomY, fp, invertedRotation); - IslandObjectPlacer islandPlacer = new IslandObjectPlacer(writer, samples, minX, minZ, pickBottomY, IslandObjectPlacer.AnchorFace.BOTTOM); + IslandObjectPlacer islandPlacer = IslandObjectPlacer.bottom(writer, sampleProvider, entry, pickBottomY); + FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(islandPlacer); int id = rng.i(0, Integer.MAX_VALUE); try { - obj.place(wx, yv, wz, islandPlacer, inverted, rng, (b, bd) -> { + int resultY = obj.place(wx, yv, wz, transaction, inverted, rng, (b, bd) -> { String marker = placementMarker(obj, id); - if (marker != null - && islandPlacer.canWriteObjectBlock(b.getX(), b.getY(), b.getZ()) - && shouldWritePlacementMarker(islandPlacer, bd, b.getX(), b.getY(), b.getZ())) { - writer.setData(b.getX(), b.getY(), b.getZ(), marker); + if (marker != null && shouldWritePlacementMarker(transaction, bd, b.getX(), b.getY(), b.getZ())) { + transaction.setData(b.getX(), b.getY(), b.getZ(), marker); } }, null, data); + if (resultY < 0) { + transaction.discard(); + } else { + transaction.commit(); + } } catch (Throwable e) { + transaction.discard(); IrisLogging.reportError(e); } } } - private static boolean isFootprintFlatBottom(FloatingObjectFootprint fp, IrisObjectRotation rotation, int pickedXf, int pickedZf, int pickBottomY, FloatingIslandSample[] samples, int tolerance) { + static boolean isFootprintFlatBottom(FloatingObjectFootprint fp, IrisObjectRotation rotation, + int pickedX, int pickedZ, int pickBottomY, + IslandObjectPlacer.SampleProvider samples, + IrisFloatingChildBiomes entry, int tolerance) { IrisBlockVector anchor = invertedFootprintAnchor(fp, rotation); - int checked = 0; - boolean touchedChunkEdge = false; long[] cells = fp.footprintXZ(); + if (cells.length == 0) { + return false; + } for (int i = 0, n = cells.length; i < n; i++) { long encoded = cells[i]; int kx = (int) (encoded >> 32); int kz = (int) (encoded & 0xFFFFFFFFL); IrisBlockVector cell = rotation.rotate(new IrisBlockVector(kx, 0, kz), 0, 0, 0); - int colXf = pickedXf + cell.getBlockX() - anchor.getBlockX(); - int colZf = pickedZf + cell.getBlockZ() - anchor.getBlockZ(); - if (colXf < 0 || colXf >= 16 || colZf < 0 || colZf >= 16) { - touchedChunkEdge = true; - continue; - } - FloatingIslandSample s = samples[(colZf << 4) | colXf]; - if (s == null) { + int columnX = pickedX + cell.getBlockX() - anchor.getBlockX(); + int columnZ = pickedZ + cell.getBlockZ() - anchor.getBlockZ(); + FloatingIslandSample sample = samples.sample(columnX, columnZ); + if (!IslandObjectPlacer.matchesAnchor(sample, entry, IslandObjectPlacer.AnchorFace.BOTTOM)) { return false; } - int by = s.bottomY(); - if (by < 0 || Math.abs(by - pickBottomY) > tolerance) { + int bottomY = sample.bottomY(); + if (bottomY < 0 || Math.abs(bottomY - pickBottomY) > tolerance) { return false; } - checked++; } - if (checked >= MIN_FOOTPRINT_CELLS_CHECKED) { - return true; - } - return touchedChunkEdge; + return true; } static int invertedBaseX(int minX, int pickedXf, FloatingObjectFootprint fp) { @@ -458,52 +461,48 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { return state.isCustom() || placesBlock; } - private static boolean isFootprintFlat(FloatingObjectFootprint fp, int pickedXf, int pickedZf, int pickTopY, FloatingIslandSample[] samples, int tolerance) { + static boolean isFootprintFlat(FloatingObjectFootprint fp, int pickedX, int pickedZ, int pickTopY, + IslandObjectPlacer.SampleProvider samples, + IrisFloatingChildBiomes entry, int tolerance) { int tallestKx = fp.getTallestKx(); int tallestKz = fp.getTallestKz(); - int checked = 0; - boolean touchedChunkEdge = false; long[] cells = fp.footprintXZ(); + if (cells.length == 0) { + return false; + } for (int i = 0, n = cells.length; i < n; i++) { long encoded = cells[i]; int kx = (int) (encoded >> 32); int kz = (int) (encoded & 0xFFFFFFFFL); - int colXf = pickedXf + (kx - tallestKx); - int colZf = pickedZf + (kz - tallestKz); - if (colXf < 0 || colXf >= 16 || colZf < 0 || colZf >= 16) { - touchedChunkEdge = true; - continue; - } - FloatingIslandSample s = samples[(colZf << 4) | colXf]; - if (s == null || Math.abs(s.topY() - pickTopY) > tolerance) { + int columnX = pickedX + (kx - tallestKx); + int columnZ = pickedZ + (kz - tallestKz); + FloatingIslandSample sample = samples.sample(columnX, columnZ); + if (!IslandObjectPlacer.matchesAnchor(sample, entry, IslandObjectPlacer.AnchorFace.TOP) + || Math.abs(sample.topY() - pickTopY) > tolerance) { return false; } - checked++; } - if (checked >= MIN_FOOTPRINT_CELLS_CHECKED) { - return true; - } - return touchedChunkEdge; + return true; } - private static KList interiorColumns(FloatingIslandSample[] samples, KList columns) { + private static KList interiorColumns(IslandObjectPlacer.SampleProvider samples, + KList columns, int minX, int minZ, + IrisFloatingChildBiomes entry, + IslandObjectPlacer.AnchorFace face) { KList interior = new KList<>(); for (int key : columns) { - int xf = key & 15; - int zf = key >> 4; - if (xf <= 0 || xf >= 15 || zf <= 0 || zf >= 15) { + int x = minX + (key & 15); + int z = minZ + (key >> 4); + if (!IslandObjectPlacer.matchesAnchor(samples.sample(x + 1, z), entry, face)) { continue; } - if (samples[(zf << 4) | (xf + 1)] == null) { + if (!IslandObjectPlacer.matchesAnchor(samples.sample(x - 1, z), entry, face)) { continue; } - if (samples[(zf << 4) | (xf - 1)] == null) { + if (!IslandObjectPlacer.matchesAnchor(samples.sample(x, z + 1), entry, face)) { continue; } - if (samples[((zf + 1) << 4) | xf] == null) { - continue; - } - if (samples[((zf - 1) << 4) | xf] == null) { + if (!IslandObjectPlacer.matchesAnchor(samples.sample(x, z - 1), entry, face)) { continue; } interior.add(key); @@ -537,8 +536,9 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { @Override protected int computeRadius() { - int maxObjectExtent = 0; - Set objectKeys = new HashSet<>(); + int maxObjectExtent = 16; + Map sizeCache = new HashMap<>(); + Set warnedLargeObjects = new HashSet<>(); try { IrisData data = getData(); for (IrisBiome biome : getDimension().getReachableBiomes(this::getData)) { @@ -547,48 +547,59 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { continue; } for (IrisFloatingChildBiomes entry : entries) { - collectPlacementKeys(entry.getFloatingObjects(), objectKeys); - collectPlacementKeys(entry.getExtraObjects(), objectKeys); - collectPlacementKeys(entry.getTopObjectOverrides(), objectKeys); - collectPlacementKeys(entry.getBottomObjectOverrides(), objectKeys); + maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.getFloatingObjects(), data, sizeCache, warnedLargeObjects)); + maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.getExtraObjects(), data, sizeCache, warnedLargeObjects)); + maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.getTopObjectOverrides(), data, sizeCache, warnedLargeObjects)); + maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.getBottomObjectOverrides(), data, sizeCache, warnedLargeObjects)); try { IrisBiome target = entry.getRealBiome(biome, data); if (target != null) { - collectPlacementKeys(entry.resolveTopObjects(target), objectKeys); - collectPlacementKeys(entry.resolveBottomObjects(target), objectKeys); + maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.resolveTopObjects(target), data, sizeCache, warnedLargeObjects)); + maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.resolveBottomObjects(target), data, sizeCache, warnedLargeObjects)); } - } catch (Throwable ignored) { + } catch (Throwable e) { + IrisLogging.reportError(e); } } } - for (String key : objectKeys) { - try { - File f = data.getObjectLoader().findFile(key); - if (f == null) { - continue; - } - IrisBlockVector sz = IrisObject.sampleSize(f); - int extent = Math.max(sz.getBlockX(), sz.getBlockZ()); - if (extent > maxObjectExtent) { - maxObjectExtent = extent; - } - } catch (Throwable ignored) { - } - } - } catch (Throwable ignored) { + } catch (Throwable e) { + IrisLogging.reportError(e); } - return Math.max(16, maxObjectExtent); + return maxObjectExtent; } - private static void collectPlacementKeys(KList placements, Set out) { + private int computePlacementRadius(KList placements, IrisData data, + Map sizeCache, + Set warnedLargeObjects) { + int radius = 0; if (placements == null) { - return; + return radius; } - for (IrisObjectPlacement p : placements) { - if (p == null || p.getPlace() == null) { + for (IrisObjectPlacement placement : placements) { + if (placement == null || placement.getPlace() == null) { continue; } - out.addAll(p.getPlace()); + for (String objectKey : placement.getPlace()) { + try { + IrisBlockVector size = sizeCache.get(objectKey); + if (size == null) { + File file = data.getObjectLoader().findFile(objectKey); + if (file == null) { + continue; + } + size = IrisObject.sampleSize(file); + sizeCache.put(objectKey, size); + } + int reach = MantleObjectComponent.calculatePlacementReach(size, placement); + if (reach > 128 && warnedLargeObjects.add(objectKey)) { + IrisLogging.warn("Floating object " + objectKey + " has a large placement reach (" + reach + " blocks) and may increase memory usage!"); + } + radius = Math.max(radius, reach); + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } } + return radius; } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFluidBodyComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFluidBodyComponent.java deleted file mode 100644 index 88ba199ee..000000000 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFluidBodyComponent.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2022 Arcane Arts (Volmit Software) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package art.arcane.iris.engine.mantle.components; - -import art.arcane.iris.engine.IrisComplex; -import art.arcane.iris.engine.data.cache.Cache; -import art.arcane.iris.engine.mantle.ComponentFlag; -import art.arcane.iris.engine.mantle.EngineMantle; -import art.arcane.iris.engine.mantle.IrisMantleComponent; -import art.arcane.iris.engine.mantle.MantleWriter; -import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.object.IrisFluidBodies; -import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.util.project.context.ChunkContext; -import art.arcane.volmlib.util.documentation.ChunkCoordinates; -import art.arcane.volmlib.util.mantle.flag.ReservedFlag; -import art.arcane.volmlib.util.math.RNG; - -@ComponentFlag(ReservedFlag.FLUID_BODIES) -public class MantleFluidBodyComponent extends IrisMantleComponent { - public MantleFluidBodyComponent(EngineMantle engineMantle) { - super(engineMantle, ReservedFlag.FLUID_BODIES, 0); - } - - @Override - public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { - IrisComplex complex = context.getComplex(); - RNG rng = new RNG(Cache.key(x, z) + seed() + 405666); - int xxx = 8 + (x << 4); - int zzz = 8 + (z << 4); - IrisRegion region = complex.getRegionStream().get(xxx, zzz); - IrisBiome biome = complex.getTrueBiomeStream().get(xxx, zzz); - generate(writer, rng, x, z, region, biome); - } - - @ChunkCoordinates - private void generate(MantleWriter writer, RNG rng, int cx, int cz, IrisRegion region, IrisBiome biome) { - generate(getDimension().getFluidBodies(), writer, new RNG((rng.nextLong() * cx) + 490495 + cz), cx, cz); - generate(biome.getFluidBodies(), writer, new RNG((rng.nextLong() * cx) + 490495 + cz), cx, cz); - generate(region.getFluidBodies(), writer, new RNG((rng.nextLong() * cx) + 490495 + cz), cx, cz); - } - - @ChunkCoordinates - private void generate(IrisFluidBodies bodies, MantleWriter writer, RNG rng, int cx, int cz) { - bodies.generate(writer, rng, getEngineMantle().getEngine(), cx << 4, -1, cz << 4); - } - - protected int computeRadius() { - int max = 0; - - max = Math.max(max, getDimension().getFluidBodies().getMaxRange(getData())); - - for (IrisRegion i : getDimension().getAllRegions(this::getData)) { - max = Math.max(max, i.getFluidBodies().getMaxRange(getData())); - } - - for (IrisBiome i : getDimension().getReachableBiomes(this::getData)) { - max = Math.max(max, i.getFluidBodies().getMaxRange(getData())); - } - - return max; - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index dddf546c5..5e4382c44 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -41,7 +41,7 @@ import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisObjectRotation; -import art.arcane.iris.engine.object.IrisObjectScale; +import art.arcane.iris.engine.object.IrisObjectTranslate; import art.arcane.iris.engine.object.IrisObjectVacuum; import art.arcane.iris.engine.object.IrisProceduralObjects; import art.arcane.iris.engine.object.IrisProceduralPlacement; @@ -55,7 +55,6 @@ import art.arcane.volmlib.util.collection.KSet; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.volmlib.util.documentation.BlockCoordinates; import art.arcane.volmlib.util.documentation.ChunkCoordinates; -import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.MatterStructurePOI; @@ -70,7 +69,6 @@ import java.io.IOException; import java.util.Locale; import java.util.Map; import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -413,15 +411,26 @@ public class MantleObjectComponent extends IrisMantleComponent { @ChunkCoordinates private void placeProceduralObjects(MantleWriter writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region) { - placeProceduralFrom(writer, rng, x, z, surfaceBiome.getProceduralObjects(), surfaceBiome.getName()); - placeProceduralFrom(writer, rng, x, z, region.getProceduralObjects(), region.getName()); + IrisCaveProfile surfaceCaveProfile = resolveCaveProfile(surfaceBiome.getCaveProfile(), region.getCaveProfile()); + IrisCaveProfile regionCaveProfile = resolveCaveProfile(region.getCaveProfile(), caveBiome == null ? null : caveBiome.getCaveProfile()); + placeProceduralFrom(writer, rng, x, z, surfaceBiome.getProceduralObjects(), surfaceBiome.getName(), surfaceCaveProfile); + placeProceduralFrom(writer, rng, x, z, region.getProceduralObjects(), region.getName(), regionCaveProfile); if (caveBiome != null && caveBiome != surfaceBiome) { - placeProceduralFrom(writer, rng, x, z, caveBiome.getProceduralObjects(), caveBiome.getName()); + IrisCaveProfile caveProfile = resolveCaveProfile(caveBiome.getCaveProfile(), region.getCaveProfile()); + placeProceduralFrom(writer, rng, x, z, caveBiome.getProceduralObjects(), caveBiome.getName(), caveProfile); } } @ChunkCoordinates - private void placeProceduralFrom(MantleWriter writer, RNG rng, int x, int z, IrisProceduralObjects proceduralObjects, String scope) { + private void placeProceduralFrom( + MantleWriter writer, + RNG rng, + int x, + int z, + IrisProceduralObjects proceduralObjects, + String scope, + IrisCaveProfile caveProfile + ) { if (proceduralObjects == null || proceduralObjects.isEmpty()) { return; } @@ -444,13 +453,15 @@ public class MantleObjectComponent extends IrisMantleComponent { IrisObjectPlacement placement = p.asPlacement(); boolean carving = placement.getCarvingSupport() == CarvingMode.CARVING_ONLY; - if (carving && placement.getMode() == ObjectPlaceMode.CENTER_HEIGHT) { - placement.setMode(ObjectPlaceMode.FAST_MIN_HEIGHT); - } - IObjectPlacer placer = p.isPlausible() ? new DecayControlPlacer(writer) : writer; - if (golden) { - placer = new GoldenDebugPlacer(placer, scope + "/" + p.getName()); + IrisCaveAnchorMode anchorMode = resolveAnchorMode(placement, caveProfile); + if (placement.getMode() == ObjectPlaceMode.CEILING_HANG) { + anchorMode = IrisCaveAnchorMode.CEILING; } + int anchorScanStep = resolveAnchorScanStep(caveProfile); + int minDepthBelowSurface = resolveObjectMinDepthBelowSurface(caveProfile); + int anchorSearchAttempts = resolveAnchorSearchAttempts(caveProfile); + IObjectPlacer basePlacer = p.isPlausible() ? new DecayControlPlacer(writer) : writer; + IObjectPlacer placer = golden ? new GoldenDebugPlacer(basePlacer, scope + "/" + p.getName()) : basePlacer; int density = Math.max(1, p.getDensity()); for (int i = 0; i < density; i++) { IrisObject variant = p.getVariantObject(getData(), rng); @@ -463,8 +474,31 @@ public class MantleObjectComponent extends IrisMantleComponent { } continue; } - int xx = rng.i(blockX, blockX + 15); - int zz = rng.i(blockZ, blockZ + 15); + CavePlacementAnchor caveAnchor = null; + if (carving) { + caveAnchor = findCavePlacementAnchor( + writer, + rng, + blockX, + blockZ, + anchorMode, + anchorScanStep, + minDepthBelowSurface, + anchorSearchAttempts, + null, + caveAnchorCache + ); + } + if (carving && caveAnchor == null) { + if (golden) { + IrisLogging.info("Goldendebug procedural cave anchor rejected: chunk=" + x + "," + z + + " placement=" + p.getName() + + " minDepthBelowSurface=" + minDepthBelowSurface); + } + continue; + } + int xx = caveAnchor == null ? rng.i(blockX, blockX + 16) : caveAnchor.x(); + int zz = caveAnchor == null ? rng.i(blockZ, blockZ + 16) : caveAnchor.z(); int id = rng.i(0, Integer.MAX_VALUE); if (golden) { KList pool = p.getVariantObjects(getData()); @@ -480,8 +514,9 @@ public class MantleObjectComponent extends IrisMantleComponent { } try { int placeResult = -1; + CaveObjectPlacementTransaction.CommitResult commitResult = CaveObjectPlacementTransaction.CommitResult.EMPTY; if (carving) { - int caveFloorY = findNearestCaveFloor(writer, xx, zz, caveAnchorCache); + int caveFloorY = caveAnchor.y(); if (golden) { IrisLogging.info("Goldendebug procedural caveFloor: chunk=" + x + "," + z + " placement=" + p.getName() @@ -489,19 +524,26 @@ public class MantleObjectComponent extends IrisMantleComponent { + " zz=" + zz + " caveFloorY=" + caveFloorY); } - if (caveFloorY > 0) { - placeResult = variant.place(xx, caveFloorY, zz, placer, placement, rng, (b, data) -> { - String marker = placementMarker(variant, id, "procedural"); - if (marker != null) { - writer.setData(b.getX(), b.getY(), b.getZ(), marker); - } - }, null, getData()); - } + IrisObjectPlacement effectivePlacement = resolveCavePlacement(placement, variant, caveProfile); + ContainedPlacementResult contained = placeContainedCaveObject( + placer, + variant, + xx, + caveFloorY, + zz, + effectivePlacement, + minDepthBelowSurface, + id, + "procedural", + rng + ); + placeResult = contained.resultY(); + commitResult = contained.commitResult(); } else { placeResult = variant.place(xx, -1, zz, placer, placement, rng, (b, data) -> { String marker = placementMarker(variant, id, "procedural"); if (marker != null) { - writer.setData(b.getX(), b.getY(), b.getZ(), marker); + placer.setData(b.getX(), b.getY(), b.getZ(), marker); } }, null, getData()); } @@ -511,7 +553,8 @@ public class MantleObjectComponent extends IrisMantleComponent { + " variant=" + variant.getLoadKey() + " xx=" + xx + " zz=" + zz - + " resultY=" + placeResult); + + " resultY=" + placeResult + + " commit=" + commitResult); } } catch (Throwable e) { IrisLogging.reportError(e); @@ -522,6 +565,72 @@ public class MantleObjectComponent extends IrisMantleComponent { } } + private CavePlacementAnchor findCavePlacementAnchor( + MantleWriter writer, + RNG rng, + int minX, + int minZ, + IrisCaveAnchorMode anchorMode, + int anchorScanStep, + int minDepthBelowSurface, + int searchAttempts, + String expectedCaveBiomeKey, + CaveAnchorCache anchorCache + ) { + for (int search = 0; search < searchAttempts; search++) { + int candidateX = rng.i(minX, minX + 16); + int candidateZ = rng.i(minZ, minZ + 16); + int candidateY = findCaveAnchorY( + writer, + rng, + candidateX, + candidateZ, + anchorMode, + anchorScanStep, + minDepthBelowSurface, + anchorCache + ); + if (candidateY < 0 || caveAnchorBiomeConflicts(candidateX, candidateY, candidateZ, expectedCaveBiomeKey)) { + continue; + } + return new CavePlacementAnchor(candidateX, candidateY, candidateZ); + } + return null; + } + + private ContainedPlacementResult placeContainedCaveObject( + IObjectPlacer placer, + IrisObject object, + int x, + int anchorY, + int z, + IrisObjectPlacement placement, + int minDepthBelowSurface, + int id, + String markerContext, + RNG rng + ) { + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(placer, anchorY, minDepthBelowSurface); + int placeY = anchorY; + if (placement.getMode() == ObjectPlaceMode.CEILING_HANG) { + placeY = Math.max(1, transaction.getCaveCeiling(x, z) - 1 - Math.floorDiv(object.getH(), 2)); + } + String marker = placementMarker(object, id, markerContext); + int result = object.place(x, placeY, z, transaction, placement, rng, (block, data) -> { + if (marker != null) { + transaction.setData(block.getX(), block.getY(), block.getZ(), marker); + } + if (placement.isDolphinTarget() && placement.isUnderwater() && B.isStorageChest(data)) { + transaction.setData(block.getX(), block.getY(), block.getZ(), MatterStructurePOI.BURIED_TREASURE); + } + }, null, getData()); + if (result < 0) { + transaction.discard(); + return new ContainedPlacementResult(result, CaveObjectPlacementTransaction.CommitResult.EMPTY); + } + return new ContainedPlacementResult(result, transaction.commit()); + } + @BlockCoordinates private ObjectPlacementResult placeObject( MantleWriter writer, @@ -557,8 +666,8 @@ public class MantleObjectComponent extends IrisMantleComponent { } continue; } - int xx = rng.i(x, x + 15); - int zz = rng.i(z, z + 15); + int xx = rng.i(x, x + 16); + int zz = rng.i(z, z + 16); IrisObjectPlacement effectivePlacement = resolveEffectivePlacement(objectPlacement, v); int id = rng.i(0, Integer.MAX_VALUE); IObjectPlacer placePlacer = golden ? new GoldenDebugPlacer(writer, scope + "/" + v.getLoadKey()) : writer; @@ -705,28 +814,20 @@ public class MantleObjectComponent extends IrisMantleComponent { continue; } - int x = 0; - int z = 0; - int y = -1; - for (int search = 0; search < anchorSearchAttempts; search++) { - int candidateX = rng.i(minX, minX + 15); - int candidateZ = rng.i(minZ, minZ + 15); - int candidateY = findCaveAnchorY(writer, rng, candidateX, candidateZ, anchorMode, anchorScanStep, objectMinDepthBelowSurface, anchorCache); - if (candidateY < 0) { - continue; - } + CavePlacementAnchor anchor = findCavePlacementAnchor( + writer, + rng, + minX, + minZ, + anchorMode, + anchorScanStep, + objectMinDepthBelowSurface, + anchorSearchAttempts, + expectedCaveBiomeKey, + anchorCache + ); - if (caveAnchorBiomeConflicts(candidateX, candidateY, candidateZ, expectedCaveBiomeKey)) { - continue; - } - - x = candidateX; - z = candidateZ; - y = candidateY; - break; - } - - if (y < 0) { + if (anchor == null) { rejected++; logCaveReject( scope, @@ -747,44 +848,43 @@ public class MantleObjectComponent extends IrisMantleComponent { continue; } + int x = anchor.x(); + int y = anchor.y(); + int z = anchor.z(); + int id = rng.i(0, Integer.MAX_VALUE); - IrisObjectPlacement resolvedPlacement = resolveEffectivePlacement(objectPlacement, object); - if (resolvedPlacement.getMode() == ObjectPlaceMode.CENTER_HEIGHT && caveProfile != null) { - ObjectPlaceMode profileMode = caveProfile.getDefaultObjectPlaceMode(); - if (profileMode != null) { - resolvedPlacement = resolvedPlacement.toPlacement(object.getLoadKey()); - resolvedPlacement.setMode(profileMode); - } - } - IrisObjectPlacement effectivePlacement = resolvedPlacement; - AtomicBoolean wrotePlacementData = new AtomicBoolean(false); + IrisObjectPlacement effectivePlacement = resolveCavePlacement(objectPlacement, object, caveProfile); try { - int caveCeiling = findCaveCeiling(writer, x, y, z); - IObjectPlacer clampedPlacer = new CeilingClampedPlacer(writer, caveCeiling); - int placeY = y; - if (effectivePlacement.getMode() == ObjectPlaceMode.CEILING_HANG) { - placeY = Math.max(1, caveCeiling - 1 - Math.floorDiv(object.getH(), 2)); - } - int result = object.place(x, placeY, z, clampedPlacer, effectivePlacement, rng, (b, data) -> { - wrotePlacementData.set(true); - String marker = placementMarker(object, id, "cave"); - if (marker != null) { - writer.setData(b.getX(), b.getY(), b.getZ(), marker); - } - if (effectivePlacement.isDolphinTarget() && effectivePlacement.isUnderwater() && B.isStorageChest(data)) { - writer.setData(b.getX(), b.getY(), b.getZ(), MatterStructurePOI.BURIED_TREASURE); - } - }, null, getData()); - - boolean wroteBlocks = wrotePlacementData.get(); + ContainedPlacementResult contained = placeContainedCaveObject( + writer, + object, + x, + y, + z, + effectivePlacement, + objectMinDepthBelowSurface, + id, + "cave", + rng + ); + int result = contained.resultY(); + boolean wroteBlocks = contained.commitResult() == CaveObjectPlacementTransaction.CommitResult.COMMITTED; if (wroteBlocks) { placed++; - } else if (result < 0) { + } else { rejected++; + String rejectReason; + if (result < 0) { + rejectReason = "PLACE_NEGATIVE"; + } else if (contained.commitResult() == CaveObjectPlacementTransaction.CommitResult.REJECTED_BOUNDS) { + rejectReason = "CONTAINMENT"; + } else { + rejectReason = "NO_WRITES"; + } logCaveReject( scope, - "PLACE_NEGATIVE", + rejectReason, metricChunkX, metricChunkZ, objectPlacement, @@ -923,8 +1023,8 @@ public class MantleObjectComponent extends IrisMantleComponent { continue; } - int xx = rng.i(minX, minX + 15); - int zz = rng.i(minZ, minZ + 15); + int xx = rng.i(minX, minX + 16); + int zz = rng.i(minZ, minZ + 16); int columnLowerSurfaceY = getEngineMantle().getEngine().getHeight(xx, zz, true); int rawUpperSurface = upperCtx.getUpperSurfaceY(xx, zz); int upperSurfaceY = Math.max(rawUpperSurface, columnLowerSurfaceY + upperGap); @@ -1069,24 +1169,33 @@ public class MantleObjectComponent extends IrisMantleComponent { return effectivePlacement; } - private int findNearestCaveFloor(MantleWriter writer, int x, int z, CaveAnchorCache anchorCache) { - KList anchors = anchorCache.get(writer, IrisCaveAnchorMode.FLOOR, 1, 0, x, z); - if (anchors.isEmpty()) { - return -1; + private IrisObjectPlacement resolveCavePlacement(IrisObjectPlacement objectPlacement, IrisObject object, IrisCaveProfile caveProfile) { + IrisObjectPlacement resolvedPlacement = resolveEffectivePlacement(objectPlacement, object); + if (resolvedPlacement.getMode() != ObjectPlaceMode.CENTER_HEIGHT || caveProfile == null) { + return resolvedPlacement; } - return anchors.get(anchors.size() - 1); + + ObjectPlaceMode profileMode = caveProfile.getDefaultObjectPlaceMode(); + if (profileMode == null) { + return resolvedPlacement; + } + + String loadKey = object.getLoadKey(); + if (loadKey == null || loadKey.isBlank()) { + resolvedPlacement = resolvedPlacement.toPlacement(); + } else { + resolvedPlacement = resolvedPlacement.toPlacement(loadKey); + } + resolvedPlacement.setMode(profileMode); + return resolvedPlacement; } - private int findCaveCeiling(MantleWriter writer, int x, int anchorY, int z) { - Engine engine = getEngineMantle().getEngine(); - int surfaceY = engine.getHeight(x, z); - int maxScan = Math.min(engine.getHeight() - 1, Math.max(0, surfaceY)); - for (int sy = anchorY + 1; sy <= maxScan; sy++) { - if (!writer.isCarved(x, sy, z)) { - return sy; - } + static int caveAnchorScanUpperBound(int worldHeight, int surfaceY, int minDepthBelowSurface) { + int maxAnchorY = CaveObjectPlacementTransaction.maxBuriedY(worldHeight, surfaceY, minDepthBelowSurface); + if (maxAnchorY <= 1) { + return 0; } - return maxScan; + return maxAnchorY + 1; } private static final class GoldenDebugPlacer implements IObjectPlacer { @@ -1177,92 +1286,6 @@ public class MantleObjectComponent extends IrisMantleComponent { } } - private static final class CeilingClampedPlacer implements IObjectPlacer { - private final IObjectPlacer delegate; - private final int maxY; - - private CeilingClampedPlacer(IObjectPlacer delegate, int maxY) { - this.delegate = delegate; - this.maxY = maxY; - } - - @Override - public int getHighest(int x, int z, IrisData data) { - return delegate.getHighest(x, z, data); - } - - @Override - public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { - return delegate.getHighest(x, z, data, ignoreFluid); - } - - @Override - public void set(int x, int y, int z, PlatformBlockState d) { - if (y >= maxY) { - return; - } - delegate.set(x, y, z, d); - } - - @Override - public PlatformBlockState get(int x, int y, int z) { - return delegate.get(x, y, z); - } - - @Override - public boolean isPreventingDecay() { - return delegate.isPreventingDecay(); - } - - @Override - public boolean isCarved(int x, int y, int z) { - return delegate.isCarved(x, y, z); - } - - @Override - public boolean isSolid(int x, int y, int z) { - return delegate.isSolid(x, y, z); - } - - @Override - public boolean isUnderwater(int x, int z) { - return delegate.isUnderwater(x, z); - } - - @Override - public int getFluidHeight() { - return delegate.getFluidHeight(); - } - - @Override - public boolean isDebugSmartBore() { - return delegate.isDebugSmartBore(); - } - - @Override - public void setData(int xx, int yy, int zz, T data) { - delegate.setData(xx, yy, zz, data); - } - - @Override - public T getData(int xx, int yy, int zz, Class t) { - return delegate.getData(xx, yy, zz, t); - } - - @Override - public void setTile(int xx, int yy, int zz, TileData tile) { - if (yy >= maxY) { - return; - } - delegate.setTile(xx, yy, zz, tile); - } - - @Override - public Engine getEngine() { - return delegate.getEngine(); - } - } - private int findCaveAnchorY(MantleWriter writer, RNG rng, int x, int z, IrisCaveAnchorMode anchorMode, int anchorScanStep, int objectMinDepthBelowSurface, CaveAnchorCache anchorCache) { KList anchors = anchorCache.get(writer, anchorMode, anchorScanStep, objectMinDepthBelowSurface, x, z); if (anchors.isEmpty()) { @@ -1273,35 +1296,21 @@ public class MantleObjectComponent extends IrisMantleComponent { return anchors.get(0); } - return anchors.get(rng.i(0, anchors.size() - 1)); + return anchors.get(rng.i(anchors.size())); } private KList scanCaveAnchorColumn(MantleWriter writer, IrisCaveAnchorMode anchorMode, int anchorScanStep, int objectMinDepthBelowSurface, int x, int z, CaveAnchorCache anchorCache) { int height = getEngineMantle().getEngine().getHeight(); int step = Math.max(1, anchorScanStep); int surfaceY = anchorCache.getSurfaceHeight(x, z); - int baseMaxAnchorY = Math.min(height - 1, surfaceY - Math.max(0, objectMinDepthBelowSurface)); - if (baseMaxAnchorY <= 1) { + int maxAnchorExclusive = caveAnchorScanUpperBound(height, surfaceY, objectMinDepthBelowSurface); + if (maxAnchorExclusive == 0) { return new KList<>(); } - int widenedMaxAnchorY = Math.min(height - 1, surfaceY - 3); - widenedMaxAnchorY = Math.min(widenedMaxAnchorY, baseMaxAnchorY + Math.max(0, objectMinDepthBelowSurface) / 2); - int carvedHeight = Math.min(height, Math.max(baseMaxAnchorY, widenedMaxAnchorY) + 4); + int carvedHeight = Math.min(height, maxAnchorExclusive + 3); byte[] carvedColumn = anchorCache.getCarvedColumn(writer, x, z, carvedHeight); - KList anchors = scanCaveAnchorRange(anchorMode, step, carvedHeight, BEDROCK_CLEARANCE, baseMaxAnchorY, carvedColumn); - if (!anchors.isEmpty()) { - return anchors; - } - - if (widenedMaxAnchorY > baseMaxAnchorY) { - anchors = scanCaveAnchorRange(anchorMode, step, carvedHeight, baseMaxAnchorY, widenedMaxAnchorY, carvedColumn); - if (!anchors.isEmpty()) { - return anchors; - } - } - - return anchors; + return scanCaveAnchorRange(anchorMode, step, carvedHeight, BEDROCK_CLEARANCE, maxAnchorExclusive, carvedColumn); } private KList scanCaveAnchorRange(IrisCaveAnchorMode anchorMode, int step, int height, int minAnchorY, int maxAnchorY, byte[] carvedColumn) { @@ -1441,6 +1450,15 @@ public class MantleObjectComponent extends IrisMantleComponent { private record ObjectPlacementResult(int attempts, int placed, int rejected, int nullObjects, int errors) { } + private record CavePlacementAnchor(int x, int y, int z) { + } + + private record ContainedPlacementResult( + int resultY, + CaveObjectPlacementTransaction.CommitResult commitResult + ) { + } + private static final class CaveRejectLogState { private final AtomicLong lastLogMs = new AtomicLong(0L); private final AtomicInteger suppressed = new AtomicInteger(0); @@ -1484,63 +1502,61 @@ public class MantleObjectComponent extends IrisMantleComponent { protected int computeRadius() { IrisDimension dimension = getDimension(); - - AtomicInteger xg = new AtomicInteger(); - AtomicInteger zg = new AtomicInteger(); - - KSet objects = new KSet<>(); - KMap> scalars = new KMap<>(); - KList vacuumPlacements = new KList<>(); + KMap sizeCache = new KMap<>(); + KSet warnedLargeObjects = new KSet<>(); + int radius = 0; for (IrisRegion region : dimension.getAllRegions(this::getData)) { - for (IrisObjectPlacement j : region.getObjects()) { - if (j.getScale().canScaleBeyond()) { - scalars.put(j.getScale(), j.getPlace()); - } else { - objects.addAll(j.getPlace()); - } - if (IrisObjectVacuum.isVacuumMode(j.getMode())) { - vacuumPlacements.add(j); - } + if (region == null) { + continue; } - updateProceduralRadiusBounds(region.getProceduralObjects(), xg, zg); + radius = Math.max(radius, computePlacementRadius(region.getObjects(), sizeCache, warnedLargeObjects)); + radius = Math.max(radius, computeProceduralRadius(region.getProceduralObjects())); } for (IrisBiome biome : dimension.getReachableBiomes(this::getData)) { - updateProceduralRadiusBounds(biome.getProceduralObjects(), xg, zg); - for (IrisObjectPlacement j : biome.getObjects()) { - if (j.getScale().canScaleBeyond()) { - scalars.put(j.getScale(), j.getPlace()); - } else { - objects.addAll(j.getPlace()); - } - if (IrisObjectVacuum.isVacuumMode(j.getMode())) { - vacuumPlacements.add(j); - } + if (biome == null) { + continue; } + radius = Math.max(radius, computePlacementRadius(biome.getObjects(), sizeCache, warnedLargeObjects)); + radius = Math.max(radius, computeProceduralRadius(biome.getProceduralObjects())); } - KMap sizeCache = new KMap<>(); - for (String i : objects) { - updateRadiusBounds(sizeCache, xg, zg, i, 1D); - } - - for (Map.Entry> entry : scalars.entrySet()) { - double ms = entry.getKey().getMaxScale(); - for (String j : entry.getValue()) { - updateRadiusBounds(sizeCache, xg, zg, j, ms); - } - } - - for (IrisObjectPlacement j : vacuumPlacements) { - updateVacuumRadiusBounds(sizeCache, xg, zg, j); - } - - return Math.max(xg.get(), zg.get()); + return radius; } - private void updateProceduralRadiusBounds(IrisProceduralObjects procedural, AtomicInteger xg, AtomicInteger zg) { - if (procedural == null || procedural.isEmpty()) { - return; + private int computePlacementRadius( + KList placements, + KMap sizeCache, + KSet warnedLargeObjects + ) { + int radius = 0; + for (IrisObjectPlacement placement : placements) { + if (placement == null) { + continue; + } + for (String objectKey : placement.getPlace()) { + try { + IrisBlockVector size = loadObjectSize(sizeCache, objectKey); + if (size == null) { + continue; + } + int reach = calculatePlacementReach(size, placement); + if (reach > 128 && warnedLargeObjects.add(objectKey)) { + IrisLogging.warn("Object " + objectKey + " has a large placement reach (" + reach + " blocks) and may increase memory usage!"); + } + radius = Math.max(radius, reach); + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } } + return radius; + } + + private int computeProceduralRadius(IrisProceduralObjects procedural) { + if (procedural == null || procedural.isEmpty()) { + return 0; + } + int radius = 0; for (IrisProceduralPlacement placement : procedural.getAllPlacements()) { if (placement == null) { continue; @@ -1549,71 +1565,65 @@ public class MantleObjectComponent extends IrisMantleComponent { if (variants == null) { continue; } + IrisObjectPlacement objectPlacement = placement.asPlacement(); for (IrisObject variant : variants) { if (variant == null) { continue; } - xg.getAndSet(Math.max(variant.getW(), xg.get())); - zg.getAndSet(Math.max(variant.getD(), zg.get())); + IrisBlockVector size = new IrisBlockVector(variant.getW(), variant.getH(), variant.getD()); + radius = Math.max(radius, calculatePlacementReach(size, objectPlacement)); } } + return radius; } - private void updateRadiusBounds( - KMap sizeCache, - AtomicInteger xg, - AtomicInteger zg, - String objectKey, - double scale - ) { - try { - IrisBlockVector bv = loadObjectSize(sizeCache, objectKey); - if (bv == null) { - throw new RuntimeException(); - } - - if (Math.max(bv.getBlockX(), bv.getBlockZ()) > 128) { - if (scale > 1D) { - IrisLogging.warn("Object " + objectKey + " has a large size (" + bv + ") and may increase memory usage! (Object scaled up to " + Form.pc(scale, 2) + ")"); - } else { - IrisLogging.warn("Object " + objectKey + " has a large size (" + bv + ") and may increase memory usage!"); - } - } - - xg.getAndSet(Math.max((int) Math.ceil(bv.getBlockX() * scale), xg.get())); - zg.getAndSet(Math.max((int) Math.ceil(bv.getBlockZ() * scale), zg.get())); - } catch (Throwable e) { - IrisLogging.reportError(e); + static int calculatePlacementReach(IrisBlockVector size, IrisObjectPlacement placement) { + if (size == null) { + return 0; } - } - - private void updateVacuumRadiusBounds( - KMap sizeCache, - AtomicInteger xg, - AtomicInteger zg, - IrisObjectPlacement placement - ) { - int pad = 2 * IrisObjectVacuum.resolveRadius(placement.getMode(), placement.getVacuumSettings()); - if (pad <= 0) { - return; + if (placement == null) { + return Math.max(Math.abs(size.getBlockX()), Math.abs(size.getBlockZ())); } double scale = placement.getScale() != null ? Math.max(1D, placement.getScale().getMaxScale()) : 1D; - for (String objectKey : placement.getPlace()) { - try { - IrisBlockVector bv = loadObjectSize(sizeCache, objectKey); - if (bv == null) { - continue; - } + int width = scaledDimension(size.getBlockX(), scale); + int height = scaledDimension(size.getBlockY(), scale); + int depth = scaledDimension(size.getBlockZ(), scale); + IrisObjectRotation rotation = placement.getRotation(); + boolean rotateX = rotation != null && rotation.isEnabled() && rotation.getXAxis() != null && rotation.getXAxis().isEnabled(); + boolean rotateZ = rotation != null && rotation.isEnabled() && rotation.getZAxis() != null && rotation.getZAxis().isEnabled(); + int footprint = rotateX || rotateZ ? Math.max(width, Math.max(height, depth)) : Math.max(width, depth); + int translation = calculateTranslationReach(placement.getTranslate(), rotation, rotateX || rotateZ); + int warp = placement.getWarp() != null && !placement.getWarp().isFlat() + ? (int) Math.ceil(Math.abs(placement.getWarp().getMultiplier()) / 2D) + : 0; + int vacuum = IrisObjectVacuum.isVacuumMode(placement.getMode()) + ? 2 * IrisObjectVacuum.resolveRadius(placement.getMode(), placement.getVacuumSettings()) + : 0; + long reach = (long) footprint + translation + warp + vacuum; + return reach > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) reach; + } - int reachX = (int) Math.ceil(Math.abs(bv.getBlockX()) * scale) + pad; - int reachZ = (int) Math.ceil(Math.abs(bv.getBlockZ()) * scale) + pad; - xg.getAndSet(Math.max(reachX, xg.get())); - zg.getAndSet(Math.max(reachZ, zg.get())); - } catch (Throwable e) { - IrisLogging.reportError(e); - } + private static int scaledDimension(int dimension, double scale) { + int absoluteDimension = Math.abs(dimension); + if (scale <= 1D) { + return absoluteDimension; } + return (int) Math.ceil((absoluteDimension * scale) + (scale * 2D)); + } + + private static int calculateTranslationReach(IrisObjectTranslate translate, IrisObjectRotation rotation, boolean rotateVertically) { + if (translate == null || !translate.canTranslate()) { + return 0; + } + double x = translate.getX(); + double y = translate.getY(); + double z = translate.getZ(); + if (rotateVertically) { + return (int) Math.ceil(Math.sqrt((x * x) + (y * y) + (z * z))); + } + boolean rotateY = rotation != null && rotation.isEnabled() && rotation.getYAxis() != null && rotation.getYAxis().isEnabled(); + return rotateY ? (int) Math.ceil(Math.hypot(x, z)) : (int) Math.max(Math.abs(x), Math.abs(z)); } private IrisBlockVector loadObjectSize(KMap sizeCache, String objectKey) { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/StructureFoundationPlanner.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/StructureFoundationPlanner.java new file mode 100644 index 000000000..22af5d9b5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/StructureFoundationPlanner.java @@ -0,0 +1,73 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.spi.PlatformBlockState; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; + +import java.util.function.IntConsumer; +import java.util.function.IntPredicate; + +final class StructureFoundationPlanner { + static final int NO_GROUND = Integer.MIN_VALUE; + + private StructureFoundationPlanner() { + } + + static void recordBaseCell(Long2IntOpenHashMap columns, int x, int y, int z, + PlatformBlockState state) { + if (columns == null || state == null || !state.isOccluding()) { + return; + } + long columnKey = pack(x, z); + if (!columns.containsKey(columnKey) || y < columns.get(columnKey)) { + columns.put(columnKey, y); + } + } + + static int findGroundY(int foundationY, int maxDepth, int minimumY, IntPredicate solidAtY) { + if (foundationY <= minimumY) { + return NO_GROUND; + } + long requestedFloor = (long) foundationY - Math.max(1, maxDepth); + int scanFloor = (int) Math.max(minimumY, requestedFloor); + for (int y = foundationY - 1; y >= scanFloor; y--) { + if (solidAtY.test(y)) { + return y; + } + } + return NO_GROUND; + } + + static boolean isGroundSolid(PlatformBlockState overlay, boolean carved, int mantleY, int terrainHeight) { + if (mantleY <= 0) { + return true; + } + if (overlay != null) { + return overlay.isSolid(); + } + return !carved && mantleY <= terrainHeight; + } + + static int fillSupportColumn(int foundationY, int groundY, IntConsumer supportAtY) { + if (groundY == NO_GROUND || groundY >= foundationY - 1) { + return 0; + } + int written = 0; + for (int y = foundationY - 1; y > groundY; y--) { + supportAtY.accept(y); + written++; + } + return written; + } + + static long pack(int x, int z) { + return ((long) x << 32) | (z & 0xffffffffL); + } + + static int unpackX(long packed) { + return (int) (packed >> 32); + } + + static int unpackZ(long packed) { + return (int) packed; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java index da087f4c9..10ea2fb8b 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java @@ -122,8 +122,9 @@ public class IrisCarveModifier extends EngineAssignedModifier yy) { + PlatformBlockState explicitState = resolveExplicitCarveState(c, context.getFluid().get(rx, rz), LAVA, AIR); + if (explicitCarveIntent) { + output.setRaw(rx, yy, rz, explicitState); + } else if (usesDefaultLava(getEngine().getDimension().getCaveLavaHeight(), yy)) { output.setRaw(rx, yy, rz, LAVA); } else { output.setRaw(rx, yy, rz, AIR); @@ -200,6 +198,32 @@ public class IrisCarveModifier extends EngineAssignedModifier 0 && B.isSolid(output.getRaw(rx, zone.getFloor() - 1, rz))) { - decorant.getSurfaceDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, InferredType.CAVE, zone.getFloor() - 1, zone.airThickness()); - } else if (decorator.getPartOf().equals(IrisDecorationPart.CEILING) && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) { - decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, InferredType.CAVE, zone.getCeiling(), zone.airThickness()); - } + IrisDecorator[] surfaceDecorators = biome.getDecoratorBucket(IrisDecorationPart.NONE); + if (surfaceDecorators.length > 0 && zone.getFloor() > 0 && B.isSolid(output.getRaw(rx, zone.getFloor() - 1, rz))) { + decorant.getSurfaceDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, InferredType.CAVE, zone.getFloor() - 1, zone.airThickness()); + } + + IrisDecorator[] ceilingDecorators = biome.getDecoratorBucket(IrisDecorationPart.CEILING); + if (ceilingDecorators.length > 0 && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) { + decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, InferredType.CAVE, zone.getCeiling(), zone.airThickness()); } } diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java index 4151ed491..a99996431 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java @@ -107,14 +107,13 @@ public class IrisDepositModifier extends EngineAssignedModifier= maxY) continue; @@ -131,10 +130,19 @@ public class IrisDepositModifier extends EngineAssignedModifier height || nx > 15 || nx < 0 || ny > getEngine().getHeight() || ny < 0 || nz < 0 || nz > 15) { + if (nx > 15 || nx < 0 || ny >= getEngine().getHeight() || ny < 0 || nz < 0 || nz > 15) { continue; } - if (!k.isReplaceBedrock() && IrisProceduralBlocks.materialKey(data.get(nx, ny, nz)).equals("minecraft:bedrock")) { + int columnSurfaceLimit = getDepositSurfaceLimit(cx, cz, nx, nz, he, context); + if (ny > columnSurfaceLimit) { + continue; + } + + PlatformBlockState current = data.get(nx, ny, nz); + if (!canReplaceDepositTarget(current)) { + continue; + } + if (!k.isReplaceBedrock() && IrisProceduralBlocks.materialKey(current).equals("minecraft:bedrock")) { continue; } @@ -143,20 +151,40 @@ public class IrisDepositModifier extends EngineAssignedModifier resolver) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandBoundarySampler.java b/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandBoundarySampler.java new file mode 100644 index 000000000..d1d9d01fb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandBoundarySampler.java @@ -0,0 +1,594 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.data.cache.Cache; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.collection.KList; +import org.jetbrains.annotations.Nullable; + +import java.util.IdentityHashMap; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +public final class FloatingIslandBoundarySampler { + static final int EDGE_TAPER_WIDTH = FloatingIslandEdgeProfile.DEFAULT_WIDTH; + static final int EDGE_FADE_RADIUS = EDGE_TAPER_WIDTH + 1; + private static final int CHUNK_SIZE = 16; + private static final int MIN_CORE_DISTANCE = 3; + + private final BiomeSampler source; + private final ConcurrentHashMap parentFields; + private final ConcurrentHashMap footprintFields; + private final ConcurrentHashMap ownershipFields; + + public FloatingIslandBoundarySampler(BiomeSampler source) { + this.source = Objects.requireNonNull(source); + this.parentFields = new ConcurrentHashMap<>(); + this.footprintFields = new ConcurrentHashMap<>(); + this.ownershipFields = new ConcurrentHashMap<>(); + } + + public @Nullable IrisBiome parent(int x, int z) { + return parentField(x, z, EDGE_TAPER_WIDTH).parent(x, z); + } + + public double edgeFade(IrisBiome parent, int x, int z) { + return edgeFade(parent, x, z, EDGE_TAPER_WIDTH); + } + + public double edgeFade(IrisBiome parent, int x, int z, int taperWidth) { + int boundaryDistance = edgeDistance(parent, x, z, taperWidth); + return edgeFadeForDistance(boundaryDistance, taperWidth); + } + + public int edgeDistance(IrisBiome parent, int x, int z, int taperWidth) { + if (parent == null) { + return 0; + } + + return parentField(x, z, taperWidth).edgeDistance(parent, x, z); + } + + public FootprintSample footprint(CNG footprint, int x, int z, double signedCut) { + return footprint(footprint, x, z, signedCut, EDGE_TAPER_WIDTH); + } + + public FootprintSample footprint(CNG footprint, int x, int z, double signedCut, int taperWidth) { + int width = FloatingIslandEdgeProfile.clampWidth(taperWidth); + int chunkX = Math.floorDiv(x, CHUNK_SIZE); + int chunkZ = Math.floorDiv(z, CHUNK_SIZE); + FootprintFieldKey key = new FootprintFieldKey( + footprint, chunkX, chunkZ, Double.doubleToLongBits(signedCut), width); + FootprintField field = footprintFields.computeIfAbsent(key, + ignored -> new FootprintField(footprint, chunkX, chunkZ, signedCut, width)); + return field.sample(x, z); + } + + public OwnershipSample ownership(KList entries, CNG picker, int x, int z) { + return ownership(entries, picker, x, z, EDGE_TAPER_WIDTH); + } + + public OwnershipSample ownership(KList entries, CNG picker, int x, int z, + int taperWidth) { + int width = FloatingIslandEdgeProfile.clampWidth(taperWidth); + int chunkX = Math.floorDiv(x, CHUNK_SIZE); + int chunkZ = Math.floorDiv(z, CHUNK_SIZE); + OwnershipFieldKey key = new OwnershipFieldKey(entries, picker, chunkX, chunkZ, width); + OwnershipField field = ownershipFields.computeIfAbsent(key, + ignored -> new OwnershipField(entries, picker, chunkX, chunkZ, width)); + return field.sample(x, z); + } + + static double edgeFadeForDistance(int boundaryDistance) { + return edgeFadeForDistance(boundaryDistance, EDGE_TAPER_WIDTH); + } + + static double edgeFadeForDistance(int boundaryDistance, int taperWidth) { + int width = FloatingIslandEdgeProfile.clampWidth(taperWidth); + double normalized = Math.max(0.0D, Math.min(1.0D, (boundaryDistance - 1.0D) / width)); + return normalized * normalized * (3.0D - (2.0D * normalized)); + } + + private ParentBoundaryField parentField(int x, int z, int taperWidth) { + int width = FloatingIslandEdgeProfile.clampWidth(taperWidth); + int chunkX = Math.floorDiv(x, CHUNK_SIZE); + int chunkZ = Math.floorDiv(z, CHUNK_SIZE); + ParentFieldKey key = new ParentFieldKey(Cache.key(chunkX, chunkZ), width); + return parentFields.computeIfAbsent(key, + ignored -> new ParentBoundaryField(source, chunkX, chunkZ, width)); + } + + private static boolean sameBiome(@Nullable IrisBiome expected, @Nullable IrisBiome actual) { + if (actual == expected) { + return true; + } + if (expected == null || actual == null || expected.getLoadKey() == null) { + return false; + } + return expected.getLoadKey().equals(actual.getLoadKey()); + } + + public record FootprintSample(double signed, int cardinalSupport, int diagonalSupport, boolean accepted, + int boundaryDistance, double edgeFade) { + } + + public record OwnershipSample(IrisFloatingChildBiomes owner, int boundaryDistance, double edgeFade) { + } + + private record ParentFieldKey(long chunkKey, int taperWidth) { + } + + private static final class FootprintFieldKey { + private final CNG footprint; + private final int chunkX; + private final int chunkZ; + private final long signedCutBits; + private final int taperWidth; + + private FootprintFieldKey(CNG footprint, int chunkX, int chunkZ, long signedCutBits, int taperWidth) { + this.footprint = footprint; + this.chunkX = chunkX; + this.chunkZ = chunkZ; + this.signedCutBits = signedCutBits; + this.taperWidth = taperWidth; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof FootprintFieldKey key)) { + return false; + } + return footprint == key.footprint && chunkX == key.chunkX && chunkZ == key.chunkZ + && signedCutBits == key.signedCutBits && taperWidth == key.taperWidth; + } + + @Override + public int hashCode() { + int result = System.identityHashCode(footprint); + result = (31 * result) + chunkX; + result = (31 * result) + chunkZ; + result = (31 * result) + Long.hashCode(signedCutBits); + return (31 * result) + taperWidth; + } + } + + private static final class OwnershipFieldKey { + private final KList entries; + private final CNG picker; + private final int chunkX; + private final int chunkZ; + private final int taperWidth; + + private OwnershipFieldKey(KList entries, CNG picker, int chunkX, int chunkZ, + int taperWidth) { + this.entries = entries; + this.picker = picker; + this.chunkX = chunkX; + this.chunkZ = chunkZ; + this.taperWidth = taperWidth; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof OwnershipFieldKey key)) { + return false; + } + return entries == key.entries && picker == key.picker && chunkX == key.chunkX && chunkZ == key.chunkZ + && taperWidth == key.taperWidth; + } + + @Override + public int hashCode() { + int result = System.identityHashCode(entries); + result = (31 * result) + System.identityHashCode(picker); + result = (31 * result) + chunkX; + result = (31 * result) + chunkZ; + return (31 * result) + taperWidth; + } + } + + private static final class ParentBoundaryField { + private final FieldGeometry geometry; + private final int minX; + private final int minZ; + private final IrisBiome[] parents; + private final byte[] distance; + private final boolean[] viable; + + private ParentBoundaryField(BiomeSampler source, int chunkX, int chunkZ, int taperWidth) { + this.geometry = FieldGeometry.forWidth(taperWidth); + this.minX = (chunkX * CHUNK_SIZE) - geometry.fadeRadius(); + this.minZ = (chunkZ * CHUNK_SIZE) - geometry.fadeRadius(); + this.parents = new IrisBiome[geometry.area()]; + this.distance = new byte[geometry.area()]; + this.viable = new boolean[geometry.area()]; + build(source); + } + + private @Nullable IrisBiome parent(int x, int z) { + return parents[geometry.index(x - minX, z - minZ)]; + } + + private int edgeDistance(IrisBiome expected, int x, int z) { + int index = geometry.index(x - minX, z - minZ); + if (!sameBiome(expected, parents[index]) || !viable[index]) { + return 0; + } + return Math.min(geometry.maxRenderableDistance(), Byte.toUnsignedInt(distance[index])); + } + + private void build(BiomeSampler source) { + int rawSize = geometry.fieldSize() + 2; + IrisBiome[] raw = new IrisBiome[rawSize * rawSize]; + int rawMinX = minX - 1; + int rawMinZ = minZ - 1; + for (int z = 0; z < rawSize; z++) { + for (int x = 0; x < rawSize; x++) { + raw[rawIndex(x, z, rawSize)] = source.sample(rawMinX + x, rawMinZ + z); + } + } + for (int z = 0; z < geometry.fieldSize(); z++) { + for (int x = 0; x < geometry.fieldSize(); x++) { + int index = geometry.index(x, z); + IrisBiome parent = raw[rawIndex(x + 1, z + 1, rawSize)]; + parents[index] = parent; + distance[index] = (byte) (touchesDifferentBiome(raw, parent, x, z, rawSize) + ? 1 + : geometry.maxDistance()); + } + } + propagateDistanceField(distance, geometry); + markLabelCoreSupported(parents, distance, viable, geometry); + } + + private static boolean touchesDifferentBiome(IrisBiome[] raw, @Nullable IrisBiome parent, int x, int z, + int rawSize) { + for (int dz = -1; dz <= 1; dz++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dz == 0) { + continue; + } + if (!sameBiome(parent, raw[rawIndex(x + dx + 1, z + dz + 1, rawSize)])) { + return true; + } + } + } + return false; + } + + private static int rawIndex(int x, int z, int rawSize) { + return (z * rawSize) + x; + } + } + + private static final class FootprintField { + private final FieldGeometry geometry; + private final int occupancyMinX; + private final int occupancyMinZ; + private final double[] signed; + private final byte[] cardinalSupport; + private final byte[] diagonalSupport; + private final boolean[] accepted; + private final byte[] distance; + private final boolean[] viable; + + private FootprintField(CNG footprint, int chunkX, int chunkZ, double signedCut, int taperWidth) { + this.geometry = FieldGeometry.forWidth(taperWidth); + this.occupancyMinX = (chunkX * CHUNK_SIZE) - geometry.fadeRadius(); + this.occupancyMinZ = (chunkZ * CHUNK_SIZE) - geometry.fadeRadius(); + this.signed = new double[geometry.area()]; + this.cardinalSupport = new byte[geometry.area()]; + this.diagonalSupport = new byte[geometry.area()]; + this.accepted = new boolean[geometry.area()]; + this.distance = new byte[geometry.area()]; + this.viable = new boolean[geometry.area()]; + build(footprint, signedCut); + } + + private FootprintSample sample(int x, int z) { + int index = geometry.index(x - occupancyMinX, z - occupancyMinZ); + int inwardDistance = Byte.toUnsignedInt(distance[index]); + boolean renderable = accepted[index] && viable[index] && inwardDistance > 1; + int boundaryDistance = renderable + ? Math.min(geometry.maxRenderableDistance(), inwardDistance) + : 0; + double fade = edgeFadeForDistance(boundaryDistance, geometry.taperWidth()); + return new FootprintSample(signed[index], Byte.toUnsignedInt(cardinalSupport[index]), + Byte.toUnsignedInt(diagonalSupport[index]), renderable, boundaryDistance, fade); + } + + private void build(CNG footprint, double signedCut) { + double[] rawSigned = sampleRawFootprint(footprint); + buildOccupancy(rawSigned, signedCut); + buildDistanceField(); + } + + private double[] sampleRawFootprint(CNG footprint) { + int rawSize = geometry.fieldSize() + 2; + double[] rawSigned = new double[rawSize * rawSize]; + int rawMinX = occupancyMinX - 1; + int rawMinZ = occupancyMinZ - 1; + for (int z = 0; z < rawSize; z++) { + for (int x = 0; x < rawSize; x++) { + rawSigned[rawIndex(x, z, rawSize)] = signedFromUnit(footprint.noise(rawMinX + x, rawMinZ + z)); + } + } + return rawSigned; + } + + private void buildOccupancy(double[] rawSigned, double signedCut) { + int rawSize = geometry.fieldSize() + 2; + for (int z = 0; z < geometry.fieldSize(); z++) { + for (int x = 0; x < geometry.fieldSize(); x++) { + int index = geometry.index(x, z); + double value = rawSigned[rawIndex(x + 1, z + 1, rawSize)]; + int cardinal = 0; + int diagonal = 0; + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + if (dx == 0 && dz == 0) { + continue; + } + if (rawSigned[rawIndex(x + dx + 1, z + dz + 1, rawSize)] <= signedCut) { + continue; + } + if (Math.abs(dx) + Math.abs(dz) == 1) { + cardinal++; + } else { + diagonal++; + } + } + } + boolean solid = value > signedCut; + FloatingIslandSample.NeighborSupport support = new FloatingIslandSample.NeighborSupport(cardinal, diagonal); + boolean supported = support.hasSolidSupport(); + boolean repairedPinhole = FloatingIslandSample.isFootprintPinholeRepairable(support); + signed[index] = value; + cardinalSupport[index] = (byte) cardinal; + diagonalSupport[index] = (byte) diagonal; + accepted[index] = (solid && supported) || (!solid && repairedPinhole); + } + } + } + + private void buildDistanceField() { + fillDistanceField(accepted, distance, geometry); + markCoreSupported(accepted, distance, viable, geometry); + } + + private static int rawIndex(int x, int z, int rawSize) { + return (z * rawSize) + x; + } + + private static double signedFromUnit(double value) { + return (Math.max(0.0D, Math.min(1.0D, value)) * 2.0D) - 1.0D; + } + } + + private static final class OwnershipField { + private final FieldGeometry geometry; + private final int minX; + private final int minZ; + private final IrisFloatingChildBiomes[] owners; + private final IdentityHashMap distances; + + private OwnershipField(KList entries, CNG picker, int chunkX, int chunkZ, + int taperWidth) { + this.geometry = FieldGeometry.forWidth(taperWidth); + this.minX = (chunkX * CHUNK_SIZE) - geometry.fadeRadius(); + this.minZ = (chunkZ * CHUNK_SIZE) - geometry.fadeRadius(); + this.owners = new IrisFloatingChildBiomes[geometry.area()]; + this.distances = new IdentityHashMap<>(); + for (int z = 0; z < geometry.fieldSize(); z++) { + for (int x = 0; x < geometry.fieldSize(); x++) { + double value = Math.max(0.0D, Math.min(1.0D, picker.noise(minX + x, minZ + z))); + owners[geometry.index(x, z)] = IRare.pick(entries, value); + } + } + for (IrisFloatingChildBiomes entry : entries) { + if (entry != null && !distances.containsKey(entry)) { + distances.put(entry, buildDistanceField(entry)); + } + } + } + + private OwnershipSample sample(int x, int z) { + int index = geometry.index(x - minX, z - minZ); + IrisFloatingChildBiomes owner = owners[index]; + if (owner == null) { + return new OwnershipSample(null, 0, 0.0D); + } + ComponentDistanceField field = distances.get(owner); + if (field == null) { + return new OwnershipSample(null, 0, 0.0D); + } + int inwardDistance = Byte.toUnsignedInt(field.distance()[index]); + int boundaryDistance = field.viable()[index] + ? Math.min(geometry.maxRenderableDistance(), inwardDistance) + : 0; + double fade = edgeFadeForDistance(boundaryDistance, geometry.taperWidth()); + return new OwnershipSample(owner, boundaryDistance, fade); + } + + private ComponentDistanceField buildDistanceField(IrisFloatingChildBiomes owner) { + boolean[] owned = new boolean[owners.length]; + byte[] distance = new byte[owners.length]; + boolean[] viable = new boolean[owners.length]; + for (int i = 0; i < owners.length; i++) { + owned[i] = owners[i] == owner; + } + fillDistanceField(owned, distance, geometry); + markCoreSupported(owned, distance, viable, geometry); + return new ComponentDistanceField(distance, viable); + } + } + + private record ComponentDistanceField(byte[] distance, boolean[] viable) { + } + + private record FieldGeometry(int taperWidth, int fadeRadius, int fieldSize, int maxDistance) { + private static FieldGeometry forWidth(int taperWidth) { + int width = FloatingIslandEdgeProfile.clampWidth(taperWidth); + int radius = width + 1; + return new FieldGeometry(width, radius, CHUNK_SIZE + (radius * 2), radius + 1); + } + + private int area() { + return fieldSize * fieldSize; + } + + private int maxRenderableDistance() { + return fadeRadius; + } + + private int index(int x, int z) { + return (z * fieldSize) + x; + } + } + + private static void fillDistanceField(boolean[] inside, byte[] distance, FieldGeometry geometry) { + for (int i = 0; i < inside.length; i++) { + distance[i] = (byte) (inside[i] ? geometry.maxDistance() : 0); + } + for (int z = 0; z < geometry.fieldSize(); z++) { + for (int x = 0; x < geometry.fieldSize(); x++) { + if (!inside[geometry.index(x, z)]) { + continue; + } + setMinimumDistance(distance, x, z, x - 1, z, geometry); + setMinimumDistance(distance, x, z, x, z - 1, geometry); + setMinimumDistance(distance, x, z, x - 1, z - 1, geometry); + setMinimumDistance(distance, x, z, x + 1, z - 1, geometry); + } + } + for (int z = geometry.fieldSize() - 1; z >= 0; z--) { + for (int x = geometry.fieldSize() - 1; x >= 0; x--) { + if (!inside[geometry.index(x, z)]) { + continue; + } + setMinimumDistance(distance, x, z, x + 1, z, geometry); + setMinimumDistance(distance, x, z, x, z + 1, geometry); + setMinimumDistance(distance, x, z, x + 1, z + 1, geometry); + setMinimumDistance(distance, x, z, x - 1, z + 1, geometry); + } + } + } + + private static void propagateDistanceField(byte[] distance, FieldGeometry geometry) { + for (int z = 0; z < geometry.fieldSize(); z++) { + for (int x = 0; x < geometry.fieldSize(); x++) { + setMinimumDistance(distance, x, z, x - 1, z, geometry); + setMinimumDistance(distance, x, z, x, z - 1, geometry); + setMinimumDistance(distance, x, z, x - 1, z - 1, geometry); + setMinimumDistance(distance, x, z, x + 1, z - 1, geometry); + } + } + for (int z = geometry.fieldSize() - 1; z >= 0; z--) { + for (int x = geometry.fieldSize() - 1; x >= 0; x--) { + setMinimumDistance(distance, x, z, x + 1, z, geometry); + setMinimumDistance(distance, x, z, x, z + 1, geometry); + setMinimumDistance(distance, x, z, x + 1, z + 1, geometry); + setMinimumDistance(distance, x, z, x - 1, z + 1, geometry); + } + } + } + + private static void setMinimumDistance(byte[] distance, int x, int z, int neighborX, int neighborZ, + FieldGeometry geometry) { + if (neighborX < 0 || neighborX >= geometry.fieldSize() + || neighborZ < 0 || neighborZ >= geometry.fieldSize()) { + return; + } + int index = geometry.index(x, z); + int neighborDistance = Byte.toUnsignedInt(distance[geometry.index(neighborX, neighborZ)]); + int currentDistance = Byte.toUnsignedInt(distance[index]); + distance[index] = (byte) Math.min(currentDistance, neighborDistance + 1); + } + + private static void markCoreSupported(boolean[] inside, byte[] distance, boolean[] viable, + FieldGeometry geometry) { + for (int z = 0; z < geometry.fieldSize(); z++) { + for (int x = 0; x < geometry.fieldSize(); x++) { + int index = geometry.index(x, z); + if (!inside[index]) { + continue; + } + int inwardDistance = Byte.toUnsignedInt(distance[index]); + viable[index] = inwardDistance >= MIN_CORE_DISTANCE + || inwardDistance == MIN_CORE_DISTANCE - 1 + && hasCoreNeighbor(inside, distance, null, x, z, geometry); + } + } + } + + private static void markLabelCoreSupported(IrisBiome[] labels, byte[] distance, boolean[] viable, + FieldGeometry geometry) { + for (int z = 0; z < geometry.fieldSize(); z++) { + for (int x = 0; x < geometry.fieldSize(); x++) { + int index = geometry.index(x, z); + int inwardDistance = Byte.toUnsignedInt(distance[index]); + viable[index] = inwardDistance >= MIN_CORE_DISTANCE + || inwardDistance == MIN_CORE_DISTANCE - 1 + && hasCoreNeighbor(null, distance, labels, x, z, geometry); + } + } + } + + private static boolean hasCoreNeighbor(@Nullable boolean[] inside, byte[] distance, + @Nullable IrisBiome[] labels, int x, int z, FieldGeometry geometry) { + int index = geometry.index(x, z); + for (int dz = -1; dz <= 1; dz++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dz == 0) { + continue; + } + int neighborX = x + dx; + int neighborZ = z + dz; + if (neighborX < 0 || neighborX >= geometry.fieldSize() + || neighborZ < 0 || neighborZ >= geometry.fieldSize()) { + continue; + } + int neighbor = geometry.index(neighborX, neighborZ); + if (Byte.toUnsignedInt(distance[neighbor]) < MIN_CORE_DISTANCE) { + continue; + } + if (inside != null && !inside[neighbor]) { + continue; + } + if (labels != null && !sameBiome(labels[index], labels[neighbor])) { + continue; + } + return true; + } + } + return false; + } + + @FunctionalInterface + public interface BiomeSampler { + @Nullable IrisBiome sample(int x, int z); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandEdgeProfile.java b/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandEdgeProfile.java new file mode 100644 index 000000000..e401bb58d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandEdgeProfile.java @@ -0,0 +1,91 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.util.project.noise.CNG; + +public final class FloatingIslandEdgeProfile { + public static final int DEFAULT_WIDTH = 10; + public static final int MIN_WIDTH = 2; + public static final int MAX_WIDTH = 32; + public static final double DEFAULT_EXPONENT = 1.0D; + public static final double MIN_EXPONENT = 0.25D; + public static final double MAX_EXPONENT = 4.0D; + public static final double MAX_VARIATION_AMPLITUDE = 8.0D; + public static final FloatingIslandEdgeProfile DEFAULT = new FloatingIslandEdgeProfile( + DEFAULT_WIDTH, DEFAULT_EXPONENT, 0.0D, null); + + private final int width; + private final double exponent; + private final double variationAmplitude; + private final CNG variation; + + public FloatingIslandEdgeProfile(int width, double exponent, double variationAmplitude, CNG variation) { + this.width = clampWidth(width); + this.exponent = clampExponent(exponent); + this.variationAmplitude = clampVariationAmplitude(variationAmplitude, this.width); + this.variation = variation; + } + + public int width() { + return width; + } + + public double exponent() { + return exponent; + } + + public double variationAmplitude() { + return variationAmplitude; + } + + public int fieldWidth() { + return Math.min(MAX_WIDTH, (int) Math.ceil(width + variationAmplitude)); + } + + public double fade(int boundaryDistance, int x, int z) { + if (boundaryDistance <= 1) { + return 0.0D; + } + double localWidth = width; + if (variation != null && variationAmplitude > 0.0D) { + double noise = Math.max(0.0D, Math.min(1.0D, variation.noise(x, z))); + double signedNoise = (noise * 2.0D) - 1.0D; + localWidth += signedNoise * variationAmplitude; + } + + double position = Math.max(0.0D, Math.min(1.0D, (boundaryDistance - 1.0D) / localWidth)); + double smooth = position * position * (3.0D - (2.0D * position)); + return exponent == DEFAULT_EXPONENT ? smooth : Math.pow(smooth, exponent); + } + + public static int clampWidth(int width) { + return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width)); + } + + public static double clampExponent(double exponent) { + return Math.max(MIN_EXPONENT, Math.min(MAX_EXPONENT, exponent)); + } + + public static double clampVariationAmplitude(double amplitude, int width) { + int clampedWidth = clampWidth(width); + double widthLimit = Math.min(clampedWidth - MIN_WIDTH, MAX_WIDTH - clampedWidth); + return Math.max(0.0D, Math.min(Math.min(MAX_VARIATION_AMPLITUDE, widthLimit), amplitude)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandSample.java b/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandSample.java index 031caf94f..147d6656b 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandSample.java +++ b/core/src/main/java/art/arcane/iris/engine/object/FloatingIslandSample.java @@ -41,9 +41,7 @@ public final class FloatingIslandSample { public static final int REJECT_COUNT = 7; public static final int REJECT_CLUSTER = REJECT_NO_SEED; - private static final double EDGE_ROUNDING_BAND = 0.28; - private static final double FOOTPRINT_PINHOLE_REPAIR_MARGIN = 0.16; - private static final int PINHOLE_CARDINAL_FILL = 4; + private static final int PINHOLE_CARDINAL_FILL = 3; private static final int PINHOLE_TOTAL_FILL = 7; private static final int CARVE_CARDINAL_SUPPORT = 2; private static final int CARVE_TOTAL_SUPPORT = 4; @@ -75,13 +73,13 @@ public final class FloatingIslandSample { FOOTPRINT_MEMO.get().clear(); } - public static FloatingIslandSample sampleMemoized(IrisBiome parent, int wx, int wz, int chunkHeight, long baseSeed, IrisData data, Engine engine) { + public static FloatingIslandSample sampleMemoized(IrisBiome parent, int wx, int wz, int chunkHeight, long baseSeed, IrisData data, Engine engine, FloatingIslandBoundarySampler boundarySampler) { long key = (((long) wx) << 32) ^ (wz & 0xFFFFFFFFL); HashMap memo = CHUNK_MEMO.get(); if (memo.containsKey(key)) { return memo.get(key); } - FloatingIslandSample result = sample(parent, wx, wz, chunkHeight, baseSeed, data, engine); + FloatingIslandSample result = sample(parent, wx, wz, chunkHeight, baseSeed, data, engine, boundarySampler); memo.put(key, result); return result; } @@ -172,17 +170,42 @@ public final class FloatingIslandSample { return baseSeed ^ ((long) wx * 341873128712L) ^ ((long) wz * 132897987541L); } - public static FloatingIslandSample sample(IrisBiome parent, int wx, int wz, int chunkHeight, long baseSeed, IrisData data, Engine engine) { + private static int maximumEdgeFieldWidth(KList entries, long baseSeed, + IrisData data, IrisBiome parent) { + int maximumWidth = FloatingIslandEdgeProfile.MIN_WIDTH; + for (IrisFloatingChildBiomes entry : entries) { + FloatingIslandEdgeProfile profile = resolveEdgeProfile(entry, baseSeed, data, parent); + maximumWidth = Math.max(maximumWidth, profile.fieldWidth()); + } + return maximumWidth; + } + + private static FloatingIslandEdgeProfile resolveEdgeProfile(IrisFloatingChildBiomes entry, long baseSeed, + IrisData data, IrisBiome parent) { + FloatingIslandEdgeProfile profile = entry.getEdgeTaperProfile(baseSeed, data); + if (profile != null) { + return profile; + } + warnNullCng("edgeTaperVariationStyle", parent); + return FloatingIslandEdgeProfile.DEFAULT; + } + + public static FloatingIslandSample sample(IrisBiome parent, int wx, int wz, int chunkHeight, long baseSeed, IrisData data, Engine engine, FloatingIslandBoundarySampler boundarySampler) { KList entries = parent.getFloatingChildBiomes(); if (entries == null || entries.isEmpty()) { return reject(REJECT_NO_ENTRIES); } + int boundaryFieldWidth = maximumEdgeFieldWidth(entries, baseSeed, data, parent); + int parentBoundaryDistance = boundarySampler.edgeDistance(parent, wx, wz, boundaryFieldWidth); + if (parent.isMergeFloatingChildBiomes()) { - return sampleMerged(parent, entries, wx, wz, chunkHeight, baseSeed, data, engine); + return sampleMerged(parent, entries, wx, wz, chunkHeight, baseSeed, data, engine, boundarySampler, + parentBoundaryDistance, boundaryFieldWidth); } IrisFloatingChildBiomes entry; + int ownershipBoundaryDistance = boundaryFieldWidth + 1; if (entries.size() == 1) { entry = entries.getFirst(); } else { @@ -192,24 +215,30 @@ public final class FloatingIslandSample { warnNullCng("pickerStyle", parent); return reject(REJECT_NO_PICK); } - double pickerValue = picker.noise(wx, wz); - double clamped = Math.max(0, Math.min(1, pickerValue)); - entry = IRare.pick(entries, clamped); + FloatingIslandBoundarySampler.OwnershipSample ownership = boundarySampler.ownership( + entries, picker, wx, wz, boundaryFieldWidth); + entry = ownership.owner(); if (entry == null) { return reject(REJECT_NO_PICK); } + ownershipBoundaryDistance = ownership.boundaryDistance(); } - return sampleEntry(parent, entry, wx, wz, chunkHeight, baseSeed, data, engine); + return sampleEntry(parent, entry, wx, wz, chunkHeight, baseSeed, data, engine, boundarySampler, + parentBoundaryDistance, ownershipBoundaryDistance); } - private static FloatingIslandSample sampleMerged(IrisBiome parent, KList entries, int wx, int wz, int chunkHeight, long baseSeed, IrisData data, Engine engine) { + private static FloatingIslandSample sampleMerged(IrisBiome parent, KList entries, + int wx, int wz, int chunkHeight, long baseSeed, IrisData data, + Engine engine, FloatingIslandBoundarySampler boundarySampler, + int parentBoundaryDistance, int boundaryFieldWidth) { KList samples = new KList<>(); int minY = Integer.MAX_VALUE; int maxY = Integer.MIN_VALUE; for (IrisFloatingChildBiomes entry : entries) { - FloatingIslandSample sample = sampleEntry(parent, entry, wx, wz, chunkHeight, baseSeed, data, engine); + FloatingIslandSample sample = sampleEntry(parent, entry, wx, wz, chunkHeight, baseSeed, data, engine, + boundarySampler, parentBoundaryDistance, boundaryFieldWidth + 1); if (sample == null) { continue; } @@ -270,30 +299,36 @@ public final class FloatingIslandSample { return new FloatingIslandSample(topEntry, minY, thickness, topIdx, solidCount, solidMask, entryMask); } - private static FloatingIslandSample sampleEntry(IrisBiome parent, IrisFloatingChildBiomes entry, int wx, int wz, int chunkHeight, long baseSeed, IrisData data, Engine engine) { + private static FloatingIslandSample sampleEntry(IrisBiome parent, IrisFloatingChildBiomes entry, int wx, int wz, + int chunkHeight, long baseSeed, IrisData data, Engine engine, + FloatingIslandBoundarySampler boundarySampler, + int parentBoundaryDistance, int ownershipBoundaryDistance) { + FloatingIslandEdgeProfile edgeProfile = resolveEdgeProfile(entry, baseSeed, data, parent); CNG footprintCng = entry.getFootprintCng(baseSeed, data); if (footprintCng == null) { warnNullCng("footprintStyle", parent); return reject(REJECT_NO_SEED); } - double signed = footprintSigned(footprintCng, wx, wz); double threshold = Math.max(0, Math.min(1, entry.getFootprintThreshold())); double signedCut = (threshold * 2.0) - 1.0; + FloatingIslandBoundarySampler.FootprintSample footprint = boundarySampler.footprint( + footprintCng, wx, wz, signedCut, edgeProfile.fieldWidth()); + double signed = footprint.signed(); double[] diag = LAST_DENSITY.get(); diag[0] = signed; diag[1] = signedCut; - NeighborSupport footprintSupport = footprintNeighborSupport(footprintCng, wx, wz, signedCut); - boolean footprintSolid = signed > signedCut; - boolean repairedFootprint = !footprintSolid && isFootprintPinholeRepairable(signed, signedCut, footprintSupport); - if (!footprintSolid && !repairedFootprint) { + NeighborSupport footprintSupport = new NeighborSupport(footprint.cardinalSupport(), footprint.diagonalSupport()); + if (!footprint.accepted()) { return reject(REJECT_NO_SEED); } - if (footprintSolid && !footprintSupport.hasSolidSupport()) { - return reject(REJECT_NO_SEED); + int boundaryDistance = Math.min(footprint.boundaryDistance(), + Math.min(parentBoundaryDistance, ownershipBoundaryDistance)); + double edgeFade = edgeProfile.fade(boundaryDistance, wx, wz); + if (edgeFade <= 0.0D) { + return reject(REJECT_NO_THICKNESS); } - double shapeSigned = repairedFootprint ? signedCut + FOOTPRINT_PINHOLE_REPAIR_MARGIN : signed; CNG altitudeCng = entry.getAltitudeCng(baseSeed, data); if (altitudeCng == null) { @@ -307,10 +342,9 @@ public final class FloatingIslandSample { int maxAlt = Math.max(minAlt, entry.getMaxHeightAboveSurface() - worldMin); int baseY = minAlt + (int) Math.round(altClamped * (maxAlt - minAlt)); - double edgeFade = edgeFade(shapeSigned, signedCut); IrisBiome target = entry.getRealBiome(parent, data); - int topH = roundedEdgeHeight(computeTopHeight(entry, target, engine, baseSeed, wx, wz, data), edgeFade); - int topY = baseY + topH; + int fullTopHeight = computeTopHeight(entry, target, engine, baseSeed, wx, wz, data); + int topH = roundedEdgeHeight(fullTopHeight, edgeFade); CNG bottomCng = entry.getBottomCng(baseSeed, data); if (bottomCng == null) { @@ -323,6 +357,17 @@ public final class FloatingIslandSample { int minDepth = Math.max(0, entry.getBottomDepthMin()); int maxDepth = Math.max(minDepth, entry.getBottomDepthMax()); int depth = roundedEdgeDepth(minDepth, maxDepth, bottomShaped, edgeFade); + if (topH == 0 && depth == 0) { + double fullDepth = minDepth + (bottomShaped * (maxDepth - minDepth)); + if (fullDepth > 0.0D) { + depth = 1; + } else if (fullTopHeight > 0) { + topH = 1; + } else { + return reject(REJECT_NO_THICKNESS); + } + } + int topY = baseY + topH; int botY = baseY - depth; Integer minAbsoluteY = entry.getMinAbsoluteY(); @@ -360,18 +405,21 @@ public final class FloatingIslandSample { boolean[] solidMask = new boolean[thickness]; CNG wallWarp = entry.getWallWarpCng(baseSeed, data); double warpAmp = Math.max(0, entry.getWallWarpAmplitude()); + double effectiveWarpAmp = effectiveWallWarpAmplitude(warpAmp, edgeFade); IrisCaveProfileSampler carvingProfileSampler = entry.getCarvingProfileSampler(engine, data); CNG carve = carvingProfileSampler == null && !entry.hasCarvingReference() ? entry.getCarveCng(baseSeed, data) : null; double carveThreshold = entry.getCarveThreshold(); - boolean useWarp = wallWarp != null && warpAmp > 0; + boolean useWarp = wallWarp != null && effectiveWarpAmp > 0; boolean useProfileCarve = carvingProfileSampler != null; boolean useCarve = directCarveEnabled(entry, carvingProfileSampler, carve, carveThreshold); + boolean[] lateralCarveMask = useProfileCarve || useCarve ? new boolean[thickness] : null; + boolean baseCarveInterior = lateralCarveMask != null && canCarveLaterally(edgeFade, footprintSupport); if (useWarp) { for (int k = 0; k < thickness; k++) { int wy = botY + k; - boolean layerSolid = layerFootprintSolid(footprintCng, wallWarp, true, warpAmp, wx, wy, wz, signedCut); - NeighborSupport layerSupport = layerNeighborSupport(footprintCng, wallWarp, true, warpAmp, wx, wy, wz, signedCut); + boolean layerSolid = layerFootprintSolid(footprintCng, wallWarp, true, effectiveWarpAmp, wx, wy, wz, signedCut); + NeighborSupport layerSupport = layerNeighborSupport(footprintCng, wallWarp, true, effectiveWarpAmp, wx, wy, wz, signedCut); if (layerSolid && !layerSupport.hasSolidSupport()) { continue; } @@ -379,16 +427,22 @@ public final class FloatingIslandSample { continue; } solidMask[k] = true; + if (lateralCarveMask != null) { + lateralCarveMask[k] = baseCarveInterior && layerSolid && layerSupport.isFullySurrounded(); + } } } else { Arrays.fill(solidMask, true); + if (baseCarveInterior) { + Arrays.fill(lateralCarveMask, true); + } } int solidCount = solidifyUncarvedInterior(solidMask); if (useProfileCarve) { - solidCount = carveSolidInterior(solidMask, botY, wx, wz, carvingProfileSampler, carveThreshold); + solidCount = carveSolidInterior(solidMask, lateralCarveMask, botY, wx, wz, carvingProfileSampler, carveThreshold); } else if (useCarve) { - solidCount = carveSolidInterior(solidMask, botY, wx, wz, carve, carveThreshold); + solidCount = carveSolidInterior(solidMask, lateralCarveMask, botY, wx, wz, carve, carveThreshold); } int highestSolidIdx = highestSolidIndex(solidMask); @@ -402,12 +456,6 @@ public final class FloatingIslandSample { return new FloatingIslandSample(entry, botY, thickness, topIdx, solidCount, solidMask); } - static double edgeFade(double signed, double signedCut) { - double edge = (signed - signedCut) / EDGE_ROUNDING_BAND; - double edgeClamped = Math.max(0, Math.min(1, edge)); - return edgeClamped * edgeClamped * (3.0 - 2.0 * edgeClamped); - } - static int roundedEdgeHeight(int topHeight, double edgeFade) { return Math.max(0, (int) Math.round(Math.max(0, topHeight) * Math.max(0, Math.min(1, edgeFade)))); } @@ -421,23 +469,33 @@ public final class FloatingIslandSample { return (int) Math.round(fullDepth * fade); } + static double effectiveWallWarpAmplitude(double wallWarpAmplitude, double edgeFade) { + double amplitude = Math.max(0, wallWarpAmplitude); + double fade = Math.max(0, Math.min(1, edgeFade)); + return amplitude * fade; + } + static boolean directCarveEnabled(IrisFloatingChildBiomes entry, IrisCaveProfileSampler carvingProfileSampler, CNG carve, double carveThreshold) { return carvingProfileSampler == null && (entry == null || !entry.hasCarvingReference()) && carve != null && carveThreshold < 1.0; } - static int carveSolidInterior(boolean[] solidMask, int botY, int wx, int wz, CNG carve, double carveThreshold) { - return carveSolidInterior(solidMask, botY, wx, wz, (x, y, z) -> { + static boolean canCarveLaterally(double edgeFade, NeighborSupport support) { + return edgeFade >= 1.0 && support.isFullySurrounded(); + } + + static int carveSolidInterior(boolean[] solidMask, boolean[] lateralCarveMask, int botY, int wx, int wz, CNG carve, double carveThreshold) { + return carveSolidInterior(solidMask, lateralCarveMask, botY, wx, wz, (x, y, z) -> { double carveNoise = carve.noise(x, y, z); double carveClamped = Math.max(0, Math.min(1, carveNoise)); return carveClamped > carveThreshold; }); } - static int carveSolidInterior(boolean[] solidMask, int botY, int wx, int wz, IrisCaveProfileSampler carve, double carveThreshold) { - return carveSolidInterior(solidMask, botY, wx, wz, (x, y, z) -> carve.shouldCarve(x, y, z, carveThreshold)); + static int carveSolidInterior(boolean[] solidMask, boolean[] lateralCarveMask, int botY, int wx, int wz, IrisCaveProfileSampler carve, double carveThreshold) { + return carveSolidInterior(solidMask, lateralCarveMask, botY, wx, wz, (x, y, z) -> carve.shouldCarve(x, y, z, carveThreshold)); } - private static int carveSolidInterior(boolean[] solidMask, int botY, int wx, int wz, CarveSampler carve) { + private static int carveSolidInterior(boolean[] solidMask, boolean[] lateralCarveMask, int botY, int wx, int wz, CarveSampler carve) { int firstSolid = -1; int lastSolid = -1; for (int i = 0; i < solidMask.length; i++) { @@ -459,7 +517,7 @@ public final class FloatingIslandSample { boolean[] carveMask = new boolean[solidMask.length]; if (carveStart <= carveEnd) { for (int i = carveStart; i <= carveEnd; i++) { - if (!solidMask[i]) { + if (!solidMask[i] || !lateralCarveMask[i]) { continue; } @@ -549,8 +607,8 @@ public final class FloatingIslandSample { return footprintNeighborSupport(footprintCng, wx, wz, signedCut).hasSolidSupport(); } - static boolean isFootprintPinholeRepairable(double signed, double signedCut, NeighborSupport support) { - return signed >= signedCut - FOOTPRINT_PINHOLE_REPAIR_MARGIN && support.canFillPinhole(); + static boolean isFootprintPinholeRepairable(NeighborSupport support) { + return support.canFillPinhole(); } static NeighborSupport footprintNeighborSupport(CNG footprintCng, int wx, int wz, double signedCut) { @@ -678,7 +736,7 @@ public final class FloatingIslandSample { private final int cardinal; private final int diagonal; - private NeighborSupport(int cardinal, int diagonal) { + NeighborSupport(int cardinal, int diagonal) { this.cardinal = cardinal; this.diagonal = diagonal; } @@ -702,6 +760,10 @@ public final class FloatingIslandSample { boolean canFillPinhole() { return cardinal >= PINHOLE_CARDINAL_FILL && total() >= PINHOLE_TOTAL_FILL; } + + boolean isFullySurrounded() { + return cardinal == 4 && diagonal == 4; + } } private static int computeTopHeight(IrisFloatingChildBiomes entry, IrisBiome target, Engine engine, long baseSeed, int wx, int wz, IrisData data) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 0fecb35c4..0db91b014 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -134,8 +134,6 @@ public class IrisBiome extends IrisRegistrant implements IRare { private int lockLayersMax = 7; @Desc("Profile-driven 3D cave configuration") private IrisCaveProfile caveProfile = new IrisCaveProfile(); - @Desc("Configuration of fluid bodies such as rivers & lakes") - private IrisFluidBodies fluidBodies = new IrisFluidBodies(); @MinNumber(1) @MaxNumber(512) @Desc("The rarity of this biome (integer)") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBlockDrops.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBlockDrops.java index 907a1e5ef..83eb8bd21 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBlockDrops.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBlockDrops.java @@ -20,6 +20,7 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.Required; @@ -82,7 +83,7 @@ public class IrisBlockDrops { public void fillDrops(boolean debug, KList d) { for (IrisLoot i : getDrops()) { - if (RNG.r.i(1, i.getRarity()) == i.getRarity()) { + if (LootResolver.oneIn(RNG.r, i.getRarity())) { d.add(i.get(debug, RNG.r)); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index b2f6dbbd5..f5265f0ec 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -161,8 +161,6 @@ public class IrisDimension extends IrisRegistrant { private KList carving = new KList<>(); @Desc("Profile-driven 3D cave configuration") private IrisCaveProfile caveProfile = new IrisCaveProfile(); - @Desc("Configuration of fluid bodies such as rivers & lakes") - private IrisFluidBodies fluidBodies = new IrisFluidBodies(); @Desc("forceConvertTo320Height") private Boolean forceConvertTo320Height = false; @Desc("The world environment") @@ -266,10 +264,10 @@ public class IrisDimension extends IrisRegistrant { @Desc("Dimension-wide Iris structure placements, independent of biome/region placements") @ArrayType(type = IrisStructurePlacement.class, min = 1) private KList structures = new KList<>(); - @Desc("Controls native vanilla & ingested datapack structure generation for this dimension. Defaults to ALL_ON. The 'disabled'/'enabled' lists autocomplete every live vanilla and ingested datapack structure key.") + @Desc("Controls native vanilla, mod, and ingested datapack structure generation for this dimension. Every registered structure is enabled by default; 'disabled' is the sole generation deny list and autocompletes live structure keys.") private IrisImportedStructureControl importedStructures = new IrisImportedStructureControl(); @ArrayType(type = String.class, min = 1) - @Desc("External datapack imports for this dimension. List Modrinth datapack page URLs ('https://modrinth.com/datapack/' resolves the latest datapack version, or a specific '.../version/' link pins a version) or a direct .zip URL. Run '/iris datapack ingest' (or '/iris ingest') to download/update them and install them into the world's datapacks folder. Once ingested, their structures behave exactly like vanilla jigsaw structures. To REPLACE a vanilla structure with an imported one, add a 'structures' placement (route IRIS_PLACED) referencing the imported structure key (which carries the matching 'vanillaSource', e.g. 'minecraft:stronghold'): its vanilla counterpart is then automatically suppressed, so it never double-generates - no 'importedStructures.disabled' entry needed. Use 'importedStructures.disabled' only to turn a vanilla structure OFF entirely when you are NOT placing a replacement, or place any imported structure key arbitrarily through a 'structures' placement.") + @Desc("External datapack sources for this dimension. List Modrinth datapack page URLs or direct zip URLs. Registered datapack structures remain native unless explicitly cloned into Iris resources. Replacing native generation requires a dimension-level Iris structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.") private KList datapackImports = new KList<>(); @MinNumber(0) @MaxNumber(318) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEnchantment.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEnchantment.java index c7cabef49..c138f74ca 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEnchantment.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEnchantment.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; import art.arcane.iris.engine.object.annotations.MinNumber; @@ -83,6 +84,6 @@ public class IrisEnchantment { } public int getLevel(RNG rng) { - return rng.i(getMinLevel(), getMaxLevel()); + return LootResolver.inclusive(rng, getMinLevel(), getMaxLevel()); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java index 8b3126d6d..04108743d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java @@ -25,6 +25,7 @@ import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.core.service.EntityRiseSVC; import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.platform.EngineBukkitOps; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; @@ -325,27 +326,27 @@ public class IrisEntity extends IrisRegistrant { l.setRemoveWhenFarAway(isRemovable()); - if (getHelmet() != null && rng.i(1, getHelmet().getRarity()) == 1) { + if (getHelmet() != null && LootResolver.oneIn(rng, getHelmet().getRarity())) { l.getEquipment().setHelmet(getHelmet().get(gen.isStudio(), rng)); } - if (getChestplate() != null && rng.i(1, getChestplate().getRarity()) == 1) { + if (getChestplate() != null && LootResolver.oneIn(rng, getChestplate().getRarity())) { l.getEquipment().setChestplate(getChestplate().get(gen.isStudio(), rng)); } - if (getLeggings() != null && rng.i(1, getLeggings().getRarity()) == 1) { + if (getLeggings() != null && LootResolver.oneIn(rng, getLeggings().getRarity())) { l.getEquipment().setLeggings(getLeggings().get(gen.isStudio(), rng)); } - if (getBoots() != null && rng.i(1, getBoots().getRarity()) == 1) { + if (getBoots() != null && LootResolver.oneIn(rng, getBoots().getRarity())) { l.getEquipment().setBoots(getBoots().get(gen.isStudio(), rng)); } - if (getMainHand() != null && rng.i(1, getMainHand().getRarity()) == 1) { + if (getMainHand() != null && LootResolver.oneIn(rng, getMainHand().getRarity())) { l.getEquipment().setItemInMainHand(getMainHand().get(gen.isStudio(), rng)); } - if (getOffHand() != null && rng.i(1, getOffHand().getRarity()) == 1) { + if (getOffHand() != null && LootResolver.oneIn(rng, getOffHand().getRarity())) { l.getEquipment().setItemInOffHand(getOffHand().get(gen.isStudio(), rng)); } } @@ -518,7 +519,7 @@ public class IrisEntity extends IrisRegistrant { for (String fi : entity.getLoot().getTables()) { IrisLootTable i = gen.getData().getLootLoader().load(fi); - items.addAll(i.getLoot(gen.isStudio(), rng.nextParallelRNG(345911), InventorySlotType.STORAGE, finalAt.getWorld(), finalAt.getBlockX(), finalAt.getBlockY(), finalAt.getBlockZ())); + items.addAll(i.getLoot(gen.isStudio(), gen.getSeedManager().getLoot(), InventorySlotType.STORAGE, finalAt.getWorld(), finalAt.getBlockX(), finalAt.getBlockY(), finalAt.getBlockZ())); } return items; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java index ffba18139..5d48b2980 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java @@ -22,6 +22,7 @@ import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.engine.object.annotations.RegistryListResource; @@ -29,6 +30,7 @@ import art.arcane.iris.engine.object.annotations.Required; import art.arcane.iris.engine.object.annotations.Snippet; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.format.C; +import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.Vector3d; import art.arcane.volmlib.util.matter.MatterMarker; @@ -70,26 +72,19 @@ public class IrisEntitySpawn implements IRare { private transient IrisMarker referenceMarker; public int spawn(Engine gen, Chunk c, RNG rng) { - int spawns = minSpawns == maxSpawns ? minSpawns : rng.i(Math.min(minSpawns, maxSpawns), Math.max(minSpawns, maxSpawns)); + int spawns = LootResolver.inclusive(rng, minSpawns, maxSpawns); int s = 0; if (spawns > 0) { for (int id = 0; id < spawns; id++) { - int x = (c.getX() * 16) + rng.i(15); - int z = (c.getZ() * 16) + rng.i(15); + int x = (c.getX() * 16) + rng.i(16); + int z = (c.getZ() * 16) + rng.i(16); World world = BukkitWorldBinding.tryBind(gen.getWorld()) ? BukkitWorldBinding.world(gen.getWorld()) : null; int h = gen.getHeight(x, z, true) + (world == null ? -64 : world.getMinHeight()); int hf = gen.getHeight(x, z, false) + (world == null ? -64 : world.getMinHeight()); Location l = switch (getReferenceSpawner().getGroup()) { case NORMAL -> new Location(c.getWorld(), x, hf + 1, z); - case CAVE -> { - if (J.isFolia()) { - // Avoid mantle region IO lookups on Folia tick threads. - yield new Location(c.getWorld(), x, h + 1, z); - } - yield gen.getMantle().findMarkers(c.getX(), c.getZ(), MarkerMatter.CAVE_FLOOR) - .convert((i) -> BukkitPlatform.toLocation(i, c.getWorld()).add(0, 1, 0)).getRandom(rng); - } + case CAVE -> findCaveSpawnLocation(gen, c, rng); case UNDERWATER, BEACH -> new Location(c.getWorld(), x, rng.i(h + 1, hf), z); }; @@ -113,7 +108,7 @@ public class IrisEntitySpawn implements IRare { } public int spawn(Engine gen, IrisPosition c, RNG rng) { - int spawns = minSpawns == maxSpawns ? minSpawns : rng.i(Math.min(minSpawns, maxSpawns), Math.max(minSpawns, maxSpawns)); + int spawns = LootResolver.inclusive(rng, minSpawns, maxSpawns); int s = 0; if (!BukkitWorldBinding.tryBind(gen.getWorld())) { @@ -160,13 +155,25 @@ public class IrisEntitySpawn implements IRare { return null; } - if (rng.aquire(() -> new RNG(g.getSeedManager().getEntity())).i(1, getRarity()) == 1) { + if (LootResolver.oneIn(rng.aquire(() -> new RNG(g.getSeedManager().getEntity())), getRarity())) { return spawn100(g, at); } return null; } + static Location findCaveSpawnLocation(Engine engine, Chunk chunk, RNG rng) { + if (J.isFolia()) { + return null; + } + KList markers = engine.getMantle().findMarkers(chunk.getX(), chunk.getZ(), MarkerMatter.CAVE_FLOOR); + return selectCaveSpawnLocation(markers, chunk.getWorld(), rng); + } + + static Location selectCaveSpawnLocation(KList markers, World world, RNG rng) { + return markers.convert((marker) -> BukkitPlatform.toLocation(marker, world).add(0, 1, 0)).getRandom(rng); + } + private Entity spawn100(Engine g, Location at) { return spawn100(g, at, false); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java b/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java index 11f28cfbc..64cb9e63a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java @@ -46,6 +46,7 @@ import lombok.experimental.Accessors; public class IrisFloatingChildBiomes implements IRare { private final transient AtomicCache resolvedBiome = new AtomicCache<>(); private final transient AtomicCache footprintCache = new AtomicCache<>(); + private final transient AtomicCache edgeTaperProfileCache = new AtomicCache<>(); private final transient AtomicCache pickerCache = new AtomicCache<>(); private final transient AtomicCache altitudeCache = new AtomicCache<>(); private final transient AtomicCache topShapeCache = new AtomicCache<>(); @@ -59,6 +60,19 @@ public class IrisFloatingChildBiomes implements IRare { return footprintCache.aquire(() -> getFootprintStyle().create(new RNG(baseSeed ^ 0xF007B17DL), data)); } + public FloatingIslandEdgeProfile getEdgeTaperProfile(long baseSeed, IrisData data) { + return edgeTaperProfileCache.aquire(() -> { + int width = FloatingIslandEdgeProfile.clampWidth(getEdgeTaperWidth()); + double amplitude = FloatingIslandEdgeProfile.clampVariationAmplitude( + getEdgeTaperVariationAmplitude(), width); + IrisGeneratorStyle style = getEdgeTaperVariationStyle(); + CNG variation = amplitude > 0.0D && style != null + ? style.create(new RNG(baseSeed ^ 0xED6E7A9E5L), data) + : null; + return new FloatingIslandEdgeProfile(width, getEdgeTaperExponent(), amplitude, variation); + }); + } + public CNG getPickerCng(long baseSeed, IrisData data) { return pickerCache.aquire(() -> getPickerStyle().create(new RNG(baseSeed ^ 0x91C4E72DL), data)); } @@ -147,6 +161,24 @@ public class IrisFloatingChildBiomes implements IRare { @Desc("Coverage threshold (0..1). Roughly the fraction of the world that is NOT island: 0.0 = every column becomes island, 0.5 ≈ 50% of columns, 0.8 ≈ sparse scattered ~20% coverage, 1.0 = no islands at all. Values near 0.5 feel most natural.") private double footprintThreshold = 0.5; + @MinNumber(2) + @MaxNumber(32) + @Desc("Width in blocks of the rounded transition from the floating island contour to its full thickness. Smaller values make a tighter edge; larger values make a broader rounded underside.") + private int edgeTaperWidth = FloatingIslandEdgeProfile.DEFAULT_WIDTH; + + @MinNumber(0.25) + @MaxNumber(4) + @Desc("Shape exponent for the rounded edge. Values below 1 fill into a softer, fuller curve; values above 1 keep the rim thinner before rising into the island interior.") + private double edgeTaperExponent = FloatingIslandEdgeProfile.DEFAULT_EXPONENT; + + @Desc("Broad 2D noise that organically varies the rounded edge without changing the island footprint. Low-frequency SIMPLEX or PERLIN styles give coherent natural contours; high-frequency or cellular styles can look speckled.") + private IrisGeneratorStyle edgeTaperVariationStyle = NoiseStyle.SIMPLEX.style().zoomed(0.18D); + + @MinNumber(0) + @MaxNumber(8) + @Desc("Maximum number of blocks by which edgeTaperVariationStyle locally widens or narrows the rounded transition. 0 disables variation. Runtime keeps the resulting local width between 2 and 32 blocks so every protected rim column remains connected.") + private double edgeTaperVariationAmplitude = 0.0D; + @Desc("Picker noise used when multiple floating child entries exist. Samples once per column to deterministically choose which entry's footprint is tested there. Use a large style zoom (e.g. zoom: 4 for ~400-block regions) so each entry owns broad coherent areas.") private IrisGeneratorStyle pickerStyle = NoiseStyle.SIMPLEX.style(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisFluidBodies.java b/core/src/main/java/art/arcane/iris/engine/object/IrisFluidBodies.java deleted file mode 100644 index f707b49e5..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisFluidBodies.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2022 Arcane Arts (Volmit Software) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package art.arcane.iris.engine.object; - -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.mantle.MantleWriter; -import art.arcane.iris.engine.object.annotations.ArrayType; -import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.Snippet; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.documentation.BlockCoordinates; -import art.arcane.volmlib.util.math.RNG; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Snippet("fluid-bodies") -@Accessors(chain = true) -@NoArgsConstructor -@AllArgsConstructor -@Desc("Represents a fluid body configuration") -@Data -public class IrisFluidBodies { - @ArrayType(type = IrisRiver.class, min = 1) - @Desc("Define rivers") - private KList rivers = new KList<>(); - - @ArrayType(type = IrisLake.class, min = 1) - @Desc("Define lakes") - private KList lakes = new KList<>(); - - @BlockCoordinates - public void generate(MantleWriter writer, RNG rng, Engine engine, int x, int y, int z) { - if (rivers.isNotEmpty()) { - for (IrisRiver i : rivers) { - i.generate(writer, rng, engine, x, y, z); - } - } - - if (lakes.isNotEmpty()) { - for (IrisLake i : lakes) { - i.generate(writer, rng, engine, x, y, z); - } - } - } - - public int getMaxRange(IrisData data) { - int max = 0; - - for (IrisRiver i : rivers) { - max = Math.max(max, i.getSize(data)); - } - - for (IrisLake i : lakes) { - max = Math.max(max, i.getSize(data)); - } - - - return max; - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java index e76cad700..0593e042e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java @@ -30,54 +30,42 @@ import lombok.NoArgsConstructor; import lombok.experimental.Accessors; import java.util.Locale; +import java.util.Objects; @Accessors(chain = true) @NoArgsConstructor @AllArgsConstructor -@Desc("Controls native vanilla, mod, and ingested datapack structure generation for this dimension (set as the dimension's 'importedStructures' field). Default mode is ALL_ON. Blacklist keys with 'disabled', or use ALL_OFF with 'enabled' as a whitelist. Family matching uses namespace, slash, or underscore boundaries, so 'minecraft:village' covers every village variant without matching unrelated names. Run '/iris structure list ' to dump every valid key. Only affects newly generated chunks and is separate from Iris structure placements.") +@Desc("Controls native vanilla, mod, and ingested datapack structure generation for this dimension (set as the dimension's 'importedStructures' field). Every registered structure generates through its native placement unless its key is explicitly listed in 'disabled' or a viable dimension-level Iris placement explicitly replaces its source. Family matching uses namespace, slash, or underscore boundaries, so 'minecraft:village' covers every village variant without matching unrelated names. Run '/iris structure list ' to dump every valid key. Only affects newly generated chunks and is separate from Iris structure placements.") @Data public class IrisImportedStructureControl { - @Desc("Master toggle. ALL_ON generates every native structure except those in 'disabled'. ALL_OFF generates nothing except those in 'enabled'.") - private VanillaStructureMode mode = VanillaStructureMode.ALL_ON; - @ArrayType(type = String.class, min = 1) @RegistryListVanillaStructure - @Desc("Structure keys to turn OFF while mode is ALL_ON, e.g. 'minecraft:stronghold'. A namespace:path prefix also matches, so 'minecraft:village' disables every village variant and 'minecraft:ruined_portal' disables every ruined portal. Ignored when mode is ALL_OFF.") + @Desc("Structure keys to deny explicitly, e.g. 'minecraft:stronghold'. A namespace:path prefix also matches, so 'minecraft:village' disables every village variant and 'minecraft:ruined_portal' disables every ruined portal. Every key not matched here remains enabled.") private KList disabled = new KList<>(); - @ArrayType(type = String.class, min = 1) - @RegistryListVanillaStructure - @Desc("Structure keys to turn ON while mode is ALL_OFF, e.g. 'minecraft:village_plains'. A family prefix also matches at namespace, slash, or underscore boundaries, so 'minecraft:village' enables every village variant. Ignored when mode is ALL_ON.") - private KList enabled = new KList<>(); - @MinNumber(-512) @MaxNumber(512) @Desc("Vertical block offset applied only to UNDERGROUND vanilla structures (the UNDERGROUND_STRUCTURES, UNDERGROUND_DECORATION, and STRONGHOLDS generation steps: strongholds, trial chambers, mineshafts, ancient cities, etc.). Surface structures (villages, outposts, etc.) are never shifted. Use a negative value to push deep structures lower when your dimension's sea/terrain level differs from vanilla's (e.g. -64 if you lowered the fluid height to 0). 0 = no shift.") private int undergroundYShift = 0; - @Desc("Controls whether ingested datapacks may replace minecraft-namespaced structure definitions, sets, pools, and templates. When false, those overrides are stripped from installed datapack copies so vanilla definitions stay intact. Non-minecraft structures from datapacks and mods remain governed by mode, enabled, and disabled because namespace alone cannot identify their origin. Resolved globally across loaded packs: if any dimension sets this false, minecraft-namespaced overrides are stripped from every installed datapack copy.") + @Desc("Controls whether ingested datapacks may replace minecraft-namespaced structure definitions, sets, pools, and templates. When false, those overrides are stripped from installed datapack copies so vanilla definitions stay intact. Non-minecraft structures from datapacks and mods remain governed by disabled because namespace alone cannot identify their origin. Resolved globally across loaded packs: if any dimension sets this false, minecraft-namespaced overrides are stripped from every installed datapack copy.") private boolean datapackOverrides = true; @ArrayType(type = IrisVanillaStructureAdjustment.class, min = 1) @Desc("Per-structure adjustments applied to vanilla, mod, and datapack structures that still generate natively. Vertical shifts from every matching entry stack. Surface-intersecting structures clear logs and leaves automatically; a matching vegetation option can force clearing for unusual placements. The last matching entry with stilt settings controls foundation columns. A structure suppressed by an Iris placement is unaffected.") private KList adjustments = new KList<>(); - public boolean active() { - return mode == VanillaStructureMode.ALL_ON || !enabled.isEmpty(); - } - public boolean shouldGenerate(String key) { - if (mode == VanillaStructureMode.ALL_ON) { - return !matches(disabled, key); - } - return matches(enabled, key); + return key != null && !key.isBlank() && !matches(disabled, key); } public IrisNativeStructureDecision resolve(String key, boolean undergroundStep) { + KList activeAdjustments = Objects.requireNonNull( + adjustments, "importedStructures.adjustments must not be null"); int y = undergroundStep ? undergroundYShift : 0; boolean clearVegetation = false; - IrisVanillaStructureStiltSettings stilt = null; - for (IrisVanillaStructureAdjustment adjustment : adjustments) { + IrisStructureStiltSettings stilt = null; + for (IrisVanillaStructureAdjustment adjustment : activeAdjustments) { if (adjustment != null && adjustment.matches(key)) { y += adjustment.getYShift(); clearVegetation |= adjustment.isClearVegetation(); @@ -86,14 +74,26 @@ public class IrisImportedStructureControl { } } } - return new IrisNativeStructureDecision(shouldGenerate(key), y, clearVegetation, stilt); + return new IrisNativeStructureDecision(generationStatus(key), y, clearVegetation, stilt); + } + + private NativeStructureGenerationStatus generationStatus(String key) { + if (key == null || key.isBlank()) { + return NativeStructureGenerationStatus.INVALID_REGISTRY_KEY; + } + if (matches(disabled, key)) { + return NativeStructureGenerationStatus.DISABLED_BY_PACK; + } + return NativeStructureGenerationStatus.GENERATE_NATIVE; } private boolean matches(KList list, String key) { if (key == null) { return false; } - for (String entry : list) { + KList activeList = Objects.requireNonNull( + list, "importedStructures.disabled must not be null"); + for (String entry : activeList) { if (matchesKey(entry, key)) { return true; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawConnector.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawConnector.java index 7eb65fb37..220025e07 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawConnector.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawConnector.java @@ -40,12 +40,16 @@ public class IrisJigsawConnector { @Desc("The direction this connector faces. The connecting piece is placed on this side.") private IrisDirection direction = IrisDirection.NORTH_NEGATIVE_Z; + @Required + @Desc("The connector's authored top direction, used to preserve aligned vanilla jigsaw orientation.") + private IrisDirection top = IrisDirection.UP_POSITIVE_Y; + @Required @RegistryListResource(IrisJigsawPool.class) @Desc("The jigsaw pool to draw the connecting piece from.") private String pool = ""; - @Desc("The name of this connector. A connector only joins to a connector on another piece whose targetName equals this name (and whose name equals this connector's targetName).") + @Desc("The identity exposed by this connector. Another connector can attach when its targetName equals this name, matching Minecraft's jigsaw target-to-name contract.") private String name = ""; @Desc("The name this connector wants to connect to on the other piece.") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceEntry.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceEntry.java index d7d821907..20419ecee 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceEntry.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceEntry.java @@ -21,7 +21,6 @@ package art.arcane.iris.engine.object; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.engine.object.annotations.RegistryListResource; -import art.arcane.iris.engine.object.annotations.Required; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -33,12 +32,18 @@ import lombok.experimental.Accessors; @Desc("A weighted reference to a jigsaw piece inside a jigsaw pool.") @Data public class IrisJigsawPieceEntry { - @Required @RegistryListResource(IrisJigsawPiece.class) - @Desc("The jigsaw piece this entry refers to.") + @Desc("The jigsaw piece this entry refers to. Leave empty only when empty is true.") private String piece = ""; @MinNumber(1) @Desc("The relative weight of this piece within its pool. Higher weights are chosen more often.") private int weight = 1; + + @Desc("When true, choosing this entry successfully terminates the current branch without placing a piece.") + private boolean empty = false; + + public IrisJigsawPieceEntry(String piece, int weight) { + this(piece, weight, false); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java index 577cf5cb6..226506991 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java @@ -43,7 +43,7 @@ public class IrisJigsawPool extends IrisRegistrant { private KList pieces = new KList<>(); @RegistryListResource(IrisJigsawPool.class) - @Desc("The pool to fall back to when the structure's max depth is reached. Use this for terminal pieces (caps, dead ends). Leave empty to simply stop expanding.") + @Desc("The direct fallback pool tried after this pool, or used alone at maximum depth. The fallback pool's own fallback is not part of the same selection. Leave empty to stop expanding at maximum depth.") private String fallback = ""; @Override diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisLake.java b/core/src/main/java/art/arcane/iris/engine/object/IrisLake.java deleted file mode 100644 index 5adc5ccef..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisLake.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2022 Arcane Arts (Volmit Software) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package art.arcane.iris.engine.object; - -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.mantle.MantleWriter; -import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.MinNumber; -import art.arcane.iris.engine.object.annotations.RegistryListResource; -import art.arcane.iris.engine.object.annotations.Required; -import art.arcane.iris.engine.object.annotations.Snippet; -import art.arcane.volmlib.util.math.RNG; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Snippet("lake") -@Accessors(chain = true) -@NoArgsConstructor -@AllArgsConstructor -@Desc("Represents an Iris Lake") -@Data -public class IrisLake implements IRare { - @Required - @Desc("Typically a 1 in RARITY on a per chunk/fork basis") - @MinNumber(1) - private int rarity = 15; - - @Desc("The width style of this lake") - private IrisShapedGeneratorStyle widthStyle = new IrisShapedGeneratorStyle(NoiseStyle.PERLIN.style(), 5, 9); - - @Desc("The depth style of this lake") - private IrisShapedGeneratorStyle depthStyle = new IrisShapedGeneratorStyle(NoiseStyle.PERLIN.style(), 4, 7); - - @Desc("Define the shape of this lake") - private IrisWorm worm = new IrisWorm(); - - @RegistryListResource(IrisBiome.class) - @Desc("Force this lake to only generate the specified custom biome") - private String customBiome = ""; - - public int getSize(IrisData data) { - return worm.getMaxDistance(); - } - - public void generate(MantleWriter writer, RNG rng, Engine engine, int x, int y, int z) { - - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisLoot.java b/core/src/main/java/art/arcane/iris/engine/object/IrisLoot.java index 82d4c17f4..533c859bc 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisLoot.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisLoot.java @@ -24,6 +24,7 @@ import art.arcane.iris.spi.IrisServices; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; @@ -38,7 +39,6 @@ import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.util.common.format.C; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.util.project.noise.CNG; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -68,7 +68,8 @@ import java.util.Optional; @EqualsAndHashCode(doNotUseGetters = true) @ToString(doNotUseGetters = true) public class IrisLoot { - private final transient AtomicCache chance = new AtomicCache<>(); + public static final int MAX_AMOUNT = 64; + private final transient AtomicCache dyeColorResolved = new AtomicCache<>(); @Desc("The target inventory slot types to fill this loot with") private InventorySlotType slotTypes = InventorySlotType.STORAGE; @@ -76,10 +77,12 @@ public class IrisLoot { @Desc("The sub rarity of this loot. Calculated after this loot table has been picked.") private int rarity = 1; @MinNumber(1) - @Desc("Minimum amount of this loot") + @MaxNumber(MAX_AMOUNT) + @Desc("Minimum amount of this loot, from 1 to 64") private int minAmount = 1; @MinNumber(1) - @Desc("Maximum amount of this loot") + @MaxNumber(MAX_AMOUNT) + @Desc("Maximum amount of this loot, from 1 to 64") private int maxAmount = 1; @MinNumber(1) @Desc("The display name of this item") @@ -163,21 +166,20 @@ public class IrisLoot { } } - public ItemStack get(boolean debug, boolean giveSomething, IrisLootTable table, RNG rng, int x, int y, int z) { - if (debug) { - chance.reset(); + public ItemStack get(boolean debug, IrisLootTable table, RNG rng, long lootSeed, int entryIndex, int x, int y, int z) { + long combinedRarity = LootResolver.combinedRarity(rarity, table.getRarity()); + if (!LootResolver.spatialOneIn(lootSeed, table, entryIndex, x, y, z, combinedRarity)) { + return null; } - if (giveSomething || chance.aquire(() -> NoiseStyle.STATIC.create(rng)).fit(1, rarity * table.getRarity(), x, y, z) == 1) { - try { - ItemStack is = getItemStack(rng); - if (is == null) - return null; - is.setItemMeta(applyProperties(is, rng, debug, table)); - return BukkitPlatform.applyCustomNbt(is, customNbt); - } catch (Throwable e) { - e.printStackTrace(); - } + try { + ItemStack is = getItemStack(rng); + if (is == null) + return null; + is.setItemMeta(applyProperties(is, rng, debug, table)); + return BukkitPlatform.applyCustomNbt(is, customNbt); + } catch (Throwable e) { + IrisLogging.reportError(e); } return null; @@ -192,10 +194,10 @@ public class IrisLoot { return new ItemStack(Material.AIR); } ItemStack is = opt.get(); - is.setAmount(Math.max(1, rng.i(getMinAmount(), getMaxAmount()))); + is.setAmount(Math.max(1, LootResolver.inclusive(rng, getMinAmount(), getMaxAmount()))); return is; } - return new ItemStack(getType(), Math.max(1, rng.i(getMinAmount(), getMaxAmount()))); + return new ItemStack(getType(), Math.max(1, LootResolver.inclusive(rng, getMinAmount(), getMaxAmount()))); } private ItemMeta applyProperties(ItemStack is, RNG rng, boolean debug, IrisLootTable table) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java b/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java index e79c16dcc..e61fd3726 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java @@ -21,6 +21,7 @@ package art.arcane.iris.engine.object; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.engine.object.annotations.RegistryListResource; import art.arcane.iris.engine.object.annotations.Snippet; @@ -38,6 +39,8 @@ import lombok.experimental.Accessors; @Desc("Represents a loot entry") @Data public class IrisLootReference { + public static final double MAX_MULTIPLIER = 16D; + private final transient AtomicCache> tt = new AtomicCache<>(); @Desc("Add = add on top of parent tables, Replace = clear first then add these. Clear = Remove all and dont add loot from this or parent.") private IrisLootMode mode = IrisLootMode.ADD; @@ -46,7 +49,8 @@ public class IrisLootReference { @Desc("Add loot table registries here") private KList tables = new KList<>(); @MinNumber(0) - @Desc("Increase the chance of loot in this area") + @MaxNumber(MAX_MULTIPLIER) + @Desc("Scale loot sources in this area from 0 to 16 times their normal count") private double multiplier = 1D; public KList getLootTables(DataProvider g) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java b/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java index c26d6d4f6..13ae7685d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java @@ -19,8 +19,10 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.engine.object.annotations.Required; import art.arcane.volmlib.util.collection.KList; @@ -43,6 +45,9 @@ import org.bukkit.inventory.ItemStack; @Data @EqualsAndHashCode(callSuper = false) public class IrisLootTable extends IrisRegistrant { + public static final int MAX_PICKED = 64; + public static final int MAX_TRIES = 256; + @Required @Desc("The name of this loot table") @MinNumber(2) @@ -53,35 +58,42 @@ public class IrisLootTable extends IrisRegistrant { private int rarity = 1; @MinNumber(1) - @Desc("The maximum amount of loot that can be picked in this table at a time.") + @MaxNumber(MAX_PICKED) + @Desc("The maximum amount of loot that can be picked in this table at a time, from 1 to 64") private int maxPicked = 5; @MinNumber(0) - @Desc("The minimum amount of loot that can be picked in this table at a time.") + @MaxNumber(MAX_PICKED) + @Desc("The minimum amount of loot that can be picked in this table at a time, from 0 to 64") private int minPicked = 1; @MinNumber(1) - @Desc("The maximum amount of tries to generate loot") + @MaxNumber(MAX_TRIES) + @Desc("The maximum amount of tries to generate loot, from 1 to 256") private int maxTries = 10; @Desc("The loot in this table") @ArrayType(min = 1, type = IrisLoot.class) private KList loot = new KList<>(); - public KList getLoot(boolean debug, RNG rng, InventorySlotType slot, World world, int x, int y, int z) { + public KList getLoot(boolean debug, long lootSeed, InventorySlotType slot, World world, int x, int y, int z) { KList lootf = new KList<>(); + if (loot.isEmpty() || maxTries <= 0) { + return lootf; + } + RNG rng = LootResolver.tableRng(lootSeed, this, x, y, z); int m = 0; int c = 0; - int mx = rng.i(getMinPicked(), getMaxPicked()); + int mx = Math.max(0, LootResolver.inclusive(rng, getMinPicked(), getMaxPicked())); while (m < mx && c++ < getMaxTries()) { - int num = rng.i(loot.size()); + int num = rng.nextInt(loot.size()); IrisLoot l = loot.get(num); - if (l.getSlotTypes() == slot) { - ItemStack item = l.get(debug, false, this, rng, x, y, z); + if (l != null && l.getSlotTypes() == slot) { + ItemStack item = l.get(debug, this, rng, lootSeed, num, x, y, z); if (item != null && item.getType() != Material.AIR) { lootf.add(item); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructureDecision.java b/core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructureDecision.java index 4639a2aa1..a6c26dbce 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructureDecision.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructureDecision.java @@ -19,9 +19,16 @@ package art.arcane.iris.engine.object; public record IrisNativeStructureDecision( - boolean generate, + NativeStructureGenerationStatus status, int yShift, boolean clearVegetation, - IrisVanillaStructureStiltSettings stilt + IrisStructureStiltSettings stilt ) { + public boolean generate() { + return status == NativeStructureGenerationStatus.GENERATE_NATIVE; + } + + public IrisNativeStructureDecision withStatus(NativeStructureGenerationStatus replacement) { + return new IrisNativeStructureDecision(replacement, yShift, clearVegetation, stilt); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java index 25a511faa..8c22fe1c9 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java @@ -87,9 +87,9 @@ public class IrisObject extends IrisRegistrant { static final PlatformBlockState AIR = B.getState("CAVE_AIR"); static final PlatformBlockState STONE = B.getState("STONE"); static final PlatformBlockState VAIR = B.getState("VOID_AIR"); + static final PlatformBlockState VAIR_DEBUG = B.getState("COBWEB"); + static final PlatformBlockState[] SNOW_LAYERS = new PlatformBlockState[]{B.getState("minecraft:snow[layers=1]"), B.getState("minecraft:snow[layers=2]"), B.getState("minecraft:snow[layers=3]"), B.getState("minecraft:snow[layers=4]"), B.getState("minecraft:snow[layers=5]"), B.getState("minecraft:snow[layers=6]"), B.getState("minecraft:snow[layers=7]"), B.getState("minecraft:snow[layers=8]")}; } - protected static final PlatformBlockState VAIR_DEBUG = B.getState("COBWEB"); - protected static final PlatformBlockState[] SNOW_LAYERS = new PlatformBlockState[]{B.getState("minecraft:snow[layers=1]"), B.getState("minecraft:snow[layers=2]"), B.getState("minecraft:snow[layers=3]"), B.getState("minecraft:snow[layers=4]"), B.getState("minecraft:snow[layers=5]"), B.getState("minecraft:snow[layers=6]"), B.getState("minecraft:snow[layers=7]"), B.getState("minecraft:snow[layers=8]")}; private static final long IMPLAUSIBLE_BEDROCK_WARN_THROTTLE_MS = 5000L; private static final long VACUUM_WAVE_SEED = 7392113L; private static final java.util.concurrent.ConcurrentHashMap IMPLAUSIBLE_BEDROCK_WARNS = new java.util.concurrent.ConcurrentHashMap<>(); @@ -195,7 +195,7 @@ public class IrisObject extends IrisRegistrant { } PrecisionStopwatch p = PrecisionStopwatch.start(); - PlatformBlockState vair = debug ? VAIR_DEBUG : States.VAIR; + PlatformBlockState vair = debug ? States.VAIR_DEBUG : States.VAIR; writeLock.lock(); AtomicInteger applied = new AtomicInteger(); if (blocks.isEmpty()) { @@ -961,7 +961,7 @@ public class IrisObject extends IrisRegistrant { IrisBlockVector offset = new IrisBlockVector(config.getTranslate().getX(), config.getTranslate().getY(), config.getTranslate().getZ()); for (int i = x - Math.floorDiv(w, 2) + (int) offset.getX(); i <= x + Math.floorDiv(w, 2) - (w % 2 == 0 ? 1 : 0) + (int) offset.getX(); i++) { for (int j = y - Math.floorDiv(h, 2) + (int) offset.getY(); j <= y + Math.floorDiv(h, 2) - (h % 2 == 0 ? 1 : 0) + (int) offset.getY(); j++) { - for (int k = z - Math.floorDiv(d, 2) + (int) offset.getZ(); k <= z + Math.floorDiv(d, 2) - (d % 2 == 0 ? 1 : 0) + (int) offset.getX(); k++) { + for (int k = z - Math.floorDiv(d, 2) + (int) offset.getZ(); k <= z + Math.floorDiv(d, 2) - (d % 2 == 0 ? 1 : 0) + (int) offset.getZ(); k++) { PlacedObject p = engine.getObjectPlacement(i, j, k); if (p == null) continue; IrisObject o = p.getObject(); @@ -981,7 +981,7 @@ public class IrisObject extends IrisRegistrant { IrisBlockVector offset = new IrisBlockVector(config.getTranslate().getX(), config.getTranslate().getY(), config.getTranslate().getZ()); for (int i = x - Math.floorDiv(w, 2) + (int) offset.getX(); i <= x + Math.floorDiv(w, 2) - (w % 2 == 0 ? 1 : 0) + (int) offset.getX(); i++) { for (int j = y - Math.floorDiv(h, 2) - config.getBoreExtendMinY() + (int) offset.getY(); j <= y + Math.floorDiv(h, 2) + config.getBoreExtendMaxY() - (h % 2 == 0 ? 1 : 0) + (int) offset.getY(); j++) { - for (int k = z - Math.floorDiv(d, 2) + (int) offset.getZ(); k <= z + Math.floorDiv(d, 2) - (d % 2 == 0 ? 1 : 0) + (int) offset.getX(); k++) { + for (int k = z - Math.floorDiv(d, 2) + (int) offset.getZ(); k <= z + Math.floorDiv(d, 2) - (d % 2 == 0 ? 1 : 0) + (int) offset.getZ(); k++) { placer.set(i, j, k, States.AIR); } } @@ -1139,24 +1139,23 @@ public class IrisObject extends IrisRegistrant { data = attachVineFaces(placer, data, xx, yy, zz); } - if (listener != null) { - listener.accept(new BlockPosition(xx, yy, zz), data); - } - - if (markers != null && markers.containsKey(g)) { - placer.getEngine().getMantle().getMantle().set(xx, yy, zz, new MatterMarker(markers.get(g))); - } - PlatformBlockState existingState = placer.get(xx, yy, zz); boolean wouldReplace = B.isSolid(existingState) && B.isVineBlock(data); String material = materialKey(data); - boolean place = !material.equals("minecraft:air") && !material.equals("minecraft:cave_air") && !wouldReplace; + boolean air = material.equals("minecraft:air") || material.equals("minecraft:cave_air"); + boolean place = shouldPlaceObjectBlock(rawStructurePiece, air, wouldReplace); if (data.isCustom() || place) { placer.set(xx, yy, zz, data); if (tile != null) { placer.setTile(xx, yy, zz, tile); } + if (markers != null && markers.containsKey(g)) { + placer.setData(xx, yy, zz, new MatterMarker(markers.get(g))); + } + if (listener != null) { + listener.accept(new BlockPosition(xx, yy, zz), data); + } if (vacuuming && yy < vacuumLowest) { vacuumLowest = yy; } @@ -1413,7 +1412,7 @@ public class IrisObject extends IrisRegistrant { if (config.getSnow() > 0) { int height = rngx.i(0, (int) (config.getSnow() * 7)); - placer.set(vx, vy + 1, vz, SNOW_LAYERS[Math.max(Math.min(height, 7), 0)]); + placer.set(vx, vy + 1, vz, States.SNOW_LAYERS[Math.max(Math.min(height, 7), 0)]); } } } @@ -1421,6 +1420,10 @@ public class IrisObject extends IrisRegistrant { return y; } + static boolean shouldPlaceObjectBlock(boolean rawStructurePiece, boolean air, boolean wouldReplace) { + return !wouldReplace && (rawStructurePiece || !air); + } + private void warnImplausibleBedrockPlacement(IObjectPlacer placer, IrisObjectPlacement config, int x, int y, int z) { String key = getLoadKey(); String fingerprint = (key == null ? "" : key) + "|" + config.getMode(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectLoot.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectLoot.java index 4c186b130..6d54a99c5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectLoot.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectLoot.java @@ -22,6 +22,7 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.engine.object.annotations.RegistryListResource; import art.arcane.iris.engine.object.annotations.Required; import art.arcane.iris.engine.object.annotations.Snippet; @@ -51,6 +52,7 @@ public class IrisObjectLoot implements IObjectLoot { @RegistryListResource(IrisLootTable.class) private String name; @Desc("The weight of this loot table being chosen") + @MinNumber(1) private int weight = 1; public KList getFilter(IrisData rdata) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java index 20f180f15..0ec396c1d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java @@ -23,6 +23,7 @@ import art.arcane.iris.platform.bukkit.BukkitBlockResolution; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; @@ -35,7 +36,6 @@ import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.util.common.data.DataProvider; -import art.arcane.volmlib.util.data.WeightedRandom; import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.project.noise.CNG; import com.google.gson.annotations.SerializedName; @@ -270,7 +270,7 @@ public class IrisObjectPlacement { TableCache tc = new TableCache(); for (IObjectLoot loot : list) { - if (loot == null) + if (loot == null || loot.getWeight() <= 0) continue; IrisLootTable table = loader.apply(loot.getName()); if (table == null) { @@ -286,7 +286,7 @@ public class IrisObjectPlacement { for (PlatformBlockState filterState : loot.getFilter(manager)) { BlockData filterData = (BlockData) filterState.nativeHandle(); if (!tc.basic.containsKey(filterData.getMaterial())) { - tc.basic.put(filterData.getMaterial(), new WeightedRandom<>()); + tc.basic.put(filterData.getMaterial(), new WeightedTables()); } tc.basic.get(filterData.getMaterial()).put(table, loot.getWeight()); @@ -300,7 +300,7 @@ public class IrisObjectPlacement { } if (!tc.exact.get(filterData.getMaterial()).containsKey(filterData)) { - tc.exact.get(filterData.getMaterial()).put(filterData, new WeightedRandom<>()); + tc.exact.get(filterData.getMaterial()).put(filterData, new WeightedTables()); } tc.exact.get(filterData.getMaterial()).get(filterData).put(table, loot.getWeight()); @@ -334,7 +334,7 @@ public class IrisObjectPlacement { picked = cache.exact.get(data.getMaterial()).get(data).pullRandom(rng); } else if (cache.basic.containsKey(data.getMaterial())) { picked = cache.basic.get(data.getMaterial()).pullRandom(rng); - } else if (cache.global.getSize() > 0) { + } else if (!cache.global.isEmpty()) { picked = cache.global.pullRandom(rng); } @@ -345,14 +345,39 @@ public class IrisObjectPlacement { } private static class TableCache { - final transient WeightedRandom global = new WeightedRandom<>(); - final transient KMap> basic = new KMap<>(); - final transient KMap>> exact = new KMap<>(); + final transient WeightedTables global = new WeightedTables(); + final transient KMap basic = new KMap<>(); + final transient KMap> exact = new KMap<>(); private void merge(TableCache other) { global.merge(other.global); - basic.merge(other.basic, WeightedRandom::merge); - exact.merge(other.exact, (a, b) -> a.merge(b, WeightedRandom::merge)); + basic.merge(other.basic, WeightedTables::merge); + exact.merge(other.exact, (a, b) -> a.merge(b, WeightedTables::merge)); } } + + private static final class WeightedTables { + private final KList tables = new KList<>(); + + private void put(IrisLootTable table, int weight) { + tables.add(new WeightedTable(table, weight)); + } + + private IrisLootTable pullRandom(RNG rng) { + WeightedTable picked = LootResolver.pickWeighted(tables, WeightedTable::weight, rng); + return picked == null ? null : picked.table(); + } + + private boolean isEmpty() { + return tables.isEmpty(); + } + + private WeightedTables merge(WeightedTables other) { + tables.addAll(other.tables); + return this; + } + } + + private record WeightedTable(IrisLootTable table, int weight) { + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java index c487899fb..4098a475c 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java @@ -43,6 +43,7 @@ import org.bukkit.block.data.type.Wall; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; @Snippet("object-rotator") @@ -53,14 +54,20 @@ import java.util.Set; @Data public class IrisObjectRotation { private static final boolean BUKKIT_PRESENT = detectBukkit(); - private static volatile StateRotator FALLBACK_ROTATOR = null; + private static volatile StateRotator PLATFORM_ROTATOR = null; public interface StateRotator { PlatformBlockState rotate(IrisObjectRotation rotation, PlatformBlockState state, int spinx, int spiny, int spinz); } - public static void bindFallbackRotator(StateRotator rotator) { - FALLBACK_ROTATOR = rotator; + public static synchronized StateRotator bindPlatformRotator(StateRotator rotator) { + StateRotator previous = PLATFORM_ROTATOR; + PLATFORM_ROTATOR = Objects.requireNonNull(rotator, "rotator"); + return previous; + } + + public static synchronized void restorePlatformRotator(StateRotator rotator) { + PLATFORM_ROTATOR = rotator; } private static boolean detectBukkit() { @@ -292,8 +299,8 @@ public class IrisObjectRotation { } if (!BUKKIT_PRESENT) { - StateRotator rotator = FALLBACK_ROTATOR; - return rotator == null ? state : rotator.rotate(this, state, spinx, spiny, spinz); + StateRotator rotator = requirePlatformRotator(PLATFORM_ROTATOR); + return rotator.rotate(this, state, spinx, spiny, spinz); } BlockData original = (BlockData) state.nativeHandle(); @@ -306,6 +313,13 @@ public class IrisObjectRotation { return rotated == null ? null : BukkitBlockState.of(rotated); } + static StateRotator requirePlatformRotator(StateRotator rotator) { + if (rotator == null) { + throw new IllegalStateException("No platform block-state rotator is bound"); + } + return rotator; + } + private static boolean canRotateBlockData(BlockData data) { return data instanceof Directional || data instanceof Rotatable diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectVacuum.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectVacuum.java index 10abc9b28..1b8b46084 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectVacuum.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectVacuum.java @@ -93,21 +93,27 @@ public final class IrisObjectVacuum { public static int columnTargetY(int dx, int dz, int lowX, int highX, int lowZ, int highZ, double effectiveRadius, double falloff, int originalY, int meetY) { + double factor = columnInfluence( + dx, dz, lowX, highX, lowZ, highZ, effectiveRadius, falloff); + return (int) Math.round(originalY + (((double) meetY - originalY) * factor)); + } + + public static double columnInfluence(int dx, int dz, int lowX, int highX, int lowZ, int highZ, + double effectiveRadius, double falloff) { int outX = outset(dx, lowX, highX); int outZ = outset(dz, lowZ, highZ); - double distance = Math.sqrt((double) (outX * outX) + (double) (outZ * outZ)); + double distance = Math.sqrt((double) outX * outX + (double) outZ * outZ); if (effectiveRadius <= 0) { - return distance <= 0 ? meetY : originalY; + return distance <= 0 ? 1.0 : 0.0; } - if (distance > effectiveRadius) { - return originalY; + if (distance >= effectiveRadius) { + return 0.0; } double t = 1.0 - (distance / effectiveRadius); if (t <= 0) { - return originalY; + return 0.0; } - double factor = Math.pow(t, Math.max(0.25, falloff)); - return (int) Math.round(originalY + ((meetY - originalY) * factor)); + return Math.pow(t, Math.max(0.25, falloff)); } public static int waveOffset(double distance, double effectiveRadius, double signedNoise, double amplitude) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectVanillaLoot.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectVanillaLoot.java index 7130f2bb2..90e35395f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectVanillaLoot.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectVanillaLoot.java @@ -4,6 +4,7 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.engine.object.annotations.RegistryListFunction; import art.arcane.iris.engine.object.annotations.Required; import art.arcane.iris.engine.object.annotations.Snippet; @@ -33,6 +34,7 @@ public class IrisObjectVanillaLoot implements IObjectLoot { @RegistryListFunction(LootTableKeyFunction.class) private String name; @Desc("The weight of this loot table being chosen") + @MinNumber(1) private int weight = 1; public KList getFilter(IrisData rdata) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java index 333cfa0e7..a3846ff22 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java @@ -126,8 +126,6 @@ public class IrisRegion extends IrisRegistrant implements IRare { private double caveBiomeZoom = 1; @Desc("Profile-driven 3D cave configuration") private IrisCaveProfile caveProfile = new IrisCaveProfile(); - @Desc("Configuration of fluid bodies such as rivers & lakes") - private IrisFluidBodies fluidBodies = new IrisFluidBodies(); @RegistryListResource(IrisBiome.class) @Required @ArrayType(min = 1, type = String.class) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiver.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiver.java deleted file mode 100644 index 6803fb068..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiver.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2022 Arcane Arts (Volmit Software) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package art.arcane.iris.engine.object; - -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.mantle.MantleWriter; -import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.MinNumber; -import art.arcane.iris.engine.object.annotations.RegistryListResource; -import art.arcane.iris.engine.object.annotations.Required; -import art.arcane.iris.engine.object.annotations.Snippet; -import art.arcane.volmlib.util.math.RNG; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Snippet("river") -@Accessors(chain = true) -@NoArgsConstructor -@AllArgsConstructor -@Desc("Represents an Iris river") -@Data -public class IrisRiver implements IRare { - @Required - @Desc("Typically a 1 in RARITY on a per chunk/fork basis") - @MinNumber(1) - private int rarity = 15; - - @Desc("The width style of this river") - private IrisStyledRange width = new IrisStyledRange(3, 6, NoiseStyle.PERLIN.style()); - - @Desc("Define the shape of this river") - private IrisWorm worm = new IrisWorm(); - - @RegistryListResource(IrisBiome.class) - @Desc("Force this river to only generate the specified custom biome") - private String customBiome = ""; - - @Desc("The width style of this lake") - private IrisShapedGeneratorStyle widthStyle = new IrisShapedGeneratorStyle(NoiseStyle.PERLIN.style(), 5, 9); - - @Desc("The depth style of this lake") - private IrisShapedGeneratorStyle depthStyle = new IrisShapedGeneratorStyle(NoiseStyle.PERLIN.style(), 4, 7); - - public int getSize(IrisData data) { - return worm.getMaxDistance(); - } - - public void generate(MantleWriter writer, RNG rng, Engine engine, int x, int y, int z) { - - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java index ccf3a596d..9cd53d208 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java @@ -37,9 +37,15 @@ import lombok.experimental.Accessors; public class IrisStructurePlacement { @ArrayType(type = String.class, min = 1) @RegistryListStructure - @Desc("Structures to place here. Any vanilla, datapack, or imported Iris structure key (e.g. minecraft_village_plains, minecraft_stronghold). Jigsaw component pieces are not valid here.") + @Desc("Editable Iris structure resources to place here. Every key must resolve to a structures/*.json resource in this pack. Live vanilla, mod, and datapack registry keys are controlled through importedStructures unless explicitly cloned into Iris resources.") private KList structures = new KList<>(); + @Desc("Stable authored identity for this placement. Set this when multiple placements use the same structure and settings. When empty, Iris derives identity from the placement content so list reordering does not move generated structures.") + private String placementId = ""; + + @Desc("Controls native replacement for this placement. REPLACE_SOURCE suppresses each selected Iris structure's vanillaSource, and is honored only for dimension-level placements. NONE leaves native generation untouched.") + private NativeStructureSuppression nativeSuppression = NativeStructureSuppression.NONE; + @Desc("How start positions are scattered.") private StructureDistribution distribution = StructureDistribution.RANDOM_SPREAD; @@ -105,6 +111,9 @@ public class IrisStructurePlacement { @Desc("when overbore=true, how many blocks of terrain to carve below the structure's floor. 0 keeps the floor solid for support; small values recess the cavern floor around the structure.") private int overboreFloor = 0; + @Desc("Optional foundation columns placed beneath the assembled structure's occupied bottom cells. Columns pass through air and fluids until they reach solid ground, up to maxDepth.") + private IrisStructureStiltSettings stilt = null; + @Desc("If false, this placement is skipped underwater.") private boolean underwater = false; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureStiltSettings.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureStiltSettings.java similarity index 83% rename from core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureStiltSettings.java rename to core/src/main/java/art/arcane/iris/engine/object/IrisStructureStiltSettings.java index d2044843a..e428a89c3 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureStiltSettings.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureStiltSettings.java @@ -11,9 +11,9 @@ import lombok.experimental.Accessors; @Accessors(chain = true) @NoArgsConstructor @AllArgsConstructor -@Desc("Defines the foundation columns placed beneath a native vanilla, mod, or datapack structure.") +@Desc("Defines foundation columns placed beneath a structure.") @Data -public class IrisVanillaStructureStiltSettings { +public class IrisStructureStiltSettings { @MinNumber(1) @MaxNumber(4064) @Desc("Maximum number of blocks each foundation column may descend while searching for solid ground.") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaLootTable.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaLootTable.java index 9f64a1590..0247c2452 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaLootTable.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaLootTable.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import lombok.Data; @@ -49,7 +50,8 @@ public class IrisVanillaLootTable extends IrisLootTable { } @Override - public KList getLoot(boolean debug, RNG rng, InventorySlotType slot, World world, int x, int y, int z) { + public KList getLoot(boolean debug, long lootSeed, InventorySlotType slot, World world, int x, int y, int z) { + RNG rng = LootResolver.tableRng(lootSeed, this, x, y, z); return new KList<>(lootTable.populateLoot(rng, new LootContext.Builder(new Location(world, x, y, z)).build())); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java index b4cc57dc9..d06617817 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java @@ -49,7 +49,7 @@ public class IrisVanillaStructureAdjustment { private boolean clearVegetation = false; @Desc("Optional foundation columns placed beneath the native structure piece bases after placement.") - private IrisVanillaStructureStiltSettings stilt = null; + private IrisStructureStiltSettings stilt = null; public boolean matches(String key) { for (String entry : match) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/NativeStructureGenerationStatus.java b/core/src/main/java/art/arcane/iris/engine/object/NativeStructureGenerationStatus.java new file mode 100644 index 000000000..e022a6ba5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/NativeStructureGenerationStatus.java @@ -0,0 +1,26 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +public enum NativeStructureGenerationStatus { + GENERATE_NATIVE, + DISABLED_BY_PACK, + REPLACED_BY_IRIS, + INVALID_REGISTRY_KEY +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/NativeStructureSuppression.java b/core/src/main/java/art/arcane/iris/engine/object/NativeStructureSuppression.java new file mode 100644 index 000000000..8c3cfc178 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/NativeStructureSuppression.java @@ -0,0 +1,12 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Controls whether an Iris structure placement suppresses its imported native source.") +public enum NativeStructureSuppression { + @Desc("Leaves native generation enabled alongside this Iris structure placement.") + NONE, + + @Desc("Suppresses the structure's vanillaSource when used by a validated dimension-level replacement placement.") + REPLACE_SOURCE +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/TileData.java b/core/src/main/java/art/arcane/iris/engine/object/TileData.java index 82ca3bf6d..c2385e849 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/TileData.java +++ b/core/src/main/java/art/arcane/iris/engine/object/TileData.java @@ -41,6 +41,7 @@ import org.bukkit.block.data.BlockData; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.util.Objects; @SuppressWarnings("ALL") @Getter @@ -50,8 +51,8 @@ import java.io.IOException; public class TileData implements Cloneable { private static final Gson gson = new GsonBuilder().disableHtmlEscaping().setStrictness(Strictness.LENIENT).create(); private static final boolean BUKKIT_PRESENT = detectBukkit(); - private static volatile TileReader FALLBACK_READER = null; - private static volatile TileFactory FALLBACK_FACTORY = null; + private static volatile TileReader PLATFORM_READER = null; + private static volatile TileFactory PLATFORM_FACTORY = null; public interface TileReader { TileData read(DataInputStream in) throws IOException; @@ -61,12 +62,24 @@ public class TileData implements Cloneable { TileData create(PlatformBlockState state, KMap properties); } - public static void bindFallbackReader(TileReader reader) { - FALLBACK_READER = reader; + public static synchronized TileReader bindPlatformReader(TileReader reader) { + TileReader previous = PLATFORM_READER; + PLATFORM_READER = Objects.requireNonNull(reader, "reader"); + return previous; } - public static void bindFallbackFactory(TileFactory factory) { - FALLBACK_FACTORY = factory; + public static synchronized TileFactory bindPlatformFactory(TileFactory factory) { + TileFactory previous = PLATFORM_FACTORY; + PLATFORM_FACTORY = Objects.requireNonNull(factory, "factory"); + return previous; + } + + public static synchronized void restorePlatformReader(TileReader reader) { + PLATFORM_READER = reader; + } + + public static synchronized void restorePlatformFactory(TileFactory factory) { + PLATFORM_FACTORY = factory; } private static boolean detectBukkit() { @@ -102,12 +115,8 @@ public class TileData implements Cloneable { } public static TileData of(PlatformBlockState state, KMap properties) { - TileFactory factory = FALLBACK_FACTORY; - if (factory != null) { - return factory.create(state, properties); - } if (!BUKKIT_PRESENT) { - return null; + return requirePlatformFactory(PLATFORM_FACTORY).create(state, properties); } Object handle = state.nativeHandle(); if (!(handle instanceof BlockData blockData)) { @@ -117,12 +126,8 @@ public class TileData implements Cloneable { } public static TileData read(DataInputStream in) throws IOException { - TileReader reader = FALLBACK_READER; - if (reader != null) { - return reader.read(in); - } if (!BUKKIT_PRESENT) { - throw new IOException("No tile data reader is available for this platform"); + return requirePlatformReader(PLATFORM_READER).read(in); } if (!in.markSupported()) throw new IOException("Mark not supported"); @@ -139,6 +144,20 @@ public class TileData implements Cloneable { } } + static TileFactory requirePlatformFactory(TileFactory factory) { + if (factory == null) { + throw new IllegalStateException("No platform tile-data factory is bound"); + } + return factory; + } + + static TileReader requirePlatformReader(TileReader reader) throws IOException { + if (reader == null) { + throw new IOException("No platform tile-data reader is bound"); + } + return reader; + } + public boolean isApplicable(BlockData data) { return material != null && data.getMaterial() == material; } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index ebe023c51..37bf15e24 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -635,17 +635,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun if (initializationFailure != null) { return false; } - try { - Engine e = this.engine; - if (e == null) { - return true; - } - return e.getDimension().getImportedStructures().active(); - } catch (Throwable t) { - IrisLogging.reportError("Iris could not resolve native structure generation policy for " - + world.name() + ".", t); - return false; - } + return true; } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java index a6b0ca98e..516c8ae6b 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java @@ -23,9 +23,9 @@ import art.arcane.iris.core.events.IrisLootEvent; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.core.tools.IrisToolbelt; -import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Locator; +import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.framework.PlacedObject; import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.object.InventorySlotType; @@ -48,7 +48,6 @@ import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; -import art.arcane.volmlib.util.math.BlockPosition; import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.math.RNG; @@ -65,18 +64,23 @@ import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; +import org.bukkit.block.BlockState; import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.type.Chest; import org.bukkit.entity.Player; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryHolder; import org.bukkit.inventory.ItemStack; +import org.bukkit.loot.Lootable; import java.lang.reflect.Method; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BooleanSupplier; import java.util.function.Consumer; +import java.util.function.Predicate; public final class EngineBukkitOps { private EngineBukkitOps() { @@ -149,7 +153,6 @@ public final class EngineBukkitOps { } } - RNG rng = new RNG(Cache.key(c.getX(), c.getZ())); chunk.iterate(MatterCavern.class, (x, yf, z, v) -> { int y = yf + engine.getWorld().minHeight(); x &= 15; @@ -172,14 +175,14 @@ public final class EngineBukkitOps { if (grid[x][z] == Integer.MIN_VALUE) { continue; } - update(engine, x, grid[x][z], z, c, chunk, rng); + update(engine, x, grid[x][z], z, c, chunk); } } chunk.iterate(MatterUpdate.class, (x, yf, z, v) -> { int y = yf + engine.getWorld().minHeight(); if (v != null && v.isUpdate()) { - update(engine, x, y, z, c, chunk, rng); + update(engine, x, y, z, c, chunk); } }); chunk.deleteSlices(MatterUpdate.class); @@ -254,16 +257,15 @@ public final class EngineBukkitOps { }; } - public static void update(Engine engine, int x, int y, int z, Chunk c, MantleChunk mc, RNG rf) { + public static void update(Engine engine, int x, int y, int z, Chunk c, MantleChunk mc) { World world = c.getWorld(); if (y < world.getMinHeight() || y >= world.getMaxHeight()) { return; } - Block block = c.getBlock(x, y, z); + Block block = c.getBlock(x & 15, y, z & 15); BlockData data = block.getBlockData(); engine.blockUpdatedMetric(); if (BukkitBlockResolution.isStorage(data)) { - RNG rx = rf.nextParallelRNG(BlockPosition.toLong(x, y, z)); InventorySlotType slot = null; if (BukkitBlockResolution.isStorageChest(data)) { @@ -271,13 +273,19 @@ public final class EngineBukkitOps { } if (slot != null) { - KList tables = getLootTables(engine, rx, block, mc); + if (!isCanonicalContainer(block)) { + return; + } + int worldX = block.getX(); + int worldZ = block.getZ(); + RNG rx = LootResolver.containerRng(engine.getSeedManager().getLoot(), worldX, y, worldZ); + KList tables = getLootTables(engine, rx, block, mc, hasNativeLootTable(block)); try { Bukkit.getPluginManager().callEvent(new IrisLootEvent(engine, block, slot, tables)); if (tables.isEmpty()) return; InventoryHolder m = (InventoryHolder) block.getState(); - addItems(engine, false, m.getInventory(), rx, tables, slot, c.getWorld(), x, y, z, 15); + addItems(engine, false, m.getInventory(), tables, slot, c.getWorld(), worldX, y, worldZ); } catch (Throwable e) { IrisLogging.reportError(e); @@ -289,6 +297,63 @@ public final class EngineBukkitOps { } } + public static boolean isCanonicalContainer(Block block) { + if (!(block.getBlockData() instanceof Chest chest) || chest.getType() == Chest.Type.SINGLE) { + return true; + } + BlockFace connectedFace = connectedFace(chest); + int connectedX = block.getX() + connectedFace.getModX(); + int connectedZ = block.getZ() + connectedFace.getModZ(); + return block.getX() < connectedX || block.getX() == connectedX && block.getZ() <= connectedZ; + } + + public static boolean hasNativeLootTable(Block block) { + if (hasNativeLootTableAt(block)) { + return true; + } + if (!(block.getBlockData() instanceof Chest chest) || chest.getType() == Chest.Type.SINGLE) { + return false; + } + BlockFace connectedFace = connectedFace(chest); + int connectedX = block.getX() + connectedFace.getModX(); + int connectedZ = block.getZ() + connectedFace.getModZ(); + if (!block.getWorld().isChunkLoaded(connectedX >> 4, connectedZ >> 4)) { + return false; + } + return hasNativeLootTableAt(block.getWorld().getBlockAt(connectedX, block.getY(), connectedZ)); + } + + private static boolean hasNativeLootTableAt(Block block) { + BlockState state = block.getState(); + return state instanceof Lootable lootable && lootable.getLootTable() != null; + } + + private static BlockFace connectedFace(Chest chest) { + return chest.getType() == Chest.Type.LEFT + ? clockwise(chest.getFacing()) + : counterClockwise(chest.getFacing()); + } + + private static BlockFace clockwise(BlockFace face) { + return switch (face) { + case NORTH -> BlockFace.EAST; + case EAST -> BlockFace.SOUTH; + case SOUTH -> BlockFace.WEST; + case WEST -> BlockFace.NORTH; + default -> BlockFace.SELF; + }; + } + + private static BlockFace counterClockwise(BlockFace face) { + return switch (face) { + case NORTH -> BlockFace.WEST; + case WEST -> BlockFace.SOUTH; + case SOUTH -> BlockFace.EAST; + case EAST -> BlockFace.NORTH; + default -> BlockFace.SELF; + }; + } + public static void scramble(Engine engine, Inventory inventory, RNG rng) { org.bukkit.inventory.ItemStack[] items = inventory.getContents(); org.bukkit.inventory.ItemStack[] nitems = new org.bukkit.inventory.ItemStack[inventory.getSize()]; @@ -328,22 +393,29 @@ public final class EngineBukkitOps { public static KList getLootTables(Engine engine, RNG rng, Block b) { MantleChunk mc = engine.getMantle().getMantle().getChunk(b.getChunk()).use(); try { - return getLootTables(engine, rng, b, mc); + return getLootTables(engine, rng, b, mc, hasNativeLootTable(b)); } finally { mc.release(); } } public static KList getLootTables(Engine engine, RNG rng, Block b, MantleChunk mc) { + return getLootTables(engine, rng, b, mc, hasNativeLootTable(b)); + } + + public static KList getLootTables(Engine engine, RNG rng, Block b, MantleChunk mc, boolean nativeLootDefined) { int rx = b.getX(); int rz = b.getZ(); int ry = b.getY() - engine.getWorld().minHeight(); - double he = engine.getComplex().getHeightStream().get(rx, rz); KList tables = new KList<>(); + boolean objectLootDefined = nativeLootDefined; PlacedObject po = engine.getObjectPlacement(rx, ry, rz, mc); if (po != null && po.getPlacement() != null) { if (BukkitBlockResolution.isStorageChest(b.getBlockData())) { + objectLootDefined = objectLootDefined + || po.getPlacement().getLoot().isNotEmpty() + || po.getPlacement().getVanillaLoot().isNotEmpty(); IrisLootTable table = po.getPlacement().getTable( BukkitBlockState.of(b.getBlockData()), engine.getData(), rng); if (table != null) { @@ -355,57 +427,55 @@ public final class EngineBukkitOps { } } - IrisRegion region = engine.getComplex().getRegionStream().get(rx, rz); - IrisBiome biomeSurface = engine.getComplex().getTrueBiomeStream().get(rx, rz); - IrisBiome biomeUnder = ry < he ? engine.getCaveBiome(rx, ry, rz) : biomeSurface; - - double multiplier = 1D * engine.getDimension().getLoot().getMultiplier() * region.getLoot().getMultiplier() * biomeSurface.getLoot().getMultiplier() * biomeUnder.getLoot().getMultiplier(); - boolean fallback = tables.isEmpty(); - engine.injectTables(tables, engine.getDimension().getLoot(), fallback); - engine.injectTables(tables, region.getLoot(), fallback); - engine.injectTables(tables, biomeSurface.getLoot(), fallback); - engine.injectTables(tables, biomeUnder.getLoot(), fallback); - - if (tables.isNotEmpty()) { - int target = (int) Math.round(tables.size() * multiplier); - - while (tables.size() < target && tables.isNotEmpty()) { - tables.add(tables.get(rng.i(tables.size() - 1))); - } - - while (tables.size() > target && tables.isNotEmpty()) { - tables.remove(rng.i(tables.size() - 1)); - } - } + LootResolver.resolveEnvironmentSources( + tables, + engine, + rng, + rx, + ry, + rz, + objectLootDefined, + table -> table + ); return tables; } - public static void addItems(Engine engine, boolean debug, Inventory inv, RNG rng, KList tables, InventorySlotType slot, World world, int x, int y, int z, int mgf) { + public static void addItems(Engine engine, boolean debug, Inventory inv, KList tables, InventorySlotType slot, World world, int x, int y, int z) { KList items = new KList<>(); for (IrisLootTable i : tables) { if (i == null) continue; - items.addAll(i.getLoot(debug, rng, slot, world, x, y, z)); + items.addAll(i.getLoot(debug, engine.getSeedManager().getLoot(), slot, world, x, y, z)); } if (IrisLootEvent.callLootEvent(items, inv, world, x, y, z)) return; if (PaperLib.isPaper() && engine.getWorld().hasPlatformWorld()) { PaperLib.getChunkAtAsync(BukkitWorldBinding.world(engine.getWorld()), x >> 4, z >> 4).thenAccept((c) -> { - Runnable r = () -> { + Runnable mutation = () -> { for (ItemStack i : items) { inv.addItem(i); } - scramble(engine, inv, rng); + scramble(engine, inv, LootResolver.containerRng(engine.getSeedManager().getLoot(), x, y, z)); }; - if (Bukkit.isPrimaryThread()) { - r.run(); - } else { - J.s(r); + LootMutationTask task = new LootMutationTask(world, x >> 4, z >> 4, mutation); + boolean scheduled = dispatchLootMutation( + task, + J.isFolia(), + Bukkit::isPrimaryThread, + regionTask -> J.runRegion( + regionTask.world(), + regionTask.chunkX(), + regionTask.chunkZ(), + regionTask.mutation()), + J::s + ); + if (!scheduled) { + IrisLogging.error("Failed to schedule loot inventory mutation for " + world.getName() + "@" + (x >> 4) + "," + (z >> 4) + "."); } }); } else { @@ -413,10 +483,28 @@ public final class EngineBukkitOps { inv.addItem(i); } - scramble(engine, inv, rng); + scramble(engine, inv, LootResolver.containerRng(engine.getSeedManager().getLoot(), x, y, z)); } } + static boolean dispatchLootMutation( + LootMutationTask task, + boolean folia, + BooleanSupplier primaryThread, + Predicate regionScheduler, + Consumer syncScheduler) { + if (folia) { + return regionScheduler.test(task); + } + + if (primaryThread.getAsBoolean()) { + task.mutation().run(); + } else { + syncScheduler.accept(task.mutation()); + } + return true; + } + public static IrisBiome getBiome(Engine engine, Location l) { return engine.getBiome(l.getBlockX(), l.getBlockY() - engine.getWorld().minHeight(), l.getBlockZ()); } @@ -550,4 +638,7 @@ public final class EngineBukkitOps { return false; } } + + record LootMutationTask(World world, int chunkX, int chunkZ, Runnable mutation) { + } } diff --git a/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java b/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java index a444b831c..721e14119 100644 --- a/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java +++ b/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java @@ -3,6 +3,7 @@ package art.arcane.iris.util.project.context; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.EngineMetrics; +import art.arcane.iris.engine.object.FloatingIslandBoundarySampler; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.spi.PlatformBlockState; @@ -24,6 +25,7 @@ public class ChunkContext { private final ChunkedDataCache rock; private final ChunkedDataCache fluid; private final ChunkedDataCache region; + private final FloatingIslandBoundarySampler floatingIslandBoundarySampler; public ChunkContext(int x, int z, IrisComplex complex) { this(x, z, complex, 0L, true, PrefillPlan.NO_CAVE, null); @@ -53,6 +55,7 @@ public class ChunkContext { this.rock = new ChunkedDataCache<>(complex.getRockStream(), x, z, cache); this.fluid = new ChunkedDataCache<>(complex.getFluidStream(), x, z, cache); this.region = new ChunkedDataCache<>(complex.getRegionStream(), x, z, cache); + this.floatingIslandBoundarySampler = new FloatingIslandBoundarySampler((wx, wz) -> complex.getTrueBiomeStream().get(wx, wz)); if (cache) { PrefillPlan resolvedPlan = prefillPlan == null ? PrefillPlan.NO_CAVE : prefillPlan; @@ -121,6 +124,10 @@ public class ChunkContext { return complex; } + public FloatingIslandBoundarySampler getFloatingIslandBoundarySampler() { + return floatingIslandBoundarySampler; + } + public long getGenerationSessionId() { return generationSessionId; } diff --git a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java new file mode 100644 index 000000000..2725e0b6d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.core.datapack; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class DatapackIngestServiceTest { + @Test + public void manifestRemainsPendingWhenAnyPackFails() { + DatapackIngestService.Entry first = new DatapackIngestService.Entry(); + DatapackIngestService.Entry second = new DatapackIngestService.Entry(); + + boolean completed = DatapackIngestService.markStructuresImportedIfComplete( + List.of(first, second), 2, 1); + + assertFalse(completed); + assertFalse(first.structuresImported); + assertFalse(second.structuresImported); + } + + @Test + public void manifestRemainsPendingWhenNoPackWasAttempted() { + DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + + boolean completed = DatapackIngestService.markStructuresImportedIfComplete( + List.of(entry), 0, 0); + + assertFalse(completed); + assertFalse(entry.structuresImported); + } + + @Test + public void manifestCompletesOnlyAfterEveryAttemptedPackSucceeds() { + DatapackIngestService.Entry first = new DatapackIngestService.Entry(); + DatapackIngestService.Entry second = new DatapackIngestService.Entry(); + + boolean completed = DatapackIngestService.markStructuresImportedIfComplete( + List.of(first, second), 2, 2); + + assertTrue(completed); + assertTrue(first.structuresImported); + assertTrue(second.structuresImported); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/nms/INMSBindingProbeCodesTest.java b/core/src/test/java/art/arcane/iris/core/nms/INMSBindingProbeCodesTest.java deleted file mode 100644 index 5d8be0b7b..000000000 --- a/core/src/test/java/art/arcane/iris/core/nms/INMSBindingProbeCodesTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package art.arcane.iris.core.nms; - -import org.junit.Test; - -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -public class INMSBindingProbeCodesTest { - @Test - public void skipsSyntheticBukkitBindingWhenNmsIsEnabled() { - List probeCodes = NmsBindingProbeSupport.getBindingProbeCodes("BUKKIT", false, List.of("v26_2_R1")); - - assertEquals(List.of("v26_2_R1"), probeCodes); - } - - @Test - public void leavesBukkitFallbackEmptyWhenNmsIsDisabled() { - List probeCodes = NmsBindingProbeSupport.getBindingProbeCodes("BUKKIT", true, List.of("v26_2_R1")); - - assertTrue(probeCodes.isEmpty()); - } - - @Test - public void keepsConcreteBindingCodesAsPrimaryProbe() { - List probeCodes = NmsBindingProbeSupport.getBindingProbeCodes("v26_2_R1", false, List.of("v26_2_R1")); - - assertEquals(List.of("v26_2_R1"), probeCodes); - } -} diff --git a/core/src/test/java/art/arcane/iris/core/nms/NmsBindingSelectorTest.java b/core/src/test/java/art/arcane/iris/core/nms/NmsBindingSelectorTest.java new file mode 100644 index 000000000..ebea5ed2e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/nms/NmsBindingSelectorTest.java @@ -0,0 +1,31 @@ +package art.arcane.iris.core.nms; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class NmsBindingSelectorTest { + @Test + public void selectsOnlyTheExactSupportedRevision() { + MinecraftVersion version = MinecraftVersion.fromBukkitVersion("26.2-R0.1-SNAPSHOT"); + + assertEquals("v26_2_R1", NmsBindingSelector.select(version)); + } + + @Test + public void rejectsUnsupportedVersionsWithoutProbingAnotherRevision() { + MinecraftVersion version = MinecraftVersion.fromBukkitVersion("26.3-R0.1-SNAPSHOT"); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> NmsBindingSelector.select(version)); + + assertTrue(failure.getMessage().contains("26.3")); + } + + @Test + public void rejectsMissingVersionDetection() { + assertThrows(IllegalStateException.class, () -> NmsBindingSelector.select(null)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/nms/v1X/NMSBinding1XWorldGenerationTest.java b/core/src/test/java/art/arcane/iris/core/nms/v1X/NMSBinding1XWorldGenerationTest.java new file mode 100644 index 000000000..87caeb608 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/nms/v1X/NMSBinding1XWorldGenerationTest.java @@ -0,0 +1,54 @@ +package art.arcane.iris.core.nms.v1X; + +import art.arcane.iris.core.nms.INMSBinding; +import org.bukkit.World; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class NMSBinding1XWorldGenerationTest { + @Test + public void limitedBindingRefusesIrisWorldInitialization() { + NMSBinding1X binding = new NMSBinding1X(); + + assertFalse(binding.supportsIrisWorldGeneration()); + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> binding.inject(1L, null, null)); + + assertTrue(error.getMessage().contains("general.disableNMS=true")); + } + + @Test + public void structureRegistryHooksAreRequiredBindingMethods() throws NoSuchMethodException { + assertFalse(INMSBinding.class.getMethod("getStructureKeys").isDefault()); + assertFalse(INMSBinding.class.getMethod("getStructureSetKeys").isDefault()); + assertFalse(INMSBinding.class.getMethod("getReachableStructureKeys", World.class).isDefault()); + assertFalse(INMSBinding.class.getMethod("getStructureBiomeKeys", String.class).isDefault()); + assertFalse(INMSBinding.class.getMethod("getPossibleBiomeKeys", World.class).isDefault()); + } + + @Test + public void limitedBindingRefusesStructureRegistryHooks() { + NMSBinding1X binding = new NMSBinding1X(); + + IllegalStateException structureKeys = assertThrows( + IllegalStateException.class, binding::getStructureKeys); + IllegalStateException structureSetKeys = assertThrows( + IllegalStateException.class, binding::getStructureSetKeys); + IllegalStateException reachableKeys = assertThrows( + IllegalStateException.class, () -> binding.getReachableStructureKeys(null)); + IllegalStateException structureBiomeKeys = assertThrows( + IllegalStateException.class, () -> binding.getStructureBiomeKeys("minecraft:village")); + IllegalStateException possibleBiomeKeys = assertThrows( + IllegalStateException.class, () -> binding.getPossibleBiomeKeys(null)); + + assertTrue(structureKeys.getMessage().contains("read registered structure keys")); + assertTrue(structureSetKeys.getMessage().contains("read registered structure-set keys")); + assertTrue(reachableKeys.getMessage().contains("resolve reachable structures")); + assertTrue(structureBiomeKeys.getMessage().contains("resolve structure biome keys")); + assertTrue(possibleBiomeKeys.getMessage().contains("resolve possible biome keys")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidationRegistryTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidationRegistryTest.java new file mode 100644 index 000000000..0829ca16f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidationRegistryTest.java @@ -0,0 +1,74 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.pack; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class PackValidationRegistryTest { + @Before + public void setUp() { + PackValidationRegistry.clear(); + } + + @After + public void tearDown() { + PackValidationRegistry.clear(); + } + + @Test + public void missingValidationFailsClosed() { + assertBroken("overworld", "has not completed"); + } + + @Test + public void blockingValidationFailsClosedWithOriginalReasons() { + PackValidationRegistry.publish(new PackValidationResult( + "overworld", List.of("replacement graph is not runtime-viable"), List.of(), 1L)); + + assertBroken("overworld", "replacement graph is not runtime-viable"); + } + + @Test + public void successfulValidationAuthorizesUse() { + PackValidationResult result = new PackValidationResult( + "overworld", List.of(), List.of("warning"), 1L); + PackValidationRegistry.publish(result); + + assertEquals(result, PackValidationRegistry.requireLoadable("overworld")); + } + + private void assertBroken(String pack, String expectedReason) { + try { + PackValidationRegistry.requireLoadable(pack); + } catch (BrokenPackException e) { + assertEquals(pack, e.getPackName()); + assertTrue(e.getReasons().toString(), e.getReasons().stream().anyMatch( + reason -> reason.contains(expectedReason))); + return; + } + throw new AssertionError("Expected pack validation to fail closed"); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorImportedStructurePolicyTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorImportedStructurePolicyTest.java new file mode 100644 index 000000000..929fa1fd3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorImportedStructurePolicyTest.java @@ -0,0 +1,67 @@ +package art.arcane.iris.core.pack; + +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class PackValidatorImportedStructurePolicyTest { + @Test + public void denyOnlyPolicyAcceptsDisabledKeysAndAdjustments() { + JSONObject policy = new JSONObject() + .put("disabled", new JSONArray().put("minecraft:stronghold")) + .put("adjustments", new JSONArray().put(new JSONObject() + .put("match", new JSONArray().put("minecraft:village")))); + List errors = validate(policy); + + assertTrue(errors.isEmpty()); + } + + @Test + public void legacyModeAndEnabledWhitelistAreRejected() { + JSONObject policy = new JSONObject() + .put("mode", "ALL_OFF") + .put("enabled", new JSONArray().put("minecraft:monument")); + List errors = validate(policy); + + assertEquals(2, errors.size()); + assertTrue(errors.get(0).contains("importedStructures.mode is not supported")); + assertTrue(errors.get(1).contains("importedStructures.enabled is not supported")); + } + + @Test + public void explicitNullsAndWrongShapesAreRejected() { + JSONObject policy = new JSONObject() + .put("disabled", JSONObject.NULL) + .put("adjustments", new JSONObject()); + List errors = validate(policy); + + assertEquals(2, errors.size()); + assertTrue(errors.stream().anyMatch(error -> error.contains("'disabled' must be an array"))); + assertTrue(errors.stream().anyMatch(error -> error.contains("adjustments must be an array"))); + } + + @Test + public void explicitNullPolicyIsRejectedWhileOmissionUsesDefaults() { + List missingErrors = new ArrayList<>(); + PackValidator.validateImportedStructurePolicy("overworld", new JSONObject(), missingErrors); + assertTrue(missingErrors.isEmpty()); + + List nullErrors = new ArrayList<>(); + PackValidator.validateImportedStructurePolicy("overworld", + new JSONObject().put("importedStructures", JSONObject.NULL), nullErrors); + assertEquals(List.of("Dimension 'overworld' importedStructures must be an object."), nullErrors); + } + + private List validate(JSONObject policy) { + List errors = new ArrayList<>(); + PackValidator.validateImportedStructurePolicy("overworld", + new JSONObject().put("importedStructures", policy), errors); + return errors; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorLootTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorLootTest.java new file mode 100644 index 000000000..196e82f1c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorLootTest.java @@ -0,0 +1,129 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.engine.object.IrisLoot; +import art.arcane.iris.engine.object.IrisLootTable; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class PackValidatorLootTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void acceptsValidLootTableAndReference() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "loot/global/clutter.json", "{\"name\":\"Clutter\",\"minPicked\":1,\"maxPicked\":2," + + "\"loot\":[{\"type\":\"stick\",\"rarity\":2,\"minAmount\":1,\"maxAmount\":3}]}"); + write(pack, "dimensions/main.json", "{\"loot\":{\"mode\":\"FALLBACK\",\"multiplier\":0.5," + + "\"tables\":[\"global/clutter\"]}}"); + + assertTrue(PackValidator.validateLootGraph(pack).isEmpty()); + } + + @Test + public void rejectsMissingLootReference() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"loot\":{\"tables\":[\"missing\"]}}"); + + assertEquals(List.of("Dimension 'main'.loot.tables[0] references missing loot table 'missing'."), + PackValidator.validateLootGraph(pack)); + } + + @Test + public void rejectsInvalidLootBoundsAndEmptyEntries() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "loot/broken.json", "{\"rarity\":0,\"minPicked\":4,\"maxPicked\":2,\"maxTries\":0," + + "\"loot\":[{\"type\":\"\",\"rarity\":0,\"minAmount\":3,\"maxAmount\":1," + + "\"enchantments\":[{\"enchantment\":\"\",\"minLevel\":4,\"maxLevel\":2,\"chance\":2}]}]}"); + + List errors = PackValidator.validateLootGraph(pack); + + assertTrue(errors.stream().anyMatch(error -> error.contains(".rarity must be at least 1"))); + assertTrue(errors.stream().anyMatch(error -> error.contains(".minPicked must not exceed"))); + assertTrue(errors.stream().anyMatch(error -> error.contains(".maxTries must be at least 1"))); + assertTrue(errors.stream().anyMatch(error -> error.contains(".type must not be blank"))); + assertTrue(errors.stream().anyMatch(error -> error.contains(".minAmount must not exceed"))); + assertTrue(errors.stream().anyMatch(error -> error.contains(".minLevel must not exceed"))); + assertTrue(errors.stream().anyMatch(error -> error.contains(".chance must be a finite number from 0 to 1"))); + } + + @Test + public void rejectsInvalidReferenceModeAndMultiplier() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"loot\":{\"mode\":\"MERGE\",\"multiplier\":-1,\"tables\":[]}}"); + + List errors = PackValidator.validateLootGraph(pack); + + assertEquals(2, errors.size()); + assertTrue(errors.get(0).contains(".mode must be")); + assertTrue(errors.get(1).contains(".multiplier must be")); + } + + @Test + public void enforcesPublishedLootMultiplierCap() throws Exception { + File acceptedPack = temporaryFolder.newFolder("accepted-pack"); + write(acceptedPack, "dimensions/main.json", "{\"loot\":{\"multiplier\":16,\"tables\":[]}}"); + assertTrue(PackValidator.validateLootGraph(acceptedPack).isEmpty()); + + File rejectedPack = temporaryFolder.newFolder("rejected-pack"); + write(rejectedPack, "dimensions/main.json", "{\"loot\":{\"multiplier\":16.01,\"tables\":[]}}"); + assertEquals(List.of("Dimension 'main'.loot.multiplier must be a finite number from 0 to 16."), + PackValidator.validateLootGraph(rejectedPack)); + } + + @Test + public void acceptsPublishedLootWorkBounds() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "loot/boundary.json", "{\"minPicked\":64,\"maxPicked\":64,\"maxTries\":256," + + "\"loot\":[{\"type\":\"stone\",\"minAmount\":64,\"maxAmount\":64}]}"); + + assertTrue(PackValidator.validateLootGraph(pack).isEmpty()); + } + + @Test + public void rejectsLootWorkBoundsAbovePublishedCaps() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "loot/excessive.json", "{\"minPicked\":65,\"maxPicked\":65,\"maxTries\":257," + + "\"loot\":[{\"type\":\"stone\",\"minAmount\":65,\"maxAmount\":65}]}"); + + List errors = PackValidator.validateLootGraph(pack); + + assertTrue(errors.contains("Loot table 'excessive'.minPicked must be at most 64.")); + assertTrue(errors.contains("Loot table 'excessive'.maxPicked must be at most 64.")); + assertTrue(errors.contains("Loot table 'excessive'.maxTries must be at most 256.")); + assertTrue(errors.contains("Loot table 'excessive'.loot[0].minAmount must be at most 64.")); + assertTrue(errors.contains("Loot table 'excessive'.loot[0].maxAmount must be at most 64.")); + } + + @Test + public void lootWorkCapsArePublishedInSchemaMetadata() throws Exception { + assertSchemaMaximum(IrisLootTable.class, "minPicked", IrisLootTable.MAX_PICKED); + assertSchemaMaximum(IrisLootTable.class, "maxPicked", IrisLootTable.MAX_PICKED); + assertSchemaMaximum(IrisLootTable.class, "maxTries", IrisLootTable.MAX_TRIES); + assertSchemaMaximum(IrisLoot.class, "minAmount", IrisLoot.MAX_AMOUNT); + assertSchemaMaximum(IrisLoot.class, "maxAmount", IrisLoot.MAX_AMOUNT); + } + + private void assertSchemaMaximum(Class type, String fieldName, int expected) throws Exception { + Field field = type.getDeclaredField(fieldName); + MaxNumber maximum = field.getAnnotation(MaxNumber.class); + assertEquals(expected, maximum.value(), 0D); + } + + private void write(File root, String relative, String content) throws Exception { + File file = new File(root, relative); + Files.createDirectories(file.toPath().getParent()); + Files.writeString(file.toPath(), content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorRemovedWorldgenFieldTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorRemovedWorldgenFieldTest.java new file mode 100644 index 000000000..007aef0c7 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorRemovedWorldgenFieldTest.java @@ -0,0 +1,54 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PackValidatorRemovedWorldgenFieldTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void rejectsFluidBodiesAcrossEveryFormerHost() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"fluidBodies\":{}}"); + write(pack, "regions/nested/region.json", "{\"fluidBodies\":null}"); + write(pack, "biomes/nested/biome.json", "{\"fluidBodies\":{\"lakes\":[]}}"); + + assertEquals(List.of( + "Dimension 'main' declares removed field 'fluidBodies'. Remove it because fluid-body generation is not supported.", + "Region 'nested/region' declares removed field 'fluidBodies'. Remove it because fluid-body generation is not supported.", + "Biome 'nested/biome' declares removed field 'fluidBodies'. Remove it because fluid-body generation is not supported." + ), PackValidator.validateRemovedWorldgenFields(pack)); + } + + @Test + public void removedFluidBodiesBlocksFullPackValidation() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"regions\":[\"region\"]}"); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", "{\"name\":\"Biome\",\"fluidBodies\":{}}"); + + PackValidationResult result = PackValidator.validate(pack); + + assertFalse(result.isLoadable()); + assertTrue(result.getBlockingErrors().contains( + "Biome 'biome' declares removed field 'fluidBodies'. Remove it because fluid-body generation is not supported.")); + } + + private void write(File root, String relative, String content) throws Exception { + Path path = root.toPath().resolve(relative); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureGraphTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureGraphTest.java index 3952762fa..d75e11d79 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureGraphTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureGraphTest.java @@ -4,13 +4,17 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import java.io.DataOutputStream; import java.io.File; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; +import java.util.Set; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class PackValidatorStructureGraphTest { @@ -30,6 +34,18 @@ public class PackValidatorStructureGraphTest { assertTrue(PackValidator.validateStructureGraph(pack).isEmpty()); } + @Test + public void collectsOnlyStructuresReferencedByRuntimePlacements() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"structures\":[{\"structures\":[\"active\"]}]}"); + write(pack, "regions/forest.json", "{\"structures\":[{\"structures\":[\"regional\"]}]}"); + write(pack, "structures/active.json", "{}"); + write(pack, "structures/regional.json", "{}"); + write(pack, "structures/library.json", "{}"); + + assertEquals(Set.of("active", "regional"), PackValidator.collectPlacedStructureKeys(pack)); + } + @Test public void reportsMissingReferencesInDeterministicGraphOrder() throws Exception { File pack = temporaryFolder.newFolder("pack"); @@ -101,9 +117,232 @@ public class PackValidatorStructureGraphTest { ), PackValidator.validateStructureGraph(pack)); } + @Test + public void acceptsViableDimensionLevelNativeReplacement() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"structures\":[{\"structures\":[\"city\"]," + + "\"nativeSuppression\":\"REPLACE_SOURCE\"}]}"); + write(pack, "structures/city.json", "{\"vanillaSource\":\"minecraft:ancient_city\"}"); + + assertTrue(PackValidator.validateNativeStructureReplacements( + pack, Set.of("city"), sampledEnvelope("city", 1, 0, 0)).isEmpty()); + } + + @Test + public void rejectsNonViableNativeReplacementWithoutFallingBack() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"structures\":[{\"structures\":[\"city\"]," + + "\"nativeSuppression\":\"REPLACE_SOURCE\"}]}"); + write(pack, "structures/city.json", "{\"vanillaSource\":\"minecraft:ancient_city\"}"); + + List errors = PackValidator.validateNativeStructureReplacements(pack, Set.of(), Map.of()); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("not runtime-viable"))); + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("will not be used as a fallback"))); + } + + @Test + public void rejectsReplacementWithoutValidVanillaSource() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"structures\":[{\"structures\":[\"city\"]," + + "\"nativeSuppression\":\"REPLACE_SOURCE\"}]}"); + write(pack, "structures/city.json", "{\"vanillaSource\":\"\"}"); + + List errors = PackValidator.validateNativeStructureReplacements( + pack, Set.of("city"), sampledEnvelope("city", 1, -1, 1)); + + assertTrue(errors.toString(), errors.stream().anyMatch( + message -> message.contains("vanillaSource is not a valid namespaced registry key"))); + } + + @Test + public void rejectsReplacementOutsideDimensionPlacement() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "regions/main.json", "{\"structures\":[{\"structures\":[\"city\"]," + + "\"nativeSuppression\":\"REPLACE_SOURCE\"}]}"); + write(pack, "structures/city.json", "{\"vanillaSource\":\"minecraft:ancient_city\"}"); + + List errors = PackValidator.validateNativeStructureReplacements( + pack, Set.of("city"), sampledEnvelope("city", 1, -1, 1)); + + assertTrue(errors.toString(), errors.stream().anyMatch( + message -> message.contains("only valid on dimension-level placements"))); + } + + @Test + public void rejectsUndergroundReplacementBelowDimensionWritableRange() throws Exception { + File pack = replacementPack("below", "STRUCTURE_PIECE", true, -80, -80); + + List errors = PackValidator.validateNativeStructureReplacements( + pack, Set.of("city"), sampledEnvelope("city", 1, -5, 4)); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("sampled seed 0") + && message.contains("piece envelope -5..4") + && message.contains("placement band -80..-80") + && message.contains("writable world -63..319") + && message.contains("will not be used as a fallback"))); + } + + @Test + public void rejectsUndergroundReplacementAboveDimensionWritableRange() throws Exception { + File pack = replacementPack("above", "STRUCTURE_PIECE", true, 318, 318); + + List errors = PackValidator.validateNativeStructureReplacements( + pack, Set.of("city"), sampledEnvelope("city", 1, -5, 4)); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("sampled seed 0") + && message.contains("piece envelope -5..4") + && message.contains("placement band 318..318") + && message.contains("writable world -63..319"))); + } + + @Test + public void surfaceAlignsMultiPieceEnvelopeBeforeCheckingWorldBounds() throws Exception { + File pack = replacementPack("surface-aligned", "CENTER_HEIGHT", false, 290, 290); + + List errors = PackValidator.validateNativeStructureReplacements( + pack, Set.of("city"), sampledEnvelope("city", 2, -30, 10)); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("surface-aligned") + && message.contains("piece envelope 0..40") + && message.contains("placement band 290..290"))); + } + + @Test + public void doesNotApplyExactYEnvelopeGateToSingleSurfacePiece() throws Exception { + File pack = replacementPack("surface-single", "CENTER_HEIGHT", false, -63, 319); + + List errors = PackValidator.validateNativeStructureReplacements( + pack, Set.of("city"), sampledEnvelope("city", 1, -500, 500)); + + assertTrue(errors.toString(), errors.isEmpty()); + } + + @Test + public void validatesEverySurfaceExactYAnchorAgainstWritableWorldBounds() throws Exception { + File pack = replacementPack("surface-exact-range", "STRUCTURE_PIECE", false, -63, 319); + + List errors = PackValidator.validateNativeStructureReplacements( + pack, Set.of("city"), sampledEnvelope("city", 1, -5, 4)); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("sampled seed 0") + && message.contains("piece envelope -5..4") + && message.contains("placement band -63..319") + && message.contains("writable world -63..319"))); + } + + @Test + public void acceptsSurfaceExactYWhenEveryConfiguredAnchorFits() throws Exception { + File pack = replacementPack("surface-exact-safe", "STRUCTURE_PIECE", false, -58, 315); + + List errors = PackValidator.validateNativeStructureReplacements( + pack, Set.of("city"), sampledEnvelope("city", 1, -5, 4)); + + assertTrue(errors.toString(), errors.isEmpty()); + } + + @Test + public void fullPackValidationRejectsReplacementOutsideDimensionEnvelope() throws Exception { + File pack = replacementPack("full-pack-below", "STRUCTURE_PIECE", true, -80, -80); + write(pack, "dimensions/main.json", "{\"dimensionHeight\":{\"min\":-64,\"max\":320}," + + "\"regions\":[\"region\"],\"structures\":[{\"structures\":[\"city\"]," + + "\"nativeSuppression\":\"REPLACE_SOURCE\",\"underground\":true," + + "\"minHeight\":-80,\"maxHeight\":-80}]}"); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}"); + write(pack, "structures/city.json", "{\"startPool\":\"city/start\"," + + "\"vanillaSource\":\"minecraft:ancient_city\",\"placeMode\":\"STRUCTURE_PIECE\"}"); + write(pack, "jigsaw-pools/city/start.json", "{\"pieces\":[{\"piece\":\"city/start\"}]}"); + write(pack, "jigsaw-pieces/city/start.json", "{\"object\":\"city/start\",\"connectors\":[]}"); + writeObjectHeader(pack, "objects/city/start.iob", 3, 9, 3); + + PackValidationResult result = PackValidator.validate(pack); + + assertTrue(result.getBlockingErrors().toString(), result.getBlockingErrors().stream().anyMatch( + message -> message.contains("REPLACE_SOURCE structure 'city'") + && message.contains("cannot fit placement band -80..-80") + && message.contains("will not be used as a fallback"))); + } + + @Test + public void fullPackValidationPreservesMalformedActiveObjectPathAndReason() throws Exception { + File pack = temporaryFolder.newFolder("malformed-active-object"); + write(pack, "dimensions/main.json", "{\"dimensionHeight\":{\"min\":-64,\"max\":320}," + + "\"regions\":[\"region\"],\"structures\":[{\"structures\":[\"castle\"]}]}"); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}"); + write(pack, "structures/castle.json", "{\"startPool\":\"castle/start\"}"); + write(pack, "jigsaw-pools/castle/start.json", + "{\"pieces\":[{\"piece\":\"castle/start\"}]}"); + write(pack, "jigsaw-pieces/castle/start.json", + "{\"object\":\"castle/start\",\"connectors\":[]}"); + Path object = pack.toPath().resolve("objects/castle/start.iob"); + Files.createDirectories(object.getParent()); + try (DataOutputStream output = new DataOutputStream(Files.newOutputStream(object))) { + output.writeInt(1); + output.writeInt(1); + output.writeInt(1); + } + + PackValidationResult result = PackValidator.validate(pack); + String expected = "Malformed Iris object resource " + object + ": EOFException"; + + assertTrue(result.getBlockingErrors().toString(), result.getBlockingErrors().contains(expected)); + assertFalse(result.getBlockingErrors().toString(), result.getBlockingErrors().stream().anyMatch( + message -> message.contains("references missing object 'castle/start'"))); + } + + private File replacementPack( + String name, + String placeMode, + boolean underground, + int minimumY, + int maximumY + ) throws Exception { + File pack = temporaryFolder.newFolder(name); + write(pack, "dimensions/main.json", "{\"dimensionHeight\":{\"min\":-64,\"max\":320}," + + "\"structures\":[{\"structures\":[\"city\"],\"nativeSuppression\":\"REPLACE_SOURCE\"," + + "\"underground\":" + underground + ",\"minHeight\":" + minimumY + + ",\"maxHeight\":" + maximumY + "}]}"); + write(pack, "structures/city.json", "{\"vanillaSource\":\"minecraft:ancient_city\"," + + "\"placeMode\":\"" + placeMode + "\"}"); + return pack; + } + + private Map> sampledEnvelope( + String structureKey, + int pieceCount, + int minimumYOffset, + int maximumYOffset + ) { + return Map.of(structureKey, List.of( + new StructureGraphPackValidator.SampledVerticalEnvelope( + 0L, pieceCount, minimumYOffset, maximumYOffset))); + } + private void write(File root, String relative, String content) throws Exception { Path path = root.toPath().resolve(relative); Files.createDirectories(path.getParent()); Files.writeString(path, content, StandardCharsets.UTF_8); } + + private void writeObjectHeader( + File root, + String relative, + int width, + int height, + int depth + ) throws Exception { + Path path = root.toPath().resolve(relative); + Files.createDirectories(path.getParent()); + try (DataOutputStream output = new DataOutputStream(Files.newOutputStream(path))) { + output.writeInt(width); + output.writeInt(height); + output.writeInt(depth); + output.writeUTF("Iris V2 IOB;"); + output.writeShort(0); + output.writeInt(0); + output.writeInt(0); + } + } } diff --git a/core/src/test/java/art/arcane/iris/core/pack/StructureGraphPackValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/StructureGraphPackValidatorTest.java new file mode 100644 index 000000000..e91f30bf4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/StructureGraphPackValidatorTest.java @@ -0,0 +1,347 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.DataOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class StructureGraphPackValidatorTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void reportsCompilerOnlyWeightAndConnectorBoundsFailures() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + write(pack, "structures/castle.json", "{\"startPool\":\"castle/start\"}"); + write(pack, "jigsaw-pools/castle/start.json", + "{\"pieces\":[{\"piece\":\"castle/start\",\"weight\":0}]}"); + write(pack, "jigsaw-pieces/castle/start.json", + "{\"object\":\"castle/start\",\"connectors\":[{\"position\":{\"x\":2,\"y\":0,\"z\":0}," + + "\"direction\":\"NORTH_NEGATIVE_Z\",\"pool\":\"castle/start\",\"name\":\"path\",\"targetName\":\"path\"}]}"); + writeObjectHeader(pack.resolve("objects/castle/start.iob"), 1, 1, 1); + + StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); + + assertTrue(validation.errors().stream().anyMatch(message -> message.contains("non-positive weight 0"))); + assertTrue(validation.errors().stream().anyMatch(message -> message.contains("outside object bounds"))); + } + + @Test + public void reportsFallbackCyclesBeforeRuntime() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + write(pack, "structures/castle.json", "{\"startPool\":\"castle/a\"}"); + write(pack, "jigsaw-pools/castle/a.json", + "{\"pieces\":[{\"piece\":\"castle/piece\"}],\"fallback\":\"castle/b\"}"); + write(pack, "jigsaw-pools/castle/b.json", + "{\"pieces\":[{\"piece\":\"castle/piece\"}],\"fallback\":\"castle/a\"}"); + write(pack, "jigsaw-pieces/castle/piece.json", + "{\"object\":\"castle/piece\",\"connectors\":[]}"); + writeObjectHeader(pack.resolve("objects/castle/piece.iob"), 1, 1, 1); + + StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); + + assertTrue(validation.errors().stream().anyMatch(message -> message.contains("fallback cycle"))); + } + + @Test + public void blocksGraphsThatCanOnlyProducePartialAssemblies() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + write(pack, "structures/castle.json", "{\"startPool\":\"castle/start\",\"maxDepth\":2}"); + write(pack, "jigsaw-pools/castle/start.json", + "{\"pieces\":[{\"piece\":\"castle/start\"}]}"); + write(pack, "jigsaw-pools/castle/target.json", + "{\"pieces\":[{\"piece\":\"castle/target\"}]}"); + write(pack, "jigsaw-pieces/castle/start.json", + "{\"object\":\"castle/start\",\"connectors\":[{\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"direction\":\"NORTH_NEGATIVE_Z\",\"pool\":\"castle/target\",\"name\":\"source\",\"targetName\":\"door\"}]}"); + write(pack, "jigsaw-pieces/castle/target.json", + "{\"object\":\"castle/target\",\"rotatable\":false,\"connectors\":[{\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"direction\":\"NORTH_NEGATIVE_Z\",\"pool\":\"castle/target\",\"name\":\"door\",\"targetName\":\"unused\"}]}"); + writeObjectHeader(pack.resolve("objects/castle/start.iob"), 1, 1, 1); + writeObjectHeader(pack.resolve("objects/castle/target.iob"), 1, 1, 1); + + StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); + + assertTrue(validation.errors().stream().anyMatch( + message -> message.contains("does not produce a complete deterministic assembly"))); + } + + @Test + public void inactiveLibraryGraphDoesNotEnterTheRuntimeGate() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + write(pack, "structures/library.json", "{\"startPool\":\"library/start\",\"maxDepth\":2}"); + write(pack, "jigsaw-pools/library/start.json", + "{\"pieces\":[{\"piece\":\"library/start\"}]}"); + write(pack, "jigsaw-pools/library/target.json", + "{\"pieces\":[{\"piece\":\"library/target\"}]}"); + write(pack, "jigsaw-pieces/library/start.json", + "{\"object\":\"library/start\",\"connectors\":[{\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"direction\":\"NORTH_NEGATIVE_Z\",\"pool\":\"library/target\",\"name\":\"source\",\"targetName\":\"door\"}]}"); + write(pack, "jigsaw-pieces/library/target.json", + "{\"object\":\"library/target\",\"rotatable\":false,\"connectors\":[{\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"direction\":\"NORTH_NEGATIVE_Z\",\"pool\":\"library/target\",\"name\":\"door\",\"targetName\":\"unused\"}]}"); + writeObjectHeader(pack.resolve("objects/library/start.iob"), 1, 1, 1); + writeObjectHeader(pack.resolve("objects/library/target.iob"), 1, 1, 1); + + StructureGraphPackValidator.Validation validation = + StructureGraphPackValidator.validate(pack, Set.of()); + + assertTrue(validation.errors().toString(), validation.errors().isEmpty()); + assertTrue(validation.warnings().toString(), validation.warnings().isEmpty()); + } + + @Test + public void rejectsTruncatedObjectsBeforeRuntime() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + write(pack, "structures/castle.json", "{\"startPool\":\"castle/start\"}"); + write(pack, "jigsaw-pools/castle/start.json", + "{\"pieces\":[{\"piece\":\"castle/start\"}]}"); + write(pack, "jigsaw-pieces/castle/start.json", + "{\"object\":\"castle/start\",\"connectors\":[]}"); + Path object = pack.resolve("objects/castle/start.iob"); + writeTruncatedObjectHeader(object, 1, 1, 1); + + StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); + + assertEquals(List.of("Malformed Iris object resource " + object + + ": EOFException"), validation.errors()); + } + + @Test + public void doesNotReadMalformedObjectsOutsideTheActiveGraph() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + write(pack, "structures/library.json", "{\"startPool\":\"library/start\"}"); + write(pack, "jigsaw-pools/library/start.json", + "{\"pieces\":[{\"piece\":\"library/start\"}]}"); + write(pack, "jigsaw-pieces/library/start.json", + "{\"object\":\"library/start\",\"connectors\":[]}"); + writeTruncatedObjectHeader(pack.resolve("objects/library/start.iob"), 1, 1, 1); + + StructureGraphPackValidator.Validation validation = + StructureGraphPackValidator.validate(pack, Set.of()); + + assertTrue(validation.errors().toString(), validation.errors().isEmpty()); + } + + @Test + public void acceptsSignedObjectCoordinates() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + write(pack, "structures/castle.json", "{\"startPool\":\"castle/start\"}"); + write(pack, "jigsaw-pools/castle/start.json", + "{\"pieces\":[{\"piece\":\"castle/start\"}]}"); + write(pack, "jigsaw-pieces/castle/start.json", + "{\"object\":\"castle/start\",\"connectors\":[]}"); + writeSignedObject(pack.resolve("objects/castle/start.iob"), 18, 31, 41, -9, -15, -20); + + StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); + + assertFalse(validation.errors().toString(), + validation.errors().stream().anyMatch(message -> message.contains("missing object"))); + assertTrue(validation.errors().toString(), validation.errors().isEmpty()); + } + + @Test + public void capturesSampledVerticalEnvelopesFromRuntimeAssembly() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + write(pack, "structures/castle.json", "{\"startPool\":\"castle/start\"}"); + write(pack, "jigsaw-pools/castle/start.json", + "{\"pieces\":[{\"piece\":\"castle/start\"}]}"); + write(pack, "jigsaw-pieces/castle/start.json", + "{\"object\":\"castle/start\",\"connectors\":[]}"); + writeObjectHeader(pack.resolve("objects/castle/start.iob"), 3, 9, 3); + + StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); + List envelopes = + validation.sampledVerticalEnvelopes().get("castle"); + + assertTrue(validation.errors().toString(), validation.errors().isEmpty()); + assertEquals(16, envelopes.size()); + assertTrue(envelopes.stream().allMatch(envelope -> envelope.pieceCount() == 1 + && envelope.minimumYOffset() == -4 + && envelope.maximumYOffset() == 4)); + assertThrows(UnsupportedOperationException.class, + () -> envelopes.add(new StructureGraphPackValidator.SampledVerticalEnvelope(1L, 1, 0, 0))); + assertThrows(UnsupportedOperationException.class, + () -> validation.sampledVerticalEnvelopes().put("other", List.of())); + } + + @Test + public void treatsAnEmptyTargetPoolAsIntentionalTermination() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + write(pack, "structures/castle.json", "{\"startPool\":\"castle/start\"}"); + write(pack, "jigsaw-pools/castle/start.json", + "{\"pieces\":[{\"piece\":\"castle/start\"}]}"); + write(pack, "jigsaw-pools/castle/empty.json", "{\"pieces\":[]}"); + write(pack, "jigsaw-pieces/castle/start.json", + "{\"object\":\"castle/start\",\"connectors\":[{\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"direction\":\"NORTH_NEGATIVE_Z\",\"pool\":\"castle/empty\"," + + "\"name\":\"source\",\"targetName\":\"door\"}]}" ); + writeObjectHeader(pack.resolve("objects/castle/start.iob"), 1, 1, 1); + + StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); + + assertTrue(validation.errors().toString(), validation.errors().isEmpty()); + } + + @Test + public void acceptsIntentionalEmptyStartButDoesNotApproveItAsAReplacement() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + write(pack, "structures/castle.json", "{\"startPool\":\"castle/start\"}"); + write(pack, "jigsaw-pools/castle/start.json", + "{\"pieces\":[{\"empty\":true,\"weight\":1}]}"); + + StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); + + assertTrue(validation.errors().toString(), validation.errors().isEmpty()); + assertFalse(validation.replacementOutputStructures().contains("castle")); + } + + @Test + public void rareWeightedEmptyStartCannotApproveNativeReplacement() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + write(pack, "structures/castle.json", "{\"startPool\":\"castle/start\"}"); + write(pack, "jigsaw-pools/castle/start.json", + "{\"pieces\":[{\"piece\":\"castle/start\",\"weight\":2147483647}," + + "{\"empty\":true,\"weight\":1}]}"); + write(pack, "jigsaw-pieces/castle/start.json", + "{\"object\":\"castle/start\",\"connectors\":[]}"); + writeObjectHeader(pack.resolve("objects/castle/start.iob"), 1, 1, 1); + + StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); + + assertTrue(validation.errors().toString(), validation.errors().isEmpty()); + assertFalse(validation.replacementOutputStructures().contains("castle")); + } + + @Test + public void oversizedStartCannotApproveNativeReplacement() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + write(pack, "structures/castle.json", + "{\"startPool\":\"castle/start\",\"maxSizeChunks\":1}"); + write(pack, "jigsaw-pools/castle/start.json", + "{\"pieces\":[{\"piece\":\"castle/start\"}]}"); + write(pack, "jigsaw-pieces/castle/start.json", + "{\"object\":\"castle/start\",\"connectors\":[]}"); + writeObjectHeader(pack.resolve("objects/castle/start.iob"), 34, 1, 1); + + StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); + + assertTrue(validation.errors().toString(), validation.errors().isEmpty()); + assertFalse(validation.replacementOutputStructures().contains("castle")); + } + + @Test + public void reportsSampledRuntimeGeometryFailureWithoutEscapingValidation() throws Exception { + Path pack = temporaryFolder.newFolder("pack").toPath(); + String source = "{\"position\":{\"x\":1,\"y\":1,\"z\":0}," + + "\"direction\":\"NORTH_NEGATIVE_Z\",\"top\":\"UP_POSITIVE_Y\"," + + "\"pool\":\"castle/recursive\",\"name\":\"source\",\"targetName\":\"door\"," + + "\"joint\":\"ROLLABLE\"}"; + String target = "{\"position\":{\"x\":1,\"y\":1,\"z\":2}," + + "\"direction\":\"SOUTH_POSITIVE_Z\",\"top\":\"UP_POSITIVE_Y\"," + + "\"pool\":\"castle/recursive\",\"name\":\"door\",\"targetName\":\"unused\"," + + "\"joint\":\"ROLLABLE\"}"; + write(pack, "structures/castle.json", + "{\"startPool\":\"castle/start\",\"maxDepth\":30,\"maxSizeChunks\":32}"); + write(pack, "jigsaw-pools/castle/start.json", + "{\"pieces\":[{\"piece\":\"castle/start\",\"weight\":1}]}"); + write(pack, "jigsaw-pools/castle/recursive.json", + "{\"pieces\":[{\"piece\":\"castle/recursive\",\"weight\":1}]}"); + write(pack, "jigsaw-pieces/castle/start.json", + "{\"object\":\"castle/start\",\"rotatable\":false,\"connectors\":[" + source + "]}"); + write(pack, "jigsaw-pieces/castle/recursive.json", + "{\"object\":\"castle/recursive\",\"rotatable\":false,\"connectors\":[" + + target + "," + source + "," + source + "]}"); + writeObjectHeader(pack.resolve("objects/castle/start.iob"), 3, 3, 3); + writeObjectHeader(pack.resolve("objects/castle/recursive.iob"), 3, 3, 3); + + StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); + + assertTrue(validation.errors().toString(), validation.errors().stream().anyMatch( + message -> message.contains("fails sampled collision- and radius-aware assembly") + && message.contains("seed 0"))); + } + + private void write(Path root, String relativePath, String content) throws Exception { + Path file = root.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, content, StandardCharsets.UTF_8); + } + + private void writeObjectHeader(Path file, int width, int height, int depth) throws Exception { + Files.createDirectories(file.getParent()); + try (DataOutputStream output = new DataOutputStream(Files.newOutputStream(file))) { + output.writeInt(width); + output.writeInt(height); + output.writeInt(depth); + output.writeUTF("Iris V2 IOB;"); + output.writeShort(0); + output.writeInt(0); + output.writeInt(0); + } + } + + private void writeTruncatedObjectHeader(Path file, int width, int height, int depth) throws Exception { + Files.createDirectories(file.getParent()); + try (DataOutputStream output = new DataOutputStream(Files.newOutputStream(file))) { + output.writeInt(width); + output.writeInt(height); + output.writeInt(depth); + } + } + + private void writeSignedObject( + Path file, + int width, + int height, + int depth, + int x, + int y, + int z + ) throws Exception { + Files.createDirectories(file.getParent()); + try (DataOutputStream output = new DataOutputStream(Files.newOutputStream(file))) { + output.writeInt(width); + output.writeInt(height); + output.writeInt(depth); + output.writeUTF("Iris V2 IOB;"); + output.writeShort(1); + output.writeUTF("minecraft:stone"); + output.writeInt(1); + output.writeShort(x); + output.writeShort(y); + output.writeShort(z); + output.writeShort(0); + output.writeInt(0); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/StructurePackageClosureTest.java b/core/src/test/java/art/arcane/iris/core/pack/StructurePackageClosureTest.java new file mode 100644 index 000000000..c1b4ca8a5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/StructurePackageClosureTest.java @@ -0,0 +1,254 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class StructurePackageClosureTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void collectsAndWritesCompleteReachableGraph() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "structures/castle.json", "{\"startPool\":\"castle/start\",\"loot\":[\"castle/chests\"]}"); + write(pack, "jigsaw-pools/castle/start.json", "{\"pieces\":[{\"piece\":\"castle/start\",\"weight\":1}],\"fallback\":\"castle/end\"}"); + write(pack, "jigsaw-pools/castle/branch.json", "{\"pieces\":[{\"piece\":\"castle/end\",\"weight\":1}]}"); + write(pack, "jigsaw-pools/castle/end.json", "{\"pieces\":[{\"piece\":\"castle/end\",\"weight\":1}]}"); + write(pack, "jigsaw-pieces/castle/start.json", "{\"object\":\"castle/start\",\"connectors\":[{\"pool\":\"castle/branch\"}]}"); + write(pack, "jigsaw-pieces/castle/end.json", "{\"object\":\"castle/end\",\"connectors\":[]}"); + write(pack, "objects/castle/start.iob", "start-object"); + write(pack, "objects/castle/end.iob", "end-object"); + write(pack, "loot/castle/chests.json", "{\"tables\":[]}"); + + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("castle")); + + assertTrue(closure.errors().toString(), closure.isValid()); + assertEquals(Set.of("castle"), closure.structures()); + assertEquals(Set.of("castle/start", "castle/branch", "castle/end"), closure.pools()); + assertEquals(Set.of("castle/start", "castle/end"), closure.pieces()); + assertEquals(Set.of("castle/start", "castle/end"), closure.objects()); + assertEquals(Set.of("castle/chests"), closure.loot()); + + File output = temporaryFolder.newFolder("output"); + closure.writeTo(output, true); + + assertTrue(new File(output, "structures/castle.json").isFile()); + assertTrue(new File(output, "jigsaw-pools/castle/branch.json").isFile()); + assertTrue(new File(output, "jigsaw-pieces/castle/end.json").isFile()); + assertTrue(new File(output, "objects/castle/end.iob").isFile()); + assertTrue(new File(output, "loot/castle/chests.json").isFile()); + } + + @Test + public void excludesUnreachableStructures() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + writeSinglePieceStructure(pack, "selected"); + writeSinglePieceStructure(pack, "unrelated"); + + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("selected")); + + assertTrue(closure.errors().toString(), closure.isValid()); + assertEquals(Set.of("selected"), closure.structures()); + assertFalse(closure.objects().contains("unrelated/piece")); + } + + @Test + public void rejectsMissingDependenciesBeforeWriting() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "structures/broken.json", "{\"startPool\":\"broken/missing\"}"); + + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("broken")); + + assertFalse(closure.isValid()); + assertEquals(List.of("Missing jigsaw-pools resource 'broken/missing'."), closure.errors()); + try { + closure.writeTo(temporaryFolder.newFolder("output"), false); + fail("Invalid closure must not be written"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("broken/missing")); + } + } + + @Test + public void rejectsTraversalKeys() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("../outside")); + + assertFalse(closure.isValid()); + assertEquals(List.of("Missing or invalid structure."), closure.errors()); + assertTrue(closure.structures().isEmpty()); + } + + @Test + public void rejectsNonCanonicalAndNonPortableKeys() throws Exception { + List invalidKeys = List.of( + " leading", + "trailing ", + "folder\\structure", + "minecraft:structure", + "folder/con", + "folder/trailing.", + "folder/control\u007fkey" + ); + + for (int index = 0; index < invalidKeys.size(); index++) { + File pack = temporaryFolder.newFolder("invalid-key-" + index); + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of(invalidKeys.get(index))); + + assertFalse(invalidKeys.get(index), closure.isValid()); + assertEquals(invalidKeys.get(index), List.of("Missing or invalid structure."), closure.errors()); + } + } + + @Test + public void rejectsCaseInsensitiveResourceCollisions() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + writeSinglePieceStructure(pack, "Castle"); + + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("Castle", "castle")); + + assertFalse(closure.isValid()); + assertTrue(closure.errors().toString(), closure.errors().stream() + .anyMatch(error -> error.contains("Case-insensitive structures resource collision"))); + } + + @Test + public void rejectsMismatchedSourcePathCasing() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "Structures/case.json", "{\"startPool\":\"case/start\"}"); + + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("case")); + + assertFalse(closure.isValid()); + } + + @Test + public void rejectsWrongStructureAndPoolFieldShapes() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "structures/broken.json", "{\"startPool\":7,\"loot\":{}}"); + write(pack, "structures/pool-shapes.json", "{\"startPool\":\"pool-shapes/start\"}"); + write(pack, "jigsaw-pools/pool-shapes/start.json", "{\"pieces\":{},\"fallback\":[]}"); + + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("broken", "pool-shapes")); + + assertFalse(closure.isValid()); + assertTrue(closure.errors().toString(), closure.errors().contains("Structure 'broken' requires string field 'startPool'.")); + assertTrue(closure.errors().toString(), closure.errors().contains("Structure 'broken' requires array field 'loot'.")); + assertTrue(closure.errors().toString(), closure.errors().contains("Jigsaw pool 'pool-shapes/start' requires array field 'pieces'.")); + assertTrue(closure.errors().toString(), closure.errors().contains("Jigsaw pool 'pool-shapes/start' requires string field 'fallback'.")); + } + + @Test + public void rejectsMalformedPoolEntryShapes() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "structures/broken.json", "{\"startPool\":\"broken/start\"}"); + write(pack, "jigsaw-pools/broken/start.json", + "{\"pieces\":[{},{\"empty\":\"true\"},{\"empty\":true,\"piece\":\"broken/piece\"}]}"); + + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("broken")); + + assertFalse(closure.isValid()); + assertTrue(closure.errors().toString(), closure.errors().contains( + "Piece entry 0 in jigsaw pool 'broken/start' requires string field 'piece'.")); + assertTrue(closure.errors().toString(), closure.errors().contains( + "Piece entry 1 in jigsaw pool 'broken/start' requires boolean field 'empty'.")); + assertTrue(closure.errors().toString(), closure.errors().contains( + "Empty piece entry 2 in jigsaw pool 'broken/start' cannot define field 'piece'.")); + } + + @Test + public void acceptsExplicitEmptyPoolEntry() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "structures/empty.json", "{\"startPool\":\"empty/start\"}"); + write(pack, "jigsaw-pools/empty/start.json", "{\"pieces\":[{\"empty\":true,\"weight\":3}]}"); + + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("empty")); + + assertTrue(closure.errors().toString(), closure.isValid()); + assertEquals(Set.of("empty/start"), closure.pools()); + assertTrue(closure.pieces().isEmpty()); + assertTrue(closure.objects().isEmpty()); + } + + @Test + public void rejectsWrongPieceAndConnectorFieldShapes() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "structures/broken.json", "{\"startPool\":\"broken/start\"}"); + write(pack, "jigsaw-pools/broken/start.json", "{\"pieces\":[{\"piece\":\"broken/shape\",\"weight\":1},{\"piece\":\"broken/connector\",\"weight\":1}]}"); + write(pack, "jigsaw-pieces/broken/shape.json", "{\"object\":{},\"connectors\":{}}"); + write(pack, "jigsaw-pieces/broken/connector.json", "{\"object\":\"broken/connector\",\"connectors\":[{},7]}"); + write(pack, "objects/broken/connector.iob", "object"); + + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("broken")); + + assertFalse(closure.isValid()); + assertTrue(closure.errors().toString(), closure.errors().contains("Jigsaw piece 'broken/shape' requires string field 'object'.")); + assertTrue(closure.errors().toString(), closure.errors().contains("Jigsaw piece 'broken/shape' requires array field 'connectors'.")); + assertTrue(closure.errors().toString(), closure.errors().contains( + "Connector 0 in jigsaw piece 'broken/connector' requires string field 'pool'.")); + assertTrue(closure.errors().toString(), closure.errors().contains( + "Jigsaw piece 'broken/connector' has a non-object connector at index 1.")); + } + + @Test + public void rejectsSourceSymlinksOutsidePack() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + File outside = temporaryFolder.newFolder("outside"); + write(outside, "escape.json", "{\"startPool\":\"escape/start\"}"); + Path link = pack.toPath().resolve("structures/escape.json"); + Files.createDirectories(link.getParent()); + Files.createSymbolicLink(link, outside.toPath().resolve("escape.json")); + + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("escape")); + + assertFalse(closure.isValid()); + assertTrue(closure.errors().toString(), closure.errors().stream() + .anyMatch(error -> error.contains("escapes the structure package through a symbolic link"))); + } + + @Test + public void rejectsTargetSymlinks() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + writeSinglePieceStructure(pack, "selected"); + StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("selected")); + File output = temporaryFolder.newFolder("output"); + File outside = temporaryFolder.newFolder("outside"); + Files.createSymbolicLink(output.toPath().resolve("structures"), outside.toPath()); + + try { + closure.writeTo(output, true); + fail("Symbolic link target must not be written"); + } catch (IOException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("symbolic link")); + } + assertEquals(0, outside.list().length); + } + + private void writeSinglePieceStructure(File pack, String key) throws Exception { + write(pack, "structures/" + key + ".json", "{\"startPool\":\"" + key + "/start\"}"); + write(pack, "jigsaw-pools/" + key + "/start.json", "{\"pieces\":[{\"piece\":\"" + key + "/piece\",\"weight\":1}]}"); + write(pack, "jigsaw-pieces/" + key + "/piece.json", "{\"object\":\"" + key + "/piece\",\"connectors\":[]}"); + write(pack, "objects/" + key + "/piece.iob", key); + } + + private void write(File root, String relative, String content) throws Exception { + Path path = root.toPath().resolve(relative); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/NativeStructureSuppressionSchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/NativeStructureSuppressionSchemaTest.java new file mode 100644 index 000000000..1d7c5923c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/NativeStructureSuppressionSchemaTest.java @@ -0,0 +1,39 @@ +package art.arcane.iris.core.project; + +import art.arcane.iris.engine.object.NativeStructureSuppression; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class NativeStructureSuppressionSchemaTest { + @Test + public void suppressionTypeAndValuesAreSchemaDescribed() throws NoSuchFieldException { + assertNotNull(NativeStructureSuppression.class.getAnnotation(Desc.class)); + assertNotNull(NativeStructureSuppression.class.getField("NONE").getAnnotation(Desc.class)); + assertNotNull(NativeStructureSuppression.class.getField("REPLACE_SOURCE").getAnnotation(Desc.class)); + + JSONObject schema = new SchemaBuilder(SuppressionModel.class, null).construct(); + JSONObject suppression = schema.getJSONObject("properties").getJSONObject("suppression"); + String definitionKey = suppression.getString("$ref").substring("#/definitions/".length()); + JSONArray values = schema.getJSONObject("definitions") + .getJSONObject(definitionKey) + .getJSONArray("oneOf"); + + assertEquals("NONE", values.getJSONObject(0).getString("const")); + assertEquals("Leaves native generation enabled alongside this Iris structure placement.", + values.getJSONObject(0).getString("description")); + assertEquals("REPLACE_SOURCE", values.getJSONObject(1).getString("const")); + assertEquals("Suppresses the structure's vanillaSource when used by a validated dimension-level replacement placement.", + values.getJSONObject(1).getString("description")); + } + + @Desc("Schema model for native structure suppression.") + public static class SuppressionModel { + @Desc("Native structure suppression behavior.") + private NativeStructureSuppression suppression = NativeStructureSuppression.NONE; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java index 5a135d285..91444c89b 100644 --- a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java @@ -19,10 +19,14 @@ package art.arcane.iris.core.project; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.engine.object.IrisExpression; import art.arcane.iris.engine.object.IrisJigsawPiece; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; import art.arcane.iris.engine.object.annotations.MinNumber; @@ -30,6 +34,7 @@ import art.arcane.iris.engine.object.annotations.RegistryListEnchantment; import art.arcane.iris.engine.object.annotations.RegistryListEntityType; import art.arcane.iris.engine.object.annotations.RegistryListItemType; import art.arcane.iris.engine.object.annotations.RegistryListPotionEffect; +import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.LogLevel; @@ -42,6 +47,8 @@ import art.arcane.iris.spi.PlatformItem; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; import org.junit.After; @@ -56,6 +63,7 @@ import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -64,6 +72,7 @@ public class SchemaBuilderParityTest { private static final List ENCHANT_KEYS = List.of("minecraft:sharpness", "cool_mod:vorpal"); private static final List ITEM_KEYS = List.of("minecraft:stone", "minecraft:diamond_sword", "cool_mod:ruby"); private static final List ENTITY_KEYS = List.of("minecraft:zombie", "cool_mod:grizzly_bear"); + private static final List STRUCTURE_KEYS = List.of("minecraft:monument", "minecraft:stronghold", "cool_mod:sky_temple"); private static final List EXPECTED_POTIONS = List.of("SPEED", "SLOW_FALLING", "MEGA_BOOST"); private static final List EXPECTED_ENCHANTS = List.of("sharpness", "vorpal"); @@ -117,25 +126,56 @@ public class SchemaBuilderParityTest { } @Test - public void structurePlacementSchemaOmitsUnsupportedTransforms() { + public void structurePlacementSchemaIncludesFoundationSettingsAndOmitsUnsupportedTransforms() { IrisData data = mock(IrisData.class); ResourceLoader structureLoader = mock(ResourceLoader.class); ResourceLoader pieceLoader = mock(ResourceLoader.class); + ResourceLoader expressionLoader = mock(ResourceLoader.class); + ResourceLoader blockLoader = mock(ResourceLoader.class); + KMap, ResourceLoader> loaders = new KMap<>(); + loaders.put(IrisExpression.class, expressionLoader); when(data.getStructureLoader()).thenReturn(structureLoader); when(data.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(data.getBlockLoader()).thenReturn(blockLoader); + when(data.getLoaders()).thenReturn(loaders); + when(data.getPossibleSnippets(anyString())).thenReturn(new KList<>()); when(structureLoader.getPossibleKeys()).thenReturn(new String[0]); when(pieceLoader.getPossibleKeys()).thenReturn(new String[0]); + when(blockLoader.getPossibleKeys()).thenReturn(new String[0]); + when(expressionLoader.getPossibleKeys()).thenReturn(new String[0]); + when(expressionLoader.getFolderName()).thenReturn("expressions"); + when(expressionLoader.getResourceTypeName()).thenReturn("Expression"); - JSONObject properties = new SchemaBuilder(IrisStructurePlacement.class, data) - .construct().getJSONObject("properties"); + JSONObject schema = new SchemaBuilder(IrisStructurePlacement.class, data).construct(); + JSONObject properties = schema.getJSONObject("properties"); + JSONObject stilt = properties.getJSONObject("stilt"); + JSONObject stiltDefinition = schema.getJSONObject("definitions") + .getJSONObject(stilt.getString("$ref").substring("#/definitions/".length())); + JSONObject stiltProperties = stiltDefinition.getJSONObject("properties"); + JSONObject maxDepth = stiltProperties.getJSONObject("maxDepth"); assertTrue(properties.has("structures")); assertTrue(properties.has("distribution")); + assertEquals("object", stilt.getString("type")); + assertTrue(stiltProperties.has("palette")); + assertEquals(1, maxDepth.getInt("minimum")); + assertEquals(4064, maxDepth.getInt("maximum")); assertFalse(properties.has("rotation")); assertFalse(properties.has("translate")); assertFalse(properties.has("scale")); } + @Test + public void structurePolicyArraysUseLiveRegistryKeys() { + JSONObject schema = new SchemaBuilder(StructureArrayModel.class, (IrisData) null).construct(); + JSONObject disabled = schema.getJSONObject("properties").getJSONObject("disabled"); + + assertEquals("#/definitions/enum-vanilla-structure", + disabled.getJSONObject("items").getString("$ref")); + assertEquals(STRUCTURE_KEYS, + enumValues(schema.getJSONObject("definitions"), "enum-vanilla-structure")); + } + private static String flavorDefinitionKey() { return "enum-" + Flavor.class.getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase(); } @@ -182,6 +222,14 @@ public class SchemaBuilderParityTest { private Flavor flavor = Flavor.ALPHA; } + @Desc("Structure array model.") + public static class StructureArrayModel { + @Desc("Disabled structures.") + @ArrayType(type = String.class) + @RegistryListVanillaStructure + private KList disabled = new KList<>(); + } + public enum Flavor { ALPHA, BETA @@ -240,7 +288,7 @@ public class SchemaBuilderParityTest { @Override public List structureKeys() { - return List.of(); + return STRUCTURE_KEYS; } @Override diff --git a/core/src/test/java/art/arcane/iris/core/service/BoardSVCStudioEligibilityTest.java b/core/src/test/java/art/arcane/iris/core/service/BoardSVCStudioEligibilityTest.java new file mode 100644 index 000000000..e7de27a7b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/BoardSVCStudioEligibilityTest.java @@ -0,0 +1,85 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import org.bukkit.scoreboard.Scoreboard; +import org.junit.Test; + +import java.util.UUID; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public class BoardSVCStudioEligibilityTest { + @Test + public void productionIrisGeneratorIsNotEligible() { + PlatformChunkGenerator generator = generator(false, false, mock(Engine.class)); + + assertFalse(BoardSVC.isStudioGeneratorEligible(generator)); + } + + @Test + public void activeStudioGeneratorIsEligible() { + PlatformChunkGenerator generator = generator(true, false, mock(Engine.class)); + + assertTrue(BoardSVC.isStudioGeneratorEligible(generator)); + } + + @Test + public void studioGeneratorWithoutEngineIsNotEligible() { + PlatformChunkGenerator generator = generator(true, false, null); + + assertFalse(BoardSVC.isStudioGeneratorEligible(generator)); + } + + @Test + public void closingStudioGeneratorIsNotEligible() { + PlatformChunkGenerator generator = generator(true, true, mock(Engine.class)); + + assertFalse(BoardSVC.isStudioGeneratorEligible(generator)); + } + + @Test + public void playerToggleIsSessionScopedAndDefaultsVisible() { + BoardSVC service = new BoardSVC(); + UUID playerId = UUID.randomUUID(); + + assertTrue(service.isPlayerBoardEnabled(playerId)); + assertFalse(service.togglePlayerBoard(playerId)); + assertFalse(service.isPlayerBoardEnabled(playerId)); + assertTrue(service.togglePlayerBoard(playerId)); + assertTrue(service.isPlayerBoardEnabled(playerId)); + + service.togglePlayerBoard(playerId); + service.clearPlayerPreference(playerId); + assertTrue(service.isPlayerBoardEnabled(playerId)); + } + + @Test + public void restoresPreviousScoreboardWhileIrisStillOwnsThePlayer() { + Scoreboard previous = mock(Scoreboard.class); + Scoreboard iris = mock(Scoreboard.class); + + assertSame(previous, BoardSVC.selectScoreboardToRestore(iris, iris, previous)); + } + + @Test + public void preservesScoreboardAssignedAfterIris() { + Scoreboard previous = mock(Scoreboard.class); + Scoreboard iris = mock(Scoreboard.class); + Scoreboard replacement = mock(Scoreboard.class); + + assertSame(replacement, BoardSVC.selectScoreboardToRestore(replacement, iris, previous)); + } + + private PlatformChunkGenerator generator(boolean studio, boolean closing, Engine engine) { + PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + doReturn(studio).when(generator).isStudio(); + doReturn(closing).when(generator).isClosing(); + doReturn(engine).when(generator).getEngine(); + return generator; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/NativeStructureLocateCapabilityTest.java b/core/src/test/java/art/arcane/iris/core/structure/NativeStructureLocateCapabilityTest.java new file mode 100644 index 000000000..a6485304d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/NativeStructureLocateCapabilityTest.java @@ -0,0 +1,48 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure; + +import art.arcane.iris.engine.object.IrisImportedStructureControl; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class NativeStructureLocateCapabilityTest { + @Test + public void monumentLocateIsUnavailableWithoutChangingGenerationPolicy() { + assertTrue(NativeStructureLocateCapability.isPaperUnavailable("minecraft:monument")); + assertTrue(NativeStructureLocateCapability.isPaperUnavailable(" MINECRAFT:MONUMENT ")); + assertTrue(new IrisImportedStructureControl().shouldGenerate("minecraft:monument")); + } + + @Test + public void otherStructuresRemainLocatable() { + assertFalse(NativeStructureLocateCapability.isPaperUnavailable(null)); + assertFalse(NativeStructureLocateCapability.isPaperUnavailable("")); + assertFalse(NativeStructureLocateCapability.isPaperUnavailable("minecraft:stronghold")); + } + + @Test + public void messageStatesThatGenerationRemainsEnabled() { + String message = NativeStructureLocateCapability.unavailableMessage().toLowerCase(); + assertTrue(message.contains("generation is enabled")); + assertTrue(message.contains("locat")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/StructureCaptureImporterFailureTest.java b/core/src/test/java/art/arcane/iris/core/structure/StructureCaptureImporterFailureTest.java new file mode 100644 index 000000000..01eb94ffe --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/StructureCaptureImporterFailureTest.java @@ -0,0 +1,33 @@ +package art.arcane.iris.core.structure; + +import org.junit.Test; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class StructureCaptureImporterFailureTest { + @Test + public void captureFailurePreservesStructureChunkDetailAndCause() { + IllegalArgumentException cause = new IllegalArgumentException("placement broke"); + + IllegalStateException error = StructureCaptureImporter.captureFailure( + "minecraft:monument", 12, -8, "platform placement failed", cause); + + assertSame(cause, error.getCause()); + assertTrue(error.getMessage().contains("minecraft:monument")); + assertTrue(error.getMessage().contains("12,-8")); + assertTrue(error.getMessage().contains("platform placement failed")); + } + + @Test + public void captureFailureSupportsInfrastructureFailuresWithoutACause() { + IllegalStateException error = StructureCaptureImporter.captureFailure( + "minecraft:stronghold", -3, 5, "region task was not accepted", null); + + assertNull(error.getCause()); + assertTrue(error.getMessage().contains("minecraft:stronghold")); + assertTrue(error.getMessage().contains("-3,5")); + assertTrue(error.getMessage().contains("region task was not accepted")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/StructureImporterCaptureTest.java b/core/src/test/java/art/arcane/iris/core/structure/StructureImporterCaptureTest.java new file mode 100644 index 000000000..6f1822061 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/StructureImporterCaptureTest.java @@ -0,0 +1,70 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure; + +import art.arcane.iris.core.structure.authoring.StructureCapability; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.BlockState; +import org.bukkit.block.data.BlockData; +import org.bukkit.structure.Palette; +import org.bukkit.structure.Structure; +import org.bukkit.util.BlockVector; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class StructureImporterCaptureTest { + @Test + public void captureReturnsAnInMemorySnapshotWithDistinctBlockMarkerLoss() { + Structure structure = mock(Structure.class); + Palette palette = mock(Palette.class); + BlockState marker = mock(BlockState.class); + BlockData markerData = mock(BlockData.class); + Location location = mock(Location.class); + when(structure.getSize()).thenReturn(new BlockVector(2, 3, 4)); + when(structure.getPalettes()).thenReturn(List.of(palette)); + when(structure.getPaletteCount()).thenReturn(2); + when(structure.getEntityCount()).thenReturn(1); + when(palette.getBlocks()).thenReturn(List.of(marker)); + when(marker.getLocation()).thenReturn(location); + when(marker.getBlockData()).thenReturn(markerData); + when(markerData.getMaterial()).thenReturn(Material.STRUCTURE_BLOCK); + + StructureImporter.CapturedStructure captured = StructureImporter.captureStructure(structure); + + assertEquals(2, captured.width()); + assertEquals(3, captured.height()); + assertEquals(4, captured.depth()); + assertEquals(1, captured.structureMarkers()); + assertEquals(0, captured.blocks()); + assertTrue(captured.capabilities().contains(StructureCapability.BLOCKS)); + assertFalse(captured.capabilities().contains(StructureCapability.CONNECTORS)); + assertTrue(captured.losses().stream().anyMatch(loss -> + loss.capability() == StructureCapability.BLOCKS + && loss.code().equals("structure_markers_resolved"))); + assertFalse(captured.losses().stream().anyMatch(loss -> loss.code().equals("connectors_not_imported"))); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/StructureImporterModeTest.java b/core/src/test/java/art/arcane/iris/core/structure/StructureImporterModeTest.java index 724c7c53b..95c7e11b3 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/StructureImporterModeTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/StructureImporterModeTest.java @@ -22,18 +22,20 @@ import org.bukkit.NamespacedKey; import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; public class StructureImporterModeTest { @Test - public void parseModeDefaultsToOverwriteForNull() { - assertEquals(StructureImporter.Mode.OVERWRITE, StructureImporter.parseMode(null)); + public void parseModeDefaultsToAddOnlyForNull() { + assertEquals(StructureImporter.Mode.ADD_ONLY, StructureImporter.parseMode(null)); } @Test - public void parseModeDefaultsToOverwriteForUnknownAndEmpty() { - assertEquals(StructureImporter.Mode.OVERWRITE, StructureImporter.parseMode("")); - assertEquals(StructureImporter.Mode.OVERWRITE, StructureImporter.parseMode("garbage")); + public void parseModeDefaultsToAddOnlyForEmptyAndRejectsUnknown() { + assertEquals(StructureImporter.Mode.ADD_ONLY, StructureImporter.parseMode("")); + assertThrows(IllegalArgumentException.class, () -> StructureImporter.parseMode("garbage")); assertEquals(StructureImporter.Mode.OVERWRITE, StructureImporter.parseMode("overwrite")); + assertEquals(StructureImporter.Mode.OVERWRITE, StructureImporter.parseMode("replace")); } @Test @@ -47,8 +49,8 @@ public class StructureImporterModeTest { @Test public void parseModeIsCaseInsensitive() { assertEquals(StructureImporter.Mode.ADD_ONLY, StructureImporter.parseMode("ADD")); - assertEquals(StructureImporter.Mode.MERGE, StructureImporter.parseMode("MERGE")); - assertEquals(StructureImporter.Mode.MERGE, StructureImporter.parseMode("merge")); + assertEquals(StructureImporter.Mode.OVERWRITE, StructureImporter.parseMode("OVERWRITE")); + assertThrows(IllegalArgumentException.class, () -> StructureImporter.parseMode("MERGE")); } @Test diff --git a/core/src/test/java/art/arcane/iris/core/structure/StructureSchemaKeysTest.java b/core/src/test/java/art/arcane/iris/core/structure/StructureSchemaKeysTest.java index 556c28000..ebe8f1230 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/StructureSchemaKeysTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/StructureSchemaKeysTest.java @@ -1,21 +1,3 @@ -/* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2022 Arcane Arts (Volmit Software) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - package art.arcane.iris.core.structure; import art.arcane.volmlib.util.collection.KList; @@ -31,127 +13,59 @@ import static org.junit.Assert.assertTrue; public class StructureSchemaKeysTest { @Test - public void vanillaKeysAreNormalizedToIrisLoadKeyForm() { + public void exposesOnlyExistingIrisStructureResources() { KList result = StructureSchemaKeys.collect( - Arrays.asList("minecraft:village_plains", "minecraft:stronghold", "minecraft:ancient_city"), - Collections.emptyList(), + Arrays.asList("minecraft_stronghold", "custom/cool_tower"), Collections.emptyList()); - assertTrue(result.contains("minecraft_village_plains")); - assertTrue(result.contains("minecraft_stronghold")); - assertTrue(result.contains("minecraft_ancient_city")); + assertEquals(Arrays.asList("custom/cool_tower", "minecraft_stronghold"), result); } @Test - public void strongholdIsAlwaysOfferedEvenWithoutAnImportedFile() { + public void nativeRegistryKeysAreNotSynthesized() { KList result = StructureSchemaKeys.collect( - Arrays.asList("minecraft:stronghold", "minecraft:fortress", "minecraft:monument"), - Collections.emptyList(), + Collections.singletonList("custom/castle"), Collections.emptyList()); - assertTrue(result.contains("minecraft_stronghold")); - assertTrue(result.contains("minecraft_fortress")); - assertTrue(result.contains("minecraft_monument")); - } - - @Test - public void datapackStructuresAreNormalizedAndIncluded() { - KList result = StructureSchemaKeys.collect( - Arrays.asList("nova_structures:temple", "Nova_Structures:Big/Castle"), - Collections.emptyList(), - Collections.emptyList()); - - assertTrue(result.contains("nova_structures_temple")); - assertTrue(result.contains("nova_structures_big_castle")); + assertFalse(result.contains("minecraft_stronghold")); + assertFalse(result.contains("minecraft:stronghold")); + assertTrue(result.contains("custom/castle")); } @Test public void jigsawComponentPiecesAreExcluded() { List pieces = Arrays.asList( "village/taiga/houses/taiga_small_house_5", - "bastion/units/rampart_plates/plate_0", - "village/common/animals/cat_black", - "trial_chambers/corridor/straight_1", - "end_city/third_floor_1", - "igloo/middle"); + "trial_chambers/corridor/straight_1"); KList result = StructureSchemaKeys.collect( - Arrays.asList("minecraft:village_taiga", "minecraft:bastion_remnant"), - pieces, + Arrays.asList("village/taiga/houses/taiga_small_house_5", "custom/castle"), pieces); - for (String piece : pieces) { - assertFalse("component piece must not be a valid structure key: " + piece, result.contains(piece)); - } - assertTrue(result.contains("minecraft_village_taiga")); - assertTrue(result.contains("minecraft_bastion_remnant")); - } - - @Test - public void importedCustomStructuresAreKept() { - KList result = StructureSchemaKeys.collect( - Collections.emptyList(), - Arrays.asList("mypack_castle", "custom/cool_tower"), - Collections.emptyList()); - - assertTrue(result.contains("mypack_castle")); - assertTrue(result.contains("custom/cool_tower")); - } - - @Test - public void importedStructureThatCollidesWithAPieceIsDropped() { - KList result = StructureSchemaKeys.collect( - Collections.emptyList(), - Arrays.asList("village/taiga/houses/taiga_small_house_5", "minecraft_village_plains"), - Collections.singletonList("village/taiga/houses/taiga_small_house_5")); - assertFalse(result.contains("village/taiga/houses/taiga_small_house_5")); - assertTrue(result.contains("minecraft_village_plains")); + assertTrue(result.contains("custom/castle")); } @Test - public void vanillaAndImportedEquivalentsDeduplicate() { + public void resultIsSortedAndDeduplicated() { KList result = StructureSchemaKeys.collect( - Collections.singletonList("minecraft:ancient_city"), - Collections.singletonList("minecraft_ancient_city"), + Arrays.asList("zeta", "alpha", "zeta"), Collections.emptyList()); - int occurrences = 0; - for (String key : result) { - if (key.equals("minecraft_ancient_city")) { - occurrences++; - } - } - assertEquals(1, occurrences); - } - - @Test - public void resultIsSorted() { - KList result = StructureSchemaKeys.collect( - Arrays.asList("minecraft:zombie_village", "minecraft:ancient_city", "minecraft:monument"), - Collections.emptyList(), - Collections.emptyList()); - - KList sorted = new KList<>(result); - Collections.sort(sorted); - assertEquals(sorted, result); + assertEquals(Arrays.asList("alpha", "zeta"), result); } @Test public void nullCollectionsAreHandled() { - KList result = StructureSchemaKeys.collect(null, null, null); - assertTrue(result.isEmpty()); + assertTrue(StructureSchemaKeys.collect(null, null).isEmpty()); } @Test public void blankAndNullEntriesAreIgnored() { KList result = StructureSchemaKeys.collect( - Arrays.asList("minecraft:village_plains", "", null, " "), - Arrays.asList("custom_one", "", null), + Arrays.asList("custom_one", "", null, " "), Arrays.asList("", null)); - assertTrue(result.contains("minecraft_village_plains")); - assertTrue(result.contains("custom_one")); - assertEquals(2, result.size()); + assertEquals(Collections.singletonList("custom_one"), result); } } diff --git a/core/src/test/java/art/arcane/iris/core/structure/VillageImporterBundleTest.java b/core/src/test/java/art/arcane/iris/core/structure/VillageImporterBundleTest.java new file mode 100644 index 000000000..e3f4722d0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/VillageImporterBundleTest.java @@ -0,0 +1,168 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure; + +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureLoss; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteMode; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class VillageImporterBundleTest { + private static final StructureKey BUNDLE_KEY = StructureKey.parse("iris:minecraft_village_plains"); + private static final StructureSource SOURCE = new StructureSource( + StructureSource.Kind.VANILLA, + StructureKey.parse("minecraft:village_plains"), + "1.21.9-R0.1-SNAPSHOT", + "" + ); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void bundleOwnsObjectsPiecesPoolsAndRootAsOneTransaction() throws IOException { + Path root = temporaryFolder.newFolder("village-bundle").toPath(); + StructureResourceBundle bundle = bundle("object-v1"); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureWriteResult result = writer.write(bundle, StructureWriteMode.ADD_ONLY); + + assertEquals(StructureWriteResult.Status.ADDED, result.status()); + assertEquals(4, bundle.resources().size()); + assertTrue(bundle.resources().containsKey("objects/minecraft_village_plains/piece/minecraft/house.iob")); + assertTrue(bundle.resources().containsKey("jigsaw-pieces/minecraft_village_plains/piece/minecraft/house.json")); + assertTrue(bundle.resources().containsKey("jigsaw-pools/minecraft_village_plains/pool/minecraft/houses.json")); + assertTrue(bundle.resources().containsKey("structures/minecraft_village_plains.json")); + StructureOwnershipManifest manifest = StructureOwnershipManifest.fromJson( + Files.readAllBytes(writer.ownershipManifestPath(BUNDLE_KEY)) + ); + assertEquals(SOURCE, manifest.source()); + assertEquals(StructureBackend.IRIS_ASSEMBLY, manifest.backend()); + assertEquals(bundle.resources().keySet(), manifest.resourceHashes().keySet()); + } + + @Test + public void overwriteRefusesOneModifiedVillageResourceAndPreservesTheWholeClosure() throws IOException { + Path root = temporaryFolder.newFolder("village-overwrite").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult initial = writer.write(bundle("object-v1"), StructureWriteMode.ADD_ONLY); + Path object = root.resolve("objects/minecraft_village_plains/piece/minecraft/house.iob"); + Path structure = root.resolve("structures/minecraft_village_plains.json"); + byte[] originalStructure = Files.readAllBytes(structure); + byte[] handEdit = "hand-edit".getBytes(StandardCharsets.UTF_8); + Files.write(object, handEdit); + + StructureWriteResult overwrite = writer.write(bundle("object-v2"), StructureWriteMode.OVERWRITE); + + assertEquals(StructureWriteResult.Status.ADDED, initial.status()); + assertEquals(StructureWriteResult.Status.OWNERSHIP_CONFLICT, overwrite.status()); + assertArrayEquals(handEdit, Files.readAllBytes(object)); + assertArrayEquals(originalStructure, Files.readAllBytes(structure)); + } + + @Test + public void sourcePathsDoNotCollapseDifferentNamespacedKeys() { + String nestedPath = VillageImporter.pieceName("village", "mod:a/b"); + String underscoredNamespace = VillageImporter.pieceName("village", "mod_a:b"); + + assertEquals("village/piece/mod/a/b", nestedPath); + assertEquals("village/piece/mod_a/b", underscoredNamespace); + assertNotEquals(nestedPath, underscoredNamespace); + } + + @Test + public void nativeMaximumDistanceControlsIrisAssemblyRadius() { + Map structure = VillageImporter.structureJson( + "minecraft:village_plains", + "village/pool/minecraft/start", + 6, + 81 + ); + + assertEquals(6, structure.get("maxDepth")); + assertEquals(6, structure.get("maxSizeChunks")); + } + + private StructureResourceBundle bundle(String objectContent) { + String pieceKey = "minecraft_village_plains/piece/minecraft/house"; + String poolKey = "minecraft_village_plains/pool/minecraft/houses"; + Map objects = Map.of(pieceKey, objectContent.getBytes(StandardCharsets.UTF_8)); + Map piece = new LinkedHashMap<>(); + piece.put("object", pieceKey); + piece.put("connectors", List.of()); + piece.put("rotatable", true); + Map> pieces = Map.of(pieceKey, piece); + Map poolEntry = new LinkedHashMap<>(); + poolEntry.put("piece", pieceKey); + poolEntry.put("weight", 1); + Map pool = new LinkedHashMap<>(); + pool.put("pieces", List.of(poolEntry)); + Map> pools = Map.of(poolKey, pool); + Map structure = VillageImporter.structureJson( + SOURCE.key().value(), + poolKey, + 6, + 80 + ); + Set capabilities = EnumSet.of( + StructureCapability.BLOCKS, + StructureCapability.CONNECTORS, + StructureCapability.IRIS_PLACEMENT + ); + List losses = List.of(StructureLoss.warning( + StructureCapability.NATIVE_PLACEMENT, + "native_placement_settings_not_imported", + "Native placement metadata outside the Iris assembly contract was not imported." + )); + return VillageImporter.buildBundle( + BUNDLE_KEY, + SOURCE, + objects, + pieces, + pools, + structure, + capabilities, + losses + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/authoring/IrisDataStructureRecoveryTest.java b/core/src/test/java/art/arcane/iris/core/structure/authoring/IrisDataStructureRecoveryTest.java new file mode 100644 index 000000000..bd6848dc6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/authoring/IrisDataStructureRecoveryTest.java @@ -0,0 +1,90 @@ +package art.arcane.iris.core.structure.authoring; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.spi.IrisServices; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutorService; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; + +public class IrisDataStructureRecoveryTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisData data; + + @Before + public void registerPreservationService() { + IrisServices.register(PreservationRegistry.class, new NoOpPreservationRegistry()); + } + + @After + public void closeDataAndServices() { + if (data != null) { + data.close(); + } + IrisServices.clear(); + } + + @Test + public void packLoadingRecoversPreparedTransactionsBeforeCreatingLoaders() throws Exception { + File packDirectory = temporaryFolder.newFolder("pack"); + Path packRoot = packDirectory.toPath(); + byte[] original = "original".getBytes(StandardCharsets.UTF_8); + byte[] replacement = "replacement".getBytes(StandardCharsets.UTF_8); + Path target = packRoot.resolve("objects/temple.iob"); + Files.createDirectories(target.getParent()); + Files.write(target, replacement); + UUID transactionId = UUID.randomUUID(); + Path transactionRoot = packRoot.resolve(".iris/structure-staging").resolve(transactionId.toString()); + Path backup = transactionRoot.resolve("backup/objects/temple.iob"); + Files.createDirectories(backup.getParent()); + Files.write(backup, original); + StructureTransactionJournal journal = StructureTransactionJournal.prepared(transactionId, List.of( + new StructureTransactionJournal.Target( + "objects/temple.iob", + true, + StructureHash.sha256(original), + StructureHash.sha256(replacement) + ) + )); + Files.write(transactionRoot.resolve(StructureTransactionJournal.FILE_NAME), journal.toJson()); + + data = IrisData.get(packDirectory); + + assertArrayEquals(original, Files.readAllBytes(target)); + assertFalse(Files.exists(transactionRoot)); + } + + private static final class NoOpPreservationRegistry implements PreservationRegistry { + @Override + public void register(Thread thread) { + } + + @Override + public void register(ExecutorService service) { + } + + @Override + public void registerCache(MeteredCache cache) { + } + + @Override + public void dereference() { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureKeyTest.java b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureKeyTest.java new file mode 100644 index 000000000..3e9c5a815 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureKeyTest.java @@ -0,0 +1,56 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class StructureKeyTest { + @Test + public void parsesExactNamespacedPath() { + StructureKey key = StructureKey.parse("nova_structures:temple/large"); + + assertEquals("nova_structures", key.namespace()); + assertEquals("temple/large", key.path()); + assertEquals("nova_structures:temple/large", key.toString()); + } + + @Test + public void appliesExplicitDefaultNamespaceOnlyWhenMissing() { + assertEquals( + new StructureKey("minecraft", "village/plains"), + StructureKey.parse("village/plains", "minecraft") + ); + assertEquals( + new StructureKey("modid", "tower"), + StructureKey.parse("modid:tower", "minecraft") + ); + } + + @Test + public void rejectsLossyOrAmbiguousKeys() { + assertThrows(IllegalArgumentException.class, () -> StructureKey.parse("village")); + assertThrows(IllegalArgumentException.class, () -> StructureKey.parse("Minecraft:village")); + assertThrows(IllegalArgumentException.class, () -> StructureKey.parse("minecraft:village:plains")); + assertThrows(IllegalArgumentException.class, () -> StructureKey.parse("minecraft:village//plains")); + assertThrows(IllegalArgumentException.class, () -> StructureKey.parse("minecraft:village/../plains")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureResourceBundleTest.java b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureResourceBundleTest.java new file mode 100644 index 000000000..6aae21e9d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureResourceBundleTest.java @@ -0,0 +1,71 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertThrows; + +public class StructureResourceBundleTest { + private static final StructureKey KEY = StructureKey.parse("iris_test:temple"); + + @Test + public void resourceContentIsImmutableAcrossThePublicBoundary() { + byte[] input = "original".getBytes(StandardCharsets.UTF_8); + StructureResourceBundle bundle = builder() + .resource("objects/temple.iob", input) + .build(); + input[0] = 'X'; + + byte[] exposed = bundle.resources().get("objects/temple.iob").content(); + exposed[1] = 'X'; + + assertArrayEquals( + "original".getBytes(StandardCharsets.UTF_8), + bundle.resources().get("objects/temple.iob").content() + ); + } + + @Test + public void rejectsTraversalInternalAndNonPortablePaths() { + assertThrows(IllegalArgumentException.class, () -> builder().resource("../outside.json", new byte[0])); + assertThrows(IllegalArgumentException.class, () -> builder().resource(".iris/manifest.json", new byte[0])); + assertThrows(IllegalArgumentException.class, () -> builder().resource("objects/CON.iob", new byte[0])); + assertThrows(IllegalArgumentException.class, () -> builder().resource("objects/bad:name.iob", new byte[0])); + assertThrows(IllegalArgumentException.class, () -> builder().resource("objects\\bad.iob", new byte[0])); + assertThrows(IllegalArgumentException.class, () -> builder().resource("objects/bad\u007f.iob", new byte[0])); + } + + @Test + public void rejectsResourcesThatCollideOnCaseInsensitiveFileSystems() { + StructureResourceBundle.Builder builder = builder().resource("objects/Temple.iob", new byte[0]); + + assertThrows(IllegalArgumentException.class, () -> builder.resource("objects/temple.iob", new byte[0])); + } + + private StructureResourceBundle.Builder builder() { + return StructureResourceBundle.builder(KEY) + .source(StructureSource.of(StructureSource.Kind.IRIS, KEY)) + .backend(StructureBackend.IRIS_ASSEMBLY) + .capability(StructureCapability.BLOCKS); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java new file mode 100644 index 000000000..d50ef41f4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java @@ -0,0 +1,544 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.authoring; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.Assume; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class StructureTransactionWriterTest { + private static final StructureKey TARGET_KEY = StructureKey.parse("iris_test:temple"); + private static final StructureKey SOURCE_KEY = StructureKey.parse("minecraft:trial_chambers"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void dryRunPerformsPreflightWithoutWriting() throws IOException { + Path root = temporaryFolder.newFolder("dry-run").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureWriteResult result = writer.preview(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY); + + assertEquals(StructureWriteResult.Status.DRY_RUN, result.status()); + assertEquals(StructureWriteResult.Action.ADD, result.action()); + assertTrue(result.successful()); + assertFalse(result.committed()); + assertFalse(Files.exists(root.resolve("objects/temple.iob"))); + assertFalse(Files.exists(writer.ownershipManifestPath(TARGET_KEY))); + assertFalse(Files.exists(root.resolve(".iris"))); + } + + @Test + public void addOnlyReportsExistingResourcesWithoutChangingThem() throws IOException { + Path root = temporaryFolder.newFolder("add-only").toPath(); + Path existing = root.resolve("objects/temple.iob"); + Files.createDirectories(existing.getParent()); + byte[] userContent = "user-content".getBytes(StandardCharsets.UTF_8); + Files.write(existing, userContent); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureWriteResult result = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY); + + assertEquals(StructureWriteResult.Status.ADD_ONLY_CONFLICT, result.status()); + assertEquals(1, result.conflicts().size()); + assertEquals(StructureWriteResult.ConflictReason.RESOURCE_EXISTS, result.conflicts().get(0).reason()); + assertArrayEquals(userContent, Files.readAllBytes(existing)); + assertFalse(Files.exists(writer.ownershipManifestPath(TARGET_KEY))); + } + + @Test + public void ownershipManifestProtectsHandEditedResources() throws IOException { + Path root = temporaryFolder.newFolder("ownership").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult initial = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY); + Path object = root.resolve("objects/temple.iob"); + byte[] handEdit = "hand-edit".getBytes(StandardCharsets.UTF_8); + assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status()); + Files.write(object, handEdit); + + StructureWriteResult result = writer.write(bundle("object-v2", "structure-v2"), StructureWriteMode.OVERWRITE); + + assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status()); + assertEquals(StructureWriteResult.Status.OWNERSHIP_CONFLICT, result.status()); + assertTrue(result.conflicts().stream().anyMatch(conflict -> + conflict.reason() == StructureWriteResult.ConflictReason.MODIFIED_RESOURCE + && conflict.relativePath().equals("objects/temple.iob"))); + assertArrayEquals(handEdit, Files.readAllBytes(object)); + StructureOwnershipManifest manifest = StructureOwnershipManifest.fromJson( + Files.readAllBytes(writer.ownershipManifestPath(TARGET_KEY)) + ); + assertEquals(StructureHash.sha256("object-v1".getBytes(StandardCharsets.UTF_8)), + manifest.resourceHashes().get("objects/temple.iob")); + } + + @Test + public void overwriteReplacesOnlyOwnedUnmodifiedResources() throws IOException { + Path root = temporaryFolder.newFolder("overwrite").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult initial = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY); + + StructureWriteResult result = writer.write(bundle("object-v2", "structure-v2"), StructureWriteMode.OVERWRITE); + + assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status()); + assertEquals(StructureWriteResult.Status.OVERWRITTEN, result.status()); + assertEquals(StructureWriteResult.Action.OVERWRITE, result.action()); + assertArrayEquals("object-v2".getBytes(StandardCharsets.UTF_8), + Files.readAllBytes(root.resolve("objects/temple.iob"))); + assertArrayEquals("structure-v2".getBytes(StandardCharsets.UTF_8), + Files.readAllBytes(root.resolve("structures/temple.json"))); + } + + @Test + public void failedMultiFileInstallRollsBackEveryResourceAndManifest() throws IOException { + Path root = temporaryFolder.newFolder("rollback").toPath(); + StructureTransactionWriter initialWriter = new StructureTransactionWriter(root); + StructureWriteResult initial = initialWriter.write( + bundle("object-v1", "structure-v1"), + StructureWriteMode.ADD_ONLY + ); + Path object = root.resolve("objects/temple.iob"); + Path structure = root.resolve("structures/temple.json"); + Path manifest = initialWriter.ownershipManifestPath(TARGET_KEY); + assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status()); + byte[] originalObject = Files.readAllBytes(object); + byte[] originalStructure = Files.readAllBytes(structure); + byte[] originalManifest = Files.readAllBytes(manifest); + FailOnceMoveOperations operations = new FailOnceMoveOperations("staged/structures/temple.json"); + StructureTransactionWriter failingWriter = new StructureTransactionWriter(root, operations); + + StructureWriteResult result = failingWriter.write( + bundle("object-v2", "structure-v2"), + StructureWriteMode.OVERWRITE + ); + + assertEquals(StructureWriteResult.Status.ADDED, initial.status()); + assertEquals(StructureWriteResult.Status.ROLLED_BACK, result.status()); + assertTrue(result.failure().isPresent()); + assertArrayEquals(originalObject, Files.readAllBytes(object)); + assertArrayEquals(originalStructure, Files.readAllBytes(structure)); + assertArrayEquals(originalManifest, Files.readAllBytes(manifest)); + Path staging = root.resolve(".iris/structure-staging"); + if (Files.exists(staging)) { + try (Stream paths = Files.list(staging)) { + assertEquals(0, paths.count()); + } + } + } + + @Test + public void preparedTransactionRecoveryRestoresBackupsAndRemovesNewTargets() throws IOException { + Path root = temporaryFolder.newFolder("prepared-recovery").toPath(); + Path originalTarget = root.resolve("objects/temple.iob"); + Path newTarget = root.resolve("structures/temple.json"); + Files.createDirectories(originalTarget.getParent()); + Files.createDirectories(newTarget.getParent()); + Files.write(originalTarget, "partially-installed".getBytes(StandardCharsets.UTF_8)); + Files.write(newTarget, "new-target".getBytes(StandardCharsets.UTF_8)); + byte[] partialReplacement = "partially-installed".getBytes(StandardCharsets.UTF_8); + byte[] newReplacement = "new-target".getBytes(StandardCharsets.UTF_8); + UUID transactionId = UUID.randomUUID(); + Path transactionRoot = transactionRoot(root, transactionId); + Path backup = transactionRoot.resolve("backup/objects/temple.iob"); + Files.createDirectories(backup.getParent()); + byte[] original = "original".getBytes(StandardCharsets.UTF_8); + Files.write(backup, original); + writeJournal(transactionRoot, StructureTransactionJournal.prepared(transactionId, List.of( + new StructureTransactionJournal.Target( + "objects/temple.iob", + true, + StructureHash.sha256(original), + StructureHash.sha256(partialReplacement) + ), + new StructureTransactionJournal.Target( + "structures/temple.json", + false, + "", + StructureHash.sha256(newReplacement) + ) + ))); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureRecoveryResult result = writer.recoverIncompleteTransactions(); + + assertTrue(result.successful()); + assertEquals(1, result.restoredPreparedTransactions()); + assertEquals(1, result.recoveredTransactions()); + assertArrayEquals("original".getBytes(StandardCharsets.UTF_8), Files.readAllBytes(originalTarget)); + assertFalse(Files.exists(newTarget)); + assertFalse(Files.exists(transactionRoot)); + } + + @Test + public void committedTransactionRecoveryKeepsInstalledTargetsAndCleansRecoveryData() throws IOException { + Path root = temporaryFolder.newFolder("committed-recovery").toPath(); + Path target = root.resolve("objects/temple.iob"); + Files.createDirectories(target.getParent()); + Files.write(target, "committed".getBytes(StandardCharsets.UTF_8)); + UUID transactionId = UUID.randomUUID(); + Path transactionRoot = transactionRoot(root, transactionId); + Path backup = transactionRoot.resolve("backup/objects/temple.iob"); + Files.createDirectories(backup.getParent()); + byte[] original = "original".getBytes(StandardCharsets.UTF_8); + Files.write(backup, original); + writeJournal(transactionRoot, StructureTransactionJournal.prepared(transactionId, List.of( + new StructureTransactionJournal.Target( + "objects/temple.iob", + true, + StructureHash.sha256(original), + StructureHash.sha256("committed".getBytes(StandardCharsets.UTF_8)) + ) + )).committed()); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureRecoveryResult result = writer.recoverIncompleteTransactions(); + + assertTrue(result.successful()); + assertEquals(1, result.cleanedCommittedTransactions()); + assertEquals(1, result.recoveredTransactions()); + assertArrayEquals("committed".getBytes(StandardCharsets.UTF_8), Files.readAllBytes(target)); + assertFalse(Files.exists(transactionRoot)); + } + + @Test + public void nextOnlyPreparedJournalIsRecoveredAfterANonAtomicMoveInterruption() throws IOException { + Path root = temporaryFolder.newFolder("next-journal-recovery").toPath(); + Path target = root.resolve("objects/temple.iob"); + byte[] original = "original".getBytes(StandardCharsets.UTF_8); + byte[] replacement = "replacement".getBytes(StandardCharsets.UTF_8); + Files.createDirectories(target.getParent()); + Files.write(target, replacement); + UUID transactionId = UUID.randomUUID(); + Path transactionRoot = transactionRoot(root, transactionId); + Path backup = transactionRoot.resolve("backup/objects/temple.iob"); + Files.createDirectories(backup.getParent()); + Files.write(backup, original); + StructureTransactionJournal journal = StructureTransactionJournal.prepared(transactionId, List.of( + new StructureTransactionJournal.Target( + "objects/temple.iob", + true, + StructureHash.sha256(original), + StructureHash.sha256(replacement) + ) + )); + Files.write(transactionRoot.resolve(StructureTransactionJournal.NEXT_FILE_NAME), journal.toJson()); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureRecoveryResult result = writer.recoverIncompleteTransactions(); + + assertTrue(result.successful()); + assertArrayEquals(original, Files.readAllBytes(target)); + assertFalse(Files.exists(transactionRoot)); + } + + @Test + public void committedRecoveryRetainsBackupsWhenInstalledTargetsDoNotMatch() throws IOException { + Path root = temporaryFolder.newFolder("committed-mismatch").toPath(); + Path target = root.resolve("objects/temple.iob"); + byte[] original = "original".getBytes(StandardCharsets.UTF_8); + byte[] unexpected = "unexpected".getBytes(StandardCharsets.UTF_8); + Files.createDirectories(target.getParent()); + Files.write(target, unexpected); + UUID transactionId = UUID.randomUUID(); + Path transactionRoot = transactionRoot(root, transactionId); + Path backup = transactionRoot.resolve("backup/objects/temple.iob"); + Files.createDirectories(backup.getParent()); + Files.write(backup, original); + StructureTransactionJournal journal = StructureTransactionJournal.prepared(transactionId, List.of( + new StructureTransactionJournal.Target( + "objects/temple.iob", + true, + StructureHash.sha256(original), + StructureHash.sha256("committed".getBytes(StandardCharsets.UTF_8)) + ) + )).committed(); + Files.write(transactionRoot.resolve(StructureTransactionJournal.FILE_NAME), journal.toJson()); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureRecoveryResult result = writer.recoverIncompleteTransactions(); + + assertFalse(result.successful()); + assertArrayEquals(unexpected, Files.readAllBytes(target)); + assertTrue(Files.exists(backup)); + assertTrue(Files.exists(transactionRoot)); + } + + @Test + public void invalidRecoveryJournalBlocksWritesAndRetainsRecoveryData() throws IOException { + Path root = temporaryFolder.newFolder("invalid-recovery").toPath(); + UUID transactionId = UUID.randomUUID(); + Path transactionRoot = transactionRoot(root, transactionId); + Path journalPath = transactionRoot.resolve(StructureTransactionJournal.FILE_NAME); + Files.createDirectories(transactionRoot); + Files.write(journalPath, "not-json".getBytes(StandardCharsets.UTF_8)); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureWriteResult result = writer.write(bundle("object-v1", "structure-v1"), + StructureWriteMode.ADD_ONLY); + + assertEquals(StructureWriteResult.Status.FAILED, result.status()); + assertTrue(result.failure().isPresent()); + assertTrue(Files.exists(journalPath)); + assertFalse(Files.exists(root.resolve("objects/temple.iob"))); + } + + @Test + public void writeRecoversPreparedTransactionBeforePreflight() throws IOException { + Path root = temporaryFolder.newFolder("write-recovery").toPath(); + UUID transactionId = UUID.randomUUID(); + Path transactionRoot = transactionRoot(root, transactionId); + byte[] interruptedReplacement = "interrupted".getBytes(StandardCharsets.UTF_8); + writeJournal(transactionRoot, StructureTransactionJournal.prepared(transactionId, List.of( + new StructureTransactionJournal.Target( + "objects/temple.iob", + false, + "", + StructureHash.sha256(interruptedReplacement) + ) + ))); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureWriteResult result = writer.write(bundle("object-v1", "structure-v1"), + StructureWriteMode.ADD_ONLY); + + assertEquals(failureMessage(result), StructureWriteResult.Status.ADDED, result.status()); + assertArrayEquals("object-v1".getBytes(StandardCharsets.UTF_8), + Files.readAllBytes(root.resolve("objects/temple.iob"))); + assertFalse(Files.exists(transactionRoot)); + } + + @Test + public void preparedRecoveryRetainsUnexpectedReplacement() throws IOException { + Path root = temporaryFolder.newFolder("replacement-conflict").toPath(); + Path target = root.resolve("objects/temple.iob"); + Files.createDirectories(target.getParent()); + byte[] userEdit = "user-edit".getBytes(StandardCharsets.UTF_8); + Files.write(target, userEdit); + UUID transactionId = UUID.randomUUID(); + Path transactionRoot = transactionRoot(root, transactionId); + writeJournal(transactionRoot, StructureTransactionJournal.prepared(transactionId, List.of( + new StructureTransactionJournal.Target( + "objects/temple.iob", + false, + "", + StructureHash.sha256("interrupted".getBytes(StandardCharsets.UTF_8)) + ) + ))); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureRecoveryResult result = writer.recoverIncompleteTransactions(); + + assertFalse(result.successful()); + assertEquals(1, result.failures().size()); + assertArrayEquals(userEdit, Files.readAllBytes(target)); + assertTrue(Files.exists(transactionRoot)); + } + + @Test + public void concurrentTargetCreationIsNotDeletedByRollback() throws IOException { + Path root = temporaryFolder.newFolder("concurrent-target").toPath(); + byte[] userContent = "concurrent-user-file".getBytes(StandardCharsets.UTF_8); + FailOnceMoveOperations operations = new FailOnceMoveOperations( + "staged/objects/temple.iob", + userContent + ); + StructureTransactionWriter writer = new StructureTransactionWriter(root, operations); + + StructureWriteResult result = writer.write(bundle("object-v1", "structure-v1"), + StructureWriteMode.ADD_ONLY); + + assertEquals(StructureWriteResult.Status.ROLLED_BACK, result.status()); + assertArrayEquals(userContent, Files.readAllBytes(root.resolve("objects/temple.iob"))); + } + + @Test + public void preparedRecoveryAcceptsAlreadyRestoredOriginalWithRemainingBackup() throws IOException { + Path root = temporaryFolder.newFolder("repeated-recovery").toPath(); + byte[] original = "original".getBytes(StandardCharsets.UTF_8); + Path target = root.resolve("objects/temple.iob"); + Files.createDirectories(target.getParent()); + Files.write(target, original); + UUID transactionId = UUID.randomUUID(); + Path transactionRoot = transactionRoot(root, transactionId); + Path backup = transactionRoot.resolve("backup/objects/temple.iob"); + Files.createDirectories(backup.getParent()); + Files.write(backup, original); + writeJournal(transactionRoot, StructureTransactionJournal.prepared(transactionId, List.of( + new StructureTransactionJournal.Target( + "objects/temple.iob", + true, + StructureHash.sha256(original), + StructureHash.sha256("replacement".getBytes(StandardCharsets.UTF_8)) + ) + ))); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureRecoveryResult result = writer.recoverIncompleteTransactions(); + + assertTrue(result.successful()); + assertEquals(1, result.restoredPreparedTransactions()); + assertArrayEquals(original, Files.readAllBytes(target)); + assertFalse(Files.exists(transactionRoot)); + } + + @Test + public void symbolicLinkAncestorsCannotEscapeThePackRoot() throws IOException { + Path root = temporaryFolder.newFolder("symlink-pack").toPath(); + Path outside = temporaryFolder.newFolder("symlink-outside").toPath(); + try { + Files.createSymbolicLink(root.resolve("objects"), outside); + } catch (IOException | UnsupportedOperationException | SecurityException e) { + Assume.assumeNoException(e); + } + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureWriteResult result = writer.write(bundle("object-v1", "structure-v1"), + StructureWriteMode.ADD_ONLY); + + assertEquals(StructureWriteResult.Status.FAILED, result.status()); + assertFalse(Files.exists(outside.resolve("temple.iob"))); + } + + private StructureResourceBundle bundle(String objectContent, String structureContent) { + return StructureResourceBundle.builder(TARGET_KEY) + .source(StructureSource.of(StructureSource.Kind.VANILLA, SOURCE_KEY)) + .backend(StructureBackend.IRIS_ASSEMBLY) + .capability(StructureCapability.BLOCKS) + .capability(StructureCapability.CONNECTORS) + .loss(StructureLoss.warning( + StructureCapability.PROCESSORS, + "processors_omitted", + "The source processor list was not represented" + )) + .resource("objects/temple.iob", objectContent.getBytes(StandardCharsets.UTF_8)) + .textResource("structures/temple.json", structureContent) + .build(); + } + + private Path transactionRoot(Path root, UUID transactionId) { + return root.resolve(".iris/structure-staging").resolve(transactionId.toString()); + } + + private String failureMessage(StructureWriteResult result) { + return result.failure().map(Throwable::toString).orElse(""); + } + + private void writeJournal( + Path transactionRoot, + StructureTransactionJournal journal + ) throws IOException { + Files.createDirectories(transactionRoot); + Files.write(transactionRoot.resolve(StructureTransactionJournal.FILE_NAME), journal.toJson()); + } + + private static final class FailOnceMoveOperations implements StructureFileOperations { + private final NioStructureFileOperations delegate; + private final String failingSuffix; + private final byte[] competingContent; + private boolean failed; + + private FailOnceMoveOperations(String failingSuffix) { + this(failingSuffix, null); + } + + private FailOnceMoveOperations(String failingSuffix, byte[] competingContent) { + delegate = new NioStructureFileOperations(); + this.failingSuffix = failingSuffix; + this.competingContent = competingContent == null ? null : competingContent.clone(); + } + + @Override + public boolean exists(Path path) { + return delegate.exists(path); + } + + @Override + public boolean isRegularFile(Path path) { + return delegate.isRegularFile(path); + } + + @Override + public byte[] readAllBytes(Path path) throws IOException { + return delegate.readAllBytes(path); + } + + @Override + public String sha256(Path path) throws IOException { + return delegate.sha256(path); + } + + @Override + public void createDirectories(Path path) throws IOException { + delegate.createDirectories(path); + } + + @Override + public void writeNew(Path path, byte[] content) throws IOException { + delegate.writeNew(path, content); + } + + @Override + public void move(Path source, Path target) throws IOException { + String portableSource = source.toString().replace(File.separatorChar, '/'); + if (!failed && portableSource.endsWith(failingSuffix)) { + failed = true; + throw new IOException("Injected install failure for " + failingSuffix); + } + delegate.move(source, target); + } + + @Override + public void moveNew(Path source, Path target) throws IOException { + String portableSource = source.toString().replace(File.separatorChar, '/'); + if (!failed && competingContent != null && portableSource.endsWith(failingSuffix)) { + failed = true; + delegate.createDirectories(target.getParent()); + delegate.writeNew(target, competingContent); + delegate.moveNew(source, target); + return; + } + StructureFileOperations.super.moveNew(source, target); + } + + @Override + public void deleteIfExists(Path path) throws IOException { + delegate.deleteIfExists(path); + } + + @Override + public void deleteTree(Path root) throws IOException { + delegate.deleteTree(root); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioCompilerTest.java b/core/src/test/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioCompilerTest.java new file mode 100644 index 000000000..7e1132082 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioCompilerTest.java @@ -0,0 +1,251 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.ObjectPlaceMode; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class SimpleStructureStudioCompilerTest { + @Test + public void publishIdentityMustMatchTheRuntimeResourceKey() { + assertThrows(IllegalArgumentException.class, () -> new SimpleStructureStudioPublishConfig( + new StructureKey("iris", "studio/owned"), + "studio/different", + 7, + 8, + ObjectPlaceMode.STRUCTURE_PIECE + )); + } + + @Test + public void compilesDeterministicOwnedGraphWithExactConnectorGeometry() throws IOException { + CompilerFixture fixture = fixture(); + + StructureResourceBundle first = SimpleStructureStudioCompiler.compile( + fixture.draft(), + fixture.config(), + fixture.objects() + ); + StructureResourceBundle second = SimpleStructureStudioCompiler.compile( + fixture.draft(), + fixture.config(), + fixture.objects() + ); + + assertEquals(StructureBackend.IRIS_ASSEMBLY, first.backend()); + assertEquals(new StructureKey("iris", "studio/hall"), first.key()); + assertTrue(first.capabilities().contains(StructureCapability.CONNECTORS)); + assertTrue(first.capabilities().contains(StructureCapability.IRIS_PLACEMENT)); + assertEquals(12, first.resources().size()); + assertEquals(resourceHashes(first), resourceHashes(second)); + + StructureOwnershipManifest manifest = StructureOwnershipManifest.from(first); + assertEquals(first.key(), manifest.structure()); + assertEquals(resourceHashes(first), manifest.resourceHashes()); + + JsonObject startPiece = json(first, "jigsaw-pieces/studio/hall/cells/0-0/start.json"); + assertFalse(startPiece.get("rotatable").getAsBoolean()); + JsonArray startConnectors = startPiece.getAsJsonArray("connectors"); + assertEquals(1, startConnectors.size()); + assertConnector(startConnectors.get(0).getAsJsonObject(), 3, 2, 0, "NORTH_NEGATIVE_Z"); + + JsonObject mainPiece = json(first, "jigsaw-pieces/studio/hall/cells/1-0/cross.json"); + assertTrue(mainPiece.get("rotatable").getAsBoolean()); + JsonArray mainConnectors = mainPiece.getAsJsonArray("connectors"); + assertEquals(4, mainConnectors.size()); + assertConnector(mainConnectors.get(0).getAsJsonObject(), 3, 2, 0, "NORTH_NEGATIVE_Z"); + assertConnector(mainConnectors.get(1).getAsJsonObject(), 5, 2, 4, "EAST_POSITIVE_X"); + assertConnector(mainConnectors.get(2).getAsJsonObject(), 3, 2, 7, "SOUTH_POSITIVE_Z"); + assertConnector(mainConnectors.get(3).getAsJsonObject(), 0, 2, 4, "WEST_NEGATIVE_X"); + + JsonObject mainPool = json(first, "jigsaw-pools/studio/hall/main.json"); + assertEquals("studio/hall/terminal", mainPool.get("fallback").getAsString()); + assertEquals(3, mainPool.getAsJsonArray("pieces").get(0).getAsJsonObject().get("weight").getAsInt()); + assertEquals(5, mainPool.getAsJsonArray("pieces").get(1).getAsJsonObject().get("weight").getAsInt()); + JsonObject startPool = json(first, "jigsaw-pools/studio/hall/start.json"); + assertEquals(1, startPool.getAsJsonArray("pieces").size()); + JsonObject terminalPool = json(first, "jigsaw-pools/studio/hall/terminal.json"); + assertEquals(1, terminalPool.getAsJsonArray("pieces").size()); + + JsonObject structure = json(first, "structures/studio/hall.json"); + assertEquals("studio/hall/start", structure.get("startPool").getAsString()); + assertEquals(1, structure.get("maxDepth").getAsInt()); + assertEquals(4, structure.get("maxSizeChunks").getAsInt()); + assertEquals("STRUCTURE_PIECE", structure.get("placeMode").getAsString()); + } + + @Test + public void failsClosedForHalfTurnOnlyPieces() { + CompilerFixture fixture = fixture(); + SimpleStructureStudioCell halfTurnMain = fixture.draft().cellOrEmpty(1, 0) + .withRotationPolicy(SimpleStructureStudioRotationPolicy.HALF_TURNS); + SimpleStructureStudioDraft unsupported = fixture.draft().withCell(halfTurnMain); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> SimpleStructureStudioCompiler.compile(unsupported, fixture.config(), fixture.objects()) + ); + + assertTrue(failure.getMessage().contains("HALF_TURNS")); + } + + @Test + public void rejectsMissingUnexpectedAndWrongSizedResolvedObjects() { + CompilerFixture fixture = fixture(); + LinkedHashMap missing = new LinkedHashMap<>(fixture.objects()); + missing.remove(new SimpleStructureStudioVariantKey(1, 0, "cross")); + + IllegalStateException missingFailure = assertThrows( + IllegalStateException.class, + () -> SimpleStructureStudioCompiler.compile(fixture.draft(), fixture.config(), missing) + ); + assertTrue(missingFailure.getMessage().contains("missing=[1,0:cross]")); + + LinkedHashMap unexpected = new LinkedHashMap<>(fixture.objects()); + unexpected.put(new SimpleStructureStudioVariantKey(3, 0, "stale"), object()); + IllegalStateException unexpectedFailure = assertThrows( + IllegalStateException.class, + () -> SimpleStructureStudioCompiler.compile(fixture.draft(), fixture.config(), unexpected) + ); + assertTrue(unexpectedFailure.getMessage().contains("unexpected=[3,0:stale]")); + + LinkedHashMap wrongSize = new LinkedHashMap<>(fixture.objects()); + wrongSize.put(new SimpleStructureStudioVariantKey(2, 0, "cap"), new IrisObject(5, 4, 8)); + IllegalStateException sizeFailure = assertThrows( + IllegalStateException.class, + () -> SimpleStructureStudioCompiler.compile(fixture.draft(), fixture.config(), wrongSize) + ); + assertTrue(sizeFailure.getMessage().contains("expected 6x4x8")); + } + + @Test + public void rejectsDisconnectedPoolChannelsBeforeWritingResources() { + CompilerFixture fixture = fixture(); + SimpleStructureStudioCell wrongChannel = fixture.draft().cellOrEmpty(2, 0) + .withConnector("iris:other", 2); + SimpleStructureStudioDraft disconnected = fixture.draft().withCell(wrongChannel); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> SimpleStructureStudioCompiler.compile(disconnected, fixture.config(), fixture.objects()) + ); + + assertTrue(failure.getMessage().contains("must cover the same connector channels")); + } + + private CompilerFixture fixture() { + SimpleStructureStudioLayout layout = new SimpleStructureStudioLayout(3, 1, 6, 8, 4); + SimpleStructureStudioCell start = SimpleStructureStudioCell + .create(0, 0, SimpleStructureStudioTopology.START) + .withRotationPolicy(SimpleStructureStudioRotationPolicy.FIXED) + .withConnector("iris:path", 2) + .addVariant(new SimpleStructureStudioVariant("start", 1)); + SimpleStructureStudioCell main = SimpleStructureStudioCell + .create(1, 0, SimpleStructureStudioTopology.CROSS) + .withQuarterTurns(1) + .withConnector("iris:path", 2) + .addVariant(new SimpleStructureStudioVariant("cross", 3)) + .addVariant(new SimpleStructureStudioVariant("mossy/cross", 5)); + SimpleStructureStudioCell terminal = SimpleStructureStudioCell + .create(2, 0, SimpleStructureStudioTopology.TERMINAL) + .withConnector("iris:path", 2) + .addVariant(new SimpleStructureStudioVariant("cap", 2)); + SimpleStructureStudioDraft draft = new SimpleStructureStudioDraft( + layout, + 773L, + List.of(terminal, main, start) + ); + SimpleStructureStudioPublishConfig config = new SimpleStructureStudioPublishConfig( + new StructureKey("iris", "studio/hall"), + "studio/hall", + 1, + 4, + ObjectPlaceMode.STRUCTURE_PIECE + ); + LinkedHashMap objects = new LinkedHashMap<>(); + objects.put(SimpleStructureStudioVariantKey.of(start, start.variants().get(0)), object()); + objects.put(SimpleStructureStudioVariantKey.of(main, main.variants().get(0)), object()); + objects.put(SimpleStructureStudioVariantKey.of(main, main.variants().get(1)), object()); + objects.put(SimpleStructureStudioVariantKey.of(terminal, terminal.variants().get(0)), object()); + return new CompilerFixture(draft, config, objects); + } + + private IrisObject object() { + return new IrisObject(6, 4, 8); + } + + private Map resourceHashes(StructureResourceBundle bundle) { + TreeMap hashes = new TreeMap<>(); + for (StructureResourceBundle.Resource resource : bundle.resources().values()) { + hashes.put(resource.relativePath(), resource.contentHash()); + } + return hashes; + } + + private JsonObject json(StructureResourceBundle bundle, String relativePath) { + StructureResourceBundle.Resource resource = bundle.resources().get(relativePath); + return JsonParser.parseString(new String(resource.content(), StandardCharsets.UTF_8)).getAsJsonObject(); + } + + private void assertConnector( + JsonObject connector, + int x, + int y, + int z, + String direction + ) { + JsonObject position = connector.getAsJsonObject("position"); + assertEquals(x, position.get("x").getAsInt()); + assertEquals(y, position.get("y").getAsInt()); + assertEquals(z, position.get("z").getAsInt()); + assertEquals(direction, connector.get("direction").getAsString()); + assertEquals("studio/hall/main", connector.get("pool").getAsString()); + assertEquals("iris:path", connector.get("name").getAsString()); + assertEquals("iris:path", connector.get("targetName").getAsString()); + assertEquals("ALIGNED", connector.get("joint").getAsString()); + } + + private record CompilerFixture( + SimpleStructureStudioDraft draft, + SimpleStructureStudioPublishConfig config, + Map objects + ) { + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioModelTest.java b/core/src/test/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioModelTest.java new file mode 100644 index 000000000..d1d7842e4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioModelTest.java @@ -0,0 +1,132 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +import com.google.gson.Gson; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class SimpleStructureStudioModelTest { + @Test + public void topologyMasksRotateAcrossCardinalDirections() { + assertEquals(0, SimpleStructureStudioTopology.EMPTY.baseConnectorMask()); + assertEquals(1, SimpleStructureStudioTopology.END.baseConnectorMask()); + assertEquals(5, SimpleStructureStudioTopology.STRAIGHT.baseConnectorMask()); + assertEquals(3, SimpleStructureStudioTopology.CORNER.baseConnectorMask()); + assertEquals(11, SimpleStructureStudioTopology.T.baseConnectorMask()); + assertEquals(15, SimpleStructureStudioTopology.CROSS.baseConnectorMask()); + + assertEquals(6, SimpleStructureStudioTopology.CORNER.connectorMask(1)); + assertEquals(12, SimpleStructureStudioTopology.CORNER.connectorMask(2)); + assertEquals(9, SimpleStructureStudioTopology.CORNER.connectorMask(3)); + assertTrue(SimpleStructureStudioTopology.CORNER.connects(SimpleStructureStudioDirection.EAST, 1)); + assertTrue(SimpleStructureStudioTopology.CORNER.connects(SimpleStructureStudioDirection.SOUTH, 1)); + assertFalse(SimpleStructureStudioTopology.CORNER.connects(SimpleStructureStudioDirection.NORTH, 1)); + } + + @Test + public void cellVariantsAreWeightedSelectableAndImmutable() { + SimpleStructureStudioCell cell = SimpleStructureStudioCell + .create(1, 2, SimpleStructureStudioTopology.CORNER) + .addVariant(new SimpleStructureStudioVariant("stone", 2)) + .addVariant(new SimpleStructureStudioVariant("mossy", 5)); + + assertEquals("stone", cell.activeVariant().orElseThrow().id()); + SimpleStructureStudioCell selected = cell.selectVariant("mossy"); + assertEquals("mossy", selected.activeVariant().orElseThrow().id()); + assertEquals("stone", selected.cycleVariant(1).activeVariant().orElseThrow().id()); + assertEquals(7, selected.setVariantWeight("mossy", 7).variants().get(1).weight()); + assertThrows(UnsupportedOperationException.class, () -> selected.variants().add( + new SimpleStructureStudioVariant("extra", 1) + )); + assertThrows(IllegalArgumentException.class, () -> cell.addVariant( + new SimpleStructureStudioVariant("stone", 1) + )); + } + + @Test + public void draftIsCanonicalAndJsonRoundTrips() { + SimpleStructureStudioLayout layout = new SimpleStructureStudioLayout(4, 3, 9, 11, 24); + SimpleStructureStudioCell later = SimpleStructureStudioCell.create( + 3, + 2, + SimpleStructureStudioTopology.TERMINAL + ); + SimpleStructureStudioCell earlier = SimpleStructureStudioCell.create( + 0, + 0, + SimpleStructureStudioTopology.START + ); + SimpleStructureStudioDraft draft = new SimpleStructureStudioDraft( + layout, + 9921L, + List.of(later, earlier) + ); + + assertEquals(earlier, draft.cells().get(0)); + assertEquals(later, draft.cells().get(1)); + assertEquals(36, layout.studioWidth()); + assertEquals(33, layout.studioDepth()); + assertThrows( + IllegalStateException.class, + () -> draft.withLayout(new SimpleStructureStudioLayout(5, 3, 9, 11, 24)) + ); + + Gson gson = new Gson(); + String json = gson.toJson(draft); + SimpleStructureStudioDraft restored = gson.fromJson(json, SimpleStructureStudioDraft.class); + assertEquals(draft, restored); + } + + @Test + public void draftRejectsInvalidGeometryAndConnectorHeights() { + assertThrows(IllegalArgumentException.class, () -> new SimpleStructureStudioLayout(0, 1, 1, 1, 1)); + assertThrows(IllegalArgumentException.class, () -> new SimpleStructureStudioVariant("../piece", 1)); + assertThrows(IllegalArgumentException.class, () -> new SimpleStructureStudioVariant("piece.", 1)); + assertThrows(IllegalArgumentException.class, () -> new SimpleStructureStudioVariant("con", 1)); + assertThrows(IllegalArgumentException.class, () -> new SimpleStructureStudioVariant("nul", 1)); + assertThrows(IllegalArgumentException.class, () -> new SimpleStructureStudioVariant("com1", 1)); + SimpleStructureStudioLayout layout = new SimpleStructureStudioLayout(2, 2, 8, 8, 4); + SimpleStructureStudioCell tooTall = SimpleStructureStudioCell + .create(0, 0, SimpleStructureStudioTopology.END) + .withConnector("iris:path", 4); + + assertThrows( + IllegalArgumentException.class, + () -> new SimpleStructureStudioDraft(layout, 0L, List.of(tooTall)) + ); + assertThrows( + IllegalArgumentException.class, + () -> new SimpleStructureStudioDraft( + layout, + 0L, + List.of( + SimpleStructureStudioCell.create(0, 0, SimpleStructureStudioTopology.END), + SimpleStructureStudioCell.create(0, 0, SimpleStructureStudioTopology.CORNER) + ) + ) + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioRepositoryTest.java b/core/src/test/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioRepositoryTest.java new file mode 100644 index 000000000..c12533f1c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioRepositoryTest.java @@ -0,0 +1,75 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +import art.arcane.iris.core.structure.authoring.StructureKey; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class SimpleStructureStudioRepositoryTest { + private static final StructureKey KEY = StructureKey.parse("iris:castle/main"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void savesLoadsAndAtomicallyReplacesDrafts() throws Exception { + Path root = temporaryFolder.newFolder("pack").toPath(); + SimpleStructureStudioRepository repository = new SimpleStructureStudioRepository(root); + SimpleStructureStudioLayout layout = new SimpleStructureStudioLayout(3, 2, 16, 16, 12); + SimpleStructureStudioDraft first = SimpleStructureStudioDraft.empty(layout, 10L); + SimpleStructureStudioDraft second = first.withPreviewSeed(20L) + .withCell(SimpleStructureStudioCell.create(1, 1, SimpleStructureStudioTopology.CORNER)); + + assertTrue(repository.load(KEY).isEmpty()); + repository.save(KEY, first); + assertEquals(Optional.of(first), repository.load(KEY)); + repository.save(KEY, second); + assertEquals(Optional.of(second), repository.load(KEY)); + try (Stream paths = Files.list(repository.draftPath(KEY).getParent())) { + assertFalse(paths.anyMatch(path -> path.getFileName().toString().endsWith(".tmp"))); + } + } + + @Test + public void malformedDraftFailsWithItsPathAndLeavesFileUntouched() throws Exception { + Path root = temporaryFolder.newFolder("pack").toPath(); + SimpleStructureStudioRepository repository = new SimpleStructureStudioRepository(root); + Path draft = repository.draftPath(KEY); + Files.createDirectories(draft.getParent()); + Files.writeString(draft, "{", StandardCharsets.UTF_8); + + Exception failure = assertThrows(Exception.class, () -> repository.load(KEY)); + + assertTrue(failure.getMessage().contains(draft.toString())); + assertEquals("{", Files.readString(draft, StandardCharsets.UTF_8)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioSessionTest.java b/core/src/test/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioSessionTest.java new file mode 100644 index 000000000..89a4a6c19 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/studio/SimpleStructureStudioSessionTest.java @@ -0,0 +1,140 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure.studio; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class SimpleStructureStudioSessionTest { + @Test + public void sessionTracksDirtySavedUndoAndRedoStates() { + SimpleStructureStudioDraft initial = emptyDraft(); + SimpleStructureStudioSession session = SimpleStructureStudioSession.open(initial, 8); + + assertFalse(session.isDirty()); + assertTrue(session.setTopology(1, 1, SimpleStructureStudioTopology.CORNER)); + assertTrue(session.isDirty()); + assertEquals(3, session.draft().cellOrEmpty(1, 1).connectorMask()); + + session.markSaved(); + assertFalse(session.isDirty()); + assertTrue(session.rotateClockwise(1, 1)); + assertEquals(6, session.draft().cellOrEmpty(1, 1).connectorMask()); + assertTrue(session.isDirty()); + + assertTrue(session.undo()); + assertFalse(session.isDirty()); + assertEquals(3, session.draft().cellOrEmpty(1, 1).connectorMask()); + assertTrue(session.redo()); + assertTrue(session.isDirty()); + assertEquals(6, session.draft().cellOrEmpty(1, 1).connectorMask()); + } + + @Test + public void resizeIsAllowedOnlyBeforeContentExists() { + SimpleStructureStudioSession session = SimpleStructureStudioSession.open(emptyDraft(), 8); + SimpleStructureStudioLayout resized = new SimpleStructureStudioLayout(6, 5, 12, 14, 28); + + assertTrue(session.resize(resized)); + assertEquals(resized, session.draft().layout()); + assertTrue(session.setTopology(0, 0, SimpleStructureStudioTopology.START)); + assertThrows( + IllegalStateException.class, + () -> session.resize(new SimpleStructureStudioLayout(7, 5, 12, 14, 28)) + ); + + assertTrue(session.clearCell(0, 0)); + assertTrue(session.resize(new SimpleStructureStudioLayout(7, 5, 12, 14, 28))); + } + + @Test + public void variantsConnectorsAndRotationPoliciesUseSharedActions() { + SimpleStructureStudioSession session = SimpleStructureStudioSession.open(emptyDraft(), 16); + session.setTopology(2, 1, SimpleStructureStudioTopology.END); + session.setConnector(2, 1, "iris:hall", 6); + session.addVariant(2, 1, new SimpleStructureStudioVariant("plain", 1)); + session.addVariant(2, 1, new SimpleStructureStudioVariant("ruined", 3)); + session.selectVariant(2, 1, "ruined"); + session.setVariantWeight(2, 1, "ruined", 8); + + SimpleStructureStudioCell configured = session.draft().cellOrEmpty(2, 1); + assertEquals("iris:hall", configured.connectorChannel()); + assertEquals(6, configured.connectorHeight()); + assertEquals("ruined", configured.activeVariant().orElseThrow().id()); + assertEquals(8, configured.activeVariant().orElseThrow().weight()); + + assertTrue(session.setRotationPolicy(2, 1, SimpleStructureStudioRotationPolicy.HALF_TURNS)); + assertTrue(session.rotateClockwise(2, 1)); + assertEquals(2, session.draft().cellOrEmpty(2, 1).quarterTurns()); + assertTrue(session.setRotationPolicy(2, 1, SimpleStructureStudioRotationPolicy.FIXED)); + assertEquals(0, session.draft().cellOrEmpty(2, 1).quarterTurns()); + assertFalse(session.rotateClockwise(2, 1)); + } + + @Test + public void previewSeedSequenceAndHistoryBoundAreDeterministic() { + SimpleStructureStudioSession first = SimpleStructureStudioSession.open(emptyDraft(), 3); + SimpleStructureStudioSession second = SimpleStructureStudioSession.open(emptyDraft(), 3); + + assertEquals(first.advancePreviewSeed(), second.advancePreviewSeed()); + assertEquals(first.advancePreviewSeed(), second.advancePreviewSeed()); + + first.setPreviewSeed(1L); + first.setPreviewSeed(2L); + first.setPreviewSeed(3L); + first.setPreviewSeed(4L); + assertEquals(3, first.undoDepth()); + assertTrue(first.undo()); + assertEquals(3L, first.draft().previewSeed()); + assertTrue(first.undo()); + assertEquals(2L, first.draft().previewSeed()); + assertTrue(first.undo()); + assertEquals(1L, first.draft().previewSeed()); + assertFalse(first.undo()); + + assertTrue(first.redo()); + assertTrue(first.setPreviewSeed(99L)); + assertFalse(first.canRedo()); + } + + @Test + public void newDraftRemainsDirtyUntilSaved() { + SimpleStructureStudioSession session = SimpleStructureStudioSession.createNew(emptyDraft(), 8); + + assertTrue(session.isDirty()); + session.markSaved(); + assertFalse(session.isDirty()); + assertThrows(IllegalStateException.class, () -> session.addVariant( + 0, + 0, + new SimpleStructureStudioVariant("missing", 1) + )); + } + + private SimpleStructureStudioDraft emptyDraft() { + return SimpleStructureStudioDraft.empty( + new SimpleStructureStudioLayout(4, 4, 10, 10, 20), + 12345L + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/tools/TreePlausibilizerTest.java b/core/src/test/java/art/arcane/iris/core/tools/TreePlausibilizerTest.java new file mode 100644 index 000000000..cd9351a8b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/tools/TreePlausibilizerTest.java @@ -0,0 +1,594 @@ +package art.arcane.iris.core.tools; + +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.math.IrisBlockVector; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TreePlausibilizerTest { + private static final String OAK_LOG = "minecraft:oak_log[axis=y]"; + private static final String BIRCH_LOG = "minecraft:birch_log[axis=y]"; + private static final String OAK_LEAVES = "minecraft:oak_leaves[distance=7,persistent=true,waterlogged=false]"; + private static final String DIRT = "minecraft:dirt"; + private static final String MUSHROOM_STEM = "minecraft:mushroom_stem"; + private static final String MUSHROOM_BLOCK = "minecraft:red_mushroom_block"; + private static final int[][] NEIGHBORS = { + {1, 0, 0}, {-1, 0, 0}, + {0, 1, 0}, {0, -1, 0}, + {0, 0, 1}, {0, 0, -1} + }; + + private record Pos(int x, int y, int z) { + } + + @BeforeClass + public static void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState defaultBlock = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + IrisPlatform platform = mock(IrisPlatform.class); + when(registries.block(anyString())).thenReturn(defaultBlock); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void unbindPlatform() { + IrisPlatforms.unbind(); + } + + private static final class TestState implements PlatformBlockState { + private final String key; + + private TestState(String raw) { + this.key = canonical(raw); + } + + static TestState of(String raw) { + return new TestState(raw); + } + + private static String canonical(String raw) { + int bracket = raw.indexOf('['); + if (bracket < 0) { + return raw; + } + String material = raw.substring(0, bracket); + String body = raw.substring(bracket + 1, raw.length() - 1); + TreeMap props = new TreeMap<>(); + for (String pair : body.split(",")) { + if (pair.isEmpty()) { + continue; + } + int eq = pair.indexOf('='); + props.put(pair.substring(0, eq), pair.substring(eq + 1)); + } + return render(material, props); + } + + private static String render(String material, TreeMap props) { + if (props.isEmpty()) { + return material; + } + StringBuilder sb = new StringBuilder(material).append('['); + boolean first = true; + for (Map.Entry e : props.entrySet()) { + if (!first) { + sb.append(','); + } + first = false; + sb.append(e.getKey()).append('=').append(e.getValue()); + } + return sb.append(']').toString(); + } + + @Override + public String key() { + return key; + } + + @Override + public String namespace() { + return "minecraft"; + } + + @Override + public PlatformBlockState withProperty(String name, String value) { + int bracket = key.indexOf('['); + String material = bracket < 0 ? key : key.substring(0, bracket); + TreeMap props = new TreeMap<>(); + if (bracket >= 0) { + String body = key.substring(bracket + 1, key.length() - 1); + for (String pair : body.split(",")) { + if (pair.isEmpty()) { + continue; + } + int eq = pair.indexOf('='); + props.put(pair.substring(0, eq), pair.substring(eq + 1)); + } + } + props.put(name, value); + return new TestState(render(material, props)); + } + + @Override + public Object nativeHandle() { + return null; + } + + @Override + public boolean matches(PlatformBlockState state) { + return state != null && key.equals(state.key()); + } + + @Override + public boolean canPlaceOnto(PlatformBlockState onto) { + return true; + } + + @Override + public boolean isAir() { + return false; + } + + @Override + public boolean isSolid() { + return true; + } + + @Override + public boolean isOccluding() { + return false; + } + + @Override + public boolean isCustom() { + return false; + } + + @Override + public boolean isFluid() { + return false; + } + + @Override + public boolean isWater() { + return false; + } + + @Override + public boolean isWaterLogged() { + return false; + } + + @Override + public boolean isLit() { + return false; + } + + @Override + public boolean isUpdatable() { + return false; + } + + @Override + public boolean isFoliage() { + return false; + } + + @Override + public boolean isTreeBlock() { + return false; + } + + @Override + public boolean isFoliagePlantable() { + return false; + } + + @Override + public boolean isDecorant() { + return false; + } + + @Override + public boolean isStorage() { + return false; + } + + @Override + public boolean isStorageChest() { + return false; + } + + @Override + public boolean isOre() { + return false; + } + + @Override + public boolean isDeepSlate() { + return false; + } + + @Override + public boolean isVineBlock() { + return false; + } + + @Override + public boolean hasTileEntity() { + return false; + } + + @Override + public boolean equals(Object o) { + return o instanceof TestState other && key.equals(other.key); + } + + @Override + public int hashCode() { + return key.hashCode(); + } + } + + private static IrisObject emptyObject() { + return new IrisObject(32, 32, 32); + } + + private static void put(IrisObject obj, int x, int y, int z, String state) { + obj.getBlocks().put(new IrisBlockVector(x, y, z), TestState.of(state)); + } + + private static void trunk(IrisObject obj, int height) { + for (int y = 0; y < height; y++) { + put(obj, 0, y, 0, OAK_LOG); + } + } + + private static void blob(IrisObject obj, int cx, int cy, int cz, int r, String state) { + for (int x = cx - r; x <= cx + r; x++) { + for (int y = cy - r; y <= cy + r; y++) { + for (int z = cz - r; z <= cz + r; z++) { + put(obj, x, y, z, state); + } + } + } + } + + private static Map snapshot(IrisObject obj) { + Map out = new HashMap<>(); + for (Map.Entry e : obj.getBlocks()) { + IrisBlockVector v = e.getKey(); + out.put(new Pos(v.getBlockX(), v.getBlockY(), v.getBlockZ()), e.getValue().key()); + } + return out; + } + + private static String materialOf(String key) { + int bracket = key.indexOf('['); + return bracket < 0 ? key : key.substring(0, bracket); + } + + private static String propertyOf(String key, String name) { + int bracket = key.indexOf('['); + if (bracket < 0) { + return null; + } + String body = key.substring(bracket + 1, key.length() - 1); + for (String pair : body.split(",")) { + int eq = pair.indexOf('='); + if (pair.substring(0, eq).equals(name)) { + return pair.substring(eq + 1); + } + } + return null; + } + + private static boolean isWoodKey(String key) { + String name = materialOf(key); + return name.endsWith("_log") || name.endsWith("_wood") || name.endsWith("_hyphae"); + } + + private static boolean isLeafKey(String key) { + return propertyOf(key, "distance") != null && propertyOf(key, "persistent") != null; + } + + private static Set woodPositions(Map snap) { + Set out = new HashSet<>(); + for (Map.Entry e : snap.entrySet()) { + if (isWoodKey(e.getValue())) { + out.add(e.getKey()); + } + } + return out; + } + + private static Set leafPositions(Map snap) { + Set out = new HashSet<>(); + for (Map.Entry e : snap.entrySet()) { + if (isLeafKey(e.getValue())) { + out.add(e.getKey()); + } + } + return out; + } + + private static Map mirrorDistances(Map snap) { + Set wood = woodPositions(snap); + Set leaves = leafPositions(snap); + Map dist = new HashMap<>(); + Deque queue = new ArrayDeque<>(); + for (Pos leaf : leaves) { + for (int[] n : NEIGHBORS) { + if (wood.contains(new Pos(leaf.x() + n[0], leaf.y() + n[1], leaf.z() + n[2]))) { + dist.put(leaf, 1); + queue.add(leaf); + break; + } + } + } + while (!queue.isEmpty()) { + Pos pos = queue.poll(); + int d = dist.get(pos); + if (d >= 6) { + continue; + } + for (int[] n : NEIGHBORS) { + Pos nk = new Pos(pos.x() + n[0], pos.y() + n[1], pos.z() + n[2]); + if (leaves.contains(nk) && !dist.containsKey(nk)) { + dist.put(nk, d + 1); + queue.add(nk); + } + } + } + return dist; + } + + @Test + public void everyLeafLegalAfterApply() { + IrisObject obj = emptyObject(); + trunk(obj, 8); + blob(obj, 10, 8, 0, 2, OAK_LEAVES); + + TreePlausibilizer.Result r = TreePlausibilizer.apply(obj, 42L, TreePlausibilizer.DEFAULT_REACH); + + assertEquals(0, r.unreachableAfter()); + Map snap = snapshot(obj); + Map dist = mirrorDistances(snap); + Set leaves = leafPositions(snap); + assertFalse(leaves.isEmpty()); + for (Pos leaf : leaves) { + String key = snap.get(leaf); + assertEquals("persistent at " + leaf, "false", propertyOf(key, "persistent")); + Integer d = dist.get(leaf); + assertNotNull("mirror distance at " + leaf, d); + assertTrue("distance in range at " + leaf, d >= 1 && d <= 6); + assertEquals("stored distance at " + leaf, String.valueOf(d), propertyOf(key, "distance")); + } + } + + @Test + public void branchWoodIsDominantTrunkMaterial() { + IrisObject obj = emptyObject(); + trunk(obj, 8); + put(obj, 3, 0, 0, BIRCH_LOG); + put(obj, 3, 1, 0, BIRCH_LOG); + blob(obj, 11, 7, 0, 1, OAK_LEAVES); + Set originalWood = woodPositions(snapshot(obj)); + + TreePlausibilizer.Result r = TreePlausibilizer.apply(obj, 42L, TreePlausibilizer.DEFAULT_REACH); + + assertTrue(r.woodPlaced() + r.leavesConvertedToWood() > 0); + Map snap = snapshot(obj); + for (Pos pos : woodPositions(snap)) { + if (originalWood.contains(pos)) { + continue; + } + assertEquals("new wood material at " + pos, "minecraft:oak_log", materialOf(snap.get(pos))); + } + } + + @Test + public void clusterBeyondReachIsPinned() { + IrisObject obj = emptyObject(); + trunk(obj, 4); + blob(obj, 20, 2, 0, 1, OAK_LEAVES); + + TreePlausibilizer.Result r = TreePlausibilizer.apply(obj, 42L, 5); + + assertEquals(0, r.woodPlaced()); + assertEquals(0, r.leavesConvertedToWood()); + assertEquals(27, r.leavesPinnedPersistent()); + Map snap = snapshot(obj); + for (Pos leaf : leafPositions(snap)) { + String key = snap.get(leaf); + assertEquals("persistent at " + leaf, "true", propertyOf(key, "persistent")); + assertEquals("distance at " + leaf, "7", propertyOf(key, "distance")); + } + } + + @Test + public void reachZeroIsUnlimited() { + IrisObject obj = emptyObject(); + trunk(obj, 4); + blob(obj, 20, 2, 0, 1, OAK_LEAVES); + + TreePlausibilizer.Result r = TreePlausibilizer.apply(obj, 42L, 0); + + assertEquals(0, r.unreachableAfter()); + assertEquals(0, r.leavesPinnedPersistent()); + assertTrue(r.woodPlaced() > 0); + Map snap = snapshot(obj); + for (Pos leaf : leafPositions(snap)) { + assertEquals("persistent at " + leaf, "false", propertyOf(snap.get(leaf), "persistent")); + } + } + + @Test + public void deterministicOutput() { + IrisObject a = emptyObject(); + trunk(a, 8); + blob(a, 10, 8, 0, 2, OAK_LEAVES); + IrisObject b = emptyObject(); + trunk(b, 8); + blob(b, 10, 8, 0, 2, OAK_LEAVES); + + TreePlausibilizer.apply(a, 42L, TreePlausibilizer.DEFAULT_REACH); + TreePlausibilizer.apply(b, 42L, TreePlausibilizer.DEFAULT_REACH); + + assertEquals(snapshot(a), snapshot(b)); + } + + @Test + public void noLeavesDeletedOrAdded() { + IrisObject obj = emptyObject(); + trunk(obj, 8); + blob(obj, 10, 8, 0, 2, OAK_LEAVES); + Map before = snapshot(obj); + int initialLeaves = leafPositions(before).size(); + int initialSize = before.size(); + + TreePlausibilizer.Result r = TreePlausibilizer.apply(obj, 42L, TreePlausibilizer.DEFAULT_REACH); + + Map after = snapshot(obj); + assertEquals(initialLeaves - r.leavesConvertedToWood(), leafPositions(after).size()); + assertEquals(initialSize + r.woodPlaced(), after.size()); + assertTrue(after.keySet().containsAll(before.keySet())); + } + + @Test + public void analyzeDoesNotMutate() { + IrisObject a = emptyObject(); + trunk(a, 8); + blob(a, 10, 8, 0, 2, OAK_LEAVES); + IrisObject b = emptyObject(); + trunk(b, 8); + blob(b, 10, 8, 0, 2, OAK_LEAVES); + Map before = snapshot(a); + + TreePlausibilizer.Result analyzed = TreePlausibilizer.analyze(a, 42L, TreePlausibilizer.DEFAULT_REACH); + TreePlausibilizer.Result applied = TreePlausibilizer.apply(b, 42L, TreePlausibilizer.DEFAULT_REACH); + + assertEquals(before, snapshot(a)); + assertEquals(applied, analyzed); + assertTrue(analyzed.mutated()); + } + + @Test + public void loglessHedgePinsEverything() { + IrisObject obj = emptyObject(); + blob(obj, 0, 0, 0, 1, OAK_LEAVES); + + TreePlausibilizer.Result r = TreePlausibilizer.apply(obj, 42L, TreePlausibilizer.DEFAULT_REACH); + + assertEquals(0, r.woodPlaced()); + assertEquals(27, r.leavesPinnedPersistent()); + Map snap = snapshot(obj); + for (Pos leaf : leafPositions(snap)) { + String key = snap.get(leaf); + assertEquals("persistent at " + leaf, "true", propertyOf(key, "persistent")); + assertEquals("distance at " + leaf, "7", propertyOf(key, "distance")); + } + } + + @Test + public void mushroomAndNetherCanopyUntouched() { + IrisObject obj = emptyObject(); + for (int y = 0; y < 5; y++) { + put(obj, 0, y, 0, MUSHROOM_STEM); + } + blob(obj, 0, 6, 0, 2, MUSHROOM_BLOCK); + Map before = snapshot(obj); + + TreePlausibilizer.Result r = TreePlausibilizer.apply(obj, 42L, TreePlausibilizer.DEFAULT_REACH); + + assertEquals(0, r.totalLeaves()); + assertFalse(r.mutated()); + assertEquals(before, snapshot(obj)); + } + + @Test + public void branchNeverOverwritesForeignBlocks() { + IrisObject obj = emptyObject(); + trunk(obj, 9); + for (int y = 0; y <= 8; y++) { + for (int z = -2; z <= 2; z++) { + put(obj, 5, y, z, DIRT); + } + } + blob(obj, 10, 4, 0, 1, OAK_LEAVES); + + TreePlausibilizer.apply(obj, 42L, TreePlausibilizer.DEFAULT_REACH); + + Map snap = snapshot(obj); + for (int y = 0; y <= 8; y++) { + for (int z = -2; z <= 2; z++) { + assertEquals("dirt intact at 5," + y + "," + z, DIRT, snap.get(new Pos(5, y, z))); + } + } + } + + @Test + public void exactDistancesWritten() { + IrisObject obj = emptyObject(); + trunk(obj, 4); + for (int x = 1; x <= 6; x++) { + put(obj, x, 2, 0, OAK_LEAVES); + } + + TreePlausibilizer.Result r = TreePlausibilizer.apply(obj, 42L, TreePlausibilizer.DEFAULT_REACH); + + assertEquals(0, r.woodPlaced()); + assertEquals(0, r.leavesConvertedToWood()); + assertEquals(6, r.distancesRewritten()); + Map snap = snapshot(obj); + for (int x = 1; x <= 6; x++) { + String key = snap.get(new Pos(x, 2, 0)); + assertEquals("distance at x=" + x, String.valueOf(x), propertyOf(key, "distance")); + assertEquals("persistent at x=" + x, "false", propertyOf(key, "persistent")); + } + } + + @Test + public void applyIsIdempotent() { + IrisObject obj = emptyObject(); + trunk(obj, 8); + blob(obj, 10, 8, 0, 2, OAK_LEAVES); + + TreePlausibilizer.Result first = TreePlausibilizer.apply(obj, 42L, TreePlausibilizer.DEFAULT_REACH); + Map afterFirst = snapshot(obj); + TreePlausibilizer.Result second = TreePlausibilizer.apply(obj, 42L, TreePlausibilizer.DEFAULT_REACH); + + assertTrue(first.mutated()); + assertFalse(second.mutated()); + assertEquals(afterFirst, snapshot(obj)); + } + + @Test + public void seedOfIsStable() { + assertEquals(TreePlausibilizer.seedOf("trees/oak/oakFancy6"), TreePlausibilizer.seedOf("trees/oak/oakFancy6")); + assertTrue(TreePlausibilizer.seedOf("trees/oak/oakFancy6") != TreePlausibilizer.seedOf("trees/oak/oakFancy5")); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerMarkerTest.java b/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerMarkerTest.java index e988f981d..05c8e8984 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerMarkerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerMarkerTest.java @@ -11,4 +11,11 @@ public class IrisWorldManagerMarkerTest { assertEquals(319, IrisWorldManager.toMantleY(255, -64)); assertEquals(42, IrisWorldManager.toMantleY(42, 0)); } + + @Test + public void mantleHeightIsTranslatedToWorldBlockHeight() { + assertEquals(0, IrisWorldManager.toWorldY(64, -64)); + assertEquals(255, IrisWorldManager.toWorldY(319, -64)); + assertEquals(42, IrisWorldManager.toWorldY(42, 0)); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/decorator/DecoratorCoreTest.java b/core/src/test/java/art/arcane/iris/engine/decorator/DecoratorCoreTest.java index e79b471c2..750b6cf80 100644 --- a/core/src/test/java/art/arcane/iris/engine/decorator/DecoratorCoreTest.java +++ b/core/src/test/java/art/arcane/iris/engine/decorator/DecoratorCoreTest.java @@ -4,7 +4,11 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDecorationPart; import art.arcane.iris.engine.object.IrisDecorator; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.volmlib.util.math.RNG; +import org.bukkit.block.BlockSupport; +import org.bukkit.block.data.BlockData; import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -16,6 +20,7 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -134,4 +139,268 @@ public class DecoratorCoreTest { + " (deviation " + String.format("%.0f%%", deviation * 100) + ")", deviation < 0.20); } } + + @Test + public void singleStackTargetsBlockAboveSupport() { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + PlatformBlockState support = sturdyState(); + PlatformBlockState air = mock(PlatformBlockState.class); + PlatformBlockState decorant = mock(PlatformBlockState.class); + when(air.isAir()).thenReturn(true); + when(decorator.getHeight(any(RNG.class), anyDouble(), anyDouble(), eq(data))).thenReturn(1); + when(decorator.pickBlockDataTop(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(decorant); + + Hunk output = Hunk.newArrayHunk(1, 4, 1); + output.set(0, 1, 0, support); + output.set(0, 2, 0, air); + DecoratorCore.PlaceOpts opts = new DecoratorCore.PlaceOpts(); + + DecoratorCore.placeStackUp(decorator, 0, 0, 0, 0, 1, 3, output, new RNG(1L), data, opts); + + assertSame(support, output.get(0, 1, 0)); + assertSame(decorant, output.get(0, 2, 0)); + } + + @Test + public void singleStackDoesNotOverwriteOccupiedTarget() { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + PlatformBlockState support = sturdyState(); + PlatformBlockState occupied = mock(PlatformBlockState.class); + PlatformBlockState decorant = mock(PlatformBlockState.class); + when(occupied.isAir()).thenReturn(false); + when(occupied.isFluid()).thenReturn(false); + when(decorator.getHeight(any(RNG.class), anyDouble(), anyDouble(), eq(data))).thenReturn(1); + when(decorator.pickBlockDataTop(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(decorant); + + Hunk output = Hunk.newArrayHunk(1, 4, 1); + output.set(0, 1, 0, support); + output.set(0, 2, 0, occupied); + DecoratorCore.PlaceOpts opts = new DecoratorCore.PlaceOpts(); + + DecoratorCore.placeStackUp(decorator, 0, 0, 0, 0, 1, 3, output, new RNG(1L), data, opts); + + assertSame(support, output.get(0, 1, 0)); + assertSame(occupied, output.get(0, 2, 0)); + } + + @Test + public void multiStackStopsBeforeOccupiedTarget() { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + PlatformBlockState support = sturdyState(); + PlatformBlockState air = mock(PlatformBlockState.class); + PlatformBlockState occupied = mock(PlatformBlockState.class); + PlatformBlockState decorant = mock(PlatformBlockState.class); + when(air.isAir()).thenReturn(true); + when(occupied.isAir()).thenReturn(false); + when(occupied.isFluid()).thenReturn(false); + when(decorant.key()).thenReturn("minecraft:tall_grass"); + when(decorator.getHeight(any(RNG.class), anyDouble(), anyDouble(), eq(data))).thenReturn(3); + when(decorator.getTopThreshold()).thenReturn(0.75); + when(decorator.pickBlockData(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(decorant); + when(decorator.pickBlockDataTop(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(decorant); + + Hunk output = Hunk.newArrayHunk(1, 5, 1); + output.set(0, 1, 0, support); + output.set(0, 2, 0, air); + output.set(0, 3, 0, occupied); + output.set(0, 4, 0, air); + DecoratorCore.PlaceOpts opts = new DecoratorCore.PlaceOpts(); + + DecoratorCore.placeStackUp(decorator, 0, 0, 0, 0, 1, 4, output, new RNG(1L), data, opts); + + assertSame(decorant, output.get(0, 2, 0)); + assertSame(occupied, output.get(0, 3, 0)); + assertSame(air, output.get(0, 4, 0)); + } + + @Test + public void descendingStackStopsAtLocalWorldFloor() { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + PlatformBlockState decorant = mock(PlatformBlockState.class); + when(decorant.key()).thenReturn("minecraft:stone"); + when(decorator.getHeight(any(RNG.class), anyDouble(), anyDouble(), eq(data))).thenReturn(2); + when(decorator.getTopThreshold()).thenReturn(1.0); + when(decorator.pickBlockData(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(decorant); + when(decorator.pickBlockDataTop(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(decorant); + + Hunk output = Hunk.newArrayHunk(1, 2, 1); + DecoratorCore.PlaceOpts opts = new DecoratorCore.PlaceOpts(); + + DecoratorCore.placeStackDown( + decorator, 0, 0, 0, 0, 0, -64, output, new RNG(1L), data, 2, opts, null); + + assertSame(decorant, output.get(0, 0, 0)); + } + + @Test + public void singleDescendingStackPreservesTargetWhenPickIsNull() { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + PlatformBlockState existing = mock(PlatformBlockState.class); + when(decorator.getHeight(any(RNG.class), anyDouble(), anyDouble(), eq(data))).thenReturn(1); + + Hunk output = Hunk.newArrayHunk(1, 2, 1); + output.set(0, 0, 0, existing); + DecoratorCore.PlaceOpts opts = new DecoratorCore.PlaceOpts(); + + DecoratorCore.placeStackDown( + decorator, 0, 0, 0, 0, 0, 0, output, new RNG(1L), data, 1, opts, null); + + assertSame(existing, output.get(0, 0, 0)); + } + + @Test + public void multiDescendingStackStopsWhenPickIsNull() { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + PlatformBlockState lowerExisting = mock(PlatformBlockState.class); + PlatformBlockState upperExisting = mock(PlatformBlockState.class); + when(decorator.getHeight(any(RNG.class), anyDouble(), anyDouble(), eq(data))).thenReturn(2); + when(decorator.getTopThreshold()).thenReturn(1.0); + + Hunk output = Hunk.newArrayHunk(1, 2, 1); + output.set(0, 0, 0, lowerExisting); + output.set(0, 1, 0, upperExisting); + DecoratorCore.PlaceOpts opts = new DecoratorCore.PlaceOpts(); + + DecoratorCore.placeStackDown( + decorator, 0, 0, 0, 0, 1, 0, output, new RNG(1L), data, 2, opts, null); + + assertSame(lowerExisting, output.get(0, 0, 0)); + assertSame(upperExisting, output.get(0, 1, 0)); + } + + @Test + public void floatingStackStopsAtHunkCeiling() { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + PlatformBlockState decorant = mock(PlatformBlockState.class); + when(decorator.getHeight(any(RNG.class), anyDouble(), anyDouble(), eq(data))).thenReturn(3); + when(decorator.getTopThreshold()).thenReturn(1.0); + when(decorator.pickBlockData(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(decorant); + when(decorator.pickBlockDataTop(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(decorant); + + Hunk output = Hunk.newArrayHunk(1, 2, 1); + int placed = DecoratorCore.placeFloatingStacked( + decorator, 0, 0, 0, 0, 0, 3, output, new RNG(1L), data); + + assertEquals(1, placed); + assertSame(decorant, output.get(0, 1, 0)); + } + + @Test + public void tallSurfacePlantDoesNotPlaceWhenUpperTargetIsOccupied() { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + PlatformBlockState support = sturdyState(); + PlatformBlockState air = airState(); + PlatformBlockState occupied = mock(PlatformBlockState.class); + PlatformBlockState plant = tallPlantState(); + when(decorator.pickBlockData(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(plant); + + Hunk output = Hunk.newArrayHunk(1, 4, 1); + output.set(0, 0, 0, support); + output.set(0, 1, 0, air); + output.set(0, 2, 0, occupied); + + DecoratorCore.placeSurfaceSingle( + decorator, 0, 0, 0, 0, 0, output, new RNG(1L), data, false, false, null); + + assertSame(air, output.get(0, 1, 0)); + assertSame(occupied, output.get(0, 2, 0)); + } + + @Test + public void tallSinglePlantDoesNotPlaceWhenLowerTargetIsOccupied() { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + PlatformBlockState occupied = mock(PlatformBlockState.class); + PlatformBlockState air = airState(); + PlatformBlockState plant = tallPlantState(); + when(decorator.pickBlockData(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(plant); + + Hunk output = Hunk.newArrayHunk(1, 4, 1); + output.set(0, 1, 0, occupied); + output.set(0, 2, 0, air); + + DecoratorCore.placeSingleUp( + decorator, 0, 0, 0, 0, 0, output, new RNG(1L), data, false, null); + + assertSame(occupied, output.get(0, 1, 0)); + assertSame(air, output.get(0, 2, 0)); + } + + @Test + public void tallFloatingPlantDoesNotPlaceWhenUpperTargetIsOccupied() { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + PlatformBlockState air = airState(); + PlatformBlockState occupied = mock(PlatformBlockState.class); + PlatformBlockState plant = tallPlantState(); + when(decorator.pickBlockData(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(plant); + + Hunk output = Hunk.newArrayHunk(1, 4, 1); + output.set(0, 1, 0, air); + output.set(0, 2, 0, occupied); + + DecoratorCore.placeFloatingSimple( + decorator, 0, 0, 0, 0, 0, 3, output, new RNG(1L), data); + + assertSame(air, output.get(0, 1, 0)); + assertSame(occupied, output.get(0, 2, 0)); + } + + @Test + public void tallSurfacePlantPlacesBothHalvesTogether() { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + PlatformBlockState support = sturdyState(); + PlatformBlockState lowerAir = airState(); + PlatformBlockState upperAir = airState(); + PlatformBlockState lower = mock(PlatformBlockState.class); + PlatformBlockState upper = mock(PlatformBlockState.class); + PlatformBlockState plant = tallPlantState(lower, upper); + when(decorator.pickBlockData(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(plant); + + Hunk output = Hunk.newArrayHunk(1, 4, 1); + output.set(0, 0, 0, support); + output.set(0, 1, 0, lowerAir); + output.set(0, 2, 0, upperAir); + + DecoratorCore.placeSurfaceSingle( + decorator, 0, 0, 0, 0, 0, output, new RNG(1L), data, false, false, null); + + assertSame(lower, output.get(0, 1, 0)); + assertSame(upper, output.get(0, 2, 0)); + } + + private PlatformBlockState airState() { + PlatformBlockState air = mock(PlatformBlockState.class); + when(air.isAir()).thenReturn(true); + return air; + } + + private PlatformBlockState tallPlantState() { + return tallPlantState(mock(PlatformBlockState.class), mock(PlatformBlockState.class)); + } + + private PlatformBlockState tallPlantState(PlatformBlockState lower, PlatformBlockState upper) { + PlatformBlockState plant = mock(PlatformBlockState.class); + when(plant.key()).thenReturn("minecraft:tall_grass[half=lower]"); + when(plant.withProperty("half", "lower")).thenReturn(lower); + when(plant.withProperty("half", "upper")).thenReturn(upper); + return plant; + } + + private PlatformBlockState sturdyState() { + PlatformBlockState support = mock(PlatformBlockState.class); + BlockData blockData = mock(BlockData.class); + when(support.nativeHandle()).thenReturn(blockData); + when(blockData.isFaceSturdy(any(), eq(BlockSupport.FULL))).thenReturn(true); + return support; + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java b/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java new file mode 100644 index 000000000..20c3664ef --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java @@ -0,0 +1,407 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.util.project.stream.interpolation.Interpolated; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.Position2; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.File; +import java.nio.file.Files; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; + +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; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class HintedLocatorTest { + private static boolean boundPlatform; + + @BeforeClass + public static void bindPlatform() throws Exception { + if (IrisPlatforms.isBound()) { + return; + } + + File dataDirectory = Files.createTempDirectory("iris-hinted-locator-test").toFile(); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.dataFile(any(String[].class))).thenAnswer(invocation -> { + Object[] arguments = invocation.getArguments(); + File file = dataDirectory; + for (Object argument : arguments) { + file = new File(file, String.valueOf(argument)); + } + return file; + }); + IrisPlatforms.bind(platform); + boundPlatform = true; + } + + @AfterClass + public static void unbindPlatform() { + if (boundPlatform) { + IrisPlatforms.unbind(); + boundPlatform = false; + } + } + + private Engine engine; + private IrisDimension dimension; + private IrisComplex complex; + private IrisData data; + private ResourceLoader biomeLoader; + + @Before + @SuppressWarnings("unchecked") + public void setup() { + engine = mock(Engine.class); + dimension = mock(IrisDimension.class); + complex = mock(IrisComplex.class); + data = mock(IrisData.class); + biomeLoader = (ResourceLoader) mock(ResourceLoader.class); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); + when(engine.getData()).thenReturn(data); + when(engine.getFocus()).thenReturn(null); + when(engine.getFocusRegion()).thenReturn(null); + when(engine.isClosed()).thenReturn(false); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + } + + private static IrisBiome biome(String key) { + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(key); + return biome; + } + + private static IrisRegion region(String key) { + IrisRegion region = new IrisRegion(); + region.setLoadKey(key); + return region; + } + + private static KList keys(String... values) { + KList list = new KList<>(); + for (String value : values) { + list.add(value); + } + return list; + } + + private static ProceduralStream constantStream(T value) { + return ProceduralStream.of((x, z) -> value, Interpolated.of(a -> 0D, a -> value)); + } + + private static ProceduralStream positionalStream(BiFunction function, T fallback) { + return ProceduralStream.of(function::apply, Interpolated.of(a -> 0D, a -> fallback)); + } + + private void stubRegions(IrisRegion... regions) { + KList list = new KList<>(); + KList regionKeys = new KList<>(); + for (IrisRegion region : regions) { + list.add(region); + regionKeys.add(region.getLoadKey()); + } + when(dimension.getAllRegions(engine)).thenReturn(list); + when(dimension.getRegions()).thenReturn(regionKeys); + } + + private void stubBiome(IrisBiome biome) { + when(biomeLoader.load(biome.getLoadKey())).thenReturn(biome); + } + + @Test + public void appendRingZeroAddsOnlyOrigin() { + KList batch = new KList<>(); + HintedLocator.appendRing(batch, new Position2(7, -3), 0, 4); + assertEquals(1, batch.size()); + assertEquals(new Position2(7, -3), batch.get(0)); + } + + @Test + public void appendRingProducesFullPerimeterAtStride() { + KList batch = new KList<>(); + HintedLocator.appendRing(batch, new Position2(0, 0), 3, 4); + assertEquals(24, batch.size()); + Set unique = new HashSet<>(batch); + assertEquals(24, unique.size()); + for (Position2 cell : batch) { + int chebyshev = Math.max(Math.abs(cell.getX()), Math.abs(cell.getZ())); + assertEquals(12, chebyshev); + assertEquals(0, cell.getX() % 4); + assertEquals(0, cell.getZ() % 4); + } + } + + @Test + public void appendRingsAtStrideOneTileThePlaneWithoutDuplicates() { + Set all = new HashSet<>(); + int total = 0; + for (int ring = 0; ring <= 5; ring++) { + KList batch = new KList<>(); + HintedLocator.appendRing(batch, new Position2(2, 2), ring, 1); + total += batch.size(); + all.addAll(batch); + } + assertEquals(121, total); + assertEquals(121, all.size()); + } + + @Test + public void biomePlanFindsChildBiomeUnderLandRoot() { + IrisRegion regionA = region("reg_a"); + regionA.setLandBiomes(keys("root_land")); + IrisBiome root = biome("root_land"); + root.setChildren(keys("target")); + IrisBiome target = biome("target"); + stubBiome(root); + stubBiome(target); + stubRegions(regionA); + + IrisRegion foreign = region("reg_other"); + when(complex.getRegionStream()).thenReturn(positionalStream((x, z) -> x < 0D ? foreign : regionA, regionA)); + when(complex.getLandBiomeStream()).thenReturn(constantStream(root)); + when(complex.getSeaBiomeStream()).thenReturn(constantStream(biome("sea_root"))); + when(complex.getShoreBiomeStream()).thenReturn(constantStream(biome("shore_root"))); + + HintedLocator.SearchPlan plan = HintedLocator.biomePlan(engine, "target"); + + assertTrue(plan.isPossible()); + assertNotNull(plan.getCoarse()); + assertNull(plan.getExactOverride()); + assertTrue(plan.getCoarse().test(100, 100)); + assertFalse(plan.getCoarse().test(-100, 100)); + } + + @Test + public void biomePlanIsImpossibleWhenBiomeIsUnreferenced() { + IrisRegion regionA = region("reg_a"); + regionA.setLandBiomes(keys("root_land")); + stubBiome(biome("root_land")); + stubRegions(regionA); + + HintedLocator.SearchPlan plan = HintedLocator.biomePlan(engine, "missing"); + + assertFalse(plan.isPossible()); + } + + @Test + public void biomePlanFallsBackToCaveStreamForCaveOnlyBiome() { + IrisRegion regionA = region("reg_a"); + regionA.setCaveBiomes(keys("cavey")); + IrisBiome cavey = biome("cavey"); + stubBiome(cavey); + stubRegions(regionA); + + when(complex.getRegionStream()).thenReturn(constantStream(regionA)); + when(complex.getCaveBiomeStream()).thenReturn(positionalStream((x, z) -> x >= 512D ? cavey : biome("other_cave"), cavey)); + + HintedLocator.SearchPlan plan = HintedLocator.biomePlan(engine, "cavey"); + + assertTrue(plan.isPossible()); + assertNotNull(plan.getExactOverride()); + assertTrue(plan.getCoarse().test(600, 0)); + assertFalse(plan.getCoarse().test(0, 0)); + + when(engine.getCaveBiome(anyInt(), anyInt())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + return x >= 512 ? cavey : biome("other_cave"); + }); + assertTrue(plan.getExactOverride().matches(engine, new Position2(40, 0))); + assertFalse(plan.getExactOverride().matches(engine, new Position2(0, 0))); + } + + @Test + public void biomePlanIsUnprunedInFocusMode() { + when(engine.getFocus()).thenReturn(biome("focus")); + + HintedLocator.SearchPlan plan = HintedLocator.biomePlan(engine, "anything"); + + assertTrue(plan.isPossible()); + assertNull(plan.getCoarse()); + assertEquals(1, plan.getStrideChunks()); + } + + @Test + public void regionPlanMatchesOnlyTargetRegionCells() { + IrisRegion regionA = region("reg_a"); + IrisRegion regionB = region("reg_b"); + stubRegions(regionA, regionB); + + when(complex.getRegionStream()).thenReturn(positionalStream((x, z) -> x >= 4096D ? regionB : regionA, regionA)); + + HintedLocator.SearchPlan plan = HintedLocator.regionPlan(engine, "reg_b"); + + assertTrue(plan.isPossible()); + assertTrue(plan.getStrideChunks() > 1); + assertTrue(plan.getCoarse().test(5000, 0)); + assertFalse(plan.getCoarse().test(0, 0)); + } + + @Test + public void regionPlanIsImpossibleForUnknownRegion() { + stubRegions(region("reg_a")); + + HintedLocator.SearchPlan plan = HintedLocator.regionPlan(engine, "reg_missing"); + + assertFalse(plan.isPossible()); + } + + @Test + public void objectPlanPrunesToHostBiomes() { + IrisRegion regionA = region("reg_a"); + regionA.setLandBiomes(keys("host_biome")); + IrisBiome host = biome("host_biome"); + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setPlace(keys("trees/oak")); + KList placements = new KList<>(); + placements.add(placement); + host.setObjects(placements); + stubBiome(host); + stubRegions(regionA); + + when(complex.getRegionStream()).thenReturn(constantStream(regionA)); + when(complex.getLandBiomeStream()).thenReturn(positionalStream((x, z) -> x >= 1024D ? host : biome("plains"), host)); + when(complex.getSeaBiomeStream()).thenReturn(constantStream(biome("sea_root"))); + when(complex.getShoreBiomeStream()).thenReturn(constantStream(biome("shore_root"))); + + HintedLocator.SearchPlan plan = HintedLocator.objectPlan(engine, "trees/oak"); + + assertTrue(plan.isPossible()); + assertEquals(1, plan.getStrideChunks()); + assertTrue(plan.getCoarse().test(2000, 0)); + assertFalse(plan.getCoarse().test(0, 0)); + } + + @Test + public void objectPlanAcceptsRegionLevelPlacements() { + IrisRegion regionA = region("reg_a"); + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setPlace(keys("ruins/tower")); + KList placements = new KList<>(); + placements.add(placement); + regionA.setObjects(placements); + stubRegions(regionA); + + when(complex.getRegionStream()).thenReturn(constantStream(regionA)); + when(complex.getLandBiomeStream()).thenReturn(constantStream(biome("plains"))); + when(complex.getSeaBiomeStream()).thenReturn(constantStream(biome("sea_root"))); + when(complex.getShoreBiomeStream()).thenReturn(constantStream(biome("shore_root"))); + + HintedLocator.SearchPlan plan = HintedLocator.objectPlan(engine, "ruins/tower"); + + assertTrue(plan.isPossible()); + assertTrue(plan.getCoarse().test(0, 0)); + } + + @Test + public void objectPlanIsImpossibleForUnplacedObject() { + IrisRegion regionA = region("reg_a"); + regionA.setLandBiomes(keys("plains")); + stubBiome(biome("plains")); + stubRegions(regionA); + + HintedLocator.SearchPlan plan = HintedLocator.objectPlan(engine, "missing/object"); + + assertFalse(plan.isPossible()); + } + + @Test + public void findLocatesDistantBiomeBandWithoutChunkScan() throws Exception { + IrisRegion regionA = region("reg_a"); + regionA.setLandBiomes(keys("target")); + IrisBiome target = biome("target"); + IrisBiome plains = biome("plains"); + stubBiome(target); + stubRegions(regionA); + + when(complex.getRegionStream()).thenReturn(constantStream(regionA)); + when(complex.getLandBiomeStream()).thenReturn(positionalStream((x, z) -> x >= 4096D ? target : plains, plains)); + when(complex.getSeaBiomeStream()).thenReturn(constantStream(biome("sea_root"))); + when(complex.getShoreBiomeStream()).thenReturn(constantStream(biome("shore_root"))); + when(engine.getSurfaceBiome(anyInt(), anyInt())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + return x >= 4096 ? target : plains; + }); + + Locator locator = Locator.surfaceBiome("target"); + AtomicInteger checks = new AtomicInteger(); + Position2 result = locator.find(engine, new Position2(0, 0), 60_000, checks::set).get(); + + assertNotNull(result); + assertTrue(((result.getX() << 4) + 8) >= 4096); + } + + @Test + public void findReturnsNullFastForImpossibleTarget() throws Exception { + IrisRegion regionA = region("reg_a"); + regionA.setLandBiomes(keys("plains")); + stubBiome(biome("plains")); + stubRegions(regionA); + + Locator locator = Locator.surfaceBiome("missing"); + long start = System.currentTimeMillis(); + Position2 result = locator.find(engine, new Position2(0, 0), 120_000, (Integer count) -> { + }).get(); + long elapsed = System.currentTimeMillis() - start; + + assertNull(result); + assertTrue(elapsed < 10_000); + } + + @Test + public void findLocatesObjectThroughCoarseCascade() throws Exception { + IrisRegion regionA = region("reg_a"); + regionA.setLandBiomes(keys("host_biome")); + IrisBiome host = biome("host_biome"); + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setPlace(keys("trees/oak")); + KList placements = new KList<>(); + placements.add(placement); + host.setObjects(placements); + stubBiome(host); + stubRegions(regionA); + + when(complex.getRegionStream()).thenReturn(constantStream(regionA)); + when(complex.getLandBiomeStream()).thenReturn(positionalStream((x, z) -> x >= 1024D ? host : biome("plains"), host)); + when(complex.getSeaBiomeStream()).thenReturn(constantStream(biome("sea_root"))); + when(complex.getShoreBiomeStream()).thenReturn(constantStream(biome("shore_root"))); + when(engine.getObjectsAt(anyInt(), anyInt())).thenAnswer(invocation -> { + int chunkX = invocation.getArgument(0); + Set found = new HashSet<>(); + if (chunkX >= 64) { + found.add("trees/oak"); + } + return found; + }); + + Locator locator = Locator.object("trees/oak"); + Position2 result = locator.find(engine, new Position2(0, 0), 60_000, (Integer count) -> { + }).get(); + + assertNotNull(result); + assertTrue(result.getX() >= 64); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java index 92a1f1e91..1326ad8aa 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java @@ -19,9 +19,19 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.structure.StructureGraphCatalog; +import art.arcane.iris.engine.object.IrisDirection; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.NativeStructureSuppression; import art.arcane.iris.engine.object.ObjectPlaceMode; import art.arcane.iris.engine.object.StructureDistribution; import art.arcane.volmlib.util.collection.KList; @@ -35,6 +45,9 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -71,6 +84,196 @@ public class IrisStructureLocatorContractTest { assertFalse(IrisStructureLocator.suppressesVanilla(engine, "")); } + @Test + public void vanillaSourceRequiresExplicitDimensionSuppression() { + IrisData data = mock(IrisData.class); + IrisStructure structure = new IrisStructure(); + structure.setLoadKey("test:city"); + structure.setVanillaSource("minecraft:ancient_city"); + when(data.load(IrisStructure.class, "test:city", false)).thenReturn(structure); + registerSinglePieceGraph(data, structure, "test:city"); + + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.getStructures().add("test:city"); + IrisDimension dimension = mock(IrisDimension.class); + KList placements = new KList<>(); + placements.add(placement); + when(dimension.getStructures()).thenReturn(placements); + when(dimension.getAllRegions(any())).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(any())).thenReturn(new KList<>()); + + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(dimension); + + assertTrue(IrisStructureLocator.isPlaced(engine, "minecraft:ancient_city")); + assertFalse(IrisStructureLocator.suppressesVanilla(engine, "minecraft:ancient_city")); + placement.setNativeSuppression(NativeStructureSuppression.REPLACE_SOURCE); + IrisStructureLocator.invalidate(engine); + assertTrue(IrisStructureLocator.suppressesVanilla(engine, "minecraft:ancient_city")); + } + + @Test + public void regionReplacementFailsInsteadOfFallingBackToNative() { + IrisData data = mock(IrisData.class); + IrisStructure structure = new IrisStructure(); + structure.setLoadKey("test:city"); + structure.setVanillaSource("minecraft:ancient_city"); + when(data.load(IrisStructure.class, "test:city", false)).thenReturn(structure); + registerSinglePieceGraph(data, structure, "test:city"); + + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.getStructures().add("test:city"); + placement.setNativeSuppression(NativeStructureSuppression.REPLACE_SOURCE); + IrisRegion region = mock(IrisRegion.class); + when(region.getStructures()).thenReturn(new KList().qadd(placement)); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getStructures()).thenReturn(new KList<>()); + when(dimension.getAllRegions(any())).thenReturn(new KList().qadd(region)); + when(dimension.getReachableBiomes(any())).thenReturn(new KList<>()); + + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(dimension); + + assertReplacementFailure( + () -> IrisStructureLocator.suppressesVanilla(engine, "minecraft:ancient_city"), + "dimension-level"); + } + + @Test + public void geometryFailureFailsInsteadOfFallingBackToNative() { + IrisData data = mock(IrisData.class); + IrisStructure structure = new IrisStructure() + .setStartPool("test:start") + .setMaxDepth(1) + .setMaxSizeChunks(1) + .setVanillaSource("minecraft:ancient_city"); + structure.setLoadKey("test:city"); + IrisJigsawConnector source = new IrisJigsawConnector() + .setPosition(new IrisPosition(0, 0, 0)) + .setDirection(IrisDirection.EAST_POSITIVE_X) + .setPool("test:target") + .setName("source") + .setTargetName("door"); + IrisJigsawConnector target = new IrisJigsawConnector() + .setPosition(new IrisPosition(50, 0, 0)) + .setDirection(IrisDirection.WEST_NEGATIVE_X) + .setPool("test:target") + .setName("door") + .setTargetName("unused"); + IrisJigsawPiece startPiece = new IrisJigsawPiece().setObject("test:start-object").setRotatable(false); + startPiece.getConnectors().add(source); + IrisJigsawPiece targetPiece = new IrisJigsawPiece().setObject("test:target-object").setRotatable(false); + targetPiece.getConnectors().add(target); + IrisJigsawPool startPool = new IrisJigsawPool(); + startPool.getPieces().add(new IrisJigsawPieceEntry("test:start-piece", 1)); + IrisJigsawPool targetPool = new IrisJigsawPool(); + targetPool.getPieces().add(new IrisJigsawPieceEntry("test:target-piece", 1)); + IrisObject startObject = mock(IrisObject.class); + IrisObject targetObject = mock(IrisObject.class); + when(startObject.getW()).thenReturn(1); + when(startObject.getH()).thenReturn(1); + when(startObject.getD()).thenReturn(1); + when(targetObject.getW()).thenReturn(100); + when(targetObject.getH()).thenReturn(1); + when(targetObject.getD()).thenReturn(1); + when(data.load(IrisStructure.class, "test:city", false)).thenReturn(structure); + when(data.load(IrisJigsawPool.class, "test:start", false)).thenReturn(startPool); + when(data.load(IrisJigsawPool.class, "test:target", false)).thenReturn(targetPool); + when(data.load(IrisJigsawPiece.class, "test:start-piece", false)).thenReturn(startPiece); + when(data.load(IrisJigsawPiece.class, "test:target-piece", false)).thenReturn(targetPiece); + when(data.load(IrisObject.class, "test:start-object", false)).thenReturn(startObject); + when(data.load(IrisObject.class, "test:target-object", false)).thenReturn(targetObject); + assertTrue(StructureGraphCatalog.compile(data, structure).isAssemblyViable()); + + IrisStructurePlacement placement = new IrisStructurePlacement() + .setNativeSuppression(NativeStructureSuppression.REPLACE_SOURCE); + placement.getStructures().add("test:city"); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getStructures()).thenReturn(new KList().qadd(placement)); + when(dimension.getAllRegions(any())).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(any())).thenReturn(new KList<>()); + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(dimension); + + assertReplacementFailure( + () -> IrisStructureLocator.suppressesVanilla(engine, "minecraft:ancient_city"), + "not runtime-viable"); + } + + @Test + public void missingVanillaSourceFailsInsteadOfFallingBackToNative() { + IrisData data = mock(IrisData.class); + IrisStructure structure = new IrisStructure(); + structure.setLoadKey("test:city"); + when(data.load(IrisStructure.class, "test:city", false)).thenReturn(structure); + registerSinglePieceGraph(data, structure, "test:city"); + IrisStructurePlacement placement = new IrisStructurePlacement() + .setNativeSuppression(NativeStructureSuppression.REPLACE_SOURCE); + placement.getStructures().add("test:city"); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getStructures()).thenReturn(new KList().qadd(placement)); + when(dimension.getAllRegions(any())).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(any())).thenReturn(new KList<>()); + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(dimension); + + assertReplacementFailure( + () -> IrisStructureLocator.suppressesVanilla(engine, "minecraft:ancient_city"), + "valid namespaced vanillaSource"); + } + + @Test + public void missingNonReplacementStructureFailsInsteadOfBeingSkipped() { + IrisData data = mock(IrisData.class); + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.getStructures().add("test:missing"); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getStructures()).thenReturn(new KList().qadd(placement)); + when(dimension.getAllRegions(any())).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(any())).thenReturn(new KList<>()); + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(dimension); + + assertReplacementFailure( + () -> IrisStructureLocator.isPlaced(engine, "test:missing"), + "references missing structure 'test:missing'"); + } + + @Test + public void placementResolutionRejectsAnUnboundEngine() { + assertReplacementFailure( + () -> IrisStructureLocator.resolvePlacement(null, new IrisStructurePlacement(), 0, 0), + "fully bound engine"); + } + + @Test + public void replacementOutputFailureIncludesStructureAndChunkContext() { + IrisStructurePlacement placement = new IrisStructurePlacement() + .setNativeSuppression(NativeStructureSuppression.REPLACE_SOURCE); + placement.getStructures().add("test:city"); + + assertReplacementFailure( + () -> IrisStructureLocator.requirePlacementOutput( + placement, "test:city", 12, -7, false, "runtime assembly produced no pieces"), + "structure 'test:city' failed in chunk 12,-7: runtime assembly produced no pieces"); + } + + @Test + public void nonReplacementOutputAbsenceRemainsSkippable() { + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.getStructures().add("test:city"); + + assertFalse(IrisStructureLocator.requirePlacementOutput( + placement, "test:city", 12, -7, false, "runtime assembly produced no pieces")); + assertTrue(IrisStructureLocator.requirePlacementOutput( + placement, "test:city", 12, -7, true, "runtime assembly produced no pieces")); + } + @Test public void startsInChunkIsFalseForNullEngine() { assertFalse(IrisStructureLocator.startsInChunk(null, "minecraft:ancient_city", 0, 0)); @@ -229,6 +432,47 @@ public class IrisStructureLocatorContractTest { assertNull(IrisStructureLocator.resolveVerticalShift(clippedSurface, placement, 300, -64, 319)); } + @Test + public void undergroundAssemblyIsShiftedBelowEveryTerrainColumn() { + Engine engine = mock(Engine.class); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight(anyInt(), anyInt(), eq(true))) + .thenAnswer(invocation -> invocation.getArgument(0) == 1 ? 140 : 164); + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.setUnderground(true); + placement.setMinHeight(-64); + placement.setMaxHeight(100); + KList pieces = new KList<>(); + pieces.add(piece(0, 60, 0, 1, 80, 0)); + + assertEquals(Integer.valueOf(-5), IrisStructureLocator.resolveUndergroundBurialShift( + engine, pieces, placement, 60, -63, 319)); + + placement.setMinHeight(60); + placement.setMaxHeight(60); + assertNull(IrisStructureLocator.resolveUndergroundBurialShift( + engine, pieces, placement, 60, -63, 319)); + } + + @Test + public void undergroundBurialIncludesOverboreCeiling() { + Engine engine = mock(Engine.class); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight(anyInt(), anyInt(), eq(true))).thenReturn(164); + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.setUnderground(true); + placement.setMinHeight(-64); + placement.setMaxHeight(100); + placement.setOverbore(true); + placement.setOverboreRadius(2); + placement.setOverboreHeight(20); + KList pieces = new KList<>(); + pieces.add(piece(0, 60, 0, 1, 80, 1)); + + assertEquals(Integer.valueOf(-17), IrisStructureLocator.resolveUndergroundBurialShift( + engine, pieces, placement, 60, -63, 319)); + } + @Test public void exactYOnlyCoversPlacementPathsWithResolvedAbsoluteAnchors() { IrisStructurePlacement placement = new IrisStructurePlacement(); @@ -280,6 +524,7 @@ public class IrisStructureLocatorContractTest { IrisStructure structure = new IrisStructure(); structure.setLoadKey("test:density"); when(data.load(IrisStructure.class, "test:density", false)).thenReturn(structure); + registerSinglePieceGraph(data, structure, "test:density"); IrisStructurePlacement placement = new IrisStructurePlacement(); placement.getStructures().add("test:density"); @@ -303,4 +548,32 @@ public class IrisStructureLocatorContractTest { when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>()); return engine; } + + private void registerSinglePieceGraph(IrisData data, IrisStructure structure, String prefix) { + String poolKey = prefix + "/start"; + String pieceKey = prefix + "/piece"; + String objectKey = prefix + "/object"; + structure.setStartPool(poolKey); + IrisJigsawPool pool = new IrisJigsawPool(); + pool.getPieces().add(new IrisJigsawPieceEntry().setPiece(pieceKey).setWeight(1)); + IrisJigsawPiece piece = new IrisJigsawPiece(); + piece.setObject(objectKey); + IrisObject object = mock(IrisObject.class); + when(object.getW()).thenReturn(1); + when(object.getH()).thenReturn(1); + when(object.getD()).thenReturn(1); + when(data.load(IrisJigsawPool.class, poolKey, false)).thenReturn(pool); + when(data.load(IrisJigsawPiece.class, pieceKey, false)).thenReturn(piece); + when(data.load(IrisObject.class, objectKey, false)).thenReturn(object); + } + + private void assertReplacementFailure(Runnable operation, String expectedMessage) { + try { + operation.run(); + } catch (IllegalStateException e) { + assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage)); + return; + } + throw new AssertionError("Expected strict native replacement failure containing '" + expectedMessage + "'"); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/LocatorPoiCoordinateTest.java b/core/src/test/java/art/arcane/iris/engine/framework/LocatorPoiCoordinateTest.java new file mode 100644 index 000000000..5eae2eb15 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/LocatorPoiCoordinateTest.java @@ -0,0 +1,27 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.core.nms.container.BlockPos; +import art.arcane.iris.core.nms.container.Pair; +import art.arcane.volmlib.util.math.Position2; +import org.junit.Test; + +import java.util.Set; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class LocatorPoiCoordinateTest { + @Test + public void poiLocatorQueriesCandidateChunkCoordinates() { + Engine engine = mock(Engine.class); + BlockPos position = new BlockPos(50, 64, -30); + when(engine.getPOIsAt(3, -2)).thenReturn(Set.of(new Pair<>("buried_treasure", position))); + + boolean matched = Locator.poi("buried_treasure").matches(engine, new Position2(3, -2)); + + assertTrue(matched); + verify(engine).getPOIsAt(3, -2); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/LootResolverTest.java b/core/src/test/java/art/arcane/iris/engine/framework/LootResolverTest.java new file mode 100644 index 000000000..7a9859647 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/LootResolverTest.java @@ -0,0 +1,318 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.object.InventorySlotType; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisLootMode; +import art.arcane.iris.engine.object.IrisLootReference; +import art.arcane.iris.engine.object.IrisLootTable; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class LootResolverTest { + @Test + public void inclusiveRollCanReachBothConfiguredBounds() { + RNG rng = new RNG(417L); + Set results = new HashSet<>(); + + for (int i = 0; i < 256; i++) { + results.add(LootResolver.inclusive(rng, 1, 3)); + } + + assertEquals(Set.of(1, 2, 3), results); + } + + @Test + public void inclusiveRollNormalizesReversedBounds() { + RNG rng = new RNG(912L); + + for (int i = 0; i < 64; i++) { + int value = LootResolver.inclusive(rng, 7, 3); + assertTrue(value >= 3 && value <= 7); + } + } + + @Test + public void oneInUsesTheWholeConfiguredRange() { + RNG success = new FixedRollRng(0); + RNG failure = new FixedRollRng(7); + + assertTrue(LootResolver.oneIn(success, 8)); + assertFalse(LootResolver.oneIn(failure, 8)); + assertTrue(LootResolver.oneIn(failure, 1)); + } + + @Test + public void spatialSeedsIncludeWorldTableAndAbsolutePosition() { + IrisLootTable first = new IrisLootTable().setName("first"); + IrisLootTable second = new IrisLootTable().setName("second"); + + long baseline = LootResolver.tableSeed(41L, first, 0, 64, 0); + + assertEquals(baseline, LootResolver.tableSeed(41L, first, 0, 64, 0)); + assertNotEquals(baseline, LootResolver.tableSeed(42L, first, 0, 64, 0)); + assertNotEquals(baseline, LootResolver.tableSeed(41L, second, 0, 64, 0)); + assertNotEquals(baseline, LootResolver.tableSeed(41L, first, 16, 64, 0)); + } + + @Test + public void spatialRarityIsStableAndDistributed() { + IrisLootTable table = new IrisLootTable().setName("distribution"); + int successes = 0; + + for (int x = 0; x < 65_536; x++) { + if (LootResolver.spatialOneIn(91L, table, 3, x, 40, x * 31, 8L)) { + successes++; + } + } + + assertTrue(successes > 7_600); + assertTrue(successes < 8_800); + assertEquals( + LootResolver.spatialOneIn(91L, table, 3, 112, 40, 992, 8L), + LootResolver.spatialOneIn(91L, table, 3, 112, 40, 992, 8L) + ); + } + + @Test + public void nativeOrObjectLootSuppressesFallbackReferences() { + ResolutionFixture fixture = new ResolutionFixture(); + List sources = new ArrayList<>(); + + LootResolver.resolveEnvironmentSources( + sources, + fixture.engine, + new RNG(19L), + 10, + 70, + 20, + true, + table -> table + ); + + assertTrue(sources.isEmpty()); + } + + @Test + public void emptyPlacementUsesEachFallbackScopeOnce() { + ResolutionFixture fixture = new ResolutionFixture(); + List sources = new ArrayList<>(); + + LootResolver.resolveEnvironmentSources( + sources, + fixture.engine, + new RNG(19L), + 10, + 70, + 20, + false, + table -> table + ); + + assertEquals(List.of(fixture.dimensionTable, fixture.regionTable, fixture.surfaceTable), sources); + } + + @Test + public void sourceScalingCanSelectTheLastSource() { + List sources = new ArrayList<>(List.of("first", "last")); + RNG rng = new FixedRollRng(1); + + LootResolver.scaleSources(sources, 1.5D, rng); + + assertEquals(List.of("first", "last", "last"), sources); + } + + @Test + public void sourceScalingRejectsUnsafeMultipliersBeforeMutation() { + List sources = new ArrayList<>(List.of("first", "second")); + + IllegalArgumentException nonFinite = assertThrows(IllegalArgumentException.class, + () -> LootResolver.scaleSources(sources, Double.POSITIVE_INFINITY, new RNG(17L))); + IllegalArgumentException negative = assertThrows(IllegalArgumentException.class, + () -> LootResolver.scaleSources(sources, -1D, new RNG(17L))); + + assertTrue(nonFinite.getMessage().contains("must be finite")); + assertTrue(negative.getMessage().contains("non-negative")); + assertEquals(List.of("first", "second"), sources); + } + + @Test + public void sourceScalingRejectsExcessiveTargetAllocationBeforeMutation() { + List sources = new ArrayList<>(); + for (int index = 0; index < 17; index++) { + sources.add("table-" + index); + } + + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> LootResolver.scaleSources(sources, IrisLootReference.MAX_MULTIPLIER, new RNG(17L))); + + assertTrue(failure.getMessage().contains("maximum of 256 sources")); + assertEquals(17, sources.size()); + } + + @Test + public void sourceScalingAllowsExactResolvedSourceBoundary() { + List sources = new ArrayList<>(); + for (int index = 0; index < 16; index++) { + sources.add("table-" + index); + } + + LootResolver.scaleSources(sources, IrisLootReference.MAX_MULTIPLIER, new RNG(17L)); + + assertEquals(256, sources.size()); + } + + @Test + public void sourceScalingRejectsNumericOverflowBeforeMutation() { + List sources = new ArrayList<>(List.of("first", "second")); + + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> LootResolver.scaleSources(sources, Double.MAX_VALUE, new RNG(17L))); + + assertTrue(failure.getMessage().contains("maximum of 256 sources")); + assertEquals(List.of("first", "second"), sources); + } + + @Test + public void sourceScalingRejectsOversizedInputBeforeSnapshotAllocation() { + List sources = new ArrayList<>(); + for (int index = 0; index < 257; index++) { + sources.add("table-" + index); + } + + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> LootResolver.scaleSources(sources, 0D, new RNG(17L))); + + assertTrue(failure.getMessage().contains("starts above the maximum of 256")); + assertEquals(257, sources.size()); + } + + @Test + public void wholeNumberScalingPreservesTableBalanceAndIsDeterministic() { + List first = new ArrayList<>(List.of("alpha", "beta", "gamma")); + List second = new ArrayList<>(first); + + LootResolver.scaleSources(first, 4D, new RNG(817L)); + LootResolver.scaleSources(second, 4D, new RNG(817L)); + + assertEquals(first, second); + assertEquals(4, Collections.frequency(first, "alpha")); + assertEquals(4, Collections.frequency(first, "beta")); + assertEquals(4, Collections.frequency(first, "gamma")); + } + + @Test + public void combinedMultiplierAboveReferenceCapRemainsUsableUnderResolvedSourceCap() { + List sources = new ArrayList<>(List.of("table")); + + LootResolver.scaleSources(sources, IrisLootReference.MAX_MULTIPLIER * 2D, new RNG(17L)); + + assertEquals(32, sources.size()); + } + + @Test + public void lootMultiplierCapIsPublishedInSchemaMetadata() throws Exception { + Field multiplier = IrisLootReference.class.getDeclaredField("multiplier"); + MaxNumber maximum = multiplier.getAnnotation(MaxNumber.class); + Desc description = multiplier.getAnnotation(Desc.class); + + assertEquals(IrisLootReference.MAX_MULTIPLIER, maximum.value(), 0D); + assertTrue(description.value().contains("0 to 16")); + } + + @Test + public void emptyLootTableReturnsNoItems() { + IrisLootTable table = new IrisLootTable().setName("empty"); + + assertTrue(table.getLoot(false, 7L, InventorySlotType.STORAGE, null, 0, 64, 0).isEmpty()); + } + + @Test + public void weightedSelectionRejectsNonPositivePoolsAndCanReachLastEntry() { + List invalid = List.of(new WeightedChoice("zero", 0), new WeightedChoice("negative", -4)); + List valid = List.of(new WeightedChoice("first", 1), new WeightedChoice("last", 1)); + + assertNull(LootResolver.pickWeighted(invalid, WeightedChoice::weight, new FixedRollRng(0))); + assertEquals("last", LootResolver.pickWeighted(valid, WeightedChoice::weight, new FixedRollRng(1)).name()); + } + + private static final class ResolutionFixture { + private final Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + private final IrisLootTable dimensionTable = new IrisLootTable().setName("dimension"); + private final IrisLootTable regionTable = new IrisLootTable().setName("region"); + private final IrisLootTable surfaceTable = new IrisLootTable().setName("surface"); + + private ResolutionFixture() { + IrisDimension dimension = mock(IrisDimension.class); + IrisRegion region = mock(IrisRegion.class); + IrisBiome surface = mock(IrisBiome.class); + IrisComplex complex = engine.getComplex(); + IrisLootReference dimensionReference = reference(dimensionTable); + IrisLootReference regionReference = reference(regionTable); + IrisLootReference surfaceReference = reference(surfaceTable); + + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getLoot()).thenReturn(dimensionReference); + when(region.getLoot()).thenReturn(regionReference); + when(surface.getLoot()).thenReturn(surfaceReference); + when(surface.getLoadKey()).thenReturn("surface"); + when(complex.getRegionStream().get(10, 20)).thenReturn(region); + when(complex.getTrueBiomeStream().get(10, 20)).thenReturn(surface); + when(complex.getHeightStream().get(10, 20)).thenReturn(64D); + when(engine.getCaveBiome(10, 70, 20)).thenReturn(surface); + } + + private IrisLootReference reference(IrisLootTable table) { + IrisLootReference reference = mock(IrisLootReference.class); + when(reference.getMode()).thenReturn(IrisLootMode.FALLBACK); + when(reference.getMultiplier()).thenReturn(1D); + when(reference.getLootTables(any())).thenReturn(new KList<>(table)); + return reference; + } + } + + private static final class FixedRollRng extends RNG { + private final int roll; + + private FixedRollRng(int roll) { + super(1L); + this.roll = roll; + } + + @Override + public int nextInt(int bound) { + return Math.min(roll, bound - 1); + } + + @Override + public long nextLong(long bound) { + return Math.min(roll, bound - 1L); + } + } + + private record WeightedChoice(String name, int weight) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureGenerationPolicyTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureGenerationPolicyTest.java new file mode 100644 index 000000000..21e23b0be --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureGenerationPolicyTest.java @@ -0,0 +1,20 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class NativeStructureGenerationPolicyTest { + @Test + public void generationStatusMessagesAreSharedAcrossPlatforms() { + assertEquals( + "Native structure minecraft:village_plains is disabled by this dimension's importedStructures settings.", + NativeStructureGenerationPolicy.generationStatusMessage( + "minecraft:village_plains", NativeStructureGenerationStatus.DISABLED_BY_PACK)); + assertEquals( + "Native structure minecraft:ancient_city is replaced by an Iris placement in this pack and locates through that explicit replacement.", + NativeStructureGenerationPolicy.generationStatusMessage( + "minecraft:ancient_city", NativeStructureGenerationStatus.REPLACED_BY_IRIS)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerTest.java new file mode 100644 index 000000000..5d11430b8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerTest.java @@ -0,0 +1,455 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.framework; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.JigsawJoint; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class StructureAssemblerTest { + @BeforeClass + public static void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void attachedNonRotatablePieceKeepsItsAuthoredFacing() { + Fixture fixture = new Fixture(); + IrisJigsawConnector startConnector = connector(IrisDirection.EAST_POSITIVE_X, "target", "start", "door"); + IrisJigsawPiece startPiece = piece("start-object", false, startConnector); + IrisJigsawConnector candidateConnector = connector(IrisDirection.NORTH_NEGATIVE_Z, "", "door", ""); + IrisJigsawPiece candidatePiece = piece("candidate-object", false, candidateConnector); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("target", pool("", "candidate-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("candidate-piece", candidatePiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("candidate-object", new IrisObject(1, 1, 1)); + + KList fixed = fixture.assembler(structure("start", 1), 0, 0, 0).assemble(new RNG(11L)); + + assertNull(fixed); + + candidatePiece.setRotatable(true); + KList rotatable = fixture.assembler(structure("start", 1), 0, 0, 0).assemble(new RNG(11L)); + + assertEquals(2, rotatable.size()); + assertSame(candidatePiece, rotatable.get(1).getPiece()); + } + + @Test + public void fallbackCandidatesAreTriedAfterPrimaryCandidatesFail() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "branch", "start", "door")); + IrisJigsawPiece incompatiblePiece = piece("incompatible-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "wrong", "")); + IrisJigsawPiece fallbackPiece = piece("fallback-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "")); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("branch", pool("fallback", "incompatible-piece")); + fixture.pool("fallback", pool("", "fallback-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("incompatible-piece", incompatiblePiece); + fixture.piece("fallback-piece", fallbackPiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("incompatible-object", new IrisObject(1, 1, 1)); + fixture.object("fallback-object", new IrisObject(1, 1, 1)); + + KList placed = fixture.assembler(structure("start", 2), 0, 0, 0).assemble(new RNG(29L)); + + assertEquals(2, placed.size()); + assertSame(fallbackPiece, placed.get(1).getPiece()); + } + + @Test + public void alignedRuntimeConnectorsPreserveTopOrientation() { + Fixture fixture = new Fixture(); + IrisJigsawConnector source = connector(IrisDirection.EAST_POSITIVE_X, "target", "start", "door") + .setJoint(JigsawJoint.ALIGNED) + .setTop(IrisDirection.UP_POSITIVE_Y); + IrisJigsawConnector target = connector(IrisDirection.WEST_NEGATIVE_X, "target", "door", "unused") + .setTop(IrisDirection.DOWN_NEGATIVE_Y); + IrisJigsawPiece startPiece = piece("start-object", false, source); + IrisJigsawPiece targetPiece = piece("target-object", false, target); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("target", pool("", "target-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("target-piece", targetPiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("target-object", new IrisObject(1, 1, 1)); + + assertNull(fixture.assembler(structure("start", 1), 0, 0, 0).assemble(new RNG(13L))); + + target.setTop(IrisDirection.UP_POSITIVE_Y); + KList placed = fixture.assembler(structure("start", 1), 0, 0, 0) + .assemble(new RNG(13L)); + assertEquals(2, placed.size()); + } + + @Test + public void fallbackAtMaximumDepthDoesNotExpandAgain() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "branch", "start", "door")); + IrisJigsawPiece branchPiece = piece("branch-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", ""), + connector(IrisDirection.EAST_POSITIVE_X, "terminal", "branch", "door")); + IrisJigsawPiece primaryTerminal = piece("primary-terminal-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "")); + IrisJigsawPiece fallbackTerminal = piece("fallback-terminal-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", ""), + connector(IrisDirection.EAST_POSITIVE_X, "terminal", "terminal", "door")); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("branch", pool("", "branch-piece")); + fixture.pool("terminal", pool("terminal-fallback", "primary-terminal-piece")); + fixture.pool("terminal-fallback", pool("", "fallback-terminal-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("branch-piece", branchPiece); + fixture.piece("primary-terminal-piece", primaryTerminal); + fixture.piece("fallback-terminal-piece", fallbackTerminal); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("branch-object", new IrisObject(1, 1, 1)); + fixture.object("primary-terminal-object", new IrisObject(1, 1, 1)); + fixture.object("fallback-terminal-object", new IrisObject(1, 1, 1)); + IrisStructure structure = structure("start", 1); + structure.setMaxSizeChunks(1); + + KList placed = fixture.assembler(structure, 0, 0, 0).assemble(new RNG(41L)); + + assertEquals(3, placed.size()); + assertSame(branchPiece, placed.get(1).getPiece()); + assertSame(fallbackTerminal, placed.get(2).getPiece()); + } + + @Test + public void fallbackOfFallbackIsNotSelected() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "branch", "start", "door")); + IrisJigsawPiece incompatiblePiece = piece("incompatible-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "wrong", "")); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("branch", pool("terminators", "incompatible-piece")); + fixture.pool("terminators", pool("empty", "incompatible-piece")); + fixture.pool("empty", pool("")); + fixture.piece("start-piece", startPiece); + fixture.piece("incompatible-piece", incompatiblePiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("incompatible-object", new IrisObject(1, 1, 1)); + + assertNull(fixture.assembler(structure("start", 2), 0, 0, 0).assemble(new RNG(31L))); + } + + @Test + public void evenSizedBoundsContainExactlyTheObjectFootprint() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false); + fixture.pool("start", pool("", "start-piece")); + fixture.piece("start-piece", startPiece); + fixture.object("start-object", new IrisObject(4, 2, 6)); + + PlacedStructurePiece placed = fixture.assembler(structure("start", 1), 10, 20, 30) + .assemble(new RNG(53L)).getFirst(); + + assertBounds(placed, 8, 19, 27, 11, 20, 32); + } + + @Test + public void rotatedEvenSizedBoundsPreserveAsymmetricCentering() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", true); + fixture.pool("start", pool("", "start-piece")); + fixture.piece("start-piece", startPiece); + fixture.object("start-object", new IrisObject(4, 2, 6)); + + StructureAssembler assembler = fixture.assembler(structure("start", 1), 10, 20, 30); + assertBounds(assembler.assemble(rotationIndex(1)).getFirst(), 7, 19, 29, 12, 20, 32); + assertBounds(assembler.assemble(rotationIndex(2)).getFirst(), 9, 19, 28, 12, 20, 33); + assertBounds(assembler.assemble(rotationIndex(3)).getFirst(), 8, 19, 28, 13, 20, 31); + } + + @Test + public void collisionUsesHalfOpenEdgesForInclusivePieceBounds() { + PlacedStructurePiece origin = bounds(0, 0, 0, 0, 0, 0); + PlacedStructurePiece overlap = bounds(0, 0, 0, 0, 0, 0); + PlacedStructurePiece adjacent = bounds(1, 0, 0, 1, 0, 0); + + assertTrue(origin.intersects(overlap)); + assertFalse(origin.intersects(adjacent)); + assertFalse(adjacent.intersects(origin)); + } + + @Test + public void maximumWeightsCannotOverflowRuntimeSelection() { + Fixture fixture = new Fixture(); + IrisJigsawPiece first = piece("first-object", false); + IrisJigsawPiece second = piece("second-object", false); + IrisJigsawPool start = new IrisJigsawPool(); + start.getPieces().add(new IrisJigsawPieceEntry("first-piece", Integer.MAX_VALUE)); + start.getPieces().add(new IrisJigsawPieceEntry("second-piece", Integer.MAX_VALUE)); + fixture.pool("start", start); + fixture.piece("first-piece", first); + fixture.piece("second-piece", second); + fixture.object("first-object", new IrisObject(1, 1, 1)); + fixture.object("second-object", new IrisObject(1, 1, 1)); + + for (long seed = 0; seed < 32; seed++) { + KList placed = fixture.assembler(structure("start", 1), 0, 0, 0) + .assemble(new RNG(seed)); + assertEquals(1, placed.size()); + } + } + + @Test + public void malformedStartConnectorDataFailsLoudly() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false); + startPiece.setConnectors(null); + fixture.pool("start", pool("", "start-piece")); + fixture.piece("start-piece", startPiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + + assertThrows(IllegalStateException.class, + () -> fixture.assembler(structure("start", 1), 0, 0, 0).assemble(new RNG(3L))); + } + + @Test + public void corruptWeightedPeerFailsBeforeCandidateSelection() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "branch", "start", "door")); + IrisJigsawPiece validPiece = piece("valid-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "")); + IrisJigsawPiece fallbackPiece = piece("fallback-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "")); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("branch", pool("fallback", "valid-piece", "missing-piece")); + fixture.pool("fallback", pool("", "fallback-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("valid-piece", validPiece); + fixture.piece("fallback-piece", fallbackPiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("valid-object", new IrisObject(1, 1, 1)); + fixture.object("fallback-object", new IrisObject(1, 1, 1)); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> fixture.assembler(structure("start", 2), 0, 0, 0).assemble(new RNG(17L))); + + assertTrue(failure.getMessage().contains("branch")); + assertTrue(failure.getMessage().contains("missing-piece")); + } + + @Test + public void corruptUnselectedStartEntryFailsBeforeCandidateSelection() { + Fixture fixture = new Fixture(); + IrisJigsawPiece validPiece = piece("valid-object", false); + fixture.pool("start", pool("", "valid-piece", "missing-piece")); + fixture.piece("valid-piece", validPiece); + fixture.object("valid-object", new IrisObject(1, 1, 1)); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> fixture.assembler(structure("start", 1), 0, 0, 0).assemble(new RNG(18L))); + + assertTrue(failure.getMessage().contains("start")); + assertTrue(failure.getMessage().contains("missing-piece")); + } + + @Test + public void malformedNestedConnectorDoesNotFallThroughToAuthoredFallback() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "branch", "start", "door")); + IrisJigsawPiece malformedPiece = piece("malformed-object", false); + malformedPiece.getConnectors().add((IrisJigsawConnector) null); + IrisJigsawPiece fallbackPiece = piece("fallback-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "")); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("branch", pool("fallback", "malformed-piece")); + fixture.pool("fallback", pool("", "fallback-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("malformed-piece", malformedPiece); + fixture.piece("fallback-piece", fallbackPiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("malformed-object", new IrisObject(1, 1, 1)); + fixture.object("fallback-object", new IrisObject(1, 1, 1)); + + assertThrows(IllegalStateException.class, + () -> fixture.assembler(structure("start", 2), 0, 0, 0).assemble(new RNG(19L))); + } + + @Test + public void emptyPoolChoiceTerminatesTheBranchWithoutPlacingAir() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "terminal", "start", "door")); + fixture.pool("start", pool("", "start-piece")); + IrisJigsawPool terminal = new IrisJigsawPool(); + terminal.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true).setWeight(1)); + fixture.pool("terminal", terminal); + fixture.piece("start-piece", startPiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + + KList placed = fixture.assembler(structure("start", 2), 0, 0, 0) + .assemble(new RNG(7L)); + + assertEquals(1, placed.size()); + assertSame(startPiece, placed.getFirst().getPiece()); + } + + @Test + public void emptyStartChoiceProducesAnIntentionalEmptyAssembly() { + Fixture fixture = new Fixture(); + IrisJigsawPool start = new IrisJigsawPool(); + start.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true).setWeight(1)); + fixture.pool("start", start); + + KList placed = fixture.assembler(structure("start", 1), 0, 0, 0) + .assemble(new RNG(9L)); + + assertTrue(placed.isEmpty()); + } + + @Test + public void assemblerRejectsLimitsOutsideTheDeclaredContract() { + Fixture fixture = new Fixture(); + IrisStructure deep = structure("start", 31); + IrisStructure wide = structure("start", 1).setMaxSizeChunks(33); + IrisStructure overflowing = structure("start", 1).setMaxSizeChunks(Integer.MAX_VALUE); + + assertThrows(IllegalStateException.class, + () -> fixture.assembler(deep, 0, 0, 0).assemble(new RNG(1L))); + assertThrows(IllegalStateException.class, + () -> fixture.assembler(wide, 0, 0, 0).assemble(new RNG(1L))); + assertThrows(IllegalStateException.class, + () -> fixture.assembler(overflowing, 0, 0, 0).assemble(new RNG(1L))); + } + + private static IrisJigsawConnector connector(IrisDirection direction, String pool, String name, String targetName) { + return new IrisJigsawConnector() + .setPosition(new IrisPosition()) + .setDirection(direction) + .setPool(pool == null || pool.isBlank() ? "terminal" : pool) + .setName(name) + .setTargetName(targetName) + .setJoint(JigsawJoint.ROLLABLE); + } + + private static IrisJigsawPiece piece(String object, boolean rotatable, IrisJigsawConnector... connectors) { + IrisJigsawPiece piece = new IrisJigsawPiece().setObject(object).setRotatable(rotatable); + piece.getConnectors().add(connectors); + return piece; + } + + private static IrisJigsawPool pool(String fallback, String... pieces) { + IrisJigsawPool pool = new IrisJigsawPool().setFallback(fallback); + for (String piece : pieces) { + pool.getPieces().add(new IrisJigsawPieceEntry().setPiece(piece).setWeight(1)); + } + return pool; + } + + private static IrisStructure structure(String startPool, int maxDepth) { + return new IrisStructure().setStartPool(startPool).setMaxDepth(maxDepth).setMaxSizeChunks(8); + } + + private static RNG rotationIndex(int index) { + return new RNG(67L + index) { + @Override + public int i(int upperBound) { + return upperBound == 4 ? index : 0; + } + }; + } + + private static PlacedStructurePiece bounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + return new PlacedStructurePiece(null, null, 0, 0, 0, null, minX, minY, minZ, maxX, maxY, maxZ); + } + + private static void assertBounds(PlacedStructurePiece piece, int minX, int minY, int minZ, + int maxX, int maxY, int maxZ) { + assertEquals(minX, piece.getMinX()); + assertEquals(minY, piece.getMinY()); + assertEquals(minZ, piece.getMinZ()); + assertEquals(maxX, piece.getMaxX()); + assertEquals(maxY, piece.getMaxY()); + assertEquals(maxZ, piece.getMaxZ()); + } + + private static final class Fixture { + private final IrisData data = mock(IrisData.class); + + private Fixture() { + when(data.load(IrisJigsawPool.class, "terminal", false)).thenReturn(new IrisJigsawPool()); + } + + private void pool(String key, IrisJigsawPool pool) { + when(data.load(IrisJigsawPool.class, key, false)).thenReturn(pool); + } + + private void piece(String key, IrisJigsawPiece piece) { + when(data.load(IrisJigsawPiece.class, key, false)).thenReturn(piece); + } + + private void object(String key, IrisObject object) { + when(data.load(IrisObject.class, key, false)).thenReturn(object); + } + + private StructureAssembler assembler(IrisStructure structure, int x, int y, int z) { + return StructureAssembler.forData(data, structure, new IrisPosition(x, y, z)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java index a166c60d4..a45775203 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java @@ -107,29 +107,32 @@ public class StructurePlacementGridTest { } @Test - public void densityStartIsDeterministicAndOrdinalScoped() { + public void densityStartIsDeterministicAndPlacementIdScoped() { IrisStructurePlacement placement = placement(StructureDistribution.DENSITY); placement.setDensity(0.35); - boolean differsByOrdinal = false; + IrisStructurePlacement secondPlacement = placement(StructureDistribution.DENSITY); + secondPlacement.setDensity(0.35); + secondPlacement.setPlacementId("second"); + boolean differsById = false; for (int cx = -32; cx <= 32; cx++) { for (int cz = -32; cz <= 32; cz++) { - boolean first = StructurePlacementGrid.startsInChunk(placement, cx, cz, 912345L, 0); - boolean repeated = StructurePlacementGrid.startsInChunk(placement, cx, cz, 912345L, 0); - boolean secondPlacement = StructurePlacementGrid.startsInChunk(placement, cx, cz, 912345L, 1); + boolean first = StructurePlacementGrid.startsInChunk(placement, cx, cz, 912345L); + boolean repeated = StructurePlacementGrid.startsInChunk(placement, cx, cz, 912345L); + boolean second = StructurePlacementGrid.startsInChunk(secondPlacement, cx, cz, 912345L); assertEquals(first, repeated); - differsByOrdinal |= first != secondPlacement; + differsById |= first != second; } } - assertTrue(differsByOrdinal); + assertTrue(differsById); } @Test public void densityBoundariesNeverAndAlwaysStart() { IrisStructurePlacement placement = placement(StructureDistribution.DENSITY); placement.setDensity(0.0); - assertFalse(StructurePlacementGrid.startsInChunk(placement, 4, -9, 77L, 0)); + assertFalse(StructurePlacementGrid.startsInChunk(placement, 4, -9, 77L)); placement.setDensity(1.0); - assertTrue(StructurePlacementGrid.startsInChunk(placement, 4, -9, 77L, 0)); + assertTrue(StructurePlacementGrid.startsInChunk(placement, 4, -9, 77L)); } @Test @@ -144,7 +147,7 @@ public class StructurePlacementGridTest { int starts = 0; for (int cx = -scanRadius; cx <= scanRadius; cx++) { for (int cz = -scanRadius; cz <= scanRadius; cz++) { - if (StructurePlacementGrid.startsInChunk(placement, cx, cz, seed, 0)) { + if (StructurePlacementGrid.startsInChunk(placement, cx, cz, seed)) { starts++; } } @@ -154,15 +157,100 @@ public class StructurePlacementGridTest { } @Test - public void placementRngIsStableAndOrdinalScoped() { + public void placementRngIsStableAndPlacementIdScoped() { IrisStructurePlacement placement = placement(StructureDistribution.RANDOM_SPREAD); - long first = StructurePlacementGrid.placementRng(placement, 7, -11, 123L, 0).getSeed(); - long repeated = StructurePlacementGrid.placementRng(placement, 7, -11, 123L, 0).getSeed(); - long secondPlacement = StructurePlacementGrid.placementRng(placement, 7, -11, 123L, 1).getSeed(); + long first = StructurePlacementGrid.placementRng(placement, 7, -11, 123L).getSeed(); + long repeated = StructurePlacementGrid.placementRng(placement, 7, -11, 123L).getSeed(); + placement.setPlacementId("second"); + long secondPlacement = StructurePlacementGrid.placementRng(placement, 7, -11, 123L).getSeed(); assertEquals(first, repeated); assertNotEquals(first, secondPlacement); } + @Test + public void authoredPlacementIdSeparatesRandomSpreadStarts() { + IrisStructurePlacement first = placement(StructureDistribution.RANDOM_SPREAD); + first.setPlacementId("first"); + IrisStructurePlacement second = placement(StructureDistribution.RANDOM_SPREAD); + second.setPlacementId("second"); + int[] firstStart = StructurePlacementGrid.randomSpreadCellChunk( + 8, -3, first.getSpacing(), first.getSeparation(), StructurePlacementGrid.placementSalt(first), 31337L); + int[] secondStart = StructurePlacementGrid.randomSpreadCellChunk( + 8, -3, second.getSpacing(), second.getSeparation(), StructurePlacementGrid.placementSalt(second), 31337L); + assertFalse(firstStart[0] == secondStart[0] && firstStart[1] == secondStart[1]); + } + + @Test + public void blankIdsDeriveDistinctStableRandomSpreadGridsFromContent() { + IrisStructurePlacement first = placement(StructureDistribution.RANDOM_SPREAD); + IrisStructurePlacement recreatedFirst = placement(StructureDistribution.RANDOM_SPREAD); + IrisStructurePlacement second = placement(StructureDistribution.RANDOM_SPREAD); + second.getStructures().clear(); + second.getStructures().add("test:other"); + + assertEquals(StructurePlacementGrid.placementSalt(first), + StructurePlacementGrid.placementSalt(recreatedFirst)); + assertNotEquals(StructurePlacementGrid.placementSalt(first), + StructurePlacementGrid.placementSalt(second)); + boolean differs = false; + for (int cell = -8; cell <= 8; cell++) { + int[] firstStart = StructurePlacementGrid.randomSpreadCellChunk( + cell, cell + 3, first.getSpacing(), first.getSeparation(), + StructurePlacementGrid.placementSalt(first), 31337L); + int[] secondStart = StructurePlacementGrid.randomSpreadCellChunk( + cell, cell + 3, second.getSpacing(), second.getSeparation(), + StructurePlacementGrid.placementSalt(second), 31337L); + differs |= firstStart[0] != secondStart[0] || firstStart[1] != secondStart[1]; + } + assertTrue(differs); + } + + @Test + public void blankIdsDeriveDistinctStableConcentricRingsFromContent() { + IrisStructurePlacement first = placement(StructureDistribution.CONCENTRIC_RINGS); + IrisStructurePlacement recreatedFirst = placement(StructureDistribution.CONCENTRIC_RINGS); + IrisStructurePlacement second = placement(StructureDistribution.CONCENTRIC_RINGS); + second.getStructures().clear(); + second.getStructures().add("test:other"); + first.setRingCount(12); + recreatedFirst.setRingCount(12); + second.setRingCount(12); + + boolean differs = false; + for (int index = 0; index < first.getRingCount(); index++) { + int[] firstPoint = StructurePlacementGrid.concentricRingChunk(first, index, 77123L); + int[] recreatedPoint = StructurePlacementGrid.concentricRingChunk(recreatedFirst, index, 77123L); + int[] secondPoint = StructurePlacementGrid.concentricRingChunk(second, index, 77123L); + assertEquals(firstPoint[0], recreatedPoint[0]); + assertEquals(firstPoint[1], recreatedPoint[1]); + differs |= firstPoint[0] != secondPoint[0] || firstPoint[1] != secondPoint[1]; + } + assertTrue(differs); + } + + @Test + public void derivedPlacementIdentityIncludesPlacementSettings() { + IrisStructurePlacement first = placement(StructureDistribution.DENSITY); + IrisStructurePlacement second = placement(StructureDistribution.DENSITY); + second.setMinHeight(first.getMinHeight() + 1); + long firstSeed = StructurePlacementGrid.placementRng(first, 9, 14, 42L).getSeed(); + long secondSeed = StructurePlacementGrid.placementRng(second, 9, 14, 42L).getSeed(); + assertNotEquals(firstSeed, secondSeed); + } + + @Test + public void explicitPlacementIdentityKeepsRngStableAcrossSettingEdits() { + IrisStructurePlacement first = placement(StructureDistribution.DENSITY); + first.setPlacementId("stable"); + IrisStructurePlacement second = placement(StructureDistribution.DENSITY); + second.setPlacementId("stable"); + second.setMinHeight(first.getMinHeight() + 1); + second.setDensity(0.75); + long firstSeed = StructurePlacementGrid.placementRng(first, 9, 14, 42L).getSeed(); + long secondSeed = StructurePlacementGrid.placementRng(second, 9, 14, 42L).getSeed(); + assertEquals(firstSeed, secondSeed); + } + private IrisStructurePlacement placement(StructureDistribution distribution) { IrisStructurePlacement placement = new IrisStructurePlacement(); placement.setDistribution(distribution); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructureReachabilityContractTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructureReachabilityContractTest.java index 11cd9e403..3386391a0 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/StructureReachabilityContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructureReachabilityContractTest.java @@ -18,57 +18,73 @@ package art.arcane.iris.engine.framework; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.PlatformWorld; import org.junit.Test; -import static org.junit.Assert.assertFalse; +import java.util.List; + +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class StructureReachabilityContractTest { @Test - public void reachableKeysIsEmptyForNullEngine() { - assertTrue(StructureReachability.reachableKeys(null).isEmpty()); + public void reachableKeysRejectsNullEngine() { + assertThrows(NullPointerException.class, () -> StructureReachability.reachableKeys(null)); } @Test - public void reachableKeysIsEmptyWhenEngineHasNoData() { + public void reachableKeysRejectsEngineWithoutData() { Engine engine = mock(Engine.class); - assertTrue(StructureReachability.reachableKeys(engine).isEmpty()); + assertThrows(IllegalStateException.class, () -> StructureReachability.reachableKeys(engine)); } @Test - public void isReachableIsFalseForNullEngine() { - assertFalse(StructureReachability.isReachable(null, "minecraft:village_taiga")); + public void isReachableRejectsNullEngine() { + assertThrows(NullPointerException.class, + () -> StructureReachability.isReachable(null, "minecraft:village_taiga")); } @Test - public void isReachableIsFalseForNullOrEmptyKey() { + public void isReachableRejectsNullOrEmptyKey() { Engine engine = mock(Engine.class); - assertFalse(StructureReachability.isReachable(engine, null)); - assertFalse(StructureReachability.isReachable(engine, "")); + assertThrows(IllegalArgumentException.class, () -> StructureReachability.isReachable(engine, null)); + assertThrows(IllegalArgumentException.class, () -> StructureReachability.isReachable(engine, "")); } @Test - public void isReachableIsFalseWhenNothingIsReachable() { + public void isReachableRejectsEngineWithoutData() { Engine engine = mock(Engine.class); - assertFalse(StructureReachability.isReachable(engine, "minecraft:village_taiga")); + assertThrows(IllegalStateException.class, + () -> StructureReachability.isReachable(engine, "minecraft:village_taiga")); } @Test - public void missingBiomeKeysIsEmptyForNullEngine() { - assertTrue(StructureReachability.missingBiomeKeys(null, "minecraft:village_taiga").isEmpty()); + public void missingBiomeKeysRejectsNullEngine() { + assertThrows(NullPointerException.class, + () -> StructureReachability.missingBiomeKeys(null, "minecraft:village_taiga")); } @Test - public void missingBiomeKeysIsEmptyForNullKey() { + public void missingBiomeKeysRejectsNullKey() { Engine engine = mock(Engine.class); - assertTrue(StructureReachability.missingBiomeKeys(engine, null).isEmpty()); + assertThrows(IllegalArgumentException.class, + () -> StructureReachability.missingBiomeKeys(engine, null)); } @Test - public void missingBiomeKeysIsEmptyWhenWorldUnavailable() { + public void missingBiomeKeysRejectsUnavailableWorld() { Engine engine = mock(Engine.class); - assertTrue(StructureReachability.missingBiomeKeys(engine, "minecraft:village_taiga").isEmpty()); + when(engine.getData()).thenReturn(mock(IrisData.class)); + assertThrows(IllegalStateException.class, + () -> StructureReachability.missingBiomeKeys(engine, "minecraft:village_taiga")); } @Test @@ -76,4 +92,55 @@ public class StructureReachabilityContractTest { StructureReachability.invalidate(null); StructureReachability.invalidate(mock(Engine.class)); } + + @Test + public void failedReachabilityBuildIsNotCachedAsEmpty() { + Engine engine = mock(Engine.class); + IrisWorld world = mock(IrisWorld.class); + PlatformWorld platformWorld = mock(PlatformWorld.class); + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks hooks = mock(PlatformStructureHooks.class); + IllegalStateException cause = new IllegalStateException("registry unavailable"); + when(engine.getData()).thenReturn(mock(IrisData.class)); + when(engine.getWorld()).thenReturn(world); + when(world.platformWorld()).thenReturn(platformWorld); + when(platform.structureHooks()).thenReturn(hooks); + when(hooks.reachableStructureKeys(platformWorld)) + .thenThrow(cause) + .thenReturn(List.of("minecraft:monument")); + + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + assertThrows(IllegalStateException.class, () -> StructureReachability.reachableKeys(engine)); + assertTrue(StructureReachability.reachableKeys(engine).contains("minecraft:monument")); + } finally { + StructureReachability.invalidate(engine); + IrisPlatforms.unbind(); + } + } + + @Test + public void missingBiomeKeysForwardsCanonicalStructureKey() { + Engine engine = mock(Engine.class); + IrisWorld world = mock(IrisWorld.class); + PlatformWorld platformWorld = mock(PlatformWorld.class); + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks hooks = mock(PlatformStructureHooks.class); + when(engine.getWorld()).thenReturn(world); + when(world.platformWorld()).thenReturn(platformWorld); + when(platform.structureHooks()).thenReturn(hooks); + when(hooks.possibleBiomeKeys(platformWorld)).thenReturn(List.of("minecraft:deep_ocean")); + when(hooks.structureBiomeKeys("minecraft:monument")).thenReturn(List.of("minecraft:deep_ocean")); + + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + assertTrue(StructureReachability.missingBiomeKeys( + engine, " MINECRAFT:MONUMENT ").isEmpty()); + verify(hooks).structureBiomeKeys("minecraft:monument"); + } finally { + IrisPlatforms.unbind(); + } + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/structure/IrisObjectFrameReaderTest.java b/core/src/test/java/art/arcane/iris/engine/framework/structure/IrisObjectFrameReaderTest.java new file mode 100644 index 000000000..6e3e651a5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/structure/IrisObjectFrameReaderTest.java @@ -0,0 +1,196 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.engine.object.IrisObject; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class IrisObjectFrameReaderTest { + @Test + public void acceptsModernTileFrame() throws Exception { + IrisObject object = readObject(tile -> { + tile.writeUTF("minecraft:chest"); + tile.writeUTF("{}"); + }); + + assertEquals(1, object.getW()); + assertEquals(1, object.getH()); + assertEquals(1, object.getD()); + } + + @Test + public void acceptsEveryLegacyTileFrame() throws Exception { + readObject(tile -> { + tile.writeShort(0); + tile.writeUTF("one"); + tile.writeUTF("two"); + tile.writeUTF("three"); + tile.writeUTF("four"); + tile.writeByte(0); + }); + readObject(tile -> { + tile.writeShort(1); + tile.writeUTF("minecraft:pig"); + }); + readObject(tile -> { + tile.writeShort(1); + tile.writeShort(0); + }); + readObject(tile -> { + tile.writeShort(2); + tile.writeByte(0); + tile.writeByte(1); + tile.writeByte(0); + tile.writeUTF("minecraft:base"); + }); + readObject(tile -> { + tile.writeShort(2); + tile.writeByte(0); + tile.writeByte(1); + tile.writeByte(0); + tile.writeByte(0); + }); + readObject(tile -> { + tile.writeShort(3); + tile.writeUTF("minecraft:chest"); + tile.writeUTF("minecraft:chests/simple_dungeon"); + tile.writeLong(42L); + }); + } + + @Test + public void acceptsLegacySignColorBoundary() throws Exception { + readObject(tile -> writeLegacySign(tile, 15)); + } + + @Test + public void rejectsBlankModernTileMaterialWithResourceContext() throws Exception { + byte[] content = objectBytes(tile -> { + tile.writeUTF(""); + tile.writeUTF("{}"); + }); + + IOException exception = assertThrows(IOException.class, () -> IrisObjectFrameReader.readBounds( + new ByteArrayInputStream(content), "objects/blank-material.iob")); + + assertEquals("Malformed Iris object resource objects/blank-material.iob: " + + "tile 0 modern material is blank", exception.getMessage()); + } + + @Test + public void rejectsLegacySignColorsOutsideRuntimeRange() throws Exception { + IOException negative = readFailure(tile -> writeLegacySign(tile, -1), "objects/sign-negative.iob"); + IOException overflow = readFailure(tile -> writeLegacySign(tile, 16), "objects/sign-overflow.iob"); + + assertTrue(negative.getMessage(), negative.getMessage().endsWith( + "tile 0 legacy sign color index -1 is outside 0..15")); + assertTrue(overflow.getMessage(), overflow.getMessage().endsWith( + "tile 0 legacy sign color index 16 is outside 0..15")); + } + + @Test + public void rejectsTileCountsAboveValidationLimit() throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(257); + output.writeInt(257); + output.writeInt(1); + output.writeUTF("Iris V2 IOB;"); + output.writeShort(0); + output.writeInt(0); + output.writeInt(65_537); + } + + IOException exception = assertThrows(IOException.class, () -> IrisObjectFrameReader.readBounds( + new ByteArrayInputStream(bytes.toByteArray()), "objects/too-many-tiles.iob")); + + assertTrue(exception.getMessage(), exception.getMessage().endsWith( + "tile count 65537 exceeds limit 65536")); + } + + @Test + public void boundsObjectInputBeforeUnboundedAllocation() throws Exception { + byte[] content = new byte[33]; + + IOException exception = assertThrows(IOException.class, () -> IrisObjectFrameReader.readLimitedContent( + new ByteArrayInputStream(content), "objects/oversized.iob", 32)); + + assertEquals("Malformed Iris object resource objects/oversized.iob: " + + "file exceeds 32-byte limit", exception.getMessage()); + } + + @Test + public void rejectsTruncatedTileFrame() throws Exception { + byte[] content = objectBytes(tile -> tile.writeUTF("minecraft:chest")); + + assertThrows(IOException.class, () -> IrisObjectFrameReader.readBounds( + new ByteArrayInputStream(content), "objects/truncated.iob")); + } + + @Test + public void rejectsTrailingBytes() throws Exception { + byte[] content = objectBytes(tile -> { + tile.writeUTF("minecraft:chest"); + tile.writeUTF("{}"); + tile.writeByte(1); + }); + + assertThrows(IOException.class, () -> IrisObjectFrameReader.readBounds( + new ByteArrayInputStream(content), "objects/trailing.iob")); + } + + private IrisObject readObject(TileWriter tileWriter) throws Exception { + return IrisObjectFrameReader.readBounds( + new ByteArrayInputStream(objectBytes(tileWriter)), "objects/test.iob"); + } + + private IOException readFailure(TileWriter tileWriter, String resourceName) throws Exception { + byte[] content = objectBytes(tileWriter); + return assertThrows(IOException.class, () -> IrisObjectFrameReader.readBounds( + new ByteArrayInputStream(content), resourceName)); + } + + private void writeLegacySign(DataOutputStream tile, int colorIndex) throws IOException { + tile.writeShort(0); + tile.writeUTF("one"); + tile.writeUTF("two"); + tile.writeUTF("three"); + tile.writeUTF("four"); + tile.writeByte(colorIndex); + } + + private byte[] objectBytes(TileWriter tileWriter) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(1); + output.writeInt(1); + output.writeInt(1); + output.writeUTF("Iris V2 IOB;"); + output.writeShort(1); + output.writeUTF("minecraft:chest"); + output.writeInt(1); + output.writeShort(0); + output.writeShort(0); + output.writeShort(0); + output.writeShort(0); + output.writeInt(1); + output.writeShort(0); + output.writeShort(0); + output.writeShort(0); + tileWriter.write(output); + } + return bytes.toByteArray(); + } + + @FunctionalInterface + private interface TileWriter { + void write(DataOutputStream output) throws IOException; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilerTest.java new file mode 100644 index 000000000..e74b60572 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilerTest.java @@ -0,0 +1,576 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.JigsawJoint; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.collection.KList; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class StructureGraphCompilerTest { + @BeforeClass + public static void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void compilesACompleteTerminalGraphAndSamplesDeterministically() { + InMemoryResolver resolver = new InMemoryResolver(); + resolver.objects.put("objects/start", object()); + resolver.pieces.put("pieces/start", piece("objects/start")); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + IrisStructure structure = structure("pools/start"); + + StructureGraphCompilation first = StructureGraphCompiler.compile(structure, resolver); + StructureGraphCompilation second = StructureGraphCompiler.compile(structure, resolver); + + assertTrue(first.getDiagnostics().isEmpty()); + assertTrue(first.isAssemblyViable()); + assertEquals(List.of("pools/start"), new ArrayList<>(first.getGraph().getPools().keySet())); + assertEquals(List.of("pieces/start"), new ArrayList<>(first.getGraph().getPieces().keySet())); + assertEquals(List.of("objects/start"), new ArrayList<>(first.getGraph().getObjects().keySet())); + assertEquals(first.getAssemblySamples(), second.getAssemblySamples()); + for (StructureGraphAssemblySample sample : first.getAssemblySamples()) { + assertEquals(List.of("pieces/start"), sample.outcome().pieceKeys()); + assertTrue(sample.outcome().isComplete()); + } + } + + @Test + public void reportsMissingReferencesInvalidWeightsAndNoViableStart() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawPool start = pool(entry("pieces/missing", 0)); + start.setFallback("pools/missing-fallback"); + resolver.pools.put("pools/start", start); + + StructureGraphCompilation result = StructureGraphCompiler.compile(structure("pools/start"), resolver); + + assertCodes(result, + StructureGraphDiagnostic.Code.INVALID_WEIGHT, + StructureGraphDiagnostic.Code.MISSING_PIECE, + StructureGraphDiagnostic.Code.MISSING_POOL, + StructureGraphDiagnostic.Code.NO_VIABLE_START_PIECE); + assertTrue(result.hasErrors()); + assertFalse(result.isAssemblyViable()); + } + + @Test + public void reportsEmptyPoolsAndConnectorFieldFailures() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector connector = connector(new IrisPosition(4, 1, 1), null, "", "path", "path"); + resolver.objects.put("objects/start", object()); + resolver.pieces.put("pieces/start", piece("objects/start", connector)); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + resolver.pools.put("pools/empty", pool()); + + StructureGraphCompilation invalidConnector = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + StructureGraphCompilation emptyStart = StructureGraphCompiler.compile( + structure("pools/empty"), resolver); + + assertCodes(invalidConnector, + StructureGraphDiagnostic.Code.INVALID_CONNECTOR_POSITION, + StructureGraphDiagnostic.Code.INVALID_CONNECTOR_DIRECTION, + StructureGraphDiagnostic.Code.MISSING_CONNECTOR_POOL); + assertCodes(emptyStart, + StructureGraphDiagnostic.Code.EMPTY_START_POOL, + StructureGraphDiagnostic.Code.NO_VIABLE_START_PIECE); + } + + @Test + public void reportsIncompatibleDirectionsAndUnreachablePieces() { + InMemoryResolver resolver = connectorGraph(false, IrisDirection.NORTH_NEGATIVE_Z); + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertCodes(result, + StructureGraphDiagnostic.Code.NO_COMPATIBLE_CONNECTOR, + StructureGraphDiagnostic.Code.UNREACHABLE_PIECE); + assertFalse(result.getGraph().getReachablePieces().contains("pieces/target")); + assertFalse(result.isAssemblyViable()); + for (StructureGraphAssemblySample sample : result.getAssemblySamples()) { + assertFalse(sample.outcome().isComplete()); + } + } + + @Test + public void acceptsHorizontalRotationWithVanillaOneWayTargetMatching() { + InMemoryResolver resolver = connectorGraph(true, IrisDirection.NORTH_NEGATIVE_Z); + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertTrue(result.getDiagnostics().isEmpty()); + assertTrue(result.getGraph().getReachablePieces().contains("pieces/target")); + for (StructureGraphAssemblySample sample : result.getAssemblySamples()) { + assertEquals(List.of("pieces/start", "pieces/target"), sample.outcome().pieceKeys()); + assertTrue(sample.outcome().isComplete()); + } + } + + @Test + public void rejectsVerticalConnectorsThatYRotationCannotAlign() { + InMemoryResolver resolver = connectorGraph(true, IrisDirection.UP_POSITIVE_Y); + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertCodes(result, + StructureGraphDiagnostic.Code.NO_COMPATIBLE_CONNECTOR, + StructureGraphDiagnostic.Code.UNREACHABLE_PIECE); + } + + @Test + public void alignedConnectorsRequireMatchingTopOrientation() { + InMemoryResolver resolver = connectorGraph(false, IrisDirection.SOUTH_POSITIVE_Z); + IrisJigsawConnector source = resolver.pieces.get("pieces/start").getConnectors().getFirst(); + IrisJigsawConnector target = resolver.pieces.get("pieces/target").getConnectors().getFirst(); + source.setJoint(JigsawJoint.ALIGNED); + source.setTop(IrisDirection.UP_POSITIVE_Y); + target.setTop(IrisDirection.DOWN_NEGATIVE_Y); + + StructureGraphCompilation aligned = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + assertCodes(aligned, + StructureGraphDiagnostic.Code.NO_COMPATIBLE_CONNECTOR, + StructureGraphDiagnostic.Code.UNREACHABLE_PIECE); + + source.setJoint(JigsawJoint.ROLLABLE); + StructureGraphCompilation rollable = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + assertTrue(rollable.isAssemblyViable()); + } + + @Test + public void deterministicSamplingUsesFallbackAtTheMaximumDepth() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector source = connector( + new IrisPosition(1, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/primary", "source", "door"); + IrisJigsawConnector primaryTarget = connector( + new IrisPosition(1, 1, 2), IrisDirection.SOUTH_POSITIVE_Z, + "pools/primary", "door", "unused"); + IrisJigsawConnector primarySource = connector( + new IrisPosition(1, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/primary", "source", "door"); + IrisJigsawConnector fallbackTarget = connector( + new IrisPosition(1, 1, 2), IrisDirection.SOUTH_POSITIVE_Z, + "pools/primary", "door", "unused"); + resolver.objects.put("objects/start", object()); + resolver.objects.put("objects/primary", object()); + resolver.objects.put("objects/fallback", object()); + resolver.pieces.put("pieces/start", piece("objects/start", source)); + resolver.pieces.put("pieces/primary", piece("objects/primary", primaryTarget, primarySource)); + resolver.pieces.put("pieces/fallback", piece("objects/fallback", fallbackTarget)); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + IrisJigsawPool primary = pool(entry("pieces/primary", 1)); + primary.setFallback("pools/fallback"); + resolver.pools.put("pools/primary", primary); + resolver.pools.put("pools/fallback", pool(entry("pieces/fallback", 1))); + IrisStructure structure = structure("pools/start"); + structure.setMaxDepth(1); + + StructureGraphCompilation result = StructureGraphCompiler.compile(structure, resolver); + + assertTrue(result.getDiagnostics().isEmpty()); + assertTrue(result.isAssemblyViable()); + for (StructureGraphAssemblySample sample : result.getAssemblySamples()) { + assertEquals(List.of("pieces/start", "pieces/primary", "pieces/fallback"), + sample.outcome().pieceKeys()); + assertTrue(sample.outcome().isComplete()); + } + } + + @Test + public void deterministicSamplingDoesNotSelectFallbackOfFallback() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector source = connector( + new IrisPosition(1, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/primary", "source", "door"); + IrisJigsawConnector incompatible = connector( + new IrisPosition(1, 1, 2), IrisDirection.SOUTH_POSITIVE_Z, + "pools/primary", "wrong", "unused"); + IrisJigsawConnector nestedTarget = connector( + new IrisPosition(1, 1, 2), IrisDirection.SOUTH_POSITIVE_Z, + "pools/primary", "door", "unused"); + resolver.objects.put("objects/start", object()); + resolver.objects.put("objects/incompatible", object()); + resolver.objects.put("objects/nested", object()); + resolver.pieces.put("pieces/start", piece("objects/start", source)); + resolver.pieces.put("pieces/incompatible", piece("objects/incompatible", incompatible)); + resolver.pieces.put("pieces/nested", piece("objects/nested", nestedTarget)); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + IrisJigsawPool primary = pool(entry("pieces/incompatible", 1)); + primary.setFallback("pools/direct"); + resolver.pools.put("pools/primary", primary); + IrisJigsawPool direct = pool(entry("pieces/incompatible", 1)); + direct.setFallback("pools/nested"); + resolver.pools.put("pools/direct", direct); + resolver.pools.put("pools/nested", pool(entry("pieces/nested", 1))); + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertFalse(result.getGraph().getReachablePools().contains("pools/nested")); + assertFalse(result.getGraph().getReachablePieces().contains("pieces/nested")); + assertFalse(result.isAssemblyViable()); + for (StructureGraphAssemblySample sample : result.getAssemblySamples()) { + assertEquals(List.of("pieces/start"), sample.outcome().pieceKeys()); + assertEquals(1, sample.outcome().unresolvedConnectorCount()); + } + } + + @Test + public void reportsFallbackCyclesAndTheirUnreachableClosure() { + InMemoryResolver resolver = new InMemoryResolver(); + resolver.objects.put("objects/terminal", object()); + resolver.pieces.put("pieces/terminal", piece("objects/terminal")); + IrisJigsawPool start = pool(entry("pieces/terminal", 1)); + IrisJigsawPool first = pool(entry("pieces/terminal", 1)); + IrisJigsawPool second = pool(entry("pieces/terminal", 1)); + start.setFallback("pools/first"); + first.setFallback("pools/second"); + second.setFallback("pools/first"); + resolver.pools.put("pools/start", start); + resolver.pools.put("pools/first", first); + resolver.pools.put("pools/second", second); + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertCodes(result, + StructureGraphDiagnostic.Code.FALLBACK_CYCLE, + StructureGraphDiagnostic.Code.UNREACHABLE_POOL); + assertTrue(result.hasErrors()); + } + + @Test + public void validatesLongFallbackChainsWithoutRecursiveTraversal() { + InMemoryResolver resolver = new InMemoryResolver(); + resolver.objects.put("objects/terminal", object()); + resolver.pieces.put("pieces/terminal", piece("objects/terminal")); + int poolCount = 10_000; + for (int index = 0; index < poolCount; index++) { + IrisJigsawPool pool = pool(entry("pieces/terminal", 1)); + if (index + 1 < poolCount) { + pool.setFallback("pools/chain-" + (index + 1)); + } + resolver.pools.put("pools/chain-" + index, pool); + } + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/chain-0"), resolver); + + assertTrue(result.getDiagnostics().stream().noneMatch( + diagnostic -> diagnostic.code() == StructureGraphDiagnostic.Code.FALLBACK_CYCLE)); + assertTrue(result.isAssemblyViable()); + } + + @Test + public void reportsMissingObjectsAndInvalidObjectBounds() { + InMemoryResolver resolver = new InMemoryResolver(); + resolver.objects.put("objects/empty", new IrisObject()); + resolver.pieces.put("pieces/missing-object", piece("objects/missing")); + resolver.pieces.put("pieces/empty-object", piece("objects/empty")); + resolver.pools.put("pools/start", pool( + entry("pieces/missing-object", 1), + entry("pieces/empty-object", 1))); + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertCodes(result, + StructureGraphDiagnostic.Code.MISSING_OBJECT, + StructureGraphDiagnostic.Code.INVALID_OBJECT_BOUNDS); + assertTrue(result.hasErrors()); + } + + @Test + public void rejectsNullConnectorCollectionsAndNames() { + InMemoryResolver nullListResolver = new InMemoryResolver(); + nullListResolver.objects.put("objects/start", object()); + IrisJigsawPiece nullListPiece = piece("objects/start"); + nullListPiece.setConnectors(null); + nullListResolver.pieces.put("pieces/start", nullListPiece); + nullListResolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + + StructureGraphCompilation nullList = StructureGraphCompiler.compile( + structure("pools/start"), nullListResolver); + + assertCodes(nullList, StructureGraphDiagnostic.Code.INVALID_CONNECTOR); + assertTrue(nullList.hasErrors()); + + InMemoryResolver nullNamesResolver = connectorGraph(true, IrisDirection.SOUTH_POSITIVE_Z); + nullNamesResolver.pieces.get("pieces/start").getConnectors().getFirst().setTargetName(null); + StructureGraphCompilation nullNames = StructureGraphCompiler.compile( + structure("pools/start"), nullNamesResolver); + + assertCodes(nullNames, StructureGraphDiagnostic.Code.INVALID_CONNECTOR); + assertTrue(nullNames.hasErrors()); + } + + @Test + public void rejectsStructureLimitsOutsideTheRuntimeContract() { + InMemoryResolver resolver = new InMemoryResolver(); + resolver.objects.put("objects/start", object()); + resolver.pieces.put("pieces/start", piece("objects/start")); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + + IrisStructure deep = structure("pools/start"); + deep.setMaxDepth(31); + IrisStructure wide = structure("pools/start"); + wide.setMaxSizeChunks(33); + IrisStructure overflowing = structure("pools/start"); + overflowing.setMaxSizeChunks(Integer.MAX_VALUE); + + assertCodes(StructureGraphCompiler.compile(deep, resolver), + StructureGraphDiagnostic.Code.INVALID_MAX_DEPTH); + assertCodes(StructureGraphCompiler.compile(wide, resolver), + StructureGraphDiagnostic.Code.INVALID_MAX_SIZE); + assertCodes(StructureGraphCompiler.compile(overflowing, resolver), + StructureGraphDiagnostic.Code.INVALID_MAX_SIZE); + } + + @Test + public void emptyPoolChoiceTerminatesABranchWithoutAPlaceholderPiece() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector source = connector( + new IrisPosition(1, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/terminal", "source", "door"); + resolver.objects.put("objects/start", object()); + resolver.pieces.put("pieces/start", piece("objects/start", source)); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + resolver.pools.put("pools/terminal", pool(emptyEntry(4))); + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertTrue(result.getDiagnostics().isEmpty()); + assertTrue(result.isAssemblyViable()); + for (StructureGraphAssemblySample sample : result.getAssemblySamples()) { + assertEquals(List.of("pieces/start"), sample.outcome().pieceKeys()); + assertTrue(sample.outcome().isComplete()); + } + } + + @Test + public void rareEmptyStartPreventsGuaranteedReplacementOutputWithoutDependingOnSamples() { + InMemoryResolver resolver = new InMemoryResolver(); + resolver.objects.put("objects/start", object()); + resolver.pieces.put("pieces/start", piece("objects/start")); + resolver.pools.put("pools/start", pool( + entry("pieces/start", Integer.MAX_VALUE), emptyEntry(1))); + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertTrue(result.isAssemblyViable()); + assertFalse(result.guaranteesAssemblyOutput()); + assertTrue(result.getAssemblySamples().stream().noneMatch( + sample -> sample.outcome().intentionalEmpty())); + } + + @Test + public void oversizedStartPreventsGuaranteedReplacementOutput() { + InMemoryResolver resolver = new InMemoryResolver(); + resolver.objects.put("objects/start", new IrisObject(130, 3, 3)); + resolver.pieces.put("pieces/start", piece("objects/start")); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertTrue(result.isAssemblyViable()); + assertFalse(result.guaranteesAssemblyOutput()); + } + + @Test + public void reachableBranchRequiresAuthoredEmptyTerminationForReplacementOutput() { + InMemoryResolver resolver = connectorGraph(true, IrisDirection.NORTH_NEGATIVE_Z); + + StructureGraphCompilation unbounded = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertTrue(unbounded.isAssemblyViable()); + assertFalse(unbounded.guaranteesAssemblyOutput()); + + resolver.pools.get("pools/target").setFallback("pools/empty"); + resolver.pools.put("pools/empty", pool()); + StructureGraphCompilation terminated = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertTrue(terminated.isAssemblyViable()); + assertTrue(terminated.guaranteesAssemblyOutput()); + } + + @Test + public void defersTopologyPieceCapsToGeometryValidation() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector startSource = connector( + new IrisPosition(1, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/recursive", "source", "door"); + IrisJigsawConnector recursiveTarget = connector( + new IrisPosition(1, 1, 2), IrisDirection.SOUTH_POSITIVE_Z, + "pools/recursive", "door", "unused"); + IrisJigsawConnector firstBranch = connector( + new IrisPosition(1, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/recursive", "source", "door"); + IrisJigsawConnector secondBranch = connector( + new IrisPosition(1, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/recursive", "source", "door"); + resolver.objects.put("objects/start", object()); + resolver.objects.put("objects/recursive", object()); + resolver.pieces.put("pieces/start", piece("objects/start", startSource)); + resolver.pieces.put("pieces/recursive", piece( + "objects/recursive", recursiveTarget, firstBranch, secondBranch)); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + IrisJigsawPool recursive = pool(entry("pieces/recursive", 1)); + recursive.setFallback("pools/empty"); + resolver.pools.put("pools/recursive", recursive); + resolver.pools.put("pools/empty", pool()); + IrisStructure structure = structure("pools/start"); + structure.setMaxDepth(30); + + StructureGraphCompilation result = StructureGraphCompiler.compile(structure, resolver); + + assertCodes(result, StructureGraphDiagnostic.Code.ASSEMBLY_PIECE_CAP_REACHED); + assertTrue(result.isAssemblyViable()); + for (StructureGraphAssemblySample sample : result.getAssemblySamples()) { + assertTrue(sample.outcome().pieceCapReached()); + assertEquals(0, sample.outcome().unresolvedConnectorCount()); + } + } + + private static InMemoryResolver connectorGraph(boolean rotatable, IrisDirection targetDirection) { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector source = connector( + new IrisPosition(1, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/target", "source", "door"); + IrisJigsawConnector target = connector( + new IrisPosition(1, 1, 0), targetDirection, + "pools/target", "door", "unrelated-target"); + IrisJigsawPiece targetPiece = piece("objects/target", target); + targetPiece.setRotatable(rotatable); + resolver.objects.put("objects/start", object()); + resolver.objects.put("objects/target", object()); + IrisJigsawPiece startPiece = piece("objects/start", source); + startPiece.setRotatable(false); + resolver.pieces.put("pieces/start", startPiece); + resolver.pieces.put("pieces/target", targetPiece); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + resolver.pools.put("pools/target", pool(entry("pieces/target", 1))); + return resolver; + } + + private static IrisStructure structure(String startPool) { + IrisStructure structure = new IrisStructure(); + structure.setLoadKey("structures/test"); + structure.setStartPool(startPool); + structure.setMaxDepth(3); + structure.setMaxSizeChunks(4); + return structure; + } + + private static IrisObject object() { + return new IrisObject(3, 3, 3); + } + + private static IrisJigsawPiece piece(String object, IrisJigsawConnector... connectors) { + IrisJigsawPiece piece = new IrisJigsawPiece(); + piece.setObject(object); + piece.setConnectors(new KList<>(connectors)); + return piece; + } + + private static IrisJigsawPool pool(IrisJigsawPieceEntry... entries) { + IrisJigsawPool pool = new IrisJigsawPool(); + pool.setPieces(new KList<>(entries)); + return pool; + } + + private static IrisJigsawPieceEntry entry(String piece, int weight) { + return new IrisJigsawPieceEntry().setPiece(piece).setWeight(weight); + } + + private static IrisJigsawPieceEntry emptyEntry(int weight) { + return new IrisJigsawPieceEntry().setEmpty(true).setWeight(weight); + } + + private static IrisJigsawConnector connector(IrisPosition position, IrisDirection direction, String pool, + String name, String targetName) { + return new IrisJigsawConnector() + .setPosition(position) + .setDirection(direction) + .setPool(pool) + .setName(name) + .setTargetName(targetName); + } + + private static void assertCodes(StructureGraphCompilation result, StructureGraphDiagnostic.Code... expected) { + List actual = new ArrayList<>(); + for (StructureGraphDiagnostic diagnostic : result.getDiagnostics()) { + if (!actual.contains(diagnostic.code())) { + actual.add(diagnostic.code()); + } + } + for (StructureGraphDiagnostic.Code code : expected) { + assertTrue("Missing diagnostic " + code + " in " + actual, actual.contains(code)); + } + } + + private static final class InMemoryResolver implements StructureGraphResolver { + private final Map pools = new LinkedHashMap<>(); + private final Map pieces = new LinkedHashMap<>(); + private final Map objects = new LinkedHashMap<>(); + + @Override + public IrisJigsawPool loadPool(String key) { + return pools.get(key); + } + + @Override + public IrisJigsawPiece loadPiece(String key) { + return pieces.get(key); + } + + @Override + public IrisObject loadObject(String key) { + return objects.get(key); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompilerTest.java new file mode 100644 index 000000000..01d658c24 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompilerTest.java @@ -0,0 +1,140 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureSource; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class StructureResourceBundleGraphCompilerTest { + @Test + public void completeBundleCompilesAndPassesRuntimeGeometry() throws IOException { + StructureResourceBundle bundle = baseBundle() + .resource("objects/start.iob", object(1, 1, 1)) + .textResource("jigsaw-pieces/start.json", + "{\"object\":\"start\",\"connectors\":[],\"rotatable\":false}") + .textResource("jigsaw-pools/start.json", + "{\"pieces\":[{\"piece\":\"start\",\"weight\":1}]}") + .textResource("structures/test.json", + "{\"startPool\":\"start\",\"maxDepth\":1,\"maxSizeChunks\":1}") + .build(); + + StructureResourceBundleGraphCompiler.requireViable(bundle); + + assertEquals(1, StructureResourceBundleGraphCompiler.compile(bundle).size()); + } + + @Test + public void topologicalGraphThatCannotFitItsRadiusIsRejectedBeforePublish() throws IOException { + String sourceConnector = "{\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"direction\":\"EAST_POSITIVE_X\",\"top\":\"UP_POSITIVE_Y\"," + + "\"pool\":\"target\",\"name\":\"source\",\"targetName\":\"door\"," + + "\"joint\":\"ROLLABLE\"}"; + String targetConnector = "{\"position\":{\"x\":50,\"y\":0,\"z\":0}," + + "\"direction\":\"WEST_NEGATIVE_X\",\"top\":\"UP_POSITIVE_Y\"," + + "\"pool\":\"target\",\"name\":\"door\",\"targetName\":\"unused\"," + + "\"joint\":\"ROLLABLE\"}"; + StructureResourceBundle bundle = baseBundle() + .resource("objects/start.iob", object(1, 1, 1)) + .resource("objects/target.iob", object(100, 1, 1)) + .textResource("jigsaw-pieces/start.json", + "{\"object\":\"start\",\"connectors\":[" + sourceConnector + "],\"rotatable\":false}") + .textResource("jigsaw-pieces/target.json", + "{\"object\":\"target\",\"connectors\":[" + targetConnector + "],\"rotatable\":false}") + .textResource("jigsaw-pools/start.json", + "{\"pieces\":[{\"piece\":\"start\",\"weight\":1}]}") + .textResource("jigsaw-pools/target.json", + "{\"pieces\":[{\"piece\":\"target\",\"weight\":1}]}") + .textResource("structures/test.json", + "{\"startPool\":\"start\",\"maxDepth\":1,\"maxSizeChunks\":1}") + .build(); + + assertThrows(IllegalArgumentException.class, + () -> StructureResourceBundleGraphCompiler.requireViable(bundle)); + } + + @Test + public void intentionalEmptyStartIsAValidNoOpGraph() { + StructureResourceBundle bundle = baseBundle() + .textResource("jigsaw-pools/start.json", + "{\"pieces\":[{\"empty\":true,\"weight\":1}]}") + .textResource("structures/test.json", + "{\"startPool\":\"start\",\"maxDepth\":1,\"maxSizeChunks\":1}") + .build(); + + StructureResourceBundleGraphCompiler.requireViable(bundle); + } + + @Test + public void truncatedObjectFrameIsRejectedBeforePublish() throws IOException { + StructureResourceBundle bundle = baseBundle() + .resource("objects/start.iob", truncatedObject(1, 1, 1)) + .textResource("jigsaw-pieces/start.json", + "{\"object\":\"start\",\"connectors\":[],\"rotatable\":false}") + .textResource("jigsaw-pools/start.json", + "{\"pieces\":[{\"piece\":\"start\",\"weight\":1}]}") + .textResource("structures/test.json", + "{\"startPool\":\"start\",\"maxDepth\":1,\"maxSizeChunks\":1}") + .build(); + + assertThrows(IllegalArgumentException.class, + () -> StructureResourceBundleGraphCompiler.requireViable(bundle)); + } + + private StructureResourceBundle.Builder baseBundle() { + StructureKey key = StructureKey.parse("iris:test"); + return StructureResourceBundle.builder(key) + .source(StructureSource.of(StructureSource.Kind.IRIS, key)) + .backend(StructureBackend.IRIS_ASSEMBLY); + } + + private byte[] object(int width, int height, int depth) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(width); + output.writeInt(height); + output.writeInt(depth); + output.writeUTF("Iris V2 IOB;"); + output.writeShort(0); + output.writeInt(0); + output.writeInt(0); + } + return bytes.toByteArray(); + } + + private byte[] truncatedObject(int width, int height, int depth) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(width); + output.writeInt(height); + output.writeInt(depth); + output.writeUTF("Iris V2 IOB;"); + } + return bytes.toByteArray(); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java new file mode 100644 index 000000000..6766dbe8d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java @@ -0,0 +1,124 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IObjectPlacer; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.spi.PlatformBlockState; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class CaveObjectPlacementTransactionTest { + @Test + public void validPlacementCommitsBlocksTilesAndMetadataTogether() { + IObjectPlacer delegate = createPlacer(128, 80, 20, 60); + PlatformBlockState state = mock(PlatformBlockState.class); + TileData tile = mock(TileData.class); + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10); + + transaction.set(4, 30, 7, state); + transaction.setTile(4, 30, 7, tile); + transaction.setData(4, 30, 7, "object@1"); + + assertSame(state, transaction.get(4, 30, 7)); + assertEquals("object@1", transaction.getData(4, 30, 7, String.class)); + verify(delegate, never()).set(anyInt(), anyInt(), anyInt(), any()); + verify(delegate, never()).setTile(anyInt(), anyInt(), anyInt(), any()); + verify(delegate, never()).setData(anyInt(), anyInt(), anyInt(), any()); + + assertEquals(CaveObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit()); + org.mockito.InOrder order = inOrder(delegate); + order.verify(delegate).set(4, 30, 7, state); + order.verify(delegate).setTile(4, 30, 7, tile); + order.verify(delegate).setData(4, 30, 7, "object@1"); + } + + @Test + public void oneInvalidMetadataWriteRejectsTheWholePlacement() { + Engine engine = mock(Engine.class); + when(engine.getHeight()).thenReturn(128); + when(engine.getHeight(eq(4), anyInt(), eq(true))).thenReturn(80); + when(engine.getHeight(eq(5), anyInt(), eq(true))).thenReturn(35); + IObjectPlacer delegate = mock(IObjectPlacer.class); + when(delegate.getEngine()).thenReturn(engine); + when(delegate.isCarved(anyInt(), anyInt(), anyInt())).thenReturn(true); + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10); + + transaction.set(4, 30, 7, mock(PlatformBlockState.class)); + transaction.setData(5, 26, 7, "ghost"); + + assertEquals(CaveObjectPlacementTransaction.CommitResult.REJECTED_BOUNDS, transaction.commit()); + verify(delegate, never()).set(anyInt(), anyInt(), anyInt(), any()); + verify(delegate, never()).setData(anyInt(), anyInt(), anyInt(), any()); + } + + @Test + public void perColumnCaveCeilingRejectsTheWholePlacement() { + Engine engine = mock(Engine.class); + when(engine.getHeight()).thenReturn(128); + when(engine.getHeight(anyInt(), anyInt(), eq(true))).thenReturn(80); + IObjectPlacer delegate = mock(IObjectPlacer.class); + when(delegate.getEngine()).thenReturn(engine); + when(delegate.isCarved(eq(4), anyInt(), eq(7))).thenAnswer(invocation -> invocation.getArgument(1) <= 60); + when(delegate.isCarved(eq(5), anyInt(), eq(7))).thenAnswer(invocation -> invocation.getArgument(1) <= 24); + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10); + + transaction.set(4, 30, 7, mock(PlatformBlockState.class)); + transaction.setData(5, 25, 7, "outside-cave"); + + assertEquals(CaveObjectPlacementTransaction.CommitResult.REJECTED_BOUNDS, transaction.commit()); + verify(delegate, never()).set(anyInt(), anyInt(), anyInt(), any()); + verify(delegate, never()).setData(anyInt(), anyInt(), anyInt(), any()); + } + + @Test + public void exactBurialBoundaryIsAllowedAndShallowerAnchorsAreExcluded() { + IObjectPlacer delegate = createPlacer(128, 80, 20, 90); + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10); + transaction.set(4, 70, 7, mock(PlatformBlockState.class)); + + assertEquals(CaveObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit()); + assertEquals(71, MantleObjectComponent.caveAnchorScanUpperBound(128, 80, 10)); + assertEquals(61, MantleObjectComponent.caveAnchorScanUpperBound(128, 80, 20)); + } + + private IObjectPlacer createPlacer(int worldHeight, int surfaceHeight, int caveFloor, int caveCeiling) { + Engine engine = mock(Engine.class); + when(engine.getHeight()).thenReturn(worldHeight); + when(engine.getHeight(anyInt(), anyInt(), eq(true))).thenReturn(surfaceHeight); + IObjectPlacer delegate = mock(IObjectPlacer.class); + when(delegate.getEngine()).thenReturn(engine); + when(delegate.isCarved(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { + int y = invocation.getArgument(1); + return y >= caveFloor && y < caveCeiling; + }); + return delegate; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/FloatingObjectPlacementTransactionTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/FloatingObjectPlacementTransactionTest.java new file mode 100644 index 000000000..0a116e0ed --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/FloatingObjectPlacementTransactionTest.java @@ -0,0 +1,100 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.spi.PlatformBlockState; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class FloatingObjectPlacementTransactionTest { + @Test + public void unsupportedFinalTerrainRejectsBlocksTilesAndMarkersTogether() { + IslandObjectPlacer delegate = createPlacer(false); + FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(delegate); + PlatformBlockState first = mock(PlatformBlockState.class); + PlatformBlockState second = mock(PlatformBlockState.class); + TileData tile = mock(TileData.class); + + transaction.set(15, 101, 8, first); + transaction.setData(15, 101, 8, "tree@7"); + transaction.set(16, 101, 8, second); + transaction.setTile(16, 101, 8, tile); + transaction.setData(16, 101, 8, "tree@7"); + + assertSame(second, transaction.get(16, 101, 8)); + assertEquals("tree@7", transaction.getData(16, 101, 8, String.class)); + verify(delegate, never()).set(anyInt(), anyInt(), anyInt(), any()); + verify(delegate, never()).setTile(anyInt(), anyInt(), anyInt(), any()); + verify(delegate, never()).setData(anyInt(), anyInt(), anyInt(), any()); + + assertEquals(FloatingObjectPlacementTransaction.CommitResult.REJECTED_SUPPORT, transaction.commit()); + verify(delegate, never()).set(anyInt(), anyInt(), anyInt(), any()); + verify(delegate, never()).setTile(anyInt(), anyInt(), anyInt(), any()); + verify(delegate, never()).setData(anyInt(), anyInt(), anyInt(), any()); + } + + @Test + public void supportedNeighborChunkCommitsBlocksTilesAndMarkers() { + IslandObjectPlacer delegate = createPlacer(true); + FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(delegate); + PlatformBlockState first = mock(PlatformBlockState.class); + PlatformBlockState second = mock(PlatformBlockState.class); + TileData tile = mock(TileData.class); + + transaction.set(15, 101, 8, first); + transaction.setData(15, 101, 8, "tree@7"); + transaction.set(16, 101, 8, second); + transaction.setTile(16, 101, 8, tile); + transaction.setData(16, 101, 8, "tree@7"); + + assertEquals(FloatingObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit()); + org.mockito.InOrder order = inOrder(delegate); + order.verify(delegate).set(15, 101, 8, first); + order.verify(delegate).setData(15, 101, 8, "tree@7"); + order.verify(delegate).set(16, 101, 8, second); + order.verify(delegate).setTile(16, 101, 8, tile); + order.verify(delegate).setData(16, 101, 8, "tree@7"); + + assertEquals(FloatingObjectPlacementTransaction.CommitResult.EMPTY, transaction.commit()); + verify(delegate).set(16, 101, 8, second); + } + + private IslandObjectPlacer createPlacer(boolean supportNeighborChunk) { + IslandObjectPlacer placer = mock(IslandObjectPlacer.class); + Engine engine = mock(Engine.class); + when(engine.getHeight()).thenReturn(384); + when(placer.getEngine()).thenReturn(engine); + when(placer.canWriteObjectBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + return x == 15 || (supportNeighborChunk && x == 16); + }); + return placer; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java index c34767a06..c3be3c7c9 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java @@ -21,61 +21,47 @@ import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterSlice; -import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.Server; -import org.bukkit.block.data.BlockData; import org.junit.BeforeClass; import org.junit.Test; -import org.mockito.invocation.InvocationOnMock; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; -import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; -import java.util.logging.Logger; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; public class IrisCaveCarver3DNearParityTest { private static Method sampleDensityMethod; + private static Method aquiferCandidateMethod; private static Field engineField; private static Field dataField; private static Field profileField; private static Field surfaceBreakDensityField; private static Field thresholdRngField; private static Field carveAirField; + private static Field carveWaterField; private static Field carveLavaField; private static Field carveForcedAirField; @BeforeClass - public static void setupBukkit() throws Exception { - if (Bukkit.getServer() == null) { - Server server = mock(Server.class); - doReturn(Logger.getLogger("IrisTest")).when(server).getLogger(); - doReturn("IrisTestServer").when(server).getName(); - doReturn("1.0").when(server).getVersion(); - doReturn("1.0").when(server).getBukkitVersion(); - doAnswer((InvocationOnMock invocation) -> namedBlockData(invocation.getArgument(0, Material.class).name().toLowerCase(Locale.ROOT))).when(server).createBlockData(any(Material.class)); - doAnswer((InvocationOnMock invocation) -> namedBlockData(invocation.getArgument(0, String.class))).when(server).createBlockData(anyString()); - Bukkit.setServer(server); - } - + public static void setupReflection() throws Exception { sampleDensityMethod = IrisCaveCarver3D.class.getDeclaredMethod("sampleDensityOptimized", int.class, int.class, int.class); sampleDensityMethod.setAccessible(true); + aquiferCandidateMethod = IrisCaveCarver3D.class.getDeclaredMethod("isAquiferCandidate", int.class, int.class, int.class, double.class); + aquiferCandidateMethod.setAccessible(true); engineField = IrisCaveCarver3D.class.getDeclaredField("engine"); engineField.setAccessible(true); dataField = IrisCaveCarver3D.class.getDeclaredField("data"); @@ -88,19 +74,14 @@ public class IrisCaveCarver3DNearParityTest { thresholdRngField.setAccessible(true); carveAirField = IrisCaveCarver3D.class.getDeclaredField("carveAir"); carveAirField.setAccessible(true); + carveWaterField = IrisCaveCarver3D.class.getDeclaredField("carveWater"); + carveWaterField.setAccessible(true); carveLavaField = IrisCaveCarver3D.class.getDeclaredField("carveLava"); carveLavaField.setAccessible(true); carveForcedAirField = IrisCaveCarver3D.class.getDeclaredField("carveForcedAir"); carveForcedAirField.setAccessible(true); } - private static BlockData namedBlockData(String key) { - String canonical = key.indexOf(':') >= 0 ? key : "minecraft:" + key; - BlockData data = mock(BlockData.class); - doReturn(canonical).when(data).getAsString(); - return data; - } - @Test public void carvedCellDistributionStableAcrossEquivalentCarvers() { Engine engine = createEngine(128, 92); @@ -229,6 +210,160 @@ public class IrisCaveCarver3DNearParityTest { assertTrue(containsLiquidInRange(forcedAirCapture.carvedLiquids, 19, 47, (byte) 0)); } + @Test + public void waterPrecedesForcedAirWhenLavaIsDisabled() { + Engine engine = createEngine(48, 46); + int[] surfaceHeights = filledHeights(46); + IrisCaveProfile wetProfile = createWaterProfile() + .setVerticalRange(new IrisRange(2D, 18D)) + .setAllowLava(false) + .setWaterRequiresFloor(false); + WriterCapture wetCapture = createWriterCapture(48); + new IrisCaveCarver3D(engine, wetProfile).carve( + wetCapture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights); + + IrisCaveProfile dryProfile = createWaterProfile() + .setVerticalRange(new IrisRange(2D, 18D)) + .setAllowWater(false) + .setAllowLava(false) + .setWaterRequiresFloor(false); + WriterCapture dryCapture = createWriterCapture(48); + new IrisCaveCarver3D(engine, dryProfile).carve( + dryCapture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights); + + assertEquals(wetCapture.carvedCells, dryCapture.carvedCells); + assertTrue(containsLiquidInRange(wetCapture.carvedLiquids, 2, 18, (byte) 1)); + assertTrue(containsLiquidInRange(wetCapture.carvedLiquids, 2, 18, (byte) 3)); + assertEquals(0, countLiquid(dryCapture, (byte) 1)); + assertTrue(containsLiquidInRange(dryCapture.carvedLiquids, 2, 18, (byte) 3)); + } + + @Test + public void waterToggleAndMinimumDepthUseEachColumnsTerrainSurface() { + Engine engine = createEngine(80, 70); + int[] surfaceHeights = splitSurfaceHeights(40, 70); + + IrisCaveProfile enabledProfile = createWaterProfile().setAllowWater(true).setWaterMinDepthBelowSurface(10); + WriterCapture enabledCapture = createWriterCapture(80); + new IrisCaveCarver3D(engine, enabledProfile).carve( + enabledCapture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights); + + IrisCaveProfile disabledProfile = createWaterProfile().setAllowWater(false).setWaterMinDepthBelowSurface(10); + WriterCapture disabledCapture = createWriterCapture(80); + new IrisCaveCarver3D(engine, disabledProfile).carve( + disabledCapture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights); + + assertEquals(enabledCapture.carvedCells, disabledCapture.carvedCells); + assertTrue(countLiquid(enabledCapture, (byte) 1) > 0); + assertEquals(0, countLiquid(disabledCapture, (byte) 1)); + assertWaterRespectsSplitCutoff(enabledCapture, 30, 60); + assertTrue(enabledCapture.carvedCells.contains(cellKey(0, 40, 0))); + assertTrue(enabledCapture.carvedCells.contains(cellKey(15, 70, 0))); + } + + @Test + public void dimensionFluidHeightCapsWaterIntent() { + Engine engine = createEngine(80, 70); + IrisCaveProfile profile = createWaterProfile().setWaterMinDepthBelowSurface(0); + WriterCapture capture = createWriterCapture(80); + + new IrisCaveCarver3D(engine, profile).carve( + capture.writer, 0, 0, fullWeights(), 0D, 0D, null, filledHeights(70)); + + assertTrue(countLiquid(capture, (byte) 1) > 0); + assertLiquidAtOrBelow(capture, (byte) 1, 64); + assertTrue(containsLiquidInRange(capture.carvedLiquids, 65, 70, (byte) 0)); + } + + @Test + public void floorRequiredWaterResolvesAfterTheCompleteCarveMask() { + Engine engine = createEngine(80, 70); + int[] surfaceHeights = filledHeights(70); + int chunkX = -8; + int chunkZ = -1; + + IrisStyledRange cupThreshold = new IrisStyledRange(0.15D, 0.15D, new IrisGeneratorStyle(NoiseStyle.FLAT)); + IrisCaveProfile supportedProfile = createWaterProfile() + .setDensityThreshold(cupThreshold) + .setWaterMinDepthBelowSurface(0) + .setWaterRequiresFloor(true); + WriterCapture firstCapture = createWriterCapture(80); + IrisCaveCarver3D.WaterSupportPlan supportPlan = new IrisCaveCarver3D.WaterSupportPlan(); + new IrisCaveCarver3D(engine, supportedProfile).carve( + firstCapture.writer, chunkX, chunkZ, fullWeights(), 0D, 0D, null, surfaceHeights, null, supportPlan); + int candidateCount = countLiquid(firstCapture, (byte) 1); + supportPlan.resolve(firstCapture.writer.acquireChunk(chunkX, chunkZ)); + + IrisCaveProfile repeatedProfile = createWaterProfile() + .setDensityThreshold(cupThreshold) + .setWaterMinDepthBelowSurface(0) + .setWaterRequiresFloor(true); + WriterCapture secondCapture = createWriterCapture(80); + new IrisCaveCarver3D(engine, repeatedProfile).carve( + secondCapture.writer, chunkX, chunkZ, fullWeights(), 0D, 0D, null, surfaceHeights); + + IrisCaveProfile unrestrictedProfile = createWaterProfile() + .setDensityThreshold(cupThreshold) + .setWaterMinDepthBelowSurface(0) + .setWaterRequiresFloor(false); + WriterCapture unrestrictedCapture = createWriterCapture(80); + new IrisCaveCarver3D(engine, unrestrictedProfile).carve( + unrestrictedCapture.writer, chunkX, chunkZ, fullWeights(), 0D, 0D, null, surfaceHeights); + + assertTrue(candidateCount > 0); + assertEquals(firstCapture.carvedLiquids, secondCapture.carvedLiquids); + assertTrue(countLiquid(firstCapture, (byte) 1) > 0); + assertTrue(countLiquid(firstCapture, (byte) 1) < countLiquid(unrestrictedCapture, (byte) 1)); + assertWaterCellsHaveSolidSupport(firstCapture); + } + + @Test + public void finalWaterSupportRejectsUnknownNeighborChunkEdges() { + WriterCapture capture = createWriterCapture(80); + MantleChunk chunk = capture.writer.acquireChunk(0, 0); + MatterSlice slice = chunk.getOrCreate(3).slice(MatterCavern.class); + MatterCavern water = new MatterCavern(true, "", (byte) 1); + MatterCavern air = new MatterCavern(true, "", (byte) 0); + int y = 56; + int z = 8; + slice.set(0, y & 15, z, water); + slice.set(8, y & 15, z, water); + IrisCaveCarver3D.WaterSupportPlan supportPlan = new IrisCaveCarver3D.WaterSupportPlan(); + supportPlan.add(0, y, z, water, air); + supportPlan.add(8, y, z, water, air); + + supportPlan.resolve(chunk); + + assertEquals(Byte.valueOf((byte) 0), capture.carvedLiquids.get(cellKey(0, y, z))); + assertEquals(Byte.valueOf((byte) 1), capture.carvedLiquids.get(cellKey(8, y, z))); + } + + @Test + public void floorRequiredWaterSeesLaterProfileCarvePasses() { + Engine engine = createEngine(80, 70); + int[] surfaceHeights = filledHeights(70); + WriterCapture capture = createWriterCapture(80); + IrisCaveCarver3D.WaterSupportPlan waterSupportPlan = new IrisCaveCarver3D.WaterSupportPlan(); + IrisCaveProfile waterProfile = createWaterProfile() + .setDensityThreshold(new IrisStyledRange(0.15D, 0.15D, new IrisGeneratorStyle(NoiseStyle.FLAT))) + .setWaterRequiresFloor(true); + IrisCaveProfile airProfile = createWaterProfile().setAllowWater(false); + + new IrisCaveCarver3D(engine, waterProfile).carve( + capture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights, + new IrisRange(20D, 64D), waterSupportPlan); + String waterCell = firstCellWithLiquid(capture, (byte) 1); + assertTrue(waterCell != null); + int waterY = coordinate(waterCell, 1); + new IrisCaveCarver3D(engine, airProfile).carve( + capture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights, + new IrisRange(waterY - 1D, waterY - 1D), waterSupportPlan); + + assertEquals(Byte.valueOf((byte) 1), capture.carvedLiquids.get(waterCell)); + waterSupportPlan.resolve(capture.writer.acquireChunk(0, 0)); + assertEquals(Byte.valueOf((byte) 0), capture.carvedLiquids.get(waterCell)); + } + @Test public void optimizedExactPathOutperformsNaiveReference() throws Exception { Engine engine = createEngine(128, 92); @@ -336,6 +471,7 @@ public class IrisCaveCarver3DNearParityTest { CNG surfaceBreakDensity = (CNG) surfaceBreakDensityField.get(carver); RNG thresholdRng = (RNG) thresholdRngField.get(carver); MatterCavern carveAir = (MatterCavern) carveAirField.get(carver); + MatterCavern carveWater = (MatterCavern) carveWaterField.get(carver); MatterCavern carveLava = (MatterCavern) carveLavaField.get(carver); MatterCavern carveForcedAir = (MatterCavern) carveForcedAirField.get(carver); @@ -366,6 +502,7 @@ public class IrisCaveCarver3DNearParityTest { int[] columnTopY = new int[256]; int[] surfaceBreakFloorY = new int[256]; boolean[] surfaceBreakColumn = new boolean[256]; + int[] waterMaxY = new int[256]; double[] passThreshold = new double[256]; double[] verticalEdgeFade = computeVerticalEdgeFade(profile, minY, maxY); MatterCavern[] matterByY = computeMatterByY(engine, profile, carveAir, carveLava, carveForcedAir, minY, maxY); @@ -388,6 +525,9 @@ public class IrisCaveCarver3DNearParityTest { boolean breakColumn = allowSurfaceBreak && signed(surfaceBreakDensity.noiseFast2D(x, z)) >= surfaceBreakNoiseThreshold; int resolvedTopY = breakColumn ? Math.min(maxY, Math.max(minY, columnSurfaceY)) : clearanceTopY; columnTopY[columnIndex] = resolvedTopY; + waterMaxY[columnIndex] = profile.isAllowWater() + ? Math.min(engine.getDimension().getFluidHeight(), columnSurfaceY - Math.max(0, profile.getWaterMinDepthBelowSurface())) + : Integer.MIN_VALUE; surfaceBreakFloorY[columnIndex] = Math.max(minY, columnSurfaceY - surfaceBreakDepth); surfaceBreakColumn[columnIndex] = breakColumn; double columnWeight = clampColumnWeight(resolvedWeights[columnIndex]); @@ -431,7 +571,12 @@ public class IrisCaveCarver3DNearParityTest { Matter sectionMatter = chunk.getOrCreate(y >> 4); MatterSlice cavernSlice = sectionMatter.slice(MatterCavern.class); - cavernSlice.set(localX, y & 15, localZ, matterByY[y - minY]); + MatterCavern verticalMatter = matterByY[y - minY]; + boolean aquifer = verticalMatter == carveAir + && y <= waterMaxY[columnIndex] + && (boolean) aquiferCandidateMethod.invoke(carver, x, y, z, localThreshold); + MatterCavern matter = aquifer ? carveWater : verticalMatter; + cavernSlice.set(localX, y & 15, localZ, matter); carved++; } } @@ -469,9 +614,7 @@ public class IrisCaveCarver3DNearParityTest { private MatterCavern[] computeMatterByY(Engine engine, IrisCaveProfile profile, MatterCavern carveAir, MatterCavern carveLava, MatterCavern carveForcedAir, int minY, int maxY) { MatterCavern[] matterByY = new MatterCavern[Math.max(0, maxY - minY + 1)]; boolean allowLava = profile.isAllowLava(); - boolean allowWater = profile.isAllowWater(); int lavaHeight = engine.getDimension().getCaveLavaHeight(); - int fluidHeight = engine.getDimension().getFluidHeight(); for (int y = minY; y <= maxY; y++) { int offset = y - minY; @@ -479,10 +622,6 @@ public class IrisCaveCarver3DNearParityTest { matterByY[offset] = carveLava; continue; } - if (allowWater && y <= fluidHeight) { - matterByY[offset] = carveAir; - continue; - } if (!allowLava && y <= lavaHeight) { matterByY[offset] = carveForcedAir; continue; @@ -565,6 +704,22 @@ public class IrisCaveCarver3DNearParityTest { return profile; } + private IrisCaveProfile createWaterProfile() { + return createProfile(false, false) + .setVerticalRange(new IrisRange(20D, 70D)) + .setVerticalEdgeFade(0) + .setVerticalEdgeFadeStrength(0D) + .setDensityThreshold(new IrisStyledRange(2D, 2D, new IrisGeneratorStyle(NoiseStyle.FLAT))) + .setAdaptiveSampling(false) + .setSurfaceClearance(5) + .setAllowSurfaceBreak(true) + .setSurfaceBreakNoiseThreshold(-1D) + .setSurfaceBreakThresholdBoost(0D) + .setAllowWater(true) + .setWaterRequiresFloor(false) + .setAllowLava(true); + } + private WriterCapture createWriterCapture(int worldHeight) { MantleWriter writer = mock(MantleWriter.class); @SuppressWarnings("unchecked") @@ -579,6 +734,7 @@ public class IrisCaveCarver3DNearParityTest { doReturn(mantle).when(writer).getMantle(); doReturn(worldHeight).when(mantle).getWorldHeight(); doReturn(chunk).when(writer).acquireChunk(anyInt(), anyInt()); + doAnswer(invocation -> sections.get(invocation.getArgument(0, Integer.class))).when(chunk).get(anyInt()); doAnswer(invocation -> { int sectionIndex = invocation.getArgument(0); Matter section = sections.get(sectionIndex); @@ -601,6 +757,7 @@ public class IrisCaveCarver3DNearParityTest { Map localCells = sectionCells.computeIfAbsent(sectionIndex, key -> new HashMap<>()); doReturn(slice).when(matter).slice(MatterCavern.class); + doReturn(slice).when(matter).getSlice(MatterCavern.class); doAnswer(invocation -> { int localX = invocation.getArgument(0); int localY = invocation.getArgument(1); @@ -635,6 +792,100 @@ public class IrisCaveCarver3DNearParityTest { return heights; } + private int[] splitSurfaceHeights(int lowHeight, int highHeight) { + int[] heights = new int[256]; + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + heights[(localX << 4) | localZ] = localX < 8 ? lowHeight : highHeight; + } + } + return heights; + } + + private void assertWaterCellsHaveSolidSupport(WriterCapture capture) { + for (Map.Entry entry : capture.carvedLiquids.entrySet()) { + if (entry.getValue() != 1) { + continue; + } + + String[] split = entry.getKey().split(":"); + int x = Integer.parseInt(split[0]); + int y = Integer.parseInt(split[1]); + int z = Integer.parseInt(split[2]); + assertFalse(capture.carvedCells.contains(cellKey(x, y - 1, z))); + assertFalse(capture.carvedCells.contains(cellKey(x, y - 2, z))); + int support = 0; + if (!capture.carvedCells.contains(cellKey(x + 1, y, z))) { + support++; + } + if (!capture.carvedCells.contains(cellKey(x - 1, y, z))) { + support++; + } + if (!capture.carvedCells.contains(cellKey(x, y, z + 1))) { + support++; + } + if (!capture.carvedCells.contains(cellKey(x, y, z - 1))) { + support++; + } + if (!capture.carvedCells.contains(cellKey(x, y + 1, z))) { + support++; + } + assertTrue(support >= 4); + } + } + + private void assertWaterRespectsSplitCutoff(WriterCapture capture, int lowCutoff, int highCutoff) { + boolean lowWater = false; + boolean highWater = false; + for (Map.Entry entry : capture.carvedLiquids.entrySet()) { + if (entry.getValue() != 1) { + continue; + } + int x = coordinate(entry.getKey(), 0); + int y = coordinate(entry.getKey(), 1); + if (x < 8) { + assertTrue(y <= lowCutoff); + lowWater = true; + } else { + assertTrue(y <= highCutoff); + highWater = true; + } + } + assertTrue(lowWater); + assertTrue(highWater); + } + + private void assertLiquidAtOrBelow(WriterCapture capture, byte liquid, int maxY) { + for (Map.Entry entry : capture.carvedLiquids.entrySet()) { + if (entry.getValue() == liquid) { + assertTrue(coordinate(entry.getKey(), 1) <= maxY); + } + } + } + + private int countLiquid(WriterCapture capture, byte liquid) { + int count = 0; + for (byte value : capture.carvedLiquids.values()) { + if (value == liquid) { + count++; + } + } + return count; + } + + private String firstCellWithLiquid(WriterCapture capture, byte liquid) { + for (Map.Entry entry : capture.carvedLiquids.entrySet()) { + if (entry.getValue() == liquid) { + return entry.getKey(); + } + } + return null; + } + + private int coordinate(String cell, int index) { + return Integer.parseInt(cell.split(":")[index]); + } + private double clampColumnWeight(double weight) { if (Double.isNaN(weight) || Double.isInfinite(weight)) { return 0D; diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentMarkerTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentMarkerTest.java index 21cc1acf2..3c2b1b177 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentMarkerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentMarkerTest.java @@ -1,11 +1,19 @@ package art.arcane.iris.engine.mantle.components; +import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.framework.PlacedStructurePiece; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.NativeStructureSuppression; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -32,4 +40,64 @@ public class IrisStructureComponentMarkerTest { assertEquals(first, repeated); assertNotEquals(first, moved); } + + @Test + public void emptyResolvedPiecesRemainSkippableForNonReplacement() { + IrisStructureLocator.ResolvedPlacement resolved = resolvedPlacement( + NativeStructureSuppression.NONE, new KList<>()); + + assertNull(IrisStructureComponent.resolvedPiecesOrNull(resolved, 4, -3)); + IrisStructureComponent.requireAppliedPieces(resolvedWithOnePiece(NativeStructureSuppression.NONE), + 4, -3, 1); + } + + @Test + public void emptyResolvedPiecesFailForReplacement() { + IrisStructureLocator.ResolvedPlacement resolved = resolvedPlacement( + NativeStructureSuppression.REPLACE_SOURCE, new KList<>()); + + assertReplacementFailure( + () -> IrisStructureComponent.resolvedPiecesOrNull(resolved, 4, -3), + "placement application received no assembled pieces"); + } + + @Test + public void rejectedAppliedPieceFailsForReplacement() { + IrisStructureLocator.ResolvedPlacement resolved = resolvedWithOnePiece( + NativeStructureSuppression.REPLACE_SOURCE); + + assertReplacementFailure( + () -> IrisStructureComponent.requireAppliedPieces(resolved, 4, -3, 1), + "object placement rejected 1 of 1 assembled piece(s)"); + } + + private IrisStructureLocator.ResolvedPlacement resolvedWithOnePiece( + NativeStructureSuppression suppression + ) { + KList pieces = new KList<>(); + pieces.add(mock(PlacedStructurePiece.class)); + return resolvedPlacement(suppression, pieces); + } + + private IrisStructureLocator.ResolvedPlacement resolvedPlacement( + NativeStructureSuppression suppression, + KList pieces + ) { + IrisStructurePlacement placement = new IrisStructurePlacement().setNativeSuppression(suppression); + placement.getStructures().add("test:city"); + IrisStructure structure = new IrisStructure(); + structure.setLoadKey("test:city"); + return new IrisStructureLocator.ResolvedPlacement( + placement, "test:city", structure, pieces, new RNG(1L), 0, 64, 0, false); + } + + private void assertReplacementFailure(Runnable operation, String expectedMessage) { + try { + operation.run(); + } catch (IllegalStateException e) { + assertTrue(e.getMessage(), e.getMessage().contains(expectedMessage)); + return; + } + throw new AssertionError("Expected strict native replacement failure containing '" + expectedMessage + "'"); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureFoundationWriteTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureFoundationWriteTest.java new file mode 100644 index 000000000..05aa4dff2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureFoundationWriteTest.java @@ -0,0 +1,54 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.mantle.MantleWriter; +import art.arcane.iris.engine.object.IrisMaterialPalette; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; +import org.mockito.InOrder; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class IrisStructureFoundationWriteTest { + @Test + public void resolvesWorldCoordinatesThenClearsCavernBeforeWritingSupport() { + MantleWriter writer = mock(MantleWriter.class); + IrisMaterialPalette palette = mock(IrisMaterialPalette.class); + RNG rng = new RNG(1337L); + IrisData data = mock(IrisData.class); + PlatformBlockState deepslateBricks = mock(PlatformBlockState.class); + when(palette.get(rng, 12, -247, -18, data)).thenReturn(deepslateBricks); + + IrisStructureComponent.writeFoundationSupport( + writer, palette, rng, data, 12, 9, -18, -256); + + InOrder order = inOrder(palette, writer); + order.verify(palette).get(rng, 12, -247, -18, data); + order.verify(writer).clearData(12, 9, -18, MatterCavern.class); + order.verify(writer).set(12, 9, -18, deepslateBricks); + } + + @Test + public void unresolvedPaletteFailsBeforeChangingCavernOrBlocks() { + MantleWriter writer = mock(MantleWriter.class); + IrisMaterialPalette palette = mock(IrisMaterialPalette.class); + RNG rng = new RNG(1337L); + IrisData data = mock(IrisData.class); + + try { + IrisStructureComponent.writeFoundationSupport( + writer, palette, rng, data, 12, 9, -18, -256); + } catch (IllegalStateException error) { + assertTrue(error.getMessage().contains("12,-247,-18")); + verifyNoInteractions(writer); + return; + } + throw new AssertionError("Expected an unresolved structure stilt palette to fail"); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponentBoundaryRadiusTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponentBoundaryRadiusTest.java new file mode 100644 index 000000000..ec6b92503 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponentBoundaryRadiusTest.java @@ -0,0 +1,103 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisFloatingChildBiomes; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisObjectTranslate; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.collection.KList; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MantleFloatingObjectComponentBoundaryRadiusTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + @SuppressWarnings("unchecked") + public void transformedFloatingPlacementExpandsOwnerRadius() throws Exception { + File objectFile = temporaryFolder.newFile("floating.iob"); + try (DataOutputStream output = new DataOutputStream(new FileOutputStream(objectFile))) { + output.writeInt(1); + output.writeInt(1); + output.writeInt(1); + } + + IrisObjectPlacement placement = new IrisObjectPlacement() + .setPlace(new KList<>("test/floating")) + .setTranslate(new IrisObjectTranslate().setX(32)); + IrisFloatingChildBiomes floatingChild = new IrisFloatingChildBiomes() + .setFloatingObjects(new KList<>(placement)); + IrisBiome biome = new IrisBiome().setFloatingChildBiomes(new KList<>(floatingChild)); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getReachableBiomes(org.mockito.ArgumentMatchers.any())).thenReturn(new KList<>(biome)); + + ResourceLoader objectLoader = mock(ResourceLoader.class); + when(objectLoader.findFile("test/floating")).thenReturn(objectFile); + IrisData data = mock(IrisData.class); + when(data.getObjectLoader()).thenReturn(objectLoader); + + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + EngineMantle engineMantle = mock(EngineMantle.class); + when(engineMantle.getEngine()).thenReturn(engine); + when(engineMantle.getData()).thenReturn(data); + + assertEquals(33, new MantleFloatingObjectComponent(engineMantle).getRadius()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponentInvertedPlacementTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponentInvertedPlacementTest.java index 80dc666a6..1574d7427 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponentInvertedPlacementTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponentInvertedPlacementTest.java @@ -1,16 +1,26 @@ package art.arcane.iris.engine.mantle.components; +import art.arcane.iris.engine.object.FloatingIslandSample; import art.arcane.iris.engine.object.FloatingObjectFootprint; +import art.arcane.iris.engine.object.IrisFloatingChildBiomes; import art.arcane.iris.engine.object.IrisObjectRotation; import org.junit.Test; import java.lang.reflect.Constructor; +import java.util.Arrays; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; public class MantleFloatingObjectComponentInvertedPlacementTest { private FloatingObjectFootprint footprint(int lowestSolidKeyY, int highestSolidKeyY, int tallestKx, int tallestKz) throws Exception { + return footprint(lowestSolidKeyY, highestSolidKeyY, tallestKx, tallestKz, new long[0]); + } + + private FloatingObjectFootprint footprint(int lowestSolidKeyY, int highestSolidKeyY, + int tallestKx, int tallestKz, long[] cells) throws Exception { Constructor constructor = FloatingObjectFootprint.class.getDeclaredConstructor( int.class, int.class, @@ -34,10 +44,25 @@ public class MantleFloatingObjectComponentInvertedPlacementTest { tallestKz, 99, 99, - new long[0] + cells ); } + private FloatingIslandSample sample(IrisFloatingChildBiomes entry) throws Exception { + Constructor constructor = FloatingIslandSample.class.getDeclaredConstructor( + IrisFloatingChildBiomes.class, + int.class, + int.class, + int.class, + int.class, + boolean[].class + ); + constructor.setAccessible(true); + boolean[] solidMask = new boolean[10]; + Arrays.fill(solidMask, true); + return constructor.newInstance(entry, 100, 10, 9, 10, solidMask); + } + @Test public void invertedBaseY_anchorsOriginalLowestSolidBelowBottomFace() throws Exception { FloatingObjectFootprint footprint = footprint(5, 30, 2, 3); @@ -82,4 +107,21 @@ public class MantleFloatingObjectComponentInvertedPlacementTest { assertEquals(104, MantleFloatingObjectComponent.invertedBaseY(100, footprint, rotation)); } + + @Test + public void topFootprintChecksNeighborChunkTerrain() throws Exception { + IrisFloatingChildBiomes entry = new IrisFloatingChildBiomes(); + FloatingIslandSample sample = sample(entry); + long[] cells = {0L, 1L << 32}; + FloatingObjectFootprint footprint = footprint(0, 0, 0, 0, cells); + + assertTrue(MantleFloatingObjectComponent.isFootprintFlat( + footprint, 15, 0, 109, + (x, z) -> z == 0 && (x == 15 || x == 16) ? sample : null, + entry, 2)); + assertFalse(MantleFloatingObjectComponent.isFootprintFlat( + footprint, 15, 0, 109, + (x, z) -> x == 15 && z == 0 ? sample : null, + entry, 2)); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentBoundaryRadiusTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentBoundaryRadiusTest.java new file mode 100644 index 000000000..45812c3c7 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentBoundaryRadiusTest.java @@ -0,0 +1,225 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.core.nms.container.Pair; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.mantle.MantleComponent; +import art.arcane.iris.engine.mantle.MatterGenerator; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisGeneratorStyle; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.engine.object.IrisObjectScale; +import art.arcane.iris.engine.object.IrisObjectTranslate; +import art.arcane.iris.engine.object.IrisProceduralObjects; +import art.arcane.iris.engine.object.IrisProceduralPlacement; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisVacuumSettings; +import art.arcane.iris.engine.object.NoiseStyle; +import art.arcane.iris.engine.object.ObjectPlaceMode; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class MantleObjectComponentBoundaryRadiusTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void verticalRotationAndTranslationExpandHorizontalReach() { + IrisObjectPlacement placement = new IrisObjectPlacement() + .setRotation(IrisObjectRotation.of(90, 0, 0)) + .setTranslate(new IrisObjectTranslate().setX(3).setY(4)); + + assertEquals(53, MantleObjectComponent.calculatePlacementReach(new IrisBlockVector(5, 48, 7), placement)); + } + + @Test + public void scaleWarpAndVacuumExpandPlacementReach() { + IrisObjectPlacement placement = new IrisObjectPlacement() + .setScale(new IrisObjectScale().setSize(2D)) + .setWarp(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).setMultiplier(10D)) + .setMode(ObjectPlaceMode.VACUUM) + .setVacuumSettings(new IrisVacuumSettings().setRadius(12)); + + assertEquals(43, MantleObjectComponent.calculatePlacementReach(new IrisBlockVector(5, 4, 3), placement)); + } + + @Test + public void proceduralTreeTransformsContributeToRadius() { + IrisObjectPlacement placement = new IrisObjectPlacement() + .setRotation(IrisObjectRotation.of(90, 0, 0)) + .setTranslate(new IrisObjectTranslate().setX(3).setY(4)); + IrisData data = mock(IrisData.class); + IrisProceduralPlacement proceduralPlacement = mock(IrisProceduralPlacement.class); + when(proceduralPlacement.getVariantObjects(data)).thenReturn(new KList<>(new IrisObject(5, 48, 7))); + when(proceduralPlacement.asPlacement()).thenReturn(placement); + IrisProceduralObjects proceduralObjects = mock(IrisProceduralObjects.class); + when(proceduralObjects.isEmpty()).thenReturn(false); + when(proceduralObjects.getAllPlacements()).thenReturn(new KList<>(proceduralPlacement)); + IrisRegion region = mock(IrisRegion.class); + when(region.getObjects()).thenReturn(new KList<>()); + when(region.getProceduralObjects()).thenReturn(proceduralObjects); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getAllRegions(any())).thenReturn(new KList<>(region)); + when(dimension.getReachableBiomes(any())).thenReturn(new KList<>()); + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + EngineMantle engineMantle = mock(EngineMantle.class); + when(engineMantle.getEngine()).thenReturn(engine); + when(engineMantle.getData()).thenReturn(data); + + assertEquals(53, new MantleObjectComponent(engineMantle).getRadius()); + } + + @Test + @SuppressWarnings("unchecked") + public void translatedTreeSchedulesDistantOwnerChunks() throws Exception { + File objectFile = temporaryFolder.newFile("tree.iob"); + try (DataOutputStream output = new DataOutputStream(new FileOutputStream(objectFile))) { + output.writeInt(1); + output.writeInt(1); + output.writeInt(1); + } + + IrisObjectPlacement placement = new IrisObjectPlacement() + .setPlace(new KList<>("test/tree")) + .setTranslate(new IrisObjectTranslate().setX(32)); + IrisBiome biome = new IrisBiome().setObjects(new KList<>(placement)); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.isUseMantle()).thenReturn(true); + when(dimension.getAllRegions(any())).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(any())).thenReturn(new KList<>(biome)); + + ResourceLoader objectLoader = mock(ResourceLoader.class); + when(objectLoader.findFile("test/tree")).thenReturn(objectFile); + IrisData data = mock(IrisData.class); + when(data.getObjectLoader()).thenReturn(objectLoader); + + EngineMantle engineMantle = mock(EngineMantle.class); + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getEngine()).thenReturn(engine); + when(engineMantle.getData()).thenReturn(data); + + MantleObjectComponent component = spy(new MantleObjectComponent(engineMantle)); + assertEquals(33, component.getRadius()); + int chunkRadius = Math.ceilDiv(component.getRadius(), 16); + assertEquals(3, chunkRadius); + + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }).when(chunk).raiseFlagSuspend(any(), any(Runnable.class)); + + List generatedChunks = new ArrayList<>(); + doAnswer(invocation -> { + int chunkX = invocation.getArgument(1); + int chunkZ = invocation.getArgument(2); + generatedChunks.add(chunkX + "," + chunkZ); + return null; + }).when(component).generateLayer(any(), anyInt(), anyInt(), any()); + + TestMatterGenerator generator = new TestMatterGenerator(new GeneratorOptions(engine, mantle, component, chunkRadius)); + generator.generateMatter(0, 0, false, mock(ChunkContext.class)); + + assertTrue(generatedChunks.contains("-2,0")); + assertTrue(generatedChunks.contains("2,0")); + } + + private static final class TestMatterGenerator implements MatterGenerator { + private final Engine engine; + private final Mantle mantle; + private final List, Integer>> components; + private final int radius; + + private TestMatterGenerator(GeneratorOptions options) { + this.engine = options.engine(); + this.mantle = options.mantle(); + this.components = List.of(new Pair<>(List.of(options.component()), options.radius())); + this.radius = options.radius(); + } + + @Override + public Engine getEngine() { + return engine; + } + + @Override + public Mantle getMantle() { + return mantle; + } + + @Override + public int getRadius() { + return radius; + } + + @Override + public int getRealRadius() { + return 0; + } + + @Override + public List, Integer>> getComponents() { + return components; + } + } + + private record GeneratorOptions(Engine engine, Mantle mantle, MantleComponent component, int radius) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/StructureFoundationPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/StructureFoundationPlannerTest.java new file mode 100644 index 000000000..29551523b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/StructureFoundationPlannerTest.java @@ -0,0 +1,81 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.spi.PlatformBlockState; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class StructureFoundationPlannerTest { + @Test + public void recordsLowestPlacedOccludingCellAcrossTheAssembledFootprint() { + PlatformBlockState solid = mock(PlatformBlockState.class); + when(solid.isOccluding()).thenReturn(true); + PlatformBlockState decoration = mock(PlatformBlockState.class); + when(decoration.isOccluding()).thenReturn(false); + + Long2IntOpenHashMap columns = new Long2IntOpenHashMap(); + StructureFoundationPlanner.recordBaseCell(columns, 100, 48, 200, decoration); + StructureFoundationPlanner.recordBaseCell(columns, 100, 49, 200, solid); + StructureFoundationPlanner.recordBaseCell(columns, 100, 50, 200, solid); + StructureFoundationPlanner.recordBaseCell(columns, 100, 45, 200, solid); + StructureFoundationPlanner.recordBaseCell(columns, -17, 60, -33, solid); + + assertEquals(2, columns.size()); + assertEquals(45, columns.get(StructureFoundationPlanner.pack(100, 200))); + assertEquals(60, columns.get(StructureFoundationPlanner.pack(-17, -33))); + assertEquals(-17, StructureFoundationPlanner.unpackX(StructureFoundationPlanner.pack(-17, -33))); + assertEquals(-33, StructureFoundationPlanner.unpackZ(StructureFoundationPlanner.pack(-17, -33))); + } + + @Test + public void scansThroughAirAndFluidUntilSolidGround() { + int groundY = StructureFoundationPlanner.findGroundY(10, 10, 0, y -> y == 3); + + assertEquals(3, groundY); + assertEquals(StructureFoundationPlanner.NO_GROUND, + StructureFoundationPlanner.findGroundY(10, 6, 0, y -> y == 3)); + } + + @Test + public void compositeGroundUsesOverlayThenCarvingThenTerrain() { + PlatformBlockState solid = mock(PlatformBlockState.class); + when(solid.isSolid()).thenReturn(true); + PlatformBlockState fluid = mock(PlatformBlockState.class); + when(fluid.isSolid()).thenReturn(false); + + assertTrue(StructureFoundationPlanner.isGroundSolid(solid, true, 12, 20)); + assertFalse(StructureFoundationPlanner.isGroundSolid(fluid, false, 12, 20)); + assertFalse(StructureFoundationPlanner.isGroundSolid(null, true, 12, 20)); + assertTrue(StructureFoundationPlanner.isGroundSolid(null, false, 12, 20)); + assertFalse(StructureFoundationPlanner.isGroundSolid(null, false, 21, 20)); + assertTrue(StructureFoundationPlanner.isGroundSolid(null, true, 0, -20)); + } + + @Test + public void fillsOnlyTheGapBetweenTheFoundationAndGround() { + List written = new ArrayList<>(); + + int count = StructureFoundationPlanner.fillSupportColumn(10, 3, written::add); + + assertEquals(6, count); + assertEquals(List.of(9, 8, 7, 6, 5, 4), written); + } + + @Test + public void doesNotWriteWithoutReachableGroundOrOverExistingGround() { + List written = new ArrayList<>(); + + assertEquals(0, StructureFoundationPlanner.fillSupportColumn( + 10, StructureFoundationPlanner.NO_GROUND, written::add)); + assertEquals(0, StructureFoundationPlanner.fillSupportColumn(10, 9, written::add)); + assertTrue(written.isEmpty()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierFluidIntentTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierFluidIntentTest.java new file mode 100644 index 000000000..91807a793 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierFluidIntentTest.java @@ -0,0 +1,45 @@ +package art.arcane.iris.engine.modifier; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.matter.MatterCavern; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisCarveModifierFluidIntentTest { + @Test + public void explicitCavernIntentsOverrideExistingFluid() { + MatterCavern airIntent = new MatterCavern(true, "", (byte) 0); + MatterCavern waterIntent = new MatterCavern(true, "", (byte) 1); + MatterCavern lavaIntent = new MatterCavern(true, "", (byte) 2); + MatterCavern forcedAirIntent = new MatterCavern(true, "", (byte) 3); + PlatformBlockState existingFluid = mock(PlatformBlockState.class); + PlatformBlockState fluid = mock(PlatformBlockState.class); + PlatformBlockState lava = mock(PlatformBlockState.class); + PlatformBlockState air = mock(PlatformBlockState.class); + when(existingFluid.isFluid()).thenReturn(true); + + assertFalse(IrisCarveModifier.hasExplicitCarveIntent(null)); + assertTrue(IrisCarveModifier.shouldPreserveExistingFluid(airIntent, existingFluid)); + assertFalse(IrisCarveModifier.shouldPreserveExistingFluid(waterIntent, existingFluid)); + assertFalse(IrisCarveModifier.shouldPreserveExistingFluid(lavaIntent, existingFluid)); + assertFalse(IrisCarveModifier.shouldPreserveExistingFluid(forcedAirIntent, existingFluid)); + assertNull(IrisCarveModifier.resolveExplicitCarveState(null, fluid, lava, air)); + assertSame(fluid, IrisCarveModifier.resolveExplicitCarveState(waterIntent, fluid, lava, air)); + assertSame(lava, IrisCarveModifier.resolveExplicitCarveState(lavaIntent, fluid, lava, air)); + assertSame(air, IrisCarveModifier.resolveExplicitCarveState(forcedAirIntent, fluid, lava, air)); + assertNull(IrisCarveModifier.resolveExplicitCarveState(airIntent, fluid, lava, air)); + } + + @Test + public void defaultLavaIncludesConfiguredBoundary() { + assertTrue(IrisCarveModifier.usesDefaultLava(18, 17)); + assertTrue(IrisCarveModifier.usesDefaultLava(18, 18)); + assertFalse(IrisCarveModifier.usesDefaultLava(18, 19)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContainmentTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContainmentTest.java new file mode 100644 index 000000000..57dd21ce1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContainmentTest.java @@ -0,0 +1,56 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.modifier; + +import art.arcane.iris.spi.PlatformBlockState; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisDepositModifierContainmentTest { + @Test + public void depositSurfaceLimitKeepsEveryColumnSevenBlocksBuried() { + assertEquals(73, IrisDepositModifier.depositSurfaceLimit(80)); + assertEquals(24, IrisDepositModifier.depositSurfaceLimit(31)); + } + + @Test + public void variantHeightUsesAbsoluteWorldY() { + assertEquals(-32, IrisDepositModifier.absoluteWorldY(-64, 32)); + assertEquals(96, IrisDepositModifier.absoluteWorldY(0, 96)); + } + + @Test + public void airAndFluidTargetsAreRejected() { + PlatformBlockState solid = mock(PlatformBlockState.class); + PlatformBlockState air = mock(PlatformBlockState.class); + PlatformBlockState fluid = mock(PlatformBlockState.class); + when(air.isAir()).thenReturn(true); + when(fluid.isFluid()).thenReturn(true); + + assertTrue(IrisDepositModifier.canReplaceDepositTarget(solid)); + assertFalse(IrisDepositModifier.canReplaceDepositTarget(air)); + assertFalse(IrisDepositModifier.canReplaceDepositTarget(fluid)); + assertFalse(IrisDepositModifier.canReplaceDepositTarget(null)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/FloatingIslandBoundarySamplerTest.java b/core/src/test/java/art/arcane/iris/engine/object/FloatingIslandBoundarySamplerTest.java new file mode 100644 index 000000000..201e57dbe --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/FloatingIslandBoundarySamplerTest.java @@ -0,0 +1,687 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertArrayEquals; +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.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class FloatingIslandBoundarySamplerTest { + @Test + public void edgeFade_remainsFullInsideOneParentBiome() { + IrisBiome parent = new IrisBiome(); + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> parent); + + assertEquals(1.0D, sampler.edgeFade(parent, 0, 0), 0.0D); + } + + @Test + public void edgeFade_roundsTowardParentBiomeBoundary() { + IrisBiome left = new IrisBiome(); + left.setLoadKey("left"); + IrisBiome right = new IrisBiome(); + right.setLoadKey("right"); + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> x < 0 ? left : right); + + assertEquals(FloatingIslandBoundarySampler.edgeFadeForDistance(1), sampler.edgeFade(right, 0, 0), 0.0D); + assertEquals(FloatingIslandBoundarySampler.edgeFadeForDistance(2), sampler.edgeFade(right, 1, 0), 0.0D); + assertEquals(1.0D, sampler.edgeFade(right, FloatingIslandBoundarySampler.EDGE_FADE_RADIUS - 1, 0), 0.0D); + } + + @Test + public void edgeFade_acceptsReloadedBiomeInstanceWithSameKey() { + IrisBiome expected = new IrisBiome(); + expected.setLoadKey("magnetics/mycelium"); + IrisBiome reloaded = new IrisBiome(); + reloaded.setLoadKey("magnetics/mycelium"); + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> reloaded); + + assertEquals(1.0D, sampler.edgeFade(expected, 0, 0), 0.0D); + } + + @Test + public void edgeFade_detectsOffAxisBoundaryWithinRadius() { + IrisBiome parent = new IrisBiome(); + parent.setLoadKey("parent"); + IrisBiome neighbor = new IrisBiome(); + neighbor.setLoadKey("neighbor"); + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> x == 2 && z == 1 ? neighbor : parent); + + assertEquals(FloatingIslandBoundarySampler.edgeFadeForDistance(2), sampler.edgeFade(parent, 0, 0), 0.0D); + } + + @Test + public void edgeFade_isSymmetricAcrossChunkBoundary() { + IrisBiome left = new IrisBiome(); + left.setLoadKey("left"); + IrisBiome right = new IrisBiome(); + right.setLoadKey("right"); + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> x < 16 ? left : right); + + assertEquals(sampler.edgeFade(left, 15, 0), sampler.edgeFade(right, 16, 0), 0.0D); + assertEquals(sampler.edgeFade(left, 14, 0), sampler.edgeFade(right, 17, 0), 0.0D); + assertEquals(sampler.edgeFade(left, 13, 0), sampler.edgeFade(right, 18, 0), 0.0D); + } + + @Test + public void parent_cachesResolvedBiomeColumns() { + IrisBiome parent = new IrisBiome(); + AtomicInteger calls = new AtomicInteger(); + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> { + calls.incrementAndGet(); + return parent; + }); + + assertSame(parent, sampler.parent(12, -9)); + int firstReadCount = calls.get(); + assertSame(parent, sampler.parent(12, -9)); + assertEquals(firstReadCount, calls.get()); + int rawWidth = 16 + (FloatingIslandBoundarySampler.EDGE_FADE_RADIUS * 2) + 2; + assertEquals(rawWidth * rawWidth, firstReadCount); + } + + @Test + public void edgeFade_boundsBiomeStreamReadsToChunkHalo() { + IrisBiome parent = new IrisBiome(); + AtomicInteger calls = new AtomicInteger(); + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> { + calls.incrementAndGet(); + return parent; + }); + + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + sampler.edgeFade(parent, x, z); + } + } + + int rawWidth = 16 + (FloatingIslandBoundarySampler.EDGE_FADE_RADIUS * 2) + 2; + assertEquals(rawWidth * rawWidth, calls.get()); + } + + @Test + public void footprintFade_usesDistanceForSaturatedHalfPlane() { + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> null); + CNG footprint = new CNG(new RNG(12)) { + @Override + public double noise(double x, double z) { + return x >= 0 ? 1.0D : 0.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + int[] depths = new int[FloatingIslandBoundarySampler.EDGE_TAPER_WIDTH]; + + assertFalse(sampler.footprint(footprint, 0, 0, 0.0D).accepted()); + for (int distance = 2; distance <= FloatingIslandBoundarySampler.EDGE_FADE_RADIUS; distance++) { + FloatingIslandBoundarySampler.FootprintSample sample = sampler.footprint(footprint, distance - 1, 0, 0.0D); + assertTrue(sample.accepted()); + assertEquals(FloatingIslandBoundarySampler.edgeFadeForDistance(distance), sample.edgeFade(), 0.0D); + depths[distance - 2] = FloatingIslandSample.roundedEdgeDepth(20, 20, 0.0D, sample.edgeFade()); + FloatingIslandSample.NeighborSupport support = new FloatingIslandSample.NeighborSupport(sample.cardinalSupport(), sample.diagonalSupport()); + assertEquals(distance == FloatingIslandBoundarySampler.EDGE_FADE_RADIUS, + FloatingIslandSample.canCarveLaterally(sample.edgeFade(), support)); + } + + assertArrayEquals(new int[]{1, 2, 4, 7, 10, 13, 16, 18, 19, 20}, depths); + for (int i = 1; i < depths.length; i++) { + assertTrue(depths[i] >= depths[i - 1]); + assertTrue(depths[i] - depths[i - 1] <= 3); + } + } + + @Test + public void footprint_deepSingleCellPinholeRemainsSolid() { + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> null); + CNG footprint = new CNG(new RNG(13)) { + @Override + public double noise(double x, double z) { + return x == 0.0D && z == 0.0D ? 0.0D : 1.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + + FloatingIslandBoundarySampler.FootprintSample sample = sampler.footprint(footprint, 0, 0, 0.0D); + + assertTrue(sample.accepted()); + assertEquals(1.0D, sample.edgeFade(), 0.0D); + } + + @Test + public void footprint_repairsOneByTwoAndDiagonalStaticGaps() { + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> null); + CNG adjacent = new CNG(new RNG(27)) { + @Override + public double noise(double x, double z) { + return z == 0.0D && (x == 0.0D || x == 1.0D) ? 0.0D : 1.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + CNG diagonal = new CNG(new RNG(28)) { + @Override + public double noise(double x, double z) { + boolean gap = x == 0.0D && z == 0.0D || x == 1.0D && z == 1.0D; + return gap ? 0.0D : 1.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + + assertTrue(sampler.footprint(adjacent, 0, 0, 0.0D).accepted()); + assertTrue(sampler.footprint(adjacent, 1, 0, 0.0D).accepted()); + assertTrue(sampler.footprint(diagonal, 0, 0, 0.0D).accepted()); + assertTrue(sampler.footprint(diagonal, 1, 1, 0.0D).accepted()); + } + + @Test + public void footprint_broadOpeningRemainsOpen() { + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> null); + CNG footprint = new CNG(new RNG(14)) { + @Override + public double noise(double x, double z) { + return Math.abs(x) <= 1.0D && Math.abs(z) <= 1.0D ? 0.0D : 1.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + + assertFalse(sampler.footprint(footprint, 0, 0, 0.0D).accepted()); + } + + @Test + public void footprint_buildsOneBoundedChunkHalo() { + AtomicInteger calls = new AtomicInteger(); + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> null); + CNG footprint = new CNG(new RNG(15)) { + @Override + public double noise(double x, double z) { + calls.incrementAndGet(); + return 1.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + sampler.footprint(footprint, x, z, 0.0D); + } + } + + int rawWidth = 16 + (FloatingIslandBoundarySampler.EDGE_FADE_RADIUS * 2) + 2; + assertEquals(rawWidth * rawWidth, calls.get()); + } + + @Test + public void footprintFade_isStableAcrossChunkAlignedFields() { + CNG footprint = new CNG(new RNG(16)) { + @Override + public double noise(double x, double z) { + return z >= 0.0D ? 1.0D : 0.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + FloatingIslandBoundarySampler forwardSampler = new FloatingIslandBoundarySampler((x, z) -> null); + FloatingIslandBoundarySampler reverseSampler = new FloatingIslandBoundarySampler((x, z) -> null); + + for (int distance = 2; distance <= FloatingIslandBoundarySampler.EDGE_FADE_RADIUS; distance++) { + double expected = FloatingIslandBoundarySampler.edgeFadeForDistance(distance); + double forwardLeft = forwardSampler.footprint(footprint, 15, distance - 1, 0.0D).edgeFade(); + double forwardRight = forwardSampler.footprint(footprint, 16, distance - 1, 0.0D).edgeFade(); + double forwardNegative = forwardSampler.footprint(footprint, -1, distance - 1, 0.0D).edgeFade(); + double forwardOrigin = forwardSampler.footprint(footprint, 0, distance - 1, 0.0D).edgeFade(); + double reverseRight = reverseSampler.footprint(footprint, 16, distance - 1, 0.0D).edgeFade(); + double reverseLeft = reverseSampler.footprint(footprint, 15, distance - 1, 0.0D).edgeFade(); + assertEquals(expected, forwardLeft, 0.0D); + assertEquals(expected, forwardRight, 0.0D); + assertEquals(expected, forwardNegative, 0.0D); + assertEquals(expected, forwardOrigin, 0.0D); + assertEquals(forwardLeft, reverseLeft, 0.0D); + assertEquals(forwardRight, reverseRight, 0.0D); + } + } + + @Test + public void footprint_rejectsTwoColumnStaticFragment() { + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> null); + CNG footprint = new CNG(new RNG(17)) { + @Override + public double noise(double x, double z) { + return z == 0.0D && (x == 0.0D || x == 1.0D) ? 1.0D : 0.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + + assertFalse(sampler.footprint(footprint, 0, 0, 0.0D).accepted()); + assertFalse(sampler.footprint(footprint, 1, 0, 0.0D).accepted()); + } + + @Test + public void footprint_rejectsComponentWithoutThreeCellDeepCore() { + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> null); + CNG threeByThree = new CNG(new RNG(21)) { + @Override + public double noise(double x, double z) { + return Math.abs(x) <= 1.0D && Math.abs(z) <= 1.0D ? 1.0D : 0.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + CNG fourWideTendril = new CNG(new RNG(22)) { + @Override + public double noise(double x, double z) { + return x >= 0.0D && x <= 3.0D ? 1.0D : 0.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + + assertFalse(sampler.footprint(threeByThree, 0, 0, 0.0D).accepted()); + assertFalse(sampler.footprint(fourWideTendril, 1, 0, 0.0D).accepted()); + assertFalse(sampler.footprint(fourWideTendril, 2, 0, 0.0D).accepted()); + } + + @Test + public void footprint_acceptsComponentWithThreeCellDeepCore() { + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> null); + CNG footprint = new CNG(new RNG(23)) { + @Override + public double noise(double x, double z) { + return Math.abs(x) <= 2.0D && Math.abs(z) <= 2.0D ? 1.0D : 0.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + + FloatingIslandBoundarySampler.FootprintSample sample = sampler.footprint(footprint, 0, 0, 0.0D); + + assertTrue(sample.accepted()); + assertEquals(FloatingIslandBoundarySampler.edgeFadeForDistance(3), sample.edgeFade(), 0.0D); + } + + @Test + public void footprint_trimsAttachedThinArmWithoutChunkSeam() { + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> null); + CNG footprint = new CNG(new RNG(25)) { + @Override + public double noise(double x, double z) { + boolean arm = Math.abs(z) <= 1.0D; + boolean core = x >= -10.0D && x <= -6.0D && Math.abs(z) <= 2.0D; + return arm || core ? 1.0D : 0.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + + assertTrue(sampler.footprint(footprint, -8, 0, 0.0D).accepted()); + assertFalse(sampler.footprint(footprint, 15, 0, 0.0D).accepted()); + assertFalse(sampler.footprint(footprint, 16, 0, 0.0D).accepted()); + } + + @Test + public void ownershipFade_tapersAcrossPickerBoundary() { + IrisFloatingChildBiomes left = new IrisFloatingChildBiomes().setRarity(1); + IrisFloatingChildBiomes right = new IrisFloatingChildBiomes().setRarity(1); + KList entries = new KList<>(); + entries.add(left); + entries.add(right); + CNG picker = new CNG(new RNG(18)) { + @Override + public double noise(double x, double z) { + return x < 0.0D ? 0.0D : 1.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> null); + + assertSame(right, sampler.ownership(entries, picker, 0, 0).owner()); + assertEquals(0.0D, sampler.ownership(entries, picker, 0, 0).edgeFade(), 0.0D); + for (int distance = 2; distance <= FloatingIslandBoundarySampler.EDGE_FADE_RADIUS; distance++) { + FloatingIslandBoundarySampler.OwnershipSample sample = sampler.ownership(entries, picker, distance - 1, 0); + assertSame(right, sample.owner()); + assertEquals(FloatingIslandBoundarySampler.edgeFadeForDistance(distance), sample.edgeFade(), 0.0D); + } + } + + @Test + public void ownershipFade_rejectsThinPickerRegion() { + IrisFloatingChildBiomes left = new IrisFloatingChildBiomes().setRarity(1); + IrisFloatingChildBiomes right = new IrisFloatingChildBiomes().setRarity(1); + KList entries = new KList<>(); + entries.add(left); + entries.add(right); + CNG picker = new CNG(new RNG(24)) { + @Override + public double noise(double x, double z) { + return Math.abs(x) <= 1.0D ? 1.0D : 0.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> null); + + FloatingIslandBoundarySampler.OwnershipSample sample = sampler.ownership(entries, picker, 0, 0); + + assertSame(right, sample.owner()); + assertEquals(0.0D, sample.edgeFade(), 0.0D); + } + + @Test + public void labelFieldsTrimAttachedThinArmWithoutChunkSeam() { + IrisBiome leftParent = new IrisBiome(); + leftParent.setLoadKey("left"); + IrisBiome rightParent = new IrisBiome(); + rightParent.setLoadKey("right"); + IrisFloatingChildBiomes leftChild = new IrisFloatingChildBiomes().setRarity(1); + IrisFloatingChildBiomes rightChild = new IrisFloatingChildBiomes().setRarity(1); + KList entries = new KList<>(); + entries.add(leftChild); + entries.add(rightChild); + CNG picker = new CNG(new RNG(29)) { + @Override + public double noise(double x, double z) { + return isAttachedArm(x, z) ? 1.0D : 0.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler( + (x, z) -> isAttachedArm(x, z) ? rightParent : leftParent); + + assertTrue(sampler.edgeFade(rightParent, -8, 0) > 0.0D); + assertEquals(0.0D, sampler.edgeFade(rightParent, 15, 0), 0.0D); + assertEquals(0.0D, sampler.edgeFade(rightParent, 16, 0), 0.0D); + assertSame(rightChild, sampler.ownership(entries, picker, 15, 0).owner()); + assertSame(rightChild, sampler.ownership(entries, picker, 16, 0).owner()); + assertEquals(0.0D, sampler.ownership(entries, picker, 15, 0).edgeFade(), 0.0D); + assertEquals(0.0D, sampler.ownership(entries, picker, 16, 0).edgeFade(), 0.0D); + } + + @Test + public void sampler_fieldsPublishSafelyAcrossConcurrentReaders() throws Exception { + IrisBiome parent = new IrisBiome(); + parent.setLoadKey("parent"); + IrisFloatingChildBiomes left = new IrisFloatingChildBiomes().setRarity(1); + IrisFloatingChildBiomes right = new IrisFloatingChildBiomes().setRarity(1); + KList entries = new KList<>(); + entries.add(left); + entries.add(right); + CNG constant = new CNG(new RNG(26)) { + @Override + public double noise(double x, double z) { + return 1.0D; + } + + @Override + public double noise(double x, double y, double z) { + return 1.0D; + } + }; + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> parent); + ExecutorService executor = Executors.newFixedThreadPool(8); + KList> futures = new KList<>(); + try { + for (int task = 0; task < 64; task++) { + int x = (task % 4) * 16; + futures.add(executor.submit(() -> sampler.parent(x, 0) == parent + && sampler.edgeFade(parent, x, 0) == 1.0D + && sampler.footprint(constant, x, 0, 0.0D).accepted() + && sampler.footprint(constant, x, 0, 0.0D).edgeFade() == 1.0D + && sampler.ownership(entries, constant, x, 0).owner() == right + && sampler.ownership(entries, constant, x, 0).edgeFade() == 1.0D)); + } + for (Future future : futures) { + assertTrue(future.get(10, TimeUnit.SECONDS)); + } + } finally { + executor.shutdownNow(); + } + } + + @Test + public void sample_appliesSpatialProfileToFinalWorldCoordinates() { + CNG footprint = new CNG(new RNG(19)) { + @Override + public double noise(double x, double z) { + return x >= 0.0D ? 1.0D : 0.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + CNG constant = new CNG(new RNG(20)) { + @Override + public double noise(double x, double z) { + return 1.0D; + } + + @Override + public double noise(double x, double y, double z) { + return 1.0D; + } + }; + IrisFloatingChildBiomes entry = mock(IrisFloatingChildBiomes.class); + IrisBiome parent = mock(IrisBiome.class); + IrisData data = mock(IrisData.class); + Engine engine = mock(Engine.class); + IrisWorld world = IrisWorld.builder().minHeight(0).maxHeight(320).build(); + KList entries = new KList<>(); + entries.add(entry); + when(parent.getFloatingChildBiomes()).thenReturn(entries); + when(parent.isMergeFloatingChildBiomes()).thenReturn(false); + when(entry.getFootprintCng(anyLong(), same(data))).thenReturn(footprint); + when(entry.getFootprintThreshold()).thenReturn(0.5D); + when(entry.getAltitudeCng(anyLong(), same(data))).thenReturn(constant); + when(entry.getMinHeightAboveSurface()).thenReturn(160); + when(entry.getMaxHeightAboveSurface()).thenReturn(160); + when(entry.getRealBiome(parent, data)).thenReturn(parent); + when(entry.getTopShapeMode()).thenReturn(TopShapeMode.NOISE); + when(entry.getTopShapeCng(anyLong(), same(data))).thenReturn(constant); + when(entry.getTopShapeAmp()).thenReturn(1.0D); + when(entry.getMaxTopHeight()).thenReturn(18); + when(entry.getBottomCng(anyLong(), same(data))).thenReturn(constant); + when(entry.getBottomExponent()).thenReturn(1.0D); + when(entry.getBottomDepthMin()).thenReturn(20); + when(entry.getBottomDepthMax()).thenReturn(20); + when(entry.getMaxThickness()).thenReturn(64); + when(entry.getMinAbsoluteY()).thenReturn(null); + when(entry.getMaxAbsoluteY()).thenReturn(null); + when(entry.getWallWarpCng(anyLong(), same(data))).thenReturn(null); + when(entry.getWallWarpAmplitude()).thenReturn(0.0D); + when(entry.getCarvingProfileSampler(engine, data)).thenReturn(null); + when(entry.hasCarvingReference()).thenReturn(false); + when(entry.getCarveCng(anyLong(), same(data))).thenReturn(constant); + when(entry.getCarveThreshold()).thenReturn(0.5D); + when(engine.getWorld()).thenReturn(world); + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> parent); + int[] expectedBottom = {159, 158, 156, 153, 150, 147, 144, 142, 141, 140}; + int[] expectedTop = {161, 162, 164, 166, 169, 172, 174, 176, 177, 178}; + + assertNull(FloatingIslandSample.sample(parent, 0, 0, 320, 31L, data, engine, sampler)); + for (int i = 0; i < expectedBottom.length; i++) { + FloatingIslandSample sample = FloatingIslandSample.sample(parent, i + 1, 0, 320, 31L, data, engine, sampler); + assertNotNull("distance " + (i + 2) + " rejected with code " + FloatingIslandSample.getLastReject(), sample); + assertEquals(expectedBottom[i], sample.bottomY()); + assertEquals(expectedTop[i], sample.topY()); + if (i < expectedBottom.length - 1) { + assertEquals(sample.thickness, sample.solidCount); + } else { + assertTrue(sample.solidCount < sample.thickness); + } + if (i > 0) { + assertTrue(Math.abs(expectedBottom[i] - expectedBottom[i - 1]) <= 3); + assertTrue(Math.abs(expectedTop[i] - expectedTop[i - 1]) <= 3); + } + } + } + + @Test + public void sample_mergesNoiseTopEntriesAcrossSpatialRim() { + CNG footprint = new CNG(new RNG(33)) { + @Override + public double noise(double x, double z) { + return x >= 0.0D ? 1.0D : 0.0D; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + CNG constant = new CNG(new RNG(34)) { + @Override + public double noise(double x, double z) { + return 1.0D; + } + + @Override + public double noise(double x, double y, double z) { + return 1.0D; + } + }; + IrisFloatingChildBiomes lower = mock(IrisFloatingChildBiomes.class); + IrisFloatingChildBiomes upper = mock(IrisFloatingChildBiomes.class); + IrisBiome parent = mock(IrisBiome.class); + IrisData data = mock(IrisData.class); + Engine engine = mock(Engine.class); + IrisWorld world = IrisWorld.builder().minHeight(0).maxHeight(320).build(); + KList entries = new KList<>(); + entries.add(lower); + entries.add(upper); + when(parent.getFloatingChildBiomes()).thenReturn(entries); + when(parent.isMergeFloatingChildBiomes()).thenReturn(true); + for (IrisFloatingChildBiomes entry : entries) { + when(entry.getFootprintCng(anyLong(), same(data))).thenReturn(footprint); + when(entry.getFootprintThreshold()).thenReturn(0.5D); + when(entry.getAltitudeCng(anyLong(), same(data))).thenReturn(constant); + when(entry.getRealBiome(parent, data)).thenReturn(parent); + when(entry.getTopShapeMode()).thenReturn(TopShapeMode.NOISE); + when(entry.getTopShapeCng(anyLong(), same(data))).thenReturn(constant); + when(entry.getTopShapeAmp()).thenReturn(1.0D); + when(entry.getMaxTopHeight()).thenReturn(18); + when(entry.getBottomCng(anyLong(), same(data))).thenReturn(constant); + when(entry.getBottomExponent()).thenReturn(1.0D); + when(entry.getBottomDepthMin()).thenReturn(20); + when(entry.getBottomDepthMax()).thenReturn(20); + when(entry.getMaxThickness()).thenReturn(64); + when(entry.getMinAbsoluteY()).thenReturn(null); + when(entry.getMaxAbsoluteY()).thenReturn(null); + when(entry.getWallWarpCng(anyLong(), same(data))).thenReturn(null); + when(entry.getWallWarpAmplitude()).thenReturn(0.0D); + when(entry.getCarvingProfileSampler(engine, data)).thenReturn(null); + when(entry.hasCarvingReference()).thenReturn(false); + when(entry.getCarveCng(anyLong(), same(data))).thenReturn(null); + when(entry.getCarveThreshold()).thenReturn(1.0D); + } + when(lower.getMinHeightAboveSurface()).thenReturn(160); + when(lower.getMaxHeightAboveSurface()).thenReturn(160); + when(upper.getMinHeightAboveSurface()).thenReturn(180); + when(upper.getMaxHeightAboveSurface()).thenReturn(180); + when(engine.getWorld()).thenReturn(world); + FloatingIslandBoundarySampler sampler = new FloatingIslandBoundarySampler((x, z) -> parent); + + assertNull(FloatingIslandSample.sample(parent, 0, 0, 320, 35L, data, engine, sampler)); + FloatingIslandSample sample = FloatingIslandSample.sample(parent, 1, 0, 320, 35L, data, engine, sampler); + + assertNotNull(sample); + assertTrue(sample.hasMergedEntries()); + assertEquals(159, sample.bottomY()); + assertEquals(181, sample.topY()); + assertEquals(23, sample.thickness); + assertEquals(6, sample.solidCount); + assertSame(upper, sample.entryAt(sample.topIdx)); + } + + private static boolean isAttachedArm(double x, double z) { + return Math.abs(z) <= 1.0D || x >= -10.0D && x <= -6.0D && Math.abs(z) <= 2.0D; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/FloatingIslandSampleBottomYTest.java b/core/src/test/java/art/arcane/iris/engine/object/FloatingIslandSampleBottomYTest.java index a583927bb..aa0ab1d1a 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/FloatingIslandSampleBottomYTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/FloatingIslandSampleBottomYTest.java @@ -110,27 +110,28 @@ public class FloatingIslandSampleBottomYTest { assertEquals(15, FloatingIslandSample.roundedEdgeDepth(10, 20, 0.5, 1.0)); } + @Test + public void roundedEdgeDepth_fixedDepthFollowsOnlyFootprintFade() { + assertEquals(0, FloatingIslandSample.roundedEdgeDepth(20, 20, 0.0, 0.0)); + assertEquals(5, FloatingIslandSample.roundedEdgeDepth(20, 20, 0.0, 0.25)); + assertEquals(10, FloatingIslandSample.roundedEdgeDepth(20, 20, 1.0, 0.5)); + assertEquals(20, FloatingIslandSample.roundedEdgeDepth(20, 20, 0.0, 1.0)); + } + + @Test + public void effectiveWallWarpAmplitude_fadesWarpOutAtRoundedRim() { + assertEquals(0.0, FloatingIslandSample.effectiveWallWarpAmplitude(5.0, 0.0), 0.0); + assertEquals(2.5, FloatingIslandSample.effectiveWallWarpAmplitude(5.0, 0.5), 0.0); + assertEquals(5.0, FloatingIslandSample.effectiveWallWarpAmplitude(5.0, 1.0), 0.0); + } + @Test public void carveSolidInterior_preservesOuterShell() { boolean[] mask = {false, true, true, true, true, false}; - CNG carve = new CNG(new RNG(1), new NoiseGenerator() { - @Override - public double noise(double x) { - return 1.0; - } + boolean[] lateralCarveMask = {true, true, true, true, true, true}; + CNG carve = constantCng(1.0); - @Override - public double noise(double x, double z) { - return 1.0; - } - - @Override - public double noise(double x, double y, double z) { - return 1.0; - } - }, 1.0, 1); - - int count = FloatingIslandSample.carveSolidInterior(mask, 100, 0, 0, carve, 0.5); + int count = FloatingIslandSample.carveSolidInterior(mask, lateralCarveMask, 100, 0, 0, carve, 0.5); assertEquals(2, count); assertEquals(false, mask[0]); @@ -141,6 +142,41 @@ public class FloatingIslandSampleBottomYTest { assertEquals(false, mask[5]); } + @Test + public void carveSolidInterior_preservesLateralPerimeter() { + boolean[] mask = {true, true, true, true, true, true}; + boolean[] lateralCarveMask = {false, false, false, false, false, false}; + + int count = FloatingIslandSample.carveSolidInterior(mask, lateralCarveMask, 100, 0, 0, constantCng(1.0), 0.5); + + assertEquals(6, count); + for (boolean solid : mask) { + assertTrue(solid); + } + } + + @Test + public void lateralCarving_requiresRoundedInteriorAndFullNeighborRing() { + CNG fullRing = constantCng(1.0); + FloatingIslandSample.NeighborSupport fullSupport = FloatingIslandSample.footprintNeighborSupport(fullRing, 0, 0, 0.0); + CNG missingDiagonal = new CNG(new RNG(11)) { + @Override + public double noise(double x, double z) { + return x == 1 && z == 1 ? 0.0 : 1.0; + } + + @Override + public double noise(double x, double y, double z) { + return noise(x, z); + } + }; + FloatingIslandSample.NeighborSupport partialSupport = FloatingIslandSample.footprintNeighborSupport(missingDiagonal, 0, 0, 0.0); + + assertTrue(FloatingIslandSample.canCarveLaterally(1.0, fullSupport)); + assertFalse(FloatingIslandSample.canCarveLaterally(0.99, fullSupport)); + assertFalse(FloatingIslandSample.canCarveLaterally(1.0, partialSupport)); + } + @Test public void footprintPinholeFill_acceptsSingleColumnHoleWithSolidRing() { CNG footprint = new CNG(new RNG(4)) { @@ -156,7 +192,7 @@ public class FloatingIslandSampleBottomYTest { }; FloatingIslandSample.NeighborSupport support = FloatingIslandSample.footprintNeighborSupport(footprint, 0, 0, 0.0); - assertTrue(FloatingIslandSample.isFootprintPinholeRepairable(-0.08, 0.0, support)); + assertTrue(FloatingIslandSample.isFootprintPinholeRepairable(support)); } @Test @@ -174,7 +210,7 @@ public class FloatingIslandSampleBottomYTest { }; FloatingIslandSample.NeighborSupport support = FloatingIslandSample.footprintNeighborSupport(footprint, 0, 0, 0.0); - assertFalse(FloatingIslandSample.isFootprintPinholeRepairable(-1.0, 0.0, support)); + assertFalse(FloatingIslandSample.isFootprintPinholeRepairable(support)); } @Test @@ -233,7 +269,8 @@ public class FloatingIslandSampleBottomYTest { } }, 1.0, 1); - int count = FloatingIslandSample.carveSolidInterior(mask, 100, 0, 0, carve, 0.5); + boolean[] lateralCarveMask = {true, true, true, true, true}; + int count = FloatingIslandSample.carveSolidInterior(mask, lateralCarveMask, 100, 0, 0, carve, 0.5); assertEquals(5, count); assertEquals(true, mask[1]); @@ -244,27 +281,14 @@ public class FloatingIslandSampleBottomYTest { @Test public void carveSolidInterior_capsBroadVerticalCarveSheet() { boolean[] mask = new boolean[30]; + boolean[] lateralCarveMask = new boolean[30]; for (int i = 0; i < mask.length; i++) { mask[i] = true; + lateralCarveMask[i] = true; } - CNG carve = new CNG(new RNG(10), new NoiseGenerator() { - @Override - public double noise(double x) { - return 1.0; - } + CNG carve = constantCng(1.0); - @Override - public double noise(double x, double z) { - return 1.0; - } - - @Override - public double noise(double x, double y, double z) { - return 1.0; - } - }, 1.0, 1); - - int count = FloatingIslandSample.carveSolidInterior(mask, 100, 0, 0, carve, 0.5); + int count = FloatingIslandSample.carveSolidInterior(mask, lateralCarveMask, 100, 0, 0, carve, 0.5); assertEquals(25, count); for (int i = 0; i < FloatingIslandSample.carveShellThickness(mask.length); i++) { @@ -341,4 +365,23 @@ public class FloatingIslandSampleBottomYTest { assertEquals(true, FloatingIslandSample.hasFootprintNeighborSupport(footprint, 0, 0, 0.0)); } + + private CNG constantCng(double value) { + return new CNG(new RNG(12), new NoiseGenerator() { + @Override + public double noise(double x) { + return value; + } + + @Override + public double noise(double x, double z) { + return value; + } + + @Override + public double noise(double x, double y, double z) { + return value; + } + }, 1.0, 1); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisEntitySpawnTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisEntitySpawnTest.java new file mode 100644 index 000000000..021e807e5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisEntitySpawnTest.java @@ -0,0 +1,14 @@ +package art.arcane.iris.engine.object; + +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import static org.junit.Assert.assertNull; + +public class IrisEntitySpawnTest { + @Test + public void caveSpawnWithoutSafeMarkerIsSkipped() { + assertNull(IrisEntitySpawn.selectCaveSpawnLocation(new KList<>(), null, new RNG(1L))); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisImportedStructureControlTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisImportedStructureControlTest.java index 9d3c9eb3c..268f24f36 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisImportedStructureControlTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisImportedStructureControlTest.java @@ -25,6 +25,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class IrisImportedStructureControlTest { @@ -37,15 +38,17 @@ public class IrisImportedStructureControlTest { } @Test - public void defaultModeGeneratesEveryVanillaStructure() { + public void defaultPolicyGeneratesEveryRegisteredStructureKey() { IrisImportedStructureControl control = new IrisImportedStructureControl(); assertTrue(control.shouldGenerate("minecraft:village_plains")); assertTrue(control.shouldGenerate("minecraft:stronghold")); - assertTrue(control.shouldGenerate("minecraft:ocean_monument")); + assertTrue(control.shouldGenerate("minecraft:monument")); + assertEquals(NativeStructureGenerationStatus.GENERATE_NATIVE, + control.resolve("minecraft:monument", false).status()); } @Test - public void defaultModeGeneratesDatapackStructures() { + public void defaultPolicyGeneratesDatapackStructures() { IrisImportedStructureControl control = new IrisImportedStructureControl(); assertTrue(control.isDatapackOverrides()); assertTrue(control.shouldGenerate("nova_structures:desert_temple")); @@ -69,26 +72,6 @@ public class IrisImportedStructureControlTest { assertTrue(control.shouldGenerate("minecraft:stronghold")); } - @Test - public void allOffWhitelistMatchesExactKey() { - IrisImportedStructureControl control = new IrisImportedStructureControl() - .setMode(VanillaStructureMode.ALL_OFF) - .setEnabled(keys("minecraft:village_plains")); - assertTrue(control.shouldGenerate("minecraft:village_plains")); - assertFalse(control.shouldGenerate("minecraft:village_taiga")); - assertFalse(control.shouldGenerate("minecraft:stronghold")); - } - - @Test - public void allOffWhitelistMatchesNamespacePrefix() { - IrisImportedStructureControl control = new IrisImportedStructureControl() - .setMode(VanillaStructureMode.ALL_OFF) - .setEnabled(keys("minecraft:village")); - assertTrue(control.shouldGenerate("minecraft:village_taiga")); - assertTrue(control.shouldGenerate("minecraft:village_desert")); - assertFalse(control.shouldGenerate("minecraft:stronghold")); - } - @Test public void datapackOverridesFalseDoesNotDisableModOrDatapackNamespaces() { IrisImportedStructureControl control = new IrisImportedStructureControl() @@ -99,10 +82,9 @@ public class IrisImportedStructureControlTest { } @Test - public void allOffWhitelistStillControlsThirdPartyStructuresWhenOverridesAreFalse() { + public void disabledListControlsThirdPartyStructuresWhenOverridesAreFalse() { IrisImportedStructureControl control = new IrisImportedStructureControl() - .setMode(VanillaStructureMode.ALL_OFF) - .setEnabled(keys("nova_structures:desert_temple", "minecraft:village_plains")) + .setDisabled(keys("aquaculture:treasure_vault")) .setDatapackOverrides(false); assertTrue(control.shouldGenerate("nova_structures:desert_temple")); assertTrue(control.shouldGenerate("minecraft:village_plains")); @@ -110,7 +92,7 @@ public class IrisImportedStructureControlTest { } @Test - public void datapackOverridesTrueAllowsDatapackKeysWhileAllOn() { + public void datapackOverridesTrueAllowsDatapackKeysByDefault() { IrisImportedStructureControl control = new IrisImportedStructureControl() .setDatapackOverrides(true); assertTrue(control.shouldGenerate("nova_structures:desert_temple")); @@ -130,26 +112,6 @@ public class IrisImportedStructureControlTest { assertTrue(control.shouldGenerate("minecraft:ancient_city")); } - @Test - public void activeTrueWhenAllOn() { - assertTrue(new IrisImportedStructureControl().active()); - } - - @Test - public void activeFalseWhenAllOffWithoutWhitelist() { - IrisImportedStructureControl control = new IrisImportedStructureControl() - .setMode(VanillaStructureMode.ALL_OFF); - assertFalse(control.active()); - } - - @Test - public void activeTrueWhenAllOffWithWhitelist() { - IrisImportedStructureControl control = new IrisImportedStructureControl() - .setMode(VanillaStructureMode.ALL_OFF) - .setEnabled(keys("minecraft:village_plains")); - assertTrue(control.active()); - } - @Test public void emptyOrNullListEntriesNeverMatchEveryKey() { IrisImportedStructureControl control = new IrisImportedStructureControl() @@ -159,11 +121,11 @@ public class IrisImportedStructureControlTest { } @Test - public void nullKeyIsNullSafeAndNotWhitelisted() { - IrisImportedStructureControl control = new IrisImportedStructureControl() - .setMode(VanillaStructureMode.ALL_OFF) - .setEnabled(keys("minecraft:village_plains")); + public void nullKeyIsRejectedAsAnInvalidRegistryKey() { + IrisImportedStructureControl control = new IrisImportedStructureControl(); assertFalse(control.shouldGenerate(null)); + assertEquals(NativeStructureGenerationStatus.INVALID_REGISTRY_KEY, + control.resolve(null, false).status()); } @Test @@ -205,7 +167,7 @@ public class IrisImportedStructureControlTest { @Test public void postprocessingUsesPrefixMatches() { - IrisVanillaStructureStiltSettings stilt = new IrisVanillaStructureStiltSettings(); + IrisStructureStiltSettings stilt = new IrisStructureStiltSettings(); IrisVanillaStructureAdjustment adjustment = new IrisVanillaStructureAdjustment() .setMatch(keys("minecraft:village")) .setClearVegetation(true) @@ -221,8 +183,8 @@ public class IrisImportedStructureControlTest { @Test public void multipleMatchesMergeVegetationAndUseLastConfiguredStilt() { - IrisVanillaStructureStiltSettings broadStilt = new IrisVanillaStructureStiltSettings().setMaxDepth(32); - IrisVanillaStructureStiltSettings specificStilt = new IrisVanillaStructureStiltSettings().setMaxDepth(96); + IrisStructureStiltSettings broadStilt = new IrisStructureStiltSettings().setMaxDepth(32); + IrisStructureStiltSettings specificStilt = new IrisStructureStiltSettings().setMaxDepth(96); IrisVanillaStructureAdjustment broad = new IrisVanillaStructureAdjustment() .setMatch(keys("minecraft:village")) .setClearVegetation(true) @@ -262,4 +224,18 @@ public class IrisImportedStructureControlTest { assertFalse(control.shouldGenerate("example:castle")); assertTrue(control.shouldGenerate("other:castle")); } + + @Test + public void malformedNullPolicyListsFailWithTheirExactField() { + IrisImportedStructureControl nullDisabled = new IrisImportedStructureControl().setDisabled(null); + IrisImportedStructureControl nullAdjustments = new IrisImportedStructureControl().setAdjustments(null); + + NullPointerException disabled = assertThrows(NullPointerException.class, + () -> nullDisabled.shouldGenerate("minecraft:monument")); + NullPointerException adjustments = assertThrows(NullPointerException.class, + () -> nullAdjustments.resolve("minecraft:monument", false)); + + assertTrue(disabled.getMessage().contains("importedStructures.disabled")); + assertTrue(adjustments.getMessage().contains("importedStructures.adjustments")); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectStructurePieceAirTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectStructurePieceAirTest.java new file mode 100644 index 000000000..a1a3f7aec --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectStructurePieceAirTest.java @@ -0,0 +1,27 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisObjectStructurePieceAirTest { + @Test + public void explicitAirPlacesOnlyForRawStructurePieces() { + assertTrue(IrisObject.shouldPlaceObjectBlock(true, true, false)); + assertFalse(IrisObject.shouldPlaceObjectBlock(false, true, false)); + } + + @Test + public void ordinaryObjectAirRemainsNondestructive() { + assertFalse(IrisObject.shouldPlaceObjectBlock(false, true, false)); + assertTrue(IrisObject.shouldPlaceObjectBlock(false, false, false)); + } + + @Test + public void vineReplacementRemainsRejectedForEveryPlacementMode() { + assertFalse(IrisObject.shouldPlaceObjectBlock(false, false, true)); + assertFalse(IrisObject.shouldPlaceObjectBlock(true, false, true)); + assertFalse(IrisObject.shouldPlaceObjectBlock(true, true, true)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisVanillaStructureStiltSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisStructureStiltSettingsTest.java similarity index 81% rename from core/src/test/java/art/arcane/iris/engine/object/IrisVanillaStructureStiltSettingsTest.java rename to core/src/test/java/art/arcane/iris/engine/object/IrisStructureStiltSettingsTest.java index fd38ee3a8..0a8793f6b 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisVanillaStructureStiltSettingsTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisStructureStiltSettingsTest.java @@ -9,10 +9,10 @@ import java.lang.reflect.Field; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -public class IrisVanillaStructureStiltSettingsTest { +public class IrisStructureStiltSettingsTest { @Test public void defaultsToCobblestoneFoundation() { - IrisVanillaStructureStiltSettings settings = new IrisVanillaStructureStiltSettings(); + IrisStructureStiltSettings settings = new IrisStructureStiltSettings(); assertEquals(64, settings.getMaxDepth()); assertNotNull(settings.getPalette()); @@ -22,7 +22,7 @@ public class IrisVanillaStructureStiltSettingsTest { @Test public void maxDepthSchemaIsBoundedToTheMaximumWorldHeight() throws NoSuchFieldException { - Field maxDepth = IrisVanillaStructureStiltSettings.class.getDeclaredField("maxDepth"); + Field maxDepth = IrisStructureStiltSettings.class.getDeclaredField("maxDepth"); MinNumber minimum = maxDepth.getAnnotation(MinNumber.class); MaxNumber maximum = maxDepth.getAnnotation(MaxNumber.class); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IslandObjectPlacerAnchorFaceTest.java b/core/src/test/java/art/arcane/iris/engine/object/IslandObjectPlacerAnchorFaceTest.java index 874386354..e4fad0b83 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IslandObjectPlacerAnchorFaceTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IslandObjectPlacerAnchorFaceTest.java @@ -19,7 +19,7 @@ public class IslandObjectPlacerAnchorFaceTest { FloatingIslandSample[] samples = new FloatingIslandSample[256]; samples[0] = sampleWithBottomAt(100, 0); - IslandObjectPlacer placer = new IslandObjectPlacer(null, samples, 0, 0, 100, IslandObjectPlacer.AnchorFace.BOTTOM); + IslandObjectPlacer placer = IslandObjectPlacer.bottom(null, sampleProvider(samples), null, 100); int result = placer.getHighest(0, 0, null); assertEquals(100, result); @@ -30,7 +30,7 @@ public class IslandObjectPlacerAnchorFaceTest { FloatingIslandSample[] samples = new FloatingIslandSample[256]; samples[0] = sampleWithBottomAt(100, 0); - IslandObjectPlacer placer = new IslandObjectPlacer(null, samples, 0, 0, 100, IslandObjectPlacer.AnchorFace.BOTTOM); + IslandObjectPlacer placer = IslandObjectPlacer.bottom(null, sampleProvider(samples), null, 100); int result = placer.getHighest(15, 15, null); assertEquals(100, result); @@ -41,7 +41,7 @@ public class IslandObjectPlacerAnchorFaceTest { FloatingIslandSample[] samples = new FloatingIslandSample[256]; samples[0] = sampleWithBottomAt(100, 0); - IslandObjectPlacer placer = new IslandObjectPlacer(null, samples, 0, 0, 100, IslandObjectPlacer.AnchorFace.BOTTOM); + IslandObjectPlacer placer = IslandObjectPlacer.bottom(null, sampleProvider(samples), null, 100); assertEquals(false, placer.canWriteObjectBlock(0, 101, 0)); placer.set(0, 101, 0, null); @@ -52,7 +52,7 @@ public class IslandObjectPlacerAnchorFaceTest { FloatingIslandSample[] samples = new FloatingIslandSample[256]; samples[0] = sampleWithBottomAt(100, 0); - IslandObjectPlacer placer = new IslandObjectPlacer(null, samples, 0, 0, 100, IslandObjectPlacer.AnchorFace.BOTTOM); + IslandObjectPlacer placer = IslandObjectPlacer.bottom(null, sampleProvider(samples), null, 100); assertEquals(true, placer.canWriteObjectBlock(0, 99, 0)); } @@ -62,7 +62,7 @@ public class IslandObjectPlacerAnchorFaceTest { FloatingIslandSample[] samples = new FloatingIslandSample[256]; samples[0] = sampleWithBottomAt(100, 0); - IslandObjectPlacer placer = new IslandObjectPlacer(null, samples, 0, 0, 100, IslandObjectPlacer.AnchorFace.BOTTOM); + IslandObjectPlacer placer = IslandObjectPlacer.bottom(null, sampleProvider(samples), null, 100); assertEquals(false, placer.canWriteObjectBlock(0, 100, 0)); assertEquals(false, placer.canWriteObjectBlock(0, 101, 0)); @@ -73,9 +73,36 @@ public class IslandObjectPlacerAnchorFaceTest { FloatingIslandSample[] samples = new FloatingIslandSample[256]; samples[0] = sampleWithBottomAt(100, 0); - IslandObjectPlacer placer = new IslandObjectPlacer(null, samples, 0, 0, 105); + IslandObjectPlacer placer = IslandObjectPlacer.top(null, sampleProvider(samples), null, 105); assertEquals(false, placer.canWriteObjectBlock(1, 104, 0)); placer.set(1, 104, 0, null); } + + @Test + public void topFace_allowsSupportedNeighborChunkWrite() { + FloatingIslandSample sample = sampleWithBottomAt(100, 0); + IslandObjectPlacer placer = IslandObjectPlacer.top(null, + (x, z) -> z == 0 && (x == 15 || x == 16) ? sample : null, null, 109); + + assertEquals(true, placer.canWriteObjectBlock(16, 110, 0)); + } + + @Test + public void topFace_rejectsUnsupportedWorldColumnBeyondOverhang() { + FloatingIslandSample sample = sampleWithBottomAt(100, 0); + IslandObjectPlacer placer = IslandObjectPlacer.top(null, + (x, z) -> x == 15 && z == 0 ? sample : null, null, 109); + + assertEquals(false, placer.canWriteObjectBlock(18, 110, 0)); + } + + private IslandObjectPlacer.SampleProvider sampleProvider(FloatingIslandSample[] samples) { + return (x, z) -> { + if (x < 0 || x >= 16 || z < 0 || z >= 16) { + return null; + } + return samples[(z << 4) | x]; + }; + } } diff --git a/core/src/test/java/art/arcane/iris/engine/object/PlatformStateHookContractTest.java b/core/src/test/java/art/arcane/iris/engine/object/PlatformStateHookContractTest.java new file mode 100644 index 000000000..0a099d011 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/PlatformStateHookContractTest.java @@ -0,0 +1,42 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class PlatformStateHookContractTest { + @Test + public void missingPlatformRotatorFailsLoudly() { + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> IrisObjectRotation.requirePlatformRotator(null)); + + assertEquals("No platform block-state rotator is bound", error.getMessage()); + } + + @Test + public void missingPlatformMergerFailsLoudly() { + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> BlockDataMergeSupport.requirePlatformMerger(null)); + + assertEquals("No platform block-state merger is bound", error.getMessage()); + } + + @Test + public void missingPlatformTileFactoryFailsLoudly() { + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> TileData.requirePlatformFactory(null)); + + assertEquals("No platform tile-data factory is bound", error.getMessage()); + } + + @Test + public void missingPlatformTileReaderFailsLoudly() { + IOException error = assertThrows(IOException.class, + () -> TileData.requirePlatformReader(null)); + + assertEquals("No platform tile-data reader is bound", error.getMessage()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsLootTest.java b/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsLootTest.java new file mode 100644 index 000000000..ef9042f1e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsLootTest.java @@ -0,0 +1,172 @@ +package art.arcane.iris.engine.platform; + +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.type.Chest; +import org.bukkit.loot.LootTable; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class EngineBukkitOpsLootTest { + @Test + public void singleChestIsCanonical() { + Block block = chestBlock(4, 8, Chest.Type.SINGLE, BlockFace.NORTH); + + assertTrue(EngineBukkitOps.isCanonicalContainer(block)); + } + + @Test + public void doubleChestHasExactlyOneCanonicalHalf() { + Block left = chestBlock(0, 0, Chest.Type.LEFT, BlockFace.NORTH); + Block right = chestBlock(1, 0, Chest.Type.RIGHT, BlockFace.NORTH); + + assertTrue(EngineBukkitOps.isCanonicalContainer(left)); + assertFalse(EngineBukkitOps.isCanonicalContainer(right)); + } + + @Test + public void nativeLootIsDetectedWithoutOpeningInventory() { + Block block = chestBlock(0, 0, Chest.Type.SINGLE, BlockFace.NORTH); + org.bukkit.block.Chest state = mock(org.bukkit.block.Chest.class); + LootTable table = mock(LootTable.class); + when(block.getState()).thenReturn(state); + when(state.getLootTable()).thenReturn(table); + + assertTrue(EngineBukkitOps.hasNativeLootTable(block)); + } + + @Test + public void foliaLootMutationUsesOwningRegionOnly() { + World world = mock(World.class); + AtomicInteger primaryChecks = new AtomicInteger(); + AtomicInteger regionSchedules = new AtomicInteger(); + AtomicInteger syncSchedules = new AtomicInteger(); + AtomicBoolean mutated = new AtomicBoolean(); + Runnable mutation = () -> mutated.set(true); + EngineBukkitOps.LootMutationTask task = new EngineBukkitOps.LootMutationTask(world, 12, -3, mutation); + + boolean scheduled = EngineBukkitOps.dispatchLootMutation( + task, + true, + () -> { + primaryChecks.incrementAndGet(); + return true; + }, + regionTask -> { + regionSchedules.incrementAndGet(); + assertSame(world, regionTask.world()); + assertEquals(12, regionTask.chunkX()); + assertEquals(-3, regionTask.chunkZ()); + assertSame(mutation, regionTask.mutation()); + regionTask.mutation().run(); + return true; + }, + runnable -> syncSchedules.incrementAndGet() + ); + + assertTrue(scheduled); + assertTrue(mutated.get()); + assertEquals(1, regionSchedules.get()); + assertEquals(0, primaryChecks.get()); + assertEquals(0, syncSchedules.get()); + } + + @Test + public void failedFoliaRegionScheduleDoesNotMutateInventoryElsewhere() { + World world = mock(World.class); + AtomicInteger primaryChecks = new AtomicInteger(); + AtomicInteger syncSchedules = new AtomicInteger(); + AtomicBoolean mutated = new AtomicBoolean(); + EngineBukkitOps.LootMutationTask task = new EngineBukkitOps.LootMutationTask(world, 1, 2, () -> mutated.set(true)); + + boolean scheduled = EngineBukkitOps.dispatchLootMutation( + task, + true, + () -> { + primaryChecks.incrementAndGet(); + return true; + }, + regionTask -> false, + runnable -> syncSchedules.incrementAndGet() + ); + + assertFalse(scheduled); + assertFalse(mutated.get()); + assertEquals(0, primaryChecks.get()); + assertEquals(0, syncSchedules.get()); + } + + @Test + public void nonFoliaPrimaryThreadMutatesInline() { + World world = mock(World.class); + AtomicInteger regionSchedules = new AtomicInteger(); + AtomicInteger syncSchedules = new AtomicInteger(); + AtomicBoolean mutated = new AtomicBoolean(); + EngineBukkitOps.LootMutationTask task = new EngineBukkitOps.LootMutationTask(world, 4, 5, () -> mutated.set(true)); + + boolean scheduled = EngineBukkitOps.dispatchLootMutation( + task, + false, + () -> true, + regionTask -> { + regionSchedules.incrementAndGet(); + return true; + }, + runnable -> syncSchedules.incrementAndGet() + ); + + assertTrue(scheduled); + assertTrue(mutated.get()); + assertEquals(0, regionSchedules.get()); + assertEquals(0, syncSchedules.get()); + } + + @Test + public void nonFoliaAsyncCompletionUsesSyncScheduler() { + World world = mock(World.class); + AtomicInteger regionSchedules = new AtomicInteger(); + AtomicInteger syncSchedules = new AtomicInteger(); + AtomicBoolean mutated = new AtomicBoolean(); + EngineBukkitOps.LootMutationTask task = new EngineBukkitOps.LootMutationTask(world, 6, 7, () -> mutated.set(true)); + + boolean scheduled = EngineBukkitOps.dispatchLootMutation( + task, + false, + () -> false, + regionTask -> { + regionSchedules.incrementAndGet(); + return true; + }, + runnable -> { + syncSchedules.incrementAndGet(); + runnable.run(); + } + ); + + assertTrue(scheduled); + assertTrue(mutated.get()); + assertEquals(0, regionSchedules.get()); + assertEquals(1, syncSchedules.get()); + } + + private Block chestBlock(int x, int z, Chest.Type type, BlockFace facing) { + Block block = mock(Block.class); + Chest chest = mock(Chest.class); + when(block.getX()).thenReturn(x); + when(block.getZ()).thenReturn(z); + when(block.getBlockData()).thenReturn(chest); + when(chest.getType()).thenReturn(type); + when(chest.getFacing()).thenReturn(facing); + return block; + } +} diff --git a/logs/latest.log b/logs/latest.log new file mode 100644 index 000000000..e69de29bb diff --git a/probe/build.gradle b/probe/build.gradle index f09b708bd..5cf7e9892 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -17,6 +17,9 @@ dependencies { implementation(project(':core')) compileOnly(libs.spigot) testImplementation('junit:junit:4.13.2') + testImplementation(volmLibCoordinate) { + transitive = false + } compileOnly(volmLibCoordinate) { transitive = false } @@ -50,6 +53,8 @@ String probePack = providers.gradleProperty('probePack') .orElse('/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/plugin-consumers/instances/purpur-26.2/plugins/Iris/packs/overworld') .get() String probeRadius = providers.gradleProperty('probeRadius').orElse('2').get() +String probeCenterChunkX = providers.gradleProperty('probeCenterChunkX').orElse('0').get() +String probeCenterChunkZ = providers.gradleProperty('probeCenterChunkZ').orElse('0').get() tasks.register('genProbe', JavaExec) { group = 'verification' @@ -57,7 +62,7 @@ tasks.register('genProbe', JavaExec) { mainClass = 'art.arcane.iris.probe.GenerationProbe' classpath = sourceSets.main.runtimeClasspath jvmArgs('--add-modules', 'jdk.incubator.vector') - args(probePack, probeRadius) + args(probePack, probeRadius, probeCenterChunkX, probeCenterChunkZ) dependsOn(':core:compileJava') } diff --git a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java index bf9afd729..28c6d6ada 100644 --- a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java @@ -19,6 +19,8 @@ package art.arcane.iris.probe; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.PackValidationResult; +import art.arcane.iris.core.pack.PackValidator; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineAssignedComponent; @@ -122,7 +124,7 @@ public final class GenerationProbe { } public static void main(String[] args) throws Exception { - IrisPlatforms.bind(new StubPlatform()); + StubPlatform.bindGenerationStateHandlers(); StubPlatform.verbose(true); StubPlatform.errorSink(REPORTED::add); IrisServices.register(PreservationRegistry.class, new InertPreservation()); @@ -132,6 +134,8 @@ public final class GenerationProbe { File packSource = new File(args[0]); int radius = args.length > 1 ? Integer.parseInt(args[1]) : 2; + int centerChunkX = args.length > 2 ? Integer.parseInt(args[2]) : 0; + int centerChunkZ = args.length > 3 ? Integer.parseInt(args[3]) : 0; if (!packSource.isDirectory()) { System.out.println("[genprobe] pack folder not found: " + packSource.getAbsolutePath()); System.exit(1); @@ -141,8 +145,25 @@ public final class GenerationProbe { File pack = clonePack(packSource, workRoot); System.out.println("[genprobe] pack: " + packSource.getAbsolutePath()); System.out.println("[genprobe] work copy: " + pack.getAbsolutePath()); + System.out.println("[genprobe] center chunk: " + centerChunkX + "," + centerChunkZ); System.out.println("[genprobe] radius: " + radius + " (" + ((2 * radius + 1) * (2 * radius + 1)) + " chunks)"); + PackValidationResult validation = PackValidator.validate(pack); + for (String warning : validation.getWarnings()) { + System.out.println("[genprobe] pack warning: " + warning); + } + if (!validation.isLoadable()) { + for (String error : validation.getBlockingErrors()) { + System.out.println("[genprobe] pack error: " + error); + } + System.out.println("[genprobe] FAIL: pack validation blocked generation"); + System.exit(1); + return; + } + System.out.println("[genprobe] pack validation: PASS"); + + IrisPlatforms.bind(new StubPlatform()); + Engine engine; try { IrisData data = IrisData.get(pack); @@ -184,8 +205,8 @@ public final class GenerationProbe { int ok = 0; int failed = 0; - for (int cz = -radius; cz <= radius; cz++) { - for (int cx = -radius; cx <= radius; cx++) { + for (int cz = centerChunkZ - radius; cz <= centerChunkZ + radius; cz++) { + for (int cx = centerChunkX - radius; cx <= centerChunkX + radius; cx++) { String at = cx + "," + cz; drainReported(); List failures = new ArrayList<>(); diff --git a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java index eec097b0b..1ca9b4523 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java @@ -18,6 +18,9 @@ package art.arcane.iris.probe; +import art.arcane.iris.engine.object.BlockDataMergeSupport; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.engine.object.TileData; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.LogLevel; import art.arcane.iris.spi.PlatformBiome; @@ -30,9 +33,12 @@ import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.iris.spi.PlatformWorld; +import art.arcane.iris.util.common.math.IrisBlockVector; import java.io.File; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; @@ -49,6 +55,25 @@ public final class StubPlatform implements IrisPlatform { ERROR_SINK = sink; } + public static void bindGenerationStateHandlers() { + IrisObjectRotation.bindPlatformRotator(StubPlatform::rotateState); + BlockDataMergeSupport.bindPlatformMerger(StubPlatform::mergeStates); + TileData.bindPlatformReader(StubTileData::read); + TileData.bindPlatformFactory(StubTileData::fromProperties); + } + + static PlatformBlockState rotateForTest(IrisObjectRotation rotation, PlatformBlockState state) { + return rotateState(rotation, state, 0, 0, 0); + } + + static PlatformBlockState mergeForTest(PlatformBlockState base, PlatformBlockState update) { + return mergeStates(base, update); + } + + static PlatformBlockState blockStateForTest(String key) { + return StubBlockState.of(key); + } + private final StubRegistries registries = new StubRegistries(); private final StubScheduler scheduler = new StubScheduler(); private final StubStructureHooks structureHooks = new StubStructureHooks(); @@ -193,7 +218,9 @@ public final class StubPlatform implements IrisPlatform { @Override public PlatformBlockState withProperty(String name, String value) { - return this; + ParsedState parsed = ParsedState.parse(key); + parsed.properties().put(normalizeProperty(name), normalizeProperty(value)); + return of(parsed.serialize()); } @Override @@ -202,6 +229,184 @@ public final class StubPlatform implements IrisPlatform { } } + private static PlatformBlockState rotateState(IrisObjectRotation rotation, PlatformBlockState state, + int spinX, int spinY, int spinZ) { + if (state == null || rotation == null || !rotation.canRotate()) { + return state; + } + ParsedState parsed = ParsedState.parse(state.key()); + Map properties = parsed.properties(); + if (properties.containsKey("facing")) { + properties.put("facing", rotateFace(rotation, properties.get("facing"), spinX, spinY, spinZ)); + } else if (properties.containsKey("rotation")) { + properties.put("rotation", rotateSegment(rotation, properties.get("rotation"), spinX, spinY, spinZ)); + } else if (properties.containsKey("axis")) { + properties.put("axis", rotateAxis(rotation, properties.get("axis"), spinX, spinY, spinZ)); + } else { + rotateFaceProperties(rotation, properties, spinX, spinY, spinZ); + } + return StubBlockState.of(parsed.serialize()); + } + + private static PlatformBlockState mergeStates(PlatformBlockState base, PlatformBlockState update) { + if (base == null) { + return update; + } + if (update == null) { + return base; + } + ParsedState parsedBase = ParsedState.parse(base.key()); + ParsedState parsedUpdate = ParsedState.parse(update.key()); + if (!parsedBase.blockKey().equals(parsedUpdate.blockKey())) { + return update; + } + parsedBase.properties().putAll(parsedUpdate.properties()); + return StubBlockState.of(parsedBase.serialize()); + } + + private static String rotateFace(IrisObjectRotation rotation, String face, + int spinX, int spinY, int spinZ) { + IrisBlockVector vector = faceVector(face); + if (vector == null) { + return face; + } + return faceName(rotation.rotate(vector, spinX, spinY, spinZ)); + } + + private static String rotateAxis(IrisObjectRotation rotation, String axis, + int spinX, int spinY, int spinZ) { + IrisBlockVector vector = switch (axis) { + case "x" -> new IrisBlockVector(1, 0, 0); + case "y" -> new IrisBlockVector(0, 1, 0); + case "z" -> new IrisBlockVector(0, 0, 1); + default -> null; + }; + if (vector == null) { + return axis; + } + IrisBlockVector rotated = rotation.rotate(vector, spinX, spinY, spinZ); + double x = Math.abs(rotated.getX()); + double y = Math.abs(rotated.getY()); + double z = Math.abs(rotated.getZ()); + if (x >= y && x >= z) { + return "x"; + } + return y >= z ? "y" : "z"; + } + + private static String rotateSegment(IrisObjectRotation rotation, String value, + int spinX, int spinY, int spinZ) { + int segment; + try { + segment = Math.floorMod(Integer.parseInt(value), 16); + } catch (NumberFormatException e) { + return value; + } + double angle = segment * Math.PI * 2D / 16D; + IrisBlockVector vector = new IrisBlockVector(-Math.sin(angle), 0D, Math.cos(angle)); + IrisBlockVector rotated = rotation.rotate(vector, spinX, spinY, spinZ); + if (Math.abs(rotated.getY()) > Math.max(Math.abs(rotated.getX()), Math.abs(rotated.getZ()))) { + return value; + } + double rotatedAngle = Math.atan2(-rotated.getX(), rotated.getZ()); + int rotatedSegment = (int) Math.round(rotatedAngle * 16D / (Math.PI * 2D)); + return Integer.toString(Math.floorMod(rotatedSegment, 16)); + } + + private static void rotateFaceProperties(IrisObjectRotation rotation, Map properties, + int spinX, int spinY, int spinZ) { + List faces = List.of("north", "east", "south", "west", "up", "down"); + int present = 0; + for (String face : faces) { + if (properties.containsKey(face)) { + present++; + } + } + if (present < 2) { + return; + } + Map rotated = new LinkedHashMap<>(); + for (String face : faces) { + String value = properties.get(face); + if (value != null) { + rotated.put(rotateFace(rotation, face, spinX, spinY, spinZ), value); + } + } + for (String face : faces) { + if (properties.containsKey(face)) { + String defaultValue = "true".equals(properties.get(face)) || "false".equals(properties.get(face)) + ? "false" : "none"; + properties.put(face, rotated.getOrDefault(face, defaultValue)); + } + } + } + + private static IrisBlockVector faceVector(String face) { + return switch (face) { + case "north" -> new IrisBlockVector(0, 0, -1); + case "east" -> new IrisBlockVector(1, 0, 0); + case "south" -> new IrisBlockVector(0, 0, 1); + case "west" -> new IrisBlockVector(-1, 0, 0); + case "up" -> new IrisBlockVector(0, 1, 0); + case "down" -> new IrisBlockVector(0, -1, 0); + default -> null; + }; + } + + private static String faceName(IrisBlockVector vector) { + double x = Math.abs(vector.getX()); + double y = Math.abs(vector.getY()); + double z = Math.abs(vector.getZ()); + if (x >= y && x >= z) { + return vector.getX() >= 0D ? "east" : "west"; + } + if (y >= z) { + return vector.getY() >= 0D ? "up" : "down"; + } + return vector.getZ() >= 0D ? "south" : "north"; + } + + private static String normalizeProperty(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + private record ParsedState(String blockKey, LinkedHashMap properties) { + private static ParsedState parse(String key) { + String normalized = key == null ? "minecraft:air" : key.trim().toLowerCase(Locale.ROOT); + int open = normalized.indexOf('['); + if (open < 0 || !normalized.endsWith("]")) { + return new ParsedState(normalized, new LinkedHashMap<>()); + } + LinkedHashMap properties = new LinkedHashMap<>(); + String body = normalized.substring(open + 1, normalized.length() - 1); + if (!body.isBlank()) { + for (String property : body.split(",")) { + int separator = property.indexOf('='); + if (separator > 0 && separator < property.length() - 1) { + properties.put(property.substring(0, separator), property.substring(separator + 1)); + } + } + } + return new ParsedState(normalized.substring(0, open), properties); + } + + private String serialize() { + if (properties.isEmpty()) { + return blockKey; + } + StringBuilder serialized = new StringBuilder(blockKey).append('['); + boolean first = true; + for (Map.Entry entry : properties.entrySet()) { + if (!first) { + serialized.append(','); + } + serialized.append(entry.getKey()).append('=').append(entry.getValue()); + first = false; + } + return serialized.append(']').toString(); + } + } + private static final class StubBiome implements PlatformBiome { private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); private final String key; diff --git a/probe/src/main/java/art/arcane/iris/probe/StubTileData.java b/probe/src/main/java/art/arcane/iris/probe/StubTileData.java new file mode 100644 index 000000000..6364703d4 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/StubTileData.java @@ -0,0 +1,260 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KMap; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.Strictness; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class StubTileData extends TileData { + private static final Gson GSON = new GsonBuilder() + .disableHtmlEscaping() + .setStrictness(Strictness.LENIENT) + .create(); + + private final String blockKey; + private final KMap tileProperties; + private final byte[] binary; + + private StubTileData(String blockKey, KMap tileProperties, byte[] binary) { + super(); + this.blockKey = normalizeBlockKey(blockKey); + this.tileProperties = tileProperties.copy(); + this.binary = Arrays.copyOf(binary, binary.length); + } + + public static StubTileData read(DataInputStream input) throws IOException { + if (!input.markSupported()) { + throw new IOException("Probe tile-data input does not support mark/reset"); + } + input.mark(Integer.MAX_VALUE); + try { + return readModern(input); + } catch (IOException | RuntimeException error) { + input.reset(); + return readLegacy(input); + } finally { + input.mark(0); + } + } + + static StubTileData fromProperties(PlatformBlockState state, KMap properties) { + if (state == null) { + throw new IllegalArgumentException("Probe tile data requires a block state"); + } + String blockKey = normalizeBlockKey(state.placementBaseState().key()); + KMap copied = properties == null ? new KMap<>() : properties.copy(); + return new StubTileData(blockKey, copied, encodeModern(blockKey, copied)); + } + + private static StubTileData readModern(DataInputStream input) throws IOException { + String blockKey = input.readUTF(); + if (!isResourceKey(blockKey)) { + throw new IOException("Probe tile data has an invalid block key: " + blockKey); + } + KMap properties = properties(input.readUTF()); + if (properties == null) { + throw new IOException("Probe tile data has invalid properties for " + blockKey); + } + return new StubTileData(blockKey, properties, encodeModern(blockKey, properties)); + } + + private static StubTileData readLegacy(DataInputStream input) throws IOException { + int type = input.readUnsignedShort(); + return switch (type) { + case 0 -> readLegacySign(input, type); + case 1 -> readLegacySpawner(input, type); + case 2 -> readLegacyBanner(input, type); + case 3 -> readLegacyLootable(input, type); + default -> throw new IOException("Unknown probe tile-data type: " + type); + }; + } + + private static StubTileData readLegacySign(DataInputStream input, int type) throws IOException { + List lines = List.of( + input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF()); + int color = input.readUnsignedByte(); + KMap properties = new KMap<>(); + properties.put("lines", lines); + properties.put("color", color); + byte[] binary = encode(output -> { + output.writeShort(type); + for (String line : lines) { + output.writeUTF(line); + } + output.writeByte(color); + }); + return new StubTileData("minecraft:oak_sign", properties, binary); + } + + private static StubTileData readLegacySpawner(DataInputStream input, int type) throws IOException { + input.mark(Integer.MAX_VALUE); + String entityKey = null; + try { + String candidate = input.readUTF(); + if (isResourceKey(candidate)) { + entityKey = candidate; + } else { + input.reset(); + } + } catch (IOException | RuntimeException error) { + input.reset(); + } + int legacyOrdinal = -1; + if (entityKey == null) { + legacyOrdinal = input.readShort(); + } + KMap properties = new KMap<>(); + properties.put("entity", entityKey == null ? legacyOrdinal : entityKey); + String resolvedEntityKey = entityKey; + int resolvedLegacyOrdinal = legacyOrdinal; + byte[] binary = encode(output -> { + output.writeShort(type); + if (resolvedEntityKey == null) { + output.writeShort(resolvedLegacyOrdinal); + } else { + output.writeUTF(resolvedEntityKey); + } + }); + return new StubTileData("minecraft:spawner", properties, binary); + } + + private static StubTileData readLegacyBanner(DataInputStream input, int type) throws IOException { + int baseColor = input.readUnsignedByte(); + int patternCount = input.readUnsignedByte(); + input.mark(Integer.MAX_VALUE); + List patterns = new ArrayList<>(patternCount); + boolean keyed = true; + try { + for (int i = 0; i < patternCount; i++) { + int color = input.readUnsignedByte(); + String pattern = input.readUTF(); + if (!isResourceKey(pattern)) { + throw new IOException("Invalid probe banner pattern: " + pattern); + } + patterns.add(new BannerPattern(color, pattern, -1)); + } + } catch (IOException | RuntimeException error) { + keyed = false; + patterns.clear(); + input.reset(); + for (int i = 0; i < patternCount; i++) { + patterns.add(new BannerPattern(input.readUnsignedByte(), null, input.readUnsignedByte())); + } + } + KMap properties = new KMap<>(); + properties.put("baseColor", baseColor); + properties.put("patterns", patterns); + boolean resolvedKeyed = keyed; + byte[] binary = encode(output -> { + output.writeShort(type); + output.writeByte(baseColor); + output.writeByte(patternCount); + for (BannerPattern pattern : patterns) { + output.writeByte(pattern.color()); + if (resolvedKeyed) { + output.writeUTF(pattern.key()); + } else { + output.writeByte(pattern.legacyOrdinal()); + } + } + }); + return new StubTileData("minecraft:white_banner", properties, binary); + } + + private static StubTileData readLegacyLootable(DataInputStream input, int type) throws IOException { + String blockKey = input.readUTF(); + String lootTable = input.readUTF(); + long seed = input.readLong(); + if (!isResourceKey(blockKey)) { + throw new IOException("Probe lootable tile has an invalid block key: " + blockKey); + } + KMap properties = new KMap<>(); + properties.put("LootTable", lootTable); + properties.put("LootTableSeed", seed); + byte[] binary = encode(output -> { + output.writeShort(type); + output.writeUTF(blockKey); + output.writeUTF(lootTable); + output.writeLong(seed); + }); + return new StubTileData(blockKey, properties, binary); + } + + private static byte[] encodeModern(String blockKey, KMap properties) { + try { + return encode(output -> { + output.writeUTF(blockKey); + output.writeUTF(GSON.toJson(properties)); + }); + } catch (IOException error) { + throw new IllegalStateException("Failed to encode probe tile data for " + blockKey, error); + } + } + + private static byte[] encode(BinaryWriter writer) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + writer.write(output); + } + return bytes.toByteArray(); + } + + @SuppressWarnings("unchecked") + private static KMap properties(String json) { + return GSON.fromJson(json, KMap.class); + } + + private static boolean isResourceKey(String key) { + if (key == null || key.isBlank()) { + return false; + } + return key.matches("[a-z0-9_.-]+:[a-z0-9/._-]+"); + } + + private static String normalizeBlockKey(String blockKey) { + int properties = blockKey.indexOf('['); + return properties < 0 ? blockKey : blockKey.substring(0, properties); + } + + String blockKey() { + return blockKey; + } + + @Override + public KMap getProperties() { + return tileProperties; + } + + @Override + public void toBinary(DataOutputStream output) throws IOException { + output.write(binary); + } + + @Override + public TileData clone() { + return new StubTileData(blockKey, tileProperties, binary); + } + + @Override + public String toString() { + return blockKey + GSON.toJson(tileProperties); + } + + @FunctionalInterface + private interface BinaryWriter { + void write(DataOutputStream output) throws IOException; + } + + private record BannerPattern(int color, String key, int legacyOrdinal) { + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/StubPlatformStateTest.java b/probe/src/test/java/art/arcane/iris/probe/StubPlatformStateTest.java new file mode 100644 index 000000000..1f68ea5e3 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/StubPlatformStateTest.java @@ -0,0 +1,48 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public final class StubPlatformStateTest { + @BeforeClass + public static void bindPlatform() { + IrisPlatforms.unbind(); + IrisPlatforms.bind(new StubPlatform()); + StubPlatform.bindGenerationStateHandlers(); + } + + @Test + public void rotatesFacingAxisAndConnectedFaces() { + IrisObjectRotation rotation = IrisObjectRotation.of(0, 90, 0); + + assertEquals("minecraft:oak_stairs[facing=west,half=bottom]", + StubPlatform.rotateForTest(rotation, state("minecraft:oak_stairs[facing=north,half=bottom]")).key()); + assertEquals("minecraft:oak_log[axis=x]", + StubPlatform.rotateForTest(rotation, state("minecraft:oak_log[axis=z]")).key()); + assertEquals("minecraft:oak_fence[north=false,east=false,south=false,west=true]", + StubPlatform.rotateForTest(rotation, + state("minecraft:oak_fence[north=true,east=false,south=false,west=false]")).key()); + } + + @Test + public void propertyUpdatesAndMergesPreserveCanonicalState() { + PlatformBlockState base = state("minecraft:oak_leaves[distance=7,persistent=false]"); + PlatformBlockState updated = base.withProperty("distance", "2").withProperty("persistent", "true"); + + assertEquals("minecraft:oak_leaves[distance=2,persistent=true]", updated.key()); + + PlatformBlockState merged = StubPlatform.mergeForTest( + base, state("minecraft:oak_leaves[persistent=true]")); + assertEquals("minecraft:oak_leaves[distance=7,persistent=true]", merged.key()); + } + + private PlatformBlockState state(String key) { + return IrisPlatforms.get().registries().block(key); + } + +} diff --git a/probe/src/test/java/art/arcane/iris/probe/StubTileDataTest.java b/probe/src/test/java/art/arcane/iris/probe/StubTileDataTest.java new file mode 100644 index 000000000..4bc3c1687 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/StubTileDataTest.java @@ -0,0 +1,87 @@ +package art.arcane.iris.probe; + +import art.arcane.volmlib.util.collection.KMap; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +public class StubTileDataTest { + @Test + public void modernTilePayloadRoundTripsWithoutBukkit() throws Exception { + KMap properties = new KMap<>(); + properties.put("LootTable", "minecraft:chests/ancient_city"); + StubTileData original = StubTileData.fromProperties( + StubPlatform.blockStateForTest("minecraft:chest[facing=north]"), properties); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + original.toBinary(output); + } + + StubTileData decoded; + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + decoded = StubTileData.read(input); + } + + assertEquals("minecraft:chest", decoded.blockKey()); + assertEquals("minecraft:chests/ancient_city", decoded.getProperties().get("LootTable")); + } + + @Test + public void legacyLootablePayloadPreservesItsCompleteFrame() throws Exception { + byte[] payload = encode(output -> { + output.writeShort(3); + output.writeUTF("minecraft:chest"); + output.writeUTF("minecraft:chests/ancient_city"); + output.writeLong(998877L); + }); + + StubTileData decoded = decode(payload); + + assertEquals("minecraft:chest", decoded.blockKey()); + assertEquals("minecraft:chests/ancient_city", decoded.getProperties().get("LootTable")); + assertArrayEquals(payload, encode(decoded::toBinary)); + } + + @Test + public void legacySpawnerAndBannerFramesRemainUnambiguous() throws Exception { + byte[] ordinalSpawner = encode(output -> { + output.writeShort(1); + output.writeShort(42); + }); + byte[] keyedBanner = encode(output -> { + output.writeShort(2); + output.writeByte(11); + output.writeByte(1); + output.writeByte(3); + output.writeUTF("minecraft:base"); + }); + + assertArrayEquals(ordinalSpawner, encode(decode(ordinalSpawner)::toBinary)); + assertArrayEquals(keyedBanner, encode(decode(keyedBanner)::toBinary)); + } + + private static StubTileData decode(byte[] payload) throws Exception { + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload))) { + return StubTileData.read(input); + } + } + + private static byte[] encode(BinaryWriter writer) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + writer.write(output); + } + return bytes.toByteArray(); + } + + @FunctionalInterface + private interface BinaryWriter { + void write(DataOutputStream output) throws Exception; + } +}